Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 24 additions & 21 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> {
Expand Down
58 changes: 58 additions & 0 deletions src/tests/login-scope-drift.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>(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);
}
});
});
Loading