From 81948d045cad7f2fe5ba2f5ca0ebf1ee0b24a6d6 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Tue, 25 Aug 2026 17:10:04 +0800 Subject: [PATCH] fix: derive the login scope set from ALL_SCOPES instead of hand-listing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codeoid login` asked ZeroID to mint a hand-maintained literal list of scopes that had drifted from the canonical set in BOTH directions: never minted (7) settings:read, settings:write, fleet:read, pipeline:create, pipeline:read, pipeline:answer, pipeline:manage not real (4) tools:read, tools:write, tools:execute, tools:agent codeoid enforces token scopes verbatim (auth.ts: `scopes: identity.scopes ?? []`) — there is no server-side expansion or default — so a scope absent from the mint request is unreachable for the life of the key. A login token therefore could not open the settings screen, the fleet board, or any pipeline verb. The failure surfaces far from its cause: a bare "Missing scope: settings:read" from a daemon whose config is otherwise correct. Nothing an operator can do fixes it — not re-login, not restarting the daemon, not upgrading ZeroID (which never sees these scopes as anything but opaque strings; its own scope ceilings were empty and it grants what is requested). `ALL_SCOPES` is the single source of truth. `ALL_SCOPES_STRING` is the same join the terminal and TUI clients already use; only the login path hand-rolled its own. The new drift test asserts the derivation structurally, so adding a scope can no longer leave this behind. This is an owner-tier token — it now includes `settings:write` and `pipeline:manage`. That matches the single-operator daemon codeoid targets: the key is minted by, and for, whoever runs it. Delegated access is expressed by exchanging this token DOWN (as the conductor and watcher paths already do), not by under-minting it here. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli.ts | 45 +++++++++++----------- src/tests/login-scope-drift.test.ts | 58 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 21 deletions(-) create mode 100644 src/tests/login-scope-drift.test.ts diff --git a/src/cli.ts b/src/cli.ts index 1f193aa..4b37433 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,6 +31,7 @@ import { removeLocalTokenFile, } from "./daemon/local-auth.js"; import type { CollaborationConfig } from "./protocol/types.js"; +import { ALL_SCOPES_STRING } from "./protocol/scopes.js"; import { TerminalClient } from "./terminal/client.js"; import { getConfigDir, @@ -249,27 +250,29 @@ function printLocalModeBanner(o: { // ── Auth ────────────────────────────────────────────────────────────────────── -/** The full scope set codeoid asks ZeroID to mint for a session-driving key. */ -const CODEOID_LOGIN_SCOPES = [ - "session:create", - "session:list", - "session:attach", - "session:watch", - "session:send", - "session:interrupt", - "session:approve", - "session:destroy", - // Conductor scopes — the owner delegates these to its conductor identity - // (owner → conductor RFC 8693 exchange). Without them in the owner's token - // the delegation's scope intersection is empty and the conductor can't act. - "session:read", - "session:dispatch", - "fs:read", - "tools:read", - "tools:write", - "tools:execute", - "tools:agent", -].join(" "); +/** + * The full scope set codeoid asks ZeroID to mint for a session-driving key. + * + * DERIVED, never hand-listed. This was previously a literal array that drifted + * from the canonical set in both directions: it omitted seven real scopes + * (`settings:read`/`settings:write`, `fleet:read`, and all four `pipeline:*`) + * and requested four that were never scopes at all (`tools:*`). A login token + * therefore could not reach the settings screen, the fleet board, or any + * pipeline verb — which surfaced as a bare "Missing scope: settings:read" on a + * daemon whose config was otherwise fine, and which no amount of re-login or + * ZeroID upgrading could fix. + * + * `ALL_SCOPES` is the single source of truth (`ALL_SCOPES_STRING` is the same + * join, already used by the terminal + TUI clients). `scopes.drift.test.ts` + * fails if a future scope is added and this silently falls behind again. + * + * Note this is an OWNER-tier token: it includes `settings:write` and + * `pipeline:manage`. That matches the single-operator daemon codeoid is built + * for — the key is minted by, and for, the person who runs it. Delegated or + * shared access is expressed by exchanging this token DOWN (the conductor and + * watcher paths already do exactly that), not by under-minting it here. + */ +const CODEOID_LOGIN_SCOPES = ALL_SCOPES_STRING; /** Read a secret from the TTY without echoing it (handles paste + backspace). */ function readSecret(promptText: string): Promise { diff --git a/src/tests/login-scope-drift.test.ts b/src/tests/login-scope-drift.test.ts new file mode 100644 index 0000000..dd45c6a --- /dev/null +++ b/src/tests/login-scope-drift.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ALL_SCOPES, ALL_SCOPES_STRING } from "../protocol/scopes.js"; + +/** + * Guard against the login scope set drifting from the canonical one. + * + * `codeoid login` asks ZeroID to mint a token with a fixed scope string, and + * codeoid then enforces scopes from that token VERBATIM (`auth.ts`: + * `scopes: (identity.scopes ?? [])`) — there is no server-side expansion or + * default. So a scope missing from the mint request is unreachable for the + * life of the key, and the failure surfaces far from its cause: a bare + * "Missing scope: settings:read" from the daemon, on a config that is + * otherwise correct, which neither re-login nor a ZeroID upgrade can fix. + * + * That is exactly what happened: the list was a hand-maintained literal that + * fell behind when `settings:*`, `fleet:read`, and `pipeline:*` were added, + * while also carrying four `tools:*` entries that were never real scopes. + */ +describe("login scope set", () => { + const cli = readFileSync(join(import.meta.dir, "..", "cli.ts"), "utf8"); + + test("is derived from ALL_SCOPES, not hand-listed", () => { + // The literal-array form is what rotted. Assert on the source so the + // regression is caught structurally, not just by value. + expect(cli).toContain("const CODEOID_LOGIN_SCOPES = ALL_SCOPES_STRING;"); + }); + + test("mints every scope the daemon can enforce", () => { + const minted = new Set(ALL_SCOPES_STRING.split(" ")); + const missing = ALL_SCOPES.filter((s) => !minted.has(s)); + expect(missing).toEqual([]); + }); + + test("mints nothing the protocol doesn't define", () => { + // The old list asked for tools:read/write/execute/agent, none of which + // were ever in SCOPES — ZeroID was being sent scopes that meant nothing. + const defined = new Set(ALL_SCOPES); + const unknown = ALL_SCOPES_STRING.split(" ").filter((s) => !defined.has(s)); + expect(unknown).toEqual([]); + }); + + test("covers the scopes whose absence caused the original failure", () => { + const minted = new Set(ALL_SCOPES_STRING.split(" ")); + for (const s of [ + "settings:read", + "settings:write", + "fleet:read", + "pipeline:create", + "pipeline:read", + "pipeline:answer", + "pipeline:manage", + ]) { + expect(minted.has(s)).toBe(true); + } + }); +});