From 67d0fd012580905c3dffdcaaed5c5b049c1fa72c Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 12:53:22 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20workstreams=20W1=20=E2=80=94=20na?= =?UTF-8?q?med=20credentials=20behind=20a=20pluggable=20secret=20store;=20?= =?UTF-8?q?a=20home's=20env=20and=20git=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - credentials[] declares a credential's id and kind (bearer, header, env, oauth) — never its value; every reference across the config is checked at load (declared once, of a kind that fits) - credentialStore picks where values live: env-file (CODEOID_CRED_, kept out of every agent subprocess by the CODEOID_ prefix) or dir (one file per id: a mounted Kubernetes Secret, a Vault sink; read-only, non-blocking, registered as a secret location) - a session gets its home workstream's env (values read from the store when it is built) and git identity; GIT_AUTHOR_*/GIT_COMMITTER_* pass the extra-env GIT_ deny, nothing else does; a workstream env name codeoid owns for the backend fails the config load Co-Authored-By: Claude Opus 5.5 --- src/config.ts | 15 +++ src/daemon/credentials/credentials.test.ts | 77 ++++++++++++++ src/daemon/credentials/schema.ts | 91 +++++++++++++++++ src/daemon/credentials/store.ts | 111 +++++++++++++++++++++ src/daemon/providers/env.ts | 8 ++ src/daemon/session-manager.ts | 34 ++++++- src/daemon/session.ts | 15 ++- src/daemon/workstreams/schema.ts | 29 +++++- src/daemon/workstreams/service.ts | 27 +++++ src/daemon/workstreams/workstreams.test.ts | 19 ++++ src/tests/provider-env.test.ts | 9 ++ src/tests/workstreams-manager.test.ts | 58 ++++++++++- 12 files changed, 480 insertions(+), 13 deletions(-) create mode 100644 src/daemon/credentials/credentials.test.ts create mode 100644 src/daemon/credentials/schema.ts create mode 100644 src/daemon/credentials/store.ts diff --git a/src/config.ts b/src/config.ts index 96999108..c0213a70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ import { validAllowedHost } from "./daemon/sandbox/hosts.js"; import { PROJECT_ROOT_PARENT, projectRootProblem } from "./daemon/sandbox/dir-rules.js"; import { worktreeRootProblem } from "./daemon/sandbox/worktrees.js"; import { workstreamsSchema, type WorkstreamsConfig } from "./daemon/workstreams/schema.js"; +import { credentialProblems, credentialsSchema, secretStoreSchema, type CredentialDecl, type SecretStoreConfig } from "./daemon/credentials/schema.js"; import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync, rmSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { join, isAbsolute, resolve, dirname } from "node:path"; @@ -1373,6 +1374,10 @@ const RootSchema = z.object({ mcpServers: McpServersSchema, /** Named lines of work (docs/workstreams-design.md). */ workstreams: workstreamsSchema, + /** Named credentials: ids and kinds only, never values (workstreams G2). */ + credentials: credentialsSchema, + /** Where credential values live (workstreams G2). */ + credentialStore: secretStoreSchema, hooks: HooksSchema, embed: EmbedSchema, push: PushSchema, @@ -1428,6 +1433,9 @@ const RootSchema = z.object({ .default({ runCommands: "never", trustedRepos: [] }), }) .default({ setup: { runCommands: "never", trustedRepos: [] } }), +}).superRefine((cfg, ctx) => { + // Credential references across sections (workstreams G2): declared once, of a kind that fits. + for (const p of credentialProblems(cfg)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: p.message, path: p.path }); }); type ParsedConfig = z.infer; @@ -1749,6 +1757,10 @@ export interface CodeoidConfig { * (schema default: none, isolation off). */ workstreams?: WorkstreamsConfig; + /** Named credentials (ids and kinds). Optional in the type; loadConfig always fills it. */ + credentials?: CredentialDecl[]; + /** Where credential values live; `dir` paths absolute once loaded. */ + credentialStore?: SecretStoreConfig; /** * Daemon-native hooks — dispatched at Session's seams for every backend. * Optional in the type so hand-built test configs stay minimal; @@ -2133,6 +2145,9 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig { mcpServers: parsed.mcpServers, mcpOAuth: parsed.mcpOAuth, workstreams: parsed.workstreams, + credentials: parsed.credentials, + credentialStore: + parsed.credentialStore.kind === "dir" ? { kind: "dir", path: configRelResolve(parsed.credentialStore.path) } : parsed.credentialStore, hooks: parsed.hooks, embed: parsed.embed, push: parsed.push, diff --git a/src/daemon/credentials/credentials.test.ts b/src/daemon/credentials/credentials.test.ts new file mode 100644 index 00000000..abc4d9d6 --- /dev/null +++ b/src/daemon/credentials/credentials.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { credentialProblems, credentialsSchema, secretStoreSchema } from "./schema.js"; +import { createSecretStore, credentialEnvName, DirSecretStore, EnvFileSecretStore } from "./store.js"; + +describe("credential declarations", () => { + test("kinds and their fields", () => { + expect(credentialsSchema.safeParse([{ id: "gh-a", kind: "bearer" }, { id: "hf", kind: "header", header: "X-Highflame-APIKey" }, { id: "tok", kind: "env" }, { id: "slack-a", kind: "oauth", server: "slack" }]).success).toBe(true); + expect(credentialsSchema.safeParse([{ id: "h", kind: "header" }]).success).toBe(false); + expect(credentialsSchema.safeParse([{ id: "x", kind: "bearer", value: "inline" }]).success).toBe(false); + expect(credentialsSchema.safeParse([{ id: "Bad", kind: "env" }]).success).toBe(false); + }); + + test("references across the config: declared once, of a kind that fits", () => { + const ws = (env: Record, mcpServers: unknown[] = []) => ({ workstreams: { items: [{ id: "w", env, mcpServers: mcpServers as never }] } }); + const msgs = (cfg: Parameters[0]) => credentialProblems(cfg).map((p) => p.message).join("\n"); + expect(msgs({ credentials: [{ id: "a", kind: "env" }, { id: "a", kind: "env" }], ...ws({}) })).toMatch(/duplicate credential id "a"/); + expect(msgs({ credentials: [], ...ws({ GH_TOKEN: { secret: "nope" } }) })).toMatch(/undeclared credential "nope"/); + expect(msgs({ credentials: [{ id: "b", kind: "bearer" }], ...ws({ GH_TOKEN: { secret: "b" } }) })).toMatch(/is a bearer credential, not env/); + expect(msgs({ credentials: [{ id: "e", kind: "env" }], ...ws({}, [{ server: "github", credential: "e" }]) })).toMatch(/is an env credential/); + expect(msgs({ credentials: [{ id: "o", kind: "oauth", server: "slack" }], ...ws({}, [{ server: "github", credential: "o" }]) })).toMatch(/is for server "slack"/); + expect(credentialProblems({ credentials: [{ id: "o", kind: "oauth", server: "slack" }, { id: "e", kind: "env" }], ...ws({ T: { secret: "e" } }, ["web", { server: "+slack", credential: "o" }]) })).toEqual([]); + }); + + test("the store defaults to env-file", () => { + expect(secretStoreSchema.parse(undefined)).toEqual({ kind: "env-file" }); + }); +}); + +describe("EnvFileSecretStore", () => { + test("reads CODEOID_CRED_, which no agent subprocess ever inherits", () => { + expect(credentialEnvName("gh-saucam")).toBe("CODEOID_CRED_GH_SAUCAM"); + const s = new EnvFileSecretStore({ CODEOID_CRED_GH_SAUCAM: "ghp_x", CODEOID_CRED_EMPTY: "" }); + expect(s.get("gh-saucam")).toBe("ghp_x"); + expect(s.get("empty")).toBeUndefined(); + expect(s.status("gh-saucam")).toEqual({ set: true, source: "CODEOID_CRED_GH_SAUCAM" }); + expect(s.status("nope")).toEqual({ set: false }); + expect(s.writable).toBe(true); + }); +}); + +describe("DirSecretStore", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "codeoid-secrets-")); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + test("one file per id, trailing newline dropped, read-only", () => { + writeFileSync(join(dir, "gh-a"), "tok-a\n"); + const s = new DirSecretStore(dir); + expect(s.get("gh-a")).toBe("tok-a"); + expect(s.get("missing")).toBeUndefined(); + expect(s.get("../etc")).toBeUndefined(); + expect(s.writable).toBe(false); + }); + + test("follows a Kubernetes-style key link; a FIFO never blocks", () => { + mkdirSync(join(dir, "..data")); + writeFileSync(join(dir, "..data", "slack"), "xoxb"); + symlinkSync(join(dir, "..data", "slack"), join(dir, "slack")); + execFileSync("mkfifo", [join(dir, "fifo")]); + const s = new DirSecretStore(dir); + expect(s.get("slack")).toBe("xoxb"); + const t = Date.now(); + expect(s.get("fifo")).toBeUndefined(); + expect(Date.now() - t).toBeLessThan(1000); + }); + + test("createSecretStore picks by config", () => { + expect(createSecretStore({ kind: "dir", path: dir }).kind).toBe("dir"); + expect(createSecretStore(undefined, {}).kind).toBe("env-file"); + }); +}); diff --git a/src/daemon/credentials/schema.ts b/src/daemon/credentials/schema.ts new file mode 100644 index 00000000..d5e795c3 --- /dev/null +++ b/src/daemon/credentials/schema.ts @@ -0,0 +1,91 @@ +/** + * Named credentials (docs/workstreams-design.md G2). + * + * `credentials[]` in config.json declares a credential: its id and what kind + * of secret it is. Never the value — values live behind a SecretStore + * (store.ts), so the config stays safe to show, diff and send to a satellite. + * + * Imports zod alone (config.ts imports this). + */ + +import { z } from "zod"; + +const ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; + +export const credentialIdSchema = z + .string() + .regex(ID, "a credential id is lowercase letters, digits and dashes (1–64), not starting or ending with a dash"); + +export const credentialDeclSchema = z.discriminatedUnion("kind", [ + /** A bearer token sent as `Authorization: Bearer …` to an MCP server. */ + z.object({ kind: z.literal("bearer"), id: credentialIdSchema, description: z.string().max(256).optional() }).strict(), + /** A value sent in a named header to an MCP server (e.g. X-Api-Key). */ + z + .object({ + kind: z.literal("header"), + id: credentialIdSchema, + header: z.string().regex(/^[A-Za-z0-9-]{1,128}$/, "a header name"), + description: z.string().max(256).optional(), + }) + .strict(), + /** A value a workstream puts in its sessions' environment (GH_TOKEN, …). */ + z.object({ kind: z.literal("env"), id: credentialIdSchema, description: z.string().max(256).optional() }).strict(), + /** A separate OAuth sign-in to one server, a slot beside its default one (§6.5 case 2). */ + z + .object({ kind: z.literal("oauth"), id: credentialIdSchema, server: z.string().min(1).max(128), description: z.string().max(256).optional() }) + .strict(), +]); + +export const credentialsSchema = z.array(credentialDeclSchema).max(512).default([]); + +/** Where secret values are kept. Pluggable by deployment (G2). */ +export const secretStoreSchema = z + .discriminatedUnion("kind", [ + /** `CODEOID_CRED_` in the daemon's environment / `~/.codeoid/.env` (default; writable). */ + z.object({ kind: z.literal("env-file") }).strict(), + /** + * One file per credential in a directory, named by id — a mounted + * Kubernetes Secret, a Vault agent's sink, a password manager's export. + * Read-only: codeoid never writes there. + */ + z.object({ kind: z.literal("dir"), path: z.string().min(1).max(4096) }).strict(), + ]) + .default({ kind: "env-file" }); + +export type CredentialDecl = z.infer; +export type SecretStoreConfig = z.infer; + +/** + * What every credential reference in the config must agree with: declared + * once, and of a kind that fits where it is used. A workstream env var takes + * an `env` credential; a server slot takes `bearer`, `header` or `oauth`, and + * an `oauth` one only for the server it was declared for. + */ +export function credentialProblems(cfg: { + credentials: readonly CredentialDecl[]; + workstreams: { items: ReadonlyArray<{ id: string; env: Record; mcpServers: ReadonlyArray }> }; +}): Array<{ message: string; path: (string | number)[] }> { + const problems: Array<{ message: string; path: (string | number)[] }> = []; + const byId = new Map(); + cfg.credentials.forEach((c, i) => { + if (byId.has(c.id)) problems.push({ message: `duplicate credential id "${c.id}"`, path: ["credentials", i, "id"] }); + else byId.set(c.id, c); + }); + cfg.workstreams.items.forEach((w, i) => { + for (const [name, ref] of Object.entries(w.env)) { + const c = byId.get(ref.secret); + if (!c) problems.push({ message: `workstream "${w.id}" env ${name} names undeclared credential "${ref.secret}"`, path: ["workstreams", "items", i, "env", name] }); + else if (c.kind !== "env") problems.push({ message: `workstream "${w.id}" env ${name}: credential "${c.id}" is a ${c.kind} credential, not env`, path: ["workstreams", "items", i, "env", name] }); + } + w.mcpServers.forEach((ref, j) => { + if (typeof ref === "string" || !ref.credential) return; + const server = ref.server.startsWith("+") ? ref.server.slice(1) : ref.server; + const c = byId.get(ref.credential); + const at = ["workstreams", "items", i, "mcpServers", j]; + if (!c) problems.push({ message: `workstream "${w.id}" server ${server} names undeclared credential "${ref.credential}"`, path: at }); + else if (c.kind === "env") problems.push({ message: `workstream "${w.id}" server ${server}: credential "${c.id}" is an env credential`, path: at }); + else if (c.kind === "oauth" && c.server !== server) problems.push({ message: `workstream "${w.id}" server ${server}: oauth credential "${c.id}" is for server "${c.server}"`, path: at }); + }); + }); + return problems; +} diff --git a/src/daemon/credentials/store.ts b/src/daemon/credentials/store.ts new file mode 100644 index 00000000..35b3bc7c --- /dev/null +++ b/src/daemon/credentials/store.ts @@ -0,0 +1,111 @@ +/** + * Secret stores (docs/workstreams-design.md G2): where a named credential's + * VALUE lives. The rest of the daemon asks a SecretStore by id and never + * knows which kind it is, so a deployment picks one in config + * (`credentialStore`) without code changes: + * + * - `env-file` (default): `CODEOID_CRED_` in the daemon's environment, + * which `~/.codeoid/.env` (0600) feeds. The `CODEOID_` prefix is what + * keeps every value out of every agent subprocess (providers/env.ts + * DENY_PREFIXES) — the guarantee G1 rests on, for free. + * - `dir`: one file per credential, named by id, in a directory — a mounted + * Kubernetes Secret, a Vault agent sink. Read-only. + * + * Values are read when used (a backend spawn, an upstream request), never + * cached here, so a rotated secret applies at the next use. + * + * OAuth tokens are not here: they change on every refresh and have their own + * store (mcp/oauth.ts, keyed by credential slot). + */ + +import { closeSync, constants as fsc, fstatSync, openSync, readSync } from "node:fs"; +import { join } from "node:path"; +import type { SecretStoreConfig } from "./schema.js"; + +/** Largest secret value read from a file. */ +const MAX_SECRET_BYTES = 64 * 1024; + +export type SecretStatus = { set: true; source: string } | { set: false }; + +export interface SecretStore { + /** Which implementation, for display ("env-file", "dir"). */ + readonly kind: string; + /** Whether codeoid may write values (the UI offers an edit box only then). */ + readonly writable: boolean; + /** The value, or undefined when it isn't set. */ + get(id: string): string | undefined; + /** Whether it is set, and where from — never the value. */ + status(id: string): SecretStatus; +} + +/** The env var an id's value lives in: `gh-saucam` → `CODEOID_CRED_GH_SAUCAM`. */ +export function credentialEnvName(id: string): string { + return `CODEOID_CRED_${id.toUpperCase().replace(/-/g, "_")}`; +} + +export class EnvFileSecretStore implements SecretStore { + readonly kind = "env-file"; + readonly writable = true; + readonly #env: Record; + + constructor(env: Record = process.env) { + this.#env = env; + } + + get(id: string): string | undefined { + const v = this.#env[credentialEnvName(id)]; + return v !== undefined && v.length > 0 ? v : undefined; + } + + status(id: string): SecretStatus { + return this.get(id) !== undefined ? { set: true, source: credentialEnvName(id) } : { set: false }; + } +} + +export class DirSecretStore implements SecretStore { + readonly kind = "dir"; + readonly writable = false; + readonly #dir: string; + + constructor(dir: string) { + this.#dir = dir; + } + + get(id: string): string | undefined { + // Ids are validated (lowercase, digits, dashes), so `id` is one path segment. + if (!/^[a-z0-9-]{1,64}$/.test(id)) return undefined; + let fd: number | undefined; + try { + // Non-blocking and no final symlink-follow, as every small read here: + // a FIFO must not hang the daemon. Kubernetes mounts a Secret's keys as + // links into a timestamped dir, so a final link is followed for this + // store only — the dir is the operator's, not an agent's. + fd = openSync(join(this.#dir, id), fsc.O_RDONLY | fsc.O_NONBLOCK); + const st = fstatSync(fd); + if (!st.isFile() || st.size > MAX_SECRET_BYTES) return undefined; + const buf = Buffer.alloc(st.size); + let off = 0; + while (off < st.size) { + const n = readSync(fd, buf, off, st.size - off, off); + if (n <= 0) break; + off += n; + } + // A trailing newline is how files are written, never part of a token. + const value = buf.subarray(0, off).toString("utf8").replace(/\r?\n$/, ""); + return value.length > 0 ? value : undefined; + } catch { + return undefined; + } finally { + if (fd !== undefined) closeSync(fd); + } + } + + status(id: string): SecretStatus { + return this.get(id) !== undefined ? { set: true, source: join(this.#dir, id) } : { set: false }; + } +} + +export function createSecretStore(cfg: SecretStoreConfig | undefined, env: Record = process.env): SecretStore { + if (cfg?.kind === "dir") return new DirSecretStore(cfg.path); + return new EnvFileSecretStore(env); +} diff --git a/src/daemon/providers/env.ts b/src/daemon/providers/env.ts index 7ddd13fa..1d7e4791 100644 --- a/src/daemon/providers/env.ts +++ b/src/daemon/providers/env.ts @@ -144,8 +144,16 @@ const EXTRA_ENV_DENY_SUFFIXES = ["_API_KEY", "_API_TOKEN", "_AUTH_TOKEN", "_ACCE */ const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/; +/** + * Git's identity variables, let through the `GIT_` deny: a name and an email + * run nothing (unlike GIT_SSH_COMMAND or GIT_CONFIG_*), and a workstream + * sets them so its commits carry its identity (docs/workstreams-design.md §6.2). + */ +export const GIT_IDENTITY_ENV: ReadonlySet = new Set(["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"]); + function extraEnvRefused(name: string, value: unknown): boolean { if (!ENV_NAME.test(name) || typeof value !== "string" || value.includes("\0")) return true; + if (GIT_IDENTITY_ENV.has(name)) return value.includes("\n"); return ( EXTRA_ENV_DENY_EXACT.has(name) || EXTRA_ENV_DENY_PREFIXES.some((p) => name.startsWith(p)) || diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 9e243570..5178e1c9 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -11,6 +11,8 @@ import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js"; import { WorkstreamService, type WorkstreamBinding } from "./workstreams/service.js"; import type { FolderReader } from "./workstreams/folder.js"; +import { createSecretStore, type SecretStore } from "./credentials/store.js"; +import { registerSecretPaths } from "./secret-paths.js"; import { type RequestDirectoryInput, type SandboxMcpMount, SandboxMcpHttp } from "./sandbox/request-directory.js"; import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; @@ -578,6 +580,8 @@ export class SessionManager { #config?: CodeoidConfig; /** Workstreams (docs/workstreams-design.md): decides a session's home; the session keeps it. */ #workstreams: WorkstreamService; + /** Where credential values live (workstreams G2): read when used, never cached. */ + #secrets: SecretStore; /** * `config.worktree.setup`, live: `codeoid worktree trust` changes it * without a restart (and writes it through to config.json). @@ -724,6 +728,8 @@ export class SessionManager { sandboxRegistry?: SandboxRegistry; /** Test-only: how folders are read for workstream matching (a fake home, an ssh config). */ _testFolderReader?: FolderReader; + /** Test-only: where credential values come from. */ + _testSecrets?: SecretStore; }, ) { this.#store = store; @@ -734,6 +740,11 @@ export class SessionManager { this.#memory = memory; this.#config = opts?.config; this.#workstreams = new WorkstreamService(opts?.config?.workstreams, opts?._testFolderReader); + this.#secrets = opts?._testSecrets ?? createSecretStore(opts?.config?.credentialStore); + // A `dir` store holds every credential's value in plain files: refused to + // every agent's file tools and hidden from every sandbox, as codeoid's own + // secrets are (secret-paths.ts). + if (opts?.config?.credentialStore?.kind === "dir") registerSecretPaths({ dirs: [opts.config.credentialStore.path] }); this.#sandboxRegistry = opts?.sandboxRegistry; if (opts?.config?.worktree?.setup) this.#worktreeSetupPolicy = opts.config.worktree.setup; this.#compressionRegistry = opts?.compressionRegistry; @@ -2290,9 +2301,26 @@ mcpHub: this.#mcpHub, /** Renders a binding for SessionInfo against the live config. */ readonly #describeWorkstream = (binding: WorkstreamBinding | undefined) => this.#workstreams.info(binding); - /** The workstream options every Session this manager builds gets (docs/workstreams-design.md). */ - #workstreamOpts(binding: WorkstreamBinding | undefined): { workstream?: WorkstreamBinding; describeWorkstream: (b: WorkstreamBinding | undefined) => ReturnType } { - return { ...(binding ? { workstream: binding } : {}), describeWorkstream: this.#describeWorkstream }; + /** + * The workstream options every Session this manager builds gets + * (docs/workstreams-design.md): the binding, how to show it, and the home's + * environment (§6.2), its credential values read now. A credential with no + * value is left out and logged: the session starts without it. + */ + #workstreamOpts(binding: WorkstreamBinding | undefined): { + workstream?: WorkstreamBinding; + describeWorkstream: (b: WorkstreamBinding | undefined) => ReturnType; + workstreamEnv?: Readonly>; + } { + const { env, missing } = this.#workstreams.envFor(binding, this.#secrets); + if (missing.length > 0) { + console.warn(`[codeoid] workstream "${binding?.id}": no value for ${missing.join(", ")} — set the credential (${this.#secrets.kind} store); the session starts without it`); + } + return { + ...(binding ? { workstream: binding } : {}), + describeWorkstream: this.#describeWorkstream, + ...(Object.keys(env).length > 0 ? { workstreamEnv: env } : {}), + }; } /** diff --git a/src/daemon/session.ts b/src/daemon/session.ts index f67114d0..a1b49150 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -557,6 +557,14 @@ export interface SessionCreateOptions { workstream?: WorkstreamBinding; /** Renders the binding for `SessionInfo` (name, color, orphaned) from the live config. */ describeWorkstream?: (binding: WorkstreamBinding | undefined) => SessionWorkstreamInfo | undefined; + /** + * The home workstream's environment for the backend's child process + * (docs/workstreams-design.md §6.2): its env credentials and git identity, + * resolved by the manager from the owner's config. Merged UNDER `extraEnv` + * and held to the same denylist (the schema already refused anything it + * would). Never persisted: recomputed from the config on every resume. + */ + workstreamEnv?: Readonly>; /** * Role-scoped goal-blackboard mount for a collaboration child: the endpoint * URL plus a bearer token that IS the scope (one goal, this role's read/write @@ -1737,16 +1745,17 @@ export class Session { this.#collaborationCost = opts.collaborationCost; this.#maxBudgetUsd = opts.maxBudgetUsd; this.#onBudgetExhausted = opts.onBudgetExhausted ?? "park"; - if (opts.extraEnv) { + const extra = opts.workstreamEnv && Object.keys(opts.workstreamEnv).length > 0 ? { ...opts.workstreamEnv, ...(opts.extraEnv ?? {}) } : opts.extraEnv; + if (extra) { // Before any side effect: a refused key must fail the create, not run a // backend with half the env the caller asked for. - const refused = extraEnvViolations(opts.extraEnv); + const refused = extraEnvViolations(extra); if (refused.length > 0) { throw new Error( `extraEnv may not set ${refused.join(", ")}: codeoid owns credential, account and process-loading variables for the backend`, ); } - this.#extraEnv = Object.freeze({ ...opts.extraEnv }); + this.#extraEnv = Object.freeze({ ...extra }); } if (opts.sandboxRegistry) { this.#sandbox = new SessionSandbox({ diff --git a/src/daemon/workstreams/schema.ts b/src/daemon/workstreams/schema.ts index 847ef19f..bbc54b11 100644 --- a/src/daemon/workstreams/schema.ts +++ b/src/daemon/workstreams/schema.ts @@ -9,11 +9,12 @@ * rest of the config (server and credential names) are checked where both are * known (W1). * - * Imports zod and the pure matcher alone: `src/config.ts` imports this, so - * anything heavier here would be pulled into every CLI start. + * Imports zod, the pure matcher and the env denylist alone: `src/config.ts` + * imports this, so anything heavier here would be pulled into every CLI start. */ import { z } from "zod"; +import { extraEnvViolations } from "../providers/env.js"; import { normalizePathRule } from "./match.js"; /** Ids a config may not use: they name a home the daemon assigns itself. */ @@ -64,7 +65,19 @@ const serverRefSchema = z.union([ /** An env value is always a reference to a named credential, never inline (§4). */ const envRefSchema = z.object({ secret: z.string().min(1).max(64) }).strict(); -const envNameSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/, "an env var name"); +/** + * A name a workstream may set: one an agent's backend would accept from any + * daemon caller (providers/env.ts) — never a credential, account, proxy or + * code-loading variable codeoid owns (ANTHROPIC_*, PATH, LD_*, …). Refused at + * load, so a session never fails to start over it. + */ +function envNameProblem(name: string): string | undefined { + if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(name)) return "an env var name is upper-case letters, digits and underscores"; + if (extraEnvViolations({ [name]: "x" }).length > 0) { + return "codeoid owns this variable for the backend (credentials, accounts, proxies, what runs): a workstream can't set it"; + } + return undefined; +} export const workstreamItemSchema = z .object({ @@ -101,7 +114,9 @@ export const workstreamItemSchema = z }) .strict() .default({}), - env: z.record(envNameSchema, envRefSchema).default({}), + // Names checked in the refinement below: a record key's own refinement + // reaches the user only as "Invalid key in record". + env: z.record(z.string(), envRefSchema).default({}), git: z .object({ name: z.string().min(1).max(256).optional(), @@ -115,6 +130,12 @@ export const workstreamItemSchema = z .refine((w) => !w.abstract || (w.match.paths.length === 0 && w.match.remotes.length === 0), { message: "an abstract (base) workstream has no match rules", path: ["match"], + }) + .superRefine((w, ctx) => { + for (const name of Object.keys(w.env)) { + const problem = envNameProblem(name); + if (problem) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${name}: ${problem}`, path: ["env", name] }); + } }); export const workstreamsSchema = z diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index 6ce00bee..de620495 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -18,6 +18,7 @@ import { effectiveWorkstreams, type EffectiveWorkstream } from "./effective.js"; import { FolderReader } from "./folder.js"; import { expandHome, type MatchableWorkstream, matchFolder, normalizePathRule } from "./match.js"; import type { WorkstreamsConfig } from "./schema.js"; +import type { SecretStore } from "../credentials/store.js"; export { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; @@ -336,6 +337,32 @@ export class WorkstreamService { }; } + /** + * The environment a session homed by `binding` gets (§6.2): its + * workstream's env, each value read from `secrets` now, and its git + * identity. Empty for no home, a reserved one, an orphaned one, or with no + * workstreams configured. `missing` names env vars whose credential has no + * value — the session starts without them, and says so. + */ + envFor(binding: WorkstreamBinding | undefined, secrets: SecretStore): { env: Record; missing: string[] } { + const env: Record = {}; + const missing: string[] = []; + if (!this.configured || !binding?.id || binding.source === "reserved") return { env, missing }; + const ws = this.#effective.get(binding.id); + if (!ws) return { env, missing }; + for (const [name, ref] of Object.entries(ws.env)) { + const value = secrets.get(ref.secret); + if (value === undefined || value.includes("\0")) missing.push(name); + else env[name] = value; + } + const oneLine = (v: string | undefined) => (v !== undefined && !/[\0\r\n]/.test(v) ? v : undefined); + const name = oneLine(ws.git?.name); + const email = oneLine(ws.git?.email); + if (name) Object.assign(env, { GIT_AUTHOR_NAME: name, GIT_COMMITTER_NAME: name }); + if (email) Object.assign(env, { GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_EMAIL: email }); + return { env, missing }; + } + #bind(r: WorkstreamResolution): WorkstreamBinding { if (r.home === undefined) return { source: "none", revision: this.#revision }; return { id: r.home, source: r.source, ...(r.matchedBy ? { matchedBy: r.matchedBy } : {}), revision: this.#revision }; diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts index e7e8bbfd..6a7c1631 100644 --- a/src/daemon/workstreams/workstreams.test.ts +++ b/src/daemon/workstreams/workstreams.test.ts @@ -8,6 +8,7 @@ import { FolderReader, findGitDirs, parseGitRemotes } from "./folder.js"; import { ancestry, expandHome, matchFolder, normalizePathRule, normalizeRemote, parseSshAliases, type MatchableWorkstream } from "./match.js"; import { workstreamsSchema } from "./schema.js"; import { WorkstreamService } from "./service.js"; +import { EnvFileSecretStore } from "../credentials/store.js"; const ws = (id: string, over: Partial = {}): MatchableWorkstream => ({ id, @@ -164,6 +165,13 @@ describe("schema", () => { expect(parse({ items: [{ id: "b", abstract: true, match: { paths: ["~/w"] } }] }).success).toBe(false); }); + test("a workstream can't set a variable codeoid owns for the backend", () => { + for (const name of ["ANTHROPIC_API_KEY", "PATH", "LD_PRELOAD", "CODEOID_X", "GIT_SSH_COMMAND", "lower_case"]) { + expect(parse({ items: [{ id: "a", env: { [name]: { secret: "s" } } }] }).success).toBe(false); + } + expect(parse({ items: [{ id: "a", env: { GH_TOKEN: { secret: "s" }, NPM_TOKEN: { secret: "s" } } }] }).success).toBe(true); + }); + test("env values are references, never inline secrets", () => { expect(parse({ items: [{ id: "a", env: { GH_TOKEN: "ghp_inline" } }] }).success).toBe(false); expect(parse({ items: [{ id: "a", env: { GH_TOKEN: { secret: "gh-a" } } }] }).success).toBe(true); @@ -505,6 +513,17 @@ describe("WorkstreamService", () => { expect(service([{ id: "b" }]).revision).not.toBe(a.revision); }); + test("envFor: the home's env from the store, its git identity; nothing for no home or a reserved one", () => { + const s = service([{ id: "a", env: { GH_TOKEN: { secret: "gh-a" }, NPM_TOKEN: { secret: "npm-a" } }, git: { name: "Ada", email: "ada@example.com" } }]); + const store = new EnvFileSecretStore({ CODEOID_CRED_GH_A: "tok" }); + const r = s.envFor({ id: "a", source: "explicit", revision: "r" }, store); + expect(r.env).toEqual({ GH_TOKEN: "tok", GIT_AUTHOR_NAME: "Ada", GIT_COMMITTER_NAME: "Ada", GIT_AUTHOR_EMAIL: "ada@example.com", GIT_COMMITTER_EMAIL: "ada@example.com" }); + expect(r.missing).toEqual(["NPM_TOKEN"]); + expect(s.envFor({ source: "none", revision: "r" }, store).env).toEqual({}); + expect(s.envFor({ id: "conductor", source: "reserved", revision: "r" }, store).env).toEqual({}); + expect(s.envFor(undefined, store).env).toEqual({}); + }); + test("summaries list env names, never values", () => { const s = service([{ id: "a", env: { GH_TOKEN: { secret: "gh-a" } } }]); expect(s.summaries()[0].env).toEqual(["GH_TOKEN"]); diff --git a/src/tests/provider-env.test.ts b/src/tests/provider-env.test.ts index 89a8393f..b295390d 100644 --- a/src/tests/provider-env.test.ts +++ b/src/tests/provider-env.test.ts @@ -239,3 +239,12 @@ describe("extraEnv denylist (SessionCreateOptions.extraEnv)", () => { expect(withExtraEnv({ PATH: "/usr/bin" }, { OPENAI_API_KEY: "evil" })).toEqual({ PATH: "/usr/bin" }); }); }); + +describe("git identity through extra env (workstreams §6.2)", () => { + it("GIT_AUTHOR_*/GIT_COMMITTER_* pass; every other GIT_ variable stays refused", () => { + const extra = { GIT_AUTHOR_NAME: "Ada", GIT_COMMITTER_EMAIL: "ada@example.com", GIT_SSH_COMMAND: "evil", GIT_CONFIG_COUNT: "1" }; + expect(extraEnvViolations(extra).sort()).toEqual(["GIT_CONFIG_COUNT", "GIT_SSH_COMMAND"]); + expect(withExtraEnv({}, extra)).toEqual({ GIT_AUTHOR_NAME: "Ada", GIT_COMMITTER_EMAIL: "ada@example.com" }); + expect(extraEnvViolations({ GIT_AUTHOR_NAME: "Ada\nx" })).toEqual(["GIT_AUTHOR_NAME"]); + }); +}); diff --git a/src/tests/workstreams-manager.test.ts b/src/tests/workstreams-manager.test.ts index 94805374..7625c2b4 100644 --- a/src/tests/workstreams-manager.test.ts +++ b/src/tests/workstreams-manager.test.ts @@ -15,6 +15,8 @@ import { SessionManager } from "../daemon/session-manager.js"; import { Store } from "../daemon/store.js"; import { TranscriptStore } from "../daemon/transcript.js"; import { FolderReader } from "../daemon/workstreams/folder.js"; +import { EnvFileSecretStore } from "../daemon/credentials/store.js"; +import { ProviderRegistry } from "../daemon/providers/registry.js"; import { ALL_SCOPES, SCOPES } from "../protocol/scopes.js"; import type { AuthContext, DaemonMessage, SessionInfo } from "../protocol/types.js"; @@ -54,13 +56,13 @@ afterEach(async () => { rmSync(tmp, { recursive: true, force: true }); }); -function manager(workstreams?: unknown): SessionManager { +function manager(workstreams?: unknown, credentials: unknown[] = []): SessionManager { // A whole config, through the loader the daemon uses: the zod parse (and its // cross-item rules) runs exactly as at boot. const config: CodeoidConfig | undefined = workstreams === undefined ? undefined - : loadConfig({ raw: { dbPath: join(tmp, "codeoid.db"), transcriptDir: join(tmp, "transcripts"), workstreams }, env: {}, quiet: true }); + : loadConfig({ raw: { dbPath: join(tmp, "codeoid.db"), transcriptDir: join(tmp, "transcripts"), workstreams, credentials }, env: {}, quiet: true }); const m = new SessionManager(store, transcript, undefined, undefined, undefined, { ...(config ? { config } : {}), _testProviderFactory: () => new MockSessionProvider(), @@ -178,7 +180,7 @@ describe("forks keep their parent's home (W0 audit)", () => { describe("workstream.list and workstream.resolve", () => { it("list needs settings:read and shows env names only", async () => { - const m = manager({ items: [{ id: "a", env: { GH_TOKEN: { secret: "gh-a" } } }] }); + const m = manager({ items: [{ id: "a", env: { GH_TOKEN: { secret: "gh-a" } } }] }, [{ id: "gh-a", kind: "env" }]); const denied = await m.handle({ type: "workstream.list", id: "l1" }, { ...OWNER, scopes: [SCOPES.SESSION_CREATE] as AuthContext["scopes"] }, client); expect(denied).toMatchObject({ type: "response.error", code: "forbidden" }); const ok = await m.handle({ type: "workstream.list", id: "l2" }, OWNER, client); @@ -263,3 +265,53 @@ async function listOne(m: SessionManager, id: string): Promise s.id === id) : undefined; } + +describe("the home's environment reaches the backend (W1, §6.2)", () => { + it("env credentials and git identity for sessions homed there, nothing for others", async () => { + const seen: Array<{ name: string; env: Record }> = []; + const registry = new ProviderRegistry("rec"); + registry.register({ + id: "rec", + displayName: "rec", + create: (init) => { + seen.push({ name: init.sessionId, env: { ...(init.extraEnv ?? {}) } }); + return new MockSessionProvider("rec", []); + }, + }); + const config = loadConfig({ + raw: { + dbPath: join(tmp, "codeoid.db"), + transcriptDir: join(tmp, "transcripts"), + credentials: [{ id: "gh-client", kind: "env" }], + workstreams: { + items: [ + { id: "client", match: { paths: ["~/client/**"] }, env: { GH_TOKEN: { secret: "gh-client" } }, git: { name: "Client Bot", email: "bot@client.example" } }, + { id: "mine", match: { paths: ["~/mine"] } }, + ], + }, + }, + env: {}, + quiet: true, + }); + const m = new SessionManager(store, transcript, undefined, undefined, undefined, { + config, + providers: registry, + _testFolderReader: new FolderReader({ home: join(tmp, "home"), sshConfig: "" }), + _testSecrets: new EnvFileSecretStore({ CODEOID_CRED_GH_CLIENT: "ghp_client" }), + }); + managers.push(m); + const a = (await create(m, "in-client", "client")) as { data: SessionInfo }; + const b = (await create(m, "in-mine", "mine")) as { data: SessionInfo }; + const client = seen.find((x) => x.name === a.data.id)?.env; + expect(client).toMatchObject({ GH_TOKEN: "ghp_client", GIT_AUTHOR_NAME: "Client Bot", GIT_COMMITTER_EMAIL: "bot@client.example" }); + expect(seen.find((x) => x.name === b.data.id)?.env).toBeDefined(); + expect(seen.find((x) => x.name === b.data.id)?.env.GH_TOKEN).toBeUndefined(); + }); + + it("a config that sets a variable codeoid owns, or names an undeclared credential, fails to load", () => { + const load = (workstreams: unknown, credentials: unknown[] = []) => () => + loadConfig({ raw: { dbPath: join(tmp, "x.db"), workstreams, credentials }, env: {}, quiet: true }); + expect(load({ items: [{ id: "a", env: { ANTHROPIC_API_KEY: { secret: "k" } } }] }, [{ id: "k", kind: "env" }])).toThrow(/codeoid owns this variable/); + expect(load({ items: [{ id: "a", env: { GH_TOKEN: { secret: "nope" } } }] })).toThrow(/undeclared credential "nope"/); + }); +}); From ff14c186a5157a425a55e79cb02ef3d4c04752aa Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 13:05:00 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20workstreams=20W1=20=E2=80=94=20is?= =?UTF-8?q?olation:=20a=20session=20reaches=20only=20its=20home's=20MCP=20?= =?UTF-8?q?servers,=20and=20no=20backend=20holds=20a=20secret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With workstreams.isolateGlobalServers on (off by default: nothing changes): - a session's MCP view lists built-ins, the servers its home selects by name, and the folder's own servers only when the folder is the home's; no home, the conductor's, or an orphaned one: built-ins only — on every backend - Claude runs with a strict MCP config (no ~/.claude.json, .mcp.json, plugin or agent servers of its own); every server it may reach is an in-process bridge to the daemon's hub, so it holds no server config, command or secret - other CLI backends mount natively only servers that need no secret; HTTP servers called with a credential go through the daemon's relay, which now takes the session's view; a stdio server needing env values is withheld - a server a workstream selects with a named credential carries a reference; the hub and relay read its value from the secret store per request and refuse without it - every hub list and call, and every relay request, is checked against the grant as it is now (G4): a removed server fails mid-turn with the reason - under isolation a user's create in a folder with no home is refused with workstream_required; sessions the daemon starts there get built-ins only Co-Authored-By: Claude Opus 5.5 --- packages/protocol/src/types.ts | 2 + src/daemon/mcp/hub.ts | 32 ++- src/daemon/mcp/proxy.ts | 33 ++- src/daemon/mcp/registry.ts | 19 ++ src/daemon/mcp/session-view.ts | 87 +++++++- src/daemon/mcp/types.ts | 7 + src/daemon/mcp/upstream.ts | 26 ++- src/daemon/providers/acp/index.ts | 2 +- src/daemon/providers/claude/index.ts | 9 +- src/daemon/providers/claude/oauth-bridge.ts | 21 +- src/daemon/providers/codex/index.ts | 2 +- src/daemon/providers/opencode/index.ts | 2 +- src/daemon/providers/qwen/index.ts | 2 +- src/daemon/server.ts | 4 + src/daemon/session-manager.ts | 15 +- src/daemon/session.ts | 8 + src/daemon/workstreams/grant.ts | 49 +++++ src/daemon/workstreams/service.ts | 47 ++++- src/tests/workstreams-isolation.test.ts | 215 ++++++++++++++++++++ 19 files changed, 548 insertions(+), 34 deletions(-) create mode 100644 src/daemon/workstreams/grant.ts create mode 100644 src/tests/workstreams-isolation.test.ts diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index a40364b8..3eed5ca1 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -3391,6 +3391,8 @@ export type ResponseErrorDetail = * §5.2). `resolution` says what the folder resolves to and why. */ | { kind: "workstream_conflict"; resolution: WorkstreamResolution } + /** `workstream_required`: isolation is on and the folder has no workstream; choose one. */ + | { kind: "workstream_required"; resolution: WorkstreamResolution } /** `workstream_unknown`: the chosen workstream isn't configured (or is a base). */ | { kind: "workstream_unknown"; workstream: string }; diff --git a/src/daemon/mcp/hub.ts b/src/daemon/mcp/hub.ts index fd34cdbc..887ed781 100644 --- a/src/daemon/mcp/hub.ts +++ b/src/daemon/mcp/hub.ts @@ -29,6 +29,7 @@ import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { ResultSchema } from "@modelcontextprotocol/sdk/types.js"; import { McpSignInRequired, type McpCredentialSource, type McpTenant } from "./oauth.js"; import type { McpProxy } from "./proxy.js"; +import type { SecretStore } from "../credentials/store.js"; import { cleanMcpError } from "./redact.js"; import { closeUpstream, FailureStreak, McpServerWantsSignIn, openUpstream, sessionGone, sessionTrouble, type UpstreamBearer, type UpstreamOptions } from "./upstream.js"; @@ -73,6 +74,8 @@ export interface McpHubOptions { daemonEnv?: Record; /** Where OAuth servers' tokens come from (docs/mcp-oauth-design.md). */ credentials?: McpCredentialSource; + /** Where named credentials' values come from (workstreams G2). */ + secrets?: SecretStore; } const DEFAULT_TIMEOUT_MS = 120_000; @@ -97,6 +100,29 @@ export class McpHub { this.#timeoutMs = opts.toolTimeoutMs && opts.toolTimeoutMs > 0 ? opts.toolTimeoutMs : DEFAULT_TIMEOUT_MS; this.#env = opts.daemonEnv ?? process.env; this.#credentials = opts.credentials ?? null; + this.#secrets = opts.secrets ?? null; + } + + #secrets: SecretStore | null; + /** + * Whether a session may still reach a server, asked on every list and call + * (workstreams G4): a server its workstream no longer selects fails at once, + * mid-turn, with the reason — the backend's tool list catches up at the next + * turn boundary. Returns the refusal, or undefined when allowed. + */ + #permit: ((sessionId: string, spec: McpServerSpec) => string | undefined) | null = null; + + setPermit(fn: (sessionId: string, spec: McpServerSpec) => string | undefined): void { + this.#permit = fn; + } + + #refusal(spec: McpServerSpec, scope: McpCallScope | undefined): string | undefined { + return scope?.sessionId && !spec.builtin ? this.#permit?.(scope.sessionId, spec) : undefined; + } + + /** The named-credential lookup upstream requests use (null store = none set). */ + get secretLookup(): (id: string) => string | undefined { + return (id) => this.#secrets?.get(id); } /** Tools `spec` exposes, filtered by its allowlist. Never throws — a server @@ -113,6 +139,8 @@ export class McpHub { * healthy server with no tools. Timed like a call. */ async listToolsOrThrow(spec: McpServerSpec, scope?: McpCallScope): Promise { + const refused = this.#refusal(spec, scope); + if (refused) throw new Error(refused); const key = clientKey(spec, scope?.tenant); let client: McpClient | undefined; try { @@ -146,6 +174,8 @@ export class McpHub { if (spec.toolAllowlist && !spec.toolAllowlist.includes(tool)) { return { text: `Tool "${tool}" is not in the allowlist for server "${spec.name}".`, isError: true }; } + const refused = this.#refusal(spec, scope); + if (refused) return { text: refused, isError: true }; let client: McpClient; try { client = this.#clientFor(spec, scope.tenant); @@ -345,7 +375,7 @@ export class McpHub { case "stdio": return new StdioMcpClient(spec.name, spec.transport, this.#env); case "http": { - const base = { name: spec.name, transport: spec.transport, env: this.#env }; + const base = { name: spec.name, transport: spec.transport, env: this.#env, secrets: this.secretLookup }; if (!isOAuthServer(spec)) return new HttpMcpClient(base, this.#timeoutMs); const credentials = this.#credentials; if (!credentials || !tenant) throw new McpSignInRequired(spec.name); diff --git a/src/daemon/mcp/proxy.ts b/src/daemon/mcp/proxy.ts index 8e768ab0..9c56db0d 100644 --- a/src/daemon/mcp/proxy.ts +++ b/src/daemon/mcp/proxy.ts @@ -36,8 +36,8 @@ import type { McpHub } from "./hub.js"; import { upstreamBearer } from "./hub.js"; import { McpSignInRequired, type McpCredentialSource, type McpTenant } from "./oauth.js"; import { cleanMcpError } from "./redact.js"; -import type { McpRegistry } from "./registry.js"; -import type { McpServerSpec } from "./types.js"; +import type { McpRegistry, McpServerView } from "./registry.js"; +import { isOAuthServer, type McpServerSpec } from "./types.js"; import { closeUpstream, FailureStreak, openUpstream, sessionGone, sessionTrouble } from "./upstream.js"; export const MCP_PROXY_PATH_PREFIX = "/mcp/proxy/"; @@ -51,8 +51,14 @@ export const MCP_PROXY_TOKEN_ENV = "CODEOID_MCP_PROXY_TOKEN"; export interface McpProxyBinding { tenant: McpTenant; sessionId: string; - /** The backend that mounts it: only its OAuth servers are reachable. */ + /** The backend that mounts it: only its relayed servers are reachable. */ backendId: string; + /** + * The session's MCP view: what it may reach, under its workstream's grant + * (G1). Asked at mint and again on every request, so a server the + * workstream drops stops at once (G4). Absent = the daemon-wide registry. + */ + view?: McpServerView; } /** What a provider needs to mount OAuth servers through the relay. */ @@ -151,10 +157,7 @@ export class McpProxy implements McpProxyMount { } mint(binding: McpProxyBinding): { token: string; servers: string[] } { - const servers = this.#registry - .oauthFor(binding.backendId) - .filter((s) => s.transport.kind === "http") - .map((s) => s.name); + const servers = (binding.view ?? this.#registry).relayFor(binding.backendId).map((s) => s.name); const token = randomBytes(32).toString("base64url"); this.#tokens.set(hashOf(token), { binding, servers: new Set(servers), relays: new Map(), starting: new Map(), calls: 0, initializes: [] }); return { token, servers }; @@ -230,7 +233,7 @@ export class McpProxy implements McpProxyMount { // Bound at mint AND still the backend's OAuth server now: an entry edited // or re-scoped since isn't reachable on an old token. const spec = record.servers.has(server) - ? this.#registry.oauthFor(record.binding.backendId).find((s) => s.name === server && s.transport.kind === "http") + ? (record.binding.view ?? this.#registry).relayFor(record.binding.backendId).find((s) => s.name === server) : undefined; if (!spec) return rpcError(404, ErrorCode.InvalidRequest, `no MCP server "${server}" here`); return this.#route(req, record, spec, server, token!, opts); @@ -321,6 +324,7 @@ export class McpProxy implements McpProxyMount { spec, binding: record.binding, credentials: this.#credentials, + secrets: (id) => this.#hub?.secretLookup(id), hub: this.#hub, env: this.#env, timeoutMs: this.#timeoutMs, @@ -354,6 +358,8 @@ interface RelayInit { spec: McpServerSpec; binding: McpProxyBinding; credentials: McpCredentialSource | null; + /** Named credentials' values, for a server called with one (workstreams G1). */ + secrets: (id: string) => string | undefined; hub: McpHub | null; env: Record; timeoutMs: number; @@ -677,14 +683,19 @@ class Relay { #ensureUpstream(): Promise { if (this.#closed) return Promise.reject(new Error("relay closed")); if (this.#upstream) return this.#upstream; - const { spec, binding, credentials, env, clientCapabilities, clientInfo } = this.#init; + const { spec, binding, credentials, env, clientCapabilities, clientInfo, secrets } = this.#init; if (spec.transport.kind !== "http") return Promise.reject(new Error(`${spec.name} is not an HTTP server`)); - if (!credentials) return Promise.reject(new McpSignInRequired(spec.name)); + // An OAuth server is called with the tenant's sign-in; one with a named + // credential (a workstream's, G1) with that — the relay holds either, the + // backend neither. + const oauth = isOAuthServer(spec); + if (oauth && !credentials) return Promise.reject(new McpSignInRequired(spec.name)); const connecting: Promise = openUpstream({ name: spec.name, transport: spec.transport, env, - oauth: upstreamBearer(credentials, spec, binding.tenant), + ...(oauth && credentials ? { oauth: upstreamBearer(credentials, spec, binding.tenant) } : {}), + secrets, clientInfo: { name: `codeoid relay${clientInfo?.name ? ` (${String(clientInfo.name).slice(0, 64)})` : ""}`, version: String(clientInfo?.version ?? "1.0").slice(0, 32), diff --git a/src/daemon/mcp/registry.ts b/src/daemon/mcp/registry.ts index caa60d43..e173881f 100644 --- a/src/daemon/mcp/registry.ts +++ b/src/daemon/mcp/registry.ts @@ -46,6 +46,19 @@ export interface McpServerView { oauthFor(backendId: string): McpServerSpec[]; /** External servers a native-mount backend mounts itself (no OAuth ones). */ forNativeMount(backendId: string): McpServerSpec[]; + /** + * HTTP servers a CLI backend reaches through the daemon's relay + * (mcp/proxy.ts), the daemon holding the credential: OAuth servers, and + * under workstream isolation every server called with a credential. + */ + relayFor(backendId: string): McpServerSpec[]; + /** + * Workstream isolation applies (docs/workstreams-design.md §6.1): what is + * listed is all the session may reach, and no backend may hold a secret — + * Claude runs with a strict MCP config, other CLIs mount only secret-free + * servers natively. + */ + readonly isolated: boolean; } export class McpRegistry implements McpServerView { @@ -121,6 +134,12 @@ export class McpRegistry implements McpServerView { forNativeMount(backendId: string): McpServerSpec[] { return this.forBackend(backendId).filter((s) => !s.builtin && !isOAuthServer(s)); } + + relayFor(backendId: string): McpServerSpec[] { + return this.oauthFor(backendId).filter((s) => s.transport.kind === "http"); + } + + readonly isolated = false; } /** Raw (zod-validated) config → normalized spec. Transport is inferred from diff --git a/src/daemon/mcp/session-view.ts b/src/daemon/mcp/session-view.ts index 53db47b2..d08dbcbc 100644 --- a/src/daemon/mcp/session-view.ts +++ b/src/daemon/mcp/session-view.ts @@ -31,6 +31,7 @@ import type { RawMcpServerConfig } from "../../config.js"; import type { McpHub } from "./hub.js"; +import type { McpGrant } from "../workstreams/grant.js"; import type { McpTenant } from "./oauth.js"; import { isReservedMcpName, normalizeSpec, type McpRegistry, type McpServerView } from "./registry.js"; import { isOAuthServer, type McpServerSpec } from "./types.js"; @@ -53,6 +54,12 @@ export interface SessionMcpViewOptions { read?: (workdir: string) => Promise; /** Injected for tests: the user's global Claude servers (null: can't be read just now). */ readGlobal?: () => Promise | null>; + /** + * The session's workstream grant (workstreams/grant.ts), read on every + * listing — undefined = no isolation, today's servers. Live: a server the + * workstream drops is gone from the next listing. + */ + grant?: () => McpGrant | undefined; } /** What changed when a view was applied. */ @@ -235,6 +242,8 @@ export class SessionMcpView implements McpServerView { /** A name the project's .mcp.json declared, on a backend that loads that file itself: its tools may be the repo's. */ leftToBackend(name: string, backendId: string): boolean { + // Isolated, Claude runs strict: it loads no .mcp.json, so nothing is its own. + if (backendId === "claude" && this.isolated) return false; return READS_MCP_JSON.has(backendId) && (this.#repoUnreadable || this.#mcpJsonNames.has(name)); } @@ -259,7 +268,30 @@ export class SessionMcpView implements McpServerView { } forNativeMount(backendId: string): McpServerSpec[] { - return this.forBackend(backendId).filter((s) => !s.builtin && !isOAuthServer(s)); + const native = this.forBackend(backendId).filter((s) => !s.builtin && !isOAuthServer(s)); + // Isolated: a backend that mounts a server itself gets only those it can + // mount holding no secret (G1). One called with a credential goes through + // the relay (relayFor); a stdio server needing env values isn't given at + // all — its env would ride the backend's own config. + return this.isolated ? native.filter((s) => secretFree(s)) : native; + } + + relayFor(backendId: string): McpServerSpec[] { + const http = (s: McpServerSpec) => s.transport.kind === "http"; + const oauth = this.oauthFor(backendId).filter(http); + if (!this.isolated) return oauth; + const credentialed = this.forBackend(backendId).filter((s) => !s.builtin && !isOAuthServer(s) && http(s) && !secretFree(s)); + return [...oauth, ...credentialed]; + } + + /** Servers a stdio server's env would need values for, withheld from a native mount under isolation. */ + withheldFromNative(backendId: string): McpServerSpec[] { + if (!this.isolated) return []; + return this.forBackend(backendId).filter((s) => !s.builtin && !isOAuthServer(s) && s.transport.kind === "stdio" && !secretFree(s)); + } + + get isolated(): boolean { + return this.#opts.grant?.() !== undefined; } // ── internals ── @@ -311,11 +343,15 @@ export class SessionMcpView implements McpServerView { globals: readonly McpServerSpec[] | null, declared: ReadonlySet, ): McpServerSpec[] { + const grant = this.#opts.grant?.(); const byName = new Map(); for (const s of this.#base.list()) if (!(globals && s.origin === "claude-global")) byName.set(s.name, s); for (const g of globals ?? []) if (!byName.has(g.name)) byName.set(g.name, g); - if (backendId !== "claude") { - const mcpJsonNames = backendId && READS_MCP_JSON.has(backendId) ? declared : null; + // Isolated, Claude runs with a strict MCP config and loads none of its + // own: the workdir layer is codeoid's to give it, as to every backend. + if (backendId !== "claude" || grant) { + // Strict (isolated) Claude reads no .mcp.json of its own: those are codeoid's to give. + const mcpJsonNames = backendId && READS_MCP_JSON.has(backendId) && !(grant && backendId === "claude") ? declared : null; for (const spec of layer) { if (mcpJsonNames?.has(spec.name)) continue; const existing = byName.get(spec.name); @@ -325,7 +361,7 @@ export class SessionMcpView implements McpServerView { byName.set(spec.name, spec); } } - return [...byName.values()]; + return grant ? applyGrant([...byName.values()], grant) : [...byName.values()]; } /** The workdir servers that apply, by consent and precedence. */ @@ -389,3 +425,46 @@ function fingerprintOfApplied(layer: readonly McpServerSpec[], globals: readonly function mcpJsonNamesOf(read: readonly WorkdirServer[]): ReadonlySet { return new Set(read.filter((w) => isMcpJson(w.path)).map((w) => w.name)); } + +/** A server a backend can mount itself without holding any secret. */ +export function secretFree(spec: McpServerSpec): boolean { + const t = spec.transport; + if (t.kind === "http") return !t.credential && !t.bearerTokenEnv && Object.keys(t.headers ?? {}).length === 0; + if (t.kind === "stdio") return Object.keys(t.env ?? {}).length === 0; + return true; +} + +/** + * The specs a grant lets through (grant.ts): built-ins; the folder's own + * servers when the folder is the home's; any other only by name. A selected + * server called with a named credential gets it as a REFERENCE on its + * transport, and a variant of its own, so the hub keeps one client per + * credential. An OAuth slot (a second sign-in) is not supported yet: such a + * server is withheld, never called with the default sign-in instead. + */ +function applyGrant(specs: readonly McpServerSpec[], grant: McpGrant): McpServerSpec[] { + const out: McpServerSpec[] = []; + for (const spec of specs) { + if (spec.builtin) { + out.push(spec); + continue; + } + if (spec.origin === "claude-local" || spec.origin === "claude-repo") { + if (grant.workdirServers) out.push(spec); + continue; + } + const sel = grant.servers.get(spec.name); + if (!sel || sel.oauthSlot) continue; + if (sel.credential && spec.transport.kind === "http" && !isOAuthServer(spec)) { + out.push({ + ...spec, + transport: { ...spec.transport, credential: { ...sel.credential } }, + variant: `${spec.variant ?? ""}#cred:${sel.credential.id}`, + }); + continue; + } + if (sel.credential) continue; // a credential this server can't take (stdio, OAuth): withheld, never sent without it + out.push(spec); + } + return out; +} diff --git a/src/daemon/mcp/types.ts b/src/daemon/mcp/types.ts index c41f9001..4ce666bc 100644 --- a/src/daemon/mcp/types.ts +++ b/src/daemon/mcp/types.ts @@ -38,6 +38,13 @@ export interface McpHttpTransport { bearerTokenEnv?: string; /** OAuth per the MCP authorization spec; the daemon holds the token. */ oauth?: McpOAuthConfig; + /** + * A named credential this server is called with (workstreams §6.5 case 2): + * a REFERENCE, never the value — the daemon reads the value from its secret + * store when it connects (mcp/upstream.ts), so no spec, config view or + * backend argv ever carries it. `header` absent = `Authorization: Bearer`. + */ + credential?: { id: string; header?: string }; } /** A remote server's OAuth settings. Everything is optional: with none, the diff --git a/src/daemon/mcp/upstream.ts b/src/daemon/mcp/upstream.ts index 26a440ee..e9942d7d 100644 --- a/src/daemon/mcp/upstream.ts +++ b/src/daemon/mcp/upstream.ts @@ -33,6 +33,8 @@ export interface UpstreamOptions { env: Record; /** Set for an OAuth server: the tenant's bearer. */ oauth?: UpstreamBearer; + /** Reads a named credential's value (transport.credential), when the request is made. */ + secrets?: (id: string) => string | undefined; /** Who the upstream server sees connecting (default: codeoid). */ clientInfo?: Implementation; /** What the client declares it can do — the relay passes its backend's. */ @@ -45,6 +47,14 @@ export interface UpstreamOptions { export const UPSTREAM_CLIENT_INFO: Implementation = { name: "codeoid", version: "1.0" }; +/** A server's named credential has no value in the secret store. */ +export class McpCredentialMissing extends Error { + constructor(readonly server: string, readonly credential: string) { + super(`${server}: credential "${credential}" has no value — set it in the credential store`); + this.name = "McpCredentialMissing"; + } +} + /** * A server configured without OAuth answered with the MCP authorization * challenge (401 + `WWW-Authenticate: Bearer …`): it wants a sign-in codeoid @@ -154,13 +164,20 @@ export async function closeUpstream(client: Client, timeoutMs = 3_000): Promise< */ export function upstreamFetch(opts: UpstreamOptions): typeof fetch { const base = opts.fetchFn ?? fetch; - const { name, transport, env, oauth } = opts; + const { name, transport, env, oauth, secrets } = opts; const send = (url: string | URL | Request, init: RequestInit | undefined, token: string | undefined): Promise => { const headers = new Headers(init?.headers); // What the SDK sets (session id, protocol version, accept) wins over config. for (const [k, v] of Object.entries(transport.headers ?? {})) if (!headers.has(k)) headers.set(k, v); if (token) headers.set("Authorization", `Bearer ${token}`); - else if (transport.bearerTokenEnv && env[transport.bearerTokenEnv]) { + else if (transport.credential) { + // A workstream's named credential, read now: a rotated value applies at + // the next request. Missing = refused here, never sent without it. + const value = secrets?.(transport.credential.id); + if (value === undefined) throw new McpCredentialMissing(name, transport.credential.id); + if (transport.credential.header) headers.set(transport.credential.header, value); + else headers.set("Authorization", `Bearer ${value}`); + } else if (transport.bearerTokenEnv && env[transport.bearerTokenEnv]) { headers.set("Authorization", `Bearer ${env[transport.bearerTokenEnv]}`); } return base(url, { ...init, headers, ...(oauth ? { redirect: "error" as const } : {}) }); @@ -177,7 +194,10 @@ export function upstreamFetch(opts: UpstreamOptions): typeof fetch { // given no credentials of its own: a 401 for a static token that expired // is that token's problem, not a sign-in to adopt. const challenge = resp.headers.get("www-authenticate") ?? ""; - const credentialed = transport.bearerTokenEnv !== undefined || Object.keys(transport.headers ?? {}).some((k) => k.toLowerCase() === "authorization"); + const credentialed = + transport.bearerTokenEnv !== undefined || + transport.credential !== undefined || + Object.keys(transport.headers ?? {}).some((k) => k.toLowerCase() === "authorization"); if (resp.status === 401 && !credentialed && /^\s*Bearer\b/i.test(challenge) && /resource_metadata\s*=/i.test(challenge)) { await resp.body?.cancel().catch(() => {}); throw new McpServerWantsSignIn(name); diff --git a/src/daemon/providers/acp/index.ts b/src/daemon/providers/acp/index.ts index 14ae698b..511c0124 100644 --- a/src/daemon/providers/acp/index.ts +++ b/src/daemon/providers/acp/index.ts @@ -171,7 +171,7 @@ export class GeminiAcpProvider implements SessionProvider { this.#mcpRegistry = init.mcpRegistry ?? null; this.#proxy = new McpProxyLease( init.mcpProxy, - init.tenant ? { tenant: init.tenant, sessionId: init.sessionId, backendId: this.id } : null, + init.tenant ? { tenant: init.tenant, sessionId: init.sessionId, backendId: this.id, ...(init.mcpRegistry ? { view: init.mcpRegistry } : {}) } : null, ); } diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 683ca7cd..e02509af 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -1044,7 +1044,9 @@ export class ClaudeProvider implements SessionProvider { // stall watchdog so the SDK signals first. const mcpToolTimeoutMs = init.config?.session.mcpToolTimeoutMs ?? 120_000; // A server codeoid now signs in to is codeoid's (mounted below), not the CLI's. - const userServers = withoutServers((init.userMcpServers ?? loadUserMcpServers)(opts.workdir), oauthMcp.deniedUrls); + // Under workstream isolation none of the user's own: the CLI runs strict, + // and every server it may reach is one of codeoid's bridges (G1). + const userServers = oauthMcp.strict ? {} : withoutServers((init.userMcpServers ?? loadUserMcpServers)(opts.workdir), oauthMcp.deniedUrls); // ...but never in place of the user's own server of that name at another // address — in this workdir's config, which adopting (from another // workdir, perhaps) couldn't see. Theirs stays; codeoid's isn't mounted here. @@ -1063,7 +1065,7 @@ export class ClaudeProvider implements SessionProvider { ...withMcpToolTimeout(userServers, mcpToolTimeoutMs), // Registry servers — mounted natively on the SDK (claude owns its client); // tool calls surface as `mcp____` and gate via canUseTool. - ...withMcpToolTimeout(registryServersForClaude(init.mcpRegistry), mcpToolTimeoutMs), + ...(oauthMcp.strict ? {} : withMcpToolTimeout(registryServersForClaude(init.mcpRegistry), mcpToolTimeoutMs)), // Registry OAuth servers the tenant has signed in to — in-process, in // front of the daemon-owned client, which holds the token (#390). The // hub applies its own per-call timeout. @@ -1340,6 +1342,9 @@ export class ClaudeProvider implements SessionProvider { // mode this also surfaces the user's own skills, which is the intended // behaviour. settingSources: ["project", "user"], + // Workstream isolation (G1): only the servers codeoid passes — no + // `~/.claude.json`, no `.mcp.json`, no plugin's, no agent's own. + ...(oauthMcp.strict ? { strictMcpConfig: true } : {}), // The CLI must not connect its own copy of a server codeoid signs in // to (from ~/.claude.json, a .mcp.json): the flag-settings denylist, // by URL, keeps it from dialing it at all (#390). diff --git a/src/daemon/providers/claude/oauth-bridge.ts b/src/daemon/providers/claude/oauth-bridge.ts index c8a4a8e9..ae290ff2 100644 --- a/src/daemon/providers/claude/oauth-bridge.ts +++ b/src/daemon/providers/claude/oauth-bridge.ts @@ -18,7 +18,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import type { McpCallScope, McpHub } from "../../mcp/hub.js"; import type { McpServerView } from "../../mcp/registry.js"; -import type { McpServerSpec } from "../../mcp/types.js"; +import { isOAuthServer, type McpServerSpec } from "../../mcp/types.js"; /** One OAuth server as an in-process server forwarding to the hub. */ export function oauthBridgeServer(spec: McpServerSpec, hub: McpHub, scope: McpCallScope): McpSdkServerConfigWithInstance { @@ -58,6 +58,8 @@ export function oauthServersForClaude( /** What the query is built with — the servers, their URLs and allowlists, * and which are signed in with which sign-in. Any change rebuilds it. */ fingerprint: string; + /** Workstream isolation (G1): the query runs with a strict MCP config. */ + strict: boolean; } { const specs = (registry?.oauthFor("claude") ?? []).filter((s) => s.transport.kind === "http"); const deniedUrls = specs.map((s) => (s.transport.kind === "http" ? s.transport.url : "")); @@ -65,10 +67,23 @@ export function oauthServersForClaude( // The sign-in, not just whether there is one: signed out and back in (after // a revoked token) between two turns must still get a fresh connection. const stamps = signedIn.map((s) => [s.name, hub?.credentialStamp(s, scope.tenant) ?? ""]); + // Under workstream isolation every other server the session may reach is a + // bridge too — stdio and HTTP alike, credentialed or not — so the CLI, + // running with a strict MCP config, holds no server config, secret or + // command of its own: what the hub reaches is all there is (G1). + const strict = registry?.isolated === true; + const bridged = strict && hub ? (registry?.forBackend("claude") ?? []).filter((s) => !s.builtin && !isOAuthServer(s)) : []; return { // Built only for a query being constructed, not on every turn's check. - mounts: () => Object.fromEntries(signedIn.map((s) => [s.name, oauthBridgeServer(s, hub!, scope)])), + mounts: () => + Object.fromEntries([...signedIn, ...bridged].map((s) => [s.name, oauthBridgeServer(s, hub!, scope)])), deniedUrls, - fingerprint: JSON.stringify([specs.map((s, i) => [s.name, deniedUrls[i], s.toolAllowlist ?? null]), stamps]), + fingerprint: JSON.stringify([ + specs.map((s, i) => [s.name, deniedUrls[i], s.toolAllowlist ?? null]), + stamps, + strict, + bridged.map((s) => [s.name, s.variant ?? "", s.toolAllowlist ?? null]), + ]), + strict, }; } diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index 8f33a120..3f5a9b3e 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -501,7 +501,7 @@ export class CodexProvider implements SessionProvider { this.#mcpRegistry = init.mcpRegistry ?? null; this.#proxy = new McpProxyLease( init.mcpProxy, - init.tenant ? { tenant: init.tenant, sessionId: init.sessionId, backendId: this.id } : null, + init.tenant ? { tenant: init.tenant, sessionId: init.sessionId, backendId: this.id, ...(init.mcpRegistry ? { view: init.mcpRegistry } : {}) } : null, ); } diff --git a/src/daemon/providers/opencode/index.ts b/src/daemon/providers/opencode/index.ts index ee8b231d..dc26fed5 100644 --- a/src/daemon/providers/opencode/index.ts +++ b/src/daemon/providers/opencode/index.ts @@ -556,7 +556,7 @@ export class OpencodeProvider implements SessionProvider { this.#modelPrices = init.modelPrices; this.#proxy = new McpProxyLease( init.mcpProxy, - init.tenant ? { tenant: init.tenant, sessionId: init.sessionId, backendId: this.id } : null, + init.tenant ? { tenant: init.tenant, sessionId: init.sessionId, backendId: this.id, ...(init.mcpRegistry ? { view: init.mcpRegistry } : {}) } : null, ); } diff --git a/src/daemon/providers/qwen/index.ts b/src/daemon/providers/qwen/index.ts index f389ca87..7d7b8cef 100644 --- a/src/daemon/providers/qwen/index.ts +++ b/src/daemon/providers/qwen/index.ts @@ -169,7 +169,7 @@ export class QwenProvider implements SessionProvider { this.#init = init; this.#proxy = new McpProxyLease( init.mcpProxy, - init.tenant ? { tenant: init.tenant, sessionId: init.sessionId, backendId: this.id } : null, + init.tenant ? { tenant: init.tenant, sessionId: init.sessionId, backendId: this.id, ...(init.mcpRegistry ? { view: init.mcpRegistry } : {}) } : null, ); } diff --git a/src/daemon/server.ts b/src/daemon/server.ts index fe05f58d..ec6e1d5b 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -9,6 +9,7 @@ */ import { SANDBOX_MCP_PATH } from "./sandbox/request-directory.js"; +import { createSecretStore } from "./credentials/store.js"; import type { SandboxRegistry } from "./sandbox/registry.js"; import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; @@ -610,6 +611,9 @@ export class DaemonServer { toolTimeoutMs: this.#config.fullConfig?.session.mcpToolTimeoutMs, daemonEnv: process.env, credentials: mcpOAuth, + // Named credentials' values (workstreams G2): the hub and the relay + // read them per request, so no backend ever holds one. + secrets: createSecretStore(this.#config.fullConfig?.credentialStore), }); // The relay for backends that own their MCP clients (#396). Its URL is the // loopback, whatever the daemon binds: the agent subprocess is local. diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 5178e1c9..4b99cd7f 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -10,6 +10,7 @@ import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js"; import { WorkstreamService, type WorkstreamBinding } from "./workstreams/service.js"; +import { grantRefusal, type McpGrant } from "./workstreams/grant.js"; import type { FolderReader } from "./workstreams/folder.js"; import { createSecretStore, type SecretStore } from "./credentials/store.js"; import { registerSecretPaths } from "./secret-paths.js"; @@ -739,7 +740,7 @@ export class SessionManager { this.#rateLimiter = rateLimiter ?? new RateLimiter(); this.#memory = memory; this.#config = opts?.config; - this.#workstreams = new WorkstreamService(opts?.config?.workstreams, opts?._testFolderReader); + this.#workstreams = new WorkstreamService(opts?.config?.workstreams, opts?._testFolderReader, opts?.config?.credentials); this.#secrets = opts?._testSecrets ?? createSecretStore(opts?.config?.credentialStore); // A `dir` store holds every credential's value in plain files: refused to // every agent's file tools and hidden from every sandbox, as codeoid's own @@ -2311,6 +2312,7 @@ mcpHub: this.#mcpHub, workstream?: WorkstreamBinding; describeWorkstream: (b: WorkstreamBinding | undefined) => ReturnType; workstreamEnv?: Readonly>; + mcpGrant: () => McpGrant | undefined; } { const { env, missing } = this.#workstreams.envFor(binding, this.#secrets); if (missing.length > 0) { @@ -2320,6 +2322,8 @@ mcpHub: this.#mcpHub, ...(binding ? { workstream: binding } : {}), describeWorkstream: this.#describeWorkstream, ...(Object.keys(env).length > 0 ? { workstreamEnv: env } : {}), + // Read live: the grant follows the config as it is at each listing. + mcpGrant: () => this.#workstreams.grantFor(binding), }; } @@ -4242,6 +4246,12 @@ mcpHub: this.#mcpHub, setMcp(registry: McpRegistry, hub: McpHub, oauth?: McpOAuth): void { this.#mcpRegistry = registry; this.#mcpHub = hub; + // Every call a session makes through the hub is checked against its + // workstream's grant as it is NOW (G4): a revoked server fails mid-turn. + hub.setPermit((sessionId, spec) => { + const session = this.#sessions.get(sessionId); + return session ? grantRefusal(this.#workstreams.grantFor(session.workstream), spec) : undefined; + }); this.#mcpOAuth = oauth; } @@ -4535,7 +4545,7 @@ mcpHub: this.#mcpHub, // Home workstream (docs/workstreams-design.md §5): an explicit choice, else // the folder's, else the default. Refused before anything is built. const givenWorkdir = givenPath(msg.workdir); - const homing = this.#workstreams.forCreate(workdir, msg.workstream, givenWorkdir); + const homing = this.#workstreams.forCreate(workdir, msg.workstream, givenWorkdir, true); if (homing.ok && msg.workstream !== undefined && auth.delegationDepth > 0) { // A delegated token may confirm the folder's own home, never pick // another: the home will decide credentials (W1), as an account choice @@ -4561,6 +4571,7 @@ mcpHub: this.#mcpHub, homing.kind === "workstream_unknown" ? { kind: "workstream_unknown", workstream: homing.workstream } : { kind: homing.kind, resolution: homing.resolution }, + }; } for (const w of homing.warnings) warn(w); diff --git a/src/daemon/session.ts b/src/daemon/session.ts index a1b49150..162a6d09 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -103,6 +103,7 @@ import { type PinnedDir, PinnedPathError, pinDir, pinRecord } from "./sandbox/pi import { applyRewinds, REWIND_EVENT, type TurnIndexEntry } from "./transcript.js"; import type { EffortChangeResult, EffortLevel, RewindFiles, RewindIrreversible, SessionWorkstreamInfo, TurnSummary } from "../protocol/types.js"; import type { WorkstreamBinding } from "./workstreams/binding.js"; +import type { McpGrant } from "./workstreams/grant.js"; import { LIMITS } from "../protocol/types.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; import type { PendingToolApproval, SessionApproveOutcome, SessionSandboxInfo, SessionSandboxSetting } from "../protocol/types.js"; @@ -565,6 +566,12 @@ export interface SessionCreateOptions { * would). Never persisted: recomputed from the config on every resume. */ workstreamEnv?: Readonly>; + /** + * The home's MCP grant under isolation (workstreams/grant.ts), read live; + * undefined result = no isolation. Applied by the session's MCP view, so + * every backend sees only what the home selects. + */ + mcpGrant?: () => McpGrant | undefined; /** * Role-scoped goal-blackboard mount for a collaboration child: the endpoint * URL plus a bearer token that IS the scope (one goal, this role's read/write @@ -1818,6 +1825,7 @@ export class Session { workdir: this.workdir, tenant: { accountId: this.accountId, projectId: this.projectId }, approvals: () => this.#store.workdirMcpApprovals(scope), + ...(opts.mcpGrant ? { grant: opts.mcpGrant } : {}), }); } this.#config = opts.config; diff --git a/src/daemon/workstreams/grant.ts b/src/daemon/workstreams/grant.ts new file mode 100644 index 00000000..9edc0b5a --- /dev/null +++ b/src/daemon/workstreams/grant.ts @@ -0,0 +1,49 @@ +/** + * What a session's home workstream lets it reach over MCP, under isolation + * (docs/workstreams-design.md §6.1, G1). + * + * A grant exists only while `workstreams.isolateGlobalServers` is on. With + * none, every session keeps today's servers. With one: + * - a built-in server (memory, fleet, blackboard, …) always reaches it; + * - any other server reaches it only if the home selects it by name; + * - the folder's own servers (Claude's per-folder entries, an approved + * `.mcp.json`) only when the folder is the home's — not for a session + * homed there by override; + * - no home, a reserved one (the conductor's) or an orphaned one: built-ins + * only. + */ + +/** A selected server's named credential, by reference. */ +export interface GrantCredential { + id: string; + /** Absent = `Authorization: Bearer`. */ + header?: string; +} + +export interface McpGrant { + /** The home, for messages. */ + workstream?: string; + /** Servers the home selects, by name. */ + servers: ReadonlyMap; + /** The folder's own servers apply. */ + workdirServers: boolean; +} + +/** + * Why a server is not (or no longer) reachable under `grant`; undefined = it + * is. The same rule the session's view lists by (session-view.ts applyGrant). + */ +export function grantRefusal(grant: McpGrant | undefined, spec: { name: string; builtin: boolean; origin?: string; transport: { credential?: { id: string } } | { kind: string } }): string | undefined { + if (!grant || spec.builtin) return undefined; + const name = spec.name; + if (spec.origin === "claude-local" || spec.origin === "claude-repo") { + return grant.workdirServers ? undefined : `MCP server "${name}" is this folder's own, and the folder isn't workstream "${grant.workstream ?? "none"}"'s`; + } + const sel = grant.servers.get(name); + const credential = "credential" in spec.transport ? spec.transport.credential?.id : undefined; + if (sel && !sel.oauthSlot && (sel.credential?.id ?? undefined) === credential) return undefined; + if (sel) return `MCP server "${name}" is now called with another credential in workstream "${grant.workstream}" — it applies from the next turn`; + return grant.workstream + ? `MCP server "${name}" is not in workstream "${grant.workstream}"` + : `MCP server "${name}" is not available to a session with no workstream`; +} diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index de620495..84019030 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -19,6 +19,8 @@ import { FolderReader } from "./folder.js"; import { expandHome, type MatchableWorkstream, matchFolder, normalizePathRule } from "./match.js"; import type { WorkstreamsConfig } from "./schema.js"; import type { SecretStore } from "../credentials/store.js"; +import type { CredentialDecl } from "../credentials/schema.js"; +import type { GrantCredential, McpGrant } from "./grant.js"; export { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; @@ -27,7 +29,7 @@ export const CONDUCTOR_WORKSTREAM = "conductor"; export type CreateBinding = | { ok: true; binding: WorkstreamBinding | undefined; resolution?: WorkstreamResolution; warnings: string[] } - | { ok: false; kind: "workstream_conflict"; resolution: WorkstreamResolution; error: string } + | { ok: false; kind: "workstream_conflict" | "workstream_required"; resolution: WorkstreamResolution; error: string } | { ok: false; kind: "workstream_unknown"; workstream: string; error: string }; /** What a meta on disk says about its session, for the resume pre-pass. */ @@ -49,9 +51,11 @@ export class WorkstreamService { #written = new Map(); #revision: string; #reader: FolderReader; + #credentials = new Map(); - constructor(cfg: WorkstreamsConfig | undefined, reader: FolderReader = new FolderReader()) { + constructor(cfg: WorkstreamsConfig | undefined, reader: FolderReader = new FolderReader(), credentials: readonly CredentialDecl[] = []) { this.#reader = reader; + for (const c of credentials) this.#credentials.set(c.id, c); this.#cfg = cfg ?? { isolateGlobalServers: false, items: [] }; this.#effective = new Map(); this.#matchables = []; @@ -104,10 +108,33 @@ export class WorkstreamService { } /** Replace the config (a settings write, W2). Live sessions keep their bindings. */ - update(cfg: WorkstreamsConfig): void { + update(cfg: WorkstreamsConfig, credentials?: readonly CredentialDecl[]): void { + if (credentials) this.#credentials = new Map(credentials.map((c) => [c.id, c])); this.#load(cfg); } + /** + * What a session homed by `binding` may reach over MCP (grant.ts), or + * undefined when isolation is off — every session then keeps today's + * servers. Read live by the session's view and the hub, so a server + * removed from a workstream stops on the next call (G4). + */ + grantFor(binding: WorkstreamBinding | undefined): McpGrant | undefined { + if (!this.configured || !this.#cfg.isolateGlobalServers) return undefined; + const ws = binding?.id && binding.source !== "reserved" ? this.#effective.get(binding.id) : undefined; + if (!ws) return { ...(binding?.id ? { workstream: binding.id } : {}), servers: new Map(), workdirServers: false }; + const servers = new Map(); + for (const s of ws.servers) { + const decl = s.credential ? this.#credentials.get(s.credential) : undefined; + if (s.credential && !decl) continue; // validated at load; a config edit that drops one fails closed + if (!decl) servers.set(s.server, {}); + else if (decl.kind === "bearer") servers.set(s.server, { credential: { id: decl.id } }); + else if (decl.kind === "header") servers.set(s.server, { credential: { id: decl.id, header: decl.header } }); + else if (decl.kind === "oauth") servers.set(s.server, { oauthSlot: decl.id }); + } + return { workstream: ws.id, servers, workdirServers: binding?.source !== "override" }; + } + /** Any workstream configured at all. With none, the daemon behaves as before workstreams. */ get configured(): boolean { return this.#cfg.items.length > 0; @@ -232,7 +259,7 @@ export class WorkstreamService { * with no choice, gets no home and a warning: W1 requires the choice once * isolation is on. */ - forCreate(workdir: string, explicit?: string, given?: string): CreateBinding { + forCreate(workdir: string, explicit?: string, given?: string, required = false): CreateBinding { if (explicit !== undefined) { const problem = this.choiceProblem(explicit); if (problem) return { ok: false, kind: "workstream_unknown", workstream: explicit, error: problem }; @@ -248,6 +275,18 @@ export class WorkstreamService { error: `This folder belongs to workstream "${c.folder}" (exclusive). Start the session in "${c.folder}", or choose a folder outside it for "${c.chosen}".`, }; } + // Isolated, a home decides everything a session reaches: a folder with + // none must be given one (§5.1). Sessions the daemon starts itself use + // forFolder (the manager's), which keeps "no home": built-ins only. + if (this.#cfg.isolateGlobalServers && resolution.home === undefined && explicit === undefined && required) { + const ids = [...this.#effective.values()].filter((w) => !w.abstract).map((w) => w.id); + return { + ok: false, + kind: "workstream_required", + resolution, + error: `This folder belongs to no workstream${resolution.ambiguous ? ` (${resolution.ambiguous.join(" and ")} both match)` : ""}: choose one (${ids.join(", ")}).`, + }; + } const warnings: string[] = []; if (resolution.ambiguous && explicit === undefined) { warnings.push(`Workstreams ${resolution.ambiguous.map((a) => `"${a}"`).join(" and ")} both match this folder — the session has no workstream. Choose one, or make one rule more specific.`); diff --git a/src/tests/workstreams-isolation.test.ts b/src/tests/workstreams-isolation.test.ts new file mode 100644 index 00000000..904a06aa --- /dev/null +++ b/src/tests/workstreams-isolation.test.ts @@ -0,0 +1,215 @@ +/** + * Workstream isolation (docs/workstreams-design.md §6.1, G1, G4): with + * `isolateGlobalServers` on, a session reaches only what its home selects — + * on every backend — no backend holds a secret, and a server the workstream + * drops stops on the next call. + */ + +import { describe, expect, it } from "bun:test"; +import type { RawMcpServerConfig } from "../config.js"; +import { McpHub } from "../daemon/mcp/hub.js"; +import { McpRegistry } from "../daemon/mcp/registry.js"; +import { secretFree, SessionMcpView } from "../daemon/mcp/session-view.js"; +import { McpCredentialMissing, upstreamFetch } from "../daemon/mcp/upstream.js"; +import { fingerprintOf, type WorkdirServer } from "../daemon/mcp/workdir-servers.js"; +import { oauthServersForClaude } from "../daemon/providers/claude/oauth-bridge.js"; +import { grantRefusal, type McpGrant } from "../daemon/workstreams/grant.js"; +import { workstreamsSchema } from "../daemon/workstreams/schema.js"; +import { WorkstreamService } from "../daemon/workstreams/service.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { McpProxy } from "../daemon/mcp/proxy.js"; + +const TENANT = { accountId: "a", projectId: "p" }; +const raw = (p: Partial): RawMcpServerConfig => + ({ args: [], env: {}, headers: {}, trust: "prompt", scope: "workspace", enabled: true, native: false, ...p }) as RawMcpServerConfig; +const entry = (name: string, origin: WorkdirServer["origin"], r: RawMcpServerConfig): WorkdirServer => ({ + name, + raw: r, + origin, + path: origin === "claude-local" ? "/h/.claude.json" : "/w/.mcp.json", + claudeApproval: "approved", + fingerprint: fingerprintOf(r), +}); + +const REGISTRY = () => + new McpRegistry( + { + github: raw({ url: "https://gh.example/mcp" }), + notes: raw({ command: "notes-mcp" }), + vault: raw({ command: "vault-mcp", env: { TOKEN: "${VAULT_TOKEN}" } }), + platform: raw({ url: "https://hf.example/mcp", headers: { "X-Api-Key": "${HF_KEY}" } }), + }, + { memoryEnabled: true }, + ); + +async function view(grant: McpGrant | undefined, read: WorkdirServer[] = [], global: Record | null = null): Promise { + const v = new SessionMcpView(REGISTRY(), null, { + workdir: "/w", + tenant: TENANT, + approvals: () => new Set(), + read: async () => read, + readGlobal: async () => global, + grant: () => grant, + }); + await v.load(); + return v; +} + +const names = (specs: { name: string }[]) => specs.map((s) => s.name).sort(); + +describe("the session's view under a grant", () => { + it("no grant: everything, as before", async () => { + const v = await view(undefined, [entry("mine", "claude-local", raw({ command: "m" }))]); + expect(v.isolated).toBe(false); + expect(names(v.forBackend("codex"))).toEqual(["codeoid_memory", "github", "mine", "notes", "platform", "vault"]); + }); + + it("only built-ins and what the home selects, by name; a user-global server too only by name", async () => { + const grant: McpGrant = { workstream: "acme", servers: new Map([["github", {}], ["slackish", {}]]), workdirServers: false }; + const v = await view(grant, [entry("mine", "claude-local", raw({ command: "m" }))], { slackish: raw({ url: "https://s.example" }), other: raw({ url: "https://o.example" }) }); + expect(v.isolated).toBe(true); + expect(names(v.forBackend("codex"))).toEqual(["codeoid_memory", "github", "slackish"]); + }); + + it("the folder's own servers only when the folder is the home's — and on Claude too, which runs strict", async () => { + const read = [entry("mine", "claude-local", raw({ command: "m" })), entry("repo", "claude-repo", raw({ command: "r" }))]; + const inHome = await view({ workstream: "acme", servers: new Map(), workdirServers: true }, read); + expect(names(inHome.forBackend("claude"))).toEqual(["codeoid_memory", "mine", "repo"]); + const override = await view({ workstream: "acme", servers: new Map(), workdirServers: false }, read); + expect(names(override.forBackend("claude"))).toEqual(["codeoid_memory"]); + }); + + it("a server selected with a credential carries a reference, never the value, and a variant of its own", async () => { + const v = await view({ workstream: "acme", servers: new Map([["github", { credential: { id: "gh-acme" } }]]), workdirServers: false }); + const gh = v.get("github"); + expect(gh?.transport).toMatchObject({ kind: "http", credential: { id: "gh-acme" } }); + expect(gh?.variant).toContain("#cred:gh-acme"); + expect(JSON.stringify(v.list())).not.toContain("ghp_"); + }); + + it("an OAuth slot (not supported yet) or a credential a stdio server can't take: withheld, never called without it", async () => { + const v = await view({ workstream: "acme", servers: new Map([["github", { oauthSlot: "gh-2" }], ["notes", { credential: { id: "x" } }]]), workdirServers: false }); + expect(names(v.list())).toEqual(["codeoid_memory"]); + }); + + it("CLI backends: native mounts only secret-free; credentialed HTTP through the relay; stdio needing env values withheld", async () => { + const v = await view({ workstream: "acme", servers: new Map([["github", { credential: { id: "gh" } }], ["notes", {}], ["vault", {}], ["platform", {}]]), workdirServers: false }); + expect(names(v.forNativeMount("codex"))).toEqual(["notes"]); + expect(names(v.relayFor("codex"))).toEqual(["github", "platform"]); + expect(names(v.withheldFromNative("codex"))).toEqual(["vault"]); + expect(secretFree(v.get("notes")!)).toBe(true); + }); +}); + +describe("credential references are resolved in the daemon, when a request is made", () => { + const transport = (credential: { id: string; header?: string }) => ({ kind: "http" as const, url: "https://x.example/mcp", headers: {}, credential }); + + it("as a bearer, or in the named header", async () => { + const seen: Headers[] = []; + const fetchFn = (async (_u: string | URL | Request, init?: RequestInit) => { + seen.push(new Headers(init?.headers)); + return new Response("{}", { status: 200 }); + }) as typeof fetch; + await upstreamFetch({ name: "s", transport: transport({ id: "a" }), env: {}, secrets: () => "tok-a", fetchFn })("https://x.example/mcp", {}); + await upstreamFetch({ name: "s", transport: transport({ id: "b", header: "X-Api-Key" }), env: {}, secrets: () => "tok-b", fetchFn })("https://x.example/mcp", {}); + expect(seen[0]!.get("authorization")).toBe("Bearer tok-a"); + expect(seen[1]!.get("x-api-key")).toBe("tok-b"); + }); + + it("refuses when the value is missing — the request is never sent without it", async () => { + let sent = 0; + const fetchFn = (async () => { + sent++; + return new Response("{}"); + }) as unknown as typeof fetch; + const f = upstreamFetch({ name: "s", transport: transport({ id: "gone" }), env: {}, secrets: () => undefined, fetchFn }); + await expect(f("https://x.example/mcp", {})).rejects.toBeInstanceOf(McpCredentialMissing); + expect(sent).toBe(0); + }); +}); + +describe("every call is checked against the grant as it is now (G4)", () => { + it("the hub refuses a server the session may no longer reach, mid-turn, saying why", async () => { + const hub = new McpHub({}); + let grant: McpGrant | undefined = { workstream: "acme", servers: new Map([["notes", {}]]), workdirServers: false }; + hub.setPermit((_sid, spec) => grantRefusal(grant, spec)); + const spec = REGISTRY().get("notes")!; + grant = { workstream: "acme", servers: new Map(), workdirServers: false }; + const r = await hub.callTool(spec, "anything", {}, { workspaceId: "w", sessionId: "s1" }); + expect(r).toEqual({ isError: true, text: 'MCP server "notes" is not in workstream "acme"' }); + await expect(hub.listToolsOrThrow(spec, { workspaceId: "w", sessionId: "s1" })).rejects.toThrow(/not in workstream "acme"/); + }); + + it("grantRefusal: built-ins always; a changed credential applies from the next turn; folder servers by home", () => { + const g: McpGrant = { workstream: "acme", servers: new Map([["gh", { credential: { id: "new" } }]]), workdirServers: false }; + expect(grantRefusal(g, { name: "codeoid_memory", builtin: true, transport: { kind: "in-process" } })).toBeUndefined(); + expect(grantRefusal(g, { name: "gh", builtin: false, transport: { kind: "http", credential: { id: "new" } } as never })).toBeUndefined(); + expect(grantRefusal(g, { name: "gh", builtin: false, transport: { kind: "http", credential: { id: "old" } } as never })).toMatch(/another credential/); + expect(grantRefusal(g, { name: "mine", builtin: false, origin: "claude-local", transport: { kind: "stdio" } })).toMatch(/this folder's own/); + expect(grantRefusal(undefined, { name: "x", builtin: false, transport: { kind: "stdio" } })).toBeUndefined(); + }); +}); + +describe("Claude under isolation", () => { + it("runs strict, and every reachable server is a bridge to the hub — no config, command or secret of its own", async () => { + const v = await view({ workstream: "acme", servers: new Map([["notes", {}], ["platform", {}]]), workdirServers: false }); + const hub = new McpHub({}); + const r = oauthServersForClaude(v, hub, { workspaceId: "w", sessionId: "s", tenant: TENANT }); + expect(r.strict).toBe(true); + const mounts = r.mounts(); + expect(Object.keys(mounts).sort()).toEqual(["notes", "platform"]); + for (const m of Object.values(mounts)) expect(m.type).toBe("sdk"); + }); + + it("without isolation nothing changes: not strict, no bridges beyond signed-in OAuth servers", async () => { + const r = oauthServersForClaude(await view(undefined), new McpHub({}), { workspaceId: "w", sessionId: "s", tenant: TENANT }); + expect(r.strict).toBe(false); + expect(Object.keys(r.mounts())).toEqual([]); + }); +}); + +describe("the relay takes the session's view (G1, G4)", () => { + it("mints for what the session may reach on that backend, credentialed servers included", async () => { + const v = await view({ workstream: "acme", servers: new Map([["github", { credential: { id: "gh" } }], ["notes", {}]]), workdirServers: false }); + const proxy = new McpProxy({ registry: REGISTRY(), credentials: null, baseUrl: () => "http://127.0.0.1:1" }); + expect(proxy.mint({ tenant: TENANT, sessionId: "s", backendId: "codex", view: v }).servers).toEqual(["github"]); + // The daemon-wide registry alone (no view): its OAuth servers only, as before. + expect(proxy.mint({ tenant: TENANT, sessionId: "s", backendId: "codex" }).servers).toEqual([]); + }); +}); + +describe("WorkstreamService.grantFor", () => { + const svc = (isolate: boolean) => + new WorkstreamService( + workstreamsSchema.parse({ + isolateGlobalServers: isolate, + items: [{ id: "acme", mcpServers: ["notes", { server: "github", credential: "gh-acme" }, { server: "slack", credential: "slack-2" }, { server: "hf", credential: "hf-key" }] }], + }), + new FolderReader({ home: "/nonexistent", sshConfig: "" }), + [ + { id: "gh-acme", kind: "bearer" }, + { id: "slack-2", kind: "oauth", server: "slack" }, + { id: "hf-key", kind: "header", header: "X-Api-Key" }, + ], + ); + + it("undefined with isolation off: every session keeps today's servers", () => { + expect(svc(false).grantFor({ id: "acme", source: "explicit", revision: "r" })).toBeUndefined(); + }); + + it("the home's servers with their credential references; no home, reserved or orphaned: built-ins only", () => { + const s = svc(true); + const g = s.grantFor({ id: "acme", source: "matched", revision: "r" }); + expect(g?.workdirServers).toBe(true); + expect([...(g?.servers.entries() ?? [])]).toEqual([ + ["notes", {}], + ["github", { credential: { id: "gh-acme" } }], + ["slack", { oauthSlot: "slack-2" }], + ["hf", { credential: { id: "hf-key", header: "X-Api-Key" } }], + ]); + expect(s.grantFor({ id: "acme", source: "override", revision: "r" })?.workdirServers).toBe(false); + for (const b of [{ source: "none" as const, revision: "r" }, { id: "conductor", source: "reserved" as const, revision: "r" }, { id: "gone", source: "matched" as const, revision: "r" }, undefined]) { + expect(s.grantFor(b)?.servers.size).toBe(0); + } + }); +}); From 8f0d4d0829b66e7409939a4ff398127e41d1e3f0 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 13:08:45 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20workstreams=20W1=20=E2=80=94=20co?= =?UTF-8?q?nfirmed=20overrides,=20the=20folder=20record,=20a=20home=20requ?= =?UTF-8?q?ired=20under=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a create choosing a workstream past another's exclusive claim is refused with what an override would change and, when the claimant allows it, a token: single use, five minutes, bound to the caller, folder, choice and config revision (G3); the confirming retry needs session:approve on an undelegated token and is audited (workstream.override); codeoid new shows the warning and asks, or --override confirms it - how each folder last resolved is recorded per tenant: a repository rule that now matches another workstream (an edited origin) makes the next create choose, and leaves a session the daemon starts there without a home - under isolation a user's create in a folder with no home is refused with the workstreams to choose from Co-Authored-By: Claude Opus 5.5 --- packages/protocol/src/schemas.ts | 1 + packages/protocol/src/types.ts | 7 ++ packages/protocol/src/workstreams.ts | 15 +++- src/cli.ts | 14 +++- src/daemon/session-manager.ts | 81 +++++++++++++++++++--- src/daemon/store.ts | 29 ++++++++ src/daemon/workstreams/override.ts | 61 ++++++++++++++++ src/daemon/workstreams/service.ts | 30 +++++++- src/daemon/workstreams/workstreams.test.ts | 2 +- src/terminal/client.ts | 21 ++++++ src/tests/workstreams-manager.test.ts | 76 +++++++++++++++++++- 11 files changed, 321 insertions(+), 16 deletions(-) create mode 100644 src/daemon/workstreams/override.ts diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 480f4681..2611c03f 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -171,6 +171,7 @@ export const sessionCreateSchema = z.object({ * `workstream_unknown` rather than an opaque schema rejection. */ workstream: workstreamIdField.optional(), + override: z.object({ token: z.string().min(1).max(128) }).strict().optional(), }); export const sessionListSchema = z.object({ ...base, type: z.literal("session.list") }); diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 3eed5ca1..c4bf07ce 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -1597,6 +1597,13 @@ export interface SessionCreateMsg extends BaseClientMsg { * `"workstream_unknown"`. */ workstream?: string; + /** + * Confirm a choice of `workstream` past another workstream's exclusive + * claim on the folder (docs/workstreams-design.md §5.2, G3): the token from + * the `workstream_conflict` refusal. Needs `session:approve` and an + * undelegated token. + */ + override?: { token: string }; } /** diff --git a/packages/protocol/src/workstreams.ts b/packages/protocol/src/workstreams.ts index 30178f76..36d25467 100644 --- a/packages/protocol/src/workstreams.ts +++ b/packages/protocol/src/workstreams.ts @@ -88,7 +88,20 @@ export interface WorkstreamResolution { * claim on the folder (§5.2). `overridable: false` = that workstream set * `allowOverride: false`. */ - conflict?: { folder: string; chosen: string; overridable: boolean }; + conflict?: { + folder: string; + chosen: string; + overridable: boolean; + /** What an override would change: servers and env the chosen home gives, and the folder's withheld. */ + changes?: { gains: string[]; withholds: string[]; env: string[]; git?: string }; + /** + * On a `session.create` refused for this conflict, when overridable: the + * token a confirming retry sends as `override.token` (G3). Single use, + * minutes long, bound to the caller, the folder, the choice and the + * config revision — a changed config makes it stale. + */ + overrideToken?: string; + }; } // ── Messages (client → daemon) ──────────────────────────────────────────────── diff --git a/src/cli.ts b/src/cli.ts index 65dbdd13..4a3d0c91 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1089,6 +1089,7 @@ program "--workstream ", "Home workstream (docs/workstreams-design.md): decides the session's servers, credentials and defaults. Default: the folder's (`codeoid workstream match `), else the configured default.", ) + .option("--override", "with --workstream: confirm it past the folder's own exclusive workstream without asking (needs session:approve)") .option( "--pack ", "Activate an installed SDLC pack on the session (inject its constitution, expose its skills/subagents). With --collaborate, the collaboration ADOPTS the pack: role names bind strictly to the pack's declared roles.", @@ -1131,6 +1132,7 @@ program projectRoot: string[]; projectRoots?: boolean; workstream?: string; + override?: boolean; }, ) => { const config = loadConfig(); @@ -1204,6 +1206,10 @@ program collaboration, ...(sandbox ? { sandbox } : {}), ...(opts.workstream ? { workstream: opts.workstream } : {}), + confirmOverride: async (warning: string) => { + console.error(warning); + return confirmOnTerminal("Start it anyway? [y/N] ", opts.override, "Not started: pass --override to confirm without a terminal."); + }, }); client.disconnect(); }, @@ -1531,10 +1537,14 @@ const worktreeCmd = program .description("Worktrees codeoid creates (forks, compare, routines): trust a repository's setup recipe, .codeoid/setup.yaml"); /** Ask y/N on the terminal; off a terminal, only --yes says yes. EOF is "no". */ -async function confirmOnTerminal(question: string, yes: boolean | undefined): Promise { +async function confirmOnTerminal( + question: string, + yes: boolean | undefined, + noTerminal = "Not trusted: pass --yes to trust without a terminal to confirm in.", +): Promise { if (yes) return true; if (!process.stdin.isTTY) { - console.error("Not trusted: pass --yes to trust without a terminal to confirm in."); + console.error(noTerminal); return false; } const { createInterface } = await import("node:readline"); diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 4b99cd7f..e7e88979 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -11,6 +11,7 @@ import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js"; import { WorkstreamService, type WorkstreamBinding } from "./workstreams/service.js"; import { grantRefusal, type McpGrant } from "./workstreams/grant.js"; +import { OverrideTokens } from "./workstreams/override.js"; import type { FolderReader } from "./workstreams/folder.js"; import { createSecretStore, type SecretStore } from "./credentials/store.js"; import { registerSecretPaths } from "./secret-paths.js"; @@ -583,6 +584,8 @@ export class SessionManager { #workstreams: WorkstreamService; /** Where credential values live (workstreams G2): read when used, never cached. */ #secrets: SecretStore; + /** Outstanding override confirmations (workstreams G3). */ + readonly #overrides = new OverrideTokens(); /** * `config.worktree.setup`, live: `codeoid worktree trust` changes it * without a restart (and writes it through to config.json). @@ -1977,7 +1980,7 @@ mcpHub: this.#mcpHub, const problem = this.#loopWorktreeProblem(init.workdir); if (problem) throw new Error(problem); const session = new Session({ - ...this.#workstreamOpts(this.#workstreamForFolder(init.workdir)), + ...this.#workstreamOpts(this.#workstreamForFolder(init.workdir, auth)), name: init.name, workdir: init.workdir, auth, @@ -2332,10 +2335,20 @@ mcpHub: this.#mcpHub, * loop attempt, a routine run, a dispatch worker): its folder's, else the * default. Never refused: there is no explicit choice to conflict with. */ - #workstreamForFolder(workdir: string): WorkstreamBinding | undefined { + #workstreamForFolder(workdir: string, auth?: { accountId: string; projectId: string }): WorkstreamBinding | undefined { try { const r = this.#workstreams.forCreate(workdir); - return r.ok ? r.binding : undefined; + if (!r.ok) return undefined; + // A repository match that differs from how the folder resolved before + // (§5.1): nobody is here to choose, so no home — built-ins only. + if (auth && r.binding?.source === "matched" && r.binding.id && r.binding.matchedBy?.startsWith("remote ")) { + const before = this.#store.workstreamFolder({ accountId: auth.accountId, projectId: auth.projectId, workdir }); + if (before && before !== r.binding.id) { + console.warn(`[codeoid] workstream for ${workdir}: was "${before}", its repository now matches "${r.binding.id}" — no home until a session there chooses`); + return { source: "none", revision: r.binding.revision }; + } + } + return r.binding; } catch (err) { console.warn(`[codeoid] workstream for ${workdir}: ${err instanceof Error ? err.message : String(err)}`); return undefined; @@ -4545,7 +4558,19 @@ mcpHub: this.#mcpHub, // Home workstream (docs/workstreams-design.md §5): an explicit choice, else // the folder's, else the default. Refused before anything is built. const givenWorkdir = givenPath(msg.workdir); - const homing = this.#workstreams.forCreate(workdir, msg.workstream, givenWorkdir, true); + // A confirmed override (G3): the token from the conflict refusal, bound to + // this caller, folder, choice and config revision; spent here, once. + let overridden = false; + if (msg.override) { + if (!hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) || auth.delegationDepth > 0) { + return { type: "response.error", requestId: msg.id, error: "Overriding a folder's workstream needs session:approve on an undelegated token", code: "forbidden" }; + } + if (msg.workstream === undefined || !this.#overrides.redeem(msg.override.token, { sub: auth.sub, workdir, chosen: msg.workstream, revision: this.#workstreams.revision })) { + return { type: "response.error", requestId: msg.id, error: "The override token is invalid, used, or expired — or the workstreams changed since: ask again", code: "invalid_request" }; + } + overridden = true; + } + const homing = this.#workstreams.forCreate(workdir, msg.workstream, givenWorkdir, true, overridden); if (homing.ok && msg.workstream !== undefined && auth.delegationDepth > 0) { // A delegated token may confirm the folder's own home, never pick // another: the home will decide credentials (W1), as an account choice @@ -4562,6 +4587,21 @@ mcpHub: this.#mcpHub, } } if (!homing.ok) { + // An overridable conflict comes back with the token a confirming retry + // sends (G3) — issued for this caller, folder, choice and revision. + const conflict = homing.kind === "workstream_conflict" ? homing.resolution.conflict : undefined; + const resolution = + homing.kind === "workstream_unknown" + ? undefined + : conflict?.overridable && msg.workstream !== undefined + ? { + ...homing.resolution, + conflict: { + ...conflict, + overrideToken: this.#overrides.issue({ sub: auth.sub, workdir, chosen: msg.workstream, revision: this.#workstreams.revision }), + }, + } + : homing.resolution; return { type: "response.error", requestId: msg.id, @@ -4570,12 +4610,35 @@ mcpHub: this.#mcpHub, detail: homing.kind === "workstream_unknown" ? { kind: "workstream_unknown", workstream: homing.workstream } - : { kind: homing.kind, resolution: homing.resolution }, + : { kind: homing.kind, resolution: resolution! }, }; } + // A repository rule matching otherwise than this folder resolved before + // (§5.1): an `origin` is text an agent can edit, so a changed match is the + // owner's to confirm — by choosing — never a silent re-home. + if (homing.ok && homing.binding?.source === "matched" && homing.binding.id) { + const before = this.#store.workstreamFolder({ accountId: auth.accountId, projectId: auth.projectId, workdir }); + if (before && before !== homing.binding.id && homing.binding.matchedBy?.startsWith("remote ")) { + this.#store.audit(auth.sub, "session.workstream.remote_changed", "", `workdir=${workdir} was=${before} now=${homing.binding.id}`); + return { + type: "response.error", + requestId: msg.id, + error: `This folder was workstream "${before}", but its repository now matches "${homing.binding.id}" (${homing.binding.matchedBy}). Its remote may have been changed: choose the workstream with --workstream.`, + code: "invalid_request", + detail: { kind: "workstream_required", resolution: { source: "none", candidates: homing.resolution?.candidates ?? [], ambiguous: [before, homing.binding.id].sort() } }, + }; + } + } for (const w of homing.warnings) warn(w); const createWorkstream = homing.binding; + if (createWorkstream?.id && createWorkstream.source !== "default") { + this.#store.setWorkstreamFolder({ accountId: auth.accountId, projectId: auth.projectId, workdir }, createWorkstream.id); + } + if (createWorkstream?.source === "override") { + const c = homing.resolution?.conflict; + this.#store.audit(auth.sub, "workstream.override", "", `workdir=${workdir} folder=${c?.folder ?? "?"} chosen=${createWorkstream.id} revision=${createWorkstream.revision}`); + } if (msg.collaboration) { let collabConfig = msg.collaboration; @@ -6254,7 +6317,7 @@ mcpHub: this.#mcpHub, const problem = this.#loopWorktreeProblem(opts.workdir); if (problem) throw new Error(problem); const session = new Session({ - ...this.#workstreamOpts(this.#workstreamForFolder(opts.workdir)), + ...this.#workstreamOpts(this.#workstreamForFolder(opts.workdir, opts.auth)), name: opts.name, workdir: opts.workdir, providerId: opts.provider, @@ -7283,7 +7346,7 @@ mcpHub: this.#mcpHub, // what the run delivered. const session = new Session({ // The checkout the loop works on, not its worktree (`-worktrees/…` matches no rule written for the repo). - ...this.#workstreamOpts(this.#workstreamForFolder(run.contract.workdir)), + ...this.#workstreamOpts(this.#workstreamForFolder(run.contract.workdir, owned(run, 0))), name: `loop-${run.id.slice(5, 13)}`, workdir, ...(run.worktreeRoot && run.branch @@ -7389,7 +7452,7 @@ mcpHub: this.#mcpHub, } const session = new Session({ // The routine's own folder, not the worktree it may run in. - ...this.#workstreamOpts(this.#workstreamForFolder(o.workdir)), + ...this.#workstreamOpts(this.#workstreamForFolder(o.workdir, o.ownerAuth)), // The conductor's name is reserved for the singleton. name: o.name === this.#conductorName() ? `${o.name}-routine` : o.name, workdir, @@ -7680,7 +7743,7 @@ mcpHub: this.#mcpHub, } const budget = this.#dispatcher.config.workerToolBudget; const session = new Session({ - ...this.#workstreamOpts(this.#workstreamForFolder(workdir)), + ...this.#workstreamOpts(this.#workstreamForFolder(workdir, this.#dispatchSenderAuth(task))), name: `worker-${task.shape}-${task.id.slice(0, 8)}`, workdir, role: "worker", diff --git a/src/daemon/store.ts b/src/daemon/store.ts index d1d9037f..a12315fe 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -419,6 +419,18 @@ export class Store { -- approved in codeoid to reach every backend there (#395). Bound to the -- entry's fingerprint: a repo that edits the entry needs consent again. -- Tenant- and workdir-scoped, like the sessions that use it. + -- How each folder last resolved to a workstream, per tenant + -- (docs/workstreams-design.md §5.1): a repository rule that matches + -- differently later (an edited origin) is refused, not a silent re-home. + CREATE TABLE IF NOT EXISTS workstream_folders ( + account_id TEXT NOT NULL, + project_id TEXT NOT NULL, + workdir TEXT NOT NULL, + workstream TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (account_id, project_id, workdir) + ); + CREATE TABLE IF NOT EXISTS mcp_workdir_approvals ( account_id TEXT NOT NULL, project_id TEXT NOT NULL, @@ -1911,6 +1923,23 @@ export class Store { } /** The (server, fingerprint) pairs a tenant approved for a workdir. */ + /** The workstream a folder last resolved to, for this tenant (workstreams §5.1). */ + workstreamFolder(scope: { accountId: string; projectId: string; workdir: string }): string | undefined { + const row = this.#db + .prepare("SELECT workstream FROM workstream_folders WHERE account_id = ? AND project_id = ? AND workdir = ?") + .get(scope.accountId, scope.projectId, scope.workdir) as { workstream: string } | null; + return row?.workstream; + } + + setWorkstreamFolder(scope: { accountId: string; projectId: string; workdir: string }, workstream: string): void { + this.#db + .prepare( + `INSERT INTO workstream_folders (account_id, project_id, workdir, workstream) VALUES (?, ?, ?, ?) + ON CONFLICT (account_id, project_id, workdir) DO UPDATE SET workstream = excluded.workstream, updated_at = datetime('now')`, + ) + .run(scope.accountId, scope.projectId, scope.workdir, workstream); + } + workdirMcpApprovals(scope: { accountId: string; projectId: string; workdir: string }): Set { const rows = this.#db .prepare("SELECT server, fingerprint FROM mcp_workdir_approvals WHERE account_id = ? AND project_id = ? AND workdir = ?") diff --git a/src/daemon/workstreams/override.ts b/src/daemon/workstreams/override.ts new file mode 100644 index 00000000..4373b541 --- /dev/null +++ b/src/daemon/workstreams/override.ts @@ -0,0 +1,61 @@ +/** + * Override tokens (docs/workstreams-design.md G3): confirming a session's + * home past another workstream's exclusive claim on its folder. + * + * A conflicting `session.create` is refused with a token; the confirming + * retry presents it. The token is held here (in memory: a daemon restart just + * means asking again), used once, valid for minutes, and bound to who asked, + * the folder, the choice and the config revision — so the warning confirmed + * is the one shown, for this folder and this choice, under this config. + */ + +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +export const OVERRIDE_TTL_MS = 5 * 60_000; +const MAX_OUTSTANDING = 256; + +export interface OverrideBinding { + sub: string; + workdir: string; + chosen: string; + revision: string; +} + +const hashOf = (token: string) => createHash("sha256").update(token).digest("hex"); +const keyOf = (b: OverrideBinding) => JSON.stringify([b.sub, b.workdir, b.chosen, b.revision]); + +export class OverrideTokens { + readonly #tokens = new Map(); + readonly #now: () => number; + + constructor(now: () => number = Date.now) { + this.#now = now; + } + + issue(binding: OverrideBinding): string { + this.#sweep(); + // Bounded: the oldest outstanding token goes first. + while (this.#tokens.size >= MAX_OUTSTANDING) this.#tokens.delete(this.#tokens.keys().next().value as string); + const token = randomBytes(24).toString("base64url"); + this.#tokens.set(hashOf(token), { key: keyOf(binding), expires: this.#now() + OVERRIDE_TTL_MS }); + return token; + } + + /** True once, for a live token bound to exactly this request. A wrong binding spends nothing. */ + redeem(token: string, binding: OverrideBinding): boolean { + this.#sweep(); + const h = hashOf(token); + const held = this.#tokens.get(h); + if (!held) return false; + const want = Buffer.from(keyOf(binding)); + const have = Buffer.from(held.key); + if (want.length !== have.length || !timingSafeEqual(want, have)) return false; + this.#tokens.delete(h); + return true; + } + + #sweep(): void { + const now = this.#now(); + for (const [h, t] of this.#tokens) if (t.expires <= now) this.#tokens.delete(h); + } +} diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index 84019030..92f349d3 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -238,7 +238,12 @@ export class WorkstreamService { home: explicit, source: "explicit", ...base, - conflict: { folder: claimant, chosen: explicit, overridable: this.#effective.get(claimant)?.allowOverride ?? true }, + conflict: { + folder: claimant, + chosen: explicit, + overridable: this.#effective.get(claimant)?.allowOverride ?? true, + changes: this.#overrideChanges(claimant, explicit), + }, }; } const by = matchedBy(explicit); @@ -259,13 +264,34 @@ export class WorkstreamService { * with no choice, gets no home and a warning: W1 requires the choice once * isolation is on. */ - forCreate(workdir: string, explicit?: string, given?: string, required = false): CreateBinding { + /** What homing a session in `chosen` instead of `folder` gives and withholds (§5.2 (c)). */ + #overrideChanges(folder: string, chosen: string): { gains: string[]; withholds: string[]; env: string[]; git?: string } { + const f = this.#effective.get(folder); + const c = this.#effective.get(chosen); + const cs = new Set(c?.servers.map((s) => s.server) ?? []); + return { + gains: [...cs], + withholds: (f?.servers ?? []).map((s) => s.server).filter((s) => !cs.has(s)), + env: Object.keys(c?.env ?? {}), + ...(c?.git?.email ? { git: c.git.email } : {}), + }; + } + + /** + * `overridden`: the caller confirmed the choice past an exclusive claim + * (G3, a redeemed token — checked by the manager). The home is then the + * choice, `source: "override"`, unless the claimant allows no override. + */ + forCreate(workdir: string, explicit?: string, given?: string, required = false, overridden = false): CreateBinding { if (explicit !== undefined) { const problem = this.choiceProblem(explicit); if (problem) return { ok: false, kind: "workstream_unknown", workstream: explicit, error: problem }; } if (!this.configured) return { ok: true, binding: undefined, warnings: [] }; const resolution = this.resolve(workdir, explicit, given); + if (resolution.conflict && overridden && resolution.conflict.overridable && explicit !== undefined) { + return { ok: true, binding: { id: explicit, source: "override", revision: this.#revision }, resolution, warnings: [] }; + } if (resolution.conflict) { const c = resolution.conflict; return { diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts index 6a7c1631..6ba3016d 100644 --- a/src/daemon/workstreams/workstreams.test.ts +++ b/src/daemon/workstreams/workstreams.test.ts @@ -334,7 +334,7 @@ describe("WorkstreamService", () => { ]); const c = s.forCreate(join(tmp, "client"), "mine"); expect(c.ok).toBe(false); - if (!c.ok && c.kind === "workstream_conflict") expect(c.resolution.conflict).toEqual({ folder: "client", chosen: "mine", overridable: false }); + if (!c.ok && c.kind === "workstream_conflict") expect(c.resolution.conflict).toMatchObject({ folder: "client", chosen: "mine", overridable: false }); const r = s.forCreate(join(tmp, "research"), "mine"); expect(r.ok && r.binding).toMatchObject({ id: "mine", source: "explicit" }); expect(s.forCreate(join(tmp, "client"), "client").ok).toBe(true); diff --git a/src/terminal/client.ts b/src/terminal/client.ts index 3005cd97..06173d5f 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -521,6 +521,13 @@ export class TerminalClient { sandbox?: SessionSandboxSetting; /** Home workstream (docs/workstreams-design.md); absent = the folder's. */ workstream?: string; + /** + * Asked when the choice conflicts with the folder's exclusive workstream + * and may be overridden (G3): shown what would change; true = confirm. + */ + confirmOverride?: (warning: string) => Promise; + /** Internal: the confirming retry's token. */ + overrideToken?: string; } = {}, ): Promise { const resp = await this.#request({ @@ -541,8 +548,22 @@ export class TerminalClient { ...(opts.collaboration ? { collaboration: opts.collaboration } : {}), ...(opts.sandbox ? { sandbox: opts.sandbox } : {}), ...(opts.workstream ? { workstream: opts.workstream } : {}), + ...(opts.overrideToken ? { override: { token: opts.overrideToken } } : {}), }); + const conflict = resp.type === "response.error" && resp.detail?.kind === "workstream_conflict" ? resp.detail.resolution.conflict : undefined; + if (conflict?.overrideToken && opts.confirmOverride && !opts.overrideToken) { + const ch = conflict.changes; + const warning = [ + `⚠ This folder belongs to workstream "${S(conflict.folder)}" (exclusive).`, + ` A session here in "${S(conflict.chosen)}" gets ${ch && ch.gains.length > 0 ? `its servers (${S(ch.gains.join(", "))})` : "no servers of the folder's workstream"}${ch && ch.env.length > 0 ? ` and env ${S(ch.env.join(", "))}` : ""}${ch?.git ? `, commits as ${S(ch.git)}` : ""},`, + ` writes into "${S(conflict.folder)}"'s code${ch && ch.withholds.length > 0 ? `, and is not given ${S(ch.withholds.join(", "))}` : ""}.`, + ].join("\n"); + if (await opts.confirmOverride(warning)) { + return this.createSession(name, workdir, { ...opts, overrideToken: conflict.overrideToken }); + } + } + if (resp.type === "response.ok") { const data = resp.data as SessionInfo; const profile = data.profile ? ` [pack: ${data.profile}]` : ""; diff --git a/src/tests/workstreams-manager.test.ts b/src/tests/workstreams-manager.test.ts index 7625c2b4..9f22e943 100644 --- a/src/tests/workstreams-manager.test.ts +++ b/src/tests/workstreams-manager.test.ts @@ -101,7 +101,7 @@ describe("session.create picks the home", () => { const resp = await create(manager(WS), "b", "client", { workstream: "mine" }); expect(resp).toMatchObject({ type: "response.error", code: "invalid_request", detail: { kind: "workstream_conflict" } }); if (resp.type === "response.error" && resp.detail?.kind === "workstream_conflict") { - expect(resp.detail.resolution.conflict).toEqual({ folder: "client", chosen: "mine", overridable: true }); + expect(resp.detail.resolution.conflict).toMatchObject({ folder: "client", chosen: "mine", overridable: true }); } }); @@ -315,3 +315,77 @@ describe("the home's environment reaches the backend (W1, §6.2)", () => { expect(load({ items: [{ id: "a", env: { GH_TOKEN: { secret: "nope" } } }] })).toThrow(/undeclared credential "nope"/); }); }); + +describe("a confirmed override (G3)", () => { + const conflictOf = (r: DaemonMessage) => (r.type === "response.error" && r.detail?.kind === "workstream_conflict" ? r.detail.resolution.conflict : undefined); + + it("a conflict comes with a token; the confirming retry homes the session by override, once", async () => { + const m = manager(WS); + const first = await create(m, "o1", "client", { workstream: "mine" }); + const c = conflictOf(first); + expect(c?.overrideToken).toBeString(); + expect(c?.changes).toEqual({ gains: [], withholds: [], env: [] }); + const ok = await create(m, "o1", "client", { workstream: "mine", override: { token: c!.overrideToken } }); + expect((ok as { data: SessionInfo }).data.workstream).toMatchObject({ id: "mine", source: "override" }); + const rows = store.database.prepare("SELECT detail FROM audit_log WHERE action = 'workstream.override'").all() as Array<{ detail: string }>; + expect(rows[0]!.detail).toContain("folder=client chosen=mine"); + const again = await create(m, "o2", "client", { workstream: "mine", override: { token: c!.overrideToken } }); + expect(again).toMatchObject({ type: "response.error", code: "invalid_request" }); + }); + + it("the token is bound: another folder or choice, a delegated token, or a missing approve scope can't use it", async () => { + const m = manager({ items: [...WS.items, { id: "third" }] }); + const c = conflictOf(await create(m, "b1", "client", { workstream: "mine" })); + const token = c!.overrideToken!; + expect(await create(m, "b2", "client", { workstream: "third", override: { token } })).toMatchObject({ type: "response.error", code: "invalid_request" }); + const delegated = await m.handle( + { type: "session.create", id: "b3", name: "b3", workdir: join(tmp, "home", "client"), workstream: "mine", override: { token } } as never, + { ...OWNER, delegationDepth: 1 }, + client, + ); + expect(delegated).toMatchObject({ type: "response.error", code: "forbidden" }); + const noApprove = await m.handle( + { type: "session.create", id: "b4", name: "b4", workdir: join(tmp, "home", "client"), workstream: "mine", override: { token } } as never, + { ...OWNER, scopes: [SCOPES.SESSION_CREATE] as AuthContext["scopes"] }, + client, + ); + expect(noApprove).toMatchObject({ type: "response.error", code: "forbidden" }); + // Not spent by the refusals above: the right request still takes it. + expect((await create(m, "b5", "client", { workstream: "mine", override: { token } })).type).toBe("response.ok"); + }); + + it("no token at all when the folder's workstream allows no override", async () => { + const m = manager({ items: WS.items.map((w) => (w.id === "client" ? { ...w, allowOverride: false } : w)) }); + const c = conflictOf(await create(m, "n1", "client", { workstream: "mine" })); + expect(c?.overridable).toBe(false); + expect(c?.overrideToken).toBeUndefined(); + }); +}); + +describe("the folder record: a repository match that changed is refused, not a silent re-home", () => { + it("an edited origin makes the next create choose; the choice is recorded", async () => { + const repo = join(tmp, "home", "repo"); + mkdirSync(join(repo, ".git"), { recursive: true }); + const origin = (url: string) => writeFileSync(join(repo, ".git", "config"), `[remote "origin"]\n\turl = ${url}\n`); + origin("https://github.com/mine/app"); + const m = manager({ items: [{ id: "mine", match: { remotes: ["github.com/mine/*"] } }, { id: "corp", match: { remotes: ["github.com/corp/*"] } }] }); + expect(((await create(m, "r1", "repo")) as { data: SessionInfo }).data.workstream).toMatchObject({ id: "mine" }); + origin("https://github.com/corp/app"); + const refused = await create(m, "r2", "repo"); + expect(refused).toMatchObject({ type: "response.error", detail: { kind: "workstream_required" } }); + if (refused.type === "response.error") expect(refused.error).toContain('was workstream "mine"'); + expect(((await create(m, "r3", "repo", { workstream: "corp" })) as { data: SessionInfo }).data.workstream).toMatchObject({ id: "corp" }); + expect(((await create(m, "r4", "repo")) as { data: SessionInfo }).data.workstream).toMatchObject({ id: "corp", source: "matched" }); + }); +}); + +describe("under isolation a user's create must have a home", () => { + it("an unmatched folder is refused with the choices; a choice settles it", async () => { + const m = manager({ ...WS, isolateGlobalServers: true }); + const r = await create(m, "i1", "loose"); + expect(r).toMatchObject({ type: "response.error", detail: { kind: "workstream_required" } }); + if (r.type === "response.error") expect(r.error).toContain("client, mine"); + expect((await create(m, "i2", "loose", { workstream: "mine" })).type).toBe("response.ok"); + }); +}); + From c333aa1e8e933307fbfc725c7b148dabdd2d3782 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 13:13:13 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20workstreams=20W1=20=E2=80=94=20OA?= =?UTF-8?q?uth=20credential=20slots:=20a=20second=20sign-in=20to=20one=20s?= =?UTF-8?q?erver,=20one=20per=20workstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcp_oauth_credentials gains a credential slot in its primary key, rebuilt once in one transaction; every existing row is the default slot ('') - the OAuth engine reads, refreshes, stamps and signs out per slot; a pending sign-in carries its slot, so a completion always lands in the slot it began for - a workstream's server with an oauth credential is called with that slot's sign-in (on the hub, the Claude bridge and the relay), never the default - mcp.oauth.begin / disconnect and codeoid mcp signin --credential take a slot, refused unless it is declared as an oauth credential for that server Co-Authored-By: Claude Opus 5.5 --- packages/protocol/src/mcp-oauth.ts | 8 +++ packages/protocol/src/schemas.ts | 2 + src/cli.ts | 3 +- src/daemon/mcp/oauth.ts | 63 ++++++++++++++------- src/daemon/mcp/session-view.ts | 11 +++- src/daemon/session-manager.ts | 17 ++++-- src/daemon/store.ts | 75 +++++++++++++++++++------ src/daemon/workstreams/grant.ts | 2 +- src/terminal/client.ts | 7 ++- src/tests/mcp-oauth.test.ts | 47 +++++++++++++++- src/tests/workstreams-isolation.test.ts | 20 ++++++- src/tests/workstreams-manager.test.ts | 15 +++++ 12 files changed, 219 insertions(+), 51 deletions(-) diff --git a/packages/protocol/src/mcp-oauth.ts b/packages/protocol/src/mcp-oauth.ts index 1897ed46..6624e683 100644 --- a/packages/protocol/src/mcp-oauth.ts +++ b/packages/protocol/src/mcp-oauth.ts @@ -26,6 +26,12 @@ export interface McpOAuthBeginMsg { id: string; /** Registry server name. */ server: string; + /** + * A named OAuth credential (a slot: a second sign-in to this server, e.g. + * one per workstream — docs/workstreams-design.md §6.5). Absent = the + * server's default sign-in. + */ + credential?: string; } export interface McpOAuthCompleteMsg { @@ -39,6 +45,8 @@ export interface McpOAuthDisconnectMsg { type: "mcp.oauth.disconnect"; id: string; server: string; + /** The slot to sign out of; absent = the default sign-in. */ + credential?: string; } /** diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 2611c03f..b82590e5 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -738,6 +738,7 @@ export const mcpOAuthBeginSchema = z.object({ ...base, type: z.literal("mcp.oauth.begin"), server: mcpServerNameField, + credential: z.string().min(1).max(64).optional(), }); export const mcpOAuthCompleteSchema = z.object({ @@ -751,6 +752,7 @@ export const mcpOAuthDisconnectSchema = z.object({ ...base, type: z.literal("mcp.oauth.disconnect"), server: mcpServerNameField, + credential: z.string().min(1).max(64).optional(), }); export const mcpAdoptSchema = z.object({ diff --git a/src/cli.ts b/src/cli.ts index 4a3d0c91..e433aa00 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1808,7 +1808,8 @@ mcp .command("signin ") .description("Sign in to an OAuth MCP server in codeoid's registry (one you adopted, or configured with oauth)") .option("--callback-url ", "finish a sign-in from the address the browser landed on") - .action((server: string, opts: { callbackUrl?: string }) => withClient((c) => c.mcpSignin(server, opts.callbackUrl))); + .option("--credential ", "sign in to one of the server's named OAuth credentials (a workstream's own sign-in) instead of its default") + .action((server: string, opts: { callbackUrl?: string; credential?: string }) => withClient((c) => c.mcpSignin(server, opts.callbackUrl, opts.credential))); mcp .command("reconnect ") diff --git a/src/daemon/mcp/oauth.ts b/src/daemon/mcp/oauth.ts index f561ec0e..38efcf89 100644 --- a/src/daemon/mcp/oauth.ts +++ b/src/daemon/mcp/oauth.ts @@ -18,7 +18,7 @@ import type { OAuthClientMetadata, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; -import type { Store } from "../store.js"; +import type { McpOAuthServerKey, Store } from "../store.js"; import type { McpOAuthConfig, McpServerSpec } from "./types.js"; export interface McpTenant { @@ -76,6 +76,8 @@ export interface McpOAuthOptions { interface PendingSignIn { server: string; + /** The credential slot being signed in (workstreams §6.5 case 2); absent = the default. */ + slot?: string; tenant: McpTenant; state: string; /** Secret the starting browser holds (as a cookie); see {@link McpOAuth.complete}. */ @@ -154,8 +156,10 @@ export class McpOAuth implements McpCredentialSource { const oauth = oauthOf(spec); this.#sweepPending(); const state = randomBytes(24).toString("base64url"); + const slot = slotOf(spec); const pending: PendingSignIn = { server: spec.name, + ...(slot ? { slot } : {}), tenant, state, binding: randomBytes(32).toString("base64url"), @@ -186,7 +190,7 @@ export class McpOAuth implements McpCredentialSource { input: { callbackUrl: string } | { state: string; code?: string; error?: string; errorDescription?: string }, proof: McpOAuthProof, resolveSpec: (server: string) => McpServerSpec | undefined, - ): Promise<{ server: string; tenant: McpTenant }> { + ): Promise<{ server: string; tenant: McpTenant; slot?: string }> { const params = "callbackUrl" in input ? parseCallback(input.callbackUrl) : input; const pending = params.state ? this.#pending.get(params.state) : undefined; if (!pending || pending.expiresAt < this.#now()) { @@ -203,8 +207,10 @@ export class McpOAuth implements McpCredentialSource { throw new Error(`Sign-in to ${pending.server} failed: ${params.errorDescription ?? params.error}`); } if (!params.code) throw new Error(`Sign-in to ${pending.server} returned no authorization code.`); - const spec = resolveSpec(pending.server); - if (!spec) throw new Error(`MCP server "${pending.server}" is no longer configured.`); + const base = resolveSpec(pending.server); + if (!base) throw new Error(`MCP server "${pending.server}" is no longer configured.`); + // The slot the sign-in was started for, never the default one. + const spec = pending.slot ? withSlot(base, pending.slot) : base; const oauth = oauthOf(spec); const provider = this.#provider(spec, oauth, pending.tenant, pending); const result = await auth(provider, { @@ -214,13 +220,13 @@ export class McpOAuth implements McpCredentialSource { fetchFn: this.#fetch, }); if (result !== "AUTHORIZED") throw new Error(`Sign-in to ${pending.server} did not complete.`); - this.#rejected.delete(keyOf(spec.name, pending.tenant)); - this.#bumpSignIn(spec.name, pending.tenant); - return { server: pending.server, tenant: pending.tenant }; + this.#rejected.delete(keyOf(spec, pending.tenant)); + this.#bumpSignIn(spec, pending.tenant); + return { server: pending.server, tenant: pending.tenant, ...(pending.slot ? { slot: pending.slot } : {}) }; } async accessToken(spec: McpServerSpec, tenant: McpTenant): Promise { - const key = keyOf(spec.name, tenant); + const key = keyOf(spec, tenant); const tokens = this.#tokens(spec, tenant); if (!tokens) return undefined; const stale = this.#rejected.get(key) === tokens.access_token; @@ -234,7 +240,7 @@ export class McpOAuth implements McpCredentialSource { } rejected(spec: McpServerSpec, tenant: McpTenant, token: string): void { - this.#rejected.set(keyOf(spec.name, tenant), token); + this.#rejected.set(keyOf(spec, tenant), token); } /** Whether the tenant has a credential the daemon can use (or refresh). */ @@ -251,19 +257,19 @@ export class McpOAuth implements McpCredentialSource { credentialStamp(spec: McpServerSpec, tenant: McpTenant): string | undefined { if (!this.signedIn(spec, tenant)) return undefined; - return String(this.#signIns.get(keyOf(spec.name, tenant)) ?? 0); + return String(this.#signIns.get(keyOf(spec, tenant)) ?? 0); } - #bumpSignIn(server: string, tenant: McpTenant): void { + #bumpSignIn(server: McpServerSpec, tenant: McpTenant): void { const key = keyOf(server, tenant); this.#signIns.set(key, (this.#signIns.get(key) ?? 0) + 1); } /** Forget the tenant's tokens for a server (its client registration stays). */ disconnect(spec: McpServerSpec, tenant: McpTenant): void { - this.#bumpSignIn(spec.name, tenant); + this.#bumpSignIn(spec, tenant); this.#store.clearMcpOAuth(tenant, serverOf(spec), "tokens"); - this.#rejected.delete(keyOf(spec.name, tenant)); + this.#rejected.delete(keyOf(spec, tenant)); } // ── internals ────────────────────────────────────────────────────────────── @@ -299,7 +305,7 @@ export class McpOAuth implements McpCredentialSource { console.error(`[codeoid] mcp oauth: refreshing ${spec.name} failed: ${err instanceof Error ? err.message : String(err)}`); return undefined; } - this.#rejected.delete(keyOf(spec.name, tenant)); + this.#rejected.delete(keyOf(spec, tenant)); return this.#tokens(spec, tenant)?.access_token; } @@ -330,6 +336,7 @@ export class McpOAuth implements McpCredentialSource { store: this.#store, server: spec.name, serverUrl: urlOf(spec), + ...(slotOf(spec) ? { slot: slotOf(spec) } : {}), tenant, pending, redirectUrl: this.redirectUrl, @@ -345,6 +352,8 @@ interface StoredProviderOptions { store: Store; server: string; serverUrl: string; + /** The credential slot read and written (workstreams §6.5 case 2); absent = the default. */ + slot?: string; tenant: McpTenant; pending: PendingSignIn; redirectUrl: string; @@ -363,8 +372,8 @@ class StoredProvider implements OAuthClientProvider { this.#o = o; } - get #server(): { name: string; url: string } { - return { name: this.#o.server, url: this.#o.serverUrl }; + get #server(): McpOAuthServerKey { + return { name: this.#o.server, url: this.#o.serverUrl, ...(this.#o.slot ? { slot: this.#o.slot } : {}) }; } get redirectUrl(): string { @@ -462,8 +471,21 @@ function scopeOf(oauth: McpOAuthConfig): string | undefined { } /** A server's store address: its name and the URL its tokens are bound to. */ -function serverOf(spec: McpServerSpec): { name: string; url: string } { - return { name: spec.name, url: urlOf(spec) }; +/** Which stored credential a spec is called with: its server and URL, and its slot (workstreams §6.5 case 2). */ +function serverOf(spec: McpServerSpec): McpOAuthServerKey { + const slot = slotOf(spec); + return { name: spec.name, url: urlOf(spec), ...(slot ? { slot } : {}) }; +} + +/** `spec` called with credential slot `slot` (an OAuth server's second sign-in). */ +export function withSlot(spec: McpServerSpec, slot: string): McpServerSpec { + if (spec.transport.kind !== "http") return spec; + return { ...spec, transport: { ...spec.transport, credential: { id: slot } }, variant: `${spec.variant ?? ""}#oauth:${slot}` }; +} + +/** The credential slot a spec is called with: its transport's named credential; none = the default sign-in. */ +export function slotOf(spec: McpServerSpec): string | undefined { + return spec.transport.kind === "http" ? spec.transport.credential?.id : undefined; } function safeEqual(a: string, b: string): boolean { @@ -472,8 +494,9 @@ function safeEqual(a: string, b: string): boolean { return x.length === y.length && timingSafeEqual(x, y); } -function keyOf(server: string, tenant: McpTenant): string { - return `${tenant.accountId}\u0000${tenant.projectId}\u0000${server}`; +function keyOf(spec: McpServerSpec | { name: string; slot?: string }, tenant: McpTenant): string { + const slot = "transport" in spec ? slotOf(spec) : spec.slot; + return `${tenant.accountId}\u0000${tenant.projectId}\u0000${spec.name}\u0000${slot ?? ""}`; } function sameTenant(a: McpTenant, b: McpTenant): boolean { diff --git a/src/daemon/mcp/session-view.ts b/src/daemon/mcp/session-view.ts index d08dbcbc..11463904 100644 --- a/src/daemon/mcp/session-view.ts +++ b/src/daemon/mcp/session-view.ts @@ -32,6 +32,7 @@ import type { RawMcpServerConfig } from "../../config.js"; import type { McpHub } from "./hub.js"; import type { McpGrant } from "../workstreams/grant.js"; +import { withSlot } from "./oauth.js"; import type { McpTenant } from "./oauth.js"; import { isReservedMcpName, normalizeSpec, type McpRegistry, type McpServerView } from "./registry.js"; import { isOAuthServer, type McpServerSpec } from "./types.js"; @@ -439,8 +440,8 @@ export function secretFree(spec: McpServerSpec): boolean { * servers when the folder is the home's; any other only by name. A selected * server called with a named credential gets it as a REFERENCE on its * transport, and a variant of its own, so the hub keeps one client per - * credential. An OAuth slot (a second sign-in) is not supported yet: such a - * server is withheld, never called with the default sign-in instead. + * credential. An OAuth server with a slot is called with that slot's own + * sign-in (withSlot) — never with the default one instead. */ function applyGrant(specs: readonly McpServerSpec[], grant: McpGrant): McpServerSpec[] { const out: McpServerSpec[] = []; @@ -454,7 +455,11 @@ function applyGrant(specs: readonly McpServerSpec[], grant: McpGrant): McpServer continue; } const sel = grant.servers.get(spec.name); - if (!sel || sel.oauthSlot) continue; + if (!sel) continue; + if (sel.oauthSlot) { + if (isOAuthServer(spec)) out.push(withSlot(spec, sel.oauthSlot)); + continue; // a slot names a sign-in; a server without OAuth has none to take + } if (sel.credential && spec.transport.kind === "http" && !isOAuthServer(spec)) { out.push({ ...spec, diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index e7e88979..b31552ae 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -12,6 +12,7 @@ import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootPr import { WorkstreamService, type WorkstreamBinding } from "./workstreams/service.js"; import { grantRefusal, type McpGrant } from "./workstreams/grant.js"; import { OverrideTokens } from "./workstreams/override.js"; +import { withSlot } from "./mcp/oauth.js"; import type { FolderReader } from "./workstreams/folder.js"; import { createSecretStore, type SecretStore } from "./credentials/store.js"; import { registerSecretPaths } from "./secret-paths.js"; @@ -3570,6 +3571,7 @@ mcpHub: this.#mcpHub, requestId: string, server: string, auth: AuthContext, + credential?: string, ): { spec: McpServerSpec; oauth: McpOAuth } | DaemonMessage { if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_WRITE)) return this.#loginForbidden(requestId); const spec = this.#mcpRegistry?.get(server); @@ -3581,18 +3583,25 @@ mcpHub: this.#mcpHub, code: "invalid_request", }; } - return { spec, oauth: this.#mcpOAuth }; + if (credential === undefined) return { spec, oauth: this.#mcpOAuth }; + // A slot (workstreams §6.5 case 2) must be declared, as an OAuth + // credential, for exactly this server. + const decl = this.#config?.credentials?.find((c) => c.id === credential); + if (!decl || decl.kind !== "oauth" || decl.server !== server) { + return { type: "response.error", requestId, error: `"${credential}" is not an OAuth credential declared for "${server}" (config.json → credentials)`, code: "invalid_request" }; + } + return { spec: withSlot(spec, credential), oauth: this.#mcpOAuth }; } async #mcpOAuthBegin( msg: Extract, auth: AuthContext, ): Promise { - const target = this.#oauthTarget(msg.id, msg.server, auth); + const target = this.#oauthTarget(msg.id, msg.server, auth, msg.credential); if ("type" in target) return target; try { const begun = await target.oauth.begin(target.spec, tenantOf(auth)); - this.#store.audit(auth.sub, "mcp.oauth.begin", "", `server=${msg.server} status=${begun.status}`); + this.#store.audit(auth.sub, "mcp.oauth.begin", "", `server=${msg.server}${msg.credential ? ` credential=${msg.credential}` : ""} status=${begun.status}`); if (begun.status === "connected") return { type: "mcp.oauth.begin.result", requestId: msg.id, status: "connected" }; return { type: "mcp.oauth.begin.result", @@ -3642,7 +3651,7 @@ mcpHub: this.#mcpHub, msg: Extract, auth: AuthContext, ): DaemonMessage { - const target = this.#oauthTarget(msg.id, msg.server, auth); + const target = this.#oauthTarget(msg.id, msg.server, auth, msg.credential); if ("type" in target) return target; const tenant = tenantOf(auth); target.oauth.disconnect(target.spec, tenant); diff --git a/src/daemon/store.ts b/src/daemon/store.ts index a12315fe..81db875a 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -231,6 +231,13 @@ function enableWalWithRetry(db: Database, dbPath: string): void { ); } +/** Which stored OAuth credential: a server at a URL, and a slot ('' / absent = its default sign-in). */ +export interface McpOAuthServerKey { + name: string; + url: string; + slot?: string; +} + export class Store { #db: Database; @@ -454,11 +461,14 @@ export class Store { project_id TEXT NOT NULL, server TEXT NOT NULL, server_url TEXT NOT NULL, + -- A named credential's slot (workstreams §6.5 case 2: a second + -- sign-in to one server). '' = the server's default sign-in. + credential TEXT NOT NULL DEFAULT '', client_json TEXT, tokens_json TEXT, tokens_saved_at INTEGER, updated_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (account_id, project_id, server, server_url) + PRIMARY KEY (account_id, project_id, server, server_url, credential) ); -- Durable conductor identity (design R2): one row per tenant, reloaded @@ -618,6 +628,37 @@ export class Store { } } + // ── mcp_oauth_credentials: a credential slot in the key (rebuild) ───── + // Workstreams give one server several sign-ins (§6.5 case 2). The slot is + // part of the primary key, which SQLite can't change by ALTER, so the + // table is rebuilt once, in one transaction: every existing row becomes + // the default slot (''), exactly what it was. + { + const cols = this.#db.prepare("PRAGMA table_info(mcp_oauth_credentials)").all() as Array<{ name: string }>; + if (cols.length > 0 && !cols.some((c) => c.name === "credential")) { + this.#db.transaction(() => { + this.#db.exec(` + CREATE TABLE mcp_oauth_credentials_rebuild ( + account_id TEXT NOT NULL, + project_id TEXT NOT NULL, + server TEXT NOT NULL, + server_url TEXT NOT NULL, + credential TEXT NOT NULL DEFAULT '', + client_json TEXT, + tokens_json TEXT, + tokens_saved_at INTEGER, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (account_id, project_id, server, server_url, credential) + ); + INSERT INTO mcp_oauth_credentials_rebuild (account_id, project_id, server, server_url, credential, client_json, tokens_json, tokens_saved_at, updated_at) + SELECT account_id, project_id, server, server_url, '', client_json, tokens_json, tokens_saved_at, updated_at FROM mcp_oauth_credentials; + DROP TABLE mcp_oauth_credentials; + ALTER TABLE mcp_oauth_credentials_rebuild RENAME TO mcp_oauth_credentials; + `); + })(); + } + } + this.#addColumnIfMissing("dispatch_tasks", "group_id", "TEXT"); this.#addColumnIfMissing("dispatch_tasks", "group_ordinal", "INTEGER"); // Owning daemon (host:port) — the executor scope for claim + reclaim. @@ -1814,14 +1855,14 @@ export class Store { /** A tenant's stored OAuth state for one MCP server, or undefined. */ getMcpOAuthCredential( tenant: { accountId: string; projectId: string }, - server: { name: string; url: string }, + server: McpOAuthServerKey, ): { client?: Record; tokens?: Record; tokensSavedAt?: number } | undefined { const row = this.#db .prepare( `SELECT client_json, tokens_json, tokens_saved_at FROM mcp_oauth_credentials - WHERE account_id = ? AND project_id = ? AND server = ? AND server_url = ?`, + WHERE account_id = ? AND project_id = ? AND server = ? AND server_url = ? AND credential = ?`, ) - .get(tenant.accountId, tenant.projectId, server.name, server.url) as + .get(tenant.accountId, tenant.projectId, server.name, server.url, server.slot ?? "") as | { client_json: string | null; tokens_json: string | null; tokens_saved_at: number | null } | null; if (!row) return undefined; @@ -1835,36 +1876,36 @@ export class Store { /** Save the client a server registered for this tenant (dynamic registration). */ saveMcpOAuthClient( tenant: { accountId: string; projectId: string }, - server: { name: string; url: string }, + server: McpOAuthServerKey, client: object, ): void { this.#db .prepare( - `INSERT INTO mcp_oauth_credentials (account_id, project_id, server, server_url, client_json, updated_at) - VALUES (?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT(account_id, project_id, server, server_url) DO UPDATE SET + `INSERT INTO mcp_oauth_credentials (account_id, project_id, server, server_url, credential, client_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(account_id, project_id, server, server_url, credential) DO UPDATE SET client_json = excluded.client_json, updated_at = excluded.updated_at`, ) - .run(tenant.accountId, tenant.projectId, server.name, server.url, JSON.stringify(client)); + .run(tenant.accountId, tenant.projectId, server.name, server.url, server.slot ?? "", JSON.stringify(client)); } /** Save a tenant's tokens for a server, stamped with when they were issued. */ saveMcpOAuthTokens( tenant: { accountId: string; projectId: string }, - server: { name: string; url: string }, + server: McpOAuthServerKey, tokens: object, savedAtMs: number, ): void { this.#db .prepare( - `INSERT INTO mcp_oauth_credentials (account_id, project_id, server, server_url, tokens_json, tokens_saved_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT(account_id, project_id, server, server_url) DO UPDATE SET + `INSERT INTO mcp_oauth_credentials (account_id, project_id, server, server_url, credential, tokens_json, tokens_saved_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(account_id, project_id, server, server_url, credential) DO UPDATE SET tokens_json = excluded.tokens_json, tokens_saved_at = excluded.tokens_saved_at, updated_at = excluded.updated_at`, ) - .run(tenant.accountId, tenant.projectId, server.name, server.url, JSON.stringify(tokens), savedAtMs); + .run(tenant.accountId, tenant.projectId, server.name, server.url, server.slot ?? "", JSON.stringify(tokens), savedAtMs); } /** @@ -1874,11 +1915,11 @@ export class Store { */ clearMcpOAuth( tenant: { accountId: string; projectId: string }, - server: { name: string; url: string }, + server: McpOAuthServerKey, what: "tokens" | "client" | "all", ): void { - const where = "account_id = ? AND project_id = ? AND server = ? AND server_url = ?"; - const args = [tenant.accountId, tenant.projectId, server.name, server.url] as const; + const where = "account_id = ? AND project_id = ? AND server = ? AND server_url = ? AND credential = ?"; + const args = [tenant.accountId, tenant.projectId, server.name, server.url, server.slot ?? ""] as const; if (what === "all") { this.#db.prepare(`DELETE FROM mcp_oauth_credentials WHERE ${where}`).run(...args); return; diff --git a/src/daemon/workstreams/grant.ts b/src/daemon/workstreams/grant.ts index 9edc0b5a..43ba2604 100644 --- a/src/daemon/workstreams/grant.ts +++ b/src/daemon/workstreams/grant.ts @@ -41,7 +41,7 @@ export function grantRefusal(grant: McpGrant | undefined, spec: { name: string; } const sel = grant.servers.get(name); const credential = "credential" in spec.transport ? spec.transport.credential?.id : undefined; - if (sel && !sel.oauthSlot && (sel.credential?.id ?? undefined) === credential) return undefined; + if (sel && (sel.oauthSlot ?? sel.credential?.id) === credential) return undefined; if (sel) return `MCP server "${name}" is now called with another credential in workstream "${grant.workstream}" — it applies from the next turn`; return grant.workstream ? `MCP server "${name}" is not in workstream "${grant.workstream}"` diff --git a/src/terminal/client.ts b/src/terminal/client.ts index 06173d5f..b6e39106 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -981,20 +981,21 @@ export class TerminalClient { * finishes a sign-in on its own only for the browser the web UI started it * from. `callbackUrl` finishes one started earlier, without a terminal. */ - async mcpSignin(server: string, callbackUrl?: string): Promise { + async mcpSignin(server: string, callbackUrl?: string, credential?: string): Promise { if (callbackUrl) { await this.#finishMcpSignin(callbackUrl); return; } // Discovery and client registration are a few round trips to the provider. - const begun = await this.#request({ type: "mcp.oauth.begin", id: randomUUID(), server }, 45_000); + const begun = await this.#request({ type: "mcp.oauth.begin", id: randomUUID(), server, ...(credential ? { credential } : {}) }, 45_000); if (begun.type !== "mcp.oauth.begin.result") { this.#printError(begun); process.exitCode = 1; return; } + const as = credential ? ` (credential ${S(credential)})` : ""; if (begun.status === "connected" || !begun.url) { - console.log(`${S(server)} is signed in.`); + console.log(`${S(server)}${as} is signed in.`); return; } console.log(`Open this address to sign in to ${S(server)}:\n\n ${begun.url}\n`); diff --git a/src/tests/mcp-oauth.test.ts b/src/tests/mcp-oauth.test.ts index d3d95ac8..175372ba 100644 --- a/src/tests/mcp-oauth.test.ts +++ b/src/tests/mcp-oauth.test.ts @@ -9,7 +9,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Store } from "../daemon/store.js"; -import { McpOAuth, type McpTenant } from "../daemon/mcp/oauth.js"; +import { McpOAuth, type McpTenant, withSlot } from "../daemon/mcp/oauth.js"; import type { McpServerSpec } from "../daemon/mcp/types.js"; import { McpHub } from "../daemon/mcp/hub.js"; import { McpRegistry } from "../daemon/mcp/registry.js"; @@ -628,3 +628,48 @@ describe("credential binding and limits", () => { hub.closeAll(); }); }); + +describe("credential slots: a second sign-in to one server (workstreams §6.5 case 2)", () => { + it("each slot signs in, stays signed in and signs out on its own; a completion lands in the slot it began for", async () => { + fake = new FakeOAuthMcp(); + const oauth = engine(); + const base = spec(); + const work = withSlot(base, "work"); + await connect(oauth, base); + expect(oauth.status(base, TENANT)).toBe("connected"); + expect(oauth.status(work, TENANT)).toBe("disconnected"); + // complete() resolves the BASE spec by name; the pending sign-in carries the slot. + const begun = await oauth.begin(work, TENANT); + if (begun.status !== "redirect") throw new Error("expected a redirect"); + const done = await oauth.complete({ callbackUrl: await signIn(begun.url) }, { tenant: TENANT }, () => base); + expect(done.slot).toBe("work"); + expect(oauth.status(work, TENANT)).toBe("connected"); + expect(await oauth.accessToken(work, TENANT)).not.toBe(await oauth.accessToken(base, TENANT)); + oauth.disconnect(work, TENANT); + expect(oauth.status(work, TENANT)).toBe("disconnected"); + expect(oauth.status(base, TENANT)).toBe("connected"); + }); +}); + +describe("the slot migration", () => { + it("rebuilds an old table once, every row becoming the default slot", () => { + const { Database } = require("bun:sqlite") as typeof import("bun:sqlite"); + const path = join(tmp, "old.db"); + const db = new Database(path); + db.exec(`CREATE TABLE mcp_oauth_credentials ( + account_id TEXT NOT NULL, project_id TEXT NOT NULL, server TEXT NOT NULL, server_url TEXT NOT NULL, + client_json TEXT, tokens_json TEXT, tokens_saved_at INTEGER, updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (account_id, project_id, server, server_url)); + INSERT INTO mcp_oauth_credentials (account_id, project_id, server, server_url, tokens_json) VALUES ('acc-1', 'proj-1', 'notes', 'https://n/mcp', '{"access_token":"t"}');`); + db.close(); + const migrated = new Store(path); + expect(migrated.getMcpOAuthCredential(TENANT, { name: "notes", url: "https://n/mcp" })?.tokens).toEqual({ access_token: "t" }); + expect(migrated.getMcpOAuthCredential(TENANT, { name: "notes", url: "https://n/mcp", slot: "work" })).toBeUndefined(); + migrated.saveMcpOAuthTokens(TENANT, { name: "notes", url: "https://n/mcp", slot: "work" }, { access_token: "w" }, 1); + expect(migrated.getMcpOAuthCredential(TENANT, { name: "notes", url: "https://n/mcp" })?.tokens).toEqual({ access_token: "t" }); + migrated.close(); + // A second open is a no-op. + new Store(path).close(); + }); +}); + diff --git a/src/tests/workstreams-isolation.test.ts b/src/tests/workstreams-isolation.test.ts index 904a06aa..85fc7631 100644 --- a/src/tests/workstreams-isolation.test.ts +++ b/src/tests/workstreams-isolation.test.ts @@ -87,11 +87,29 @@ describe("the session's view under a grant", () => { expect(JSON.stringify(v.list())).not.toContain("ghp_"); }); - it("an OAuth slot (not supported yet) or a credential a stdio server can't take: withheld, never called without it", async () => { + it("a slot or credential a server can't take is withheld, never called without it", async () => { + // github has no OAuth (a slot names a sign-in), notes is stdio (a credential is an HTTP header). const v = await view({ workstream: "acme", servers: new Map([["github", { oauthSlot: "gh-2" }], ["notes", { credential: { id: "x" } }]]), workdirServers: false }); expect(names(v.list())).toEqual(["codeoid_memory"]); }); + it("an OAuth server with a slot is called with that slot's own sign-in", async () => { + const registry = new McpRegistry({ slack: raw({ url: "https://slack.example/mcp", oauth: true }) }, { memoryEnabled: false }); + const v = new SessionMcpView(registry, null, { + workdir: "/w", + tenant: TENANT, + approvals: () => new Set(), + read: async () => [], + readGlobal: async () => null, + grant: () => ({ workstream: "hf", servers: new Map([["slack", { oauthSlot: "slack-hf" }]]), workdirServers: false }), + }); + await v.load(); + const slack = v.get("slack"); + expect(slack?.transport).toMatchObject({ kind: "http", credential: { id: "slack-hf" } }); + expect(slack?.variant).toContain("#oauth:slack-hf"); + expect(names(v.relayFor("codex"))).toEqual(["slack"]); + }); + it("CLI backends: native mounts only secret-free; credentialed HTTP through the relay; stdio needing env values withheld", async () => { const v = await view({ workstream: "acme", servers: new Map([["github", { credential: { id: "gh" } }], ["notes", {}], ["vault", {}], ["platform", {}]]), workdirServers: false }); expect(names(v.forNativeMount("codex"))).toEqual(["notes"]); diff --git a/src/tests/workstreams-manager.test.ts b/src/tests/workstreams-manager.test.ts index 9f22e943..550a46f6 100644 --- a/src/tests/workstreams-manager.test.ts +++ b/src/tests/workstreams-manager.test.ts @@ -17,6 +17,9 @@ import { TranscriptStore } from "../daemon/transcript.js"; import { FolderReader } from "../daemon/workstreams/folder.js"; import { EnvFileSecretStore } from "../daemon/credentials/store.js"; import { ProviderRegistry } from "../daemon/providers/registry.js"; +import { McpRegistry } from "../daemon/mcp/registry.js"; +import { McpHub } from "../daemon/mcp/hub.js"; +import { McpOAuth } from "../daemon/mcp/oauth.js"; import { ALL_SCOPES, SCOPES } from "../protocol/scopes.js"; import type { AuthContext, DaemonMessage, SessionInfo } from "../protocol/types.js"; @@ -389,3 +392,15 @@ describe("under isolation a user's create must have a home", () => { }); }); +describe("signing in to a credential slot", () => { + it("is refused unless the slot is declared as an OAuth credential for that server", async () => { + const m = manager(WS, [{ id: "slack-hf", kind: "oauth", server: "slack" }, { id: "plain", kind: "env" }, { id: "other-oauth", kind: "oauth", server: "notion" }]); + const registry = new McpRegistry({ slack: { url: "https://slack.invalid/mcp", oauth: true, args: [], env: {}, headers: {}, trust: "prompt", scope: "workspace", enabled: true, native: false } as never }, { memoryEnabled: false }); + m.setMcp(registry, new McpHub({}), new McpOAuth({ store, redirectBaseUrl: () => "http://127.0.0.1:1", env: {} })); + for (const credential of ["undeclared", "plain", "other-oauth"]) { + const r = await m.handle({ type: "mcp.oauth.begin", id: `s-${credential}`, server: "slack", credential } as never, OWNER, client); + expect(r).toMatchObject({ type: "response.error", code: "invalid_request" }); + if (r.type === "response.error") expect(r.error).toContain("is not an OAuth credential declared for \"slack\""); + } + }); +}); From 910a97852a7a9679e1718a0a18e4917566b70a97 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 13:15:31 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20workstreams=20W1=20=E2=80=94=20co?= =?UTF-8?q?deoid=20workstream=20import,=20an=20e2e=20journey,=20and=20the?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codeoid workstream import proposes workstreams from the repositories on disk (grouped by remote owner) and lists Claude's global MCP servers to assign; it writes nothing - skillDirs are accepted but not applied yet, and the daemon says so - e2e: under isolation a create with no home is refused with the choices; an exclusive folder takes only a confirmed override; credentials show by name - docs/CONFIGURATION.md: credentials, the secret store, what a workstream gives its sessions, overrides, moving an existing setup; the design's backlog and status Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 19 +++++- docs/CONFIGURATION.md | 52 ++++++++++++++++- docs/workstreams-design.md | 7 ++- e2e/specs/workstreams.spec.ts | 40 ++++++++++++- src/cli.ts | 38 ++++++++++++ src/daemon/session-manager.ts | 4 ++ src/daemon/workstreams/import.ts | 67 ++++++++++++++++++++++ src/daemon/workstreams/workstreams.test.ts | 21 +++++++ 8 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 src/daemon/workstreams/import.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2cbbbc7e..e981e412 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,8 +251,9 @@ codeoid routine add --prompt … [--cron|--every|--at] [--check ] # codeoid routine add --loop --prompt … --verify "id: cmd" [--test] [--attempts N] [--attempt-minutes N] [--run-minutes N] [--budget N] # start a loop (--check = gate, --verify = its acceptance checks) codeoid routine ls | show | runs | run [--wait] | edit | pause | resume | rm | cancel | token | preview codeoid worktree trust [dir] [--off] [--yes] # trust the setup command of a repo's .codeoid/setup.yaml, pinned to its blob (docs/CONFIGURATION.md) -codeoid workstream ls | show | match [dir] [--workstream ] # named lines of work and what a folder resolves to (docs/workstreams-design.md) -codeoid new --workstream # a session's home workstream; default: the folder's, else workstreams.default +codeoid workstream ls | show | match [dir] [--workstream ] | import [--root ] # named lines of work, what a folder resolves to, a proposal from disk (docs/workstreams-design.md) +codeoid new --workstream [--override] # a session's home workstream; default: the folder's, else workstreams.default +codeoid mcp signin --credential # a named OAuth slot: a second sign-in to one server (a workstream's own) ``` `/mcp/control` (off by default, `mcpControl.*`) lets external agents drive @@ -536,6 +537,20 @@ and the decided home is written with `TranscriptStore.patchMeta` after resume succeeded, touching no other field. Every `new Session` site in SessionManager passes `#workstreamOpts(...)`; children and forks inherit, the conductor's is reserved. With no workstreams configured no binding exists and nothing changes. +W1: `credentials[]` (ids and kinds, never values) behind a `SecretStore` +(`credentials/store.ts`: env-file `CODEOID_CRED_`, or a read-only dir). +A home's env and git identity reach the backend via `Session` `workstreamEnv` +(merged under `extraEnv`; `GIT_IDENTITY_ENV` is the only `GIT_` exemption). +Isolation (`isolateGlobalServers`): `WorkstreamService.grantFor` → `McpGrant`, +applied by `SessionMcpView` (`applyGrant`) for every backend; Claude runs +`strictMcpConfig` with every server bridged to the hub (oauth-bridge.ts); +CLI backends mount natively only `secretFree` servers and reach credentialed +HTTP ones through the relay, whose binding carries the session's view; a +server's named credential is a REFERENCE on its transport, resolved by +`upstreamFetch` per request. The hub's `setPermit` (G4) checks every list and +call against the live grant. Overrides: `OverrideTokens` (G3); the folder +record is `workstream_folders`. OAuth slots: `mcp_oauth_credentials.credential` +in the key, `withSlot`/`slotOf` in mcp/oauth.ts. Routines (`src/daemon/routines/`, docs/routines.md) run sessions on a schedule or an API call. `RoutineService` owns the scheduler and runs; it reaches diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b080e6c6..d2a633b5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -406,8 +406,56 @@ A session from before workstreams were configured is given its folder's home onc A home deleted from the config leaves its sessions marked orphaned; removing the whole `workstreams` block turns workstreams off instead. A session whose stored home is unreadable or invalid gets no home (it is never re-matched to its folder). -This is phase W0: a session's workstream is decided, kept and shown, and nothing it is given changes yet. -Servers, credentials, git identity and memory follow in later phases (the design's §13). +### Credentials + +A credential is declared by id and kind in `credentials`; its value is never in `config.json`. + +```jsonc +"credentials": [ + { "id": "gh-acme", "kind": "env" }, // an env var a workstream sets + { "id": "hf-key", "kind": "header", "header": "X-Highflame-APIKey" }, // sent in a header to an MCP server + { "id": "gh-bot", "kind": "bearer" }, // sent as Authorization: Bearer + { "id": "slack-hf", "kind": "oauth", "server": "slack" } // a second OAuth sign-in to one server +], +"credentialStore": { "kind": "env-file" } // or { "kind": "dir", "path": "/var/run/secrets/codeoid" } +``` + +- `env-file` (default) reads `CODEOID_CRED_` (`gh-acme` → `CODEOID_CRED_GH_ACME`) from the daemon's environment or `~/.codeoid/.env`. The `CODEOID_` prefix keeps every value out of every agent process. +- `dir` reads one file per id from a directory: a mounted Kubernetes Secret, a Vault agent's sink. Read-only, and hidden from every agent like codeoid's own secrets. +- An OAuth slot is signed in with `codeoid mcp signin --credential `. + +Every reference to a credential is checked when the config loads: declared once, of a kind that fits where it is used. + +### What a workstream gives its sessions + +```jsonc +{ "id": "acme", "match": { "remotes": ["github.com/acme/*"] }, + "env": { "GH_TOKEN": { "secret": "gh-acme" } }, // read from the store when the session starts + "git": { "name": "Acme Bot", "email": "bot@acme.example" }, + "mcpServers": ["web", { "server": "github", "credential": "gh-bot" }, { "server": "slack", "credential": "slack-hf" }], + "extends": ["base"], "exclude": ["notes"] } +``` + +- **env and git identity** reach the backend's process (not opencode's shared server yet). A name codeoid owns for the backend (`ANTHROPIC_*`, `PATH`, `LD_*`, …) fails the config load. +- **mcpServers** take effect with `isolateGlobalServers: true`. Then a session reaches built-ins, the servers its home names, and the folder's own servers only when the folder is the home's; no home, the conductor's, or a deleted one: built-ins only. + - Claude runs with a strict MCP config: nothing from `~/.claude.json`, a `.mcp.json`, a plugin or an agent's own; every server it reaches is codeoid's, called by the daemon. Tools only: an MCP server's prompts and resources don't pass. + - Other CLI backends (Codex, Gemini CLI, Qwen, opencode) mount natively only servers that need no secret; an HTTP server called with a credential goes through the daemon's relay, and a stdio server that needs env values is not given. + - A credential's value is read by the daemon when it calls the server, never handed to a backend. A server removed from a workstream fails on the next call, mid-turn; the tool list catches up at the next turn. +- **skillDirs** are accepted but not applied yet (the daemon says so at start). + +Under isolation a session you create in a folder with no workstream is refused until you choose one; sessions the daemon starts there (loops, routines, dispatch) get built-ins only. + +### Overrides + +Choosing a workstream for a folder another workstream holds exclusively is refused with what it would change. +`codeoid new … --workstream ` then asks to confirm, or `--override` confirms without asking (needs `session:approve` on an undelegated token; audited as `workstream.override`). +A workstream with `allowOverride: false` can't be overridden at all. + +A folder's workstream is remembered: if its repository later matches a different one (its `origin` was changed), the next session there must choose. + +### Moving an existing setup + +`codeoid workstream import [--root ]` proposes workstreams from your repositories' remotes and lists Claude's global MCP servers to assign. It writes nothing; isolation stays off until you turn it on. ## Signing a backend in, instead of pasting a key diff --git a/docs/workstreams-design.md b/docs/workstreams-design.md index 4e5b35f7..339bff21 100644 --- a/docs/workstreams-design.md +++ b/docs/workstreams-design.md @@ -1,6 +1,6 @@ # Codeoid Workstreams — Design Proposal -> Status: **DRAFT for grilling** · Author: design session 2026-11-10 +> Status: **W0 in review (#461), W1 built** · Author: design session 2026-11-10 > Companion to [conductor-design.md](./conductor-design.md) and [federation-design.md](./federation-design.md). > Goal: let one owner run several separate lines of work on one daemon. A line of work can be a company, a client, a project, a team, research, or just a different personal setup. > Each line of work gets its own MCP servers, credentials, models and memory. @@ -528,6 +528,11 @@ How this fits [conductor-build-plan.md](./conductor-build-plan.md): ## 15. Deferred (backlog) +- **`skillDirs`** (W1): accepted, not applied. Delivering skills per session safely reuses the pack's per-entry plugin synthesis (pipeline/pack-service.ts `#ensureSkillPlugin`), which wants extracting rather than copying. +- **Orphaned sessions refusing turns** (§8): under isolation an orphaned session already gets built-ins only; refusing its turns waits for `session.set_workstream` (W3), so there is a way out. +- **Workstream env on opencode**: its one shared server takes no per-session process env; a session's shell environment could carry it. +- **MCP prompts and resources through the Claude bridge**: the bridge serves tools only. + - **Per-workstream spend caps** (was open question 5). A daily cap per workstream plus an overall cap, so an unattended loop in one workstream can't use up the subscription quota another needs. Deferred on 2026-11-10. It stays a prerequisite of unattended routines across workstreams, the same position as the amended R5 in conductor-design. - **A real read fence** (§7.3): a per-session OS sandbox with the home and reference folders bound in. Decide together with conductor-design open question #10. diff --git a/e2e/specs/workstreams.spec.ts b/e2e/specs/workstreams.spec.ts index ccaa1c69..3d29f50e 100644 --- a/e2e/specs/workstreams.spec.ts +++ b/e2e/specs/workstreams.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { expect, test } from "../harness"; @@ -43,3 +43,41 @@ test("a session's workstream comes from its folder, an exclusive folder refuses expect(after).toContain("workstream: research — the default"); expect(after).not.toContain("wrong-home"); }); + +// Workstreams W1: with isolation on, a user's session must have a home; an +// exclusive folder can be overridden only by confirming; credentials are +// declared by name and their values never shown. +test("under isolation a session needs a home, an exclusive folder takes a confirmed override, and credentials show by name only", async ({ codeoid }) => { + const scratch = join(codeoid.root, "scratch"); + mkdirSync(scratch, { recursive: true }); + writeFileSync(join(codeoid.configDir, "codeoid", ".env"), "CODEOID_CRED_GH_ACME=ghp_e2e_secret\n"); + codeoid.configure({ + credentials: [{ id: "gh-acme", kind: "env" }], + workstreams: { + isolateGlobalServers: true, + items: [ + { id: "acme", claim: "exclusive", match: { paths: [codeoid.workdir] }, env: { GH_TOKEN: { secret: "gh-acme" } }, git: { email: "bot@acme.example" } }, + { id: "research", match: { paths: ["~/never"] } }, + ], + }, + }); + await codeoid.restart(); + + const show = await codeoid.cliOk("workstream", "show", "acme"); + expect(show).toContain("GH_TOKEN"); + expect(show).not.toContain("ghp_e2e_secret"); + expect(await codeoid.cliOk("workstream", "ls")).toContain("Server isolation: on"); + + const homeless = await codeoid.cli("new", "nowhere", scratch); + expect(homeless.code).not.toBe(0); + expect(homeless.output).toContain("belongs to no workstream: choose one (acme, research)"); + expect(await codeoid.cliOk("new", "chosen", scratch, "--workstream", "research")).toContain("workstream: research — chosen"); + + // No terminal and no --override: refused, saying how to confirm. + const asked = await codeoid.cli("new", "past-claim", codeoid.workdir, "--workstream", "research"); + expect(asked.code).not.toBe(0); + expect(asked.output).toContain('belongs to workstream "acme" (exclusive)'); + expect(asked.output).toContain("pass --override"); + expect(await codeoid.cliOk("new", "past-claim", codeoid.workdir, "--workstream", "research", "--override")).toContain("chosen past the folder's own workstream"); + expect(await codeoid.cliOk("ls")).toContain("chosen past the folder's own workstream"); +}); diff --git a/src/cli.ts b/src/cli.ts index e433aa00..1c0a782f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1744,6 +1744,44 @@ workstreamCmd ); }); +workstreamCmd + .command("import") + .description( + "Propose workstreams from what is on disk — your repositories grouped by their remote's owner, and the MCP servers in Claude's global config — as a config block to review. Writes nothing.", + ) + .option("--root ", "a folder whose subfolders are your repositories (repeatable; default ~/Workspace, else this folder)", (v: string, p: string[] = []) => [...p, v], [] as string[]) + .option("--json", "print the proposal as JSON") + .action(async (o: { root: string[]; json?: boolean }) => { + const { existsSync } = await import("node:fs"); + const { homedir } = await import("node:os"); + const { proposeWorkstreams } = await import("./daemon/workstreams/import.js"); + const { readGlobalServers } = await import("./daemon/mcp/workdir-servers.js"); + const defaultRoot = join(homedir(), "Workspace"); + const roots = o.root.length > 0 ? o.root.map(dirFlag) : [existsSync(defaultRoot) ? defaultRoot : process.cwd()]; + const global = Object.keys((await readGlobalServers()) ?? {}); + const p = proposeWorkstreams(roots, global); + if (o.json) return void console.log(JSON.stringify(p, null, 2)); + if (p.items.length === 0) { + console.log(`No repositories with a remote under ${roots.join(", ")}.`); + return; + } + console.log("Proposed workstreams (one per remote owner). Review, rename, merge, then add to config.json:\n"); + const block = { + workstreams: { + isolateGlobalServers: false, + items: p.items.map(({ id, name, match }) => ({ id, name, claim: "suggest", match, mcpServers: [] as string[] })), + }, + }; + console.log(JSON.stringify(block, null, 2)); + console.log("\nFolders each would match:"); + for (const it of p.items) console.log(` ${clean(it.id)}: ${it.folders.length} — ${clean(it.folders.slice(0, 4).join(", "))}${it.folders.length > 4 ? ", …" : ""}`); + if (p.unmatched.length > 0) console.log(`\nNo remote (add a path rule, or leave them to a default): ${clean(p.unmatched.join(", "))}`); + if (p.globalServers.length > 0) { + console.log(`\nClaude's global MCP servers, reaching every session today: ${clean(p.globalServers.join(", "))}`); + console.log("Add each to the mcpServers of the workstreams that should reach it. Isolation stays off until you set isolateGlobalServers: true."); + } + }); + // ── Outward control plane (/mcp/control — docs/mcp-control.md) ───────────── const mcp = program diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index b31552ae..71b87be2 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -745,6 +745,10 @@ export class SessionManager { this.#memory = memory; this.#config = opts?.config; this.#workstreams = new WorkstreamService(opts?.config?.workstreams, opts?._testFolderReader, opts?.config?.credentials); + const withSkills = (opts?.config?.workstreams?.items ?? []).filter((w) => w.skillDirs.length > 0).map((w) => w.id); + if (withSkills.length > 0) { + console.warn(`[codeoid] workstreams ${withSkills.join(", ")} set skillDirs, which aren't applied yet: those skills don't reach their sessions`); + } this.#secrets = opts?._testSecrets ?? createSecretStore(opts?.config?.credentialStore); // A `dir` store holds every credential's value in plain files: refused to // every agent's file tools and hidden from every sandbox, as codeoid's own diff --git a/src/daemon/workstreams/import.ts b/src/daemon/workstreams/import.ts new file mode 100644 index 00000000..31a97ddf --- /dev/null +++ b/src/daemon/workstreams/import.ts @@ -0,0 +1,67 @@ +/** + * `codeoid workstream import` (docs/workstreams-design.md §13, "Moving off + * today's setup"): a PROPOSAL from what is already on disk — repositories + * grouped by their remote's owner, and the MCP servers in Claude's global + * config, none of them assigned. Writes nothing: the owner reviews, edits and + * saves it, and only then turns isolation on. + */ + +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { FolderReader } from "./folder.js"; + +export interface ImportProposal { + /** Proposed workstreams, by remote owner. */ + items: Array<{ id: string; name: string; match: { remotes: string[] }; folders: string[] }>; + /** Folders with no remote any rule could name. */ + unmatched: string[]; + /** Server names from Claude's global config: assign each to the workstreams that should reach it. */ + globalServers: string[]; +} + +/** A config id from a remote owner: lowercase, dashes, at most 64. */ +function idOf(owner: string): string { + const id = owner.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64).replace(/-+$/, ""); + return id.length > 0 ? id : "repos"; +} + +export function proposeWorkstreams(roots: readonly string[], globalServers: readonly string[], reader = new FolderReader()): ImportProposal { + const byOwner = new Map(); + const unmatched: string[] = []; + for (const root of roots) { + let names: string[]; + try { + names = readdirSync(root).filter((n) => !n.startsWith(".")); + } catch { + continue; + } + for (const n of names.sort()) { + const dir = join(root, n); + try { + if (!statSync(dir).isDirectory()) continue; + } catch { + continue; + } + const remote = reader.remotes(dir).origin; + const parts = remote?.split("/"); + if (!parts || parts.length < 3) { + unmatched.push(dir); + continue; + } + const key = `${parts[0]}/${parts[1]}`; + const group = byOwner.get(key) ?? { host: parts[0]!, owner: parts[1]!, folders: [] }; + group.folders.push(dir); + byOwner.set(key, group); + } + } + const used = new Set(); + const items = [...byOwner.values()] + .sort((a, b) => b.folders.length - a.folders.length || a.owner.localeCompare(b.owner)) + .map((g) => { + let id = idOf(g.owner); + for (let i = 2; used.has(id); i++) id = `${idOf(g.owner).slice(0, 60)}-${i}`; + used.add(id); + return { id, name: g.owner, match: { remotes: [`${g.host}/${g.owner}/*`] }, folders: g.folders }; + }); + return { items, unmatched, globalServers: [...globalServers].sort() }; +} diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts index 6ba3016d..e100fd61 100644 --- a/src/daemon/workstreams/workstreams.test.ts +++ b/src/daemon/workstreams/workstreams.test.ts @@ -8,6 +8,7 @@ import { FolderReader, findGitDirs, parseGitRemotes } from "./folder.js"; import { ancestry, expandHome, matchFolder, normalizePathRule, normalizeRemote, parseSshAliases, type MatchableWorkstream } from "./match.js"; import { workstreamsSchema } from "./schema.js"; import { WorkstreamService } from "./service.js"; +import { proposeWorkstreams } from "./import.js"; import { EnvFileSecretStore } from "../credentials/store.js"; const ws = (id: string, over: Partial = {}): MatchableWorkstream => ({ @@ -530,3 +531,23 @@ describe("WorkstreamService", () => { expect(JSON.stringify(s.summaries())).not.toContain("gh-a"); }); }); + +describe("proposeWorkstreams (codeoid workstream import)", () => { + test("groups repositories by remote owner; folders without a remote and the global servers are listed apart", () => { + const root = join(tmp, "Workspace"); + repo(join(root, "a1"), { origin: "git@github.com:Acme/a1.git" }); + repo(join(root, "a2"), { origin: "https://github.com/acme/a2" }); + repo(join(root, "b1"), { origin: "https://gitlab.com/beta/b1" }); + mkdirSync(join(root, "plain"), { recursive: true }); + const p = proposeWorkstreams([root], ["slack", "github"], new FolderReader({ home: tmp, sshConfig: "" })); + expect(p.items.map((i) => [i.id, i.match.remotes[0], i.folders.length])).toEqual([ + ["acme", "github.com/acme/*", 2], + ["beta", "gitlab.com/beta/*", 1], + ]); + expect(p.unmatched).toEqual([join(root, "plain")]); + expect(p.globalServers).toEqual(["github", "slack"]); + // Every proposed item is a valid config item. + expect(workstreamsSchema.safeParse({ items: p.items.map(({ id, name, match }) => ({ id, name, match })) }).success).toBe(true); + }); +}); + From 2ef6910e7cae115906b5b038a2ddd118830fdb04 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 13:32:58 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20workstreams=20W1=20audit=20round?= =?UTF-8?q?=201=20=E2=80=94=20CLI=20backends=20kept=20to=20codeoid's=20ser?= =?UTF-8?q?vers,=20no=20daemon=20variable=20in=20a=20server=20config,=20an?= =?UTF-8?q?=20honest=20folder=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Codex, the Gemini CLI and Qwen loaded MCP servers of their own under isolation. Codex now has every server its user and project configs declare turned off (with a placeholder transport, so an untrusted project file still starts; measured on codex 0.166) and its apps off; the Gemini CLI and Qwen get only codeoid's server names allowed; opencode's turns are refused under isolation until its project config can be kept out - ${CODEOID_*}, ${ZEROID_*}, ${TELEGRAM_*} in a server config resolve empty, and such a bearerTokenEnv is ignored: a repo's .mcp.json could read any workstream's credential from the daemon's environment - secretFree also reads the URL (a token in its path or query, user:pass@) and the command line: such servers go through the relay or are withheld - Claude under isolation bridges only HTTP servers; a stdio one needing no secret is the CLI's to run inside the session's sandbox, as before (the hub would have run a repo's script on the host) - a delegated token can't edit an origin and then choose the workstream it now matches, and never writes the folder record - the hub's per-call check refuses an unknown session under isolation; a relay whose credential changed is closed (the backend initializes again); a slot sign-in or sign-out drops that slot's hub clients too - override tokens go only to callers who can redeem them Correctness: - the folder record holds what the folder matched, never a chosen home, and is written once the session exists: an explicit choice no longer makes the next plain create in that folder refuse - an override token is spent only once its session exists - not isolated, the relay reaches exactly what it did before - a server address change rebuilds the Claude query - servers withheld from a backend are said in the session, once per set - --override without --workstream is refused with why Co-Authored-By: Claude Opus 5.5 --- docs/CONFIGURATION.md | 6 +- src/cli.ts | 4 + src/daemon/mcp/hub.ts | 7 + src/daemon/mcp/proxy.ts | 10 ++ src/daemon/mcp/registry.ts | 6 +- src/daemon/mcp/session-view.ts | 29 ++-- src/daemon/mcp/types.ts | 15 ++- src/daemon/providers/acp/index.ts | 21 ++- src/daemon/providers/claude/index.ts | 12 +- src/daemon/providers/claude/oauth-bridge.ts | 14 +- src/daemon/providers/codex/index.ts | 8 +- src/daemon/providers/codex/isolation.test.ts | 53 ++++++++ src/daemon/providers/codex/isolation.ts | 134 +++++++++++++++++++ src/daemon/providers/qwen/index.ts | 6 + src/daemon/session-manager.ts | 40 ++++-- src/daemon/session.ts | 33 +++++ src/daemon/store.ts | 10 +- src/daemon/workstreams/override.ts | 21 ++- src/tests/workstreams-isolation.test.ts | 50 ++++++- src/tests/workstreams-manager.test.ts | 42 +++++- 20 files changed, 470 insertions(+), 51 deletions(-) create mode 100644 src/daemon/providers/codex/isolation.test.ts create mode 100644 src/daemon/providers/codex/isolation.ts diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d2a633b5..2350b5b9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -438,8 +438,10 @@ Every reference to a credential is checked when the config loads: declared once, - **env and git identity** reach the backend's process (not opencode's shared server yet). A name codeoid owns for the backend (`ANTHROPIC_*`, `PATH`, `LD_*`, …) fails the config load. - **mcpServers** take effect with `isolateGlobalServers: true`. Then a session reaches built-ins, the servers its home names, and the folder's own servers only when the folder is the home's; no home, the conductor's, or a deleted one: built-ins only. - - Claude runs with a strict MCP config: nothing from `~/.claude.json`, a `.mcp.json`, a plugin or an agent's own; every server it reaches is codeoid's, called by the daemon. Tools only: an MCP server's prompts and resources don't pass. - - Other CLI backends (Codex, Gemini CLI, Qwen, opencode) mount natively only servers that need no secret; an HTTP server called with a credential goes through the daemon's relay, and a stdio server that needs env values is not given. + - Claude runs with a strict MCP config: nothing from `~/.claude.json`, a `.mcp.json`, a plugin or an agent's own. Every HTTP server it reaches is codeoid's, called by the daemon (tools only: a server's prompts and resources don't pass); a stdio server needing no secret is the CLI's to run, inside the session's sandbox as before. + - Codex, the Gemini CLI and Qwen are kept to codeoid's servers: Codex has every server its own configs declare (`~/.codex/config.toml`, a project's `.codex/config.toml`) turned off and its apps off; the Gemini CLI and Qwen are started with only codeoid's server names allowed. opencode can't be kept to them yet: under isolation its turns are refused. + - Every backend mounts natively only servers that need no secret — none in a header, an env value, the URL (a token in its path or query) or the command line. An HTTP server that needs one goes through the daemon's relay; a stdio one isn't given, and the session says so. + - A server config can never name codeoid's own variables: `${CODEOID_*}` (every credential's value lives there), `${ZEROID_*}`, `${TELEGRAM_*}` resolve empty, and such a `bearerTokenEnv` is ignored. - A credential's value is read by the daemon when it calls the server, never handed to a backend. A server removed from a workstream fails on the next call, mid-turn; the tool list catches up at the next turn. - **skillDirs** are accepted but not applied yet (the daemon says so at start). diff --git a/src/cli.ts b/src/cli.ts index 1c0a782f..970702d7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1136,6 +1136,10 @@ program }, ) => { const config = loadConfig(); + if (opts.override && !opts.workstream) { + console.error("--override confirms a --workstream choice; give one (codeoid workstream match shows the folder's)."); + process.exit(1); + } let sandbox: SessionSandboxSetting | undefined; try { sandbox = sandboxSettingFromFlags(opts); diff --git a/src/daemon/mcp/hub.ts b/src/daemon/mcp/hub.ts index 887ed781..9c306f80 100644 --- a/src/daemon/mcp/hub.ts +++ b/src/daemon/mcp/hub.ts @@ -339,6 +339,13 @@ export class McpHub { /** Drop one tenant's client for a server (it signed out or back in). */ dropTenant(name: string, tenant: McpTenant): void { this.#drop(tenantKey(name, tenant)); + // ...and every variant of it for this tenant: a credential slot's client + // (workstreams §6.5) is keyed by its variant, and a sign-in or sign-out + // to the server must not leave one connected on the old credential. + const suffix = tenantKey("", tenant); + for (const key of [...this.#clients.keys()]) { + if (key.startsWith(`${name}\u0001`) && key.endsWith(suffix)) this.#drop(key); + } } /** Drop `client` if this failure broke it — and only if it is still the diff --git a/src/daemon/mcp/proxy.ts b/src/daemon/mcp/proxy.ts index 9c56db0d..46cbbe7a 100644 --- a/src/daemon/mcp/proxy.ts +++ b/src/daemon/mcp/proxy.ts @@ -266,6 +266,13 @@ export class McpProxy implements McpProxyMount { if (sessionId) { const relay = record.relays.get(sessionId); if (!relay || relay.server !== server) return rpcError(404, ErrorCode.InvalidRequest, "session not found"); + // Its credential changed since it started (the workstream moved the + // server to another credential or slot): never keep calling with the + // old one. Closed, and a 404 makes the backend initialize again (W1 audit). + if (relay.variant !== (spec.variant ?? "")) { + void relay.close(); + return rpcError(404, ErrorCode.InvalidRequest, "session not found"); + } if (req.method !== "POST") { opts.liftIdleTimeout?.(); return relay.handle(req); @@ -423,6 +430,8 @@ const CLIENT_NOTIFICATIONS = new Set(["notifications/roots/list_changed"]); class Relay { readonly server: string; + /** Which credential it calls with (a workstream's slot or named credential lives in the variant). */ + readonly variant: string; /** When the backend last used this session (for eviction at the cap). */ lastUsed = Date.now(); readonly #init: RelayInit; @@ -443,6 +452,7 @@ class Relay { private constructor(init: RelayInit) { this.#init = init; this.server = init.spec.name; + this.variant = init.spec.variant ?? ""; this.#transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomBytes(16).toString("hex"), onsessioninitialized: (id) => init.onSession(id, this), diff --git a/src/daemon/mcp/registry.ts b/src/daemon/mcp/registry.ts index e173881f..3c9d32b0 100644 --- a/src/daemon/mcp/registry.ts +++ b/src/daemon/mcp/registry.ts @@ -11,7 +11,7 @@ import type { RawMcpServerConfig } from "../../config.js"; import { MEMORY_MCP_SERVER_NAME } from "../memory/mcp-http.js"; -import { isOAuthServer, mcpToolPrefix, type McpServerSpec, type McpTransport } from "./types.js"; +import { isDaemonOwnedEnvName, isOAuthServer, mcpToolPrefix, type McpServerSpec, type McpTransport } from "./types.js"; /** Names codeoid owns internally — a user entry using one is ignored (the * built-in wins) rather than silently shadowing an internal server. Compared @@ -152,7 +152,9 @@ export function normalizeSpec(name: string, raw: RawMcpServerConfig): McpServerS kind: "http", url: raw.url, headers: raw.headers, - bearerTokenEnv: raw.bearerTokenEnv, + // Never one of the daemon's own variables (isDaemonOwnedEnvName): + // every backend reads this name from the daemon's environment. + bearerTokenEnv: raw.bearerTokenEnv && !isDaemonOwnedEnvName(raw.bearerTokenEnv) ? raw.bearerTokenEnv : undefined, ...(raw.oauth !== undefined ? { oauth: raw.oauth === true ? {} : raw.oauth } : {}), } : { kind: "stdio", command: raw.command ?? "", args: raw.args, env: raw.env }; diff --git a/src/daemon/mcp/session-view.ts b/src/daemon/mcp/session-view.ts index 11463904..d4e3c866 100644 --- a/src/daemon/mcp/session-view.ts +++ b/src/daemon/mcp/session-view.ts @@ -33,6 +33,7 @@ import type { RawMcpServerConfig } from "../../config.js"; import type { McpHub } from "./hub.js"; import type { McpGrant } from "../workstreams/grant.js"; import { withSlot } from "./oauth.js"; +import { redactSecretText, urlHasHiddenParts } from "./redact.js"; import type { McpTenant } from "./oauth.js"; import { isReservedMcpName, normalizeSpec, type McpRegistry, type McpServerView } from "./registry.js"; import { isOAuthServer, type McpServerSpec } from "./types.js"; @@ -243,8 +244,8 @@ export class SessionMcpView implements McpServerView { /** A name the project's .mcp.json declared, on a backend that loads that file itself: its tools may be the repo's. */ leftToBackend(name: string, backendId: string): boolean { - // Isolated, Claude runs strict: it loads no .mcp.json, so nothing is its own. - if (backendId === "claude" && this.isolated) return false; + // Isolated, no backend loads a .mcp.json of its own: nothing is left to it. + if (this.isolated) return false; return READS_MCP_JSON.has(backendId) && (this.#repoUnreadable || this.#mcpJsonNames.has(name)); } @@ -278,9 +279,11 @@ export class SessionMcpView implements McpServerView { } relayFor(backendId: string): McpServerSpec[] { + // Not isolated: exactly what the relay reached before workstreams — the + // daemon-wide registry's OAuth servers. + if (!this.isolated) return this.#base.relayFor(backendId); const http = (s: McpServerSpec) => s.transport.kind === "http"; const oauth = this.oauthFor(backendId).filter(http); - if (!this.isolated) return oauth; const credentialed = this.forBackend(backendId).filter((s) => !s.builtin && !isOAuthServer(s) && http(s) && !secretFree(s)); return [...oauth, ...credentialed]; } @@ -351,8 +354,9 @@ export class SessionMcpView implements McpServerView { // Isolated, Claude runs with a strict MCP config and loads none of its // own: the workdir layer is codeoid's to give it, as to every backend. if (backendId !== "claude" || grant) { - // Strict (isolated) Claude reads no .mcp.json of its own: those are codeoid's to give. - const mcpJsonNames = backendId && READS_MCP_JSON.has(backendId) && !(grant && backendId === "claude") ? declared : null; + // Isolated, no backend reads a .mcp.json of its own (Claude runs strict, + // Qwen keeps to the names codeoid mounts): those are codeoid's to give. + const mcpJsonNames = backendId && READS_MCP_JSON.has(backendId) && !grant ? declared : null; for (const spec of layer) { if (mcpJsonNames?.has(spec.name)) continue; const existing = byName.get(spec.name); @@ -427,11 +431,20 @@ function mcpJsonNamesOf(read: readonly WorkdirServer[]): ReadonlySet { return new Set(read.filter((w) => isMcpJson(w.path)).map((w) => w.name)); } -/** A server a backend can mount itself without holding any secret. */ +/** + * A server a backend can mount itself without holding any secret: no + * credential, bearer, header or env value — and none in the address or the + * command line either (a token in a URL's path or query, `user:pass@`, a + * `--token …` argument), as the redaction rules read them (redact.ts). + */ export function secretFree(spec: McpServerSpec): boolean { const t = spec.transport; - if (t.kind === "http") return !t.credential && !t.bearerTokenEnv && Object.keys(t.headers ?? {}).length === 0; - if (t.kind === "stdio") return Object.keys(t.env ?? {}).length === 0; + if (t.kind === "http") { + return !t.credential && !t.bearerTokenEnv && Object.keys(t.headers ?? {}).length === 0 && !urlHasHiddenParts(t.url); + } + if (t.kind === "stdio") { + return Object.keys(t.env ?? {}).length === 0 && [t.command, ...(t.args ?? [])].every((a) => redactSecretText(a) === a); + } return true; } diff --git a/src/daemon/mcp/types.ts b/src/daemon/mcp/types.ts index 4ce666bc..cce67531 100644 --- a/src/daemon/mcp/types.ts +++ b/src/daemon/mcp/types.ts @@ -156,7 +156,20 @@ export function isProxied(spec: McpServerSpec): boolean { * of the config file; a literal (no `${...}`) passes through unchanged. */ export function resolveEnvValue(value: string, env: Record): string { const m = /^\$\{(\w+)\}$/.exec(value); - return m ? (env[m[1]] ?? "") : value; + if (!m) return value; + // Never codeoid's own: the daemon's environment carries every workstream's + // credential (CODEOID_CRED_*), its root key and bot tokens — a server + // config (a repo's .mcp.json included) naming one must not be handed it. + return isDaemonOwnedEnvName(m[1]) ? "" : (env[m[1]] ?? ""); +} + +/** + * The daemon's own variables (providers/env.ts DENY_PREFIXES): kept out of + * every agent process, and out of every value an MCP server config can name + * by `${VAR}` or `bearerTokenEnv` (workstreams W1 audit). + */ +export function isDaemonOwnedEnvName(name: string): boolean { + return /^(CODEOID_|ZEROID_|TELEGRAM_)/.test(name); } /** Resolve every value in an env map via {@link resolveEnvValue}. */ diff --git a/src/daemon/providers/acp/index.ts b/src/daemon/providers/acp/index.ts index 511c0124..58f5475f 100644 --- a/src/daemon/providers/acp/index.ts +++ b/src/daemon/providers/acp/index.ts @@ -358,7 +358,7 @@ export class GeminiAcpProvider implements SessionProvider { this.#proc = new StdioJsonRpcProcess({ name: "gemini-cli", command: this.#command, - args: [...this.#argsPrefix, "--acp"], + args: [...this.#argsPrefix, ...this.#isolationArgs(), "--acp"], cwd: opts.workdir, env, ...(handle?.wrap ? { wrap: handle.wrap.bind(handle) } : {}), @@ -508,6 +508,25 @@ export class GeminiAcpProvider implements SessionProvider { return servers; } + /** + * Workstream isolation (G1): the Gemini CLI also loads MCP servers from its + * own settings (`~/.gemini/settings.json`, a project's `.gemini/`) and its + * extensions. `--allowed-mcp-server-names` keeps it to the names codeoid + * mounts (session/new's list: built-ins, native mounts, the relay's) — an + * empty list would mean "no filter", so none mounted names one that isn't. + */ + #isolationArgs(): string[] { + const reg = this.#mcpRegistry; + if (!reg?.isolated) return []; + const names = new Set(); + if (this.#memoryMcp) names.add(MEMORY_MCP_SERVER_NAME); + if (this.#blackboardMcp?.()) names.add(BLACKBOARD_MCP_SERVER_NAME); + if (this.#sandboxMcp?.()) names.add(SANDBOX_MCP_SERVER_NAME); + for (const s of reg.forNativeMount(this.id)) names.add(s.name); + for (const s of reg.relayFor(this.id)) names.add(s.name); + return ["--allowed-mcp-server-names", ...(names.size > 0 ? [...names].sort() : ["codeoid-isolated-none"])]; + } + #push(event: ProviderEvent): void { const queue = this.#turnQueue; if (!queue || queue.closed) return; diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index e02509af..a70b2954 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -1065,7 +1065,12 @@ export class ClaudeProvider implements SessionProvider { ...withMcpToolTimeout(userServers, mcpToolTimeoutMs), // Registry servers — mounted natively on the SDK (claude owns its client); // tool calls surface as `mcp____` and gate via canUseTool. - ...(oauthMcp.strict ? {} : withMcpToolTimeout(registryServersForClaude(init.mcpRegistry), mcpToolTimeoutMs)), + // Strict (isolated): only the stdio servers needing no secret are the + // CLI's to run (forNativeMount keeps to those); every HTTP one is a bridge. + ...withMcpToolTimeout( + oauthMcp.strict ? stdioOnly(registryServersForClaude(init.mcpRegistry)) : registryServersForClaude(init.mcpRegistry), + mcpToolTimeoutMs, + ), // Registry OAuth servers the tenant has signed in to — in-process, in // front of the daemon-owned client, which holds the token (#390). The // hub applies its own per-call timeout. @@ -2667,6 +2672,11 @@ export function registryServersForClaude(registry: McpServerView | undefined): R return out; } +/** The stdio entries of a server map (a strict, isolated Claude bridges every HTTP one). */ +function stdioOnly(servers: Record): Record { + return Object.fromEntries(Object.entries(servers).filter(([, cfg]) => typeof (cfg as { command?: unknown }).command === "string")); +} + /** User servers minus those codeoid owns now — by URL: a server of the same * name elsewhere (another workdir's, at another URL) is not codeoid's. */ export function withoutServers(servers: Record, urls: readonly string[]): Record { diff --git a/src/daemon/providers/claude/oauth-bridge.ts b/src/daemon/providers/claude/oauth-bridge.ts index ae290ff2..00d3ffb6 100644 --- a/src/daemon/providers/claude/oauth-bridge.ts +++ b/src/daemon/providers/claude/oauth-bridge.ts @@ -67,12 +67,13 @@ export function oauthServersForClaude( // The sign-in, not just whether there is one: signed out and back in (after // a revoked token) between two turns must still get a fresh connection. const stamps = signedIn.map((s) => [s.name, hub?.credentialStamp(s, scope.tenant) ?? ""]); - // Under workstream isolation every other server the session may reach is a - // bridge too — stdio and HTTP alike, credentialed or not — so the CLI, - // running with a strict MCP config, holds no server config, secret or - // command of its own: what the hub reaches is all there is (G1). + // Under workstream isolation every other HTTP server the session may reach + // is a bridge too, credentialed or not: the CLI, running with a strict MCP + // config, holds no address or secret of its own (G1). A stdio server stays + // the CLI's to run — inside the session's sandbox, as before — when it needs + // no secret (the provider mounts those natively); one that does is withheld. const strict = registry?.isolated === true; - const bridged = strict && hub ? (registry?.forBackend("claude") ?? []).filter((s) => !s.builtin && !isOAuthServer(s)) : []; + const bridged = strict && hub ? (registry?.forBackend("claude") ?? []).filter((s) => !s.builtin && !isOAuthServer(s) && s.transport.kind === "http") : []; return { // Built only for a query being constructed, not on every turn's check. mounts: () => @@ -82,7 +83,8 @@ export function oauthServersForClaude( specs.map((s, i) => [s.name, deniedUrls[i], s.toolAllowlist ?? null]), stamps, strict, - bridged.map((s) => [s.name, s.variant ?? "", s.toolAllowlist ?? null]), + // The address too: an edited URL must rebuild the query, not keep dialing the old one. + bridged.map((s) => [s.name, s.variant ?? "", s.toolAllowlist ?? null, s.transport.kind === "http" ? s.transport.url : ""]), ]), strict, }; diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index 3f5a9b3e..5d7c2345 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -47,6 +47,7 @@ */ import type { McpTenant } from "../../mcp/oauth.js"; +import { codexIsolationArgs, codexMountedNames } from "./isolation.js"; import { randomUUID } from "node:crypto"; import { resolve } from "node:path"; import { AsyncQueue } from "../../async-queue.js"; @@ -983,6 +984,11 @@ export class CodexProvider implements SessionProvider { // (codexSandboxPolicy) or, run whole inside, this one's. // Its home is a private copy (backend-home.ts): nothing its commands // write there runs later in a codex outside the sandbox. + // Workstream isolation (G1): codex's own configured servers and apps off, + // so the session reaches only what codeoid mounted. Fails the start + // (CodexIsolationError) rather than run with a server it can't name. + const own = codexMountedNames([...mcp.args, ...bb.args, ...sbx.args, ...reg.args, ...oauth.args]); + const isolation = this.#mcpRegistry?.isolated ? codexIsolationArgs(defaultCodexHome(env), opts.workdir, own) : []; const rt = this.#sandboxRuntime(); let handle: SandboxHandle | null = null; let procEnv = env; @@ -996,7 +1002,7 @@ export class CodexProvider implements SessionProvider { this.#proc = new CodexRpcProcess({ command: this.#command, argsPrefix: this.#argsPrefix, - args: [...mcp.args, ...bb.args, ...sbx.args, ...reg.args, ...oauth.args, ...(shellEnv ?? [])], + args: [...mcp.args, ...bb.args, ...sbx.args, ...reg.args, ...oauth.args, ...(shellEnv ?? []), ...isolation], cwd: opts.workdir, env: procEnv, ...(handle?.wrap ? { wrap: handle.wrap.bind(handle) } : {}), diff --git a/src/daemon/providers/codex/isolation.test.ts b/src/daemon/providers/codex/isolation.test.ts new file mode 100644 index 00000000..598f3914 --- /dev/null +++ b/src/daemon/providers/codex/isolation.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CodexIsolationError, codexIsolationArgs, codexMountedNames } from "./isolation.js"; + +let tmp: string; +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-codex-iso-")); +}); +afterEach(() => rmSync(tmp, { recursive: true, force: true })); + +describe("codex under workstream isolation", () => { + test("every server its user and project configs declare, but codeoid's, is off — with a placeholder of its kind; apps off", () => { + const home = join(tmp, "codex"); + mkdirSync(home); + writeFileSync(join(home, "config.toml"), '[mcp_servers.leaky]\nurl = "https://leaky.example/mcp?token=x"\n\n[profiles.work.mcp_servers.prof]\ncommand = "p"\n\n[mcp_servers.codeoid_memory]\nurl = "x"\n'); + const work = join(tmp, "repo", "sub"); + mkdirSync(join(tmp, "repo", ".codex"), { recursive: true }); + mkdirSync(work, { recursive: true }); + writeFileSync(join(tmp, "repo", ".codex", "config.toml"), '[mcp_servers.proj]\ncommand = "proj-mcp"\n'); + const args = codexIsolationArgs(home, work, new Set(["codeoid_memory"])); + expect(args).toEqual([ + "-c", 'mcp_servers.leaky.url="http://127.0.0.1:9/codeoid-disabled"', "-c", "mcp_servers.leaky.enabled=false", + "-c", 'mcp_servers.prof.command="codeoid-disabled"', "-c", "mcp_servers.prof.enabled=false", + "-c", 'mcp_servers.proj.command="codeoid-disabled"', "-c", "mcp_servers.proj.enabled=false", + "-c", "features.apps=false", + ]); + // The real URL (with its token) never reaches argv. + expect(args.join(" ")).not.toContain("leaky.example"); + }); + + test("no config at all: apps off, nothing else", () => { + expect(codexIsolationArgs(join(tmp, "none"), tmp, new Set())).toEqual(["-c", "features.apps=false"]); + }); + + test("fails closed: a name it can't write as a key, invalid TOML, or one server declared as both kinds", () => { + const home = join(tmp, "codex"); + mkdirSync(home); + writeFileSync(join(home, "config.toml"), '[mcp_servers."has space"]\ncommand = "x"\n'); + expect(() => codexIsolationArgs(home, tmp, new Set())).toThrow(CodexIsolationError); + writeFileSync(join(home, "config.toml"), "[mcp_servers.x\n"); + expect(() => codexIsolationArgs(home, tmp, new Set())).toThrow(/isn't valid TOML/); + writeFileSync(join(home, "config.toml"), '[mcp_servers.x]\nurl = "u"\n'); + mkdirSync(join(tmp, ".codex")); + writeFileSync(join(tmp, ".codex", "config.toml"), '[mcp_servers.x]\ncommand = "c"\n'); + expect(() => codexIsolationArgs(home, tmp, new Set())).toThrow(/both as a URL and as a command/); + }); + + test("codexMountedNames reads the names codeoid's own -c arguments add", () => { + expect([...codexMountedNames(["-c", 'mcp_servers.codeoid_memory.url="u"', "-c", "mcp_servers.gh.bearer_token_env_var=\"T\"", "-c", "other=1"])].sort()).toEqual(["codeoid_memory", "gh"]); + }); +}); diff --git a/src/daemon/providers/codex/isolation.ts b/src/daemon/providers/codex/isolation.ts new file mode 100644 index 00000000..3e8ea6f5 --- /dev/null +++ b/src/daemon/providers/codex/isolation.ts @@ -0,0 +1,134 @@ +/** + * Codex under workstream isolation (docs/workstreams-design.md §6.1, G1). + * + * Codex loads MCP servers of its own besides the ones codeoid passes with + * `-c mcp_servers.*`: from its user config (`$CODEX_HOME/config.toml`), from + * a project's `.codex/config.toml`, and its apps (ChatGPT connectors). A `-c + * mcp_servers={}` override does not clear them (measured: codex 0.166 merges + * the override into the table), but `-c mcp_servers..enabled=false` + * turns one off. So every server any of those files declares, that codeoid + * didn't add, is turned off by name, and apps with them. + * + * Each one also gets a placeholder transport of its own kind (a dead loopback + * URL, a no-op command): a project file codex doesn't trust isn't loaded, and + * an override naming only `enabled` would then be a server with no transport, + * which codex refuses to start with (measured). The placeholder keeps the + * entry valid either way — and no server's real URL ever reaches argv. + * + * Fails closed: a declared name that can't be written as a config key, or a + * config file that can't be read, refuses the start — a server codeoid can't + * name is a server it can't keep out. + */ + +import { closeSync, constants as fsc, fstatSync, openSync, readSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { codexKeySafe } from "./shell-env.js"; + +const MAX_CONFIG_BYTES = 1_000_000; +const MAX_WALK = 64; + +export class CodexIsolationError extends Error { + constructor(message: string) { + super(message); + this.name = "CodexIsolationError"; + } +} + +/** A small regular file's text, never through a final link, never blocking; undefined if absent. */ +function readConfig(path: string): string | undefined { + let fd: number | undefined; + try { + fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW | fsc.O_NONBLOCK); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT" || (e as NodeJS.ErrnoException).code === "ENOTDIR") return undefined; + throw new CodexIsolationError(`codex config ${path} can't be read (${(e as NodeJS.ErrnoException).code}): refusing to start without knowing its MCP servers`); + } + try { + const st = fstatSync(fd); + if (!st.isFile() || st.size > MAX_CONFIG_BYTES) throw new CodexIsolationError(`codex config ${path} isn't a small regular file: refusing to start without knowing its MCP servers`); + const buf = Buffer.alloc(st.size); + let off = 0; + while (off < st.size) { + const n = readSync(fd, buf, off, st.size - off, off); + if (n <= 0) break; + off += n; + } + return buf.subarray(0, off).toString("utf8"); + } finally { + closeSync(fd); + } +} + +/** Every server of every `mcp_servers` table anywhere in a parsed config (profiles included), by transport kind. */ +function serverNames(doc: unknown, out: Map, depth = 0): void { + if (depth > 8 || typeof doc !== "object" || doc === null || Array.isArray(doc)) return; + for (const [k, v] of Object.entries(doc as Record)) { + if (k === "mcp_servers" && typeof v === "object" && v !== null && !Array.isArray(v)) { + for (const [name, entry] of Object.entries(v as Record)) { + const kind = typeof entry === "object" && entry !== null && "url" in entry ? "url" : "command"; + const seen = out.get(name); + if (seen && seen !== kind) { + throw new CodexIsolationError(`codex configs declare MCP server "${name}" both as a URL and as a command: refusing to start without knowing which codex runs`); + } + out.set(name, kind); + } + } else serverNames(v, out, depth + 1); + } +} + +/** The config files codex may load for a session in `workdir`. */ +export function codexConfigFiles(codexHome: string, workdir: string): string[] { + const files = [join(codexHome, "config.toml")]; + let cur = workdir; + for (let i = 0; i < MAX_WALK; i += 1) { + files.push(join(cur, ".codex", "config.toml")); + const up = dirname(cur); + if (up === cur) break; + cur = up; + } + return files; +} + +/** + * The `-c` arguments that keep codex to codeoid's servers: each server its + * own configs declare, other than `own`, turned off; apps off. + */ +export function codexIsolationArgs(codexHome: string, workdir: string, own: ReadonlySet): string[] { + const declared = new Map(); + for (const file of codexConfigFiles(codexHome, workdir)) { + const text = readConfig(file); + if (text === undefined) continue; + let doc: unknown; + try { + doc = Bun.TOML.parse(text); + } catch { + throw new CodexIsolationError(`codex config ${file} isn't valid TOML: refusing to start without knowing its MCP servers`); + } + serverNames(doc, declared); + } + const args: string[] = []; + for (const [name, kind] of [...declared].sort(([a], [b]) => a.localeCompare(b))) { + if (own.has(name)) continue; + if (!codexKeySafe(name)) { + throw new CodexIsolationError(`codex config declares an MCP server named ${JSON.stringify(name)}, which codeoid can't turn off by name: rename it to start codex under workstream isolation`); + } + args.push( + "-c", + kind === "url" ? `mcp_servers.${name}.url="http://127.0.0.1:9/codeoid-disabled"` : `mcp_servers.${name}.command="codeoid-disabled"`, + "-c", + `mcp_servers.${name}.enabled=false`, + ); + } + args.push("-c", "features.apps=false"); + return args; +} + +/** The server names a set of `-c mcp_servers..…` arguments adds. */ +export function codexMountedNames(args: readonly string[]): Set { + const out = new Set(); + for (const a of args) { + const m = /^mcp_servers\.([A-Za-z0-9_-]+)\./.exec(a); + if (m) out.add(m[1]!); + } + return out; +} diff --git a/src/daemon/providers/qwen/index.ts b/src/daemon/providers/qwen/index.ts index 7d7b8cef..ba0ca283 100644 --- a/src/daemon/providers/qwen/index.ts +++ b/src/daemon/providers/qwen/index.ts @@ -503,6 +503,12 @@ export class QwenProvider implements SessionProvider { ...(Object.keys(mcpServers).length > 0 ? { mcpServers: mcpServers as never } : {}), + // Workstream isolation (G1): Qwen also loads MCP servers from its own + // settings (~/.qwen, a project's .qwen/ and .mcp.json): kept to the + // names codeoid mounts. Empty would mean "no filter". + ...(init.mcpRegistry?.isolated + ? { allowedMcpServerNames: Object.keys(mcpServers).length > 0 ? Object.keys(mcpServers).sort() : ["codeoid-isolated-none"] } + : {}), ...(desiredAppend ? { systemPrompt: { diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 71b87be2..71d5d988 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -4276,7 +4276,10 @@ mcpHub: this.#mcpHub, // workstream's grant as it is NOW (G4): a revoked server fails mid-turn. hub.setPermit((sessionId, spec) => { const session = this.#sessions.get(sessionId); - return session ? grantRefusal(this.#workstreams.grantFor(session.workstream), spec) : undefined; + if (session) return grantRefusal(this.#workstreams.grantFor(session.workstream), spec); + // Not (or no longer) a session here — a teardown racing a call: under + // isolation that is no grant at all, never every server (W1 audit). + return this.#workstreams.grantFor(undefined) ? `MCP server "${spec.name}": no such session` : undefined; }); this.#mcpOAuth = oauth; } @@ -4578,7 +4581,7 @@ mcpHub: this.#mcpHub, if (!hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) || auth.delegationDepth > 0) { return { type: "response.error", requestId: msg.id, error: "Overriding a folder's workstream needs session:approve on an undelegated token", code: "forbidden" }; } - if (msg.workstream === undefined || !this.#overrides.redeem(msg.override.token, { sub: auth.sub, workdir, chosen: msg.workstream, revision: this.#workstreams.revision })) { + if (msg.workstream === undefined || !this.#overrides.valid(msg.override.token, { sub: auth.sub, workdir, chosen: msg.workstream, revision: this.#workstreams.revision })) { return { type: "response.error", requestId: msg.id, error: "The override token is invalid, used, or expired — or the workstreams changed since: ask again", code: "invalid_request" }; } overridden = true; @@ -4606,7 +4609,7 @@ mcpHub: this.#mcpHub, const resolution = homing.kind === "workstream_unknown" ? undefined - : conflict?.overridable && msg.workstream !== undefined + : conflict?.overridable && msg.workstream !== undefined && hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) && auth.delegationDepth === 0 ? { ...homing.resolution, conflict: { @@ -4627,27 +4630,30 @@ mcpHub: this.#mcpHub, }; } - // A repository rule matching otherwise than this folder resolved before - // (§5.1): an `origin` is text an agent can edit, so a changed match is the - // owner's to confirm — by choosing — never a silent re-home. - if (homing.ok && homing.binding?.source === "matched" && homing.binding.id) { + // The folder record (§5.1) is what the FOLDER matches, never the home a + // session chose: an `origin` is text an agent can edit, so a repository + // match that changed since the folder was last used is the owner's to + // confirm — by choosing — never a silent re-home. + const own = homing.ok && this.#workstreams.configured ? this.#workstreams.resolve(workdir, undefined, givenWorkdir) : undefined; + const folderMatch = own?.source === "matched" ? own.home : undefined; + // An explicit choice settles it for the owner — but not for a delegated + // token, which could otherwise edit the origin and then "choose" the + // workstream it now matches (W1 audit). + if (homing.ok && (msg.workstream === undefined || auth.delegationDepth > 0) && folderMatch && own?.matchedBy?.startsWith("remote ")) { const before = this.#store.workstreamFolder({ accountId: auth.accountId, projectId: auth.projectId, workdir }); - if (before && before !== homing.binding.id && homing.binding.matchedBy?.startsWith("remote ")) { - this.#store.audit(auth.sub, "session.workstream.remote_changed", "", `workdir=${workdir} was=${before} now=${homing.binding.id}`); + if (before && before !== folderMatch) { + this.#store.audit(auth.sub, "session.workstream.remote_changed", "", `workdir=${workdir} was=${before} now=${folderMatch}`); return { type: "response.error", requestId: msg.id, - error: `This folder was workstream "${before}", but its repository now matches "${homing.binding.id}" (${homing.binding.matchedBy}). Its remote may have been changed: choose the workstream with --workstream.`, + error: `This folder matched workstream "${before}", but its repository now matches "${folderMatch}" (${own.matchedBy}). Its remote may have been changed: choose the workstream with --workstream.`, code: "invalid_request", - detail: { kind: "workstream_required", resolution: { source: "none", candidates: homing.resolution?.candidates ?? [], ambiguous: [before, homing.binding.id].sort() } }, + detail: { kind: "workstream_required", resolution: { source: "none", candidates: own.candidates, ambiguous: [before, folderMatch].sort() } }, }; } } for (const w of homing.warnings) warn(w); const createWorkstream = homing.binding; - if (createWorkstream?.id && createWorkstream.source !== "default") { - this.#store.setWorkstreamFolder({ accountId: auth.accountId, projectId: auth.projectId, workdir }, createWorkstream.id); - } if (createWorkstream?.source === "override") { const c = homing.resolution?.conflict; this.#store.audit(auth.sub, "workstream.override", "", `workdir=${workdir} folder=${c?.folder ?? "?"} chosen=${createWorkstream.id} revision=${createWorkstream.revision}`); @@ -4963,6 +4969,12 @@ mcpHub: this.#mcpHub, goalSessionId = session.id; this.#sessions.set(session.id, session); this.#rateLimiter.recordCreation(auth.sub); + // Now the session exists: record what the folder matches (any create here + // — a choice too — vouches for the folder as it is now). + // The override it confirmed is spent now that the session exists (G3). + if (msg.override && createWorkstream?.source === "override") this.#overrides.spend(msg.override.token); + // Never from a delegated token's create: what the folder matched is the owner's to vouch for. + if (folderMatch && auth.delegationDepth === 0) this.#store.setWorkstreamFolder({ accountId: auth.accountId, projectId: auth.projectId, workdir }, folderMatch); // What the sandbox resolves to here, said at once (#448). const sandboxInfo = await session.ensureSandbox(); if (msg.sandbox?.provider === "off") this.#store.audit(auth.sub, "session.sandbox_off", session.id, "at create"); diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 162a6d09..6264b1f8 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -2976,6 +2976,32 @@ export class Session { if (this.#destroyed) return; await this.#rebuildForMcp(); this.#auditRepoServers(); // a backend switched to, or a sign-in hold that lapsed + this.#noteWithheldServers(); + } + + /** The servers last said to be withheld (workstream isolation), so each set is said once. */ + #withheldSaid = ""; + + /** + * Under workstream isolation (G1), servers the home selects that this + * backend can't be given without holding a secret (a stdio server needing + * env values): an info line once per set, so a missing tool isn't a mystery. + */ + #noteWithheldServers(): void { + const names = (this.#mcpView?.withheldFromNative(this.#provider.id) ?? []).map((s) => s.name).sort(); + const key = `${this.#provider.id}\u0000${names.join("\u0000")}`; + if (names.length === 0 || key === this.#withheldSaid) return; + this.#withheldSaid = key; + const infoMsg = this.#makeMessage( + "info", + `⚠ Not given on ${this.#provider.id}: ${names.join(", ")} — a stdio MCP server that needs secret values can't be handed to this backend without exposing them (workstream isolation).`, + SYSTEM_IDENTITY, + undefined, + undefined, + { event: "workstream.servers_withheld", servers: names.join(",") }, + ); + this.#persistAndBuffer(infoMsg); + this.#broadcastRaw(infoMsg); } /** @@ -4854,6 +4880,13 @@ export class Session { this.#store.audit(sender.sub, "session.sandbox_refused", this.id, sandboxRefused); throw new SandboxRequiredError(`Not started: ${sandboxRefused}`); } + // Workstream isolation (G1) on a backend that can't be kept to codeoid's + // MCP servers yet: opencode loads a project's own config, which codeoid + // doesn't filter. Refused rather than run with servers the home didn't give. + if (this.#provider.id === "opencode" && this.#mcpView?.isolated) { + this.#store.audit(sender.sub, "session.isolation_refused", this.id, "opencode"); + throw new Error("Not started: opencode can't be kept to its workstream's MCP servers yet — switch this session to another backend, or turn workstream isolation off"); + } this.#store.audit(sender.sub, "session.send", this.id); // Its worktree folders, removed while empty at the last turn's end, are there again for this one's commands: only // once its directories are what was pinned (#448 round 14), and never made through a link (worktrees.ts). diff --git a/src/daemon/store.ts b/src/daemon/store.ts index 81db875a..e6222350 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -422,10 +422,6 @@ export class Store { PRIMARY KEY (account_id, project_id, workdir, provider_id, model) ); - -- A repo's MCP server (a workdir's .mcp.json or project settings) - -- approved in codeoid to reach every backend there (#395). Bound to the - -- entry's fingerprint: a repo that edits the entry needs consent again. - -- Tenant- and workdir-scoped, like the sessions that use it. -- How each folder last resolved to a workstream, per tenant -- (docs/workstreams-design.md §5.1): a repository rule that matches -- differently later (an edited origin) is refused, not a silent re-home. @@ -438,6 +434,10 @@ export class Store { PRIMARY KEY (account_id, project_id, workdir) ); + -- A repo's MCP server (a workdir's .mcp.json or project settings) + -- approved in codeoid to reach every backend there (#395). Bound to the + -- entry's fingerprint: a repo that edits the entry needs consent again. + -- Tenant- and workdir-scoped, like the sessions that use it. CREATE TABLE IF NOT EXISTS mcp_workdir_approvals ( account_id TEXT NOT NULL, project_id TEXT NOT NULL, @@ -1963,7 +1963,6 @@ export class Store { .run(scope.accountId, scope.projectId, scope.workdir, server, fingerprint, approvedBy); } - /** The (server, fingerprint) pairs a tenant approved for a workdir. */ /** The workstream a folder last resolved to, for this tenant (workstreams §5.1). */ workstreamFolder(scope: { accountId: string; projectId: string; workdir: string }): string | undefined { const row = this.#db @@ -1981,6 +1980,7 @@ export class Store { .run(scope.accountId, scope.projectId, scope.workdir, workstream); } + /** The (server, fingerprint) pairs a tenant approved for a workdir. */ workdirMcpApprovals(scope: { accountId: string; projectId: string; workdir: string }): Set { const rows = this.#db .prepare("SELECT server, fingerprint FROM mcp_workdir_approvals WHERE account_id = ? AND project_id = ? AND workdir = ?") diff --git a/src/daemon/workstreams/override.ts b/src/daemon/workstreams/override.ts index 4373b541..b30d2b66 100644 --- a/src/daemon/workstreams/override.ts +++ b/src/daemon/workstreams/override.ts @@ -41,16 +41,25 @@ export class OverrideTokens { return token; } - /** True once, for a live token bound to exactly this request. A wrong binding spends nothing. */ - redeem(token: string, binding: OverrideBinding): boolean { + /** Whether `token` is live and bound to exactly this request. Spends nothing. */ + valid(token: string, binding: OverrideBinding): boolean { this.#sweep(); - const h = hashOf(token); - const held = this.#tokens.get(h); + const held = this.#tokens.get(hashOf(token)); if (!held) return false; const want = Buffer.from(keyOf(binding)); const have = Buffer.from(held.key); - if (want.length !== have.length || !timingSafeEqual(want, have)) return false; - this.#tokens.delete(h); + return want.length === have.length && timingSafeEqual(want, have); + } + + /** Spend a token: once the session it confirmed exists, so a create that fails later can be retried with it. */ + spend(token: string): void { + this.#tokens.delete(hashOf(token)); + } + + /** True once, for a live token bound to exactly this request. A wrong binding spends nothing. */ + redeem(token: string, binding: OverrideBinding): boolean { + if (!this.valid(token, binding)) return false; + this.spend(token); return true; } diff --git a/src/tests/workstreams-isolation.test.ts b/src/tests/workstreams-isolation.test.ts index 85fc7631..3ad38773 100644 --- a/src/tests/workstreams-isolation.test.ts +++ b/src/tests/workstreams-isolation.test.ts @@ -169,14 +169,17 @@ describe("every call is checked against the grant as it is now (G4)", () => { }); describe("Claude under isolation", () => { - it("runs strict, and every reachable server is a bridge to the hub — no config, command or secret of its own", async () => { - const v = await view({ workstream: "acme", servers: new Map([["notes", {}], ["platform", {}]]), workdirServers: false }); + it("runs strict; every reachable HTTP server is a bridge to the hub, a stdio one stays the CLI's to run in its sandbox", async () => { + const v = await view({ workstream: "acme", servers: new Map([["notes", {}], ["platform", {}], ["vault", {}]]), workdirServers: false }); const hub = new McpHub({}); const r = oauthServersForClaude(v, hub, { workspaceId: "w", sessionId: "s", tenant: TENANT }); expect(r.strict).toBe(true); const mounts = r.mounts(); - expect(Object.keys(mounts).sort()).toEqual(["notes", "platform"]); + expect(Object.keys(mounts)).toEqual(["platform"]); for (const m of Object.values(mounts)) expect(m.type).toBe("sdk"); + // notes needs no secret: the CLI runs it; vault needs env values: withheld, and said. + expect(names(v.forNativeMount("claude"))).toEqual(["notes"]); + expect(names(v.withheldFromNative("claude"))).toEqual(["vault"]); }); it("without isolation nothing changes: not strict, no bridges beyond signed-in OAuth servers", async () => { @@ -231,3 +234,44 @@ describe("WorkstreamService.grantFor", () => { } }); }); + +describe("secrets outside the obvious fields (W1 audit)", () => { + it("a token in a URL or a command line isn't secret-free: relayed or withheld, never mounted natively", async () => { + const registry = new McpRegistry( + { + zap: raw({ url: "https://hooks.zapier.com/api/mcp/s/Zx9Kq2Lm8Np4Rt7Vw1Yb3Cd5Ef6Gh0Jk/mcp" }), + tok: raw({ command: "gh-mcp", args: ["--token", "ghp_abcdefghijklmnopqrstuvwxyz0123456789"] }), + plain: raw({ command: "npx", args: ["-y", "@acme/mcp"] }), + }, + { memoryEnabled: false }, + ); + const v = new SessionMcpView(registry, null, { + workdir: "/w", + tenant: TENANT, + approvals: () => new Set(), + read: async () => [], + readGlobal: async () => null, + grant: () => ({ workstream: "a", servers: new Map([["zap", {}], ["tok", {}], ["plain", {}]]), workdirServers: false }), + }); + await v.load(); + expect(names(v.forNativeMount("codex"))).toEqual(["plain"]); + expect(names(v.relayFor("codex"))).toEqual(["zap"]); + expect(names(v.withheldFromNative("codex"))).toEqual(["tok"]); + }); + + it("a server config can't name the daemon's own variables: ${CODEOID_*} resolves empty, a CODEOID_ bearerTokenEnv is dropped", async () => { + const { resolveEnvValue } = await import("../daemon/mcp/types.js"); + const env = { CODEOID_CRED_X: "secret", ZEROID_KEY: "k", MINE: "ok" }; + expect(resolveEnvValue("${CODEOID_CRED_X}", env)).toBe(""); + expect(resolveEnvValue("${ZEROID_KEY}", env)).toBe(""); + expect(resolveEnvValue("${MINE}", env)).toBe("ok"); + const spec = new McpRegistry({ s: raw({ url: "https://s.example", bearerTokenEnv: "CODEOID_CRED_X" }) }, { memoryEnabled: false }).get("s"); + expect(spec?.transport).not.toHaveProperty("bearerTokenEnv", "CODEOID_CRED_X"); + }); + + it("not isolated, the relay reaches exactly what it did before: the daemon registry's OAuth servers", async () => { + const v = await view(undefined, [entry("mine", "claude-local", raw({ url: "https://mine.example", oauth: true }))]); + expect(names(v.relayFor("codex"))).toEqual([]); + }); +}); + diff --git a/src/tests/workstreams-manager.test.ts b/src/tests/workstreams-manager.test.ts index 550a46f6..65037c8f 100644 --- a/src/tests/workstreams-manager.test.ts +++ b/src/tests/workstreams-manager.test.ts @@ -376,7 +376,7 @@ describe("the folder record: a repository match that changed is refused, not a s origin("https://github.com/corp/app"); const refused = await create(m, "r2", "repo"); expect(refused).toMatchObject({ type: "response.error", detail: { kind: "workstream_required" } }); - if (refused.type === "response.error") expect(refused.error).toContain('was workstream "mine"'); + if (refused.type === "response.error") expect(refused.error).toContain('matched workstream "mine"'); expect(((await create(m, "r3", "repo", { workstream: "corp" })) as { data: SessionInfo }).data.workstream).toMatchObject({ id: "corp" }); expect(((await create(m, "r4", "repo")) as { data: SessionInfo }).data.workstream).toMatchObject({ id: "corp", source: "matched" }); }); @@ -404,3 +404,43 @@ describe("signing in to a credential slot", () => { } }); }); + +describe("the folder record after the W1 audit", () => { + it("an explicit choice never poisons it: the next plain create in the folder still gets the folder's match", async () => { + const repo = join(tmp, "home", "repo2"); + mkdirSync(join(repo, ".git"), { recursive: true }); + writeFileSync(join(repo, ".git", "config"), `[remote "origin"]\n\turl = https://github.com/mine/app\n`); + const m = manager({ items: [{ id: "mine", match: { remotes: ["github.com/mine/*"] } }, { id: "research" }] }); + expect((await create(m, "p1", "repo2", { workstream: "research" })).type).toBe("response.ok"); + expect(((await create(m, "p2", "repo2")) as { data: SessionInfo }).data.workstream).toMatchObject({ id: "mine", source: "matched" }); + }); + + it("a delegated token can't edit the origin and then choose the workstream it now matches", async () => { + const repo = join(tmp, "home", "repo3"); + mkdirSync(join(repo, ".git"), { recursive: true }); + const origin = (url: string) => writeFileSync(join(repo, ".git", "config"), `[remote "origin"]\n\turl = ${url}\n`); + origin("https://github.com/acme/x"); + const m = manager({ items: [{ id: "acme", match: { remotes: ["github.com/acme/*"] } }, { id: "beta", match: { remotes: ["github.com/beta/*"] } }] }); + expect((await create(m, "q1", "repo3")).type).toBe("response.ok"); + origin("https://github.com/beta/x"); + const r = await m.handle( + { type: "session.create", id: "q2", name: "q2", workdir: repo, workstream: "beta" } as never, + { ...OWNER, delegationDepth: 1 }, + client, + ); + expect(r).toMatchObject({ type: "response.error", detail: { kind: "workstream_required" } }); + }); +}); + +describe("the hub's per-call check, wired by the manager (G4)", () => { + it("under isolation an unknown session reaches nothing; a live one only its grant", async () => { + const m = manager({ ...WS, isolateGlobalServers: true }); + const hub = new McpHub({}); + m.setMcp(new McpRegistry({}, { memoryEnabled: false }), hub); + const spec = { name: "notes", builtin: false, transport: { kind: "stdio", command: "x", args: [], env: {} }, trust: "prompt", scope: "workspace", enabled: true, native: false } as never; + expect(await hub.callTool(spec, "t", {}, { workspaceId: "w", sessionId: "nope" })).toMatchObject({ isError: true, text: expect.stringContaining("no such session") }); + const made = (await create(m, "g1", "mine")) as { data: SessionInfo }; + expect(await hub.callTool(spec, "t", {}, { workspaceId: "w", sessionId: made.data.id })).toMatchObject({ isError: true, text: expect.stringContaining('not in workstream "mine"') }); + }); +}); + From a4a0ea7efc24a461e2575f3fc75025dc6f510279 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 13:43:20 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20workstreams=20W1=20audit=20round?= =?UTF-8?q?=202=20=E2=80=94=20codex=20plugins=20off,=20rejecting=20a=20cha?= =?UTF-8?q?nged=20origin=20sticks,=20the=20daemon-variable=20rule=20narrow?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codex plugins bring MCP servers that can't be turned off by name: under isolation plugins are off whole (and /etc/codex layers are read too) - choosing a workstream other than the folder's match (rejecting a changed origin included) no longer records the new match; the record changes only when the owner accepts the match - codex's home config.toml is read through a link (account overlays link it; isolation failed to start for every codex account); a project's is not - a codeoid server named like one of codex's own entries is refused: codex merges the two, lending codeoid's server that entry's headers or env - the rule against naming daemon variables in a server config is narrowed to workstream credentials (CODEOID_CRED_*): a user's own CODEOID_* variable in their own config resolves as before - an override token is spent up front again: single use under concurrent creates - tests: codex plugins/links/merged names, the Gemini/Qwen allowlist, the opencode refusal, the record after a rejected change Co-Authored-By: Claude Opus 5.5 --- docs/CONFIGURATION.md | 4 +-- src/daemon/mcp/registry.ts | 2 +- src/daemon/mcp/types.ts | 16 +++++---- src/daemon/providers/acp/index.ts | 12 ++++++- src/daemon/providers/codex/isolation.test.ts | 29 +++++++++++++--- src/daemon/providers/codex/isolation.ts | 32 ++++++++++++----- src/daemon/providers/qwen/index.ts | 3 +- src/daemon/session-manager.ts | 15 +++++--- src/tests/workstreams-isolation.test.ts | 17 ++++++--- src/tests/workstreams-manager.test.ts | 36 ++++++++++++++++++++ 10 files changed, 131 insertions(+), 35 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2350b5b9..e160714c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -439,9 +439,9 @@ Every reference to a credential is checked when the config loads: declared once, - **env and git identity** reach the backend's process (not opencode's shared server yet). A name codeoid owns for the backend (`ANTHROPIC_*`, `PATH`, `LD_*`, …) fails the config load. - **mcpServers** take effect with `isolateGlobalServers: true`. Then a session reaches built-ins, the servers its home names, and the folder's own servers only when the folder is the home's; no home, the conductor's, or a deleted one: built-ins only. - Claude runs with a strict MCP config: nothing from `~/.claude.json`, a `.mcp.json`, a plugin or an agent's own. Every HTTP server it reaches is codeoid's, called by the daemon (tools only: a server's prompts and resources don't pass); a stdio server needing no secret is the CLI's to run, inside the session's sandbox as before. - - Codex, the Gemini CLI and Qwen are kept to codeoid's servers: Codex has every server its own configs declare (`~/.codex/config.toml`, a project's `.codex/config.toml`) turned off and its apps off; the Gemini CLI and Qwen are started with only codeoid's server names allowed. opencode can't be kept to them yet: under isolation its turns are refused. + - Codex, the Gemini CLI and Qwen are kept to codeoid's servers: Codex has every server its own configs declare (`/etc/codex/`, `~/.codex/config.toml`, a project's `.codex/config.toml`) turned off, and its apps and plugins off — a codeoid server named like one of those is refused, since codex would lend it that entry's headers; the Gemini CLI and Qwen are started with only codeoid's server names allowed. opencode can't be kept to them yet: under isolation its turns are refused. - Every backend mounts natively only servers that need no secret — none in a header, an env value, the URL (a token in its path or query) or the command line. An HTTP server that needs one goes through the daemon's relay; a stdio one isn't given, and the session says so. - - A server config can never name codeoid's own variables: `${CODEOID_*}` (every credential's value lives there), `${ZEROID_*}`, `${TELEGRAM_*}` resolve empty, and such a `bearerTokenEnv` is ignored. + - A server config can never name a workstream credential's variable: `${CODEOID_CRED_*}` resolves empty and such a `bearerTokenEnv` is ignored (a repository's config can name none of codeoid's variables at all, as before). - A credential's value is read by the daemon when it calls the server, never handed to a backend. A server removed from a workstream fails on the next call, mid-turn; the tool list catches up at the next turn. - **skillDirs** are accepted but not applied yet (the daemon says so at start). diff --git a/src/daemon/mcp/registry.ts b/src/daemon/mcp/registry.ts index 3c9d32b0..b5396ec8 100644 --- a/src/daemon/mcp/registry.ts +++ b/src/daemon/mcp/registry.ts @@ -152,7 +152,7 @@ export function normalizeSpec(name: string, raw: RawMcpServerConfig): McpServerS kind: "http", url: raw.url, headers: raw.headers, - // Never one of the daemon's own variables (isDaemonOwnedEnvName): + // Never a workstream credential's variable (isDaemonOwnedEnvName): // every backend reads this name from the daemon's environment. bearerTokenEnv: raw.bearerTokenEnv && !isDaemonOwnedEnvName(raw.bearerTokenEnv) ? raw.bearerTokenEnv : undefined, ...(raw.oauth !== undefined ? { oauth: raw.oauth === true ? {} : raw.oauth } : {}), diff --git a/src/daemon/mcp/types.ts b/src/daemon/mcp/types.ts index cce67531..cbecdfee 100644 --- a/src/daemon/mcp/types.ts +++ b/src/daemon/mcp/types.ts @@ -157,19 +157,21 @@ export function isProxied(spec: McpServerSpec): boolean { export function resolveEnvValue(value: string, env: Record): string { const m = /^\$\{(\w+)\}$/.exec(value); if (!m) return value; - // Never codeoid's own: the daemon's environment carries every workstream's - // credential (CODEOID_CRED_*), its root key and bot tokens — a server - // config (a repo's .mcp.json included) naming one must not be handed it. + // Never a workstream credential: the daemon's environment carries every + // workstream's (CODEOID_CRED_*), and a server config naming one would hand + // one workstream's secret to a server of any (workstreams W1 audit). return isDaemonOwnedEnvName(m[1]) ? "" : (env[m[1]] ?? ""); } /** - * The daemon's own variables (providers/env.ts DENY_PREFIXES): kept out of - * every agent process, and out of every value an MCP server config can name - * by `${VAR}` or `bearerTokenEnv` (workstreams W1 audit). + * Variables no MCP server config may name by `${VAR}` or `bearerTokenEnv`: + * the workstream credentials (credentials/store.ts). Narrow on purpose — a + * user's own config naming one of their own `CODEOID_*` variables keeps + * working as before; a repository's config can name none of codeoid's at all + * (workdir-servers.ts withoutDaemonSecrets). */ export function isDaemonOwnedEnvName(name: string): boolean { - return /^(CODEOID_|ZEROID_|TELEGRAM_)/.test(name); + return name.startsWith("CODEOID_CRED_"); } /** Resolve every value in an env map via {@link resolveEnvValue}. */ diff --git a/src/daemon/providers/acp/index.ts b/src/daemon/providers/acp/index.ts index 58f5475f..d00ffb3d 100644 --- a/src/daemon/providers/acp/index.ts +++ b/src/daemon/providers/acp/index.ts @@ -524,7 +524,7 @@ export class GeminiAcpProvider implements SessionProvider { if (this.#sandboxMcp?.()) names.add(SANDBOX_MCP_SERVER_NAME); for (const s of reg.forNativeMount(this.id)) names.add(s.name); for (const s of reg.relayFor(this.id)) names.add(s.name); - return ["--allowed-mcp-server-names", ...(names.size > 0 ? [...names].sort() : ["codeoid-isolated-none"])]; + return ["--allowed-mcp-server-names", ...allowedMcpNames(names)]; } #push(event: ProviderEvent): void { @@ -678,3 +678,13 @@ function toolCallOutput(update: Record): string { return ""; } +/** + * The names a CLI with an MCP allowlist (the Gemini CLI, Qwen) is started + * with under workstream isolation: exactly codeoid's, sorted. Never empty — + * an empty list means "no filter" to them — so none gives one no server has. + */ +export function allowedMcpNames(names: Iterable): string[] { + const list = [...new Set(names)].sort(); + return list.length > 0 ? list : ["codeoid-isolated-none"]; +} + diff --git a/src/daemon/providers/codex/isolation.test.ts b/src/daemon/providers/codex/isolation.test.ts index 598f3914..1c69300c 100644 --- a/src/daemon/providers/codex/isolation.test.ts +++ b/src/daemon/providers/codex/isolation.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CodexIsolationError, codexIsolationArgs, codexMountedNames } from "./isolation.js"; @@ -14,7 +14,7 @@ describe("codex under workstream isolation", () => { test("every server its user and project configs declare, but codeoid's, is off — with a placeholder of its kind; apps off", () => { const home = join(tmp, "codex"); mkdirSync(home); - writeFileSync(join(home, "config.toml"), '[mcp_servers.leaky]\nurl = "https://leaky.example/mcp?token=x"\n\n[profiles.work.mcp_servers.prof]\ncommand = "p"\n\n[mcp_servers.codeoid_memory]\nurl = "x"\n'); + writeFileSync(join(home, "config.toml"), '[mcp_servers.leaky]\nurl = "https://leaky.example/mcp?token=x"\n\n[profiles.work.mcp_servers.prof]\ncommand = "p"\n'); const work = join(tmp, "repo", "sub"); mkdirSync(join(tmp, "repo", ".codex"), { recursive: true }); mkdirSync(work, { recursive: true }); @@ -24,14 +24,14 @@ describe("codex under workstream isolation", () => { "-c", 'mcp_servers.leaky.url="http://127.0.0.1:9/codeoid-disabled"', "-c", "mcp_servers.leaky.enabled=false", "-c", 'mcp_servers.prof.command="codeoid-disabled"', "-c", "mcp_servers.prof.enabled=false", "-c", 'mcp_servers.proj.command="codeoid-disabled"', "-c", "mcp_servers.proj.enabled=false", - "-c", "features.apps=false", + "-c", "features.apps=false", "-c", "features.plugins=false", ]); // The real URL (with its token) never reaches argv. expect(args.join(" ")).not.toContain("leaky.example"); }); test("no config at all: apps off, nothing else", () => { - expect(codexIsolationArgs(join(tmp, "none"), tmp, new Set())).toEqual(["-c", "features.apps=false"]); + expect(codexIsolationArgs(join(tmp, "none"), tmp, new Set())).toEqual(["-c", "features.apps=false", "-c", "features.plugins=false"]); }); test("fails closed: a name it can't write as a key, invalid TOML, or one server declared as both kinds", () => { @@ -50,4 +50,25 @@ describe("codex under workstream isolation", () => { test("codexMountedNames reads the names codeoid's own -c arguments add", () => { expect([...codexMountedNames(["-c", 'mcp_servers.codeoid_memory.url="u"', "-c", "mcp_servers.gh.bearer_token_env_var=\"T\"", "-c", "other=1"])].sort()).toEqual(["codeoid_memory", "gh"]); }); + + test("a name in both codeoid's mounts and codex's own config is refused: codex would merge the user's headers into codeoid's server", () => { + const home = join(tmp, "codex"); + mkdirSync(home); + writeFileSync(join(home, "config.toml"), '[mcp_servers.linear]\nurl = "https://linear.example"\nhttp_headers = { X-Api-Key = "sekrit" }\n'); + expect(() => codexIsolationArgs(home, tmp, new Set(["linear"]))).toThrow(/also declares an MCP server named "linear"/); + }); + + test("the home's config.toml is read through a link (account overlays link it); a project's isn't", () => { + const real = join(tmp, "real.toml"); + writeFileSync(real, '[mcp_servers.u]\ncommand = "u"\n'); + const home = join(tmp, "overlay"); + mkdirSync(home); + symlinkSync(real, join(home, "config.toml")); + expect(codexIsolationArgs(home, join(tmp, "none"), new Set())).toContain("mcp_servers.u.enabled=false"); + const proj = join(tmp, "proj"); + mkdirSync(join(proj, ".codex"), { recursive: true }); + symlinkSync(real, join(proj, ".codex", "config.toml")); + expect(() => codexIsolationArgs(join(tmp, "none"), proj, new Set())).toThrow(CodexIsolationError); + }); }); + diff --git a/src/daemon/providers/codex/isolation.ts b/src/daemon/providers/codex/isolation.ts index 3e8ea6f5..b313c49f 100644 --- a/src/daemon/providers/codex/isolation.ts +++ b/src/daemon/providers/codex/isolation.ts @@ -7,7 +7,7 @@ * mcp_servers={}` override does not clear them (measured: codex 0.166 merges * the override into the table), but `-c mcp_servers..enabled=false` * turns one off. So every server any of those files declares, that codeoid - * didn't add, is turned off by name, and apps with them. + * didn't add, is turned off by name, and apps and plugins with them. * * Each one also gets a placeholder transport of its own kind (a dead loopback * URL, a no-op command): a project file codex doesn't trust isn't loaded, and @@ -34,11 +34,16 @@ export class CodexIsolationError extends Error { } } -/** A small regular file's text, never through a final link, never blocking; undefined if absent. */ -function readConfig(path: string): string | undefined { +/** + * A small regular file's text, never blocking; undefined if absent. A final + * link is followed only where the owner put one (`followLink`: codex's own + * home, where account overlays and dotfile managers link config.toml), never + * in the workdir walk. + */ +function readConfig(path: string, followLink = false): string | undefined { let fd: number | undefined; try { - fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW | fsc.O_NONBLOCK); + fd = openSync(path, fsc.O_RDONLY | fsc.O_NONBLOCK | (followLink ? 0 : fsc.O_NOFOLLOW)); } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT" || (e as NodeJS.ErrnoException).code === "ENOTDIR") return undefined; throw new CodexIsolationError(`codex config ${path} can't be read (${(e as NodeJS.ErrnoException).code}): refusing to start without knowing its MCP servers`); @@ -76,9 +81,9 @@ function serverNames(doc: unknown, out: Map, depth = } } -/** The config files codex may load for a session in `workdir`. */ +/** The config files codex may load for a session in `workdir`: system layers, its home's, a project's. */ export function codexConfigFiles(codexHome: string, workdir: string): string[] { - const files = [join(codexHome, "config.toml")]; + const files = ["/etc/codex/config.toml", "/etc/codex/managed_config.toml", join(codexHome, "config.toml")]; let cur = workdir; for (let i = 0; i < MAX_WALK; i += 1) { files.push(join(cur, ".codex", "config.toml")); @@ -95,8 +100,9 @@ export function codexConfigFiles(codexHome: string, workdir: string): string[] { */ export function codexIsolationArgs(codexHome: string, workdir: string, own: ReadonlySet): string[] { const declared = new Map(); + const followed = new Set(["/etc/codex/config.toml", "/etc/codex/managed_config.toml", join(codexHome, "config.toml")]); for (const file of codexConfigFiles(codexHome, workdir)) { - const text = readConfig(file); + const text = readConfig(file, followed.has(file)); if (text === undefined) continue; let doc: unknown; try { @@ -108,7 +114,12 @@ export function codexIsolationArgs(codexHome: string, workdir: string, own: Read } const args: string[] = []; for (const [name, kind] of [...declared].sort(([a], [b]) => a.localeCompare(b))) { - if (own.has(name)) continue; + if (own.has(name)) { + // codex MERGES codeoid's `-c mcp_servers..*` into a same-named + // entry of its own config (measured): codeoid's server would be called + // with that entry's headers, env or bearer. Refused, not merged. + throw new CodexIsolationError(`codex's own config also declares an MCP server named "${name}": it would lend its headers or env to codeoid's — rename one to start codex under workstream isolation`); + } if (!codexKeySafe(name)) { throw new CodexIsolationError(`codex config declares an MCP server named ${JSON.stringify(name)}, which codeoid can't turn off by name: rename it to start codex under workstream isolation`); } @@ -119,7 +130,10 @@ export function codexIsolationArgs(codexHome: string, workdir: string, own: Read `mcp_servers.${name}.enabled=false`, ); } - args.push("-c", "features.apps=false"); + // Apps (ChatGPT connectors) and plugins bring servers of their own, which + // can't be turned off by name (measured: a plugin server named as + // `enabled=false` is refused as "invalid transport"): both off whole. + args.push("-c", "features.apps=false", "-c", "features.plugins=false"); return args; } diff --git a/src/daemon/providers/qwen/index.ts b/src/daemon/providers/qwen/index.ts index ba0ca283..c685550e 100644 --- a/src/daemon/providers/qwen/index.ts +++ b/src/daemon/providers/qwen/index.ts @@ -30,6 +30,7 @@ */ import type { McpTenant } from "../../mcp/oauth.js"; +import { allowedMcpNames } from "../acp/index.js"; import { query, type Query, type SDKMessage, type SDKUserMessage } from "@qwen-code/sdk"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; @@ -507,7 +508,7 @@ export class QwenProvider implements SessionProvider { // settings (~/.qwen, a project's .qwen/ and .mcp.json): kept to the // names codeoid mounts. Empty would mean "no filter". ...(init.mcpRegistry?.isolated - ? { allowedMcpServerNames: Object.keys(mcpServers).length > 0 ? Object.keys(mcpServers).sort() : ["codeoid-isolated-none"] } + ? { allowedMcpServerNames: allowedMcpNames(Object.keys(mcpServers)) } : {}), ...(desiredAppend ? { diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 71d5d988..1515e7c2 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -4581,7 +4581,9 @@ mcpHub: this.#mcpHub, if (!hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) || auth.delegationDepth > 0) { return { type: "response.error", requestId: msg.id, error: "Overriding a folder's workstream needs session:approve on an undelegated token", code: "forbidden" }; } - if (msg.workstream === undefined || !this.#overrides.valid(msg.override.token, { sub: auth.sub, workdir, chosen: msg.workstream, revision: this.#workstreams.revision })) { + // Spent here, before anything else: single use even for two concurrent + // creates (a create that then fails means asking again). + if (msg.workstream === undefined || !this.#overrides.redeem(msg.override.token, { sub: auth.sub, workdir, chosen: msg.workstream, revision: this.#workstreams.revision })) { return { type: "response.error", requestId: msg.id, error: "The override token is invalid, used, or expired — or the workstreams changed since: ask again", code: "invalid_request" }; } overridden = true; @@ -4971,10 +4973,13 @@ mcpHub: this.#mcpHub, this.#rateLimiter.recordCreation(auth.sub); // Now the session exists: record what the folder matches (any create here // — a choice too — vouches for the folder as it is now). - // The override it confirmed is spent now that the session exists (G3). - if (msg.override && createWorkstream?.source === "override") this.#overrides.spend(msg.override.token); - // Never from a delegated token's create: what the folder matched is the owner's to vouch for. - if (folderMatch && auth.delegationDepth === 0) this.#store.setWorkstreamFolder({ accountId: auth.accountId, projectId: auth.projectId, workdir }, folderMatch); + // Recorded only when the owner accepted the folder's match — no choice, or + // choosing exactly it. Choosing another home (rejecting a changed origin + // included) keeps the record as it was (W1 audit round 2); never from a + // delegated token's create. + if (folderMatch && auth.delegationDepth === 0 && (msg.workstream === undefined || msg.workstream === folderMatch)) { + this.#store.setWorkstreamFolder({ accountId: auth.accountId, projectId: auth.projectId, workdir }, folderMatch); + } // What the sandbox resolves to here, said at once (#448). const sandboxInfo = await session.ensureSandbox(); if (msg.sandbox?.provider === "off") this.#store.audit(auth.sub, "session.sandbox_off", session.id, "at create"); diff --git a/src/tests/workstreams-isolation.test.ts b/src/tests/workstreams-isolation.test.ts index 3ad38773..297feb31 100644 --- a/src/tests/workstreams-isolation.test.ts +++ b/src/tests/workstreams-isolation.test.ts @@ -259,14 +259,21 @@ describe("secrets outside the obvious fields (W1 audit)", () => { expect(names(v.withheldFromNative("codex"))).toEqual(["tok"]); }); - it("a server config can't name the daemon's own variables: ${CODEOID_*} resolves empty, a CODEOID_ bearerTokenEnv is dropped", async () => { + it("a server config can't name a workstream credential's variable; the user's own others resolve as before", async () => { const { resolveEnvValue } = await import("../daemon/mcp/types.js"); - const env = { CODEOID_CRED_X: "secret", ZEROID_KEY: "k", MINE: "ok" }; + const env = { CODEOID_CRED_X: "secret", CODEOID_GITHUB_TOKEN: "mine", MINE: "ok" }; expect(resolveEnvValue("${CODEOID_CRED_X}", env)).toBe(""); - expect(resolveEnvValue("${ZEROID_KEY}", env)).toBe(""); + expect(resolveEnvValue("${CODEOID_GITHUB_TOKEN}", env)).toBe("mine"); expect(resolveEnvValue("${MINE}", env)).toBe("ok"); - const spec = new McpRegistry({ s: raw({ url: "https://s.example", bearerTokenEnv: "CODEOID_CRED_X" }) }, { memoryEnabled: false }).get("s"); - expect(spec?.transport).not.toHaveProperty("bearerTokenEnv", "CODEOID_CRED_X"); + const reg = new McpRegistry({ s: raw({ url: "https://s.example", bearerTokenEnv: "CODEOID_CRED_X" }), t: raw({ url: "https://t.example", bearerTokenEnv: "CODEOID_GITHUB_TOKEN" }) }, { memoryEnabled: false }); + expect(reg.get("s")?.transport).not.toHaveProperty("bearerTokenEnv", "CODEOID_CRED_X"); + expect(reg.get("t")?.transport).toHaveProperty("bearerTokenEnv", "CODEOID_GITHUB_TOKEN"); + }); + + it("the Gemini CLI and Qwen get exactly codeoid's names, never an empty list (which would mean no filter)", async () => { + const { allowedMcpNames } = await import("../daemon/providers/acp/index.js"); + expect(allowedMcpNames(["b", "a", "a"])).toEqual(["a", "b"]); + expect(allowedMcpNames([])).toEqual(["codeoid-isolated-none"]); }); it("not isolated, the relay reaches exactly what it did before: the daemon registry's OAuth servers", async () => { diff --git a/src/tests/workstreams-manager.test.ts b/src/tests/workstreams-manager.test.ts index 65037c8f..b504e718 100644 --- a/src/tests/workstreams-manager.test.ts +++ b/src/tests/workstreams-manager.test.ts @@ -444,3 +444,39 @@ describe("the hub's per-call check, wired by the manager (G4)", () => { }); }); +describe("rejecting a changed origin keeps the record (W1 audit round 2)", () => { + it("choosing the original workstream after a remote change leaves the folder recorded as it was", async () => { + const repo = join(tmp, "home", "repo4"); + mkdirSync(join(repo, ".git"), { recursive: true }); + const origin = (url: string) => writeFileSync(join(repo, ".git", "config"), `[remote "origin"]\n\turl = ${url}\n`); + origin("https://github.com/mine/app"); + const m = manager({ items: [{ id: "mine", match: { remotes: ["github.com/mine/*"] } }, { id: "corp", match: { remotes: ["github.com/corp/*"] } }] }); + expect((await create(m, "k1", "repo4")).type).toBe("response.ok"); + origin("https://github.com/corp/app"); + expect(await create(m, "k2", "repo4")).toMatchObject({ type: "response.error", detail: { kind: "workstream_required" } }); + expect((await create(m, "k3", "repo4", { workstream: "mine" })).type).toBe("response.ok"); + // Still refused: the owner rejected the change, the record didn't move to corp. + expect(await create(m, "k4", "repo4")).toMatchObject({ type: "response.error", detail: { kind: "workstream_required" } }); + }); +}); + +describe("opencode under isolation", () => { + it("its turns are refused, saying why", async () => { + const registry = new ProviderRegistry("opencode"); + registry.register({ id: "opencode", displayName: "opencode", create: () => new MockSessionProvider("opencode", []) }); + const config = loadConfig({ raw: { dbPath: join(tmp, "codeoid.db"), transcriptDir: join(tmp, "transcripts"), workstreams: { ...WS, isolateGlobalServers: true } }, env: {}, quiet: true }); + const m = new SessionManager(store, transcript, undefined, undefined, undefined, { config, providers: registry, _testFolderReader: new FolderReader({ home: join(tmp, "home"), sshConfig: "" }) }); + managers.push(m); + m.setMcp(new McpRegistry({}, { memoryEnabled: false }), new McpHub({})); + const made = (await create(m, "oc", "mine")) as { data: SessionInfo }; + // Acked, then refused before the backend runs anything (shown to the user as the send's failure). + await m.handle({ type: "session.send", id: "snd", sessionId: made.data.id, text: "hi" } as never, OWNER, client); + for (let i = 0; i < 50; i++) { + const row = store.database.prepare("SELECT detail FROM audit_log WHERE session_id = ? AND action = 'session.isolation_refused'").get(made.data.id); + if (row) return expect(row).toEqual({ detail: "opencode" }); + await Bun.sleep(20); + } + throw new Error("the turn was not refused"); + }); +}); + From 770753e674e84ebf994d0b93219c8cb6b38e18d9 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Thu, 12 Nov 2026 09:37:13 +0800 Subject: [PATCH 08/10] fix: codex isolation holds a config to well-formed table headers, never to the runtime parser's leniency Bun 1.3.13 (the pinned runtime) reads an unclosed header such as `[mcp_servers.x` as a table where 1.4 refuses it, so on CI a malformed codex config started codex instead of being refused. tomlStructureProblem checks every table header (outside strings and open values) and that nothing is left open, before the parse. Co-Authored-By: Claude Opus 5.5 --- src/daemon/providers/codex/isolation.test.ts | 16 +++++- src/daemon/providers/codex/isolation.ts | 59 ++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/daemon/providers/codex/isolation.test.ts b/src/daemon/providers/codex/isolation.test.ts index 1c69300c..759a640e 100644 --- a/src/daemon/providers/codex/isolation.test.ts +++ b/src/daemon/providers/codex/isolation.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { CodexIsolationError, codexIsolationArgs, codexMountedNames } from "./isolation.js"; +import { CodexIsolationError, codexIsolationArgs, codexMountedNames, tomlStructureProblem } from "./isolation.js"; let tmp: string; beforeEach(() => { @@ -70,5 +70,17 @@ describe("codex under workstream isolation", () => { symlinkSync(real, join(proj, ".codex", "config.toml")); expect(() => codexIsolationArgs(join(tmp, "none"), proj, new Set())).toThrow(CodexIsolationError); }); -}); + test("the structure check doesn't lean on the runtime's TOML parser (Bun 1.3 takes an unclosed header)", () => { + expect(tomlStructureProblem("[mcp_servers.x\n")).toContain("table header"); + expect(tomlStructureProblem('[mcp_servers."a b"]\ncommand = "x"\n')).toBeUndefined(); + expect(tomlStructureProblem("[[profiles]]\nname = 'a' # [not a header\n")).toBeUndefined(); + // A value spanning lines whose lines start with `[` is a value, not a header. + expect(tomlStructureProblem('args = [\n ["a", "b"],\n ["c"],\n]\n[mcp_servers.y]\ncommand = "c"\n')).toBeUndefined(); + // A header-looking line inside a multi-line string is text. + expect(tomlStructureProblem('note = """\n[mcp_servers.z\n"""\n')).toBeUndefined(); + expect(tomlStructureProblem('args = [\n "a",\n')).toContain("left open"); + expect(tomlStructureProblem('note = """\nnever closed\n')).toContain("left open"); + expect(tomlStructureProblem('x = "unterminated\n')).toContain("string left open"); + }); +}); diff --git a/src/daemon/providers/codex/isolation.ts b/src/daemon/providers/codex/isolation.ts index b313c49f..656d0762 100644 --- a/src/daemon/providers/codex/isolation.ts +++ b/src/daemon/providers/codex/isolation.ts @@ -105,7 +105,11 @@ export function codexIsolationArgs(codexHome: string, workdir: string, own: Read const text = readConfig(file, followed.has(file)); if (text === undefined) continue; let doc: unknown; + // Held to its table headers first: a runtime's TOML parser may take a malformed one (Bun 1.3.x reads + // `[mcp_servers.x` as a table), where codex itself would read something else, or refuse. + const malformed = tomlStructureProblem(text); try { + if (malformed) throw new Error(malformed); doc = Bun.TOML.parse(text); } catch { throw new CodexIsolationError(`codex config ${file} isn't valid TOML: refusing to start without knowing its MCP servers`); @@ -146,3 +150,58 @@ export function codexMountedNames(args: readonly string[]): Set { } return out; } + +const TOML_KEY = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\\n]|\\.)*"|'[^'\n]*')`; +const TOML_HEADER = new RegExp(String.raw`^\s*(?:\[\[\s*${TOML_KEY}(?:\s*\.\s*${TOML_KEY})*\s*\]\]|\[\s*${TOML_KEY}(?:\s*\.\s*${TOML_KEY})*\s*\])\s*(?:#.*)?$`); + +/** + * Why a TOML document's structure can't be trusted, or undefined: a table + * header that isn't well-formed, or a string, array or inline table left + * open at its end. Not a parser — only the shape every table header must + * have, found as a parser would find it: a line that starts with `[` outside + * any string and any open value (a multi-line array's `[1, 2],` is a value). + */ +export function tomlStructureProblem(text: string): string | undefined { + let depth = 0; + let multiline: string | null = null; + const lines = text.split(/\r?\n/); + for (const [n, line] of lines.entries()) { + let i = 0; + if (multiline) { + const end = line.indexOf(multiline); + if (end < 0) continue; + i = end + 3; + multiline = null; + } else if (depth === 0 && line.trimStart().startsWith("[")) { + if (!TOML_HEADER.test(line)) return `line ${n + 1}: a table header that isn't well-formed`; + continue; + } + while (i < line.length) { + const c = line[i]!; + if (c === "#") break; + if (line.startsWith('"""', i) || line.startsWith("'''", i)) { + const q = line.slice(i, i + 3); + const end = line.indexOf(q, i + 3); + if (end < 0) { + multiline = q; + break; + } + i = end + 3; + continue; + } + if (c === '"' || c === "'") { + let j = i + 1; + while (j < line.length && line[j] !== c) j += c === '"' && line[j] === "\\" ? 2 : 1; + if (j >= line.length) return `line ${n + 1}: a string left open`; + i = j + 1; + continue; + } + if (c === "[" || c === "{") depth++; + else if (c === "]" || c === "}") depth = Math.max(0, depth - 1); + i++; + } + } + if (multiline) return "a multi-line string left open"; + if (depth > 0) return "an array or inline table left open"; + return undefined; +} From 82ae68cbef03156250349f5da25f1b4d7fc7e837 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Thu, 12 Nov 2026 11:04:05 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20workstreams=20W2=20=E2=80=94=20ed?= =?UTF-8?q?it=20workstreams=20and=20credentials=20live,=20in=20the=20web?= =?UTF-8?q?=20UI=20(#463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: workstreams W2 — edit workstreams and credentials live - workstream.upsert / delete / settings and credential.list / upsert / delete / set_value: settings:write on an undelegated token; written to config.json through the settings store (the whole config validated, nothing written otherwise) and applied to the running daemon at once — new sessions and every session's grant follow it, live sessions keep their homes; workstreams.changed announces it - a credential's value goes to .env (0600) and the daemon's environment and is never sent back: only whether it is set, and where; an OAuth one points at codeoid mcp signin; deleting one a workstream uses is refused - a session's MCP view fingerprint includes its grant: a workstream edited rebuilds that session's backend at its next turn, on every backend Co-Authored-By: Claude Opus 5.5 * feat: workstreams W2 — the web UI: Workstreams and Credentials in Settings, a picker and override confirm in New session, a chip on every session - Settings → Workstreams: isolation switch, default, the list (claim, share, rules, servers, env), an editor that keeps what it doesn't show (the item as configured, not bases merged — WorkstreamSummary.config), remove, and 'check a folder' - Settings → Credentials: declare by id and kind, set a value from a password field (sent once, cleared, never shown back), clear, remove (refused while used), OAuth slots point at codeoid mcp signin - New session: what the folder resolves to and why, an explicit choice; a choice past an exclusive folder is offered with what it would change, and Start anyway resends with the override token - a workstream chip in the session header and the session list (orphaned red, override amber); the list filter matches workstream names - live: workstreams.changed refetches what's shown Co-Authored-By: Claude Opus 5.5 * fix: workstreams W2 — the UI journey with screenshots at every viewport; fixes they showed - e2e: Settings lists and adds a workstream, a credential value is set and never shown back, the new-session dialog says what the folder resolves to, a choice past an exclusive folder is offered and confirmed, and the chip shows it — screenshots at 375x667, 390x844, 768x1024, 1280x800, 1470x830, 1920x1080 and 1280x300, the override's buttons asserted inside the window - the Default select no longer overflows a phone's width; the override offer scrolls into view as it appears and says what the choice gets accurately Co-Authored-By: Claude Opus 5.5 * fix: workstreams W2 audit round 1 — edits apply both blocks, live env, a header is part of a credential, editor survives refreshes Daemon: - an edit applies both the workstreams and the credentials block from the file as written: a hand edit since boot is never shadowed by a stale copy - a session's workstream env is read live: a credential set, swapped or cleared, an env entry removed, reaches the backend at its next start, and a changed env rebuilds the backend at the next turn like its servers - a credential's header is part of its hub client and of the per-call check: redeclaring it with another header is another credential - credential.delete takes a strict id and refuses an undeclared one; deleting an OAuth slot signs it out; a value set in the daemon's real environment is refused (it would win at the next start); an edit's reply needs no settings:read; the revision covers credentials too Web: - workstream rows are keyed by id: an open editor keeps what was typed when the list refreshes (another client's edit, a toggle) - a forced refresh during a fetch refetches after it, so the newer list wins - the editor checks env and server lines before sending; failed settings saves put the control back; remove and clear confirm, with Cancel, busy-guarded; the credential field uses new-password - New session: the last folder's answer never shows for a new one, a failed check says so, the override offer names workstreams as the picker does and clears on a mode switch, focus moves to create after choosing the folder's, and workstream_required is said in the UI's words with the picker focused Co-Authored-By: Claude Opus 5.5 * fix: workstreams W2 audit round 2 — env changes rebuild a backend without MCP servers, shadowed .env values refused, an OAuth slot's removal signs every tenant out - A session with no MCP view never rebuilt its backend when its workstream's env changed: #applyMcpView returned early. The env check now runs before the view check, and #envBuiltWith keeps a sha256, not the values. - loadDotEnv records the keys the real environment shadowed; set_value refuses those too, as it did a key set only in the real environment. - Removing an OAuth credential forgets its slot for every tenant and URL (Store.clearMcpOAuthSlot, McpOAuth.forgetSlot), never the default sign-in. - Credentials rows keyed by id, so a refresh keeps a typed value. - New-session dialog: the workstream picker in collaborative mode; pipeline runs get UI-worded workstream errors (showCreateError). - The dialog's deferred focus no longer moves focus already inside it (a fast fill typed the workdir into the name: the e2e flake). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/CONFIGURATION.md | 8 + docs/workstreams-design.md | 3 +- e2e/specs/workstreams-ui.spec.ts | 106 +++++ packages/protocol/src/schemas.test.ts | 7 + packages/protocol/src/schemas.ts | 30 ++ packages/protocol/src/types.ts | 18 + packages/protocol/src/workstreams.ts | 82 ++++ src/config.ts | 15 +- src/daemon/mcp/oauth.ts | 15 + src/daemon/mcp/session-view.ts | 15 +- src/daemon/session-manager.ts | 172 ++++++++- src/daemon/session.ts | 73 +++- src/daemon/settings/store.ts | 49 +++ src/daemon/store.ts | 14 + src/daemon/workstreams/grant.ts | 5 +- src/daemon/workstreams/service.ts | 4 +- src/daemon/workstreams/workstreams.test.ts | 5 +- src/tests/protocol.test.ts | 4 + src/tests/workstreams-edit.test.ts | 256 +++++++++++++ src/tests/workstreams-isolation.test.ts | 3 + web/src/components/CenterPane.tsx | 2 + web/src/components/CredentialsPanel.tsx | 237 ++++++++++++ web/src/components/NewSessionModal.tsx | 173 ++++++++- .../NewSessionModal.workstreams.test.tsx | 123 ++++++ web/src/components/SessionListPane.tsx | 4 + web/src/components/SettingsDrawer.tsx | 10 + web/src/components/WorkstreamChip.test.tsx | 39 ++ web/src/components/WorkstreamChip.tsx | 51 +++ web/src/components/WorkstreamsPanel.tsx | 361 ++++++++++++++++++ web/src/components/WorkstreamsPanels.test.tsx | 109 ++++++ web/src/lib/fleet.ts | 4 +- web/src/lib/workstream-form.test.ts | 33 ++ web/src/lib/workstream-form.ts | 137 +++++++ web/src/state/connection.ts | 7 + web/src/state/workstreams.test.ts | 35 ++ web/src/state/workstreams.ts | 177 +++++++++ 36 files changed, 2353 insertions(+), 33 deletions(-) create mode 100644 e2e/specs/workstreams-ui.spec.ts create mode 100644 src/tests/workstreams-edit.test.ts create mode 100644 web/src/components/CredentialsPanel.tsx create mode 100644 web/src/components/NewSessionModal.workstreams.test.tsx create mode 100644 web/src/components/WorkstreamChip.test.tsx create mode 100644 web/src/components/WorkstreamChip.tsx create mode 100644 web/src/components/WorkstreamsPanel.tsx create mode 100644 web/src/components/WorkstreamsPanels.test.tsx create mode 100644 web/src/lib/workstream-form.test.ts create mode 100644 web/src/lib/workstream-form.ts create mode 100644 web/src/state/workstreams.test.ts create mode 100644 web/src/state/workstreams.ts diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e160714c..12ab4f45 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -447,6 +447,14 @@ Every reference to a credential is checked when the config loads: declared once, Under isolation a session you create in a folder with no workstream is refused until you choose one; sessions the daemon starts there (loops, routines, dispatch) get built-ins only. +### Editing them live + +Settings → Workstreams and Settings → Credentials in the web UI (or the `workstream.*` / `credential.*` messages) edit `config.json` and `.env` directly; the whole config is validated before anything is written. +A change applies at once to new sessions and to every call a session makes through codeoid; a running session's backend is rebuilt with its workstream's servers, env and credentials as they are now at its next turn. +A session keeps its workstream: editing rules never moves a session to another. +A value set in the daemon's own environment (`CODEOID_CRED_=… codeoid start`) wins over `.env`, so setting it from the UI is refused: change it where it is set. +Removing an OAuth credential signs every user out of that slot, so declaring the id again later starts from no sign-in. + ### Overrides Choosing a workstream for a folder another workstream holds exclusively is refused with what it would change. diff --git a/docs/workstreams-design.md b/docs/workstreams-design.md index 339bff21..fb614904 100644 --- a/docs/workstreams-design.md +++ b/docs/workstreams-design.md @@ -409,7 +409,8 @@ An unsandboxed session (`--sandbox off`) keeps the weaker rows as written. - The config has a **revision** (a hash of the `workstreams` block, so it is the same across restarts for the same config), and each binding records the revision it applied. - **Narrowing** changes take effect at the **next turn boundary** of every affected live session, with a backend restart. That includes removing a server, removing a reference, and tightening `share`. Waiting for a manual refresh would leave a revoked credential live, which is the wrong failure. -- **Widening** changes (adding a server, loosening `share`) take effect on the next refresh or the next new session, matching how the MCP session view works today ("what's applied changes only on `load()`"). +- **Widening** changes (adding a server, loosening `share`) take effect at the next turn boundary too (as built, W2: the session's MCP view fingerprint includes its grant, and a change either way rebuilds the backend — simpler, and a rebuild is cheap next to a turn). +- A session's **env** (its workstream's env credentials and git identity) is read live as well: a credential cleared or an env entry removed is gone from the backend's next start, which the same next-turn rebuild brings (W2 audit). - **Deleting a workstream** re-homes its sessions to the default only if they have no running turn. Otherwise they are marked `orphaned` and refuse new turns until re-homed. Memory keeps its tag. - **Changing match rules** never re-homes a live session. It affects only new sessions. The panel lists the sessions that would resolve differently. diff --git a/e2e/specs/workstreams-ui.spec.ts b/e2e/specs/workstreams-ui.spec.ts new file mode 100644 index 00000000..516a0823 --- /dev/null +++ b/e2e/specs/workstreams-ui.spec.ts @@ -0,0 +1,106 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { expect, openUi, test } from "../harness"; + +// The viewports the workspace rules ask layout changes be looked at in. +const VIEWPORTS = [ + [375, 667], + [390, 844], + [768, 1024], + [1280, 800], + [1470, 830], + [1920, 1080], + [1280, 300], +] as const; +const SHOTS = join(process.cwd(), "test-results", "workstreams-shots"); + +// Workstreams W2 in the web UI: Settings shows and edits them; the new-session +// dialog says what the folder resolves to, offers an override past an +// exclusive workstream with what it changes, and the session's chip shows it. +test("workstreams in the web UI: settings, the new-session picker, a confirmed override, the chip", async ({ page, codeoid }) => { + mkdirSync(SHOTS, { recursive: true }); + codeoid.configure({ + credentials: [{ id: "gh-acme", kind: "env" }], + workstreams: { + items: [ + { id: "acme", name: "Acme Corp", color: "orange", claim: "exclusive", match: { paths: [codeoid.workdir] }, env: { GH_TOKEN: { secret: "gh-acme" } } }, + { id: "mine", name: "Mine", match: { paths: ["~/never"] } }, + ], + }, + }); + await codeoid.restart(); + await openUi(page, codeoid); + + // Settings → Workstreams lists them; add one there. + await page.getByTitle("Settings", { exact: true }).first().click(); + await page.getByRole("button", { name: /Workstreams/ }).click(); + await expect(page.locator("span.font-medium", { hasText: "Acme Corp" })).toBeVisible(); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await page.screenshot({ path: join(SHOTS, `settings-${w}x${h}.png`), fullPage: false }); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await page.getByRole("button", { name: "Add workstream" }).click(); + const form = page.getByRole("form", { name: "New workstream" }); + await form.getByPlaceholder("acme", { exact: true }).fill("research"); + await form.getByPlaceholder("Acme Corp").fill("Research"); + await form.getByRole("button", { name: "Save" }).click(); + await expect(page.locator("span.font-medium", { hasText: "Research" })).toBeVisible(); + + // Credentials: a value set is never shown back. + await page.getByRole("button", { name: /Credentials/ }).click(); + await expect(page.getByTestId("credential-gh-acme")).toContainText("not set"); + await page.getByLabel("Value of gh-acme").fill("ghp_e2e_value"); + await page.getByRole("button", { name: "Set", exact: true }).click(); + await expect(page.getByTestId("credential-gh-acme")).toContainText("set (CODEOID_CRED_GH_ACME)"); + await expect(page.getByText("ghp_e2e_value")).toHaveCount(0); + await page.keyboard.press("Escape"); + + // New session in Acme's folder, choosing Mine: offered, with what changes. + await page.getByRole("button", { name: /new session/ }).click(); + await page.getByRole("textbox", { name: "Name", exact: true }).fill("past-claim"); + await page.getByRole("textbox", { name: /^Workdir/ }).fill(codeoid.workdir); + await expect(page.getByLabel("Workstream")).toContainText("The folder's: acme", { timeout: 10_000 }); + await page.getByLabel("Workstream").selectOption("mine"); + await page.getByRole("button", { name: "create", exact: true }).click(); + const offer = page.getByRole("alert").filter({ hasText: "belongs to workstream" }); + await expect(offer).toContainText("“Acme Corp” (exclusive)"); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await offer.scrollIntoViewIfNeeded(); + // Its buttons fit, whatever the window: nothing to confirm with is cut off. + const box = await page.getByRole("button", { name: "Start anyway" }).boundingBox(); + expect(box && box.width > 40 && box.height > 16 && box.x + box.width <= w).toBe(true); + await page.screenshot({ path: join(SHOTS, `override-${w}x${h}.png`), fullPage: false }); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await page.getByRole("button", { name: "Start anyway" }).click(); + await expect(page.getByRole("heading", { name: "past-claim", level: 2 })).toBeVisible(); + // The session header's chip (not the list row's): beside the session's own heading. + const header = page.getByRole("heading", { name: "past-claim", level: 2 }).locator("xpath=.."); + await expect(header.getByTestId("workstream-chip")).toContainText("Mine"); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await page.screenshot({ path: join(SHOTS, `session-${w}x${h}.png`), fullPage: false }); + } + + // A collaborative session has the picker too, and it fits every window. + await page.setViewportSize({ width: 1280, height: 800 }); + await page.getByRole("button", { name: /new session/ }).click(); + await page.getByRole("radio", { name: "Collaborative" }).click(); + await page.getByRole("textbox", { name: /^Workdir/ }).fill(codeoid.workdir); + const picker = page.getByLabel("Workstream"); + await expect(picker).toContainText("The folder's: acme", { timeout: 10_000 }); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await picker.scrollIntoViewIfNeeded(); + const box = await picker.boundingBox(); + expect(box && box.width > 100 && box.height > 16 && box.x >= 0 && box.x + box.width <= w).toBe(true); + // The create button keeps its label below the picker, in every window. + const create = page.getByRole("button", { name: "create collaboration" }); + await create.scrollIntoViewIfNeeded(); + const cb = await create.boundingBox(); + expect(cb && cb.width > 60 && cb.height > 16 && cb.x + cb.width <= w && cb.y >= 0 && cb.y + cb.height <= h).toBe(true); + await page.screenshot({ path: join(SHOTS, `collaborate-${w}x${h}.png`), fullPage: false }); + } +}); diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index 0592839e..841efe9b 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -165,6 +165,13 @@ const samples: { [T in ClientTypes]: Extract } = { "worktree.trust": { type: "worktree.trust", id: "wt1", dir: "/tmp/repo", recipeHash: "0123456789abcdef0123456789abcdef01234567" }, "workstream.list": { type: "workstream.list", id: "ws1" }, "workstream.resolve": { type: "workstream.resolve", id: "ws2", workdir: "/tmp/repo", workstream: "acme" }, + "workstream.upsert": { type: "workstream.upsert", id: "ws3", item: { id: "acme" } }, + "workstream.delete": { type: "workstream.delete", id: "ws4", workstream: "acme" }, + "workstream.settings": { type: "workstream.settings", id: "ws5", isolateGlobalServers: true, default: null }, + "credential.list": { type: "credential.list", id: "c1" }, + "credential.upsert": { type: "credential.upsert", id: "c2", item: { id: "gh", kind: "env" } }, + "credential.delete": { type: "credential.delete", id: "c3", credential: "gh" }, + "credential.set_value": { type: "credential.set_value", id: "c4", credential: "gh", value: "x" }, "session.turns": { type: "session.turns", id: "r94", sessionId: "s1" }, "session.approvals": { type: "session.approvals", id: "r94a", sessionId: "s1" }, "claude.accounts.list": { type: "claude.accounts.list", id: "ca1" }, diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index b82590e5..0469af12 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -1102,6 +1102,29 @@ export const workstreamResolveSchema = z.object({ workstream: workstreamIdField.optional(), }); +// Items are bounded objects here: their SHAPE is the daemon's config schema's +// to judge (one rule, one error message), not the wire's. +const configItemField = z.record(z.string().max(128), z.unknown()).refine((o) => JSON.stringify(o).length <= 64 * 1024, "too large"); +export const workstreamUpsertSchema = z.object({ ...base, type: z.literal("workstream.upsert"), item: configItemField }); +export const workstreamDeleteSchema = z.object({ ...base, type: z.literal("workstream.delete"), workstream: workstreamIdField }); +export const workstreamSettingsSchema = z.object({ + ...base, + type: z.literal("workstream.settings"), + isolateGlobalServers: z.boolean().optional(), + default: workstreamIdField.nullable().optional(), +}); +export const credentialListSchema = z.object({ ...base, type: z.literal("credential.list") }); +export const credentialUpsertSchema = z.object({ ...base, type: z.literal("credential.upsert"), item: configItemField }); +// A credential's id shape (the daemon's config rule): no line breaks or spaces reach an audit row. +const credentialIdField = z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/); +export const credentialDeleteSchema = z.object({ ...base, type: z.literal("credential.delete"), credential: credentialIdField }); +export const credentialSetValueSchema = z.object({ + ...base, + type: z.literal("credential.set_value"), + credential: credentialIdField, + value: z.string().max(LIMITS.SETTING_VALUE_MAX).nullable(), +}); + export const skillGrantSchema = z.object({ ...base, type: z.literal("skill.grant"), @@ -1337,6 +1360,13 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ worktreeTrustSchema, workstreamListSchema, workstreamResolveSchema, + workstreamUpsertSchema, + workstreamDeleteSchema, + workstreamSettingsSchema, + credentialListSchema, + credentialUpsertSchema, + credentialDeleteSchema, + credentialSetValueSchema, sessionTurnsSchema, sessionApprovalsSchema, claudeAccountsListSchema, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index c4bf07ce..0f08e754 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -45,12 +45,21 @@ import type { import type { SkillGrantMsg, SkillGrantResultMsg } from "./skill-grant.js"; import type { WorktreeTrustMsg, WorktreeTrustResultMsg } from "./worktree-trust.js"; import type { + CredentialDeleteMsg, + CredentialListMsg, + CredentialListResultMsg, + CredentialSetValueMsg, + CredentialUpsertMsg, SessionWorkstreamInfo, + WorkstreamDeleteMsg, WorkstreamListMsg, WorkstreamListResultMsg, WorkstreamResolution, WorkstreamResolveMsg, WorkstreamResolveResultMsg, + WorkstreamSettingsMsg, + WorkstreamsChangedMsg, + WorkstreamUpsertMsg, } from "./workstreams.js"; import type { SessionRewindMsg, SessionRewindResultMsg, SessionTurnsMsg, SessionTurnsResultMsg } from "./turns.js"; import type { SessionApprovalsMsg, SessionApprovalsResultMsg } from "./approvals.js"; @@ -1357,6 +1366,13 @@ export type ClientMessage = | WorktreeTrustMsg | WorkstreamListMsg | WorkstreamResolveMsg + | WorkstreamUpsertMsg + | WorkstreamDeleteMsg + | WorkstreamSettingsMsg + | CredentialListMsg + | CredentialUpsertMsg + | CredentialDeleteMsg + | CredentialSetValueMsg | SessionTurnsMsg | SessionApprovalsMsg | ClaudeAccountsListMsg @@ -3281,6 +3297,8 @@ export type DaemonMessage = | WorktreeTrustResultMsg | WorkstreamListResultMsg | WorkstreamResolveResultMsg + | WorkstreamsChangedMsg + | CredentialListResultMsg | SessionTurnsResultMsg | SessionApprovalsResultMsg | ClaudeAccountsResultMsg diff --git a/packages/protocol/src/workstreams.ts b/packages/protocol/src/workstreams.ts index 36d25467..3708a26f 100644 --- a/packages/protocol/src/workstreams.ts +++ b/packages/protocol/src/workstreams.ts @@ -63,6 +63,8 @@ export interface WorkstreamSummary { env: string[]; git?: { name?: string; email?: string }; skillDirs: string[]; + /** The item as configured (bases NOT merged): what an editor edits and sends back to `workstream.upsert`. */ + config: Record; } export interface WorkstreamMatchInfo { @@ -141,3 +143,83 @@ export interface WorkstreamResolveResultMsg { revision: string; resolution: WorkstreamResolution; } + +// ── Editing (W2): settings:write on an undelegated token ───────────────────── + +/** Add or replace one workstream (by `item.id`). The whole config is validated; nothing is written if it doesn't. Replies `workstream.list.result`. */ +export interface WorkstreamUpsertMsg { + type: "workstream.upsert"; + id: string; + /** A `workstreams.items[]` entry as config.json takes it (docs/CONFIGURATION.md, Workstreams). */ + item: Record; +} + +/** Remove a workstream. Sessions homed there keep their binding, shown orphaned. Replies `workstream.list.result`. */ +export interface WorkstreamDeleteMsg { + type: "workstream.delete"; + id: string; + workstream: string; +} + +/** The block-wide settings. `default: null` removes the default. Replies `workstream.list.result`. */ +export interface WorkstreamSettingsMsg { + type: "workstream.settings"; + id: string; + isolateGlobalServers?: boolean; + default?: string | null; +} + +/** Sent to subscribed clients after the workstreams or credentials change. */ +export interface WorkstreamsChangedMsg { + type: "workstreams.changed"; + revision: string; +} + +// ── Credentials (W2) ───────────────────────────────────────────────────────── + +export interface CredentialInfo { + id: string; + kind: "bearer" | "header" | "env" | "oauth"; + header?: string; + server?: string; + description?: string; + /** Whether a value is set, and where (never the value). An `oauth` credential's is its sign-in, shown with its server. */ + status: { set: boolean; source?: string }; + /** Workstreams that reference it. */ + usedBy: string[]; +} + +/** The declared credentials and where their values live. Needs `settings:read`. */ +export interface CredentialListMsg { + type: "credential.list"; + id: string; +} + +/** Declare or replace one credential (by `item.id`). Replies `credential.list.result`. */ +export interface CredentialUpsertMsg { + type: "credential.upsert"; + id: string; + item: Record; +} + +/** Remove a credential's declaration (refused while a workstream references it) and its value. */ +export interface CredentialDeleteMsg { + type: "credential.delete"; + id: string; + credential: string; +} + +/** Set (or with null, clear) a credential's value in a writable store. The value is never sent back. */ +export interface CredentialSetValueMsg { + type: "credential.set_value"; + id: string; + credential: string; + value: string | null; +} + +export interface CredentialListResultMsg { + type: "credential.list.result"; + requestId: string; + store: { kind: string; writable: boolean }; + credentials: CredentialInfo[]; +} diff --git a/src/config.ts b/src/config.ts index c0213a70..becd5372 100644 --- a/src/config.ts +++ b/src/config.ts @@ -151,6 +151,16 @@ export function isLoopbackUrl(url: string): boolean { * optional surrounding single/double quotes are stripped. Intentionally * minimal — not a full dotenv dialect (no interpolation, no multiline). */ +const shadowedDotEnv = new Set(); + +/** + * Whether `.env` sets `key` but the daemon's real environment set it first, + * so the file's value isn't the one in use (recorded by `loadDotEnv`). + */ +export function dotEnvShadowed(key: string): boolean { + return shadowedDotEnv.has(key); +} + export function loadDotEnv(): string[] { const envPath = join(getConfigDir(), ".env"); if (!existsSync(envPath)) return []; @@ -169,7 +179,10 @@ export function loadDotEnv(): string[] { const key = line.slice(0, eq).trim(); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; // Real environment wins — file is the fallback, not an override. - if (process.env[key] !== undefined) continue; + if (process.env[key] !== undefined) { + shadowedDotEnv.add(key); + continue; + } let value = line.slice(eq + 1).trim(); if ( value.length >= 2 && diff --git a/src/daemon/mcp/oauth.ts b/src/daemon/mcp/oauth.ts index 38efcf89..79b137da 100644 --- a/src/daemon/mcp/oauth.ts +++ b/src/daemon/mcp/oauth.ts @@ -272,6 +272,21 @@ export class McpOAuth implements McpCredentialSource { this.#rejected.delete(keyOf(spec, tenant)); } + /** + * Forget a credential slot of a server entirely (its credential was + * removed): every tenant's tokens and client registration, at every URL. + * Returns the tenants that had one, so their clients can be dropped. + */ + forgetSlot(server: string, slot: string): McpTenant[] { + const tenants = this.#store.clearMcpOAuthSlot(server, slot); + for (const t of tenants) { + const key = keyOf({ name: server, slot }, t); + this.#signIns.set(key, (this.#signIns.get(key) ?? 0) + 1); + this.#rejected.delete(key); + } + return tenants; + } + // ── internals ────────────────────────────────────────────────────────────── async #refresh(spec: McpServerSpec, tenant: McpTenant): Promise { diff --git a/src/daemon/mcp/session-view.ts b/src/daemon/mcp/session-view.ts index d4e3c866..5094c7cc 100644 --- a/src/daemon/mcp/session-view.ts +++ b/src/daemon/mcp/session-view.ts @@ -110,9 +110,17 @@ export class SessionMcpView implements McpServerView { return this.#loaded; } - /** What's applied, as one comparable value. */ + /** + * What's applied, as one comparable value — the workstream grant included, + * read live: a workstream edited (a server removed, a credential swapped) + * rebuilds the backend at its next turn, as G4 and §8 promise, on backends + * that mount servers themselves too. + */ get fingerprint(): string { - return this.#fingerprint; + const grant = this.#opts.grant?.(); + if (!grant) return this.#fingerprint; + const servers = [...grant.servers].map(([n, s]) => `${n}=${s.credential?.id ?? ""}:${s.credential?.header ?? ""}:${s.oauthSlot ?? ""}`).sort(); + return `${this.#fingerprint}\u0003${grant.workstream ?? ""}\u0001${grant.workdirServers ? 1 : 0}\u0001${servers.join("\u0001")}`; } /** This session's own servers in use, once each: their hub clients are its to let go. */ @@ -477,7 +485,8 @@ function applyGrant(specs: readonly McpServerSpec[], grant: McpGrant): McpServer out.push({ ...spec, transport: { ...spec.transport, credential: { ...sel.credential } }, - variant: `${spec.variant ?? ""}#cred:${sel.credential.id}`, + // The header too: a credential redeclared with another header is another client. + variant: `${spec.variant ?? ""}#cred:${sel.credential.id}:${sel.credential.header ?? ""}`, }); continue; } diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 1515e7c2..e33a511c 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -14,7 +14,9 @@ import { grantRefusal, type McpGrant } from "./workstreams/grant.js"; import { OverrideTokens } from "./workstreams/override.js"; import { withSlot } from "./mcp/oauth.js"; import type { FolderReader } from "./workstreams/folder.js"; -import { createSecretStore, type SecretStore } from "./credentials/store.js"; +import { createSecretStore, credentialEnvName, type SecretStore } from "./credentials/store.js"; +import { credentialsSchema } from "./credentials/schema.js"; +import { workstreamsSchema } from "./workstreams/schema.js"; import { registerSecretPaths } from "./secret-paths.js"; import { type RequestDirectoryInput, type SandboxMcpMount, SandboxMcpHttp } from "./sandbox/request-directory.js"; import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs"; @@ -37,7 +39,7 @@ import type { HookBus } from "./hooks/bus.js"; import type { Store } from "./store.js"; import { createPushTransport, PushService } from "./push/index.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; -import { applyPatches, getManifest, getSnapshot, previewPatches, readMcpServerEntry, writeMcpServerEntry, writeWorktreeTrust } from "./settings/store.js"; +import { applyPatches, getManifest, getSnapshot, previewPatches, readMcpServerEntry, readDotEnvValueSet, writeConfigBlock, writeCredentialValue, writeMcpServerEntry, writeWorktreeTrust } from "./settings/store.js"; import { adoptability, adoptableName, adoptedEntry, adoptionRefusal, claudeMcpServers, fromRepoFile, fullUrlRequired, isSameAdoption, shellArg } from "./mcp/adopt.js"; import { readWorkdirServers } from "./mcp/workdir-servers.js"; import { cleanMcpError, commandSummary, MAX_SHOWN_KEYS, quotedVisible, redactMcpUrl, serverProgram, shownLabel, visibleCompact, visibleVerbatim } from "./mcp/redact.js"; @@ -154,7 +156,7 @@ import { redactCommand } from "./skill-command.js"; import type { McpHub } from "./mcp/hub.js"; import { mcpOAuthBindingCookieName, type McpOAuth, type McpOAuthProof, type McpTenant } from "./mcp/oauth.js"; import { isOAuthServer, type McpServerSpec, OAUTH_UNSUPPORTED_BACKENDS } from "./mcp/types.js"; -import { atomicWrite, type CodeoidConfig, DEFAULT_PROVIDER_ENV, getConfigDir, loadConfig, mutateConfigFile, validateConfigObject } from "../config.js"; +import { atomicWrite, type CodeoidConfig, DEFAULT_PROVIDER_ENV, dotEnvShadowed, getConfigDir, loadConfig, mutateConfigFile, validateConfigObject } from "../config.js"; import type { CompressionRegistry } from "./compress/index.js"; import { endpointNameOf, endpointProviderId, ORCHESTRATOR_ROLE, type ProviderInfo } from "../protocol/types.js"; import type { @@ -1663,6 +1665,16 @@ mcpHub: this.#mcpHub, return this.#workstreamList(msg, auth); case "workstream.resolve": return this.#workstreamResolve(msg, auth); + case "workstream.upsert": + case "workstream.delete": + case "workstream.settings": + return this.#workstreamEdit(msg, auth); + case "credential.list": + return this.#credentialList(msg, auth); + case "credential.upsert": + case "credential.delete": + case "credential.set_value": + return this.#credentialEdit(msg, auth); case "mcp.oauth.begin": return this.#mcpOAuthBegin(msg, auth); case "mcp.oauth.complete": @@ -2319,17 +2331,19 @@ mcpHub: this.#mcpHub, #workstreamOpts(binding: WorkstreamBinding | undefined): { workstream?: WorkstreamBinding; describeWorkstream: (b: WorkstreamBinding | undefined) => ReturnType; - workstreamEnv?: Readonly>; + workstreamEnv?: () => Readonly>; mcpGrant: () => McpGrant | undefined; } { - const { env, missing } = this.#workstreams.envFor(binding, this.#secrets); + const { missing } = this.#workstreams.envFor(binding, this.#secrets); if (missing.length > 0) { console.warn(`[codeoid] workstream "${binding?.id}": no value for ${missing.join(", ")} — set the credential (${this.#secrets.kind} store); the session starts without it`); } return { ...(binding ? { workstream: binding } : {}), describeWorkstream: this.#describeWorkstream, - ...(Object.keys(env).length > 0 ? { workstreamEnv: env } : {}), + // Read live: an edit to the workstream or a credential reaches the + // session's next backend start (its next turn), never only a restart. + ...(binding?.id ? { workstreamEnv: () => this.#workstreams.envFor(binding, this.#secrets).env } : {}), // Read live: the grant follows the config as it is at each listing. mcpGrant: () => this.#workstreams.grantFor(binding), }; @@ -3432,6 +3446,152 @@ mcpHub: this.#mcpHub, }; } + /** Who may change workstreams and credentials: settings:write, never a delegated token. */ + #configEditRefusal(requestId: string, auth: AuthContext): DaemonMessage | undefined { + if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_WRITE)) { + return { type: "response.error", requestId, error: "Missing scope: settings:write", code: "forbidden" }; + } + if (auth.delegationDepth > 0) { + return { type: "response.error", requestId, error: "A delegated token can't change workstreams or credentials", code: "forbidden" }; + } + return undefined; + } + + /** + * Apply a workstreams or credentials block just written to config.json: + * the live service (grants and new sessions follow it at once — live + * sessions keep their homes), this manager's config, and every subscriber. + */ + #applyWorkstreamConfig(config: Record): void { + // Both blocks from the file as written (W2 audit): a hand edit to the other + // block since boot is applied too, never shadowed by a stale copy. + const workstreams = workstreamsSchema.parse(config.workstreams); + const credentials = credentialsSchema.parse(config.credentials); + if (this.#config) this.#config = { ...this.#config, workstreams, credentials }; + this.#workstreams.update(workstreams, credentials); + this.#pushToProviderSubscribers({ type: "workstreams.changed", revision: this.#workstreams.revision }); + } + + #workstreamEdit(msg: Extract, auth: AuthContext): DaemonMessage { + const refused = this.#configEditRefusal(msg.id, auth); + if (refused) return refused; + const asObject = (v: unknown): Record => (v && typeof v === "object" && !Array.isArray(v) ? { ...(v as Record) } : {}); + const written = writeConfigBlock("workstreams", (current) => { + const block = asObject(current); + const items = Array.isArray(block.items) ? [...(block.items as unknown[])] : []; + const idOf = (i: unknown) => (i && typeof i === "object" ? (i as { id?: unknown }).id : undefined); + if (msg.type === "workstream.upsert") { + const at = items.findIndex((i) => idOf(i) === msg.item.id); + if (at >= 0) items[at] = msg.item; + else items.push(msg.item); + block.items = items; + } else if (msg.type === "workstream.delete") { + block.items = items.filter((i) => idOf(i) !== msg.workstream); + if (block.default === msg.workstream) delete block.default; + } else { + if (msg.isolateGlobalServers !== undefined) block.isolateGlobalServers = msg.isolateGlobalServers; + if (msg.default === null) delete block.default; + else if (msg.default !== undefined) block.default = msg.default; + } + return block; + }); + if (!written.ok) return { type: "response.error", requestId: msg.id, error: written.error, code: "invalid_request" }; + this.#store.audit(auth.sub, msg.type, "", msg.type === "workstream.upsert" ? `id=${String(msg.item.id)}` : msg.type === "workstream.delete" ? `id=${msg.workstream}` : `isolate=${msg.isolateGlobalServers ?? "-"} default=${msg.default === null ? "none" : (msg.default ?? "-")}`); + this.#applyWorkstreamConfig(written.config); + // The edit's own reply needs no settings:read: it is what the caller just wrote. + return this.#workstreamList({ type: "workstream.list", id: msg.id }, { ...auth, scopes: [...auth.scopes, SCOPES.SETTINGS_READ] as AuthContext["scopes"] }); + } + + #credentialList(msg: { id: string }, auth: AuthContext): DaemonMessage { + if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_READ)) { + return { type: "response.error", requestId: msg.id, error: "Missing scope: settings:read", code: "forbidden" }; + } + const items = this.#config?.workstreams?.items ?? []; + const usedBy = (id: string) => + items + .filter((w) => Object.values(w.env).some((r) => r.secret === id) || w.mcpServers.some((s) => typeof s !== "string" && s.credential === id)) + .map((w) => w.id); + const tenant = tenantOf(auth); + const credentials = (this.#config?.credentials ?? []).map((c) => { + let status: { set: boolean; source?: string }; + if (c.kind === "oauth") { + const spec = this.#mcpRegistry?.get(c.server); + const signedIn = spec && this.#mcpOAuth ? this.#mcpOAuth.signedIn(withSlot(spec, c.id), tenant) : false; + status = signedIn ? { set: true, source: `signed in to ${c.server}` } : { set: false }; + } else { + const s = this.#secrets.status(c.id); + status = s.set ? { set: true, source: s.source } : { set: false }; + } + return { + id: c.id, + kind: c.kind, + ...("header" in c && c.header ? { header: c.header } : {}), + ...("server" in c && c.server ? { server: c.server } : {}), + ...(c.description ? { description: c.description } : {}), + status, + usedBy: usedBy(c.id), + }; + }); + return { type: "credential.list.result", requestId: msg.id, store: { kind: this.#secrets.kind, writable: this.#secrets.writable }, credentials }; + } + + #credentialEdit(msg: Extract, auth: AuthContext): DaemonMessage { + const refused = this.#configEditRefusal(msg.id, auth); + if (refused) return refused; + const err = (error: string): DaemonMessage => ({ type: "response.error", requestId: msg.id, error, code: "invalid_request" }); + const declared = this.#config?.credentials ?? []; + if (msg.type === "credential.set_value") { + const decl = declared.find((c) => c.id === msg.credential); + if (!decl) return err(`No credential "${msg.credential}" is declared`); + if (decl.kind === "oauth") return err(`"${decl.id}" is an OAuth sign-in: codeoid mcp signin ${decl.server} --credential ${decl.id}`); + if (!this.#secrets.writable || this.#secrets.kind !== "env-file") return err(`The ${this.#secrets.kind} credential store is managed outside codeoid`); + // A value from the daemon's real environment (not .env) wins at the next + // start whatever is written here: say so rather than seem to change it. + // Set in both is the same: .env was skipped at start (dotEnvShadowed). + const name = credentialEnvName(decl.id); + if (process.env[name] !== undefined && (dotEnvShadowed(name) || !readDotEnvValueSet(name))) { + return err(`${credentialEnvName(decl.id)} is set in the daemon's own environment, which wins over .env: change it there`); + } + const w = writeCredentialValue(credentialEnvName(decl.id), msg.value); + if (!w.ok) return err(w.error); + // The value never goes to the audit, only that it changed. + this.#store.audit(auth.sub, "credential.set_value", "", `id=${decl.id} ${msg.value === null ? "cleared" : "set"}`); + } else { + const id = msg.type === "credential.upsert" ? String(msg.item.id ?? "") : msg.credential; + const existing = declared.find((c) => c.id === id); + if (msg.type === "credential.delete" && !existing) return err(`No credential "${id}" is declared`); + if (msg.type === "credential.delete") { + const users = (this.#config?.workstreams?.items ?? []).filter( + (w) => Object.values(w.env).some((r) => r.secret === id) || w.mcpServers.some((s) => typeof s !== "string" && s.credential === id), + ); + if (users.length > 0) return err(`"${id}" is used by ${users.map((u) => u.id).join(", ")}: remove it there first`); + } + const written = writeConfigBlock("credentials", (current) => { + const list = Array.isArray(current) ? [...(current as unknown[])] : []; + const idOf = (i: unknown) => (i && typeof i === "object" ? (i as { id?: unknown }).id : undefined); + if (msg.type === "credential.upsert") { + const at = list.findIndex((i) => idOf(i) === id); + if (at >= 0) list[at] = msg.item; + else list.push(msg.item); + return list; + } + return list.filter((i) => idOf(i) !== id); + }); + if (!written.ok) return err(written.error); + if (msg.type === "credential.delete" && existing?.kind !== "oauth" && this.#secrets.writable && this.#secrets.kind === "env-file") writeCredentialValue(credentialEnvName(id), null); + // An OAuth slot's sign-ins go with it, every tenant's: re-declaring the + // id later must not silently reuse an old sign-in. + if (msg.type === "credential.delete" && existing?.kind === "oauth") { + const tenants = this.#mcpOAuth?.forgetSlot(existing.server, existing.id) ?? []; + for (const t of tenants) this.#mcpHub?.dropTenant(existing.server, t); + } + this.#store.audit(auth.sub, msg.type, "", `id=${id}`); + this.#applyWorkstreamConfig(written.config); + } + if (msg.type === "credential.set_value") this.#pushToProviderSubscribers({ type: "workstreams.changed", revision: this.#workstreams.revision }); + return this.#credentialList(msg, { ...auth, scopes: [...auth.scopes, SCOPES.SETTINGS_READ] as AuthContext["scopes"] }); + } + /** * What a folder resolves to (and whether an explicit choice conflicts), for * the new-session picker. Gated like a create: it answers the question a diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 6264b1f8..960736a7 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -565,7 +565,7 @@ export interface SessionCreateOptions { * and held to the same denylist (the schema already refused anything it * would). Never persisted: recomputed from the config on every resume. */ - workstreamEnv?: Readonly>; + workstreamEnv?: () => Readonly>; /** * The home's MCP grant under isolation (workstreams/grant.ts), read live; * undefined result = no isolation. Applied by the session's MCP view, so @@ -1402,7 +1402,51 @@ export class Session { /** This turn's end-attempt interrupt has been scheduled (once per turn). */ #endAttemptInterruptQueued = false; /** See SessionCreateOptions.extraEnv (validated in the constructor). */ + #extraEnvStatic: Readonly> | undefined; + /** The home workstream's env, read live (SessionCreateOptions.workstreamEnv). */ + #workstreamEnv: (() => Readonly>) | undefined; + /** What a backend gets as extra env: a live view over both (see #liveExtraEnv). */ #extraEnv: Readonly> | undefined; + /** The extra env the current backend was built with, to rebuild it when the workstream's changes. */ + #envBuiltWith: string | undefined; + + /** The workstream's env under the caller's extra env, held to the extra-env denylist. */ + #composedEnv(): Record { + const out: Record = { ...(this.#workstreamEnv?.() ?? {}), ...(this.#extraEnvStatic ?? {}) }; + for (const k of extraEnvViolations(out)) delete out[k]; + return out; + } + + /** + * The extra env handed to the backend: a read-only view that is always the + * workstream's env as configured NOW. Providers read it when they start a + * process (spread or iterated), so a workstream edit — a credential cleared, + * an env entry removed — reaches the next backend start, which + * #rebuildForMcp brings to the next turn (design §8: narrowing applies at + * the next turn boundary). + */ + #liveExtraEnv(): Readonly> { + const current = () => this.#composedEnv(); + return new Proxy({} as Record, { + get: (_t, k) => (typeof k === "string" ? current()[k] : undefined), + has: (_t, k) => typeof k === "string" && k in current(), + ownKeys: () => Object.keys(current()), + getOwnPropertyDescriptor: (_t, k) => { + const v = typeof k === "string" ? current()[k] : undefined; + return v === undefined ? undefined : { value: v, enumerable: true, configurable: true, writable: false }; + }, + set: () => false, + deleteProperty: () => false, + defineProperty: () => false, + }); + } + + /** A hash of the composed env: held to compare, so the values themselves aren't kept around. */ + #envDigest(): string { + return createHash("sha256") + .update(JSON.stringify(Object.entries(this.#composedEnv()).sort(([a], [b]) => a.localeCompare(b)))) + .digest("hex"); + } /** The session's OS sandbox (#448); null on a Session built without a sandbox registry. */ #sandbox: SessionSandbox | null = null; /** The repository's git settings as last looked at (#448: #checkGitControl), from the first turn on. */ @@ -1752,7 +1796,8 @@ export class Session { this.#collaborationCost = opts.collaborationCost; this.#maxBudgetUsd = opts.maxBudgetUsd; this.#onBudgetExhausted = opts.onBudgetExhausted ?? "park"; - const extra = opts.workstreamEnv && Object.keys(opts.workstreamEnv).length > 0 ? { ...opts.workstreamEnv, ...(opts.extraEnv ?? {}) } : opts.extraEnv; + this.#workstreamEnv = opts.workstreamEnv; + const extra = opts.extraEnv; if (extra) { // Before any side effect: a refused key must fail the create, not run a // backend with half the env the caller asked for. @@ -1762,8 +1807,9 @@ export class Session { `extraEnv may not set ${refused.join(", ")}: codeoid owns credential, account and process-loading variables for the backend`, ); } - this.#extraEnv = Object.freeze({ ...extra }); + this.#extraEnvStatic = Object.freeze({ ...extra }); } + if (this.#extraEnvStatic || this.#workstreamEnv) this.#extraEnv = this.#liveExtraEnv(); if (opts.sandboxRegistry) { this.#sandbox = new SessionSandbox({ sessionId: this.id, @@ -2962,8 +3008,8 @@ export class Session { */ async #applyMcpView(): Promise { const view = this.#mcpView; - if (!view || this.#mcpBusy()) return; - if (!view.loaded || this.#mcpRefreshPending) { + if ((!view && !this.#workstreamEnv) || this.#mcpBusy()) return; + if (view && (!view.loaded || this.#mcpRefreshPending)) { this.#mcpRefreshPending = false; try { await this.#loadMcp(); @@ -3111,14 +3157,20 @@ export class Session { * Whether it was rebuilt. The first time, nothing was built yet: just noted. */ async #rebuildForMcp(): Promise { + // The workstream's env changed since the backend started (a credential + // cleared or swapped, an env entry removed): rebuilt too, like its servers + // — with or without an MCP view (a session may have none). + const envNow = this.#workstreamEnv ? this.#envDigest() : undefined; + if (envNow !== undefined) this.#envBuiltWith ??= envNow; + const envChanged = envNow !== undefined && envNow !== this.#envBuiltWith; const view = this.#mcpView; - if (!view?.loaded) return false; - if (this.#mcpBuiltWith === undefined) { + if (!view?.loaded) { + if (!envChanged) return false; + } else if (this.#mcpBuiltWith === undefined) { this.#mcpBuiltWith = view.fingerprint; this.#releaseMcp(); - return false; - } - if (view.fingerprint === this.#mcpBuiltWith) return false; + if (!envChanged) return false; + } else if (view.fingerprint === this.#mcpBuiltWith && !envChanged) return false; await this.#teardownProvider(); // records what's applied, and lets go of what was removed await this.#reseedAfterTeardown(); return true; @@ -6139,6 +6191,7 @@ export class Session { // still rebuilds it. Nothing still running can call a server the session // stopped using. if (this.#mcpView?.loaded) this.#mcpBuiltWith = this.#mcpView.fingerprint; + if (this.#workstreamEnv) this.#envBuiltWith = this.#envDigest(); this.#releaseMcp(); this.#mcpProjectNames.clear(); // the next backend reads its project files afresh try { await taskToAwait; } catch { /* consumer handles its own errors */ } diff --git a/src/daemon/settings/store.ts b/src/daemon/settings/store.ts index 59965e70..08f38a77 100644 --- a/src/daemon/settings/store.ts +++ b/src/daemon/settings/store.ts @@ -275,6 +275,55 @@ export function writeWorktreeTrust( return { ok: true, before, after }; } +/** + * Replace one top-level block of config.json (`workstreams`, `credentials`) + * with what `update` returns from the block as the file holds it NOW — so a + * hand edit made since the daemon started is never overwritten — then + * validate the whole config and write it atomically. Nothing is written if it + * doesn't validate; `undefined` from `update` removes the block. Returns the + * block as written. + */ +export function writeConfigBlock( + key: "workstreams" | "credentials", + update: (current: unknown) => unknown, +): { ok: true; value: unknown; config: Record } | { ok: false; error: string } { + let raw: Record; + try { + raw = readRawConfig(); + } catch (err) { + return { ok: false, error: `config.json is not valid JSON: ${errMsg(err)}` }; + } + const value = update(raw[key]); + if (value === undefined) delete raw[key]; + else raw[key] = value; + const validation = validateConfigObject(raw); + if (!validation.ok) return { ok: false, error: validation.issues.map((i) => `${i.path ? `${i.path}: ` : ""}${i.message}`).join("; ") }; + writeConfigJson(raw); + // The whole file as written: a caller applies every block it depends on from + // it, never one block from here and another from what it loaded at boot. + return { ok: true, value, config: raw }; +} + +/** Whether `.env` itself defines `key` (not merely the daemon's environment). */ +export function readDotEnvValueSet(key: string): boolean { + return readDotEnvKeys(configFilePaths().envPath).has(key); +} + +/** + * Set (or with `null`, clear) a named credential's value in the env-file + * secret store: `CODEOID_CRED_` in `.env` (0600) and the daemon's live + * environment, so it applies at the next use without a restart. The value is + * never read back out: callers get presence only (credentials/store.ts). + */ +export function writeCredentialValue(envName: string, value: string | null): { ok: true } | { ok: false; error: string } { + if (!/^CODEOID_CRED_[A-Z0-9_]{1,64}$/.test(envName)) return { ok: false, error: "not a credential's variable" }; + if (value !== null && (value.length === 0 || /[\r\n\0]/.test(value))) return { ok: false, error: "a credential value is one non-empty line" }; + writeDotEnv(new Map([[envName, value]])); + if (value === null) delete process.env[envName]; + else process.env[envName] = value; + return { ok: true }; +} + // ── config.json IO ────────────────────────────────────────────────────────── function readRawConfig(): Record { diff --git a/src/daemon/store.ts b/src/daemon/store.ts index e6222350..7dd61827 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -1908,6 +1908,20 @@ export class Store { .run(tenant.accountId, tenant.projectId, server.name, server.url, server.slot ?? "", JSON.stringify(tokens), savedAtMs); } + /** + * Delete one named credential slot of a server for every tenant and URL + * (the credential was removed). Never the default sign-in (slot ""). Returns + * the tenants that had a row. + */ + clearMcpOAuthSlot(server: string, slot: string): Array<{ accountId: string; projectId: string }> { + if (!slot) return []; + const rows = this.#db + .prepare("SELECT DISTINCT account_id, project_id FROM mcp_oauth_credentials WHERE server = ? AND credential = ?") + .all(server, slot) as Array<{ account_id: string; project_id: string }>; + this.#db.prepare("DELETE FROM mcp_oauth_credentials WHERE server = ? AND credential = ?").run(server, slot); + return rows.map((r) => ({ accountId: r.account_id, projectId: r.project_id })); + } + /** * Forget part of a tenant's OAuth state for a server: its tokens (sign out, * or the server rejected them), its client registration, or both. A hard diff --git a/src/daemon/workstreams/grant.ts b/src/daemon/workstreams/grant.ts index 43ba2604..887742b3 100644 --- a/src/daemon/workstreams/grant.ts +++ b/src/daemon/workstreams/grant.ts @@ -40,8 +40,9 @@ export function grantRefusal(grant: McpGrant | undefined, spec: { name: string; return grant.workdirServers ? undefined : `MCP server "${name}" is this folder's own, and the folder isn't workstream "${grant.workstream ?? "none"}"'s`; } const sel = grant.servers.get(name); - const credential = "credential" in spec.transport ? spec.transport.credential?.id : undefined; - if (sel && (sel.oauthSlot ?? sel.credential?.id) === credential) return undefined; + const cred = "credential" in spec.transport ? spec.transport.credential : undefined; + const same = sel?.oauthSlot ? cred?.id === sel.oauthSlot : (sel?.credential?.id === cred?.id && (sel?.credential?.header ?? "") === (cred && "header" in cred ? (cred.header ?? "") : "")); + if (sel && same) return undefined; if (sel) return `MCP server "${name}" is now called with another credential in workstream "${grant.workstream}" — it applies from the next turn`; return grant.workstream ? `MCP server "${name}" is not in workstream "${grant.workstream}"` diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index 92f349d3..7e2a873e 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -74,7 +74,8 @@ export class WorkstreamService { paths: w.match.paths.flatMap((p) => this.#pathRuleForms(p)), remotes: w.match.remotes.map((r) => r.toLowerCase()), })); - this.#revision = createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16); + // The credentials too: a credential edit changes what sessions get, and clients skip a repeated revision. + this.#revision = createHash("sha256").update(JSON.stringify([cfg, [...this.#credentials.values()]])).digest("hex").slice(0, 16); } /** @@ -181,6 +182,7 @@ export class WorkstreamService { env: Object.keys(w.env), ...(w.git ? { git: { ...w.git } } : {}), skillDirs: [...w.skillDirs], + config: structuredClone((this.#cfg.items.find((i) => i.id === w.id) ?? {}) as Record), })); } diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts index e100fd61..3fb55231 100644 --- a/src/daemon/workstreams/workstreams.test.ts +++ b/src/daemon/workstreams/workstreams.test.ts @@ -525,10 +525,11 @@ describe("WorkstreamService", () => { expect(s.envFor(undefined, store).env).toEqual({}); }); - test("summaries list env names, never values", () => { + test("summaries list env names and credential ids, never values", () => { const s = service([{ id: "a", env: { GH_TOKEN: { secret: "gh-a" } } }]); expect(s.summaries()[0].env).toEqual(["GH_TOKEN"]); - expect(JSON.stringify(s.summaries())).not.toContain("gh-a"); + // A credential id is a reference the editor shows; a value never is here. + expect(s.summaries()[0].config).toMatchObject({ env: { GH_TOKEN: { secret: "gh-a" } } }); }); }); diff --git a/src/tests/protocol.test.ts b/src/tests/protocol.test.ts index ba89f5c1..6acc4e37 100644 --- a/src/tests/protocol.test.ts +++ b/src/tests/protocol.test.ts @@ -466,6 +466,10 @@ describe("DaemonMessage routing", () => { return `workstream.list:${msg.workstreams.length}`; case "workstream.resolve.result": return `workstream.resolve:${msg.resolution.home ?? "none"}`; + case "workstreams.changed": + return `workstreams.changed:${msg.revision}`; + case "credential.list.result": + return `credential.list:${msg.credentials.length}`; case "session.turns.result": return `turns:${msg.turns.length}`; case "session.approvals.result": diff --git a/src/tests/workstreams-edit.test.ts b/src/tests/workstreams-edit.test.ts new file mode 100644 index 00000000..8bad41ea --- /dev/null +++ b/src/tests/workstreams-edit.test.ts @@ -0,0 +1,256 @@ +/** + * Editing workstreams and credentials live (W2): written to config.json / + * .env through the settings store (whole config validated, nothing written + * otherwise), applied to the running daemon at once, and announced. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, loadDotEnv } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { ProviderRegistry } from "../daemon/providers/registry.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { ALL_SCOPES, SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, DaemonMessage, SessionInfo } from "../protocol/types.js"; + +const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "rec", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } }; +const OWNER: AuthContext = { sub: "user:edit", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }; +const pushed: DaemonMessage[] = []; +const client = { id: "c-edit", auth: OWNER, send: (m: DaemonMessage) => void pushed.push(m) }; + +let tmp: string; +let store: Store; +let transcript: TranscriptStore; +let savedXdg: string | undefined; +let manager: SessionManager; + +function configJson(): Record { + return JSON.parse(readFileSync(join(tmp, "config", "codeoid", "config.json"), "utf8")); +} + +beforeEach(async () => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-ws-edit-"))); + savedXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = join(tmp, "config"); + mkdirSync(join(tmp, "config", "codeoid"), { recursive: true }); + mkdirSync(join(tmp, "home", "acme"), { recursive: true }); + writeFileSync(join(tmp, "config", "codeoid", "config.json"), JSON.stringify({ dbPath: join(tmp, "codeoid.db"), transcriptDir: join(tmp, "transcripts") })); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: loadConfig({ env: {}, quiet: true }), + _testProviderFactory: () => new MockSessionProvider(), + _testFolderReader: new FolderReader({ home: join(tmp, "home"), sshConfig: "" }), + }); + // Subscribe the client to provider/workstream broadcasts, as a web client does. + await manager.handle({ type: "providers.list", id: "sub" } as never, OWNER, client); + pushed.length = 0; +}); + +afterEach(async () => { + try { + await manager.drain(2_000); + } catch {} + try { + await transcript.flush(); + } catch {} + store.close(); + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; + for (const k of Object.keys(process.env)) if (k.startsWith("CODEOID_CRED_")) delete process.env[k]; + rmSync(tmp, { recursive: true, force: true }); +}); + +const h = (msg: Record, auth: AuthContext = OWNER) => manager.handle(msg as never, auth, client); + +describe("editing workstreams", () => { + it("an upsert is validated, written, applied at once and announced; a create there gets it", async () => { + const r = await h({ type: "workstream.upsert", id: "u1", item: { id: "acme", claim: "exclusive", match: { paths: ["~/acme"] } } }); + expect(r).toMatchObject({ type: "workstream.list.result", workstreams: [{ id: "acme", claim: "exclusive" }] }); + expect((configJson().workstreams as { items: unknown[] }).items).toEqual([{ id: "acme", claim: "exclusive", match: { paths: ["~/acme"] } }]); + expect(pushed.some((m) => m.type === "workstreams.changed")).toBe(true); + const made = (await h({ type: "session.create", id: "c1", name: "s", workdir: join(tmp, "home", "acme") })) as { data: SessionInfo }; + expect(made.data.workstream).toMatchObject({ id: "acme", source: "matched" }); + }); + + it("an invalid item writes nothing and says why", async () => { + const before = readFileSync(join(tmp, "config", "codeoid", "config.json"), "utf8"); + const r = await h({ type: "workstream.upsert", id: "u2", item: { id: "Bad Id" } }); + expect(r).toMatchObject({ type: "response.error", code: "invalid_request" }); + if (r.type === "response.error") expect(r.error).toContain("workstream id"); + expect(readFileSync(join(tmp, "config", "codeoid", "config.json"), "utf8")).toBe(before); + }); + + it("settings and delete; a deleted default is removed with it", async () => { + await h({ type: "workstream.upsert", id: "u3", item: { id: "acme" } }); + const s = await h({ type: "workstream.settings", id: "u4", isolateGlobalServers: true, default: "acme" }); + expect(s).toMatchObject({ isolateGlobalServers: true, default: "acme" }); + const d = await h({ type: "workstream.delete", id: "u5", workstream: "acme" }); + expect(d).toMatchObject({ type: "workstream.list.result", workstreams: [] }); + expect((d as { default?: string }).default).toBeUndefined(); + }); + + it("needs settings:write on an undelegated token", async () => { + expect(await h({ type: "workstream.upsert", id: "u6", item: { id: "x" } }, { ...OWNER, scopes: [SCOPES.SETTINGS_READ] as AuthContext["scopes"] })).toMatchObject({ code: "forbidden" }); + expect(await h({ type: "workstream.upsert", id: "u7", item: { id: "x" } }, { ...OWNER, delegationDepth: 1 })).toMatchObject({ code: "forbidden" }); + }); +}); + +describe("editing credentials", () => { + it("declare, set a value (never returned), see it used, refuse deleting it while used, then delete", async () => { + const up = await h({ type: "credential.upsert", id: "k1", item: { id: "gh-acme", kind: "env" } }); + expect(up).toMatchObject({ type: "credential.list.result", store: { kind: "env-file", writable: true }, credentials: [{ id: "gh-acme", status: { set: false }, usedBy: [] }] }); + const set = await h({ type: "credential.set_value", id: "k2", credential: "gh-acme", value: "ghp_secret_value" }); + expect(JSON.stringify(set)).not.toContain("ghp_secret_value"); + expect(set).toMatchObject({ credentials: [{ id: "gh-acme", status: { set: true, source: "CODEOID_CRED_GH_ACME" } }] }); + expect(readFileSync(join(tmp, "config", "codeoid", ".env"), "utf8")).toContain("CODEOID_CRED_GH_ACME=ghp_secret_value"); + await h({ type: "workstream.upsert", id: "k3", item: { id: "acme", env: { GH_TOKEN: { secret: "gh-acme" } } } }); + expect(await h({ type: "credential.list", id: "k4" })).toMatchObject({ credentials: [{ usedBy: ["acme"] }] }); + const blocked = await h({ type: "credential.delete", id: "k5", credential: "gh-acme" }); + expect(blocked).toMatchObject({ type: "response.error" }); + if (blocked.type === "response.error") expect(blocked.error).toContain("used by acme"); + await h({ type: "workstream.delete", id: "k6", workstream: "acme" }); + expect(await h({ type: "credential.delete", id: "k7", credential: "gh-acme" })).toMatchObject({ credentials: [] }); + expect(readFileSync(join(tmp, "config", "codeoid", ".env"), "utf8")).not.toContain("CODEOID_CRED_GH_ACME"); + }); + + it("a multi-line value, an undeclared one, or an OAuth one's value is refused", async () => { + await h({ type: "credential.upsert", id: "m1", item: { id: "e", kind: "env" } }); + await h({ type: "credential.upsert", id: "m2", item: { id: "o", kind: "oauth", server: "slack" } }); + expect(await h({ type: "credential.set_value", id: "m3", credential: "e", value: "a\nb" })).toMatchObject({ type: "response.error" }); + expect(await h({ type: "credential.set_value", id: "m4", credential: "nope", value: "x" })).toMatchObject({ type: "response.error" }); + const o = await h({ type: "credential.set_value", id: "m5", credential: "o", value: "x" }); + if (o.type === "response.error") expect(o.error).toContain("codeoid mcp signin slack --credential o"); + }); +}); + +describe("live edits after the W2 audit", () => { + it("a hand edit to the other block since boot is applied too, never shadowed", async () => { + const path = join(tmp, "config", "codeoid", "config.json"); + writeFileSync(path, JSON.stringify({ ...configJson(), credentials: [{ id: "hand", kind: "env" }] })); + await h({ type: "workstream.upsert", id: "a1", item: { id: "acme", env: { GH_TOKEN: { secret: "hand" } } } }); + expect(await h({ type: "credential.list", id: "a2" })).toMatchObject({ credentials: [{ id: "hand", usedBy: ["acme"] }] }); + expect(await h({ type: "credential.set_value", id: "a3", credential: "hand", value: "v" })).toMatchObject({ type: "credential.list.result" }); + }); + + it("deleting an undeclared or malformed credential is refused, touching nothing", async () => { + writeFileSync(join(tmp, "config", "codeoid", ".env"), "CODEOID_CRED_GHOST=keep\n"); + expect(await h({ type: "credential.delete", id: "d1", credential: "ghost" })).toMatchObject({ type: "response.error" }); + expect(readFileSync(join(tmp, "config", "codeoid", ".env"), "utf8")).toContain("CODEOID_CRED_GHOST=keep"); + }); + + it("a write-only token gets its edit's reply", async () => { + const r = await h({ type: "workstream.upsert", id: "w1", item: { id: "acme" } }, { ...OWNER, scopes: [SCOPES.SETTINGS_WRITE] as AuthContext["scopes"] }); + expect(r).toMatchObject({ type: "workstream.list.result" }); + }); + + it("a value from the daemon's real environment is refused: it would win at the next start", async () => { + await h({ type: "credential.upsert", id: "e1", item: { id: "real", kind: "env" } }); + process.env.CODEOID_CRED_REAL = "from-env"; + const r = await h({ type: "credential.set_value", id: "e2", credential: "real", value: "x" }); + expect(r).toMatchObject({ type: "response.error" }); + if (r.type === "response.error") expect(r.error).toContain("daemon's own environment"); + }); + + it("a value set in both .env and the real environment is refused too: .env was skipped at start", async () => { + await h({ type: "credential.upsert", id: "b1", item: { id: "both", kind: "env" } }); + writeFileSync(join(tmp, "config", "codeoid", ".env"), "CODEOID_CRED_BOTH=from-file\n"); + process.env.CODEOID_CRED_BOTH = "from-env"; + loadDotEnv(); + const r = await h({ type: "credential.set_value", id: "b2", credential: "both", value: "x" }); + expect(r).toMatchObject({ type: "response.error" }); + expect(readFileSync(join(tmp, "config", "codeoid", ".env"), "utf8")).toContain("CODEOID_CRED_BOTH=from-file"); + }); + + it("forgetting an OAuth slot clears every tenant's sign-in to it, never the default sign-in", () => { + const a = { accountId: "a", projectId: "p" }; + const b = { accountId: "b", projectId: "p" }; + store.saveMcpOAuthTokens(a, { name: "slack", url: "https://s/mcp", slot: "work" }, { access_token: "1" }, 1); + store.saveMcpOAuthTokens(b, { name: "slack", url: "https://old/mcp", slot: "work" }, { access_token: "2" }, 1); + store.saveMcpOAuthTokens(a, { name: "slack", url: "https://s/mcp" }, { access_token: "3" }, 1); + const gone = store.clearMcpOAuthSlot("slack", "work"); + expect(gone.map((t) => t.accountId).sort()).toEqual(["a", "b"]); + expect(store.getMcpOAuthCredential(a, { name: "slack", url: "https://s/mcp", slot: "work" })).toBeUndefined(); + expect(store.getMcpOAuthCredential(b, { name: "slack", url: "https://old/mcp", slot: "work" })).toBeUndefined(); + expect(store.getMcpOAuthCredential(a, { name: "slack", url: "https://s/mcp" })?.tokens).toEqual({ access_token: "3" }); + expect(store.clearMcpOAuthSlot("slack", "")).toEqual([]); + }); + + it("a live session's backend env follows the workstream: a cleared credential is gone at its next start", async () => { + const seen: Array> = []; + const registry = new ProviderRegistry("rec"); + registry.register({ + id: "rec", + displayName: "rec", + create: (init) => { + seen.push(init.extraEnv as Record); + return new MockSessionProvider("rec", []); + }, + }); + const m = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: loadConfig({ env: {}, quiet: true }), + providers: registry, + _testFolderReader: new FolderReader({ home: join(tmp, "home"), sshConfig: "" }), + }); + const hm = (msg: Record) => m.handle(msg as never, OWNER, client); + await hm({ type: "credential.upsert", id: "l1", item: { id: "gh", kind: "env" } }); + await hm({ type: "credential.set_value", id: "l2", credential: "gh", value: "tok-1" }); + await hm({ type: "workstream.upsert", id: "l3", item: { id: "acme", match: { paths: ["~/acme"] }, env: { GH_TOKEN: { secret: "gh" } } } }); + expect((await hm({ type: "session.create", id: "l4", name: "s", workdir: join(tmp, "home", "acme") })).type).toBe("response.ok"); + const env = seen.at(-1)!; + expect({ ...env }.GH_TOKEN).toBe("tok-1"); + await hm({ type: "credential.set_value", id: "l5", credential: "gh", value: "tok-2" }); + expect({ ...env }.GH_TOKEN).toBe("tok-2"); + await hm({ type: "credential.set_value", id: "l6", credential: "gh", value: null }); + expect({ ...env }.GH_TOKEN).toBeUndefined(); + await m.drain(2_000); + }); + + it("a backend that ran is rebuilt at the next turn when its workstream's env changed", async () => { + const made_: MockSessionProvider[] = []; + // A rebuild tears the backend down: its next turn starts it afresh with the env as it is now. + const torn = () => made_.reduce((n, p) => n + p.teardownCount, 0); + const registry = new ProviderRegistry("rec"); + registry.register({ + id: "rec", + displayName: "rec", + create: () => { + const p = new MockSessionProvider("rec", [[DONE], [DONE], [DONE]]); + made_.push(p); + return p; + }, + }); + const m = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: loadConfig({ env: {}, quiet: true }), + providers: registry, + _testFolderReader: new FolderReader({ home: join(tmp, "home"), sshConfig: "" }), + }); + const hm = (msg: Record) => m.handle(msg as never, OWNER, client); + await hm({ type: "credential.upsert", id: "r1", item: { id: "gh", kind: "env" } }); + await hm({ type: "credential.set_value", id: "r2", credential: "gh", value: "tok-1" }); + await hm({ type: "workstream.upsert", id: "r3", item: { id: "acme", match: { paths: ["~/acme"] }, env: { GH_TOKEN: { secret: "gh" } } } }); + const made = (await hm({ type: "session.create", id: "r4", name: "s", workdir: join(tmp, "home", "acme") })) as { data: SessionInfo }; + const session = m.findByName(made.data.name, OWNER)!; + const idle = async () => { + for (let i = 0; i < 200 && session.toInfo().status !== "idle"; i++) await Bun.sleep(10); + }; + await session.send("one", OWNER); + await idle(); + const before = torn(); + await session.send("two", OWNER); + await idle(); + expect(torn()).toBe(before); // nothing changed: the same backend + await hm({ type: "credential.set_value", id: "r5", credential: "gh", value: "tok-2" }); + await session.send("three", OWNER); + await idle(); + expect(torn()).toBe(before + 1); + await m.drain(2_000); + }); +}); + diff --git a/src/tests/workstreams-isolation.test.ts b/src/tests/workstreams-isolation.test.ts index 297feb31..cf4a97e1 100644 --- a/src/tests/workstreams-isolation.test.ts +++ b/src/tests/workstreams-isolation.test.ts @@ -163,6 +163,9 @@ describe("every call is checked against the grant as it is now (G4)", () => { expect(grantRefusal(g, { name: "codeoid_memory", builtin: true, transport: { kind: "in-process" } })).toBeUndefined(); expect(grantRefusal(g, { name: "gh", builtin: false, transport: { kind: "http", credential: { id: "new" } } as never })).toBeUndefined(); expect(grantRefusal(g, { name: "gh", builtin: false, transport: { kind: "http", credential: { id: "old" } } as never })).toMatch(/another credential/); + // The same id with another header is another credential too (W2 audit). + const hg: McpGrant = { workstream: "acme", servers: new Map([["gh", { credential: { id: "new", header: "X-Api-Key" } }]]), workdirServers: false }; + expect(grantRefusal(hg, { name: "gh", builtin: false, transport: { kind: "http", credential: { id: "new" } } as never })).toMatch(/another credential/); expect(grantRefusal(g, { name: "mine", builtin: false, origin: "claude-local", transport: { kind: "stdio" } })).toMatch(/this folder's own/); expect(grantRefusal(undefined, { name: "x", builtin: false, transport: { kind: "stdio" } })).toBeUndefined(); }); diff --git a/web/src/components/CenterPane.tsx b/web/src/components/CenterPane.tsx index 06dca1e4..7eec877b 100644 --- a/web/src/components/CenterPane.tsx +++ b/web/src/components/CenterPane.tsx @@ -25,6 +25,7 @@ import PromptBox from "./prompt/PromptBox"; import SessionControls from "./SessionControls"; import { AccountChip } from "./AccountChip"; import { SandboxChip } from "./SandboxChip"; +import { WorkstreamChip } from "./WorkstreamChip"; import Transcript from "./transcript/Transcript"; import FleetRail from "./FleetRail"; import WorkerIndicator from "./transcript/WorkerIndicator"; @@ -119,6 +120,7 @@ const SessionHeader: Component = () => ( {s().model} + diff --git a/web/src/components/CredentialsPanel.tsx b/web/src/components/CredentialsPanel.tsx new file mode 100644 index 00000000..1169e572 --- /dev/null +++ b/web/src/components/CredentialsPanel.tsx @@ -0,0 +1,237 @@ +/** + * Settings → Credentials (docs/workstreams-design.md G2): named credentials + * workstreams use — declared by id and kind; values set here (into the + * daemon's env-file store) or managed outside codeoid (a `dir` store, an + * OAuth sign-in). A value is typed into a password field, sent once and + * cleared; the daemon only ever says whether one is set. + */ + +import { type Component, For, Show, createSignal, onMount } from "solid-js"; + +import type { CredentialInfo } from "../protocol/types"; +import { credentialsState, deleteCredential, fetchCredentials, saveCredential, setCredentialValue } from "../state/workstreams"; + +const label = "text-[11px] font-medium uppercase tracking-wider text-fg-faint"; +const input = + "w-full rounded border border-border bg-bg px-3 py-1.5 font-mono text-sm text-fg focus:border-accent focus:outline-none"; +const button = + "rounded border border-border bg-bg px-2.5 py-1 text-[12px] text-fg-muted transition hover:border-accent/40 hover:text-fg disabled:opacity-50"; + +const KINDS: Array<{ kind: CredentialInfo["kind"]; what: string }> = [ + { kind: "env", what: "an env var a workstream sets (GH_TOKEN…)" }, + { kind: "bearer", what: "sent to an MCP server as Authorization: Bearer" }, + { kind: "header", what: "sent to an MCP server in a named header" }, + { kind: "oauth", what: "a second OAuth sign-in to one server" }, +]; + +export const CredentialsPanel: Component = () => { + onMount(() => void fetchCredentials()); + const [busy, setBusy] = createSignal(null); + const [error, setError] = createSignal(null); + const [adding, setAdding] = createSignal(false); + const run = async (key: string, fn: () => Promise) => { + setBusy(key); + setError(null); + try { + await fn(); + return true; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + return false; + } finally { + setBusy(null); + } + }; + const store = () => credentialsState().store; + + return ( +
+
+

Credentials

+

+ Secrets workstreams use, by name. A value never leaves the daemon for an agent: it's read when a server is called or a + session starts. Values live in the {store().kind} store + {store().writable ? "." : ", managed outside codeoid."} +

+
+ + + +
+ +
None declared yet.
+
+ {/* Keyed by id, not by object: every refresh hands new objects, and a row + remounting would drop a value being typed or an open confirm. */} + c.id)}> + {(id) => ( + c.id === id)}> + {(c) => } + + )} + +
+ setAdding(true)}> + Add credential + + } + > + setAdding(false)} run={run} busy={busy()} /> + +
+ ); +}; + +const CredentialRow: Component<{ + c: CredentialInfo; + writable: boolean; + run: (key: string, fn: () => Promise) => Promise; + busy: string | null; +}> = (props) => { + const [value, setValue] = createSignal(""); + const [confirm, setConfirm] = createSignal(false); + const [confirmClear, setConfirmClear] = createSignal(false); + const c = () => props.c; + return ( +
+
+ {c().id} + {c().kind} + + {c().header} + + + for {c().server} + + {c().status.set ? `set (${c().status.source})` : "not set"} + 0}> + used by {c().usedBy.join(", ")} + + + setConfirm(true)} disabled={props.busy !== null}> + Remove + + } + > + + + + +
+ + Sign in with codeoid mcp signin {c().server} --credential {c().id} +
+ } + > + +
{ + e.preventDefault(); + void props.run(`set:${c().id}`, () => setCredentialValue(c().id, value())).then((ok) => ok && setValue("")); + }} + > + setValue(e.currentTarget.value)} + /> + + + setConfirmClear(true)}> + Clear + + } + > + + + +
+
+ + + ); +}; + +const AddCredential: Component<{ onDone: () => void; run: (key: string, fn: () => Promise) => Promise; busy: string | null }> = (props) => { + const [id, setId] = createSignal(""); + const [kind, setKind] = createSignal("env"); + const [extra, setExtra] = createSignal(""); + const save = async () => { + const item: Record = { id: id().trim(), kind: kind() }; + if (kind() === "header") item.header = extra().trim(); + if (kind() === "oauth") item.server = extra().trim(); + if (await props.run("add", () => saveCredential(item))) props.onDone(); + }; + return ( +
{ + e.preventDefault(); + void save(); + }} + > + + + + + +
+ + +
+
+ ); +}; diff --git a/web/src/components/NewSessionModal.tsx b/web/src/components/NewSessionModal.tsx index 561f9773..37e316be 100644 --- a/web/src/components/NewSessionModal.tsx +++ b/web/src/components/NewSessionModal.tsx @@ -59,6 +59,8 @@ function sandboxDefaultLabel(d: SandboxProvidersData | null): string { import DirectoryPicker from "./files/DirectoryPicker"; import { providerIds, providerLabel } from "../state/providers"; import { accountDetail, backendAccounts, DEFAULT_ACCOUNT, fetchAccounts } from "../state/accounts"; +import { fetchWorkstreams, homeChoices, resolveWorkstream } from "../state/workstreams"; +import type { WorkstreamResolution } from "../protocol/types"; /** The modal serves three flows from one dialog: a plain session, a governed * pipeline run (docs/pipeline-run.md — adds a goal box + requires a pack), or @@ -162,6 +164,37 @@ const NewSessionModal: Component = () => { // daemon default, "default" = pin the daemon's own login, else a named // account (accounts.ts). Names are per backend, so a provider change resets it. const [account, setAccount] = createSignal(""); + // Home workstream (docs/workstreams-design.md): "" = the folder's (what + // `workstream.resolve` says), else an explicit choice. + const [workstream, setWorkstream] = createSignal(""); + const [resolution, setResolution] = createSignal(null); + // The folder couldn't be checked (a scope, an unreadable folder): said as such, never as "none matches". + const [resolveFailed, setResolveFailed] = createSignal(false); + let workstreamSelect: HTMLSelectElement | undefined; + let createButton: HTMLButtonElement | undefined; + // A choice refused past another workstream's exclusive claim, with the token + // that confirms it (G3): shown with what would change, never sent unasked. + const [overrideOffer, setOverrideOffer] = createSignal | null>(null); + // What the folder resolves to, as it is typed or picked (debounced). + createEffect( + on([workdir, openSignal, () => homeChoices().length, mode], ([dir, open, homes]) => { + setOverrideOffer(null); + // The last folder's answer never stands for this one, even for the debounce. + setResolution(null); + setResolveFailed(false); + if (!open || homes === 0 || !dir.trim()) return; + const timer = setTimeout(() => { + resolveWorkstream(dir.trim()) + .then((r) => { + if (workdir() === dir) setResolution(r); + }) + .catch(() => { + if (workdir() === dir) setResolveFailed(true); + }); + }, 300); + onCleanup(() => clearTimeout(timer)); + }), + ); // OS sandbox (#448): "" = the daemon's default; else auto | off | a provider id. const [sandboxChoice, setSandboxChoice] = createSignal(""); // "" = the daemon's default network; else on | off. @@ -421,7 +454,14 @@ const NewSessionModal: Component = () => { void fetchPacks().catch(() => {}); // Named accounts, for the account picker (hidden without any). void fetchAccounts(true).catch(() => {}); - requestAnimationFrame(() => nameRef?.focus()); + // Workstreams, for the workstream picker (hidden without any). + void fetchWorkstreams(true).catch(() => {}); + // Only if focus is still outside the dialog: by the next frame the user + // may already be typing in another field, and moving focus then would + // send the rest of their typing into the name. + requestAnimationFrame(() => { + if (!nameRef?.form?.contains(document.activeElement)) nameRef?.focus(); + }); } }), ); @@ -454,6 +494,9 @@ const NewSessionModal: Component = () => { setGoal(""); setProviderId(""); setAccount(""); + setWorkstream(""); + setResolution(null); + setOverrideOffer(null); setPackId(""); setPackRole(""); setSandboxChoice(""); @@ -464,7 +507,45 @@ const NewSessionModal: Component = () => { setWarnings([]); } - async function submit(ev: Event): Promise { + /** A workstream as the picker names it. */ + function nameOf(id: string): string { + const w = homeChoices().find((x) => x.id === id); + return w && w.name !== id ? w.name : id; + } + + /** + * A create refused: past another workstream's exclusive claim, the confirm + * is offered (never sent unasked); no home for the folder, the picker is + * the way where there is one (`picker`), Settings where not — the daemon's + * own text names a CLI flag. Anything else is the daemon's message. + */ + function showCreateError(err: unknown, picker: boolean, overrideToken?: string): void { + const detail = err && typeof err === "object" && "detail" in err ? (err as { detail?: { kind?: string; resolution?: WorkstreamResolution } }).detail : undefined; + const conflict = detail?.kind === "workstream_conflict" ? detail.resolution?.conflict : undefined; + if (picker && conflict?.overrideToken && !overrideToken) { + setOverrideOffer(conflict); + setError(null); + return; + } + setOverrideOffer(null); + if (detail?.kind === "workstream_required") { + const ambiguous = detail.resolution?.ambiguous; + const why = + ambiguous && ambiguous.length > 0 + ? `${ambiguous.map(nameOf).join(" and ")} both match this folder${/remote/.test(err instanceof Error ? err.message : "") ? " (its repository's remote changed)" : ""}` + : "This folder belongs to no workstream"; + setError(picker ? `${why}: choose a workstream.` : `${why}: set one up for it, or a default, in Settings → Workstreams.`); + if (picker) workstreamSelect?.focus(); + return; + } + if (!picker && detail?.kind === "workstream_conflict" && conflict) { + setError(`This folder belongs to workstream "${nameOf(conflict.folder)}" (exclusive): start the run from a session there.`); + return; + } + setError(err instanceof Error ? err.message : String(err)); + } + + async function submit(ev: Event, overrideToken?: string): Promise { ev.preventDefault(); if (busy()) return; const n = name().trim(); @@ -498,7 +579,7 @@ const NewSessionModal: Component = () => { // PipelineRunner cockpit (state/pipelines carries them) — close here. closeAndReset(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + showCreateError(err, false); setBusy(false); } return; @@ -526,6 +607,8 @@ const NewSessionModal: Component = () => { // Pack ADOPTION (docs/role-model-binding.md §6): the pack — not the // collab machinery — defines what each role is. Omitted = free-form. ...(packId() ? { pack: packId() } : {}), + ...(workstream() ? { workstream: workstream() } : {}), + ...(overrideToken && workstream() ? { override: { token: overrideToken } } : {}), collaboration: { goal: goal().trim(), roles: roles().map((r) => ({ @@ -562,8 +645,9 @@ const NewSessionModal: Component = () => { } catch (err) { // The daemon's message is the useful one here — it names the exact // rule broken (unknown provider, non-claude orchestrator, over the - // child ceiling), so surface it verbatim rather than paraphrasing. - setError(err instanceof Error ? err.message : String(err)); + // child ceiling), so surface it verbatim; a workstream refusal is + // worded for the picker. + showCreateError(err, true, overrideToken); setBusy(false); } return; @@ -597,6 +681,8 @@ const NewSessionModal: Component = () => { : {}), ...(packId() ? { pack: packId() } : {}), ...(packRole() ? { packRole: packRole() } : {}), + ...(workstream() ? { workstream: workstream() } : {}), + ...(overrideToken && workstream() ? { override: { token: overrideToken } } : {}), ...(sandboxChoice() || sandboxNetwork() || sandboxDocker() || sandboxRoots().trim() || sandboxAdded().trim() ? { sandbox: { @@ -628,7 +714,7 @@ const NewSessionModal: Component = () => { closeAndReset(); } } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + showCreateError(err, true, overrideToken); setBusy(false); } } @@ -1166,6 +1252,80 @@ const NewSessionModal: Component = () => { + 0}> + + + + + {(c) => ( + + )} + +
{error()} @@ -1212,6 +1372,7 @@ const NewSessionModal: Component = () => { cancel + setConfirmRemove(w().id)} disabled={busy() !== null}> + Remove + + } + > + + + + +
+
+ {[...w().match.paths, ...w().match.remotes.map((r) => `remote ${r}`)].join(" · ") || (w().abstract ? "a base for others to extend" : "no rules — chosen only")} + 0}> · servers: {w().servers.map((s) => s.server).join(", ")} + 0}> · env: {w().env.join(", ")} +
+ + setEditing(null)} run={run} busy={busy()} /> + + + )} +
+ )} + + + + setEditing("")} disabled={busy() !== null}> + Add workstream + + } + > +
+ setEditing(null)} run={run} busy={busy()} /> +
+
+ + + + ); +}; + +const Badge: Component<{ children: unknown; accent?: boolean }> = (props) => ( + + {props.children as string} + +); + +/** Edit one workstream (or add one): the fields people set; the rest of the item is kept as configured. */ +const Editor: Component<{ + existing?: WorkstreamSummary; + onDone: () => void; + run: (key: string, fn: () => Promise) => Promise; + busy: string | null; +}> = (props) => { + // eslint-disable-next-line solid/reactivity -- seeded once; the editor is remounted per workstream + const [form, setForm] = createSignal(props.existing ? toForm(props.existing.config) : EMPTY_FORM); + const field = (k: K) => ({ + value: form()[k] as string, + onInput: (e: InputEvent & { currentTarget: HTMLInputElement | HTMLTextAreaElement }) => setForm({ ...form(), [k]: e.currentTarget.value }), + }); + const save = async () => { + const problem = formProblem(form()); + const ok = await props.run("save", () => (problem ? Promise.reject(new Error(problem)) : saveWorkstream(fromForm(form(), props.existing?.config)))); + if (ok) props.onDone(); + }; + const row = "grid gap-1"; + return ( +
{ + e.preventDefault(); + void save(); + }} + aria-label={props.existing ? `Edit ${props.existing.id}` : "New workstream"} + > + + +