mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Merge current master after workspace prerequisite landed
Co-Authored-By: Paperclip <noreply@paperclip.ing> * origin/master: fix(ui): hide Google connectors only on the Connections page (#14774) feat(ui): streamline account menu profile access and add Invite shortcut (#14480) fix: isolate repository-free low-trust tasks in private directories (#14766) ci: pin a checkout-independent Rust cache path so PR lanes hit master's cache (#14394) Add bounded AgentMail failure diagnostics (#14768) fix: enforce terminal outcomes during recovered sandbox cleanup (#14767)
This commit is contained in:
commit
70dd278f56
26 files changed
+1546
-150
No files matched your search
@@ -5,8 +5,41 @@ import { readFileSync } from "node:fs";
|
||||
const read = (name) => readFileSync(new URL(`../../workflows/${name}`, import.meta.url), "utf8");
|
||||
const prWorkflow = read("pr-trusted.yml");
|
||||
const releaseWorkflow = read("release-verify.yml");
|
||||
const pr = prWorkflow.split(" verify_paperclip_runner:")[1].split(" build:")[0];
|
||||
const release = releaseWorkflow.split(" verify_paperclip_runner:")[1].split(" build:")[0];
|
||||
|
||||
// Slice one job out of a workflow: from its two-space-indented key to the
|
||||
// next one. Steps sit at six spaces and `with:` at eight, so only job keys
|
||||
// match the boundary pattern.
|
||||
const job = (workflow, name) => {
|
||||
const start = workflow.indexOf(`\n ${name}:\n`);
|
||||
assert.ok(start >= 0, `workflow is missing the ${name} job`);
|
||||
const body = workflow.slice(start + 1);
|
||||
const header = ` ${name}:\n`.length;
|
||||
const next = body.slice(header).search(/\n [a-z0-9_-]+:\n/);
|
||||
return next === -1 ? body : body.slice(0, header + next + 1);
|
||||
};
|
||||
|
||||
const READ_STEP = " - name: Restore Runner Rust dependencies (read only)";
|
||||
const WRITE_STEP = " - name: Cache Runner Rust dependencies";
|
||||
const SELECT_STEP = " - name: Select the pinned Runner Rust toolchain";
|
||||
|
||||
// Every PR job that builds the Runner restores master's `release-runner-v2`
|
||||
// entry. Each one has to agree with the writer on every key input, or that
|
||||
// one lane misses and silently recompiles every third-party crate while the
|
||||
// others hit.
|
||||
const READER_JOBS = ["typecheck_release_registry", "verify_paperclip_runner", "build", "canary_dry_run"];
|
||||
const readers = READER_JOBS.map((name) => [name, job(prWorkflow, name)]);
|
||||
const release = job(releaseWorkflow, "verify_paperclip_runner");
|
||||
|
||||
test("every rust-cache restore in the PR workflow belongs to a guarded reader job", () => {
|
||||
const total = prWorkflow.match(/uses: Swatinem\/rust-cache@/g)?.length ?? 0;
|
||||
const guarded = readers.filter(([, body]) => /uses: Swatinem\/rust-cache@/.test(body)).length;
|
||||
assert.equal(guarded, READER_JOBS.length, "each listed reader job must restore the Rust cache");
|
||||
assert.equal(total, guarded, "a job restores the Rust cache but is not in READER_JOBS; add it so it is guarded");
|
||||
for (const [name, body] of readers) {
|
||||
assert.equal(body.match(/uses: Swatinem\/rust-cache@/g).length, 1, `${name}: exactly one rust-cache step`);
|
||||
assert.ok(body.includes(READ_STEP), `${name}: the rust-cache step must be the read-only restore`);
|
||||
}
|
||||
});
|
||||
|
||||
// The key is computed from these inputs. A pull request that disagrees with
|
||||
// the master writer on any of them misses every time and silently recompiles
|
||||
@@ -14,43 +47,51 @@ const release = releaseWorkflow.split(" verify_paperclip_runner:")[1].split("
|
||||
// restore exists to remove.
|
||||
const keyInputs = [
|
||||
/uses: Swatinem\/rust-cache@([0-9a-f]{40}) # v[0-9.]+/,
|
||||
/workspaces: (packages\/paperclip-runner\/runner -> target)/,
|
||||
/shared-key: (release-runner-v1)/,
|
||||
/workspaces: (\$\{\{ steps\.runner_rust_workspace\.outputs\.path \}\} -> target)/,
|
||||
/shared-key: (release-runner-v2)/,
|
||||
/cache-workspace-crates: (false)/,
|
||||
/cache-bin: (false)/,
|
||||
];
|
||||
|
||||
test("the PR lane restores the Rust cache under the same key the master push writes", () => {
|
||||
for (const pattern of keyInputs) {
|
||||
const mine = pr.match(pattern);
|
||||
const theirs = release.match(pattern);
|
||||
assert.ok(mine, `PR lane is missing ${pattern}`);
|
||||
assert.ok(theirs, `master writer is missing ${pattern}`);
|
||||
assert.equal(mine[1], theirs[1], `key input drifted from the master writer: ${pattern}`);
|
||||
test("every PR reader restores the Rust cache under the same key the master push writes", () => {
|
||||
for (const [name, pr] of readers) {
|
||||
for (const pattern of keyInputs) {
|
||||
const mine = pr.match(pattern);
|
||||
const theirs = release.match(pattern);
|
||||
assert.ok(mine, `${name}: PR lane is missing ${pattern}`);
|
||||
assert.ok(theirs, `master writer is missing ${pattern}`);
|
||||
assert.equal(mine[1], theirs[1], `${name}: key input drifted from the master writer: ${pattern}`);
|
||||
}
|
||||
assert.doesNotMatch(pr, /prefix-key:|cache-on-failure: true|cache-all-crates: true/, name);
|
||||
}
|
||||
assert.doesNotMatch(pr, /prefix-key:|cache-on-failure: true|cache-all-crates: true/);
|
||||
});
|
||||
|
||||
test("the PR lane pins the compiler before the key is computed", () => {
|
||||
const select = pr.indexOf(" - name: Select the pinned Runner Rust toolchain");
|
||||
const cache = pr.indexOf(" - name: Restore Runner Rust dependencies (read only)");
|
||||
const verify = pr.indexOf(" - name: Verify Paperclip Runner\n");
|
||||
assert.ok(select >= 0 && cache > select && verify > cache);
|
||||
const setup = pr.slice(select, cache);
|
||||
assert.match(setup, /working-directory: packages\/paperclip-runner/);
|
||||
assert.match(setup, /rustup show active-toolchain/);
|
||||
assert.match(setup, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/);
|
||||
// The gate routes to either ubuntu-latest or the public PR fleet, so a
|
||||
// missing rustup must cost the cache, never the pull request.
|
||||
assert.match(setup, /command -v rustup/);
|
||||
assert.doesNotMatch(setup, /set -euo pipefail/);
|
||||
test("every PR reader pins the compiler before the key is computed", () => {
|
||||
for (const [name, pr] of readers) {
|
||||
const select = pr.indexOf(SELECT_STEP);
|
||||
const cache = pr.indexOf(READ_STEP);
|
||||
assert.ok(select >= 0 && cache > select, `${name}: the toolchain must be selected before the cache step`);
|
||||
const setup = pr.slice(select, cache);
|
||||
// Nothing may sit between the pin and the restore that could change the key.
|
||||
assert.equal(setup.match(/^ - name: /gm).length, 1, `${name}: no step between the toolchain pin and the restore`);
|
||||
assert.match(setup, /working-directory: packages\/paperclip-runner/, name);
|
||||
assert.match(setup, /rustup show active-toolchain/, name);
|
||||
assert.match(setup, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/, name);
|
||||
// The gate routes to either ubuntu-latest or the public PR fleet, so a
|
||||
// missing rustup must cost the cache, never the pull request.
|
||||
assert.match(setup, /command -v rustup/, name);
|
||||
assert.doesNotMatch(setup, /set -euo pipefail/, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("a pull request never writes to or evicts the master cache entry", () => {
|
||||
const step = pr.split(" - name: Restore Runner Rust dependencies (read only)")[1]
|
||||
.split(" - name: Verify Paperclip Runner\n")[0];
|
||||
assert.equal(step.match(/^\s*save-if: (.+)$/m)?.[1], "false");
|
||||
assert.doesNotMatch(step, /^\s*if:/m, "the restore must not be conditional; a miss is already free");
|
||||
for (const [name, pr] of readers) {
|
||||
const after = pr.split(READ_STEP)[1];
|
||||
const nextStep = after.search(/\n - name: /);
|
||||
const step = nextStep === -1 ? after : after.slice(0, nextStep);
|
||||
assert.equal(step.match(/^\s*save-if: (.+)$/m)?.[1], "false", `${name}: the restore must set save-if: false`);
|
||||
assert.doesNotMatch(step, /^\s*if:/m, `${name}: the restore must not be conditional; a miss is already free`);
|
||||
}
|
||||
assert.doesNotMatch(prWorkflow, /uses: Swatinem\/rust-cache@[0-9a-f]{40}[\s\S]*?save-if: (?!false)/);
|
||||
});
|
||||
|
||||
@@ -62,28 +103,75 @@ test("a pull request never writes to or evicts the master cache entry", () => {
|
||||
// the key matches nothing and every run recompiles.
|
||||
const NORMALIZE = /# rust-cache hashes every installed toolchain[\s\S]*?rustup toolchain list\n/;
|
||||
|
||||
test("reader and writer strip extra toolchains identically before the key is computed", () => {
|
||||
const mine = pr.match(NORMALIZE);
|
||||
test("every reader and the writer strip extra toolchains identically before the key is computed", () => {
|
||||
const theirs = release.match(NORMALIZE);
|
||||
assert.ok(mine, "pr-trusted.yml must normalize the installed toolchains");
|
||||
assert.ok(theirs, "release-verify.yml must normalize the installed toolchains");
|
||||
assert.equal(mine[0], theirs[0], "the normalization must be identical in both workflows");
|
||||
|
||||
for (const [name, body] of [["reader", mine[0]], ["writer", theirs[0]]]) {
|
||||
for (const [name, body] of [["writer", theirs[0]]]) {
|
||||
// Keep the pin, drop the rest, and never fail the job over it.
|
||||
assert.match(body, /grep -vx "\$toolchain"/, name);
|
||||
assert.match(body, /xargs -n1 rustup toolchain uninstall/, name);
|
||||
assert.match(body, /\|\| true/, name);
|
||||
}
|
||||
for (const [name, pr] of readers) {
|
||||
const mine = pr.match(NORMALIZE);
|
||||
assert.ok(mine, `${name}: pr-trusted.yml must normalize the installed toolchains`);
|
||||
assert.equal(mine[0], theirs[0], `${name}: the normalization must be identical to the writer's`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the toolchain is stripped before the cache step, not after", () => {
|
||||
for (const [name, body, cacheStep] of [
|
||||
["reader", pr, " - name: Restore Runner Rust dependencies (read only)"],
|
||||
["writer", release, " - name: Cache Runner Rust dependencies"],
|
||||
...readers.map(([name, body]) => [name, body, READ_STEP]),
|
||||
["writer", release, WRITE_STEP],
|
||||
]) {
|
||||
const normalize = body.search(NORMALIZE);
|
||||
const cache = body.indexOf(cacheStep);
|
||||
assert.ok(normalize >= 0 && cache > normalize, `${name}: normalization must precede the cache step`);
|
||||
}
|
||||
});
|
||||
|
||||
// GitHub matches a cache entry on its key and on a version hash of the
|
||||
// absolute paths being cached. rust-cache resolves the target directory under
|
||||
// the checkout, and the checkout root differs by runner (/home/runner/_work on
|
||||
// the RunsOn fleets that write the cache, /home/runner/work on GitHub-hosted
|
||||
// runners). With every key input aligned, every GitHub-hosted pull request
|
||||
// still logged "No cache found" (run 36424309181, 2026-09-28). Both workflows
|
||||
// therefore hand rust-cache the same checkout-independent path, and they have
|
||||
// to build it the same way or the version matches nothing.
|
||||
const PIN = /# rust-cache hashes its absolute cache paths[\s\S]*?echo "path=\$pinned" >> "\$GITHUB_OUTPUT"\n/;
|
||||
|
||||
test("every reader and the writer pin an identical checkout-independent Rust workspace path", () => {
|
||||
const theirs = release.match(PIN);
|
||||
assert.ok(theirs, "release-verify.yml must pin the Runner Rust workspace path");
|
||||
const pins = [["writer", theirs[0]]];
|
||||
for (const [name, pr] of readers) {
|
||||
const mine = pr.match(PIN);
|
||||
assert.ok(mine, `${name}: pr-trusted.yml must pin the Runner Rust workspace path`);
|
||||
assert.equal(mine[0], theirs[0], `${name}: the pinned path must be built identically to the writer's`);
|
||||
pins.push([name, mine[0]]);
|
||||
}
|
||||
|
||||
for (const [name, body] of pins) {
|
||||
assert.match(body, /- name: Pin the Runner Rust workspace path\n\s+id: runner_rust_workspace\n/, name);
|
||||
// Anchor under $HOME, which both runner images share, never under the checkout.
|
||||
assert.match(body, /pinned="\$HOME\/[A-Za-z0-9._-]+"/, name);
|
||||
assert.match(body, /rm -rf "\$pinned"\n\s+ln -s "\$GITHUB_WORKSPACE\/packages\/paperclip-runner\/runner" "\$pinned"/, name);
|
||||
assert.match(body, /echo "path=\$pinned" >> "\$GITHUB_OUTPUT"/, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("the workspace path is pinned before the cache step and never names the checkout", () => {
|
||||
for (const [name, body, cacheStep] of [
|
||||
...readers.map(([name, body]) => [name, body, READ_STEP]),
|
||||
["writer", release, WRITE_STEP],
|
||||
]) {
|
||||
const pin = body.search(PIN);
|
||||
const cache = body.indexOf(cacheStep);
|
||||
assert.ok(pin >= 0 && cache > pin, `${name}: the pin must precede the cache step`);
|
||||
assert.doesNotMatch(body, /workspaces: (\.|packages\/|\$\{\{ github\.workspace)/, `${name}: workspaces must not resolve under the checkout`);
|
||||
}
|
||||
// No rust-cache step anywhere in either workflow may point back into the checkout.
|
||||
for (const [name, workflow] of [["pr-trusted.yml", prWorkflow], ["release-verify.yml", releaseWorkflow]]) {
|
||||
assert.doesNotMatch(workflow, /workspaces: (\.|packages\/|\$\{\{ github\.workspace)/, `${name}: workspaces must not resolve under the checkout`);
|
||||
}
|
||||
});
|
||||
@@ -15,8 +15,13 @@ test("Runner dependency caching selects the package's pinned compiler before com
|
||||
assert.match(setup, /rustup show active-toolchain/);
|
||||
assert.match(setup, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/);
|
||||
assert.match(runner, /uses: Swatinem\/rust-cache@[0-9a-f]{40} # v[0-9.]+/);
|
||||
assert.match(runner, /workspaces: packages\/paperclip-runner\/runner -> target/);
|
||||
assert.match(runner, /shared-key: release-runner-v1/);
|
||||
// The target path feeds the entry's version hash, so it must not resolve
|
||||
// under the checkout, whose root differs between the fleet and GitHub-hosted
|
||||
// runners. The pin step publishes a $HOME-anchored path to the same directory.
|
||||
const pin = runner.indexOf(" - name: Pin the Runner Rust workspace path");
|
||||
assert.ok(pin >= 0 && cache > pin, "the workspace path must be pinned before the cache step");
|
||||
assert.match(runner, /workspaces: \$\{\{ steps\.runner_rust_workspace\.outputs\.path \}\} -> target/);
|
||||
assert.match(runner, /shared-key: release-runner-v2/);
|
||||
});
|
||||
|
||||
test("the shared cache excludes workspace artifacts and only restores or saves the exact master-push source", () => {
|
||||
|
||||
@@ -28,11 +28,32 @@ for (const [name, overrides, ref, allowed] of [
|
||||
}
|
||||
test("cache excludes workspace code and executable installs, and preserves full checks", () => {
|
||||
assert.match(cache, /uses: Swatinem\/rust-cache@[a-f0-9]{40}/);
|
||||
assert.match(cache, /workspaces: packages\/paperclip-runner\/runner -> target/);
|
||||
assert.match(cache, /shared-key: release-typecheck-v1/);
|
||||
// The target path feeds the entry's version hash; a checkout-relative path
|
||||
// hashes differently on the fleet (/home/runner/_work) and on GitHub-hosted
|
||||
// runners (/home/runner/work), so the pin step publishes a $HOME-anchored one.
|
||||
assert.match(cache, /workspaces: \$\{\{ steps\.runner_rust_workspace\.outputs\.path \}\} -> target/);
|
||||
assert.match(cache, /shared-key: release-typecheck-v2/);
|
||||
assert.match(cache, /cache-workspace-crates: false/);
|
||||
assert.match(cache, /cache-bin: false/);
|
||||
const pin = typecheck.indexOf(" - name: Pin the Runner Rust workspace path");
|
||||
assert.ok(pin >= 0 && pin < typecheck.indexOf("uses: Swatinem/rust-cache"));
|
||||
assert.ok(typecheck.indexOf('echo "RUSTUP_TOOLCHAIN=$toolchain"') < typecheck.indexOf("uses: Swatinem/rust-cache"));
|
||||
assert.doesNotMatch(typecheck, /workspaces: (\.|packages\/|\$\{\{ github\.workspace)/);
|
||||
});
|
||||
// The typecheck key is written and read only by master pushes on the fleet, so
|
||||
// its version would still match if this pin drifted. Hold it to the same text
|
||||
// as the Runner writer anyway: `doc/RELEASE-AUTOMATION-SETUP.md` promises one
|
||||
// identical pin step before every Rust cache step, and a divergent copy here
|
||||
// is the first place a later edit would quietly break that promise.
|
||||
test("the typecheck pin step is identical to the Runner writer's", () => {
|
||||
const PIN = /# rust-cache hashes its absolute cache paths[\s\S]*?echo "path=\$pinned" >> "\$GITHUB_OUTPUT"\n/;
|
||||
const writer = workflow.split("\n verify_paperclip_runner:\n")[1];
|
||||
assert.ok(writer, "release-verify.yml is missing the verify_paperclip_runner job");
|
||||
const theirs = writer.match(PIN);
|
||||
const mine = typecheck.match(PIN);
|
||||
assert.ok(theirs, "the Runner writer must pin the Runner Rust workspace path");
|
||||
assert.ok(mine, "the typecheck job must pin the Runner Rust workspace path");
|
||||
assert.equal(mine[0], theirs[0], "the typecheck pin step drifted from the Runner writer's");
|
||||
assert.match(typecheck, /run: pnpm -r typecheck/);
|
||||
assert.match(workflow, /shared-key: release-runner-v1/);
|
||||
assert.match(workflow, /shared-key: release-runner-v2/);
|
||||
});
|
||||
@@ -407,6 +407,28 @@ jobs:
|
||||
# rebuilds the Runner release binary; without the shared Rust cache that
|
||||
# is a ~3m40s cold compile of all third-party crates (run 35036001734,
|
||||
# 2026-09-15). Same restore-only contract as Verify Paperclip Runner.
|
||||
|
||||
# rust-cache hashes its absolute cache paths into the entry's version,
|
||||
# and GitHub only serves an entry whose key and version both match. The
|
||||
# target directory sits under the checkout, and the checkout root
|
||||
# differs by runner: /home/runner/_work on the RunsOn fleets that write
|
||||
# this cache against /home/runner/work on GitHub-hosted runners. Every
|
||||
# key input agreed, yet all 25 completed pull requests on 2026-09-28
|
||||
# logged "No cache found" (run 36424309181) and cold-compiled every
|
||||
# crate for ~4 minutes in four lanes. Point rust-cache at the same
|
||||
# directory through a checkout-independent path so both layouts hash
|
||||
# the same version. Keep this block identical in both workflows: the
|
||||
# reader and the writer must agree or the version matches nothing.
|
||||
- name: Pin the Runner Rust workspace path
|
||||
id: runner_rust_workspace
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pinned="$HOME/paperclip-runner-rust"
|
||||
# A symlink here is removed as a link, never followed into the checkout.
|
||||
rm -rf "$pinned"
|
||||
ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned"
|
||||
echo "path=$pinned" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select the pinned Runner Rust toolchain
|
||||
working-directory: packages/paperclip-runner
|
||||
run: |
|
||||
@@ -445,8 +467,8 @@ jobs:
|
||||
- name: Restore Runner Rust dependencies (read only)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: packages/paperclip-runner/runner -> target
|
||||
shared-key: release-runner-v1
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-runner-v2
|
||||
# Mirror the master writer: these also feed the cache key.
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
@@ -766,11 +788,33 @@ jobs:
|
||||
|
||||
# Same restore-only contract as the pnpm store above, for the Rust
|
||||
# dependency tree: master's post-merge verification is the sole writer
|
||||
# of release-runner-v1, and PR merge refs must not save branch-scoped
|
||||
# of release-runner-v2, and PR merge refs must not save branch-scoped
|
||||
# copies of a ~680MB target directory. Every key input below has to
|
||||
# match that writer in release-verify.yml exactly or each PR misses and
|
||||
# recompiles all 313 third-party crates in both profiles. A miss is a
|
||||
# slow run, never a wrong one.
|
||||
|
||||
# rust-cache hashes its absolute cache paths into the entry's version,
|
||||
# and GitHub only serves an entry whose key and version both match. The
|
||||
# target directory sits under the checkout, and the checkout root
|
||||
# differs by runner: /home/runner/_work on the RunsOn fleets that write
|
||||
# this cache against /home/runner/work on GitHub-hosted runners. Every
|
||||
# key input agreed, yet all 25 completed pull requests on 2026-09-28
|
||||
# logged "No cache found" (run 36424309181) and cold-compiled every
|
||||
# crate for ~4 minutes in four lanes. Point rust-cache at the same
|
||||
# directory through a checkout-independent path so both layouts hash
|
||||
# the same version. Keep this block identical in both workflows: the
|
||||
# reader and the writer must agree or the version matches nothing.
|
||||
- name: Pin the Runner Rust workspace path
|
||||
id: runner_rust_workspace
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pinned="$HOME/paperclip-runner-rust"
|
||||
# A symlink here is removed as a link, never followed into the checkout.
|
||||
rm -rf "$pinned"
|
||||
ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned"
|
||||
echo "path=$pinned" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select the pinned Runner Rust toolchain
|
||||
working-directory: packages/paperclip-runner
|
||||
run: |
|
||||
@@ -809,8 +853,8 @@ jobs:
|
||||
- name: Restore Runner Rust dependencies (read only)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: packages/paperclip-runner/runner -> target
|
||||
shared-key: release-runner-v1
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-runner-v2
|
||||
# Mirror the master writer: these also feed the cache key.
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
@@ -884,6 +928,28 @@ jobs:
|
||||
# shared Rust cache that is a ~3m40s cold compile of all third-party
|
||||
# crates (run 35036001734, 2026-09-15). Same restore-only contract as
|
||||
# Verify Paperclip Runner.
|
||||
|
||||
# rust-cache hashes its absolute cache paths into the entry's version,
|
||||
# and GitHub only serves an entry whose key and version both match. The
|
||||
# target directory sits under the checkout, and the checkout root
|
||||
# differs by runner: /home/runner/_work on the RunsOn fleets that write
|
||||
# this cache against /home/runner/work on GitHub-hosted runners. Every
|
||||
# key input agreed, yet all 25 completed pull requests on 2026-09-28
|
||||
# logged "No cache found" (run 36424309181) and cold-compiled every
|
||||
# crate for ~4 minutes in four lanes. Point rust-cache at the same
|
||||
# directory through a checkout-independent path so both layouts hash
|
||||
# the same version. Keep this block identical in both workflows: the
|
||||
# reader and the writer must agree or the version matches nothing.
|
||||
- name: Pin the Runner Rust workspace path
|
||||
id: runner_rust_workspace
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pinned="$HOME/paperclip-runner-rust"
|
||||
# A symlink here is removed as a link, never followed into the checkout.
|
||||
rm -rf "$pinned"
|
||||
ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned"
|
||||
echo "path=$pinned" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select the pinned Runner Rust toolchain
|
||||
working-directory: packages/paperclip-runner
|
||||
run: |
|
||||
@@ -922,8 +988,8 @@ jobs:
|
||||
- name: Restore Runner Rust dependencies (read only)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: packages/paperclip-runner/runner -> target
|
||||
shared-key: release-runner-v1
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-runner-v2
|
||||
# Mirror the master writer: these also feed the cache key.
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
@@ -1094,6 +1160,28 @@ jobs:
|
||||
# build:binary step; without the shared Rust cache that is a ~3m40s cold
|
||||
# compile of all third-party crates (run 35036001734, 2026-09-15). Same
|
||||
# restore-only contract as Verify Paperclip Runner.
|
||||
|
||||
# rust-cache hashes its absolute cache paths into the entry's version,
|
||||
# and GitHub only serves an entry whose key and version both match. The
|
||||
# target directory sits under the checkout, and the checkout root
|
||||
# differs by runner: /home/runner/_work on the RunsOn fleets that write
|
||||
# this cache against /home/runner/work on GitHub-hosted runners. Every
|
||||
# key input agreed, yet all 25 completed pull requests on 2026-09-28
|
||||
# logged "No cache found" (run 36424309181) and cold-compiled every
|
||||
# crate for ~4 minutes in four lanes. Point rust-cache at the same
|
||||
# directory through a checkout-independent path so both layouts hash
|
||||
# the same version. Keep this block identical in both workflows: the
|
||||
# reader and the writer must agree or the version matches nothing.
|
||||
- name: Pin the Runner Rust workspace path
|
||||
id: runner_rust_workspace
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pinned="$HOME/paperclip-runner-rust"
|
||||
# A symlink here is removed as a link, never followed into the checkout.
|
||||
rm -rf "$pinned"
|
||||
ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned"
|
||||
echo "path=$pinned" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select the pinned Runner Rust toolchain
|
||||
working-directory: packages/paperclip-runner
|
||||
run: |
|
||||
@@ -1132,8 +1220,8 @@ jobs:
|
||||
- name: Restore Runner Rust dependencies (read only)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: packages/paperclip-runner/runner -> target
|
||||
shared-key: release-runner-v1
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-runner-v2
|
||||
# Mirror the master writer: these also feed the cache key.
|
||||
cache-workspace-crates: false
|
||||
cache-bin: false
|
||||
|
||||
@@ -42,6 +42,27 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
# rust-cache hashes its absolute cache paths into the entry's version,
|
||||
# and GitHub only serves an entry whose key and version both match. The
|
||||
# target directory sits under the checkout, and the checkout root
|
||||
# differs by runner: /home/runner/_work on the RunsOn fleets that write
|
||||
# this cache against /home/runner/work on GitHub-hosted runners. Every
|
||||
# key input agreed, yet all 25 completed pull requests on 2026-09-28
|
||||
# logged "No cache found" (run 36424309181) and cold-compiled every
|
||||
# crate for ~4 minutes in four lanes. Point rust-cache at the same
|
||||
# directory through a checkout-independent path so both layouts hash
|
||||
# the same version. Keep this block identical in both workflows: the
|
||||
# reader and the writer must agree or the version matches nothing.
|
||||
- name: Pin the Runner Rust workspace path
|
||||
id: runner_rust_workspace
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pinned="$HOME/paperclip-runner-rust"
|
||||
# A symlink here is removed as a link, never followed into the checkout.
|
||||
rm -rf "$pinned"
|
||||
ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned"
|
||||
echo "path=$pinned" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select the pinned Runner Rust toolchain
|
||||
working-directory: packages/paperclip-runner
|
||||
run: |
|
||||
@@ -56,8 +77,8 @@ jobs:
|
||||
if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: packages/paperclip-runner/runner -> target
|
||||
shared-key: release-typecheck-v1
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-typecheck-v2
|
||||
# Rebuild workspace code and rerun every check. Cache only compiled
|
||||
# dependencies; never restore installed executables from cargo/bin.
|
||||
cache-workspace-crates: false
|
||||
@@ -294,6 +315,27 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
# rust-cache hashes its absolute cache paths into the entry's version,
|
||||
# and GitHub only serves an entry whose key and version both match. The
|
||||
# target directory sits under the checkout, and the checkout root
|
||||
# differs by runner: /home/runner/_work on the RunsOn fleets that write
|
||||
# this cache against /home/runner/work on GitHub-hosted runners. Every
|
||||
# key input agreed, yet all 25 completed pull requests on 2026-09-28
|
||||
# logged "No cache found" (run 36424309181) and cold-compiled every
|
||||
# crate for ~4 minutes in four lanes. Point rust-cache at the same
|
||||
# directory through a checkout-independent path so both layouts hash
|
||||
# the same version. Keep this block identical in both workflows: the
|
||||
# reader and the writer must agree or the version matches nothing.
|
||||
- name: Pin the Runner Rust workspace path
|
||||
id: runner_rust_workspace
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pinned="$HOME/paperclip-runner-rust"
|
||||
# A symlink here is removed as a link, never followed into the checkout.
|
||||
rm -rf "$pinned"
|
||||
ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned"
|
||||
echo "path=$pinned" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select the pinned Runner Rust toolchain
|
||||
working-directory: packages/paperclip-runner
|
||||
run: |
|
||||
@@ -325,8 +367,8 @@ jobs:
|
||||
if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: packages/paperclip-runner/runner -> target
|
||||
shared-key: release-runner-v1
|
||||
workspaces: ${{ steps.runner_rust_workspace.outputs.path }} -> target
|
||||
shared-key: release-runner-v2
|
||||
# Rebuild workspace code and rerun every check. Cache only compiled
|
||||
# dependencies; never restore installed executables from cargo/bin.
|
||||
cache-workspace-crates: false
|
||||
|
||||
@@ -1246,6 +1246,9 @@ Only successful turns retain a live warm provider session. A structured failed
|
||||
or cancelled result must retire that session and collect its managed files before
|
||||
heartbeat stops the reusable sandbox. A later retry can resume the same sandbox
|
||||
without inheriting the stopped provider transport.
|
||||
The shared lease-release boundary rechecks the durable run status for recovery
|
||||
and ordinary teardown. Successful workspace copy-back alone must not keep a
|
||||
failed turn's sandbox running.
|
||||
|
||||
Run the credential-free real-process restart suite with:
|
||||
|
||||
|
||||
@@ -347,11 +347,22 @@ readiness can succeed. A failed lane does not cancel the other lane.
|
||||
Both lanes restore Cargo dependencies with the pinned Rust Cache action. The
|
||||
compiler comes from the Runner package's `rust-toolchain.toml` before the action
|
||||
computes its key. Compiler and Cargo metadata changes select a new cache. The
|
||||
existing `release-runner-v1` shared key avoids separate copies for these lanes.
|
||||
`release-runner-v2` shared key avoids separate copies for these lanes.
|
||||
Only the Rust lane saves this cache. After verification it also runs `build:rust`
|
||||
to warm the debug dependencies used by the protocol lane; its own tests already
|
||||
warm release dependencies. The cache writer is shorter than the protocol lane.
|
||||
|
||||
GitHub matches a cache entry on its key and on a version hash of the absolute
|
||||
paths in the entry. The master writer runs on the RunsOn fleet, where the
|
||||
checkout is `/home/runner/_work/paperclip/paperclip`. The trusted PR workflow
|
||||
restores the same entry read-only on GitHub-hosted runners, where the checkout
|
||||
is `/home/runner/work/paperclip/paperclip`. A `Pin the Runner Rust workspace
|
||||
path` step in both workflows links `$HOME/paperclip-runner-rust` to the Runner
|
||||
crate and passes that path to the cache action, so both layouts hash the same
|
||||
paths and the PR lanes can restore master's entry. The guard tests under
|
||||
`.github/scripts/tests/` require this step, with identical text, before every
|
||||
Rust cache step in both workflows.
|
||||
|
||||
Workspace crates and installed Cargo binaries are excluded. Every run rebuilds
|
||||
workspace code and runs all assigned checks, including on a cache hit. Only an
|
||||
own-repository master-push run verifying that push's exact SHA can restore the
|
||||
|
||||
@@ -158,10 +158,13 @@ shared infrastructure ownership separately before removing those resources.
|
||||
Source verification's typecheck job builds the native Runner binary through the
|
||||
server's `prepare:runner-vendor` command. It restores and saves compiled Rust
|
||||
dependencies only for canonical master pushes that verify the event's exact SHA.
|
||||
The `release-typecheck-v1` cache is separate from Runner verification because
|
||||
The `release-typecheck-v2` cache is separate from Runner verification because
|
||||
those jobs compile different profiles. The pinned toolchain is selected before
|
||||
cache lookup. Workspace crates and installed cargo binaries are excluded, and
|
||||
all typechecks still execute. A missing or invalidated cache triggers compilation.
|
||||
cache lookup, and the cache action receives the Runner crate through the same
|
||||
checkout-independent `$HOME/paperclip-runner-rust` path as the Runner lanes
|
||||
(see `RELEASE-AUTOMATION-SETUP.md`). Workspace crates and installed cargo
|
||||
binaries are excluded, and all typechecks still execute. A missing or
|
||||
invalidated cache triggers compilation.
|
||||
|
||||
### pnpm dependency store cache
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ Verified custom domains are selectable after checking the API key. Complete DNS
|
||||
setup in [AgentMail](https://docs.agentmail.to/custom-domains). Paperclip does not
|
||||
register domains or manage DNS.
|
||||
|
||||
Failed provider requests retain their HTTP status, a fixed operation name, and
|
||||
an allowlisted [AgentMail error code](https://docs.agentmail.to/errors). For
|
||||
example, `create_inbox` with `missing_permission` differs from `limit_exceeded`.
|
||||
An HTTP 403 alone does not establish the cause. Missing or unrecognized codes
|
||||
appear as `unknown`. Error-body inspection is limited to 8 KiB and one second;
|
||||
malformed, larger, or stalled responses keep the original HTTP failure. Provider
|
||||
messages, suggested fixes, URLs, inbox identifiers, and credentials are excluded
|
||||
from these diagnostics. These diagnostics do not retry or suppress failures.
|
||||
|
||||
The setup and Permissions page warn that an unrestricted inbox can receive mail
|
||||
from anyone. Configure sender allowlists in AgentMail; Paperclip does not manage
|
||||
or verify them. AgentMail controls new-message and reply lists separately. The
|
||||
|
||||
@@ -22,6 +22,20 @@ Google's hosted Workspace MCP servers are Developer Preview services. The app
|
||||
cards remain independent even when several services use the same customer-owned
|
||||
Google OAuth client or the same Paperclip Cloud broker deployment.
|
||||
|
||||
## Temporary Connections page visibility hold
|
||||
|
||||
While Google OAuth verification is pending, the Connections landing page
|
||||
(`ui/src/pages/apps/Browse.tsx`) hides all nine Google Workspace entries,
|
||||
including their saved accounts. This is a display-only filter. App definitions,
|
||||
direct setup and management routes, OAuth profiles, saved credentials, and
|
||||
runtime tools remain unchanged. This is not an access-control restriction.
|
||||
|
||||
Keep verification instances pinned to their pre-hold app release so reviewers
|
||||
can still find and test the integrations. After approval, remove the page's
|
||||
`GOOGLE_CONNECTOR_SLUGS` filter and update its visibility tests before upgrading
|
||||
those instances. Do not disable the shared definitions or broker profiles to
|
||||
control this page's visibility.
|
||||
|
||||
## Developer Preview enrollment
|
||||
|
||||
Google grants preview access to the specific Workspace email addresses and
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { Webhook } from "svix";
|
||||
import {
|
||||
agentmailApi,
|
||||
AgentmailApiError,
|
||||
agentmailMessageSchema,
|
||||
emailText,
|
||||
emailReplyRecipients,
|
||||
@@ -138,6 +139,116 @@ describe("AgentMail protocol boundary", () => {
|
||||
"AgentMail request failed (403)",
|
||||
);
|
||||
});
|
||||
it.each(["missing_permission", "limit_exceeded", "domain_not_verified"])(
|
||||
"keeps a bounded create-inbox diagnostic for %s without provider data",
|
||||
async (code) => {
|
||||
const privateValue = "private-address@example.test";
|
||||
const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
code,
|
||||
message: privateValue,
|
||||
fix: `Use credential ${privateValue}`,
|
||||
docs: `https://example.test/${privateValue}`,
|
||||
nested: { code: privateValue },
|
||||
}), { status: 403 }));
|
||||
const error = await agentmailApi(privateValue, fetcher)
|
||||
.createInbox({ username: privateValue }).catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(AgentmailApiError);
|
||||
expect(error).toMatchObject({ status: 403, operation: "create_inbox", providerCode: code });
|
||||
expect(String(error)).toBe(`Error: AgentMail request failed (403) [operation=create_inbox, code=${code}]`);
|
||||
expect(JSON.stringify(error)).not.toContain(privateValue);
|
||||
expect((error as Error).stack).not.toContain(privateValue);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
"private email and credentials",
|
||||
JSON.stringify({ code: "private-address@example.test" }),
|
||||
JSON.stringify({ code: ["missing_permission"] }),
|
||||
JSON.stringify([{ code: "missing_permission" }]),
|
||||
JSON.stringify({ message: "Forbidden" }),
|
||||
JSON.stringify(null),
|
||||
])("does not copy unrecognized provider bodies into diagnostics (%#)", async (body) => {
|
||||
const fetcher = vi.fn().mockResolvedValue(new Response(body, { status: 403 }));
|
||||
await expect(agentmailApi("private-key", fetcher).whoami()).rejects.toMatchObject({
|
||||
status: 403,
|
||||
operation: "inspect_key",
|
||||
providerCode: "unknown",
|
||||
message: "AgentMail request failed (403) [operation=inspect_key, code=unknown]",
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
["GET", "/inboxes?limit=100", "list_inboxes"],
|
||||
["GET", "/inboxes/private%40example.test", "get_inbox"],
|
||||
["GET", "/domains?limit=100", "list_domains"],
|
||||
["GET", "/domains/private.example.test", "get_domain"],
|
||||
["POST", "/inboxes/private%40example.test/api-keys", "create_inbox_key"],
|
||||
["DELETE", "/inboxes/private%40example.test/api-keys/private-key", "delete_inbox_key"],
|
||||
["POST", "/inboxes/private%40example.test/webhooks", "create_webhook"],
|
||||
["DELETE", "/inboxes/private%40example.test/webhooks/private-hook", "delete_webhook"],
|
||||
["GET", "/inboxes/private%40example.test/messages?page_token=private-token", "list_messages"],
|
||||
["GET", "/inboxes/private%40example.test/messages/private-message", "get_message"],
|
||||
["GET", "/inboxes/private%40example.test/threads/private-thread", "get_thread"],
|
||||
["POST", "/inboxes/private%40example.test/messages/send", "send_message"],
|
||||
["POST", "/inboxes/private%40example.test/messages/private-message/reply", "reply_message"],
|
||||
["GET", "/inboxes/private%40example.test/messages/private-message/attachments/private-attachment", "get_attachment"],
|
||||
["POST", "/private-path?key=private-key", "request"],
|
||||
["PATCH", "/inboxes/private%40example.test", "request"],
|
||||
])("uses a fixed operation for %s route %#", async (method, path, operation) => {
|
||||
const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 403 }));
|
||||
const error = await agentmailApi("private-key", fetcher).request(path, method).catch((error: unknown) => error);
|
||||
expect(error).toMatchObject({ operation, providerCode: "unknown" });
|
||||
expect(String(error)).not.toContain("private");
|
||||
expect(JSON.stringify(error)).not.toContain("private");
|
||||
});
|
||||
it("bounds the error body read and cancels the remaining stream", async () => {
|
||||
const cancel = vi.fn();
|
||||
const response = new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(JSON.stringify({ code: "missing_permission", message: "x".repeat(8192) })));
|
||||
},
|
||||
cancel,
|
||||
}), { status: 403 });
|
||||
await expect(agentmailApi("private-key", vi.fn().mockResolvedValue(response)).whoami())
|
||||
.rejects.toMatchObject({ status: 403, providerCode: "unknown" });
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("keeps the HTTP status when the error body stalls or cancellation fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const cancel = vi.fn().mockRejectedValue(new Error("private provider error"));
|
||||
const response = new Response(new ReadableStream({ cancel }), { status: 403 });
|
||||
const failure = expect(agentmailApi("private-key", vi.fn().mockResolvedValue(response)).whoami())
|
||||
.rejects.toMatchObject({ status: 403, providerCode: "unknown" });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await failure;
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("keeps retry classification when reading an error body fails", async () => {
|
||||
const response = new Response(new ReadableStream({
|
||||
start(controller) { controller.error(new Error("private provider error")); },
|
||||
}), { status: 429, headers: { "retry-after": "9" } });
|
||||
const fetcher = vi.fn().mockResolvedValue(response);
|
||||
await expect(agentmailApi("private-key", fetcher).send("private@example.test", {}, "private-key"))
|
||||
.rejects.toMatchObject({ status: 429, retryAfterMs: 9000, providerCode: "unknown", operation: "send_message" });
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
it.each([403, 429])("preserves HTTP %s when the response body is already locked", async (status) => {
|
||||
const response = new Response(JSON.stringify({ code: "missing_permission" }), {
|
||||
status,
|
||||
headers: { "retry-after": "9" },
|
||||
});
|
||||
const owner = response.body!.getReader();
|
||||
try {
|
||||
await expect(agentmailApi("private-key", vi.fn().mockResolvedValue(response))
|
||||
.send("private@example.test", {}, "private-key"))
|
||||
.rejects.toMatchObject({ status, retryAfterMs: 9000, providerCode: "unknown", operation: "send_message" });
|
||||
} finally {
|
||||
owner.releaseLock();
|
||||
}
|
||||
});
|
||||
it("constructs deliberate reply-all from visible recipients, excluding self and Bcc", () => {
|
||||
const envelope = {
|
||||
from: "Sender <sender@example.test>",
|
||||
|
||||
@@ -301,6 +301,43 @@ describeEmbeddedPostgres("heartbeat teardown terminalizes the run before releasi
|
||||
expect(releaseRunLeases).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["failed", "cancelled", "timed_out", "interrupted", "succeeded"])(
|
||||
"uses the durable %s outcome when recovered workspace cleanup requests warm retention",
|
||||
async (status) => {
|
||||
const { companyId, agentId, issueId, runId } = await seed({
|
||||
issueStatus: status === "succeeded" ? "done" : "blocked",
|
||||
runStatus: status,
|
||||
});
|
||||
await db.update(heartbeatRuns).set({
|
||||
runtimeMode: "native",
|
||||
nativeIssueId: issueId,
|
||||
nativePhase: "committed",
|
||||
finishedAt: new Date(),
|
||||
}).where(eq(heartbeatRuns.id, runId));
|
||||
const releaseRunLeases = vi.fn(async () => []);
|
||||
const heartbeat = heartbeatService(db, {
|
||||
environmentRuntime: { releaseRunLeases } as unknown as HeartbeatEnvironmentRuntime,
|
||||
});
|
||||
|
||||
// Successful workspace copy-back does not make a failed provider turn
|
||||
// successful. Recovery can reach this boundary without the run's finally.
|
||||
await heartbeat.releaseEnvironmentLeasesForRun({
|
||||
runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "succeeded",
|
||||
providerResourceDisposition: "keep_running",
|
||||
});
|
||||
|
||||
expect(releaseRunLeases).toHaveBeenCalledWith(
|
||||
runId,
|
||||
leaseReleaseStatusForRunStatus(status),
|
||||
expect.any(Function),
|
||||
status === "succeeded" ? "keep_running" : "stop_and_retain",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["daytona", "local"])("destroy after failed checkpoint requires a terminal remote owner: %s", async provider => {
|
||||
const { companyId, agentId, runId } = await seed({ issueStatus: "blocked", runStatus: "running" });
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
|
||||
|
||||
@@ -42,13 +42,84 @@ export interface AgentmailScope {
|
||||
pod_id?: string;
|
||||
inbox_id?: string;
|
||||
}
|
||||
|
||||
// Only these documented codes may leave the provider boundary. The other
|
||||
// response fields (including `fix`) can contain addresses, URLs, or credentials.
|
||||
const providerErrorCodes = [
|
||||
"missing_authorization", "invalid_token_type", "unknown_api_key", "unauthorized",
|
||||
"missing_permission", "permission_escalation", "unrestricted_key_required", "forbidden",
|
||||
"validation_error", "not_found", "unprocessable", "query_range_too_wide",
|
||||
"already_exists", "resource_taken", "limit_exceeded", "domain_not_verified",
|
||||
"conflict", "race_condition", "resource_deleting", "cannot_delete", "message_rejected",
|
||||
"rate_limit_exceeded", "service_unavailable", "internal_error",
|
||||
] as const;
|
||||
type ProviderErrorCode = (typeof providerErrorCodes)[number] | "unknown";
|
||||
const providerOperations = [
|
||||
["GET", /^\/auth\/me$/, "inspect_key"],
|
||||
["GET", /^\/inboxes$/, "list_inboxes"],
|
||||
["POST", /^\/inboxes$/, "create_inbox"],
|
||||
["GET", /^\/inboxes\/[^/]+$/, "get_inbox"],
|
||||
["GET", /^\/domains$/, "list_domains"],
|
||||
["GET", /^\/domains\/[^/]+$/, "get_domain"],
|
||||
["POST", /^\/inboxes\/[^/]+\/api-keys$/, "create_inbox_key"],
|
||||
["DELETE", /^\/inboxes\/[^/]+\/api-keys\/[^/]+$/, "delete_inbox_key"],
|
||||
["POST", /^\/inboxes\/[^/]+\/webhooks$/, "create_webhook"],
|
||||
["DELETE", /^\/inboxes\/[^/]+\/webhooks\/[^/]+$/, "delete_webhook"],
|
||||
["GET", /^\/inboxes\/[^/]+\/messages$/, "list_messages"],
|
||||
["GET", /^\/inboxes\/[^/]+\/messages\/[^/]+$/, "get_message"],
|
||||
["GET", /^\/inboxes\/[^/]+\/threads\/[^/]+$/, "get_thread"],
|
||||
["POST", /^\/inboxes\/[^/]+\/messages\/send$/, "send_message"],
|
||||
["POST", /^\/inboxes\/[^/]+\/messages\/[^/]+\/reply$/, "reply_message"],
|
||||
["GET", /^\/inboxes\/[^/]+\/messages\/[^/]+\/attachments\/[^/]+$/, "get_attachment"],
|
||||
] as const;
|
||||
type ProviderOperation = (typeof providerOperations)[number][2] | "request";
|
||||
|
||||
async function readProviderErrorCode(response: Response): Promise<ProviderErrorCode> {
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
if (!response.body) return "unknown";
|
||||
const bodyReader = response.body.getReader();
|
||||
reader = bodyReader;
|
||||
return await Promise.race([
|
||||
(async (): Promise<ProviderErrorCode> => {
|
||||
const parts: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const part = await bodyReader.read();
|
||||
if (part.done) break;
|
||||
bytes += part.value.length;
|
||||
if (bytes > 8 * 1024) return "unknown";
|
||||
parts.push(part.value);
|
||||
}
|
||||
const body: unknown = JSON.parse(Buffer.concat(parts).toString("utf8"));
|
||||
const code = body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).code
|
||||
: undefined;
|
||||
return providerErrorCodes.find((known) => known === code) ?? "unknown";
|
||||
})(),
|
||||
new Promise<ProviderErrorCode>((resolve) => {
|
||||
timeout = setTimeout(() => resolve("unknown"), 1000);
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// A malformed, truncated, or interrupted body must not replace the HTTP error.
|
||||
return "unknown";
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
void reader?.cancel().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentmailApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly retryAfterMs = 1000,
|
||||
readonly operation: ProviderOperation = "request",
|
||||
readonly providerCode: ProviderErrorCode = "unknown",
|
||||
) {
|
||||
// Provider bodies may contain credentials or private mail. Never log them.
|
||||
super(`AgentMail request failed (${status})`);
|
||||
super(`AgentMail request failed (${status}) [operation=${operation}, code=${providerCode}]`);
|
||||
}
|
||||
}
|
||||
export const AGENTMAIL_EVENTS = [
|
||||
@@ -183,6 +254,10 @@ export function agentmailApi(apiKey: string, fetchImpl: typeof fetch = fetch) {
|
||||
throw new AgentmailApiError(
|
||||
response.status,
|
||||
Math.max(1000, Math.min(300_000, Number.isFinite(delay) ? delay : 1000)),
|
||||
providerOperations.find(([verb, route]) =>
|
||||
verb === method && route.test(path.split("?")[0]),
|
||||
)?.[2] ?? "request",
|
||||
await readProviderErrorCode(response),
|
||||
);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
@@ -10174,7 +10174,14 @@ export function heartbeatService(
|
||||
}) {
|
||||
const leaseOwnerRun = await getRun(input.runId);
|
||||
if (leaseOwnerRun && isNativeRunnerOwnershipHeld(leaseOwnerRun)) return;
|
||||
if (input.providerResourceDisposition === "destroy") {
|
||||
// Recovery can finish workspace copy-back outside the executor's finally.
|
||||
// Successful copy-back does not earn warm retention for a failed turn.
|
||||
const status = leaseOwnerRun?.status ?? input.status;
|
||||
const providerResourceDisposition = providerResourceDispositionForTerminalRun(
|
||||
input.providerResourceDisposition,
|
||||
status,
|
||||
);
|
||||
if (providerResourceDisposition === "destroy") {
|
||||
const closeResult = await (
|
||||
options.closeWarmNativeSessionsForRun ??
|
||||
(async ({ runId, reason }) => {
|
||||
@@ -10242,9 +10249,9 @@ export function heartbeatService(
|
||||
heartbeatRunId: input.runId,
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
status: leaseReleaseStatusForRunStatus(input.status),
|
||||
status: leaseReleaseStatusForRunStatus(status),
|
||||
failureReason: input.failureReason ?? undefined,
|
||||
providerResourceDisposition: input.providerResourceDisposition,
|
||||
providerResourceDisposition,
|
||||
nativeLifecycleTelemetry: input.nativeLifecycleTelemetry,
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -5,16 +5,19 @@ import {
|
||||
Flag,
|
||||
LogOut,
|
||||
type LucideIcon,
|
||||
UserRound,
|
||||
UserRoundPen,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import { hidesCompanyPage, type DeploymentMode } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useCloudInviteUrl } from "@/hooks/useCloudInviteUrl";
|
||||
import { useCanInviteCompanyMembers } from "@/hooks/useCompanyInviteAccess";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { useSignOut } from "@/hooks/useSignOut";
|
||||
import { useStagingCommit } from "@/hooks/useStagingCommit";
|
||||
import { userProfilePath } from "@/lib/userProfileLinks";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -23,7 +26,7 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { SidebarServerInfo } from "./SidebarServerInfo";
|
||||
|
||||
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
|
||||
const INVITES_PATH = "/company/settings/members?tab=invites";
|
||||
const DOCS_URL = "https://docs.paperclip.ing/";
|
||||
const FEEDBACK_URL = "https://paperclip.ing/feedback";
|
||||
|
||||
@@ -39,7 +42,14 @@ interface MenuActionProps {
|
||||
icon: LucideIcon;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
/** Opens `href` in a new tab (docs and other off-product links). */
|
||||
external?: boolean;
|
||||
/**
|
||||
* Leaves the app in the current tab with a full navigation. Cloud links
|
||||
* must use this: the cloud harness shadows those paths on tenant hosts, so
|
||||
* the in-app router can never reach them.
|
||||
*/
|
||||
topLevel?: boolean;
|
||||
}
|
||||
|
||||
function deriveInitials(name: string) {
|
||||
@@ -50,21 +60,15 @@ function deriveInitials(name: string) {
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) {
|
||||
const candidates = [name, email?.split("@")[0], email, id];
|
||||
for (const candidate of candidates) {
|
||||
const slug = candidate
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['"]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (slug) return slug;
|
||||
}
|
||||
return "me";
|
||||
}
|
||||
|
||||
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
|
||||
function MenuAction({
|
||||
label,
|
||||
description,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
href,
|
||||
external = false,
|
||||
topLevel = false,
|
||||
}: MenuActionProps) {
|
||||
const className =
|
||||
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
|
||||
|
||||
@@ -89,6 +93,14 @@ function MenuAction({ label, description, icon: Icon, onClick, href, external =
|
||||
);
|
||||
}
|
||||
|
||||
if (topLevel) {
|
||||
return (
|
||||
<a href={href} className={className} onClick={onClick}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} className={className} onClick={onClick}>
|
||||
{content}
|
||||
@@ -108,7 +120,26 @@ export function SidebarAccountMenu({
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: SidebarAccountMenuProps) {
|
||||
const isCloud = Boolean(useCloudInstance());
|
||||
const cloud = useCloudInstance();
|
||||
const isCloud = Boolean(cloud);
|
||||
// Invites live on the Members page (or in Cloud's People settings). Hide the
|
||||
// shortcut when the hosting operator hides either surface, and until the
|
||||
// health response resolves so a hidden surface never flashes.
|
||||
const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings();
|
||||
// On Cloud the shortcut exists only for the current stack's owner/admin and
|
||||
// only once the stack metadata is known; the in-app Invites tab is never a
|
||||
// fallback there because it drives a different invitation flow.
|
||||
const cloudInviteUrl = useCloudInviteUrl();
|
||||
// Self-hosted invites need the `users:invite` grant. Offer the shortcut only
|
||||
// to boards that hold it (company owner/admin, instance admins, local
|
||||
// boards) so a plain member is never sent to a permission error.
|
||||
const canInviteMembers = useCanInviteCompanyMembers(!isCloud);
|
||||
const inviteHref = isCloud ? cloudInviteUrl : canInviteMembers ? INVITES_PATH : null;
|
||||
const showInvite =
|
||||
hiddenSettingsLoaded &&
|
||||
inviteHref !== null &&
|
||||
!hidesCompanyPage(hiddenSettings, "company.members") &&
|
||||
!hidesCompanyPage(hiddenSettings, "company.invites");
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
@@ -127,7 +158,7 @@ export function SidebarAccountMenu({
|
||||
const secondaryLabel =
|
||||
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
|
||||
const initials = deriveInitials(displayName);
|
||||
const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
|
||||
const profileHref = userProfilePath(session?.user);
|
||||
|
||||
function closeNavigationChrome() {
|
||||
setOpen(false);
|
||||
@@ -166,19 +197,26 @@ export function SidebarAccountMenu({
|
||||
>
|
||||
<div className="h-24 bg-(image:--gradient-extract-25)" />
|
||||
<div className="-mt-8 px-4 pb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-2xl border-4 border-popover bg-popover p-0.5 shadow-sm">
|
||||
{/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */}
|
||||
<div className="relative flex items-start gap-3">
|
||||
<Link
|
||||
to={profileHref}
|
||||
aria-label="View profile"
|
||||
onClick={closeNavigationChrome}
|
||||
className="absolute inset-0 rounded-xl transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<div className="pointer-events-none relative rounded-2xl border-4 border-popover bg-popover p-0.5 shadow-sm">
|
||||
<Avatar size="lg">
|
||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-1">
|
||||
<div className="pointer-events-none relative min-w-0 flex-1 pt-1">
|
||||
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
|
||||
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
|
||||
{stagingCommit ? (
|
||||
<a
|
||||
className="block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
|
||||
className="pointer-events-auto block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
|
||||
href={`https://github.com/paperclipai/paperclip/commit/${stagingCommit}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -192,20 +230,16 @@ export function SidebarAccountMenu({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1">
|
||||
<MenuAction
|
||||
label="View profile"
|
||||
description="Open your activity, task, and usage ledger."
|
||||
icon={UserRound}
|
||||
href={profileHref}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Edit profile"
|
||||
description="Update your display name and avatar."
|
||||
icon={UserRoundPen}
|
||||
href={PROFILE_SETTINGS_PATH}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
{showInvite && inviteHref ? (
|
||||
<MenuAction
|
||||
label="Invite"
|
||||
description="Invite people to your organization."
|
||||
icon={UserPlus}
|
||||
href={inviteHref}
|
||||
topLevel={isCloud}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
) : null}
|
||||
<MenuAction
|
||||
label="Documentation"
|
||||
description="Open Paperclip docs in a new tab."
|
||||
|
||||
@@ -20,6 +20,7 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
const mockHealthApi = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const mockGetCurrentBoardAccess = vi.hoisted(() => vi.fn());
|
||||
const mockToggleTheme = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockNavigateTopLevel = vi.hoisted(() => vi.fn());
|
||||
@@ -30,6 +31,18 @@ vi.mock("@/api/auth", () => ({
|
||||
|
||||
vi.mock("@/api/health", () => ({ healthApi: mockHealthApi }));
|
||||
|
||||
vi.mock("@/api/access", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/api/access")>();
|
||||
return {
|
||||
...actual,
|
||||
accessApi: { ...actual.accessApi, getCurrentBoardAccess: mockGetCurrentBoardAccess },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useOptionalCompany: () => ({ selectedCompanyId: "company-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/browserNavigation", () => ({
|
||||
navigateTopLevel: mockNavigateTopLevel,
|
||||
}));
|
||||
@@ -78,6 +91,39 @@ async function flushReact() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Stack portfolio with the acme-labs stack current at the given Cloud role. */
|
||||
function cloudStacksPortfolio(role: string) {
|
||||
return {
|
||||
stacks: [
|
||||
{
|
||||
displayName: "Acme Labs",
|
||||
stackSlug: "acme-labs",
|
||||
primaryHost: "acme-labs.example.test",
|
||||
lifecycleState: "active",
|
||||
sleepState: "awake",
|
||||
role,
|
||||
isCurrent: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Board access snapshot for a signed-in member of company-1 at the given company role. */
|
||||
function boardAccess(
|
||||
membershipRole: "owner" | "admin" | "operator" | "viewer",
|
||||
overrides: { isInstanceAdmin?: boolean; source?: string } = {},
|
||||
) {
|
||||
return {
|
||||
user: { id: "user-1", email: "jane@example.com", name: "Jane Example", image: null },
|
||||
userId: "user-1",
|
||||
isInstanceAdmin: overrides.isInstanceAdmin ?? false,
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", membershipRole, status: "active" as const }],
|
||||
source: overrides.source ?? "session",
|
||||
keyId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SidebarAccountMenu", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -97,6 +143,9 @@ describe("SidebarAccountMenu", () => {
|
||||
enableIsolatedWorkspaces: false,
|
||||
});
|
||||
mockAuthApi.signOut.mockResolvedValue({ success: true, redirectTo: "/cloud/logout" });
|
||||
// Default to a company owner: the self-hosted Invite shortcut needs the
|
||||
// `users:invite` grant, which owners and admins hold.
|
||||
mockGetCurrentBoardAccess.mockResolvedValue(boardAccess("owner"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -347,7 +396,6 @@ describe("SidebarAccountMenu", () => {
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Edit profile");
|
||||
expect(document.body.textContent).toContain("Settings");
|
||||
expect(document.body.textContent).not.toContain("Instance settings");
|
||||
expect(document.body.textContent).toContain("Documentation");
|
||||
@@ -356,6 +404,35 @@ describe("SidebarAccountMenu", () => {
|
||||
expect(popover?.textContent).not.toContain("Feedback");
|
||||
expect(popover?.querySelector('a[href="https://paperclip.ing/feedback"]')).toBeNull();
|
||||
|
||||
// Profile access moved off the action list: the header links to the
|
||||
// profile, and the separate view/edit rows are gone. Editing lives on the
|
||||
// profile page itself.
|
||||
expect(popover?.textContent).not.toContain("View profile");
|
||||
expect(popover?.textContent).not.toContain("Edit profile");
|
||||
// The link resolves by user id, not display name: names are not unique,
|
||||
// and the profile endpoint returns the first member whose slug matches.
|
||||
const profileHeaderLink = popover?.querySelector<HTMLAnchorElement>('a[aria-label="View profile"]');
|
||||
expect(profileHeaderLink?.getAttribute("href")).toBe("/u/user-1");
|
||||
// The link is a stretched overlay over the header block (so the staging
|
||||
// SHA anchor can live beside the email without nesting anchors), so the
|
||||
// identity text is in the header block rather than inside the anchor.
|
||||
const profileHeader = profileHeaderLink?.parentElement;
|
||||
expect(profileHeader?.textContent).toContain("Jane Example");
|
||||
expect(profileHeader?.textContent).toContain("jane@example.com");
|
||||
expect(profileHeader?.querySelector("a a")).toBeNull();
|
||||
expect(popover?.querySelector('a[href="/company/settings/instance/profile"]')).toBeNull();
|
||||
|
||||
// Self-hosted invites open the in-app Members page on its Invites tab.
|
||||
const inviteLink = popover?.querySelector<HTMLAnchorElement>(
|
||||
'a[href="/company/settings/members?tab=invites"]',
|
||||
);
|
||||
expect(inviteLink?.textContent).toBe("Invite");
|
||||
expect(inviteLink?.querySelector("svg")?.classList).toContain("lucide-user-plus");
|
||||
expect(inviteLink?.hasAttribute("target")).toBe(false);
|
||||
const menuOrder = popover?.textContent ?? "";
|
||||
expect(menuOrder.indexOf("Settings")).toBeLessThan(menuOrder.indexOf("Invite"));
|
||||
expect(menuOrder.indexOf("Invite")).toBeLessThan(menuOrder.indexOf("Documentation"));
|
||||
|
||||
// Documentation still appears before the theme toggle.
|
||||
const menuText = popover?.textContent ?? "";
|
||||
const docsPos = menuText.indexOf("Documentation");
|
||||
@@ -376,7 +453,6 @@ describe("SidebarAccountMenu", () => {
|
||||
.not.toContain("bg-muted");
|
||||
expect(document.body.textContent).not.toContain("Manage company and instance settings.");
|
||||
expect(document.body.textContent).not.toContain("Open your activity, task, and usage ledger.");
|
||||
expect(document.body.querySelector('a[href="/company/settings/instance/profile"]')).not.toBeNull();
|
||||
expect(document.body.querySelector('a[href="/company/settings"]')).not.toBeNull();
|
||||
|
||||
const signOutButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
@@ -412,6 +488,7 @@ describe("SidebarAccountMenu", () => {
|
||||
cloudBaseUrl: "https://cloud.example.test",
|
||||
},
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.cloud.stacks, cloudStacksPortfolio("owner"));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -430,6 +507,20 @@ describe("SidebarAccountMenu", () => {
|
||||
|
||||
expect(container.querySelector('a[aria-label="Share feedback"]')).toBeNull();
|
||||
|
||||
// Cloud manages invitations in the stack's People settings, reached with a
|
||||
// same-tab top-level navigation rather than the in-app router.
|
||||
const popover = document.body.querySelector('[data-slot="popover-content"]');
|
||||
const inviteLink = Array.from(popover?.querySelectorAll("a") ?? []).find(
|
||||
(anchor) => anchor.textContent?.includes("Invite"),
|
||||
);
|
||||
expect(inviteLink?.getAttribute("href")).toBe(
|
||||
"https://cloud.example.test/workspaces/acme-labs/settings?section=people",
|
||||
);
|
||||
expect(inviteLink?.hasAttribute("target")).toBe(false);
|
||||
expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull();
|
||||
expect(popover?.querySelector('a[aria-label="View profile"]')?.getAttribute("href")).toBe("/u/user-1");
|
||||
expect(popover?.textContent).not.toContain("Edit profile");
|
||||
|
||||
const signOutButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Sign out"),
|
||||
);
|
||||
@@ -448,6 +539,198 @@ describe("SidebarAccountMenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("offers no cloud invite shortcut to members below stack admin (%#)", async (AccountMenu) => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
cloud: {
|
||||
managed: true,
|
||||
managedBy: "paperclip-cloud",
|
||||
stackSlug: "acme-labs",
|
||||
cloudBaseUrl: "https://cloud.example.test",
|
||||
},
|
||||
});
|
||||
// Company roles can differ from Cloud roles; the Members page only offers
|
||||
// the Cloud invite link to the current stack's owner/admin, so the menu
|
||||
// must match rather than send a plain member to People settings.
|
||||
queryClient.setQueryData(queryKeys.cloud.stacks, cloudStacksPortfolio("member"));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<AccountMenu deploymentMode="authenticated" open />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const popover = document.body.querySelector('[data-slot="popover-content"]');
|
||||
expect(popover?.textContent).not.toContain("Invite");
|
||||
expect(popover?.querySelector('a[href^="https://cloud.example.test/"]')).toBeNull();
|
||||
expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull();
|
||||
expect(popover?.querySelector('a[aria-label="View profile"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("never falls back to in-app invites when cloud stack metadata is missing (%#)", async (AccountMenu) => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
// Managed instance whose health block lacks the stack slug: the portfolio
|
||||
// has no current stack either, so no Cloud People URL can be built. The
|
||||
// in-app Invites tab drives a different flow and must not appear instead.
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
cloud: {
|
||||
managed: true,
|
||||
managedBy: "paperclip-cloud",
|
||||
stackSlug: null,
|
||||
cloudBaseUrl: "https://cloud.example.test",
|
||||
},
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.cloud.stacks, { stacks: [] });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<AccountMenu deploymentMode="authenticated" open />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const popover = document.body.querySelector('[data-slot="popover-content"]');
|
||||
expect(popover?.textContent).not.toContain("Invite");
|
||||
expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull();
|
||||
expect(popover?.textContent).toContain("Documentation");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("hides the invite shortcut when the operator hides invites (%#)", async (AccountMenu) => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
hiddenSettings: ["company.invites"],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<AccountMenu deploymentMode="authenticated" open />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const popover = document.body.querySelector('[data-slot="popover-content"]');
|
||||
expect(popover?.textContent).not.toContain("Invite");
|
||||
expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull();
|
||||
// The settings-independent entries stay put.
|
||||
expect(popover?.querySelector('a[aria-label="View profile"]')).not.toBeNull();
|
||||
expect(popover?.textContent).toContain("Documentation");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("offers no self-hosted invite shortcut to members without the invite grant (%#)", async (AccountMenu) => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
});
|
||||
// Operators and viewers lack `users:invite`; the Invites tab would only
|
||||
// show them a permission error, so the menu must not offer it.
|
||||
mockGetCurrentBoardAccess.mockResolvedValue(boardAccess("operator"));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<AccountMenu deploymentMode="authenticated" open />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockGetCurrentBoardAccess).toHaveBeenCalledOnce();
|
||||
const popover = document.body.querySelector('[data-slot="popover-content"]');
|
||||
expect(popover?.textContent).not.toContain("Invite");
|
||||
expect(popover?.querySelector('a[href="/company/settings/members?tab=invites"]')).toBeNull();
|
||||
expect(popover?.querySelector('a[aria-label="View profile"]')).not.toBeNull();
|
||||
expect(popover?.textContent).toContain("Documentation");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["instance admin", boardAccess("viewer", { isInstanceAdmin: true })],
|
||||
["local board", boardAccess("viewer", { source: "local_implicit" })],
|
||||
["company admin", boardAccess("admin")],
|
||||
])("offers the self-hosted invite shortcut to a %s", async (_label, access) => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
});
|
||||
mockGetCurrentBoardAccess.mockResolvedValue(access);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<SidebarAccountMenu deploymentMode="authenticated" open />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const popover = document.body.querySelector('[data-slot="popover-content"]');
|
||||
const inviteLink = popover?.querySelector<HTMLAnchorElement>(
|
||||
'a[href="/company/settings/members?tab=invites"]',
|
||||
);
|
||||
expect(inviteLink?.textContent).toBe("Invite");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps sign-out hidden outside authenticated deployment mode", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -6,16 +6,19 @@ import {
|
||||
LogOut,
|
||||
Settings,
|
||||
type LucideIcon,
|
||||
UserRound,
|
||||
UserRoundPen,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import { hidesCompanyPage, type DeploymentMode } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useCloudInviteUrl } from "@/hooks/useCloudInviteUrl";
|
||||
import { useCanInviteCompanyMembers } from "@/hooks/useCompanyInviteAccess";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { useSignOut } from "@/hooks/useSignOut";
|
||||
import { useStagingCommit } from "@/hooks/useStagingCommit";
|
||||
import { userProfilePath } from "@/lib/userProfileLinks";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -24,7 +27,7 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { SidebarServerInfo } from "./SidebarServerInfo";
|
||||
|
||||
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
|
||||
const INVITES_PATH = "/company/settings/members?tab=invites";
|
||||
const DOCS_URL = "https://docs.paperclip.ing/";
|
||||
const FEEDBACK_URL = "https://paperclip.ing/feedback";
|
||||
|
||||
@@ -41,7 +44,14 @@ interface MenuActionProps {
|
||||
icon: LucideIcon;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
/** Opens `href` in a new tab (docs and other off-product links). */
|
||||
external?: boolean;
|
||||
/**
|
||||
* Leaves the app in the current tab with a full navigation. Cloud links
|
||||
* must use this: the cloud harness shadows those paths on tenant hosts, so
|
||||
* the in-app router can never reach them.
|
||||
*/
|
||||
topLevel?: boolean;
|
||||
}
|
||||
|
||||
function deriveInitials(name: string) {
|
||||
@@ -52,26 +62,13 @@ function deriveInitials(name: string) {
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) {
|
||||
const candidates = [name, email?.split("@")[0], email, id];
|
||||
for (const candidate of candidates) {
|
||||
const slug = candidate
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['"]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (slug) return slug;
|
||||
}
|
||||
return "me";
|
||||
}
|
||||
|
||||
function MenuAction({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
href,
|
||||
external = false,
|
||||
topLevel = false,
|
||||
}: MenuActionProps) {
|
||||
const className =
|
||||
"flex h-(--profile-popover-row-height) w-full items-center gap-(--profile-popover-row-gap) rounded-lg px-2.5 text-left text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) text-foreground transition-colors hover:bg-accent";
|
||||
@@ -94,6 +91,14 @@ function MenuAction({
|
||||
);
|
||||
}
|
||||
|
||||
if (topLevel) {
|
||||
return (
|
||||
<a href={href} className={className} onClick={onClick}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} className={className} onClick={onClick}>
|
||||
{content}
|
||||
@@ -114,7 +119,26 @@ export function SidebarAccountMenu({
|
||||
onOpenChange,
|
||||
forceExpanded = false,
|
||||
}: SidebarAccountMenuProps) {
|
||||
const isCloud = Boolean(useCloudInstance());
|
||||
const cloud = useCloudInstance();
|
||||
const isCloud = Boolean(cloud);
|
||||
// Invites live on the Members page (or in Cloud's People settings). Hide the
|
||||
// shortcut when the hosting operator hides either surface, and until the
|
||||
// health response resolves so a hidden surface never flashes.
|
||||
const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings();
|
||||
// On Cloud the shortcut exists only for the current stack's owner/admin and
|
||||
// only once the stack metadata is known; the in-app Invites tab is never a
|
||||
// fallback there because it drives a different invitation flow.
|
||||
const cloudInviteUrl = useCloudInviteUrl();
|
||||
// Self-hosted invites need the `users:invite` grant. Offer the shortcut only
|
||||
// to boards that hold it (company owner/admin, instance admins, local
|
||||
// boards) so a plain member is never sent to a permission error.
|
||||
const canInviteMembers = useCanInviteCompanyMembers(!isCloud);
|
||||
const inviteHref = isCloud ? cloudInviteUrl : canInviteMembers ? INVITES_PATH : null;
|
||||
const showInvite =
|
||||
hiddenSettingsLoaded &&
|
||||
inviteHref !== null &&
|
||||
!hidesCompanyPage(hiddenSettings, "company.members") &&
|
||||
!hidesCompanyPage(hiddenSettings, "company.invites");
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||
const rail = collapsed && !peeking && !forceExpanded;
|
||||
@@ -133,7 +157,7 @@ export function SidebarAccountMenu({
|
||||
const secondaryLabel =
|
||||
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
|
||||
const initials = deriveInitials(displayName);
|
||||
const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
|
||||
const profileHref = userProfilePath(session?.user);
|
||||
|
||||
function closeNavigationChrome() {
|
||||
setOpen(false);
|
||||
@@ -170,12 +194,19 @@ export function SidebarAccountMenu({
|
||||
sideOffset={10}
|
||||
className="min-h-(--profile-popover-min-height) w-(--profile-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
|
||||
>
|
||||
<div className="flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
|
||||
<Avatar className="size-9">
|
||||
{/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */}
|
||||
<div className="relative flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
|
||||
<Link
|
||||
to={profileHref}
|
||||
aria-label="View profile"
|
||||
onClick={closeNavigationChrome}
|
||||
className="absolute inset-0 transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
/>
|
||||
<Avatar className="pointer-events-none relative size-9">
|
||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback className="text-xs text-foreground">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="pointer-events-none relative min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-semibold leading-(--profile-popover-label-line-height) text-foreground">
|
||||
{displayName}
|
||||
</h2>
|
||||
@@ -184,7 +215,7 @@ export function SidebarAccountMenu({
|
||||
</p>
|
||||
{stagingCommit ? (
|
||||
<a
|
||||
className="block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
|
||||
className="pointer-events-auto block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
|
||||
href={`https://github.com/paperclipai/paperclip/commit/${stagingCommit}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -204,18 +235,15 @@ export function SidebarAccountMenu({
|
||||
href="/company/settings"
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="View profile"
|
||||
icon={UserRound}
|
||||
href={profileHref}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Edit profile"
|
||||
icon={UserRoundPen}
|
||||
href={PROFILE_SETTINGS_PATH}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
{showInvite && inviteHref ? (
|
||||
<MenuAction
|
||||
label="Invite"
|
||||
icon={UserPlus}
|
||||
href={inviteHref}
|
||||
topLevel={isCloud}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
) : null}
|
||||
<MenuAction
|
||||
label="Documentation"
|
||||
icon={BookOpen}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cloudApi } from "@/api/cloud";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cloudStackInviteUrl } from "@/lib/cloudLinks";
|
||||
import { useCloudInstance } from "./useCloudInstance";
|
||||
|
||||
/**
|
||||
* Where the signed-in user invites people on a Paperclip Cloud instance, or
|
||||
* null when no Cloud invite link should be offered.
|
||||
*
|
||||
* Mirrors the Members page: Cloud manages human invitations in the current
|
||||
* stack's People settings, and only that stack's owner or admin may invite
|
||||
* (company roles can differ from Cloud roles, so the company membership is
|
||||
* not enough). Resolves to null on self-hosted instances, while the stack
|
||||
* portfolio is still loading or has failed, for every other stack role, and
|
||||
* when the stack slug or cloud base URL is unknown.
|
||||
*
|
||||
* Callers must not substitute the in-app Invites tab when this is null on a
|
||||
* Cloud instance: that tab drives in-app invitations, a different flow from
|
||||
* Cloud People settings. Offer no shortcut instead.
|
||||
*/
|
||||
export function useCloudInviteUrl(): string | null {
|
||||
const cloud = useCloudInstance();
|
||||
const isCloud = Boolean(cloud);
|
||||
const cloudStacksQuery = useQuery({
|
||||
queryKey: queryKeys.cloud.stacks,
|
||||
queryFn: () => cloudApi.listStacks(),
|
||||
enabled: isCloud,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
if (!cloud || cloudStacksQuery.isError) return null;
|
||||
const currentStack = cloudStacksQuery.data?.stacks.find((stack) => stack.isCurrent);
|
||||
if (currentStack?.role !== "owner" && currentStack?.role !== "admin") return null;
|
||||
return cloudStackInviteUrl(cloud.cloudBaseUrl, currentStack.stackSlug);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CurrentBoardAccess } from "@/api/access";
|
||||
import { canInviteCompanyMembers } from "./useCompanyInviteAccess";
|
||||
|
||||
function snapshot(overrides: Partial<CurrentBoardAccess> = {}): CurrentBoardAccess {
|
||||
return {
|
||||
user: { id: "user-1", email: "jane@example.com", name: "Jane Example", image: null },
|
||||
userId: "user-1",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }],
|
||||
source: "session",
|
||||
keyId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("canInviteCompanyMembers", () => {
|
||||
it("is false until the board access snapshot is known", () => {
|
||||
expect(canInviteCompanyMembers("company-1", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("always passes local boards and instance admins", () => {
|
||||
expect(canInviteCompanyMembers("company-1", snapshot({ source: "local_implicit" }))).toBe(true);
|
||||
expect(canInviteCompanyMembers("company-1", snapshot({ isInstanceAdmin: true }))).toBe(true);
|
||||
// Both bypass the company lookup, so a missing company is fine.
|
||||
expect(canInviteCompanyMembers(null, snapshot({ isInstanceAdmin: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["owner", "admin"] as const)("passes an active company %s", (membershipRole) => {
|
||||
const access = snapshot({
|
||||
memberships: [{ companyId: "company-1", membershipRole, status: "active" }],
|
||||
});
|
||||
expect(canInviteCompanyMembers("company-1", access)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["operator", "viewer", "member", null] as const)("rejects a company %s", (membershipRole) => {
|
||||
const access = snapshot({
|
||||
memberships: [{ companyId: "company-1", membershipRole, status: "active" }],
|
||||
});
|
||||
expect(canInviteCompanyMembers("company-1", access)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores inactive memberships and other companies", () => {
|
||||
const suspended = snapshot({
|
||||
memberships: [{ companyId: "company-1", membershipRole: "owner", status: "suspended" }],
|
||||
});
|
||||
expect(canInviteCompanyMembers("company-1", suspended)).toBe(false);
|
||||
const elsewhere = snapshot({
|
||||
memberships: [{ companyId: "company-2", membershipRole: "owner", status: "active" }],
|
||||
});
|
||||
expect(canInviteCompanyMembers("company-1", elsewhere)).toBe(false);
|
||||
expect(canInviteCompanyMembers(null, elsewhere)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the snapshot carries no memberships", () => {
|
||||
expect(canInviteCompanyMembers("company-1", snapshot({ memberships: undefined }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accessApi, type CurrentBoardAccess } from "@/api/access";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useOptionalCompany } from "@/context/CompanyContext";
|
||||
|
||||
/**
|
||||
* Whether a board may invite people to `companyId` on a self-hosted instance,
|
||||
* judged from the current board access snapshot.
|
||||
*
|
||||
* Mirrors the other client-side role gates (`ToolsAdminGate`, the run
|
||||
* ledger): local implicit boards and instance admins always pass; otherwise
|
||||
* the active company membership must be owner or admin, the roles that carry
|
||||
* the `users:invite` grant. Resolves to false while the snapshot is unknown.
|
||||
* The server stays authoritative; this only decides whether to offer a
|
||||
* shortcut that would otherwise end in a permission error.
|
||||
*/
|
||||
export function canInviteCompanyMembers(
|
||||
companyId: string | null | undefined,
|
||||
boardAccess: CurrentBoardAccess | undefined,
|
||||
): boolean {
|
||||
if (!boardAccess) return false;
|
||||
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
|
||||
if (!companyId) return false;
|
||||
const membership = boardAccess.memberships?.find(
|
||||
(item) => item.companyId === companyId && item.status === "active",
|
||||
);
|
||||
return membership?.membershipRole === "owner" || membership?.membershipRole === "admin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the signed-in board may invite people to the selected company on a
|
||||
* self-hosted instance. Pass `enabled: false` on Cloud, where invitations live
|
||||
* in the stack's People settings and use a different rule
|
||||
* (see `useCloudInviteUrl`).
|
||||
*/
|
||||
export function useCanInviteCompanyMembers(enabled = true): boolean {
|
||||
const company = useOptionalCompany();
|
||||
const boardAccess = useQuery({
|
||||
queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||||
enabled,
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
return enabled && canInviteCompanyMembers(company?.selectedCompanyId, boardAccess.data);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { userProfilePath } from "./userProfileLinks";
|
||||
|
||||
describe("userProfilePath", () => {
|
||||
it("links by user id even when a display name is available", () => {
|
||||
// Two members can share a name; the endpoint returns the first slug match,
|
||||
// so a name-based link could open a namesake's profile.
|
||||
expect(userProfilePath({ id: "user-1", name: "Jane Example", email: "jane@example.com" })).toBe("/u/user-1");
|
||||
});
|
||||
|
||||
it("slugifies ids the same way the profile endpoint does", () => {
|
||||
expect(userProfilePath({ id: "paperclip-id:TLqGPuleJpjX" })).toBe("/u/paperclip-id-tlqgpulejpjx");
|
||||
});
|
||||
|
||||
it("falls back to name, then email, when the session carries no id", () => {
|
||||
expect(userProfilePath({ id: null, name: "Jane Example", email: "jane@example.com" })).toBe("/u/jane-example");
|
||||
expect(userProfilePath({ id: "", name: " ", email: "jane.doe@example.com" })).toBe("/u/jane-doe");
|
||||
expect(userProfilePath({ email: "@example.com" })).toBe("/u/example-com");
|
||||
});
|
||||
|
||||
it("uses a placeholder when nothing identifies the user", () => {
|
||||
expect(userProfilePath(undefined)).toBe("/u/me");
|
||||
expect(userProfilePath({ id: "!!!", name: "'\"" })).toBe("/u/me");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
interface ProfileLinkUser {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
function slugifyUserPart(value: string | null | undefined): string | null {
|
||||
const slug = value
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['"]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app path of a user's profile page (`/u/:userSlug`).
|
||||
*
|
||||
* The profile endpoint resolves `:userSlug` against every member's name,
|
||||
* email local part, email, and principal id, and returns the first member
|
||||
* that matches. Display names are not unique, so a name-based slug can open a
|
||||
* namesake's profile instead of the intended one. The principal id is the one
|
||||
* candidate that is unique per member, so links to a known user always use
|
||||
* it; name and email slugs are only a fallback for sessions with no user id.
|
||||
*/
|
||||
export function userProfilePath(user: ProfileLinkUser | null | undefined): string {
|
||||
const candidates = [user?.id, user?.name, user?.email?.split("@")[0], user?.email];
|
||||
for (const candidate of candidates) {
|
||||
const slug = slugifyUserPart(candidate);
|
||||
if (slug) return `/u/${slug}`;
|
||||
}
|
||||
return "/u/me";
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UserProfileResponse } from "@paperclipai/shared";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { UserProfile } from "./UserProfile";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUserProfilesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockUseParams = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/userProfiles", () => ({
|
||||
userProfilesApi: mockUserProfilesApi,
|
||||
}));
|
||||
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({
|
||||
setBreadcrumbs: mockSetBreadcrumbs,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
useParams: () => mockUseParams(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function windowStats(key: "last7" | "last30" | "all", label: string): UserProfileResponse["stats"][number] {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
touchedIssues: 4,
|
||||
createdIssues: 2,
|
||||
completedIssues: 3,
|
||||
assignedOpenIssues: 1,
|
||||
commentCount: 5,
|
||||
activityCount: 9,
|
||||
costCents: 1200,
|
||||
inputTokens: 1000,
|
||||
cachedInputTokens: 200,
|
||||
outputTokens: 300,
|
||||
costEventCount: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function profileFor(userId: string): UserProfileResponse {
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
slug: "jane-example",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: null,
|
||||
membershipRole: "owner",
|
||||
membershipStatus: "active",
|
||||
joinedAt: new Date("2026-01-05T00:00:00Z"),
|
||||
},
|
||||
stats: [windowStats("last7", "Last 7 days"), windowStats("last30", "Last 30 days"), windowStats("all", "All time")],
|
||||
daily: [],
|
||||
recentIssues: [],
|
||||
recentActivity: [],
|
||||
topAgents: [],
|
||||
topProviders: [],
|
||||
};
|
||||
}
|
||||
|
||||
const EDIT_PROFILE_HREF = "/company/settings/instance/profile";
|
||||
|
||||
describe("UserProfile", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockUseParams.mockReturnValue({ userSlug: "jane-example" });
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render(profile: UserProfileResponse, hiddenSettings: string[] = []) {
|
||||
mockUserProfilesApi.get.mockResolvedValue(profile);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
hiddenSettings,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<UserProfile />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
it("offers an edit link on the viewer's own profile", async () => {
|
||||
const root = await render(profileFor("user-1"));
|
||||
|
||||
expect(container.textContent).toContain("Jane Example");
|
||||
const editLink = container.querySelector<HTMLAnchorElement>(`a[href="${EDIT_PROFILE_HREF}"]`);
|
||||
expect(editLink).not.toBeNull();
|
||||
expect(editLink?.textContent).toContain("Edit profile");
|
||||
expect(editLink?.querySelector("svg")?.classList).toContain("lucide-user-round-pen");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps other users' profiles read-only", async () => {
|
||||
const root = await render(profileFor("user-2"));
|
||||
|
||||
expect(container.textContent).toContain("Jane Example");
|
||||
expect(container.querySelector(`a[href="${EDIT_PROFILE_HREF}"]`)).toBeNull();
|
||||
expect(container.textContent).not.toContain("Edit profile");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("drops the edit link when the operator hides profile settings", async () => {
|
||||
const root = await render(profileFor("user-1"), ["instance.profile"]);
|
||||
|
||||
expect(container.textContent).toContain("Jane Example");
|
||||
expect(container.querySelector(`a[href="${EDIT_PROFILE_HREF}"]`)).toBeNull();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, UserRound } from "lucide-react";
|
||||
import type { UserProfileDailyPoint, UserProfileWindowStats } from "@paperclipai/shared";
|
||||
import { AlertCircle, UserRound, UserRoundPen } from "lucide-react";
|
||||
import {
|
||||
hidesInstancePage,
|
||||
type UserProfileDailyPoint,
|
||||
type UserProfileWindowStats,
|
||||
} from "@paperclipai/shared";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import { authApi } from "../api/auth";
|
||||
import { userProfilesApi } from "../api/userProfiles";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { IssueStatusBadge } from "../components/StatusBadge";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useHiddenSettings } from "../hooks/useHiddenSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
formatCents,
|
||||
@@ -23,6 +30,7 @@ import {
|
||||
} from "../lib/utils";
|
||||
|
||||
const NO_COMPANY = "__none__";
|
||||
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
|
||||
|
||||
function initials(name: string | null | undefined) {
|
||||
const value = name?.trim() || "User";
|
||||
@@ -205,6 +213,18 @@ export function UserProfile() {
|
||||
queryFn: () => userProfilesApi.get(companyId, userSlug),
|
||||
enabled: !!selectedCompanyId && !!userSlug,
|
||||
});
|
||||
// The edit link only belongs on the viewer's own profile, and never when the
|
||||
// hosting operator hides the profile settings surface the link points at.
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
const { hidden: hiddenSettings } = useHiddenSettings();
|
||||
const canEditProfile =
|
||||
Boolean(data?.user.id) &&
|
||||
data?.user.id === session?.user.id &&
|
||||
!hidesInstancePage(hiddenSettings, "instance.profile");
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Users" }, { label: data?.user.name ?? userSlug }]);
|
||||
@@ -280,6 +300,14 @@ export function UserProfile() {
|
||||
<span>{metaParts.join(" · ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{canEditProfile ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={PROFILE_SETTINGS_PATH}>
|
||||
<UserRoundPen />
|
||||
Edit profile
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
@@ -200,6 +200,124 @@ describe("Connectors landing page", () => {
|
||||
expect(container.textContent).not.toContain("Paused");
|
||||
});
|
||||
|
||||
const googleSlugs = [
|
||||
"gmail", "google-drive", "google-docs", "google-sheets", "google-slides",
|
||||
"google-calendar", "google-chat", "google-people", "google-workspace-search",
|
||||
];
|
||||
|
||||
it("temporarily hides all Google Workspace catalog rows without changing their definitions", async () => {
|
||||
const definitions = googleSlugs.map((slug) => getAppStoreDefinition(slug)!);
|
||||
listGalleryMock.mockResolvedValue({ apps: [...definitions, getAppStoreDefinition("notion")] });
|
||||
const client = await renderBrowse();
|
||||
|
||||
for (const definition of definitions) {
|
||||
expect(definition.methods.length).toBeGreaterThan(0);
|
||||
expect(getAppStoreDefinition(definition.slug)).toBe(definition);
|
||||
expect(container.querySelector(`[data-app-slug="${definition.slug}"]`)).toBeNull();
|
||||
}
|
||||
expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull();
|
||||
expect(client.getQueryData(queryKeys.apps.gallery("company-1"))).toEqual({
|
||||
apps: [...definitions, getAppStoreDefinition("notion")],
|
||||
});
|
||||
expect(archiveConnectionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["active", "draft", "disabled"])(
|
||||
"hides saved Google %s accounts without disabling or removing them",
|
||||
async (status) => {
|
||||
const applications = googleSlugs.map((slug) => application({
|
||||
id: `app-${slug}`, name: `Saved ${slug}`, applicationKey: `app-gallery:${slug}:one`,
|
||||
metadata: { sourceTemplateKey: slug },
|
||||
}));
|
||||
const connections = googleSlugs.map((slug) => connection({
|
||||
id: `conn-${slug}`, applicationId: `app-${slug}`, name: `Account for ${slug}`,
|
||||
status, enabled: status !== "disabled", config: { sourceTemplateKey: slug },
|
||||
}));
|
||||
listGalleryMock.mockResolvedValue({ apps: googleSlugs.map(getAppStoreDefinition) });
|
||||
listApplicationsMock.mockResolvedValue({ applications });
|
||||
listConnectionsMock.mockResolvedValue({ connections });
|
||||
const client = await renderBrowse();
|
||||
|
||||
for (const slug of googleSlugs) {
|
||||
expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull();
|
||||
expect(container.textContent).not.toContain(`Account for ${slug}`);
|
||||
}
|
||||
expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections });
|
||||
expect(client.getQueryData(queryKeys.tools.applications("company-1"))).toEqual({ applications });
|
||||
expect(archiveConnectionMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["config", "transportConfig"])(
|
||||
"hides a Google account identified by %s even when its gallery entry is absent",
|
||||
async (sourceField) => {
|
||||
listGalleryMock.mockResolvedValue({ apps: [] });
|
||||
listApplicationsMock.mockResolvedValue({ applications: [application({
|
||||
id: "legacy-google", name: "My documents", applicationKey: null, metadata: null,
|
||||
}), application()] });
|
||||
listConnectionsMock.mockResolvedValue({ connections: [connection({
|
||||
id: "legacy-google-account", applicationId: "legacy-google", name: "Saved Google account",
|
||||
config: {}, transportConfig: {}, [sourceField]: { sourceTemplateKey: "google-docs" },
|
||||
}), connection()] });
|
||||
await renderBrowse();
|
||||
|
||||
expect(container.textContent).not.toContain("Saved Google account");
|
||||
expect(container.textContent).toContain("Notion");
|
||||
expect(archiveConnectionMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["catalog", "custom"])(
|
||||
"preserves non-Google accounts in a mixed-provider %s row",
|
||||
async (rowKind) => {
|
||||
const notion = getAppStoreDefinition("notion")!;
|
||||
listGalleryMock.mockResolvedValue({ apps: rowKind === "catalog" ? [notion] : [] });
|
||||
listApplicationsMock.mockResolvedValue({ applications: [application(rowKind === "custom"
|
||||
? { name: "My tools", applicationKey: null, metadata: null }
|
||||
: {})] });
|
||||
const connections = [connection({
|
||||
id: "google-account", name: "Hidden Google account",
|
||||
config: { sourceTemplateKey: "google-docs" },
|
||||
}), connection({
|
||||
id: "notion-account", name: "Visible Notion account",
|
||||
config: { sourceTemplateKey: "notion" },
|
||||
})];
|
||||
listConnectionsMock.mockResolvedValue({ connections });
|
||||
const client = await renderBrowse();
|
||||
|
||||
expect(container.textContent).toContain("Visible Notion account");
|
||||
expect(container.textContent).not.toContain("Hidden Google account");
|
||||
expect(container.textContent).toContain(rowKind === "catalog" ? "Notion" : "My tools");
|
||||
expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections });
|
||||
expect(archiveConnectionMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["metadata", "applicationKey"])(
|
||||
"keeps a non-Google custom connector identified by %s when only its Google accounts are hidden",
|
||||
async (sourceField) => {
|
||||
listGalleryMock.mockResolvedValue({ apps: [] });
|
||||
const savedApplication = application({
|
||||
name: "My custom connector",
|
||||
metadata: sourceField === "metadata" ? { sourceTemplateKey: "custom-provider" } : null,
|
||||
applicationKey: sourceField === "applicationKey" ? "custom-provider" : null,
|
||||
});
|
||||
listApplicationsMock.mockResolvedValue({ applications: [savedApplication] });
|
||||
const connections = [connection({
|
||||
name: "Hidden Google account", config: { sourceTemplateKey: "google-docs" },
|
||||
})];
|
||||
listConnectionsMock.mockResolvedValue({ connections });
|
||||
const client = await renderBrowse();
|
||||
|
||||
expect(container.querySelector('[data-app-slug="custom-provider"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("My custom connector");
|
||||
expect(container.textContent).not.toContain("Hidden Google account");
|
||||
expect(client.getQueryData(queryKeys.tools.applications("company-1"))).toEqual({ applications: [savedApplication] });
|
||||
expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections });
|
||||
expect(archiveConnectionMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("hides cached memory connectors until enabled and preserves saved MCP connections", async () => {
|
||||
const providers = ["mem0", "zep", "supermemory", "cognee", "honcho"];
|
||||
listGalleryMock.mockResolvedValue({ apps: [...providers, "notion"].map(getAppStoreDefinition) });
|
||||
@@ -326,7 +444,6 @@ describe("Connectors landing page", () => {
|
||||
).toEqual([
|
||||
"discord",
|
||||
"github-code-review-bot",
|
||||
"gmail",
|
||||
"imessage-photon",
|
||||
"jira",
|
||||
"microsoft-teams",
|
||||
@@ -338,11 +455,7 @@ describe("Connectors landing page", () => {
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Connect Jira"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Unavailable Gmail"]',
|
||||
)?.disabled,
|
||||
).toBe(true);
|
||||
expect(container.querySelector('[data-app-slug="gmail"]')).toBeNull();
|
||||
expect(container.textContent).toContain("Connect your own tool");
|
||||
|
||||
const customConnect = container.querySelector<HTMLButtonElement>(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getAppStoreDefinition,
|
||||
isToolConnectionAttentionHealth,
|
||||
aiSubscriptionNeedsIsolatedLogin,
|
||||
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
||||
} from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
|
||||
@@ -63,6 +64,7 @@ import { buildCompanyUserProfileMap } from "@/lib/company-members";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import {
|
||||
appApplicationSourceSlug,
|
||||
appConnectionSourceSlug,
|
||||
appDefinitionDarkLogoUrl,
|
||||
appDefinitionDescription,
|
||||
appDefinitionLogoUrl,
|
||||
@@ -111,6 +113,13 @@ type ConnectionRemovalTarget = {
|
||||
|
||||
};
|
||||
|
||||
// Temporary, page-only hold until Google OAuth verification is approved.
|
||||
// Keep definitions, direct setup/management routes, and runtime access intact.
|
||||
// Remove this filter after approval; reviewer instances stay on their pinned build.
|
||||
const GOOGLE_CONNECTOR_SLUGS = new Set(
|
||||
Object.values(GOOGLE_WORKSPACE_CONNECTOR_PROFILES).map((profile) => profile.appSlug),
|
||||
);
|
||||
|
||||
function chatProviderForSlug(slug: string): ChatProvider | null {
|
||||
const method = getAppStoreDefinition(slug)?.methods.find(
|
||||
(candidate) =>
|
||||
@@ -477,8 +486,18 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
|
||||
const customRows: ConnectorRowModel[] = [];
|
||||
for (const application of activeApplications) {
|
||||
const appConnections =
|
||||
const applicationSlug = appApplicationSourceSlug(application);
|
||||
const savedAppConnections =
|
||||
connectionsByApplicationId.get(application.id) ?? [];
|
||||
const appConnections = savedAppConnections.filter(
|
||||
(connection) => !GOOGLE_CONNECTOR_SLUGS.has(appConnectionSourceSlug(connection) ?? ""),
|
||||
);
|
||||
// Hide source-only Google rows, but keep independently identified connectors.
|
||||
if (
|
||||
(!applicationSlug || applicationSlug === "link") &&
|
||||
savedAppConnections.length > 0 &&
|
||||
appConnections.length === 0
|
||||
) continue;
|
||||
const configuredConnectionSlug = appConnections
|
||||
.map(
|
||||
(connection) =>
|
||||
@@ -500,7 +519,6 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
: null,
|
||||
)
|
||||
.find((value): value is string => Boolean(value));
|
||||
const applicationSlug = appApplicationSourceSlug(application);
|
||||
const resolvedSlug =
|
||||
applicationSlug &&
|
||||
applicationSlug !== "link" &&
|
||||
@@ -568,6 +586,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
}
|
||||
|
||||
return [...rowsBySlug.values(), ...customRows]
|
||||
.filter((row) => !GOOGLE_CONNECTOR_SLUGS.has(row.slug))
|
||||
.map((row) => ({
|
||||
...row,
|
||||
connections: [...row.connections].sort(
|
||||
|
||||
Reference in new issue
Block a user