feat(accounts): manage agent accounts on an authenticated self-hosted cockpit - #970
piotrchabros wants to merge 5 commits into
Conversation
… cockpit Agent accounts were gated on `localHandoff`, so every remote deployment — including a self-hosted cockpit behind an authenticated reverse proxy — was refused the whole agent-profiles family and shown "managed from the machine that owns the checkout". Separate "remote UI" from "may manage host-side accounts": `CEZ_REMOTE_AGENT_ACCOUNTS=1` lets an operator who has put the cockpit behind an authenticated perimeter list, add, rename, select, probe, inspect and remove accounts. The default stays off, and desktop-opening actions remain local-only regardless of the flag — Connect still returns the login command to run on the host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🤖 |
|
🤖
|
pat-lewczuk
left a comment
There was a problem hiding this comment.
Code Review: feat(accounts): manage agent accounts on an authenticated self-hosted cockpit
Verdict: Request changes — 2 majors, no blockers.
The split this PR makes is the right one. localHandoff genuinely means "this server can open a desktop app for the person looking at the page", agentAccountsEnabled means "this deployment is trusted to read and write ~/.cezar/agent-accounts.json", and conflating them is what left a self-hosted cockpit permanently single-account with no way forward. Keeping the new predicate off resolveCapabilities, requiring exact 1, and leaving /open and the terminal handoff in /providers/connect on localHandoff are all correct calls, and the PR body argues them honestly.
What holds it up is the other side of the flag. Once it is on, the newly-revealed UI contains affordances that can never succeed and whose error message tells the operator to set the flag they just set (Major 1); and the newly-remote POST /workspace/agent-profiles accepts an unconfined absolute host path whose existence it then reports back, which is the disclosure the hosted browse-root narrowing exists to prevent and which the flag's own documentation does not mention (Major 2).
Major
1. An opted-in remote cockpit renders open-in-app menus that always fail, with an error that misdiagnoses the cause — packages/web/src/routes/settings/accounts-section.tsx:648-687, packages/cezar/src/server/server.ts:319 and :2172
With CEZ_REMOTE_AGENT_ACCOUNTS=1, editable is true, so AccountsPane now renders account rows and expanding one renders AccountDetails with its Config files and Folder menus. Those menus are not gated on localHandoff:
useOpenTargets()→GET /open-targetsreturns{ targets: [] }in remote mode (server.ts:4368), sofileChoicesandfolderChoicesare empty;- but
OpenInMenualways renders its trigger and itsleadingitem (packages/web/src/components/open-in-menu.tsx:128-136), so "System default" is the only item in both menus; - picking it calls
POST /workspace/agent-profiles/:id/open, stilllocalHandoff-gated atserver.ts:2172, which returns the sharedhostedProfileRefusal; errorForprefers the server'sjson.error(packages/web/src/api/client.ts:199-207), soonError: (error) => toast(error.message, …)shows that text verbatim.
The operator therefore sees: "agent accounts are disabled in hosted mode; set CEZ_REMOTE_AGENT_ACCOUNTS=1 only behind an authenticated perimeter" — on a deployment where CEZ_REMOTE_AGENT_ACCOUNTS=1 is already set. It names the wrong cause and prescribes an action that changes nothing; worse, it points a security-conscious operator at a security flag and implies it is not taking effect.
This PR creates the state. Before it, remote mode replaced the whole pane with the hosted copy, so these controls were unreachable. The new test at packages/cezar/src/server/agent-profiles-api.test.ts:748 walks exactly this path but asserts only expect(opened.status).toBe(409), never the body, so the wording slips through.
Two fixes, both small:
- give
/openits own refusal instead of reusinghostedProfileRefusal—/providers/connectalready models the right shape one screen up atserver.ts:1821('Run this command on the machine hosting cezar.'); - hide or disable the two
OpenInMenus whenhealth.capabilities.localHandoffis false, the waypackages/web/src/routes/task-git/task-changes.tsx:214already does.
2. The flag makes an unconfined absolute host path remotely postable, and the response reports whether it exists — packages/cezar/src/server/server.ts:1926-1935 and :1993-2042
checkProfileDir validates only "no control characters" and "absolute after expandTilde". That was sufficient while POST /workspace/agent-profiles was reachable only from the local machine, whose owner can read the filesystem anyway. With the flag on, an authenticated remote client can post any absolute host path and read back what the server learned about it:
agentProfileBody(server.ts:1877-1904) embedsawait profileDirState(provider, profile.path), whichreaddirs the path and returns{ exists, looksValid }(packages/cezar/src/workspace/agent-profiles.ts:141-152);- and
files[], each with apathand anexists(server.ts:1858-1874); - both in the
201body and in every subsequentGET /workspace/agent-profiles.
So POST {"provider":"claude","configDir":"/root/.ssh"} answers whether /root/.ssh is a readable directory, and nothing confines the path to the hosted browse root. That is precisely the disclosure fs-browse's hosted narrowing is built to deny — server.ts:2595-2605 says so in its own words: "an out-of-root path must answer the SAME way whether or not it exists, or the route becomes the existence oracle fs-browse narrows the tree to prevent" — and .env.example documents CEZ_BROWSE_ROOT for exactly "if the remote viewer should not enumerate the whole home". An operator who set both flags gets the second one quietly defeated by the first.
The flag's documentation does not cover this. The README row and the .env.example comment describe disclosure in the outbound direction only ("exposes absolute paths and account identity"); the inbound direction — the caller naming any host path — is not mentioned, so the consent the PR body is careful to obtain is incomplete on the point that matters.
There is a real counterargument, and it may be the right resolution: an authenticated cockpit user can already launch a run with unrestricted Bash on the host (CODE_REVIEW.md:40), so this grants nothing they could not already obtain. If that is the accepted reasoning, state it — in the README row, or as a BACKWARD_COMPATIBILITY.md entry (see Minor 3). A waiver with that rationale written down resolves this finding; what should not ship is the gap left implicit in a change whose entire premise is informed operator consent.
Otherwise: confine checkProfileDir to workspaceBrowseRoot() when !capabilities().localHandoff, reusing isLexicallyInsideBrowseRoot and its fail-identically-on-absent rule.
Minor
3. No BACKWARD_COMPATIBILITY.md entry, unlike every prior capability flag — BACKWARD_COMPATIBILITY.md
CEZ_FOLLOWUPS (§"Follow-up inbox default flip (#471)"), CEZ_SINGLE_PROJECT (§"Single-project workspace mode", :205-226) and CEZ_AUTOMATIONS (§"GitHub automations — opt-in gating (#801)", :228-263) each get an entry documenting activation strictness, exactly which routes change, and non-destructive rollback. CEZ_REMOTE_AGENT_ACCOUNTS changes the conditional answer of seven /api/v1 routes and gets none.
Nothing breaks by default, so this is not the blocker CODE_REVIEW.md:58 describes — but that file is the repo's index of what env flags do to /api/v1, and it is now missing one. The same gap exists in packages/cezar/src/server/capabilities.ts:1-36, whose module doc catalogues localHandoff, followups, singleProject and automations but not the flag whose predicate was just added to that module.
4. agentAccountsEnabled re-derives localHandoff instead of reusing it — packages/cezar/src/server/capabilities.ts:139
return (env.CEZ_REMOTE !== '1' && isLoopbackHost(bindHost)) || env.CEZ_REMOTE_AGENT_ACCOUNTS === '1';is a second spelling of :161's localHandoff: env.CEZ_REMOTE !== '1' && isLoopbackHost(bindHost). Twenty lines below, the same file argues against exactly this: "Deliberately not re-derived here: RunManager enforces the same predicate, and two spellings of 'is the inbox on' would eventually disagree." (:162-163). Same hazard, and the two now have to stay in step through any future change to what "remote" means.
resolveCapabilities(env, bindHost).localHandoff || env.CEZ_REMOTE_AGENT_ACCOUNTS === '1', or a shared isLocalDeployment(env, bindHost) that both call, keeps one spelling.
5. The two highest-consequence changes are the untested ones — packages/cezar/src/server/agent-profiles-api.test.ts
:748-772 covers create → editable: true listing → select → /open 409 under the flag, which is good coverage of the happy path. Not covered:
GET /workspace/agent-profiles/:id/detailsunder the flag — the route that returns the account's signed-in identity, and the disclosure the PR body itself calls "real";warmAgentKnowledge's changed gate (server.ts:1622), where an opted-in remote server now spawns per-account auth probes at boot that it previously did not.
CODE_REVIEW.md:53 asks for tests on changed behavior; these are the two changes where a future regression would be least visible.
Nit
6. Stub line left by a comment rewrap — packages/cezar/src/server/server.ts:1769-1771
// A NAMED account is refused in hosted mode unless the operator opted in, before anything is
// resolved, exactly like every
// sibling route in the agent-profiles family. …
Rewrap the paragraph.
Validation gate
Run on the PR head (f3c380c8) in an isolated worktree, base main.
| Command | Result |
|---|---|
npm run typecheck |
✅ pass — clean across contract, client, server, web |
npm test |
|
npm run test:unit |
✅ pass — 12/12 |
npm run build |
✅ pass — check:pack ok — 481 files, 85 under web/dist |
npm run test:package |
✅ pass |
Note on npm test: two environmental results, neither attributable to this PR.
- A first run showed 6 failures in
health-forge.test.ts/projects-api.test.ts, all asserting that amkdtempdirectory is not a git repository. Artifact of this reviewer's sandbox, which setsTMPDIRinside the checkout (/home/cezar/cezar/.ai/cezar/tmp/…), soos.tmpdir()resolved into the repo and git detection legitimately found it. With a realTMPDIRall six pass. - That clean re-run then failed 1 test in
packages/cezar/src/workflows/run.test.ts, with unhandledENOENTrejections fromRunManager.rescueStalledQueue→RunStore.appendEventwriting to an NDJSON file in a temp run store that teardown had already removed — a teardown race under full-suite parallelism. Re-run in isolation the file passes 94/94. The PR touches neitherworkflows/run.tsnorruns/store.ts, so this is a pre-existing flake rather than a regression; worth a separate issue, not a change here.
Net: every test relevant to this diff passes, including the new capabilities.test.ts and agent-profiles-api.test.ts cases and the pre-existing hosted-mode refusal tests.
CI on the PR: license/cla ✅. No other checks are configured and branch protection is not readable on main, so the table above is the only functional evidence this change has.
Other signals
- No merge conflicts —
mergeable: MERGEABLE. The head is behindorigin/main(e8c95f3avs9ea303eb) but merges cleanly. - No inherited review feedback — no prior reviews, conversation comments, or inline comments on the PR.
- Backward compatibility — no protected surface from
BACKWARD_COMPATIBILITY.md§2 is removed or reshaped. The/api/v1route set is unchanged;agentProfilesResponseSchemais unchanged (only its doc comment moved);editablekeepsz.boolean(). ThehostedProfileRefusalbody text changes, which is not a protected shape. Default behavior with the flag unset is byte-identical — verified by the pre-existing hosted-mode tests still passing.
What is right here, for the record
- The predicate is deliberately not a new
Capabilitiesfield, and the PR explains why rather than leaving it to the reader. - Exact-
1activation matchesCEZ_FOLLOWUPS/CEZ_SINGLE_PROJECT/CEZ_AUTOMATIONS, andcapabilities.test.ts:147-159pins it — including the'true'rejection. /providers/connectkeeps its two-stage gate: the named-account refusal moves to the new predicate, the terminal handoff at:1820stays onlocalHandoff. That is the correct split and it is easy to get wrong.- The listing's single-
returnshape is preserved, with its reasoning intact. warmAgentKnowledgewas not forgotten.
🤖 om-auto-review-pr — reviewed at PR head f3c380c8. Autofix: skipped (not my PR — re-run with --autofix to fix it here).
|
@piotrchabros, two majors to clear before this can go in — full detail in the review:
Three minors (missing The core design — splitting "can open a desktop app" from "may manage host-side accounts" — is right, and the validation gate is green on your head commit. Push the update and re-request review. |
|
🤖 Two majors (dead open-in-app affordances with a misdiagnosing 409 on an opted-in remote cockpit; unconfined remotely-postable Autofix: skipped (not my PR — re-run with |
|
Addressed all review findings in 7689dff:
Validation: 207 focused tests pass; workspace typecheck, unit tests, build/check:pack, and package tests pass. The full suite reached 6394 passing with only the already-documented ambient root/env failures and queue teardown race outside this diff. |
# Conflicts: # packages/cezar/src/server/capabilities.ts
|
🤖 |
pat-lewczuk
left a comment
There was a problem hiding this comment.
Re-review: feat(accounts): manage agent accounts on an authenticated self-hosted cockpit
Verdict: Request changes — 2 blockers, no majors.
All six findings from the previous review were addressed, and Major 1 is fully closed: the two OpenInMenus are gated on localHandoff (accounts-section.tsx:652), /open got its own refusal (hostedProfileOpenRefusal, server.ts:2194), and both sides have tests. localHandoffEnabled is now one spelling shared by localHandoff and agentAccountsEnabled (capabilities.ts:155), the BACKWARD_COMPATIBILITY.md entry is there with an honest rollback paragraph, and the two untested paths — /details and warmAgentKnowledge under the flag — are covered.
Major 2 is where it stops. The confinement that was added implements the lexical half of the repo's own two-half containment rule and not the realpath half, so a symlink under the browse root walks straight out of it — I reproduced it, below. That reinstates exactly the existence oracle the fix was for, and makes the new BACKWARD_COMPATIBILITY.md sentence "an outside path is rejected before its existence is probed" untrue as written.
The second blocker is not yours: npm test is red on this head because it is red on main.
⛔ Blocker
1. The browse-root confinement is only half applied — a symlink under the root escapes it — packages/cezar/src/server/server.ts:1947-1955
checkProfileDir calls isLexicallyInsideBrowseRoot and stops there. fs-browse.ts:101-111 says in its own words why that is only half the gate:
This split lets the register route reject out-of-root paths UNIFORMLY (the existence oracle stays shut …), then answer honestly about existence, then still catch symlink escapes with the realpath gate.
and the sibling route — POST /api/projects, the one other place that re-asks containment — applies both, in that order, with the reason spelled out at server.ts:2645-2648:
The REALPATH half, now that the path is known to exist: a symlink inside the root pointing out of it spells as contained and is not. Same message as the lexical rejection, so the two halves stay indistinguishable from outside.
Verified on this head (917fd587), remote + CEZ_REMOTE_AGENT_ACCOUNTS=1, CEZ_BROWSE_ROOT set to a fixture home, with a throwaway vitest case driving the real route:
configDir posted |
symlink target | response |
|---|---|---|
<root>/link-to-outside |
an existing dir outside the root | 201, exists: true, files[].path rooted at the link |
<root>/link-to-missing |
an absent path outside the root | 201, exists: false |
Two requests that spell identically-in-root, two different answers about the world outside the root. Three consequences:
- the existence oracle is back for anything reachable through a symlink under the root —
profileDirStatereaddirs the path (workspace/agent-profiles.ts:141-152) andaccountFilesstats each file under it, and both land in the201body and in every laterGET /workspace/agent-profiles; - the escaped path is persisted and becomes the account's
path, whichprofileEnvhands to the agent as its config dir — so a run reads and writes outside the root, not merely probes it; BACKWARD_COMPATIBILITY.md's new "a remotely posted account folder must be insideCEZ_BROWSE_ROOT; an outside path is rejected before its existence is probed" is a guarantee the code does not keep. A compatibility doc that overstates a confinement is worse than one that says nothing.
Fix — mirror server.ts:2643-2654 inside checkProfileDir: keep the lexical gate first (it is what keeps the answer uniform for a path that is not there), then, once the candidate is known to exist, re-ask with isInsideBrowseRoot(root, expanded) and return the same message on failure so the two halves stay indistinguishable. Tests to add: a symlink under the root pointing out (both a live and a dead target, asserting identical responses), and the same case through PATCH /workspace/agent-profiles/:id, which shares checkProfileDir and has no confinement test at all today.
2. The validation gate is red — npm test fails 1 of 6600 — packages/cezar/src/workflows/agent-profile-wiring.test.ts:82
FAIL |server| RunManager agent-profile resolution >
adds NOTHING for the default account — the zero-config env is untouched
AssertionError: expected [ 'CEZ_API_URL', 'CEZ_BIN', …(6) ] to deeply equal [ 'CEZ_HANDOFF_FILE', …(5) ]
+ "CEZ_API_URL"
+ "CEZ_BIN"
This is not your change. It reproduces deterministically on af7e8289 itself — the main commit this branch merged, #972 feat(dispatch) — which added CEZ_API_URL and CEZ_BIN to the base run env without updating the assertion that exists to pin that env. main has been red since.
It is still a blocker here, because the gate rule takes no "pre-existing" exception: this head fails, and nothing on the PR catches it (license/cla is the only check and main is not branch-protected). The fix is one line — add both keys to the expected array with a note that dispatch owns them — landed either on main and merged forward, or here. Your call which; a maintainer waiver on the grounds that main owns it would also clear this finding.
Minor
3. The new localHandoff derivation fails open, against the rule the rest of the web package follows — packages/web/src/routes/settings/accounts-section.tsx:603
const localHandoff = health.data?.capabilities?.localHandoff !== falseUnknown ⇒ local. Every other consumer treats unknown as hosted, and packages/web/src/lib/git-actions.ts:29 states it as the rule: "False (or unknown) = hosted mode". task-changes.tsx:112 spells it ?? false; queries.ts:688 spells it === true.
The comment justifies the choice by "an older server response", but that case and "health has not resolved yet" are the same expression here, and only one of them is old-server compatibility. On a cold load of Settings → Agent accounts on a remote cockpit, the Config files and Folder menus render until useHealth settles and then disappear — a flash of the exact affordance Major 1 removed. Server-side nothing is at risk; this is UI honesty and consistency.
Fix: branch on health.data first, and apply the old-server fallback only once data has actually arrived — health.data ? (health.data.capabilities?.localHandoff ?? true) : false.
4. The out-of-root rejection names the absolute browse root; the sibling route deliberately does not — packages/cezar/src/server/server.ts:1953
return `folder must be inside the browsable root: ${root}`;server.ts:2632-2637 refuses to do this on purpose: "No resolved path in the message (fs-browse's rule): saying where the root is would hand a remote viewer the layout the narrowing hides." The practical exposure is small — a caller who reached this line is already opted in and already sees absolute paths in the listing — but it is the same route family answering the same question two different ways, and the narrow one is the documented one. Use 'folder is outside the browsable root', which also gives Blocker 1's realpath half the identical string it needs.
Nit
5. The rewrapped comment still has a stub line — packages/cezar/src/server/server.ts:1782-1784
// A NAMED account is refused in hosted mode unless the operator opted in. This happens
// before resolution, exactly like every sibling route in the agent-profiles family;
// checking later would already have read
// `~/.cezar/agent-accounts.json`, built a command carrying …
Line three is still short. Rewrap the paragraph as a whole.
🧪 Validation Gate
Run on the PR head (917fd587) in an isolated worktree, base main, with TMPDIR outside the checkout.
| Command | Status | Evidence |
|---|---|---|
npm run typecheck |
✅ PASS | clean across contract, client, server, web; inline-contract ok — 19 file(s) repointed |
npm test |
❌ FAIL | Test Files 1 failed | 346 passed (347), Tests 1 failed | 6599 passed (6600) — agent-profile-wiring.test.ts:82; Blocker 2 |
npm run test:unit |
✅ PASS | pass 35, fail 0, skipped 1 (the skip is setsid is not available on this platform) |
npm run build |
✅ PASS | check:pack ok — 497 files, 85 under web/dist (shell + assets present) |
npm run test:package |
✅ PASS | tests 16, pass 16, fail 0 |
The one failure is isolated and attributed: it reproduces alone (vitest run packages/cezar/src/workflows/agent-profile-wiring.test.ts → 1 failed | 6 passed) and reproduces identically on af7e8289 with this PR's work absent. Everything this diff touches is green, including the new capabilities.test.ts cases (exact-1, 'true' rejected, bind-host variants), the opted-in create/select/open/details walk, the out-of-root rejection pair, and the it.each boot-warm test in both modes.
Last review's workflows/run.test.ts teardown flake did not recur.
Other signals
- No merge conflicts —
mergeable: MERGEABLE.mergeStateStatus: BLOCKEDreflects the standing changes-requested review, not a conflict. - CI —
license/cla✅ is the only check;mainis not branch-protected, so it is treated as the required set. Nothing failing, nothing pending. - No inherited reviewer feedback — the only prior review is this reviewer's own, re-checked above; the author's status comment carries no unaddressed ask.
- Not duplicated work —
agentAccountsEnableddoes not exist onorigin/main; no parallel PR landed this.
💥 Breaking changes
No protected surface from BACKWARD_COMPATIBILITY.md §2 is removed or reshaped: the /api/v1 route set is unchanged, agentProfilesResponseSchema keeps editable: z.boolean(), and behavior with the flag unset is unchanged (the pre-existing hosted-mode tests still pass untouched in substance). The hosted refusal text changed on the agent-profiles family and /open gained a distinct one; error strings are not a protected shape here and the only in-repo consumers — the toast and the tests — were updated. editable's meaning widens from "local machine" to "local machine or opted in", which is additive for every existing client.
One thing the new BACKWARD_COMPATIBILITY.md entry should gain alongside the Blocker 1 fix: the default browseRoot is ~/ (workspace/config.ts:184), so an operator who sets CEZ_REMOTE_AGENT_ACCOUNTS=1 without also narrowing CEZ_BROWSE_ROOT is confined to their whole home directory. That is the same posture /api/fs/browse has and the .env.example comment already advises narrowing — but the entry currently reads as if the confinement is meaningful by default, and it is worth one clause saying what it is worth when the root is unset.
🤖 om-auto-review-pr — re-reviewed at PR head 917fd587 (previous review: f3c380c8). Autofix: skipped (not my PR — re-run with --autofix to fix it here).
|
@piotrchabros, all six earlier findings are closed — the open-in menus, the shared
Two minors (the Push the update and re-request review. |
|
🤖 Re-review of Validation on Autofix: skipped (not my PR — re-run with |
|
@pat-lewczuk addressed the re-review findings in 7d51d0a:
Validation on the pushed head: typecheck passed; Vitest 347 files passed, 6,604 tests passed, 1 skipped, no unhandled errors; unit 36/36; build/check:pack passed; package 16/16. The full suite ran with inherited CEZ_* deployment variables cleared, Linux DAC permission bypass disabled for permission tests, and four workers. The new symlink and unknown-health regressions were confirmed red before the fixes. Ready for another review. |
Summary
CEZ_REMOTE_AGENT_ACCOUNTS=1(exact1), which re-enables listing, adding, renaming, selecting, status-probing, details and removal of agent accounts in remote modeREADME.mdand.env.exampleProblem
Every route in the agent-profiles family was gated on
capabilities().localHandoff, which is false for any non-loopback bind orCEZ_REMOTE=1. That conflates two different questions: "can this server open a desktop app for the person looking at the page?" (genuinely no on a VPS) and "is this deployment trusted enough to read and write~/.cezar/agent-accounts.json?" (a deployment behind authenticated Caddy/nginx can be). The result was a self-hosted cockpit that could never use more than one Claude/Codex account, with the UI offering no way forward.Approach
agentAccountsEnabled(env, bindHost)incapabilities.tsis deliberately a separate predicate rather than a new field onresolveCapabilities:localHandoffstill means "this server can open a desktop app", and that answer does not change. The flag requires exact1, matching the other opt-ins in this codebase, so a straytrueor0cannot silently widen the surface.The disclosure this guards is real — account folders are absolute host paths carrying the username, and the details route can expose account identity — so the flag is off by default and both the README row and the
.env.examplecomment say plainly that it belongs only behind an authenticated perimeter.warmAgentKnowledgenow uses the same predicate, so an opted-in remote cockpit warms per-account auth status instead of only the defaults.Verification
NODE_ENV=test npx vitest run packages/cezar/src/server/capabilities.test.ts packages/cezar/src/server/agent-profiles-api.test.ts packages/web/src/routes/settings/accounts-section.test.tsx— 204 tests passednpm run typecheck— clean across the workspaceagentAccountsEnabledaccepts only exact1; an opted-in remote server can create an account, have it appear in aneditable: truelisting, and select it, whilePOST .../openstill returns 409editable: trueand the multi-account controls appear in Settings🤖 Generated with Claude Code