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:
DottaandPaperclip committed 2026-09-30 19:06:32 -05:00
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/);
});
+97 -9
View File
@@ -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
+46 -4
View File
@@ -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
+3
View File
@@ -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:
+12 -1
View File
@@ -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
+6 -3
View File
@@ -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
+9
View File
@@ -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
+14
View File
@@ -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
+111
View File
@@ -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));
+76 -1
View File
@@ -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;
+10 -3
View File
@@ -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."
+285 -2
View File
@@ -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({
+64 -36
View File
@@ -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}
+36
View File
@@ -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);
});
});
+46
View File
@@ -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);
}
+25
View File
@@ -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");
});
});
+34
View File
@@ -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";
}
+177
View File
@@ -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());
});
});
+30 -2
View File
@@ -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">
+119 -6
View File
@@ -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>(
+21 -2
View File
@@ -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(