diff --git a/CLAUDE.md b/CLAUDE.md index 2cbbbc7e..eecdb848 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,8 +251,11 @@ 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) +codeoid new --ref [:] # another workstream it may read (its folders read-only, its memory), by that one's share policy +codeoid workstream ref add|rm [:] | move # references and moves (§5.3, §7) ``` `/mcp/control` (off by default, `mcpControl.*`) lets external agents drive @@ -536,6 +539,39 @@ 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. +W3 (under isolation): the read fence `WorkstreamService.fenceFor` (home's and +references' folders, every other workstream's as `foreign`; folders from +`workstreams/folders.ts` and recorded homes), read live by Session through +`workstreamFence(binding)`: the sandbox's project roots and `extraHidden` +(`SessionSandbox` `workstreamDirs`), the gate's `workstreams/gate.ts` +`fenceRefusal` in every mode (and Claude's PreToolUse hook), and recall +(`MemoryEngine.scopeSession`; `episodes.workstream_id`). References +(`WorkstreamBinding.references`, `readFrom`) by the read workstream's `share` +(`referenceVerdict`), moves by `moveVerdict`; `session.add_reference` / +`remove_reference` / `set_workstream` in `#sessionWorkstreamEdit`, +`Session.setWorkstream`; the env, grant and fence closures take the binding, +so a change applies at the next turn. +W4: the conductor's fleet tools name each result's workstream and take a +`workstream` filter; `FleetDeps.textWithheld` keeps a `closed` workstream's +episode text out of fleet_find / fleet_summary / fleet_recall and the dispatch +digest. `fleet_spawn.workstream` → `checkWorkstream` (`#fleetSpawnHome`) before +queueing, `dispatch_tasks.workstream` judged again at claim; send-class cards +carry `targetWorkstream` (`#fleetCardWorkstream`). `workstreams/egress.ts` is +the egress stub. 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..09ec3220 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -406,8 +406,109 @@ 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 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 (`/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 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). + +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. +`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. + +### Reading another workstream, and moving a session + +With `isolateGlobalServers: true` a session is fenced off from every other workstream's folders and memory: +- its sandbox shows its home's folders read-only as its project roots (never the whole `@parent`), and hides other workstreams' folders; +- a file tool (Read, Grep, Glob, Write, Edit, on every backend, in every mode) in another workstream's folder is refused, naming that workstream; +- `recall`, `timeline` and `get_episode` leave out episodes another workstream wrote (episodes from before workstreams stay visible in their repository). + +A workstream's folders are the directories its path rules name on this machine (a glob such as `~/Workspace/acme-*` expands to the folders it matches, never to `~/Workspace`), plus the folders where its sessions were started by their folder's own match. +The more specific rule decides: a catch-all workstream over `~/Workspace` is fenced off from `~/Workspace/acme-api` when that is another's, and inside a folder of another workstream's, only what the session may read (its own workdir, its home's folders, what a reference grants) stays readable. +The home itself is never a workstream's folder. + +Limits: a repository matched only by a remote rule is fenced once a session has been started there (its folder is recorded then); one never opened is not. A session's commands in an unsandboxed session (`--sandbox off`) can still read other workstreams' folders; only its file tools are fenced. + +A **reference** lets a session read another workstream: its folders read-only (or only some), and its memory. Never its servers, credentials or accounts, and never a write. +The workstream being read decides, by its `share`: + +| `share` | A reference to it is… | +| --- | --- | +| `open` | granted. | +| `ask` | granted when its owner asks with `session:approve` on an undelegated token; asked of the owner on the session otherwise. | +| `closed` | refused. A reference granted before it was closed gives nothing. | + +```bash +codeoid new --ref pub --ref acme:~/Workspace/acme-api # at create +codeoid workstream ref add pub # later (the session header's chip too) +codeoid workstream ref rm pub +codeoid workstream move # another home +``` + +Moving a session takes its whole conversation with it, so it follows the `share` of its old home and of every workstream it has read from (for its lifetime, even after the reference is gone; each home it leaves counts too). Moving from the default workstream, or from no home, is always allowed; past another workstream's exclusive folder it is an override (needs `session:approve`). +A session homed in another workstream's folder (chosen at create, or moved) reads that folder, so the folder's workstream's `share` decides there too: `closed` refuses it, `ask` needs `session:approve`. +Each change is saved at once and applies at the session's next turn (a turn running keeps what it started with): its backend is rebuilt with the new servers, credentials and sandbox. + +A search with no folder (Grep or Glob over the workdir) is refused when another workstream's folder is inside the workdir: search a narrower folder. + + +### The conductor and workstreams + +The conductor is homed in a reserved workstream of its own: no workstream's servers, env or folders. It reads across all of them through its fleet tools, which take a `workstream` filter and name each result's workstream. +With `isolateGlobalServers: true`, a `closed` workstream gives the conductor its sessions' status and metadata only (in `fleet_list`), never their text: `fleet_find` and `fleet_recall` never return them (not even that something matched), `fleet_summary` gives status only, `machine_map` leaves out their branches, and a worker of a closed workstream reports that it finished, not what it found. Episodes from before workstreams are judged by their session's home (as recorded; for one that no longer runs, its stored binding, else its folder's record, else its folder now). A workstream deleted from the config has no policy left: its sessions' text is no longer withheld. Closing a workstream doesn't reach back: what the conductor was told while it was open (its own transcript, a task's digest on `fleet_tasks`) stays. + +The conductor acts only through sessions homed where the work belongs. `fleet_spawn` takes `workstream` (else the folder's; a folder with none, or one whose repository's remote now matches another workstream than before, must be given one) and follows the same rules as a create: past a workstream's exclusive folder, or into an `ask` workstream's folder, the spawn's approval card says so (naming the workstream it passes), and only the session owner's own approval of that card confirms it: the approval adds a one-time token to that very call, which its handler accepts only if the spawn would still be judged exactly as the card showed, at the config revision it was shown at. A closed workstream's folder never takes another's worker, and a closed workstream's worker works only in its own folders. Every send-class card (web, Telegram, the TUI, `codeoid approve`, the terminal) names the workstream the action runs in. + +### 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..d92d94ce 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. @@ -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. @@ -440,7 +441,7 @@ The conductor is the one session whose job crosses workstreams, and this design - That worker has only the target workstream's servers, credentials and memory. - A Highflame Slack reply is drafted by a worker that has never seen a Sentisum episode. - The approval card shows the workstream as well as recipient, repo and content. -- **`fleet_spawn`** takes `workstream` and follows §5. A conflicting spawn returns `workstream_conflict` to the conductor, and it has to choose instead of guessing. +- **`fleet_spawn`** takes `workstream` and follows §5. A conflict the owner can override is shown on the spawn's approval card, and the owner's own approval of that card confirms it (G3); one that can't be overridden, or wasn't confirmed, comes back to the conductor, which has to choose instead of guessing. - **Intake.** When the conductor's Slack and GitHub intake arrives (Slack front end, a later phase), each inbox source is bound to one workstream: Highflame Slack to `highflame`, Sentisum Slack to `sentisum`. A signal is born with its workstream, and its reply worker inherits it. @@ -493,6 +494,33 @@ codeoid new --workstream [--ref [:]]... codeoid workstream move ``` +### 12.1 W3 as built (2026-11-11) + +- **Enforcement switch.** Everything W3 enforces (the fence, recall scoping) is on only with `isolateGlobalServers`, like W1's servers. Without it, episodes are still tagged with `workstream_id` and references are recorded, but nothing is fenced. +- **What is fenced.** Another workstream's folders, not everything outside the home: a blanket deny would refuse toolchains and system headers, and the cross-workstream read is the risk. A workstream's folders are its path rules expanded on disk (`workstreams/folders.ts`: one segment at a time, links followed only where the rule names a segment literally, at most 512) plus its recorded homes' repository tops (never the home, nor a top at or above it). Every one is fenced, nested in the home's or holding it: what the session may read inside one (`WorkstreamFence.readable`: its workdir and repository, its home's folders, exactly what its references grant) is carved out of it (the gate: a path in a foreign folder and in none of those; the sandbox: every other entry on the way to them, `carveOut`, else the folder whole). +- **Sandbox.** The fence's readable folders replace `@parent` as the project roots unless the session's own setting names roots; other workstreams' folders go into the spec's hidden paths (`SpecInputs.extraHidden`), and a root inside one is dropped. A change of fence is a change of directories, applied between turns (`SessionSandbox.dirsPending`). +- **Gate.** `workstreams/gate.ts` judges every tool call's read, search and write paths (secret-paths.ts and held-writes.ts extractors), in every mode; Claude's PreToolUse hook refuses the same calls, since the CLI skips `canUseTool` for some. +- **Memory.** `episodes.workstream_id`, tagged with the home at write time. Recall stays the repository's (`workspace_id`), filtered to the home and references; untagged episodes stay visible. Cross-repository recall over a whole workstream is deferred. +- **`ask` grants.** The owner asking with `session:approve`, undelegated, is the approval. Anyone else (a delegated or send-only token) is asked of the owner on the session, and the verdict is judged again when they answer. +- **Read-from record.** `readFrom` is recorded when a reference is granted (it may be read from then). +- **Monorepos.** A session's repository top is its own only where no other workstream's folder holds it; a recorded home is widened to its repository's top only where no other workstream has a folder inside that top. A granted reference path counts only where it really is (written and real) inside the granting workstream's folders, at grant (resolved then) and at every fence. +- **Turn boundary.** `Session.setWorkstream` saves the new binding at once and applies it at the next turn's start (`#applyPendingWorkstream`); a running turn keeps its gate, servers, env and memory scope together. +- **Homed in another's folder.** `folderVerdict`: a home chosen in, or moved into, a folder another workstream holds (any claim) reads it, judged by that workstream's `share`, and it is added to `readFrom`. A move adds the old home (but the default) to `readFrom`. +- **Carve limit.** A carve past `CARVE_LIMIT` is tried at ten times it, then the folder is hidden whole; one holding the session's workspace can't be hidden whole, and is then left visible to its commands (said in `limits`), the gate still fencing its file tools. +- **Sealed.** A stored binding that can't be read back gives no home and `sealed: true`: what it read from is unknown, so it never moves. +- **Known limits:** a reference with `paths` gives the workstream's whole memory (recall isn't filtered by folder yet); an episode is tagged with the home that wrote it, so what a session read through a reference stays in its home's recall after that workstream closes; the conductor's fleet reads (`fleet_summary`, the dispatch digest) are W4's. +- **Deferred:** `Bash` commands naming another workstream's path in an unsandboxed session (§14 q4) are not yet asked about; fork *into* another workstream; skillDirs. + +### 12.2 W4 as built (2026-11-11) + +- **Fleet reads.** `FleetSessionView.workstream`; `fleet_list`, `fleet_find` and `fleet_recall` take a `workstream` filter and name each result's. `FleetDeps.textWithheld` (the manager: a `closed` workstream, under isolation) drops a closed workstream's episode text from `fleet_find` snippets, `fleet_summary` and `fleet_recall` (counted, not shown) and from the dispatch digest (`buildWorkerDigest`: status only), which is also what `fleet_tasks` shows. +- **No rolling summaries yet.** §10 promises a closed workstream's session cards and summaries; nothing writes session cards today (`memory/cards.ts` has no caller), so a closed workstream gives status and metadata only until cards exist (backlog). +- **Spawns.** `fleet_spawn.workstream`; `FleetDispatchDeps.checkWorkstream` before queueing (`#fleetSpawnHome`: `forCreate` with the folder required under isolation, `folderVerdict`), stored on the task (`dispatch_tasks.workstream`, `workstream_override`) and judged again at claim. The approval card (`ToolWaitingConfirmationState.targetWorkstream`, from `Session` `fleetTargetWorkstream`, the manager's `#fleetCardWorkstream`) names the workstream and warns of the override approving it confirms (G3), or of a refusal. +- **Egress.** `workstreams/egress.ts` `planEgress`: a tested stub (G6) — a message names its workstream and is sent by a worker homed there, never by the conductor; wired when P6's Slack and email egress lands. +- **The conductor's own memory.** Its recall includes its reserved workstream's episodes (it couldn't reach them under isolation before). +- **Phones.** The fleet rail docks beside the chat from `md` up; below, the chat (and its approval card) has the width. The rail's board has no phone view yet (backlog); `fleet_list` and `fleet_tasks` answer in chat. +- **Audit round 1.** Untagged episodes are judged by their session (live, else its stored folder; unknown: withheld while any workstream is closed). A closed workstream's hits are dropped from queries before anything is counted. A worker's digest is withheld from the conductor only (an orchestrator's children share its workstream); the conductor's tenant-wide board withholds others'. A confirmation is a one-time token (round 2): issued when the session owner's own approval (`isOwnApprover`) settles a card that needed one, added to that call's input by the gate (`#withFleetConfirmation`, `confirmation`), and accepted by its handler only for the tenant, workdir, workstream, conflicting workstream, `ask` folders and config revision the card was judged on (`#spendFleetToken`); the revision is stored on the task and checked at claim. A backend that drops the gate's input change gets no token: the spawn is refused. An unnamed spawn is stored without a workstream and judged again at claim by `#fleetSpawnHome` (remote-changed guard included). + ## 13. Phases | Phase | Ships | Enforced by the end | @@ -528,6 +556,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-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/e2e/specs/workstreams-w3.spec.ts b/e2e/specs/workstreams-w3.spec.ts new file mode 100644 index 00000000..709f0ca4 --- /dev/null +++ b/e2e/specs/workstreams-w3.spec.ts @@ -0,0 +1,103 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { expect, openUi, test } from "../harness"; + +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"); + +function configureW3(codeoid: { root: string; workdir: string; configure(p: Record): void }): void { + for (const d of ["pub", "hf", "acme"]) mkdirSync(join(codeoid.root, d), { recursive: true }); + codeoid.configure({ + workstreams: { + isolateGlobalServers: true, + items: [ + { id: "mine", name: "Mine", share: "open", match: { paths: [codeoid.workdir] } }, + { id: "pub", name: "Pub", share: "open", match: { paths: [join(codeoid.root, "pub")] } }, + { id: "acme", name: "Acme", share: "ask", match: { paths: [join(codeoid.root, "acme")] } }, + { id: "hf", name: "HF", share: "closed", match: { paths: [join(codeoid.root, "hf")] } }, + ], + }, + }); +} + +// Workstreams W3 (§5.3, §7): references by each workstream's share policy, +// and moving a session, from the CLI. +test("references follow the share policy; a session moves; a closed workstream is never read", async ({ codeoid }) => { + configureW3(codeoid); + await codeoid.restart(); + + expect(await codeoid.cliOk("new", "r1", codeoid.workdir, "--ref", "pub")).toContain("workstream: Mine (mine)"); + const closed = await codeoid.cli("new", "r2", codeoid.workdir, "--ref", "hf"); + expect(closed.code).not.toBe(0); + expect(closed.output).toContain('"hf" is closed'); + // The owner's own token, undelegated: an `ask` workstream is granted at once. + const asked = await codeoid.cliOk("workstream", "ref", "add", "r1", "acme"); + expect(asked).toContain("reads: pub; acme"); + expect(await codeoid.cliOk("workstream", "ref", "rm", "r1", "pub")).toContain("reads: acme"); + // r1 read from acme (ask): the owner's token is the approval. + expect(await codeoid.cliOk("workstream", "move", "r1", "pub")).toContain("workstream: Pub (pub)"); + await codeoid.restart(); + expect(await codeoid.cliOk("ls")).toContain("workstream: Pub (pub)"); +}); + +// The same in the web UI: the dialog offers what the session may read, the +// header chip opens what it reads and where it lives. +test("the web UI: references in the new-session dialog, the chip's panel at every viewport", async ({ page, codeoid }) => { + mkdirSync(SHOTS, { recursive: true }); + configureW3(codeoid); + await codeoid.restart(); + await openUi(page, codeoid); + + await page.getByRole("button", { name: /new session/ }).click(); + await page.getByRole("textbox", { name: "Name", exact: true }).fill("reader"); + await page.getByRole("textbox", { name: /^Workdir/ }).fill(codeoid.workdir); + await expect(page.getByLabel("Workstream")).toContainText("The folder's: mine", { timeout: 10_000 }); + const reads = page.getByRole("group", { name: "Also reads" }); + await expect(reads).toContainText("Pub"); + await expect(reads).toContainText("Acme (asks)"); + await expect(reads).not.toContainText("HF"); + await reads.getByLabel(/^Pub/).check(); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await reads.scrollIntoViewIfNeeded(); + const box = await reads.boundingBox(); + expect(box && box.x >= 0 && box.x + box.width <= w && box.height > 20).toBe(true); + await page.screenshot({ path: join(SHOTS, `reads-${w}x${h}.png`), fullPage: false }); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await page.getByRole("button", { name: "create", exact: true }).click(); + const header = page.getByRole("heading", { name: "reader", level: 2 }).locator("xpath=.."); + const chip = header.getByTestId("workstream-chip"); + await expect(chip).toContainText("Mine +1"); + // A fresh session's sandbox is no change: nothing said about its directories. + await expect(page.getByText("Sandbox directories applied")).toHaveCount(0); + + await chip.click(); + const panel = page.getByRole("dialog", { name: "Workstream" }); + await expect(panel).toContainText("Pub"); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + // Re-opened at each size: it is placed from the chip's box when it opens. + await page.keyboard.press("Escape"); + await chip.click(); + const box = await panel.boundingBox(); + expect(box && box.x >= 0 && box.x + box.width <= w && box.width > 200 && box.height > 40).toBe(true); + const move = await panel.getByRole("button", { name: "Move", exact: true }).boundingBox(); + expect(move && move.x + move.width <= w).toBe(true); + await page.screenshot({ path: join(SHOTS, `chip-panel-${w}x${h}.png`), fullPage: false }); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await panel.getByLabel("Add a workstream to read").selectOption("acme"); + await panel.getByRole("button", { name: "Add", exact: true }).click(); + await expect(chip).toContainText("Mine +2"); + await panel.getByRole("button", { name: "Remove" }).first().click(); + await expect(chip).toContainText("Mine +1"); +}); diff --git a/e2e/specs/workstreams-w4.spec.ts b/e2e/specs/workstreams-w4.spec.ts new file mode 100644 index 00000000..f49b6797 --- /dev/null +++ b/e2e/specs/workstreams-w4.spec.ts @@ -0,0 +1,67 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { expect, openUi, test } from "../harness"; + +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 W4 (§10): the conductor's send-class card names the workstream +// the action runs in, and warns when approving it confirms an override. +test("the conductor's spawn card names the workstream, and warns of an override", async ({ page, codeoid }) => { + mkdirSync(SHOTS, { recursive: true }); + const acme = join(codeoid.root, "acme"); + mkdirSync(acme, { recursive: true }); + codeoid.configure({ + conductor: { enabled: true, provider: "scripted" }, + workstreams: { + isolateGlobalServers: true, + items: [ + { id: "mine", match: { paths: [codeoid.workdir] } }, + { id: "acme", claim: "exclusive", match: { paths: [acme] } }, + ], + }, + }); + codeoid.setScript([ + { + match: "^spawn it", + steps: [ + { text: "Spawning a worker." }, + { tool: "mcp__codeoid_fleet__fleet_spawn", input: { workdir: acme, task: "look around", workstream: "mine" } }, + { text: "Done." }, + ], + }, + ]); + await codeoid.restart(); + // `conductor` is made on first use; the send leaves its turn waiting on the spawn's card. + await codeoid.cliOk("send", "conductor", "spawn it"); + await openUi(page, codeoid); + await page.getByRole("button", { name: "Conductor", exact: true }).click(); + const line = page.getByTestId("approval-workstream"); + await expect(line).toContainText("workstream mine", { timeout: 15_000 }); + await expect(line).toContainText('Past workstream "acme"'); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + // The bar re-renders on a resize: wait for the line as it is now, never a node swapped out. + await expect(line).toBeVisible(); + for (let i = 0; i < 3; i++) { + try { + await line.scrollIntoViewIfNeeded({ timeout: 2_000 }); + break; + } catch {} + } + await page.screenshot({ path: join(SHOTS, `conductor-card-${w}x${h}.png`), fullPage: false }); + const box = await line.boundingBox(); + // On screen, not only in the DOM: inside the window both ways. + expect([w, h, box && box.x >= 0 && box.x + box.width <= w && box.y >= 0 && box.y + box.height <= h && box.height > 8]).toEqual([w, h, true]); + const approve = await page.getByRole("button", { name: "approve", exact: true }).boundingBox(); + expect([w, h, approve !== null && approve.x + approve.width <= w]).toEqual([w, h, true]); + } +}); 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/packages/protocol/src/approvals.ts b/packages/protocol/src/approvals.ts index 524a6b33..b85ed6a8 100644 --- a/packages/protocol/src/approvals.ts +++ b/packages/protocol/src/approvals.ts @@ -42,6 +42,8 @@ export interface PendingToolApproval { sandboxNote?: string; /** The sandbox lets commands reach the container runtime (Docker access). */ sandboxDocker?: boolean; + /** A send-class fleet call's workstream, and what approving it confirms (workstreams §10; as on the card). */ + targetWorkstream?: { workstream?: string; warning?: string }; } export interface SessionApprovalsResultMsg { 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.test.ts b/packages/protocol/src/schemas.test.ts index 0592839e..90d34e59 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -96,6 +96,9 @@ const samples: { [T in ClientTypes]: Extract } = { "session.set_mode": { type: "session.set_mode", id: "r10", sessionId: "s1", mode: "autonomous", maxTurns: 5 }, "session.set_sandbox": { type: "session.set_sandbox", id: "r10s", sessionId: "s1", sandbox: { provider: "bwrap", network: "allowlist", allowedHosts: ["github.com"] } }, "session.sandbox_dir": { type: "session.sandbox_dir", id: "r10d", sessionId: "s1", action: "add", path: "/home/u/Workspace/b" }, + "session.add_reference": { type: "session.add_reference", id: "r10e", sessionId: "s1", workstream: "acme", paths: ["/w/acme-api"] }, + "session.remove_reference": { type: "session.remove_reference", id: "r10f", sessionId: "s1", workstream: "acme" }, + "session.set_workstream": { type: "session.set_workstream", id: "r10g", sessionId: "s1", workstream: "acme" }, "sandbox.providers": { type: "sandbox.providers", id: "r10p" }, "session.pin": { type: "session.pin", id: "r11", sessionId: "s1", path: "SPEC.md" }, "session.unpin": { type: "session.unpin", id: "r12", sessionId: "s1", path: "SPEC.md" }, @@ -165,6 +168,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 480f4681..1be987aa 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -32,6 +32,15 @@ const base = { id: idField }; /** A workstream id, bounded only: a malformed one must PARSE so the daemon answers `workstream_unknown`. */ const workstreamIdField = z.string().min(1).max(64); +/** A reference asked for (§7.2): absolute folders only, bounded. */ +export const workstreamReferenceInputSchema = z + .object({ + workstream: workstreamIdField, + paths: z.array(z.string().min(1).max(4096)).max(64).optional(), + }) + .strict(); + + // ── Collaboration ───────────────────────────────────────────────────────────── /** @@ -171,6 +180,8 @@ 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(), + references: z.array(workstreamReferenceInputSchema).max(32).optional(), }); export const sessionListSchema = z.object({ ...base, type: z.literal("session.list") }); @@ -287,6 +298,28 @@ export const sessionSetSandboxSchema = z.object({ sandbox: sessionSandboxSettingSchema, }); +export const sessionAddReferenceSchema = z.object({ + ...base, + type: z.literal("session.add_reference"), + sessionId: sessionIdField, + workstream: workstreamIdField, + paths: z.array(z.string().min(1).max(4096)).max(64).optional(), +}); + +export const sessionRemoveReferenceSchema = z.object({ + ...base, + type: z.literal("session.remove_reference"), + sessionId: sessionIdField, + workstream: workstreamIdField, +}); + +export const sessionSetWorkstreamSchema = z.object({ + ...base, + type: z.literal("session.set_workstream"), + sessionId: sessionIdField, + workstream: workstreamIdField, +}); + export const sessionSandboxDirSchema = z.object({ ...base, type: z.literal("session.sandbox_dir"), @@ -737,6 +770,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({ @@ -750,6 +784,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({ @@ -1099,6 +1134,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"), @@ -1298,6 +1356,9 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ sessionSetModeSchema, sessionSetSandboxSchema, sessionSandboxDirSchema, + sessionAddReferenceSchema, + sessionRemoveReferenceSchema, + sessionSetWorkstreamSchema, sandboxProvidersSchema, sessionPinSchema, sessionUnpinSchema, @@ -1334,6 +1395,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 a40364b8..d02e71f6 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"; @@ -1117,6 +1126,14 @@ export interface ToolWaitingConfirmationState { * roll-up could not be computed. */ collaborationCost?: CollaborationCost; + /** + * Present only for a send-class fleet dispatch with workstreams: the + * workstream the action runs in (the target session's, or the spawned + * worker's home), and a warning when the spawn's home is chosen past the + * folder's own exclusive workstream (approving the card confirms it, G3) + * or can't be decided (the spawn will be refused). docs/workstreams-design.md §10. + */ + targetWorkstream?: { workstream?: string; warning?: string }; /** * A shell command's risk, judged from what it runs (#431); for a file-tool * write into what the OS sandbox holds read-only, that reason first @@ -1321,6 +1338,9 @@ export type ClientMessage = | SessionSetModeMsg | SessionSetSandboxMsg | SessionSandboxDirMsg + | SessionAddReferenceMsg + | SessionRemoveReferenceMsg + | SessionSetWorkstreamMsg | SandboxProvidersMsg | SessionPinMsg | SessionUnpinMsg @@ -1357,6 +1377,13 @@ export type ClientMessage = | WorktreeTrustMsg | WorkstreamListMsg | WorkstreamResolveMsg + | WorkstreamUpsertMsg + | WorkstreamDeleteMsg + | WorkstreamSettingsMsg + | CredentialListMsg + | CredentialUpsertMsg + | CredentialDeleteMsg + | CredentialSetValueMsg | SessionTurnsMsg | SessionApprovalsMsg | ClaudeAccountsListMsg @@ -1597,6 +1624,20 @@ 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 }; + /** + * Other workstreams the session may read (docs/workstreams-design.md §7.2): + * their folders read-only and their memory. Each follows the share policy + * of the workstream read: `open` is granted, `ask` needs `session:approve` + * on an undelegated token (the creator's confirm), `closed` is refused. + */ + references?: WorkstreamReferenceInput[]; } /** @@ -1915,6 +1956,45 @@ export interface SessionSetSandboxMsg extends BaseClientMsg { * (`data: { pending: true }`), and lands on a yes. It takes effect at the * session's next turn with no background work running. */ +/** A reference asked for (§7.2): a workstream, and optionally folders inside it (absolute; absent = all of them). */ +export interface WorkstreamReferenceInput { + workstream: string; + paths?: string[]; +} + +/** + * Give a session read access to another workstream (§7.1, §7.2). Owner-only. + * `open`: granted; `ask`: granted at once when the owner asks with + * `session:approve` on an undelegated token, else the owner confirms on the + * session (`response.ok` with `pending`); `closed`: refused. + */ +export interface SessionAddReferenceMsg extends BaseClientMsg { + type: "session.add_reference"; + sessionId: string; + workstream: string; + paths?: string[]; +} + +/** Take a reference away (owner-only). The session still counts as having read from it (§14 q3). */ +export interface SessionRemoveReferenceMsg extends BaseClientMsg { + type: "session.remove_reference"; + sessionId: string; + workstream: string; +} + +/** + * Move a session to another home workstream (§5.3): a share of its whole + * transcript, so it follows the share policy of its old home and of every + * workstream it has read from (`ask` as for a reference; `closed` refuses). + * Past another workstream's exclusive claim on the folder it needs + * `session:approve` on an undelegated token. Applied at the next turn. + */ +export interface SessionSetWorkstreamMsg extends BaseClientMsg { + type: "session.set_workstream"; + sessionId: string; + workstream: string; +} + export interface SessionSandboxDirMsg extends BaseClientMsg { type: "session.sandbox_dir"; sessionId: string; @@ -3274,6 +3354,8 @@ export type DaemonMessage = | WorktreeTrustResultMsg | WorkstreamListResultMsg | WorkstreamResolveResultMsg + | WorkstreamsChangedMsg + | CredentialListResultMsg | SessionTurnsResultMsg | SessionApprovalsResultMsg | ClaudeAccountsResultMsg @@ -3391,6 +3473,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/packages/protocol/src/workstreams.ts b/packages/protocol/src/workstreams.ts index 30178f76..3ef50eca 100644 --- a/packages/protocol/src/workstreams.ts +++ b/packages/protocol/src/workstreams.ts @@ -36,6 +36,10 @@ export interface SessionWorkstreamInfo { matchedBy?: string; /** The home was deleted from the config: the session needs a new one. */ orphaned?: boolean; + /** Other workstreams it may read (§7.2); `paths` absent = all their folders. */ + references?: Array<{ workstream: string; paths?: string[] }>; + /** Every workstream it has read from, for its lifetime (a move follows each one's policy). */ + readFrom?: string[]; } export interface WorkstreamServerInfo { @@ -63,6 +67,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 { @@ -88,7 +94,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) ──────────────────────────────────────────────── @@ -128,3 +147,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/cli.ts b/src/cli.ts index 65dbdd13..d7b398cc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1089,6 +1089,13 @@ 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( + "--ref ", + "Another workstream the session may read: its folders read-only (or only ) and its memory, repeatable. By its share policy: open, ask (needs session:approve), closed (refused).", + (v: string, p: string[] = []) => [...p, v], + [] as string[], + ) .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,9 +1138,22 @@ program projectRoot: string[]; projectRoots?: boolean; workstream?: string; + override?: boolean; + ref: string[]; }, ) => { const config = loadConfig(); + let references: Array<{ workstream: string; paths?: string[] }>; + try { + references = referenceFlags(opts.ref); + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + 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); @@ -1204,11 +1224,32 @@ program collaboration, ...(sandbox ? { sandbox } : {}), ...(opts.workstream ? { workstream: opts.workstream } : {}), + ...(references.length > 0 ? { references } : {}), + 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(); }, ); +/** `--ref acme`, `--ref acme:~/Workspace/acme-api` → references, one per workstream (folders gathered). */ +export function referenceFlags(flags: readonly string[]): Array<{ workstream: string; paths?: string[] }> { + const out = new Map(); + for (const f of flags) { + const at = f.indexOf(":"); + const workstream = (at < 0 ? f : f.slice(0, at)).trim(); + const dir = at < 0 ? "" : f.slice(at + 1).trim(); + if (!workstream) throw new Error(`--ref ${f}: name a workstream (--ref [:])`); + const cur = out.get(workstream) ?? { workstream, whole: false }; + if (!dir) cur.whole = true; + else cur.paths = [...(cur.paths ?? []), dirFlag(dir)]; + out.set(workstream, cur); + } + return [...out.values()].map((r) => (r.whole || !r.paths ? { workstream: r.workstream } : { workstream: r.workstream, paths: r.paths })); +} + /** A directory as the daemon keeps it: `~` left to it (its home), a relative one this shell's. */ function dirFlag(d: string): string { return d === "~" || d.startsWith("~/") || isAbsolute(d) ? d : resolve(process.cwd(), d); @@ -1531,10 +1572,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"); @@ -1694,6 +1739,43 @@ workstreamCmd }); }); +workstreamCmd + .command("move ") + .description("Move a session to another home workstream: its whole conversation goes with it, so the share policy of its old home (and of each workstream it read) decides; applied at its next turn") + .action(async (session: string, workstream: string) => { + const client = new TerminalClient(loadConfig()); + await client.connect(); + try { + await client.sessionWorkstream(session, { kind: "set_workstream", workstream }); + } finally { + client.disconnect(); + } + }); + +workstreamCmd + .command("ref ") + .description("add | rm: let a session read another workstream (its folders read-only, or only , and its memory), or stop") + .action(async (action: string, session: string, target: string) => { + if (action !== "add" && action !== "rm") { + console.error("codeoid workstream ref add|rm [:]"); + process.exit(1); + } + let ref: { workstream: string; paths?: string[] }; + try { + [ref] = referenceFlags([target]) as [{ workstream: string; paths?: string[] }]; + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const client = new TerminalClient(loadConfig()); + await client.connect(); + try { + await client.sessionWorkstream(session, action === "add" ? { kind: "add_reference", ...ref } : { kind: "remove_reference", workstream: ref.workstream }); + } finally { + client.disconnect(); + } + }); + workstreamCmd .command("match [dir]") .description("Which workstream a folder belongs to, and why — what a new session there would get") @@ -1734,6 +1816,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 @@ -1798,7 +1918,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/config.ts b/src/config.ts index 96999108..becd5372 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"; @@ -150,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 []; @@ -168,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 && @@ -1373,6 +1387,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 +1446,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 +1770,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 +2158,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/dispatch.ts b/src/daemon/dispatch.ts index ecb6f535..09578cfa 100644 --- a/src/daemon/dispatch.ts +++ b/src/daemon/dispatch.ts @@ -359,6 +359,10 @@ export class Dispatcher { provider?: string; /** Per-child model, already resolved against `provider`. */ model?: string; + /** A spawn's target workstream (workstreams §10), checked by the caller; and whether its card confirmed an override. */ + workstream?: string; + workstreamOverride?: boolean; + workstreamRevision?: string; createdBy: string; }): string { const isAttempt = input.kind === "attempt"; @@ -385,6 +389,7 @@ export class Dispatcher { "dispatch.enqueued", `task=${id} kind=${input.kind} shape=${input.shape} target=${input.targetSession ?? input.workdir ?? "-"}` + `${input.provider ? ` provider=${input.provider}` : ""}${input.model ? ` model=${input.model}` : ""}` + + `${input.workstream ? ` workstream=${input.workstream}${input.workstreamOverride ? " override" : ""}` : ""}` + `${isAttempt ? ` run=${input.runId} attempt=${input.attemptId}` : ""}`, ); this.#signalBoardChange(); diff --git a/src/daemon/fleet.ts b/src/daemon/fleet.ts index 070c6d0e..c237ac8a 100644 --- a/src/daemon/fleet.ts +++ b/src/daemon/fleet.ts @@ -44,6 +44,8 @@ export interface FleetSessionView { model?: string; attachedClients: number; createdAt: string; + /** Its home workstream (workstreams §10); absent with none configured, or no home. */ + workstream?: string; } /** A dispatch task as the conductor sees it on the fleet_tasks board. */ @@ -74,7 +76,18 @@ export interface FleetDispatchDeps { prompt: string; provider?: string; model?: string; + workstream?: string; + workstreamOverride?: boolean; + workstreamRevision?: string; }): string; + /** + * A spawn's home (workstreams §10, §5): the workstream asked for, else the + * folder's — checked before the task is queued, so a conflict reaches the + * conductor while it can still choose. `override`: past the folder's + * exclusive claim, which the spawn's approval card said (G3). Absent = no + * workstreams on this daemon. + */ + checkWorkstream?(workdir: string, workstream?: string, confirmation?: string): { ok: true; workstream?: string; override?: boolean; revision?: string } | { ok: false; error: string }; /** Interrupt a running session immediately (post-approval). */ interrupt(sessionId: string): Promise; /** Normalize + validate a spawn workdir; null when unusable. */ @@ -122,6 +135,14 @@ export interface FleetDeps { conductorSessionId(): string; /** Send-class dispatch (P4). Absent = read-only conductor. */ dispatch?: FleetDispatchDeps; + /** + * Whether text is withheld from the conductor (workstreams §10: a + * `closed` workstream gives status, never text): judged by the episode's + * workstream, else its session's (an untagged episode, from before + * workstreams), else withheld while any workstream is closed. Absent = + * nothing withheld (no workstreams, or isolation off). + */ + textWithheld?(ref: { workstream?: string; sessionId?: string }): boolean; } /** @@ -189,9 +210,13 @@ function ago(iso: string | number): string { function sessionLine(s: FleetSessionView): string { const marker = s.role === "conductor" ? " [conductor — you]" : ""; const model = s.model ? ` model=${s.model}` : ""; - return `- ${s.name} (${s.id.slice(0, 8)})${marker} — ${s.status}, ${s.attachedClients} client(s), provider=${s.providerId}${model}, workdir=${s.workdir}, created ${ago(s.createdAt)}`; + const ws = s.workstream ? `, workstream=${s.workstream}` : ""; + return `- ${s.name} (${s.id.slice(0, 8)})${marker} — ${s.status}, ${s.attachedClients} client(s), provider=${s.providerId}${model}${ws}, workdir=${s.workdir}, created ${ago(s.createdAt)}`; } +/** What the conductor is told in place of a closed workstream's text. */ +const WITHHELD = (ws: string | undefined) => `(workstream ${ws ?? "?"} is closed: its text isn't shared — status only)`; + /** Resolve a user-supplied session reference (name or id/prefix) to a view. */ function resolveSession( sessions: FleetSessionView[], @@ -211,8 +236,8 @@ function resolveSession( */ export function createFleetHandlers(deps: FleetDeps) { return { - async fleet_list(): Promise { - const sessions = deps.listSessions(); + async fleet_list(args: { workstream?: string } = {}): Promise { + const sessions = deps.listSessions().filter((s) => !args.workstream || s.workstream === args.workstream); deps.audit("fleet.list", `sessions=${sessions.length}`); if (sessions.length === 0) return "No sessions in the fleet."; // Group by workdir so the fleet reads as a machine map, not a flat list. @@ -229,13 +254,14 @@ export function createFleetHandlers(deps: FleetDeps) { return `${sessions.length} session(s) across ${byWorkdir.size} workspace(s):\n\n${blocks.join("\n\n")}`; }, - async fleet_find(args: { query: string; limit?: number }): Promise { + async fleet_find(args: { query: string; limit?: number; workstream?: string }): Promise { deps.audit("fleet.find", `query=${args.query.slice(0, 200)}`); if (!deps.memory) { return "Memory is disabled on this daemon — fleet_find needs the memory engine. Use fleet_list instead."; } const sessions = deps.listSessions(); const sessionNames = new Map(sessions.map((s) => [s.id, s.name])); + const sessionWorkstreams = new Map(sessions.map((s) => [s.id, s.workstream])); const conductorId = deps.conductorSessionId(); const hits = ( await deps.memory.searchSessions({ @@ -245,17 +271,23 @@ export function createFleetHandlers(deps: FleetDeps) { limit: Math.min(args.limit ?? 5, MAX_LIMIT) + 1, sessionNames, }) - ).filter((h) => h.sessionId !== conductorId); + ) + .filter((h) => h.sessionId !== conductorId) + .map((h) => ({ ...h, workstream: sessionWorkstreams.get(h.sessionId) ?? h.snippets.find((sn) => sn.workstreamId)?.workstreamId })) + .filter((h) => !args.workstream || h.workstream === args.workstream) + // A closed workstream's sessions never come back from a query: that they matched is text too (§10). + .filter((h) => !deps.textWithheld?.({ sessionId: h.sessionId, ...(h.workstream ? { workstream: h.workstream } : {}) }) && !h.snippets.some((sn) => deps.textWithheld?.({ sessionId: h.sessionId, ...(sn.workstreamId ? { workstream: sn.workstreamId } : {}) }))); if (hits.length === 0) { return `No session matched "${args.query}". It may predate memory, or try different terms.`; } const lines = hits.slice(0, Math.min(args.limit ?? 5, MAX_LIMIT)).map((h, i) => { const name = sessionNames.get(h.sessionId) ?? "(no longer running)"; + // A closed workstream's snippets are its text: never shown (§10). const evidence = h.snippets .slice(0, 2) .map((sn) => ` · [${sn.kind}] ${sn.summary}`) .join("\n"); - return `${i + 1}. ${name} (${h.sessionId.slice(0, 8)}) — ${h.matchCount} match(es), last activity ${ago(h.lastMatchAt)}\n${evidence}`; + return `${i + 1}. ${name} (${h.sessionId.slice(0, 8)})${h.workstream ? ` [${h.workstream}]` : ""} — ${h.matchCount} match(es), last activity ${ago(h.lastMatchAt)}\n${evidence}`; }); return `Top session(s) for "${args.query}":\n${lines.join("\n")}`; }, @@ -268,12 +300,13 @@ export function createFleetHandlers(deps: FleetDeps) { return `No session matches "${args.session}". Use fleet_list to see the fleet.`; } const head = sessionLine(target); + if (deps.textWithheld?.({ sessionId: target.id, ...(target.workstream ? { workstream: target.workstream } : {}) })) return `${head}\n${WITHHELD(target.workstream)}`; if (!deps.memory) return `${head}\n(no memory engine — activity digest unavailable)`; // Compressed digest: the session's recent episode SUMMARIES (one line // each), never raw scrollback/transcript — the never-OOC guarantee. const episodes = deps.memory .timeline(target.workspaceId, 60) - .filter((e) => e.sessionId === target.id) + .filter((e) => e.sessionId === target.id && !deps.textWithheld?.({ sessionId: e.sessionId, ...(e.workstreamId ? { workstream: e.workstreamId } : {}) })) .slice(0, 12); if (episodes.length === 0) return `${head}\n(no recorded activity yet)`; const lines = episodes.map( @@ -282,24 +315,28 @@ export function createFleetHandlers(deps: FleetDeps) { return `${head}\n\nRecent activity (${episodes.length} episode(s), newest first):\n${lines.join("\n")}`; }, - async fleet_recall(args: { query: string; limit?: number }): Promise { + async fleet_recall(args: { query: string; limit?: number; workstream?: string }): Promise { deps.audit("fleet.recall", `query=${args.query.slice(0, 200)}`); if (!deps.memory) { return "Memory is disabled on this daemon — fleet_recall needs the memory engine."; } const sessionNames = new Map(deps.listSessions().map((s) => [s.id, s.name])); - const hits = await deps.memory.recallGlobal({ - query: args.query, - tenant: deps.tenant, - limit: Math.min(args.limit ?? 6, MAX_LIMIT), - }); + const limit = Math.min(args.limit ?? 6, MAX_LIMIT); + // Asked for more than shown: a closed workstream's episodes are dropped, never shown (§10). + const all = await deps.memory.recallGlobal({ query: args.query, tenant: deps.tenant, limit: limit * 3 }); + const inScope = all.filter((h) => !args.workstream || h.episode.workstreamId === args.workstream); + // Dropped before anything is counted or said: even "N more were withheld" would answer the query. + const hits = inScope + .filter((h) => !deps.textWithheld?.({ sessionId: h.episode.sessionId, ...(h.episode.workstreamId ? { workstream: h.episode.workstreamId } : {}) })) + .slice(0, limit); + const note = ""; if (hits.length === 0) return `Nothing recalled for "${args.query}".`; const lines = hits.map((h) => { const e = h.episode; const name = sessionNames.get(e.sessionId) ?? e.sessionId.slice(0, 8); - return `- [${name}] ${e.kind}${e.toolName ? `/${e.toolName}` : ""}: ${e.summary}`; + return `- [${name}${e.workstreamId ? ` · ${e.workstreamId}` : ""}] ${e.kind}${e.toolName ? `/${e.toolName}` : ""}: ${e.summary}`; }); - return `Recalled ${hits.length} episode(s) across the fleet:\n${lines.join("\n")}`; + return `Recalled ${hits.length} episode(s) across the fleet:\n${lines.join("\n")}${note}`; }, async machine_map(): Promise { @@ -314,7 +351,9 @@ export function createFleetHandlers(deps: FleetDeps) { } const blocks = await Promise.all( [...byWorkdir.entries()].map(async ([workdir, group]) => { - const git = await probeGit(workdir); + // A closed workstream's branches are its text too (feat/acme-acquisition). + const closed = group.some((s) => deps.textWithheld?.({ sessionId: s.id, ...(s.workstream ? { workstream: s.workstream } : {}) })); + const git = closed ? "(a closed workstream's folder)" : await probeGit(workdir); const members = group .map((s) => `${s.name} (${s.status}${s.role === "conductor" ? ", conductor" : ""})`) .join(", "); @@ -350,7 +389,7 @@ export function createFleetHandlers(deps: FleetDeps) { targetSession: target.id, prompt: args.message, }); - return `Queued task ${taskId.slice(0, 8)}: send to ${target.name} (${target.workdir}). Delivery happens on the next dispatcher tick — track it with fleet_tasks.`; + return `Queued task ${taskId.slice(0, 8)}: send to ${target.name} (${target.workdir}${target.workstream ? `, workstream ${target.workstream}` : ""}). Delivery happens on the next dispatcher tick — track it with fleet_tasks.`; }, /** @@ -409,6 +448,9 @@ export function createFleetHandlers(deps: FleetDeps) { shape?: "ship" | "scout"; provider?: string; model?: string; + workstream?: string; + /** Set by codeoid on an approved call whose card confirmed something (workstreams §10); never the model's. */ + confirmation?: string; }): Promise { if (!deps.dispatch) return "Dispatch is disabled on this daemon."; const shape = args.shape ?? "scout"; @@ -425,6 +467,11 @@ export function createFleetHandlers(deps: FleetDeps) { return `Workdir not usable: ${args.workdir} (missing, protected, or outside the allowed root).`; } if (!backend.ok) return backend.error; + // Its home (§10): the workstream named, else the folder's — a conflict is the conductor's to resolve now. + const home: { ok: true; workstream?: string; override?: boolean; revision?: string } | { ok: false; error: string } = deps.dispatch.checkWorkstream + ? deps.dispatch.checkWorkstream(workdir, args.workstream, args.confirmation) + : { ok: true }; + if (!home.ok) return home.error; const taskId = deps.dispatch.enqueue({ kind: "spawn", shape, @@ -432,11 +479,16 @@ export function createFleetHandlers(deps: FleetDeps) { prompt: args.task, provider: backend.provider, model: backend.model, + // Stored only when named: the folder's own is decided again at claim, as for any spawn (its + // remote may have been changed meanwhile: SessionManager #workstreamForFolder). + ...(args.workstream && home.workstream ? { workstream: home.workstream } : {}), + ...(home.override ? { workstreamOverride: true, ...(home.revision ? { workstreamRevision: home.revision } : {}) } : {}), }); const on = backend.provider ? ` on ${backend.provider}${backend.model ? `/${backend.model}` : ""}` : ""; - return `Queued task ${taskId.slice(0, 8)}: spawn ${shape} worker${on} in ${workdir}. You'll receive a digest when it finishes — track it with fleet_tasks.`; + const ws = home.workstream ? `, homed in workstream ${home.workstream}${home.override ? " (past the folder's own, as approved)" : ""}` : ""; + return `Queued task ${taskId.slice(0, 8)}: spawn ${shape} worker${on} in ${workdir}${ws}. You'll receive a digest when it finishes — track it with fleet_tasks.`; }, async fleet_interrupt(args: { session: string }): Promise { @@ -568,9 +620,9 @@ export function buildFleetMcpServer( const registered = pick([ tool( "fleet_list", - "List every session in the fleet, grouped by workspace — names, status, provider, attached clients. Your view of what exists right now.", - {}, - async () => text(await handlers.fleet_list()), + "List every session in the fleet, grouped by workspace — names, status, provider, workstream, attached clients. Your view of what exists right now.", + { workstream: z.string().max(64).optional().describe("Only this workstream's sessions (each result names its workstream)"), }, + async ({ workstream }) => text(await handlers.fleet_list({ ...(workstream ? { workstream } : {}) })), ), tool( "fleet_find", @@ -578,8 +630,9 @@ export function buildFleetMcpServer( { query: z.string().describe("Natural-language description of the work/session to find"), limit: z.number().int().min(1).max(MAX_LIMIT).optional().describe("Max sessions to return (default 5)"), + workstream: z.string().max(64).optional().describe("Only this workstream's sessions (each result names its workstream)"), }, - async ({ query, limit }) => text(await handlers.fleet_find({ query, limit })), + async ({ query, limit, workstream }) => text(await handlers.fleet_find({ query, limit, ...(workstream ? { workstream } : {}) })), ), tool( "fleet_summary", @@ -595,8 +648,9 @@ export function buildFleetMcpServer( { query: z.string().describe("What to recall"), limit: z.number().int().min(1).max(MAX_LIMIT).optional().describe("Max episodes (default 6)"), + workstream: z.string().max(64).optional().describe("Only this workstream's episodes. A closed workstream's are never returned"), }, - async ({ query, limit }) => text(await handlers.fleet_recall({ query, limit })), + async ({ query, limit, workstream }) => text(await handlers.fleet_recall({ query, limit, ...(workstream ? { workstream } : {}) })), ), tool( "machine_map", @@ -654,9 +708,15 @@ export function buildFleetMcpServer( shape: z.enum(["ship", "scout"]).optional().describe("scout = investigate/report (default, read-only identity); ship = deliver a change"), provider: z.string().optional().describe("Backend the worker runs on (e.g. claude, gemini, openai, codex, pi). Omit for the daemon default. An unregistered id is rejected before the task is queued."), model: z.string().optional().describe("Model for the worker, valid for the chosen provider. Omit for that provider's default."), + workstream: z + .string() + .max(64) + .optional() + .describe("The workstream the worker is homed in: it gets that workstream's servers, credentials and memory only. Omit for the folder's own; required where the folder has none. A conflict with the folder's exclusive workstream is shown on the approval card."), + confirmation: z.string().max(64).optional().describe("Set by codeoid when the owner approves; never set it yourself."), }, - async ({ workdir, task, shape, provider, model }) => - text(await handlers.fleet_spawn({ workdir, task, shape, provider, model })), + async ({ workdir, task, shape, provider, model, workstream, confirmation }) => + text(await handlers.fleet_spawn({ workdir, task, shape, provider, model, ...(workstream ? { workstream } : {}), ...(confirmation ? { confirmation } : {}) })), ), tool( "fleet_interrupt", diff --git a/src/daemon/mcp/hub.ts b/src/daemon/mcp/hub.ts index fd34cdbc..9c306f80 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); @@ -309,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 @@ -345,7 +382,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/oauth.ts b/src/daemon/mcp/oauth.ts index f561ec0e..79b137da 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,34 @@ 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)); + } + + /** + * 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 ────────────────────────────────────────────────────────────── @@ -299,7 +320,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 +351,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 +367,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 +387,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 +486,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 +509,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/proxy.ts b/src/daemon/mcp/proxy.ts index 8e768ab0..46cbbe7a 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); @@ -263,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); @@ -321,6 +331,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 +365,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; @@ -417,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; @@ -437,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), @@ -677,14 +693,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..b5396ec8 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 @@ -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 @@ -133,7 +152,9 @@ export function normalizeSpec(name: string, raw: RawMcpServerConfig): McpServerS kind: "http", url: raw.url, headers: raw.headers, - bearerTokenEnv: raw.bearerTokenEnv, + // 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 } : {}), } : { 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 53db47b2..5094c7cc 100644 --- a/src/daemon/mcp/session-view.ts +++ b/src/daemon/mcp/session-view.ts @@ -31,6 +31,9 @@ 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"; @@ -53,6 +56,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. */ @@ -101,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. */ @@ -235,6 +252,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, 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)); } @@ -259,7 +278,32 @@ 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[] { + // 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); + 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 +355,16 @@ 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) { + // 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); @@ -325,7 +374,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 +438,60 @@ 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: 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 && !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; +} + +/** + * 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 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[] = []; + 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) 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, + transport: { ...spec.transport, credential: { ...sel.credential } }, + // The header too: a credential redeclared with another header is another client. + variant: `${spec.variant ?? ""}#cred:${sel.credential.id}:${sel.credential.header ?? ""}`, + }); + 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..cbecdfee 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 @@ -149,7 +156,22 @@ 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 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]] ?? ""); +} + +/** + * 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 name.startsWith("CODEOID_CRED_"); } /** Resolve every value in an env map via {@link resolveEnvValue}. */ 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/memory/engine.ts b/src/daemon/memory/engine.ts index 9853b868..be2665d0 100644 --- a/src/daemon/memory/engine.ts +++ b/src/daemon/memory/engine.ts @@ -13,7 +13,7 @@ import type { Embedder } from "./embedder.js"; import { normalize } from "./embedder.js"; -import type { SqliteEpisodeStore, WorkspaceTenant } from "./store.js"; +import { type SqliteEpisodeStore, type WorkspaceTenant, workstreamReadable } from "./store.js"; import { DEFAULT_WEIGHTS, rank, type RankerWeights } from "./ranker.js"; import type { Reranker } from "./reranker.js"; import { embedWindows, indexView } from "./lossless.js"; @@ -55,6 +55,8 @@ export interface SessionSearchHit { createdAt: number; score: number; filePaths: string[]; + /** The workstream that wrote it (workstreams §6.4); absent when untagged. */ + workstreamId?: string; }>; } @@ -212,8 +214,8 @@ export class MemoryEngine { } /** The verbatim body of an episode in `workspaceId` — not just its index view. */ - getFullContentScoped(id: string, workspaceId: string): string | null { - if (!this.#store.getEpisodeInWorkspace(id, workspaceId)) return null; + getFullContentScoped(id: string, workspaceId: string, workstreams?: readonly string[]): string | null { + if (!this.getEpisodeScoped(id, workspaceId, workstreams)) return null; return this.#store.getFullContent(id); } @@ -228,6 +230,26 @@ export class MemoryEngine { } } + /** Each session's workstream filter (workstreams §6.4), read at every call by the memory tools. */ + #workstreamScopes = new Map readonly string[] | undefined>(); + + /** + * Scope a session's memory tools to the workstreams it may read (its home + * and references), read live; returns the unregister. Every transport's + * tools carry the session id, so this one seam covers them all. + */ + scopeSession(sessionId: string, workstreams: () => readonly string[] | undefined): () => void { + this.#workstreamScopes.set(sessionId, workstreams); + return () => { + if (this.#workstreamScopes.get(sessionId) === workstreams) this.#workstreamScopes.delete(sessionId); + }; + } + + /** The workstreams a session's memory tools may read; undefined = no filter. */ + workstreamsOf(sessionId: string): readonly string[] | undefined { + return this.#workstreamScopes.get(sessionId)?.(); + } + /** Hybrid retrieval. */ async recall(q: RecallQuery): Promise { const limit = q.limit ?? 8; @@ -348,14 +370,16 @@ export class MemoryEngine { } /** Tenant-scoped fetch by id — the only variant safe to expose to a model. */ - getEpisodeScoped(id: string, workspaceId: string): Episode | null { - return this.#store.getEpisodeInWorkspace(id, workspaceId); + getEpisodeScoped(id: string, workspaceId: string, workstreams?: readonly string[]): Episode | null { + const ep = this.#store.getEpisodeInWorkspace(id, workspaceId); + return ep && workstreamReadable(ep.workstreamId, workstreams) ? ep : null; } /** List recent episodes (for the warm-tier index / timeline UI). `offset` - * enables ordered pagination over the full workspace history. */ - timeline(workspaceId: string, limit = 40, offset = 0): Episode[] { - return this.#store.listRecent(workspaceId, limit, offset); + * enables ordered pagination over the full workspace history; `workstreams` + * keeps it to what they may read (RecallQuery.workstreams). */ + timeline(workspaceId: string, limit = 40, offset = 0, workstreams?: readonly string[]): Episode[] { + return this.#store.listRecent(workspaceId, limit, offset, workstreams); } /** @@ -481,6 +505,7 @@ export class MemoryEngine { createdAt: h.episode.createdAt, score: h.score, filePaths: h.episode.filePaths, + ...(h.episode.workstreamId ? { workstreamId: h.episode.workstreamId } : {}), })), }); } diff --git a/src/daemon/memory/store.ts b/src/daemon/memory/store.ts index e302ae26..71b70870 100644 --- a/src/daemon/memory/store.ts +++ b/src/daemon/memory/store.ts @@ -59,6 +59,12 @@ interface EpisodeRow { embedding_model: string | null; created_at: number; created_by: string; + workstream_id?: string | null; +} + +/** Whether an episode's workstream is one `allowed` lets the caller read (RecallQuery.workstreams). */ +export function workstreamReadable(episodeWorkstream: string | undefined, allowed: readonly string[] | undefined): boolean { + return allowed === undefined || episodeWorkstream === undefined || allowed.includes(episodeWorkstream); } /** The tenant a workspace id is scoped to. Every episode query filters by @@ -434,6 +440,11 @@ export class SqliteEpisodeStore { this.#db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('turn_usage') WHERE name = 'cost_source'").get() as { n: number } | undefined )?.n ?? 0; if (!hasCostSource) this.#db.exec("ALTER TABLE turn_usage ADD COLUMN cost_source TEXT"); + // The writing session's home workstream (workstreams §6.4); NULL on episodes from before it. + const hasWorkstream = ( + this.#db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('episodes') WHERE name = 'workstream_id'").get() as { n: number } | undefined + )?.n ?? 0; + if (!hasWorkstream) this.#db.exec("ALTER TABLE episodes ADD COLUMN workstream_id TEXT"); } // ── Writes ──────────────────────────────────────────────────────────── @@ -453,8 +464,8 @@ export class SqliteEpisodeStore { .prepare( `INSERT INTO episodes ( id, workspace_id, session_id, kind, tool_name, summary, content, - file_paths, token_estimate, embedding, embedding_model, created_at, created_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + file_paths, token_estimate, embedding, embedding_model, created_at, created_by, workstream_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( id, @@ -470,6 +481,7 @@ export class SqliteEpisodeStore { episode.embeddingModel ?? null, episode.createdAt, episode.createdBy, + episode.workstreamId ?? null, ); if (episode.fullContent !== undefined && episode.fullContent !== episode.content) { @@ -1008,7 +1020,26 @@ export class SqliteEpisodeStore { return row ? this.#rowToEpisode(row) : null; } - listRecent(workspaceId: string, limit: number, offset = 0): Episode[] { + /** Whether `workspaceId` holds an episode a workstream outside `allowed` wrote (its index would show it). */ + hasOtherWorkstreams(workspaceId: string, allowed: readonly string[]): boolean { + const marks = allowed.map(() => "?").join(", "); + const row = this.#db + .prepare(`SELECT 1 FROM episodes WHERE workspace_id = ? AND workstream_id IS NOT NULL${allowed.length > 0 ? ` AND workstream_id NOT IN (${marks})` : ""} LIMIT 1`) + .get(workspaceId, ...allowed); + return row !== null && row !== undefined; + } + + listRecent(workspaceId: string, limit: number, offset = 0, workstreams?: readonly string[]): Episode[] { + if (workstreams !== undefined) { + // Only what the caller's workstreams may read (RecallQuery.workstreams), paged over that. + const marks = workstreams.map(() => "?").join(", "); + const rows = this.#db + .prepare( + `SELECT * FROM episodes WHERE workspace_id = ? AND (workstream_id IS NULL${workstreams.length > 0 ? ` OR workstream_id IN (${marks})` : ""}) ORDER BY created_at DESC LIMIT ? OFFSET ?`, + ) + .all(workspaceId, ...workstreams, limit, offset) as EpisodeRow[]; + return rows.map((r) => this.#rowToEpisode(r)); + } const rows = this.#db .prepare( "SELECT * FROM episodes WHERE workspace_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", @@ -1580,6 +1611,7 @@ export class SqliteEpisodeStore { embeddingModel: row.embedding_model ?? undefined, createdAt: row.created_at, createdBy: row.created_by, + ...(row.workstream_id ? { workstreamId: row.workstream_id } : {}), }; } } @@ -1624,6 +1656,7 @@ function sanitizeFtsQuery(q: string): string { } function matchesQuery(ep: Episode, q: RecallQuery): boolean { + if (!workstreamReadable(ep.workstreamId, q.workstreams)) return false; if (q.sessionId && ep.sessionId !== q.sessionId) return false; if (q.excludeSessionId && ep.sessionId === q.excludeSessionId) return false; if (q.toolName && ep.toolName !== q.toolName) return false; diff --git a/src/daemon/memory/tools.test.ts b/src/daemon/memory/tools.test.ts index f707d77b..da00f650 100644 --- a/src/daemon/memory/tools.test.ts +++ b/src/daemon/memory/tools.test.ts @@ -135,3 +135,38 @@ describe("memory tool registry", () => { expect(typeof r).toBe("string"); }); }); + +describe("workstream scoping (workstreams §6.4)", () => { + async function scoped() { + const store = new SqliteEpisodeStore(":memory:"); + const engine = new MemoryEngine({ store, embedder: new FakeEmbedder() }); + await engine.init(); + const ids = { + acme: engine.ingest(ep("wsA", "s1", "acme unicorn", "unicorn acme deploy", { workstreamId: "acme" })).id, + hf: engine.ingest(ep("wsA", "s2", "hf unicorn", "unicorn hf deploy", { workstreamId: "hf" })).id, + legacy: engine.ingest(ep("wsA", "s3", "old unicorn", "unicorn old deploy")).id, + }; + await engine.drain(); + return { engine, ids }; + } + + test("episodes keep their workstream; a scoped session reads its home's and the untagged, never another's", async () => { + const { engine, ids } = await scoped(); + expect(engine.getEpisodeScoped(ids.acme, "wsA")?.workstreamId).toBe("acme"); + const off = engine.scopeSession("me", () => ["acme"]); + const ctx: MemoryToolContext = { engine, workspaceId: "wsA", sessionId: "me" }; + const recall = await defs().recall!.run({ query: "unicorn deploy", limit: 10 }, ctx); + expect(recall).toContain("acme unicorn"); + expect(recall).toContain("old unicorn"); + expect(recall).not.toContain("hf unicorn"); + const timeline = await defs().timeline!.run({ limit: 10 }, ctx); + expect(timeline).not.toContain("hf unicorn"); + expect(timeline).toContain("acme unicorn"); + expect(await defs().get_episode!.run({ episode_id: ids.hf }, ctx)).toContain("not found"); + // A reference widens it; unscoped (isolation off), everything as before. + off(); + engine.scopeSession("me", () => ["acme", "hf"]); + expect(await defs().recall!.run({ query: "unicorn deploy", limit: 10 }, ctx)).toContain("hf unicorn"); + expect(await defs().recall!.run({ query: "unicorn deploy", limit: 10 }, { ...ctx, sessionId: "other" })).toContain("hf unicorn"); + }); +}); diff --git a/src/daemon/memory/tools.ts b/src/daemon/memory/tools.ts index 1b9c7648..2f014467 100644 --- a/src/daemon/memory/tools.ts +++ b/src/daemon/memory/tools.ts @@ -25,6 +25,11 @@ export interface MemoryToolContext { workspaceId: string; /** Excluded from recall by default so the caller doesn't recall its own turns. */ sessionId: string; + /** + * The workstreams the calling session may read (workstreams §6.4: its home + * and references), read at each call; undefined = no filter (isolation off). + */ + workstreams?: () => readonly string[] | undefined; } export interface MemoryToolDef { @@ -58,6 +63,12 @@ const clampInt = (v: unknown, min: number, max: number, dflt: number): number => return Math.min(max, Math.max(min, Math.floor(n))); }; +/** The caller's workstream filter for a query (MemoryToolContext.workstreams). */ +function readable(ctx: MemoryToolContext): { workstreams?: readonly string[] } { + const w = ctx.workstreams ? ctx.workstreams() : ctx.engine.workstreamsOf(ctx.sessionId); + return w === undefined ? {} : { workstreams: w }; +} + /** The four defs. A function (not a const) so each call gets fresh zod objects. */ export function memoryToolDefs(): MemoryToolDef[] { const recall: MemoryToolDef = { @@ -92,6 +103,7 @@ export function memoryToolDefs(): MemoryToolDef[] { limit: clampInt(args.limit, 1, 20, 6), toolName: str(args.tool_name), excludeSessionId: includeCurrent ? undefined : ctx.sessionId, + ...readable(ctx), }); return formatHits(hits, ctx.sessionId, includeCurrent); }, @@ -117,6 +129,7 @@ export function memoryToolDefs(): MemoryToolDef[] { workspaceId: ctx.workspaceId, filePaths: [path], limit: 3, + ...readable(ctx), }); if (hits.length === 0) return `No prior reads of ${path} in this workspace.`; return `Found ${hits.length} prior read(s) of ${path}:\n\n${formatHits(hits, ctx.sessionId, true)}`; @@ -143,7 +156,7 @@ export function memoryToolDefs(): MemoryToolDef[] { async run(args, ctx) { const limit = clampInt(args.limit, 1, 60, 20); const offset = clampInt(args.offset, 0, Number.MAX_SAFE_INTEGER, 0); - const episodes = ctx.engine.timeline(ctx.workspaceId, limit, offset); + const episodes = ctx.engine.timeline(ctx.workspaceId, limit, offset, readable(ctx).workstreams); return formatTimeline(episodes, ctx.sessionId, offset); }, }; @@ -185,9 +198,10 @@ export function memoryToolDefs(): MemoryToolDef[] { }, async run(args, ctx) { const id = String(args.episode_id ?? ""); - const ep = ctx.engine.getEpisodeScoped(id, ctx.workspaceId); + const { workstreams } = readable(ctx); + const ep = ctx.engine.getEpisodeScoped(id, ctx.workspaceId, workstreams); if (!ep) return `Episode ${id} not found in this workspace.`; - const full = ctx.engine.getFullContentScoped(id, ctx.workspaceId) ?? ep.content; + const full = ctx.engine.getFullContentScoped(id, ctx.workspaceId, workstreams) ?? ep.content; const offset = clampInt(args.offset, 0, Number.MAX_SAFE_INTEGER, 0); const maxChars = clampInt(args.max_chars, 1000, GET_EPISODE_MAX_PAGE, GET_EPISODE_DEFAULT_PAGE); return formatEpisode({ ...ep, content: full }, ctx.sessionId, { offset, maxChars }); diff --git a/src/daemon/memory/types.ts b/src/daemon/memory/types.ts index f2ea65f0..b88b497e 100644 --- a/src/daemon/memory/types.ts +++ b/src/daemon/memory/types.ts @@ -34,6 +34,8 @@ export interface Episode { embeddingModel?: string; createdAt: number; createdBy: string; + /** The home workstream of the session that wrote it (workstreams §6.4); absent before workstreams, or with none. */ + workstreamId?: string; } /** Result of a recall query. */ @@ -67,6 +69,12 @@ export interface RecallQuery { before?: number; /** Exclude episodes older than this (unix ms). */ after?: number; + /** + * The workstreams the caller may read (workstreams §6.4: its home and its + * references). Set: an episode another workstream wrote is left out; + * one with none (from before workstreams) stays. Absent: no filter. + */ + workstreams?: readonly string[]; } /** File read cache entry — enables silent reuse across sessions in a workspace. */ diff --git a/src/daemon/providers/acp/index.ts b/src/daemon/providers/acp/index.ts index 14ae698b..d00ffb3d 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, ); } @@ -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", ...allowedMcpNames(names)]; + } + #push(event: ProviderEvent): void { const queue = this.#turnQueue; if (!queue || queue.closed) return; @@ -659,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/claude/index.ts b/src/daemon/providers/claude/index.ts index 683ca7cd..443cd23c 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -165,6 +165,8 @@ export interface ClaudeProviderInit { onForeignAgentDenied?: (agentId: string, toolName: string) => void; /** The session is in autonomous mode now (ProviderSessionInit.isAutonomous); absent: treated as so. */ isAutonomous?: () => boolean; + /** The workstream fence's refusal of a call (ProviderSessionInit.workstreamFenceRefusal). */ + workstreamFenceRefusal?: (toolName: string, input: unknown, cwd: string | null | undefined) => string | null; /** The session's OS sandbox while on (ProviderSessionInit.sandbox, #448). */ sandbox?: () => SandboxRuntime | null; /** codeoid session id — used as the initial backing id and for audit/logging. */ @@ -1044,7 +1046,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 +1067,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. - ...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. @@ -1340,6 +1349,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). @@ -1401,6 +1413,19 @@ export class ClaudeProvider implements SessionProvider { // Fail-closed too (#secretPathDecision). const secret = this.#secretPathDecision(input, opts.workdir); if (secret) return secret; + // The workstream fence (workstreams §7.3): a call in another + // workstream's folders is refused here, before any allow rule. + // Fail-closed: a throw refuses. + if (init.workstreamFenceRefusal) { + let fenced: string | null; + try { + fenced = init.workstreamFenceRefusal(input.tool_name, input.tool_input, hookShellCwd(input)); + } catch (err) { + console.error(`[claude-provider ${this.#init.sessionId.slice(0, 8)}] checking a call against the workstream fence failed, refusing it: ${err instanceof Error ? err.message : String(err)}`); + fenced = "codeoid couldn't check this call against the session's workstream, so it was refused."; + } + if (fenced) return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: fenced } }; + } // A shell command is judged by what it runs (#431): a risky one, // one the gate can't read, or one naming a credential file never // runs on an allow rule — it goes to canUseTool, which decides. @@ -2662,6 +2687,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 c8a4a8e9..00d3ffb6 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,25 @@ 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 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) && s.transport.kind === "http") : []; 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, + // 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 8f33a120..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"; @@ -501,7 +502,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, ); } @@ -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..759a640e --- /dev/null +++ b/src/daemon/providers/codex/isolation.test.ts @@ -0,0 +1,86 @@ +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, tomlStructureProblem } 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'); + 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", "-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", "-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", () => { + 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"]); + }); + + 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); + }); + + 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 new file mode 100644 index 00000000..656d0762 --- /dev/null +++ b/src/daemon/providers/codex/isolation.ts @@ -0,0 +1,207 @@ +/** + * 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 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 + * 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 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_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`); + } + 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`: system layers, its home's, a project's. */ +export function codexConfigFiles(codexHome: string, workdir: string): string[] { + 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")); + 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(); + 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, 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`); + } + serverNames(doc, declared); + } + const args: string[] = []; + for (const [name, kind] of [...declared].sort(([a], [b]) => a.localeCompare(b))) { + 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`); + } + 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`, + ); + } + // 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; +} + +/** 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; +} + +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; +} 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/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..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"; @@ -169,7 +170,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, ); } @@ -503,6 +504,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: allowedMcpNames(Object.keys(mcpServers)) } + : {}), ...(desiredAppend ? { systemPrompt: { diff --git a/src/daemon/providers/registry.ts b/src/daemon/providers/registry.ts index caa22ffe..14465baa 100644 --- a/src/daemon/providers/registry.ts +++ b/src/daemon/providers/registry.ts @@ -149,6 +149,13 @@ export interface ProviderSessionInit { * Absent: treated as autonomous (routed). */ isAutonomous?: () => boolean; + /** + * The workstream fence's refusal of a tool call (workstreams/gate.ts), or + * null. Claude only: its PreToolUse hook refuses such a call itself, since + * the CLI runs some calls (reads in the workdir, allow rules) without + * asking the gate. Throws fail closed (the hook refuses). + */ + workstreamFenceRefusal?: (toolName: string, input: unknown, cwd: string | null | undefined) => string | null; /** * The session's OS sandbox while it is on (#448, sandbox/session-sandbox.ts), * read each time the backend is started: null when it is off, unavailable, @@ -474,6 +481,7 @@ export function createDefaultProviderRegistry( isOwnTask: init.isOwnTask, onForeignAgentDenied: init.onForeignAgentDenied, ...(init.isAutonomous ? { isAutonomous: init.isAutonomous } : {}), + ...(init.workstreamFenceRefusal ? { workstreamFenceRefusal: init.workstreamFenceRefusal } : {}), sandbox: init.sandbox, }), }); diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts index 5682ea5c..746820b3 100644 --- a/src/daemon/sandbox/policy.ts +++ b/src/daemon/sandbox/policy.ts @@ -405,6 +405,12 @@ function within(child: string, parent: string): boolean { export interface SpecInputs { sessionId: string; + /** + * More paths hidden for this session alone: other workstreams' folders + * under its read fence (workstreams §7.3). Never the workspace or what + * holds it (SessionSandbox leaves those out). + */ + extraHidden?: readonly string[]; workspace: string; /** * The session's cwd, when it is below `workspace` (a monorepo's package, @@ -1204,7 +1210,11 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { const baseHidden = hiddenPathsFor(inputs.config, inputs.hidden); // What the hidden list hides, the home aside: no root or added dir is inside it (validated; again here). const inHidden = (p: string) => [...baseHidden, ...(root ? [root] : [])].some((h) => within(p, h) || within(realOr(p), realOr(h))); - const addedKept = added.filter((a) => !inHidden(a.dir) && !userHidden.some((u) => within(a.dir, u))); + // An added dir inside another workstream's folder isn't kept either: adding it can't open the fence. + const extraHidden = (inputs.extraHidden ?? []).map((h) => resolve(h)); + const addedKept = added.filter( + (a) => !inHidden(a.dir) && !userHidden.some((u) => within(a.dir, u)) && !extraHidden.some((h) => within(a.dir, h) || within(realOr(a.dir), realOr(h))), + ); // The session's own: its workspace and the dirs its owner added (their credentials are its to use, as the // workspace's are). A dir the agent added itself (autonomous `request_directory`) keeps the hiding a root has. // Its repository's git dir too (a fork's worktree uses its main checkout's): git there reads its config. @@ -1219,7 +1229,15 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { if (record) refuse(pinProblem(r, "project root", { stored: record })); return dirIdentity(r) !== null ? r : realOr(r); }) - .filter((r, i, all) => dirIdentity(r) !== null && all.indexOf(r) === i && !userHidden.some((u) => within(r, u)) && !inHidden(r)); + .filter( + (r, i, all) => + dirIdentity(r) !== null && + all.indexOf(r) === i && + !userHidden.some((u) => within(r, u)) && + !inHidden(r) && + // A root inside another workstream's folder: the session's own setting can't show it past its fence. + !(inputs.extraHidden ?? []).some((h) => within(r, resolve(h)) || within(realOr(r), realOr(resolve(h)))), + ); // The credential files anywhere in a root and in an agent's own added dir (not the session's own); a root or such // a dir too big to look through whole isn't shown at all, nor written (fail closed: resolveSandbox says so). const scanned = [...rootCandidates, ...agentDirs]; @@ -1249,6 +1267,7 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { ...rootSecrets.hidden, ...locationSecrets.hidden, ...(root ? [root] : []), + ...(inputs.extraHidden ?? []).map((p) => resolve(p)), ]; // The session's worktree folders: writable, but never inside something hidden (a root configured in a hidden // path), and, unlike the backend's own state, never taking a hidden path inside them out of the hidden list. diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts index c6e197f6..0f7109d5 100644 --- a/src/daemon/sandbox/session-sandbox.ts +++ b/src/daemon/sandbox/session-sandbox.ts @@ -167,6 +167,12 @@ export interface EffectiveSandbox { agentDirs: string[]; } +/** A session's workstream fence as the sandbox takes it: the roots shown read-only, the folders hidden. */ +export interface WorkstreamDirs { + roots: string[]; + hidden: string[]; +} + /** The setting a session ends up with: its own fields over the config's. */ export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxSetting | undefined): EffectiveSandbox { const choice = setting?.provider ?? config.provider; @@ -596,6 +602,31 @@ export class SessionSandbox { readonly #onWarning: ((text: string) => void) | undefined; readonly #onWorktreeEntries: ((common: string, findings: WorktreeEntryFinding[]) => void) | undefined; readonly #name: () => string; + readonly #workstreamDirs: (() => WorkstreamDirs | undefined) | undefined; + /** The fence the last resolution was made with, to see when it changed. */ + #workstreamKeyApplied = ""; + + /** The fence now, with what would hide the workspace or an added dir left out (those are the session's own). */ + #workstreamNow(): WorkstreamDirs | undefined { + const wd = this.#workstreamDirs?.(); + if (!wd) return undefined; + const workspace = this.#workspace(); + const holds = (h: string, p: string) => h === "/" || p === h || p.startsWith(`${h}/`); + // An added dir doesn't un-hide one: one inside a hidden folder is dropped (policy.ts), one holding it keeps it hidden. + const hidden = wd.hidden.filter((h) => !holds(h, workspace) && !holds(h, this.#workdir)); + return { roots: wd.roots, hidden }; + } + + #workstreamKey(): string { + const wd = this.#workstreamNow(); + return wd ? JSON.stringify(wd) : ""; + } + + /** The setting with the fence's folders as its project roots, when it names none of its own. */ + #withWorkstream(setting: SessionSandboxSetting | undefined, wd: WorkstreamDirs | undefined = this.#workstreamNow()): SessionSandboxSetting | undefined { + if (!wd || setting?.projectRoots !== undefined) return setting; + return { ...(setting ?? {}), projectRoots: wd.roots }; + } constructor(opts: { sessionId: string; @@ -609,7 +640,15 @@ export class SessionSandbox { onWarning?: (text: string) => void; /** What a look at the repository's worktree entries found (checkWorktreeEntries): said on the session and audited. */ onWorktreeEntries?: (common: string, findings: WorktreeEntryFinding[]) => void; + /** + * The session's workstream fence (workstreams §7.3), read live: its + * readable folders replace the default project roots (never a root the + * session's own setting names), and other workstreams' are hidden. + * Undefined result = no fence. + */ + workstreamDirs?: () => WorkstreamDirs | undefined; }) { + this.#workstreamDirs = opts.workstreamDirs; this.#onWarning = opts.onWarning; this.#onWorktreeEntries = opts.onWorktreeEntries; this.#sessionId = opts.sessionId; @@ -637,7 +676,7 @@ export class SessionSandbox { /** What `setting` means with the config filled in. */ effective(setting: SessionSandboxSetting | undefined = this.#setting): EffectiveSandbox { - return effectiveSetting(this.config, setting); + return effectiveSetting(this.config, this.#withWorkstream(setting)); } /** The last resolution, or null before the first. */ @@ -795,7 +834,10 @@ export class SessionSandbox { * rebuilt mid-work would end it; a process backend is restarted). */ get dirsPending(): boolean { - return this.#dirsStale || this.#pinsBroken; + // The fence changed since the last resolution (a reference, a move, a folder made): none before the + // first, which is no change of anything (it resolves with the fence as it is then). + const fenceChanged = this.#workstreamDirs !== undefined && this.#info !== null && this.#workstreamKey() !== this.#workstreamKeyApplied; + return this.#dirsStale || this.#pinsBroken || fenceChanged; } /** The added directories the running sandbox has (SandboxSpec.addedDirs as resolved): what host-side file tools are held to. */ @@ -854,13 +896,15 @@ export class SessionSandbox { async #refreshNow(backendId: string): Promise { let r: Awaited>; + let wsKey = ""; for (;;) { const generation = this.#generation; this.#pinWorkspace(); + wsKey = this.#workstreamKey(); r = await resolveSandbox({ registry: this.#registry, config: this.config, - setting: this.#setting, + setting: this.#withWorkstream(this.#setting), backendId, workspace: this.#workdir, top: this.#workspace(), @@ -880,6 +924,7 @@ export class SessionSandbox { this.#resolvedFor = backendId; this.#stale = false; this.#dirsStale = false; + this.#workstreamKeyApplied = wsKey; const dirs = { roots: [...(r.info.projectRoots ?? [])], added: [...(r.info.addedDirs ?? [])], ...(r.rootScan ? { scan: r.rootScan } : {}) }; this.#dirs = dirs; this.#droppedDirs = r.droppedDirs ?? []; @@ -1075,6 +1120,8 @@ export class SessionSandbox { for (const d of dirs.added) this.#checkEntriesOf(d); } const agent = new Set(this.effective().agentDirs); + // Other workstreams' folders, as at the last resolution (a change since is a dirs change, applied between turns). + const hiddenForWorkstream = this.#workstreamKeyApplied ? (JSON.parse(this.#workstreamKeyApplied) as WorkstreamDirs).hidden : []; const build = () => buildSandboxSpec({ backendReadable: readable, sessionId: this.#sessionId, @@ -1088,6 +1135,7 @@ export class SessionSandbox { dockerSocket, hideLocalServices, projectRoots: dirs.roots, + ...(hiddenForWorkstream.length > 0 ? { extraHidden: hiddenForWorkstream } : {}), addedDirs: dirs.added.map((dir) => { const guard = this.#guardFor(dir); return { dir, ...(agent.has(dir) ? { agent: true } : {}), ...(folder && guard ? { ownWorktreeEntries: guard.ownEntries() } : {}) }; diff --git a/src/daemon/secret-paths.ts b/src/daemon/secret-paths.ts index d87c2137..2edcba8e 100644 --- a/src/daemon/secret-paths.ts +++ b/src/daemon/secret-paths.ts @@ -68,7 +68,9 @@ const PATH_LIST_KEYS = ["paths", "file_paths", "absolute_paths", "include", "dir /** Tools whose `pattern` is a path glob (not a regex) and whose absent root means the workdir. */ const GLOB_TOOLS = new Set(["Glob", "glob"]); /** Search tools: an absent root means the workdir. */ -const SEARCH_TOOLS = new Set(["Glob", "glob", "Grep", "grep", "grep_search", "search_file_content", "LS", "list_directory"]); +const SEARCH_TOOLS = new Set(["Glob", "glob", "Grep", "grep", "grep_search", "search_file_content", "LS", "list_directory", "find", "ls", "acp_search"]); +/** pi's file tools: a path's leading `@` is dropped, as pi drops it. */ +const PI_PATH_TOOLS = new Set(["read", "find", "ls", "grep", "write", "edit"]); /** Files in codeoid's config dir that hold credentials. */ function isSecretConfigFile(name: string): boolean { @@ -247,6 +249,25 @@ function within(child: string, parent: string): boolean { return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } +/** + * The paths a tool call reads or searches, as written (relative ones from its + * cwd), backend-neutral: also what the workstream fence judges (workstreams/gate.ts). + */ +export function toolReadPaths(toolName: string, input: unknown): string[] { + return candidatePaths(toolName, input); +} + +/** Whether a tool searches (an absent root: the workdir): one whose root holds a fenced folder reads into it. */ +export function isSearchTool(toolName: string): boolean { + return SEARCH_TOOLS.has(toolName); +} + +/** A tool's path made absolute from `cwd`, as the kernel resolves it, and its real path: the forms a check judges. */ +export function toolPathForms(p: string, cwd: string): string[] { + const literal = expand(p, cwd); + return [...new Set([literal, real(literal)])]; +} + function candidatePaths(toolName: string, input: unknown): string[] { if (!input || typeof input !== "object") return []; const obj = input as Record; @@ -256,12 +277,29 @@ function candidatePaths(toolName: string, input: unknown): string[] { const v = obj[k]; if (Array.isArray(v)) for (const p of v) if (typeof p === "string" && p) out.push(p); } + // ACP's `locations` (acp/index.ts acpToolInput): paths, or `{path}` as the agent sent them. + if (Array.isArray(obj.locations)) { + for (const l of obj.locations) { + const p = typeof l === "string" ? l : l && typeof l === "object" ? (l as { path?: unknown }).path : undefined; + if (typeof p === "string" && p) out.push(p); + } + } + if (PI_PATH_TOOLS.has(toolName)) { + // Only before an absolute or `~` path: another backend's relative `@x` (a file so named) stays as written. + for (const [i, p] of out.entries()) if (p.startsWith("@/") || p.startsWith("@~")) out[i] = p.slice(1); + } // A glob's pattern can be absolute (`/home/u/.codeoid/*.json`): judge its // literal prefix, up to the first wildcard. Only for glob tools — Grep's // `pattern` is a regex, and searching code for "/proc/self/environ" is fine. if (GLOB_TOOLS.has(toolName) && typeof obj.pattern === "string" && (isAbsolute(obj.pattern) || obj.pattern.startsWith("~"))) { const literal = obj.pattern.split(/[*?[{]/, 1)[0] ?? ""; - if (literal) out.push(literal.endsWith("/") ? literal.slice(0, -1) || "/" : literal); + if (literal.endsWith("/")) out.push(literal.slice(0, -1) || "/"); + else if (literal) { + // A wildcard inside the last segment (`/w/h?/*`, `~/.codeoid*`): judged where it starts (a credential + // dir it names is refused, as before) and at the dir it searches (`/w`: any `h?` there). + out.push(literal); + if (obj.pattern !== literal) out.push(dirname(literal)); + } } // A search with no root searches the workdir. if (out.length === 0 && SEARCH_TOOLS.has(toolName)) out.push("."); 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 9e243570..8e4d6c3e 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -9,13 +9,21 @@ */ import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js"; -import { WorkstreamService, type WorkstreamBinding } from "./workstreams/service.js"; +import { WorkstreamService, type WorkstreamBinding, type WorkstreamFence, type WorkstreamReference } from "./workstreams/service.js"; +import { READ_FROM_LIMIT, REFERENCE_LIMIT, readWorkstreamBinding, referencePathsProblem } from "./workstreams/binding.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, 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"; +import { existsSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; -import { type ForkPoint, RewindError, type SendHold, SendStoppedError, SHUTDOWN_AUTH, Session, type AttachedClient, type WindowScope } from "./session.js"; +import { type FleetCardTarget, type ForkPoint, RewindError, type SendHold, SendStoppedError, SHUTDOWN_AUTH, Session, type AttachedClient, type WindowScope } from "./session.js"; import type { CanonicalTurn } from "./providers/canonical.js"; import { sweepCheckpoints } from "./checkpoints.js"; import type { CompareRunRow } from "./store.js"; @@ -32,7 +40,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"; @@ -149,9 +157,9 @@ 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 { endpointNameOf, endpointProviderId, FLEET_TOOL_PREFIX, ORCHESTRATOR_ROLE, type ProviderInfo } from "../protocol/types.js"; import type { AuthContext, ClaudeConfigMcpServer, @@ -578,6 +586,10 @@ 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; + /** 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). @@ -724,6 +736,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; @@ -733,7 +747,16 @@ 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); + 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 + // 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; @@ -1180,6 +1203,12 @@ mcpHub: this.#mcpHub, // Conductor gets the tenant-wide surface; a collaboration // orchestrator gets the role-aware one scoped to its own children. // Its id is already known here, so the thunk is trivial. + ...(meta.role === "conductor" + ? { + fleetTargetWorkstream: this.#fleetCardWorkstream(meta.accountId, meta.projectId), + fleetConfirm: (target: FleetCardTarget) => this.#issueFleetToken({ accountId: meta.accountId, projectId: meta.projectId }, target), + } + : {}), fleet: meta.role === "conductor" ? this.#buildFleetServer(meta.accountId, meta.projectId) @@ -1525,6 +1554,10 @@ mcpHub: this.#mcpHub, return this.#loopWorkerGuard(msg, "change the sandbox of") ?? this.#setSandbox(msg, auth); case "session.sandbox_dir": return this.#loopWorkerGuard(msg, "change the sandbox of") ?? this.#sandboxDir(msg, auth); + case "session.add_reference": + case "session.remove_reference": + case "session.set_workstream": + return this.#loopWorkerGuard(msg, "change the workstream of") ?? this.#sessionWorkstreamEdit(msg, auth); case "sandbox.providers": return this.#sandboxProviders(msg, auth); case "session.pin": @@ -1643,6 +1676,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": @@ -1965,7 +2008,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, @@ -2290,9 +2333,56 @@ 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?: (b: WorkstreamBinding | undefined) => Readonly>; + mcpGrant: (b: WorkstreamBinding | undefined) => McpGrant | undefined; + workstreamFence: (b: WorkstreamBinding | undefined, own: readonly string[]) => WorkstreamFence | undefined; + } { + 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, + // Each read live, for the session's binding as it is then: an edit to + // the workstream or a credential, or a move to another (§5.3), reaches + // the session's next backend start (its next turn), never only a restart. + ...(this.#workstreams.configured || binding ? { workstreamEnv: (b: WorkstreamBinding | undefined) => (b?.id ? this.#workstreams.envFor(b, this.#secrets).env : {}) } : {}), + // The grant follows the config as it is at each listing. + mcpGrant: (b) => this.#workstreams.grantFor(b), + workstreamFence: (b, own) => this.#workstreamFence(b, own), + }; + } + + /** Fences by binding and config revision, briefly: the gate reads one on every file tool call. */ + #fenceCache = new Map(); + + /** + * A session's read fence (workstreams §7.3), or undefined when isolation is + * off. Recorded home folders count as their workstream's (a remote rule + * names no path), as repository tops. Cached 5s: folders made meanwhile + * (a new clone) are fenced within that. + */ + #workstreamFence(binding: WorkstreamBinding | undefined, own: readonly string[] = []): WorkstreamFence | undefined { + if (!this.#workstreams.configured || !this.#workstreams.isolateGlobalServers) return undefined; + const key = `${this.#workstreams.revision}\u0000${JSON.stringify(binding ?? null)}\u0000${own.join("\u0000")}`; + const now = Date.now(); + const hit = this.#fenceCache.get(key); + if (hit && now - hit.at < 5_000) return hit.fence; + const recorded = this.#store.workstreamFoldersByWorkstream(); + const fence = this.#workstreams.fenceFor(binding, (id) => recorded.get(id) ?? [], own); + if (this.#fenceCache.size > 256) this.#fenceCache.clear(); + this.#fenceCache.set(key, { at: now, fence }); + return fence; } /** @@ -2300,10 +2390,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; @@ -3382,6 +3482,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 @@ -3525,6 +3771,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); @@ -3536,18 +3783,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", @@ -3597,7 +3851,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); @@ -4214,6 +4468,15 @@ 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); + 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; } @@ -4507,7 +4770,21 @@ 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); + // 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" }; + } + // 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; + } + 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 @@ -4524,6 +4801,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 && hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) && auth.delegationDepth === 0 + ? { + ...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, @@ -4532,11 +4824,73 @@ 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! }, + }; } + // 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 !== 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 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: own.candidates, ambiguous: [before, folderMatch].sort() } }, + }; + } + } for (const w of homing.warnings) warn(w); - const createWorkstream = homing.binding; + let createWorkstream = homing.binding; + // A home chosen in another workstream's folder reads that folder (its workdir stays readable to it): + // judged by that workstream's share, as a reference is, and counted as read from. + if (createWorkstream?.id) { + const fv = this.#workstreams.folderVerdict(workdir, createWorkstream.id, givenWorkdir); + if (!fv.ok) return { type: "response.error", requestId: msg.id, error: fv.error, code: "forbidden" }; + if (fv.ask.length > 0 && !(hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) && auth.delegationDepth === 0)) { + return { type: "response.error", requestId: msg.id, error: `This folder is workstream "${fv.folder}"'s, which shares only with approval: a session of another workstream here needs session:approve on an undelegated token`, code: "forbidden" }; + } + if (fv.folder) createWorkstream = { ...createWorkstream, readFrom: [...new Set([...(createWorkstream.readFrom ?? []), fv.folder])] }; + } + // References asked at create (§7.2): each by the share policy of the + // workstream read. The creator asking with session:approve, undelegated, + // is the confirm an `ask` workstream wants; anyone else gets `open` only. + if (msg.references && msg.references.length > 0) { + if (!this.#workstreams.configured || !createWorkstream) { + return { type: "response.error", requestId: msg.id, error: "No workstreams are configured: a session can't reference one", code: "invalid_request" }; + } + const approves = hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) && auth.delegationDepth === 0; + const recorded = this.#store.workstreamFoldersByWorkstream(); + const refs: WorkstreamReference[] = []; + for (const asked of msg.references) { + const paths = asked.paths?.map(referenceFolder); + const pathProblem = referencePathsProblem(paths); + if (pathProblem) return { type: "response.error", requestId: msg.id, error: pathProblem, code: "invalid_request" }; + const v = this.#workstreams.referenceVerdict(createWorkstream.id, { workstream: asked.workstream, ...(paths ? { paths } : {}) }, recorded.get(asked.workstream) ?? []); + if (!v.ok) return { type: "response.error", requestId: msg.id, error: v.error, code: "forbidden" }; + if (v.ask.length > 0 && !approves) { + return { type: "response.error", requestId: msg.id, error: `Workstream "${asked.workstream}" shares only with approval: referencing it needs session:approve on an undelegated token`, code: "forbidden" }; + } + refs.push({ workstream: asked.workstream, ...(paths ? { paths } : {}), grantedBy: auth.sub, grantedAt: new Date().toISOString() }); + } + const unique = [...new Map(refs.map((r) => [r.workstream, r])).values()]; + createWorkstream = { ...createWorkstream, references: unique, readFrom: [...new Set([...(createWorkstream.readFrom ?? []), ...unique.map((r) => r.workstream)])] }; + this.#store.audit(auth.sub, "workstream.reference_added", "", `workdir=${workdir} references=${unique.map((r) => r.workstream).join(",")} at=create`); + } + 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; @@ -4848,6 +5202,15 @@ 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). + // 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"); @@ -5927,6 +6290,8 @@ mcpHub: this.#mcpHub, providerId, defaultModel: conductorConfig?.model, fleet: this.#buildFleetServer(auth.accountId, auth.projectId), + fleetTargetWorkstream: this.#fleetCardWorkstream(auth.accountId, auth.projectId), + fleetConfirm: (target) => this.#issueFleetToken({ accountId: auth.accountId, projectId: auth.projectId }, target), auth, store: this.#store, transcriptStore: this.#transcriptStore, @@ -6215,7 +6580,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, @@ -7244,7 +7609,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 @@ -7350,7 +7715,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, @@ -7639,9 +8004,24 @@ mcpHub: this.#mcpHub, `provider "${task.provider}" is not registered on this daemon — available: ${this.#providers.ids().join(", ")}`, ); } + // Its home: the workstream the spawn named, judged again now (the config may have changed since it + // was queued), else the folder's as before W4. + let home: WorkstreamBinding | undefined; + if (task.workstream) { + // A confirmation the card gave holds only at the config revision it was given at. + const confirmed = task.workstreamOverride && task.workstreamRevision === this.#workstreams.revision; + const h = this.#fleetSpawnHome(workdir, task.workstream, confirmed, { accountId: task.accountId, projectId: task.projectId }); + if (!h.ok) throw new NonRetryableDispatchError(`workstream: ${h.error}`); + home = h.binding; + } else if (this.#workstreams.configured && task.createdBy && !goalIdFromCreatedBy(task.createdBy)) { + // The conductor's unnamed spawn: the folder's own, judged as when it was queued (remote guard included). + const h = this.#fleetSpawnHome(workdir, undefined, false, { accountId: task.accountId, projectId: task.projectId }); + if (!h.ok) throw new NonRetryableDispatchError(`workstream: ${h.error}`); + home = h.binding; + } else home = this.#workstreamForFolder(workdir, this.#dispatchSenderAuth(task)); const budget = this.#dispatcher.config.workerToolBudget; const session = new Session({ - ...this.#workstreamOpts(this.#workstreamForFolder(workdir)), + ...this.#workstreamOpts(home), name: `worker-${task.shape}-${task.id.slice(0, 8)}`, workdir, role: "worker", @@ -7729,6 +8109,11 @@ mcpHub: this.#mcpHub, const worker = this.#sessionForTask(task.workerSessionId, task); const header = `task ${task.id.slice(0, 8)} (${task.kind}/${task.shape}) in ${task.workdir ?? task.targetSession ?? "?"}`; if (!worker) return `${header} — worker session unavailable; check the workdir/git state for artifacts.`; + // A closed workstream's worker reports its status, never its text (workstreams §10). + // Withheld from the conductor only: an orchestrator's children share its workstream (they inherit it). + if (!goalIdFromCreatedBy(task.createdBy) && this.#workstreamTextWithheld({ sessionId: worker.id, ...(worker.workstream?.id ? { workstream: worker.workstream.id } : {}) })) { + return `${header} — done in workstream ${worker.workstream?.id}, which is closed: its result isn't shared with the conductor. Its owner sees it in the session ${worker.name}.`; + } const parts: string[] = [header]; const finalText = worker.lastAssistantText; if (finalText) { @@ -8040,9 +8425,208 @@ mcpHub: this.#mcpHub, /** The conductor's tenant-wide deps. Extracted from `#buildFleetServer` so a * test can compare them against the orchestrator's scoped ones. */ + /** Whether any workstream is closed while isolation is on: then text whose workstream can't be told is withheld. */ + #anyClosedWorkstream(): boolean { + return this.#workstreams.configured && this.#workstreams.isolateGlobalServers && this.#workstreams.anyClosed; + } + + /** #workstreamTextWithheld's answers, briefly: a fleet tool asks once per episode. */ + #withheldMemo = new Map(); + + /** A session's home as last recorded, for one not running: its meta's binding, else its folder's record, else its folder now. */ + #storedSessionWorkstream(sessionId: string): string | undefined | null { + const stored = this.#store.getSession(sessionId); + if (!stored) return null; + try { + const meta = JSON.parse(readFileSync(this.#transcriptStore.metaPath(sessionId), "utf8")) as { workstream?: unknown }; + const b = meta.workstream !== undefined ? readWorkstreamBinding(meta.workstream) : undefined; + if (b) return b.sealed ? null : b.id; + } catch {} + const owner = stored as { accountId?: string; projectId?: string }; + if (owner.accountId && owner.projectId) { + const recorded = this.#store.workstreamFolder({ accountId: owner.accountId, projectId: owner.projectId, workdir: stored.workdir }); + if (recorded) return recorded; + } + return this.#workstreams.resolve(stored.workdir).home; + } + + /** + * Whether text is withheld from the conductor and its digests (workstreams + * §10): a `closed` workstream's, under isolation. Judged by the episode's + * workstream; an untagged one (written before workstreams) by its + * session's — live, else the folder the session was stored with —; one + * whose workstream can't be told at all is withheld while any is closed. + */ + #workstreamTextWithheld(ref: { workstream?: string; sessionId?: string }): boolean { + if (!this.#anyClosedWorkstream()) return false; + const key = `${this.#workstreams.revision}\u0000${ref.workstream ?? ""}\u0000${ref.sessionId ?? ""}`; + const hit = this.#withheldMemo.get(key); + if (hit && Date.now() - hit.at < 2_000) return hit.withheld; + const withheld = this.#judgeWithheld(ref); + if (this.#withheldMemo.size > 2_000) this.#withheldMemo.clear(); + this.#withheldMemo.set(key, { at: Date.now(), withheld }); + return withheld; + } + + #judgeWithheld(ref: { workstream?: string; sessionId?: string }): boolean { + let ws = ref.workstream; + if (!ws && ref.sessionId) { + const live = this.#sessions.get(ref.sessionId); + if (live) { + ws = live.workstream?.id; + if (!ws) return false; // a session with no home: nothing of a closed workstream's is in it + if (live.workstream?.source === "reserved") return false; + } else { + const recorded = this.#storedSessionWorkstream(ref.sessionId); + if (recorded === null) return true; // unknown, or its record unreadable: withheld while any is closed + ws = recorded; + if (!ws) return false; + } + } + if (!ws) return true; + return this.#workstreams.get(ws)?.share === "closed"; + } + + /** + * A dispatched worker's home (workstreams §10, §5): `workstream` if named, + * else the folder's — as a create judges it, a folder with none refused + * under isolation, and a folder whose repository's remote now matches + * another workstream than it did refused too (an agent can edit `origin`: + * #workstreamForFolder). `confirmed`: approving the spawn's card confirmed + * what it warned of — an override past the folder's exclusive workstream + * (G3), a home in an `ask` workstream's folder. A closed workstream's + * folder never takes another's worker, and a closed workstream's worker + * works only in its own folders (what it writes elsewhere is no one's). + */ + #fleetSpawnHome( + workdir: string, + workstream: string | undefined, + confirmed: boolean, + tenant?: { accountId: string; projectId: string }, + ): { ok: true; binding: WorkstreamBinding | undefined; override: boolean; conflict?: string; asks: string[] } | { ok: false; error: string } { + if (!this.#workstreams.configured) { + return workstream ? { ok: false, error: "No workstreams are configured on this daemon: omit workstream" } : { ok: true, binding: undefined, override: false, asks: [] }; + } + const r = this.#workstreams.forCreate(workdir, workstream, undefined, true, confirmed); + if (!r.ok) { + const c = r.kind === "workstream_conflict" ? r.resolution.conflict : undefined; + if (c && !c.overridable) return { ok: false, error: `${r.error} That workstream allows no override.` }; + if (c) return { ok: false, error: `${r.error} Approving the spawn's card confirms it; this one wasn't confirmed (it changed since it was shown): ask again.` }; + return { ok: false, error: r.kind === "workstream_required" ? `${r.error} Name it with workstream.` : r.error }; + } + if (!workstream && tenant && r.binding?.source === "matched" && r.binding.id && r.binding.matchedBy?.startsWith("remote ")) { + const before = this.#store.workstreamFolder({ ...tenant, workdir }); + if (before && before !== r.binding.id) { + return { ok: false, error: `This folder matched workstream "${before}", but its repository now matches "${r.binding.id}": its remote may have been changed. Name the workstream.` }; + } + } + const fv = this.#workstreams.folderVerdict(workdir, r.binding?.id); + if (!fv.ok) return { ok: false, error: fv.error }; + if (fv.ask.length > 0 && !confirmed) { + return { ok: false, error: `This folder is workstream "${fv.folder}"'s, which shares only with approval: approving the spawn's card confirms it; this one wasn't confirmed: ask again.` }; + } + const home = r.binding?.id ? this.#workstreams.get(r.binding.id) : undefined; + if (home?.share === "closed") { + const recorded = this.#store.workstreamFoldersByWorkstream().get(home.id) ?? []; + const { folders } = this.#workstreams.foldersOf(home.id, recorded); + // Its own: a folder of its, or one it matches (a remote rule names no folder until one is recorded). + const own = folders.some((f) => workdir === f || workdir.startsWith(`${f}/`)) || this.#workstreams.resolve(workdir).home === home.id; + if (!own) return { ok: false, error: `Workstream "${home.id}" is closed: its workers work only in its own folders` }; + } + const binding = r.binding && fv.folder ? { ...r.binding, readFrom: [fv.folder] } : r.binding; + const override = r.binding?.source === "override"; + return { ok: true, binding, override, ...(override && r.resolution?.conflict ? { conflict: r.resolution.conflict.folder } : {}), asks: fv.ask }; + } + + /** + * Confirmation tokens (workstreams §10, G3): one per approved card that + * needed one, issued when the owner's own approval settled it and added to + * that call's input; spent once by its handler, which re-judges the spawn + * and accepts only the very verdict the card showed, at the revision it was + * shown at. Ten minutes. + */ + #fleetTokens = new Map }>(); + + #issueFleetToken(tenant: { accountId: string; projectId: string }, target: FleetCardTarget): string | undefined { + if (!target.confirm) return undefined; + if (this.#fleetTokens.size > 256) this.#fleetTokens.clear(); + const token = randomUUID(); + this.#fleetTokens.set(token, { at: Date.now(), tenant, confirm: target.confirm }); + return token; + } + + /** Spend `token` for a spawn: valid only for this tenant, call and verdict, unchanged since the card. */ + #spendFleetToken( + token: string | undefined, + tenant: { accountId: string; projectId: string }, + workdir: string, + workstream: string | undefined, + verdict: { conflict?: string; asks: string[] }, + ): boolean { + if (!token) return false; + const t = this.#fleetTokens.get(token); + this.#fleetTokens.delete(token); + if (!t || Date.now() - t.at > 10 * 60_000) return false; + const c = t.confirm; + return ( + t.tenant.accountId === tenant.accountId && + t.tenant.projectId === tenant.projectId && + c.workdir === workdir && + (c.workstream ?? "") === (workstream ?? "") && + c.revision === this.#workstreams.revision && + (c.conflict ?? "") === (verdict.conflict ?? "") && + JSON.stringify([...c.asks].sort()) === JSON.stringify([...verdict.asks].sort()) + ); + } + + /** + * What a send-class fleet call's approval card says of its workstream + * (workstreams §10): the target session's home (send, interrupt, a + * panel's members), or the spawned worker's — with the warning approving + * it confirms (past the folder's exclusive workstream), or why it will be + * refused. Undefined without workstreams. + */ + #fleetCardWorkstream(accountId: string, projectId: string): (toolName: string, input: unknown) => FleetCardTarget | undefined { + return (toolName, input) => { + if (!this.#workstreams.configured) return undefined; + const tool = toolName.startsWith(FLEET_TOOL_PREFIX) ? toolName.slice(FLEET_TOOL_PREFIX.length) : toolName; + const i = input && typeof input === "object" ? (input as Record) : {}; + const mine = [...this.#sessions.values()].filter((x) => x.accountId === accountId && x.projectId === projectId); + const find = (ref: unknown) => + typeof ref === "string" ? (mine.find((x) => x.id === ref) ?? mine.find((x) => x.name === ref) ?? mine.find((x) => x.id.startsWith(ref))) : undefined; + if (tool === "fleet_spawn") { + const workdir = typeof i.workdir === "string" ? normalizeWorkdir(i.workdir) : null; + if (!workdir) return undefined; + const ws = typeof i.workstream === "string" ? i.workstream : undefined; + const tenant = { accountId, projectId }; + const plain = this.#fleetSpawnHome(workdir, ws, false, tenant); + if (plain.ok) return plain.binding?.id ? { workstream: plain.binding.id } : undefined; + const confirmed = this.#fleetSpawnHome(workdir, ws, true, tenant); + if (confirmed.ok && (confirmed.override || confirmed.asks.length > 0)) { + const what = confirmed.override + ? `Past workstream "${confirmed.conflict ?? "?"}"'s own folder (exclusive): approving this confirms a worker of "${confirmed.binding?.id}" there.` + : `It reads workstream "${confirmed.asks.join(", ")}"'s folder, which shares only with approval: approving this confirms it.`; + return { + ...(confirmed.binding?.id ? { workstream: confirmed.binding.id } : {}), + warning: what, + confirm: { workdir, ...(ws ? { workstream: ws } : {}), revision: this.#workstreams.revision, ...(confirmed.conflict ? { conflict: confirmed.conflict } : {}), asks: confirmed.asks }, + }; + } + return { warning: `It will be refused: ${confirmed.ok ? plain.error : confirmed.error}` }; + } + if (tool === "fleet_panel" && Array.isArray(i.sessions)) { + const homes = [...new Set(i.sessions.map((r) => find(r)?.workstream?.id ?? "none"))]; + return { workstream: homes.join(", ") }; + } + const target = find(i.session); + return target?.workstream?.id ? { workstream: target.workstream.id } : undefined; + }; + } + #conductorFleetDeps(accountId: string, projectId: string): FleetDeps { return { tenant: { accountId, projectId }, + textWithheld: (ref) => this.#workstreamTextWithheld(ref), listSessions: (): FleetSessionView[] => { const views: FleetSessionView[] = []; for (const s of this.#sessions.values()) { @@ -8058,6 +8642,7 @@ mcpHub: this.#mcpHub, model: s.toInfo().model, attachedClients: s.attachedClientCount, createdAt: s.createdAt, + ...(s.workstream?.id ? { workstream: s.workstream.id } : {}), }); } return views; @@ -8115,6 +8700,21 @@ mcpHub: this.#mcpHub, const n = normalizeWorkdir(path); return n && !this.#loopWorktreeProblem(n) ? n : null; }, + // Run after the owner approved the spawn's card, which showed any conflict: confirmed. + checkWorkstream: (workdir: string, workstream?: string, token?: string) => { + if (!this.#workstreams.configured && !workstream) return { ok: true }; + const tenant = { accountId, projectId }; + // Confirmed only by the token the owner's approval of this very call's card put in its input, for + // exactly the verdict that card showed (#spendFleetToken). + let home = this.#fleetSpawnHome(workdir, workstream, false, tenant); + if (!home.ok) { + const confirmed = this.#fleetSpawnHome(workdir, workstream, true, tenant); + if (confirmed.ok && this.#spendFleetToken(token, tenant, workdir, workstream, { ...(confirmed.conflict ? { conflict: confirmed.conflict } : {}), asks: confirmed.asks })) home = confirmed; + } else if (token) this.#fleetTokens.delete(token); + if (!home.ok) return home; + const confirmed = home.override || home.asks.length > 0; + return { ok: true, ...(home.binding?.id ? { workstream: home.binding.id } : {}), ...(confirmed ? { override: true, revision: this.#workstreams.revision } : {}) }; + }, resolveBackend: (provider?: string, model?: string) => { // Fail closed on an unknown provider — same rule and same wording as // session.create / fork / set_provider. Rejecting here (pre-queue) @@ -8185,7 +8785,12 @@ mcpHub: this.#mcpHub, target: t.targetSession ?? t.workdir, createdAt: t.createdAt, error: t.error, - resultDigest: t.resultDigest, + // Another's task (an orchestrator's) carries its full digest: not the conductor's to read when + // its worker was a closed workstream's (§10). The conductor's own were withheld when built. + resultDigest: + goalIdFromCreatedBy(t.createdBy) !== undefined && this.#workstreamTextWithheld({ ...(t.workerSessionId ? { sessionId: t.workerSessionId } : t.targetSession ? { sessionId: t.targetSession } : {}) }) + ? (t.resultDigest ? "(a closed workstream's result: not shared with the conductor)" : null) + : t.resultDigest, })), }; } @@ -8242,6 +8847,15 @@ mcpHub: this.#mcpHub, * "the orchestrator doesn't get X" test proves nothing unless something in * the same run shows X was actually available to give. */ + /** The token the owner's approval of `target`'s card would add to the call, for tests. */ + _issueFleetTokenForTest(accountId: string, projectId: string, target: FleetCardTarget): string | undefined { + return this.#issueFleetToken({ accountId, projectId }, target); + } + + _fleetCardWorkstreamForTest(accountId: string, projectId: string): (toolName: string, input: unknown) => FleetCardTarget | undefined { + return this.#fleetCardWorkstream(accountId, projectId); + } + _fleetDepsForTest(accountId: string, projectId: string): FleetDeps { return this.#conductorFleetDeps(accountId, projectId); } @@ -9389,6 +10003,13 @@ mcpHub: this.#mcpHub, return { type: "response.error", requestId: msg.id, error: "A delegated token can't approve a loop worker's tool calls", code: "forbidden" }; } + // A card warning of a workstream override or an `ask` folder (workstreams §10, G3) is the owner's own to + // confirm, as an override at create is; and that approval is what the spawn handler may count as confirmed. + const card = session.approvalWorkstream(msg.approvalId); + if (msg.approved && card?.target.confirm && !session.isOwnApprover(auth)) { + this.#store.audit(auth.sub, "workstream.override_refused", session.id, `approvalId=${msg.approvalId} reason=not_owner`); + return { type: "response.error", requestId: msg.id, error: "Only the session's owner can confirm what this card warns of", code: "forbidden" }; + } // Still `response.ok` for a stale id — the web ApprovalBar sends one to // dismiss a leftover prompt — but say so, for callers that report it. const outcome = session.approve(msg.approvalId, msg.approved, auth, msg.updatedInput); @@ -9656,6 +10277,170 @@ mcpHub: this.#mcpHub, return { type: "response.ok", requestId: msg.id, data: { ...(result.pending ? { pending: true } : {}), message: result.message, sandbox: session.sandboxInfo } }; } + /** + * `session.add_reference` / `remove_reference` / `set_workstream` + * (workstreams §5.3, §7): owner-only. A grant or a move follows the share + * policy of each workstream it reads from (WorkstreamService + * `referenceVerdict` / `moveVerdict`): `closed` refuses; `ask` is granted + * at once to the owner asking with session:approve on an undelegated token, + * else asked of the owner on the session. Applied at the next turn. + */ + async #sessionWorkstreamEdit( + msg: Extract, + auth: AuthContext, + ): Promise { + const err = (error: string, code: "forbidden" | "invalid_request" | "not_found" = "invalid_request"): DaemonMessage => ({ type: "response.error", requestId: msg.id, error, code }); + if (!hasScope(auth.scopes as string[], SCOPES.SESSION_SEND)) return err("Missing scope: session:send", "forbidden"); + const session = this.#getOwnedSession(msg.sessionId, auth); + if (!session) return err("Session not found", "not_found"); + if (session.createdBy !== auth.sub) return err("Only the session's owner can change what workstreams it reads", "forbidden"); + const binding = session.workstreamAhead; + if (!this.#workstreams.configured || !binding) return err("No workstreams are configured"); + if (binding.source === "reserved") return err("The conductor's workstream can't be changed"); + // The owner asking with approval rights, undelegated, is the confirm an `ask` policy wants. + const approves = hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) && auth.delegationDepth === 0; + const reply = (data: Record): DaemonMessage => ({ type: "response.ok", requestId: msg.id, data: { ...data, workstream: this.#describeWorkstream(session.workstreamAhead) } }); + + if (msg.type === "session.remove_reference") { + const refs = binding.references ?? []; + if (!refs.some((r) => r.workstream === msg.workstream)) return err(`This session has no reference to "${msg.workstream}"`); + const rest = refs.filter((r) => r.workstream !== msg.workstream); + session.setWorkstream({ ...withoutKey(binding, "references"), ...(rest.length > 0 ? { references: rest } : {}) }, `No longer reads workstream "${msg.workstream}" (from the next turn)`); + this.#store.audit(auth.sub, "workstream.reference_removed", session.id, `workstream=${msg.workstream}`); + return reply({ message: `removed the reference to ${msg.workstream}` }); + } + + // What is asked, as it will be granted; null = refused (with why). + let decide: + | { ask: string[]; recheck: () => string | undefined; apply: (by: string) => { next: WorkstreamBinding; said: string; audit: [string, string] } } + | { error: string }; + if (msg.type === "session.add_reference") { + const paths = msg.paths?.map(referenceFolder); + // Held to what a binding is read back with: one stored that can't be read would seal the session. + const pathProblem = referencePathsProblem(paths); + if (pathProblem) return err(pathProblem); + const others = (binding.references ?? []).filter((r) => r.workstream !== msg.workstream); + if (others.length >= REFERENCE_LIMIT) return err(`A session reads at most ${REFERENCE_LIMIT} other workstreams`); + const recorded = this.#store.workstreamFoldersByWorkstream().get(msg.workstream) ?? []; + const v = this.#workstreams.referenceVerdict(binding.id, { workstream: msg.workstream, ...(paths ? { paths } : {}) }, recorded); + const again = () => { + // Judged again as things are now, the bounds a binding is read back with included (several asks may be pending). + const cur = session.workstreamAhead; + if ((cur?.references ?? []).filter((r) => r.workstream !== msg.workstream).length >= REFERENCE_LIMIT) return `A session reads at most ${REFERENCE_LIMIT} other workstreams`; + if (new Set([...(cur?.readFrom ?? []), msg.workstream]).size > READ_FROM_LIMIT) return "This session has read from too many workstreams to record another"; + const w = this.#workstreams.referenceVerdict(cur?.id, { workstream: msg.workstream, ...(paths ? { paths } : {}) }, recorded); + return w.ok ? undefined : w.error; + }; + const tooMany = again(); + if (tooMany && v.ok) return err(tooMany); + decide = !v.ok + ? { error: v.error } + : { + ask: v.ask, + recheck: again, + apply: (by) => { + const cur = session.workstreamAhead ?? binding; + const ref = { workstream: msg.workstream, ...(paths ? { paths } : {}), grantedBy: by, grantedAt: new Date().toISOString() }; + const next: WorkstreamBinding = { + ...cur, + references: [...(cur.references ?? []).filter((r) => r.workstream !== msg.workstream), ref], + readFrom: [...new Set([...(cur.readFrom ?? []), msg.workstream])], + }; + return { next, said: `Reads workstream "${msg.workstream}"${paths ? ` (${paths.join(", ")})` : ""} from the next turn: its folders read-only, and its memory`, audit: ["workstream.reference_added", `workstream=${msg.workstream}${paths ? ` paths=${paths.join(",")}` : ""} by=${by}`] }; + }, + }; + } else { + if (msg.workstream === binding.id) return err(`The session is already in "${msg.workstream}"`); + // Everything a move is judged by, as things are when asked — and again when the owner answers. + const judge = (cur: WorkstreamBinding | undefined): { error: string } | { ask: string[]; conflict: boolean; folder?: string } => { + const v = this.#workstreams.moveVerdict(cur, msg.workstream); + if (!v.ok) return { error: v.error }; + if ((cur?.readFrom?.length ?? 0) + 2 > READ_FROM_LIMIT) return { error: "This session has read from too many workstreams to record another move" }; + const fv = this.#workstreams.folderVerdict(session.workdir, msg.workstream); + if (!fv.ok) return { error: fv.error }; + // Past another workstream's exclusive claim on the folder: an override (§5.2), never asked on the session. + const conflict = this.#workstreams.resolve(session.workdir, msg.workstream).conflict; + if (conflict && !conflict.overridable) return { error: `This folder belongs to workstream "${conflict.folder}", which allows no override` }; + return { ask: [...new Set([...v.ask, ...fv.ask])], conflict: conflict !== undefined, ...(fv.folder ? { folder: fv.folder } : {}) }; + }; + const first = judge(binding); + if ("error" in first) return err(first.error, "forbidden"); + if (first.conflict && !approves) return err("This folder belongs to another workstream (exclusive): moving the session past it needs session:approve on an undelegated token", "forbidden"); + let answer = first; + decide = { + ask: first.ask, + recheck: () => { + const now = judge(session.workstreamAhead); + if ("error" in now) return now.error; + answer = now; + return undefined; + }, + apply: (by) => { + const cur = session.workstreamAhead ?? binding; + const refs = (cur.references ?? []).filter((r) => r.workstream !== msg.workstream); + // The conversation goes with it: the old home (but the default) and the folder's workstream are read from now. + const readFrom = [...new Set([...(cur.readFrom ?? []), ...(cur.id && cur.id !== this.#workstreams.defaultId ? [cur.id] : []), ...(answer.folder ? [answer.folder] : [])])].filter((w) => w !== msg.workstream); + const next: WorkstreamBinding = { + id: msg.workstream, + source: answer.conflict ? "override" : "explicit", + revision: this.#workstreams.revision, + ...(refs.length > 0 ? { references: refs } : {}), + ...(readFrom.length > 0 ? { readFrom } : {}), + }; + return { next, said: `Moved to workstream "${msg.workstream}"${cur.id ? ` from "${cur.id}"` : ""}: its servers, credentials and sandbox from the next turn`, audit: [answer.conflict ? "workstream.override" : "workstream.moved", `from=${cur.id ?? "none"} to=${msg.workstream} by=${by}`] }; + }, + }; + } + if ("error" in decide) return err(decide.error, "forbidden"); + const applyNow = (by: string) => { + const { next, said, audit } = decide.apply(by); + session.setWorkstream(next, said); + this.#store.audit(by, audit[0], session.id, audit[1]); + }; + if (decide.ask.length === 0 || approves) { + applyNow(auth.sub); + return reply({ message: "done" }); + } + // An `ask` workstream, and the asker can't approve it: the owner confirms on the session. + const what = msg.type === "session.add_reference" ? `read workstream "${msg.workstream}"` : `move to workstream "${msg.workstream}"`; + this.#store.audit(auth.sub, "workstream.share_ask", session.id, `${msg.type} ${msg.workstream} ask=${decide.ask.join(",")}`); + let answeredBy: string | undefined; + void session + .requestUserInput( + { + method: "confirm", + title: `Let "${session.name}" ${what}?`, + message: + msg.type === "session.add_reference" + ? `"${msg.workstream}" shares only with approval. The session could read its folders and recall its memory; it gets none of its servers or credentials, and writes nothing there.` + : `${decide.ask.map((w) => `"${w}"`).join(" and ")} share${decide.ask.length === 1 ? "s" : ""} only with approval, and the session's whole conversation would go with it.`, + }, + { + ownerOnly: true, + onAnswer: (sub) => { + answeredBy = sub; + }, + }, + ) + .then( + (answer) => { + if (answer.cancelled || answer.confirmed !== true) { + this.#store.audit(answeredBy ?? auth.sub, "workstream.share_denied", session.id, `${msg.type} ${msg.workstream} ${answer.reason ?? "declined"}`); + return; + } + // Judged again as it is now: a policy tightened while the owner was asked wins. + const now = decide.recheck(); + if (now) { + this.#store.audit(answeredBy ?? auth.sub, "workstream.share_denied", session.id, `${msg.type} ${msg.workstream} ${now}`); + return; + } + applyNow(answeredBy ?? auth.sub); + }, + () => {}, + ); + return reply({ pending: true, message: `asked on the session: ${what}?` }); + } + /** `sandbox.providers` (#448): what the daemon has and whether each runs here. */ async #sandboxProviders(msg: Extract, auth: AuthContext): Promise { if (!hasScope(auth.scopes as string[], SCOPES.SESSION_CREATE)) { @@ -10114,3 +10899,27 @@ function daemonSandboxRoot(): string[] { const root = sandboxRootIfMade(); return root ? [root] : []; } + +/** `obj` without `key` (an optional field taken away). */ +function withoutKey(obj: T, key: K): Omit { + const { [key]: _, ...rest } = obj; + return rest; +} + +/** + * A reference's folder as it is granted: `~` expanded, made absolute and + * resolved to where it really is (a `..` or a link never names one + * workstream's path for another's). One that doesn't exist is kept as + * written, which referencePathsProblem refuses unless it is absolute (and + * the fence counts it only where it really is inside the workstream). + */ +function referenceFolder(p: string): string { + const expanded = p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p; + if (!expanded.startsWith("/")) return p; + const abs = resolve(expanded); + try { + return realpathSync(abs); + } catch { + return abs; + } +} diff --git a/src/daemon/session.ts b/src/daemon/session.ts index f67114d0..f59fc670 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -103,6 +103,10 @@ 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 { WorkstreamFence } from "./workstreams/service.js"; +import { type FenceRefusal, fenceRefusal, fenceRefusalText } from "./workstreams/gate.js"; +import { repositoryTopOf } from "./workstreams/folders.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"; @@ -433,6 +437,18 @@ export interface WindowScope { workdir: string; } +/** + * What a send-class fleet call's card says of its workstream (workstreams + * §10), and — when approving it confirms something — what it was judged on, + * which the manager checks a confirmation against. `confirm` never leaves the + * daemon (the card shows `workstream` and `warning`). + */ +export interface FleetCardTarget { + workstream?: string; + warning?: string; + confirm?: { workdir: string; workstream?: string; revision: string; conflict?: string; asks: string[] }; +} + export interface SessionCreateOptions { name: string; workdir: string; @@ -557,6 +573,27 @@ 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?: (binding: WorkstreamBinding | undefined) => Readonly>; + /** + * The home's MCP grant under isolation (workstreams/grant.ts), read live + * for the session's binding as it is now; undefined result = no isolation. + * Applied by the session's MCP view, so every backend sees only what the + * home selects. + */ + mcpGrant?: (binding: WorkstreamBinding | undefined) => McpGrant | undefined; + /** + * The session's read fence under isolation (workstreams §7.3), read live + * for its binding: its home's and references' folders, and every other + * workstream's. Undefined result = no fence. + */ + workstreamFence?: (binding: WorkstreamBinding | undefined, own: readonly string[]) => WorkstreamFence | 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 @@ -612,6 +649,19 @@ export interface SessionCreateOptions { * role-child. Returns undefined when there is nothing to report. */ collaborationCost?: () => CollaborationCost | undefined; + /** + * The workstream a send-class fleet call acts in, for its approval card + * (workstreams §10). Injected by the manager (the conductor's): a session + * can't see its targets' homes. Undefined = nothing to say. + */ + fleetTargetWorkstream?: (toolName: string, input: unknown) => FleetCardTarget | undefined; + /** + * The owner approved a card whose target needs confirming (an override, an + * `ask` folder): a one-time token for that very call, which the gate adds + * to its input (`confirmation`) for the fleet handler to present. Absent + * or undefined: none (the handler then refuses what needed it). + */ + fleetConfirm?: (target: FleetCardTarget, input: unknown) => string | undefined; /** * Initial execution mode + autonomous tool budget. Spawned workers start * "autonomous" with a bounded budget so they can work unattended; when the @@ -1070,6 +1120,42 @@ export class Session { return this.#workstream; } + /** + * Change the session's binding (workstreams W3: a reference granted or + * taken away, a move to another home, §5.3, §7). Authorized and judged by + * the manager. Saved at once; what it gives is read live, so the backend + * is rebuilt with it at the next turn (its servers, env, sandbox roots and + * recall), never mid-turn. Said on the session. + */ + setWorkstream(next: WorkstreamBinding, said: string): void { + // Saved at once (a restart resumes with it), applied at the next turn's start: a turn running now + // keeps the binding it started with whole — its gate, servers, env and memory together. + this.#pendingWorkstream = next; + this.#saveMetaNow(); + if (!this.#mcpBusy()) this.#applyPendingWorkstream(); + const info = this.#makeMessage("info", `🧭 ${said}`, SYSTEM_IDENTITY, undefined, undefined, { event: "workstream.changed", workstream: next.id ?? null }); + this.#persistAndBuffer(info); + this.#broadcastRaw(info); + this.#broadcastInfoUpdate(); + } + + /** A binding change waiting for the turn boundary (setWorkstream). */ + #pendingWorkstream: WorkstreamBinding | undefined; + + /** The binding as it will be: the pending one, else the one in use. What is saved and shown. */ + get workstreamAhead(): WorkstreamBinding | undefined { + return this.#pendingWorkstream ?? this.#workstream; + } + + #applyPendingWorkstream(): void { + const next = this.#pendingWorkstream; + if (!next) return; + this.#pendingWorkstream = undefined; + this.#workstream = next; + // The frozen memory index was judged against the old scope. + this.#frozenMemoryIndex = null; + } + /** * Goal + role→backend bindings when this session was created with the * Collaborative toggle (set from opts / restored from meta). Readonly: the @@ -1371,6 +1457,8 @@ export class Session { #mode: SessionMode = "guarded"; #onStatusChange?: (sessionId: string, status: SessionStatus, opts?: { settled: true }) => void; #collaborationCost?: () => CollaborationCost | undefined; + #fleetTargetWorkstream?: (toolName: string, input: unknown) => FleetCardTarget | undefined; + #fleetConfirm?: (target: FleetCardTarget, input: unknown) => string | undefined; #workerShape?: "ship" | "scout"; #turnsRemaining: number | undefined = undefined; /** See SessionCreateOptions.onBudgetExhausted. */ @@ -1387,7 +1475,79 @@ 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; + /** The session's read fence (SessionCreateOptions.workstreamFence), for its binding as it is now. */ + #workstreamFence: (() => WorkstreamFence | undefined) | 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 read fence as the sandbox takes it (workstreams §7.3): the home's and + * references' folders shown read-only (the project roots), every other + * workstream's hidden. + */ + /** Lets go of the session's recall scope (MemoryEngine.scopeSession), at destroy. */ + #unscopeMemory: (() => void) | undefined; + + /** The workstreams this session's recall may read: its home and references; undefined without a fence. */ + #memoryWorkstreams(): readonly string[] | undefined { + const fence = this.#workstreamFence?.(); + if (!fence) return undefined; + // A reserved home (the conductor's) is no workstream's folders, but its own memory is its own. + const reserved = this.#workstream?.source === "reserved" && this.#workstream.id ? [this.#workstream.id] : []; + return [...new Set([...(fence.workstream ? [fence.workstream] : []), ...reserved, ...fence.references.map((r) => r.workstream)])]; + } + + #sandboxFence(): { roots: string[]; hidden: string[] } | undefined { + const fence = this.#workstreamFence?.(); + if (!fence) return undefined; + return { + roots: [...new Set([...fence.home, ...fence.references.flatMap((r) => r.folders)])], + hidden: fence.hidden, + }; + } + + /** 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. */ @@ -1735,19 +1895,30 @@ export class Session { this.#onStatusChange = opts.onStatusChange; this.#workerShape = opts.workerShape; this.#collaborationCost = opts.collaborationCost; + this.#fleetTargetWorkstream = opts.fleetTargetWorkstream; + this.#fleetConfirm = opts.fleetConfirm; this.#maxBudgetUsd = opts.maxBudgetUsd; this.#onBudgetExhausted = opts.onBudgetExhausted ?? "park"; - if (opts.extraEnv) { + const envOf = opts.workstreamEnv; + this.#workstreamEnv = envOf ? () => envOf(this.#workstream) : undefined; + const fenceOf = opts.workstreamFence; + // The session's own folder stays readable whatever its home (one moved, or homed past a claim): its + // workdir and the repository holding it (the sandbox's workspace in a monorepo). + const own = [...new Set([opts.workdir, repositoryTopOf(opts.workdir, homedir())])]; + this.#workstreamFence = fenceOf ? () => fenceOf(this.#workstream, own) : undefined; + 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. - 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.#extraEnvStatic = Object.freeze({ ...extra }); } + if (this.#extraEnvStatic || this.#workstreamEnv) this.#extraEnv = this.#liveExtraEnv(); if (opts.sandboxRegistry) { this.#sandbox = new SessionSandbox({ sessionId: this.id, @@ -1758,6 +1929,7 @@ export class Session { ...(opts.sandbox ? { setting: opts.sandbox } : {}), onWarning: (text) => this.#sandboxWarning(text, "sandbox.login_not_saved"), onWorktreeEntries: (common, findings) => this.#worktreeEntriesFound(common, findings), + ...(this.#workstreamFence ? { workstreamDirs: () => this.#sandboxFence() } : {}), }); } this.#requireSandbox = opts.requireSandbox === true; @@ -1809,6 +1981,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.#workstream) } : {}), }); } this.#config = opts.config; @@ -1925,6 +2098,8 @@ export class Session { if (this.#memory) { const memory = this.#memory; + // Recall kept to the home and its references under isolation (workstreams §6.4). + if (this.#workstreamFence) this.#unscopeMemory = memory.scopeSession(this.id, () => this.#memoryWorkstreams()); this.#indexScheduler = new IndexScheduler({ store: memory.store, workspaceId: this.#workspaceId, @@ -1944,7 +2119,9 @@ export class Session { // Retry a registration that failed at construction; until it // succeeds the workspace is invisible to cross-workspace search. if (!this.#workspaceRegistered) this.#registerWorkspace(); - memory.ingest(episode); + // Tagged with the home as it is now (workstreams §6.4): a session moved since writes into its new one. + const home = this.#workstream?.id; + memory.ingest(home ? { ...episode, workstreamId: home } : episode); scheduler.onEpisode(); } catch (err) { console.error( @@ -1987,7 +2164,7 @@ export class Session { worktree: this.worktree, collaboration: this.collaboration, collaborationRole: this.collaborationRole, - ...(this.#workstream ? { workstream: this.#workstream } : {}), + ...(this.workstreamAhead ? { workstream: this.workstreamAhead } : {}), // Fire-and-forget: saveMeta's write chain owns the failure log; an // unconsumed rejection here would be an unhandled-rejection crash. }).catch(() => {}); @@ -2070,6 +2247,14 @@ export class Session { isOwnTask: (taskId) => this.#isOwnTask(taskId), onForeignAgentDenied: (agentId, toolName) => this.#noteForeignAgentRefused(agentId, toolName), isAutonomous: () => this.#mode === "autonomous", + ...(this.#workstreamFence + ? { + workstreamFenceRefusal: (toolName: string, input: unknown, cwd: string | null | undefined) => { + const r = this.#fenceRefusal(toolName, input, cwd); + return r ? fenceRefusalText(r) : null; + }, + } + : {}), // Read each time the backend starts, and only what was resolved for THIS backend. sandbox: () => this.#sandbox?.runtimeFor(factory.id) ?? null, ...(this.#extraEnv ? { extraEnv: this.#extraEnv } : {}), @@ -2407,6 +2592,38 @@ export class Session { } } + /** The workstream fence's verdict on a tool call (workstreams/gate.ts); null without a fence. Fails closed. */ + #fenceRefusal(toolName: string, input: unknown, shellCwd: string | null | undefined): FenceRefusal | null { + let fence: WorkstreamFence | undefined; + try { + fence = this.#workstreamFence?.(); + } catch (e) { + console.error(`[codeoid/session ${this.id}] workstream fence: ${e instanceof Error ? e.message : String(e)}`); + return { path: "(couldn't be judged)", workstream: this.#workstream?.id ?? "?", kind: "read" }; + } + if (!fence) return null; + // paths: judging a tool call's input (fenceRefusal): no use of the path + return fenceRefusal(toolName, input, { workdir: this.workdir, ...(shellCwd !== undefined ? { cwd: shellCwd } : {}), fence }); + } + + /** Refuse a call the workstream fence holds: its card, the audit, and an info line. */ + #refuseFenced(approvalId: string, toolName: string, r: FenceRefusal, sender: AuthContext): { behavior: "deny"; message: string } { + const reason = fenceRefusalText(r); + this.#approvalPatchKeys.delete(approvalId); + this.#resolveToolCallMessage(approvalId, { phase: "cancelled", reason: "denied", message: reason }); + this.#store.audit(sender.sub, "session.workstream_fence_refuse", this.id, `tool=${toolName} kind=${r.kind} workstream=${r.workstream}`); + const said = r.kind === "unresolved" ? `couldn't tell where ${r.path} is` : `${r.path} is workstream "${r.workstream}"'s`; + const infoMsg = this.#makeMessage("info", `⛔ Refused ${toolName}: ${said}`, SYSTEM_IDENTITY, undefined, undefined, { + event: "workstream.fenced", + tool: toolName, + workstream: r.workstream, + kind: r.kind, + }); + this.#persistAndBuffer(infoMsg); + this.#broadcastRaw(infoMsg); + return { behavior: "deny", message: reason }; + } + /** A host-side file tool beyond the sandbox, in autonomous mode: refused (#outsideSandbox). Null in every other mode. */ #outsideSandboxRefusal(toolName: string, input: Record, shellCwd: string | null | undefined): HostToolBreach | null { return this.#mode === "autonomous" ? this.#outsideSandbox(toolName, input, shellCwd) : null; @@ -2945,8 +3162,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(); @@ -2959,6 +3176,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); } /** @@ -3068,14 +3311,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; @@ -3561,7 +3810,11 @@ export class Session { const root = sandboxRootIfMade(); const hidden = [...hiddenPathsFor(this.#config), ...(root ? [root] : [])]; const problem = addedDirProblem(written, { home, configDir: getConfigDir(), hidden }); - return problem ? { error: problem } : { dir, written }; + if (problem) return { error: problem }; + // Another workstream's folder (workstreams §7.3): writable would open the fence; reading it is a reference. + const fenced = this.#fenceRefusal("Write", { file_path: join(dir, ".codeoid-probe") }, null); + if (fenced) return { error: `${dir} is in workstream "${fenced.workstream}"'s folders: add a reference to read it (codeoid workstream ref add), never a writable directory` }; + return { dir, written }; } /** @@ -4837,6 +5090,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). @@ -5188,6 +5448,8 @@ export class Session { // The session's MCP servers: read before its first turn, and a refresh // asked for meanwhile applied (#395) — before the stop and adopted-turn // checks below, which must come after every await. + // A workstream change made during the last turn applies now, before the servers and env are read. + if (!this.#mcpBusy()) this.#applyPendingWorkstream(); await this.#applyMcpView(); // A thinking effort the backend takes only at startup (#417): rebuild it now, between turns. await this.#applyEffortRestart(); @@ -6089,6 +6351,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 */ } @@ -6262,7 +6525,15 @@ export class Session { const gated = this.#approvalInputs.get(approvalId); if (!gated) continue; const risk = this.#shellRisk(gated.toolName, gated.input as Record, gated.shellCwd); - out.push({ approvalId, toolName: gated.toolName, input: gated.input, ...this.#cardRisk(risk, this.#heldWrite(gated.toolName, gated.input)), ...this.#cardSandbox(gated.toolName, gated.input, gated.shellCwd) }); + const ws = this.#approvalWorkstreams.get(approvalId); + out.push({ + approvalId, + toolName: gated.toolName, + input: gated.input, + ...this.#cardRisk(risk, this.#heldWrite(gated.toolName, gated.input)), + ...this.#cardSandbox(gated.toolName, gated.input, gated.shellCwd), + ...(ws ? { targetWorkstream: { ...(ws.target.workstream ? { workstream: ws.target.workstream } : {}), ...(ws.target.warning ? { warning: ws.target.warning } : {}) } } : {}), + }); } return out; } @@ -6351,6 +6622,7 @@ export class Session { // the commit look's git is bounded, and a destroy waits no longer. await Promise.race([this.#checkGitControl(sender.sub), new Promise((r) => setTimeout(r, DESTROY_COMMIT_CHECK_MS).unref?.())]); this.#destroyed = true; + this.#unscopeMemory?.(); // This session's own servers' clients in the hub: let go (the last user closes them). for (const spec of [...this.#mcpRetained.values(), ...this.#mcpToRelease.values()]) this.#mcpHub?.release(spec, this.#mcpTenant()); this.#mcpRetained.clear(); @@ -7630,6 +7902,52 @@ export class Session { * Never throws. A roll-up that could fail an approval would be worse than no * roll-up at all — the dispatch path must not be wedgeable by a cost display. */ + /** What each pending card said of its workstream, with the input it was judged on; let go when it settles. */ + #approvalWorkstreams = new Map(); + + /** What a pending approval's card said of its workstream (workstreams §10), or undefined. */ + approvalWorkstream(approvalId: string): { toolName: string; input: unknown; target: FleetCardTarget } | undefined { + return this.#approvalWorkstreams.get(approvalId); + } + + /** Whether `sender` answers as this session's owner (its own user token), as an owner-only confirm needs. */ + isOwnApprover(sender: AuthContext): boolean { + return this.#isOwnApproval(sender); + } + + /** The workstream a send-class fleet call acts in, for its card (never throws: a display can't wedge an approval). */ + #targetWorkstreamFor(toolName: string, input: unknown, approvalId?: string): { targetWorkstream?: { workstream?: string; warning?: string } } { + if (!this.#fleetTargetWorkstream || !isFleetSendTool(toolName)) return {}; + try { + const t = this.#fleetTargetWorkstream(toolName, input); + if (t && approvalId) { + if (this.#approvalWorkstreams.size > 64) this.#approvalWorkstreams.delete(this.#approvalWorkstreams.keys().next().value!); + this.#approvalWorkstreams.set(approvalId, { toolName, input, target: t }); + } + // The card shows what it says; what it was judged on stays here. + return t ? { targetWorkstream: { ...(t.workstream ? { workstream: t.workstream } : {}), ...(t.warning ? { warning: t.warning } : {}) } } : {}; + } catch { + return {}; + } + } + + /** + * The approved call's input with a confirmation token, when its card needed one (workstreams §10, G3): only + * for the owner's own approval of that very card, on the input the card was judged on. Never throws. + */ + #withFleetConfirmation(approvalId: string, input: Record): Record { + const card = this.#approvalWorkstreams.get(approvalId); + this.#approvalWorkstreams.delete(approvalId); + if (!card?.target.confirm || !this.#fleetConfirm || !this.#ownerApproved.has(approvalId)) return input; + try { + if (JSON.stringify(card.input) !== JSON.stringify(input)) return input; + const token = this.#fleetConfirm(card.target, input); + return token ? { ...input, confirmation: token } : input; + } catch { + return input; + } + } + #collaborationCostFor(toolName: string): { collaborationCost?: CollaborationCost } { if (!this.#collaborationCost || !isFleetSendTool(toolName)) return {}; try { @@ -7780,7 +8098,9 @@ export class Session { worktree: this.worktree, collaboration: this.collaboration, collaborationRole: this.collaborationRole, - ...(this.#workstream ? { workstream: this.#describeWorkstream?.(this.#workstream) ?? { ...(this.#workstream.id ? { id: this.#workstream.id } : {}), source: this.#workstream.source } } : {}), + ...(this.workstreamAhead + ? { workstream: this.#describeWorkstream?.(this.workstreamAhead) ?? { ...(this.workstreamAhead.id ? { id: this.workstreamAhead.id } : {}), source: this.workstreamAhead.source } } + : {}), // Ambient pack driving this session (docs/pack-loading.md) — id, or // "id (role)" when a capability role is active. ...(this.#pack @@ -8771,7 +9091,7 @@ export class Session { let timelineEpisodes: ReturnType = []; if (this.#memory) { try { - timelineEpisodes = this.#memory.timeline(this.#workspaceId, 30); + timelineEpisodes = this.#memory.timeline(this.#workspaceId, 30, 0, this.#memoryWorkstreams()); } catch { /* graceful — the map still works without the page table */ } @@ -8910,7 +9230,11 @@ export class Session { // live scheduler every turn churns the append by a few bytes (relative times, // counts) and forces a per-turn query-loop rebuild that aborts the turn. if (this.#frozenMemoryIndex === null) { - this.#frozenMemoryIndex = this.#indexScheduler?.get() ?? ""; + // The index is the repository's whole memory: left out where another workstream wrote some of it + // (a repository two workstreams share); recall, filtered, still reaches the session's own. + const scope = this.#memoryWorkstreams(); + const mixed = scope !== undefined && (this.#memory?.store.hasOtherWorkstreams(this.#workspaceId, scope) ?? false); + this.#frozenMemoryIndex = mixed ? "" : (this.#indexScheduler?.get() ?? ""); } const index = this.#frozenMemoryIndex; if (!index) return MEMORY_SYSTEM_PROMPT_APPEND; @@ -9577,6 +9901,11 @@ export class Session { return { behavior: "deny" as const, message: reason }; } + // The workstream fence (workstreams §7.3), every mode: a file tool in + // another workstream's folders, or writing a reference, is refused. + const fenced = this.#fenceRefusal(toolName, effectiveInput, shellCwd); + if (fenced) return this.#refuseFenced(approvalId, toolName, fenced, sender); + // A file tool running outside the OS sandbox (Claude's, opencode's) is // held to the sandbox's own policy in autonomous mode (#448 rounds 8, 9): // beyond it, refused, never asked. Guarded and interactive mode decide @@ -9678,8 +10007,9 @@ export class Session { this.#store.audit(sender.sub, "session.approve", this.id, `tool=${toolName} approvalId=${approvalId}`); if (unsandboxed) this.#store.audit(sender.sub, "session.unsandboxed_approve", this.id, `tool=${toolName} approvalId=${approvalId}`); const merged: Record = sanitizedPatch ? { ...effectiveInput, ...sanitizedPatch } : effectiveInput; - return { behavior: "allow" as const, updatedInput: merged }; + return { behavior: "allow" as const, updatedInput: sanitizedPatch ? merged : this.#withFleetConfirmation(approvalId, merged) }; } + this.#approvalWorkstreams.delete(approvalId); this.#store.audit(sender.sub, "session.deny", this.id, `tool=${toolName} approvalId=${approvalId}`); return { behavior: "deny" as const, message: "Denied by user" }; }; @@ -10282,11 +10612,21 @@ export class Session { const budgetDenied = this.#budgetExhausted || this.#budgetSpentFor(event.name); // Autonomous mode refuses it outright (#autonomousRefusal): no approval card either. const deniedFirst = networkAsk || budgetDenied || secret?.action === "deny" || risk?.secret?.action === "deny"; + // The workstream fence refuses it in every mode (#fenceRefusal): no approval card. + const fenced = deniedFirst ? null : this.#fenceRefusal(event.name, event.input, event.shellCwd); // A pinned directory changed (#448 round 13): the gate refuses every call (#sandboxPinRefusal). const pinned = budgetDenied ? null : this.#sandboxPinRefusal(); // Beyond the sandbox, a host-side file tool is refused in autonomous mode (#outsideSandbox). const outside = deniedFirst || pinned ? null : this.#outsideSandboxRefusal(event.name, event.input, event.shellCwd); - const refused = pinned ? { reason: pinned } : deniedFirst ? null : outside ? { reason: hostFileBreachReason(outside) } : this.#autonomousRefusal(event.name, event.input, risk, secret, held); + const refused = pinned + ? { reason: pinned } + : deniedFirst + ? null + : fenced + ? { reason: fenceRefusalText(fenced) } + : outside + ? { reason: hostFileBreachReason(outside) } + : this.#autonomousRefusal(event.name, event.input, risk, secret, held); const toolMsg = this.#makeMessage( "tool_call", `${event.name}(${Object.keys(event.input).join(", ")})`, @@ -10308,6 +10648,7 @@ export class Session { description: `${event.name}(${Object.keys(event.input).join(", ")})`, approvalId: event.approvalId, ...this.#collaborationCostFor(event.name), + ...this.#targetWorkstreamFor(event.name, event.input, event.approvalId), ...this.#cardRisk(risk, held), ...this.#cardSandbox(event.name, event.input, event.shellCwd), } as unknown as ToolState), @@ -12098,7 +12439,7 @@ export class Session { collaboration: this.collaboration, collaborationRole: this.collaborationRole, // Both meta writes, for the same reason as the collaboration above. - ...(this.#workstream ? { workstream: this.#workstream } : {}), + ...(this.workstreamAhead ? { workstream: this.workstreamAhead } : {}), }).catch(() => {}); } } 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 d1d9037f..ad90d51a 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -51,6 +51,15 @@ export interface DispatchTaskRow { provider: string | null; /** Per-child model, already resolved against `provider`. NULL = provider default. */ model: string | null; + /** + * A spawn's target home workstream (workstreams §10), checked when queued + * and again at claim. NULL = the folder's (every task before W4). + */ + workstream: string | null; + /** The spawn's card confirmed an override or an `ask` folder (its approval card said so). */ + workstreamOverride: boolean; + /** The config revision that confirmation was given at: it holds only at that one. */ + workstreamRevision: string | null; status: DispatchTaskStatus; attempts: number; failureLimit: number; @@ -139,6 +148,9 @@ interface RawDispatchRow { attempt_id: string | null; generation: number; held_until: number | null; + workstream?: string | null; + workstream_override?: number | null; + workstream_revision?: string | null; created_at: number; updated_at: number; } @@ -157,6 +169,9 @@ function rowToDispatchTask(r: RawDispatchRow): DispatchTaskRow { // missing-column undefined and a stored NULL to the same "use default". provider: r.provider ?? null, model: r.model ?? null, + workstream: r.workstream ?? null, + workstreamOverride: r.workstream_override === 1, + workstreamRevision: r.workstream_revision ?? null, status: r.status, attempts: r.attempts, failureLimit: r.failure_limit, @@ -231,6 +246,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; @@ -415,6 +437,18 @@ export class Store { PRIMARY KEY (account_id, project_id, workdir, provider_id, model) ); + -- 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) + ); + -- 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. @@ -442,11 +476,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 @@ -606,6 +643,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. @@ -629,6 +697,10 @@ export class Store { this.#addColumnIfMissing("dispatch_tasks", "attempt_id", "TEXT"); this.#addColumnIfMissing("dispatch_tasks", "generation", "INTEGER NOT NULL DEFAULT 0"); this.#addColumnIfMissing("dispatch_tasks", "held_until", "INTEGER"); + // A spawn's target workstream (workstreams W4); NULL = the folder's. + this.#addColumnIfMissing("dispatch_tasks", "workstream", "TEXT"); + this.#addColumnIfMissing("dispatch_tasks", "workstream_override", "INTEGER"); + this.#addColumnIfMissing("dispatch_tasks", "workstream_revision", "TEXT"); // AFTER the ALTERs, never inside the CREATE block above. On an existing // database `CREATE TABLE IF NOT EXISTS` is a no-op, so an index declared // there would run against a table that does not have the column yet and @@ -1092,6 +1164,10 @@ export class Store { provider?: string; /** Per-child model, already resolved against `provider`. */ model?: string; + /** A spawn's target workstream, and whether it was confirmed past the folder's claim. */ + workstream?: string; + workstreamOverride?: boolean; + workstreamRevision?: string; failureLimit: number; createdBy: string; /** Dispatch group for a barrier fan-out; omit for a standalone task. */ @@ -1111,8 +1187,8 @@ export class Store { `INSERT INTO dispatch_tasks (id, account_id, project_id, kind, shape, target_session, workdir, prompt, provider, model, failure_limit, created_by, group_id, - group_ordinal, owner_daemon, run_id, attempt_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + group_ordinal, owner_daemon, run_id, attempt_id, workstream, workstream_override, workstream_revision, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( task.id, @@ -1132,6 +1208,9 @@ export class Store { task.ownerDaemon ?? null, task.runId ?? null, task.attemptId ?? null, + task.workstream ?? null, + task.workstreamOverride ? 1 : null, + task.workstreamRevision ?? null, task.now, task.now, ); @@ -1802,14 +1881,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; @@ -1823,36 +1902,50 @@ 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); + } + + /** + * 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 })); } /** @@ -1862,11 +1955,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; @@ -1910,6 +2003,35 @@ export class Store { .run(scope.accountId, scope.projectId, scope.workdir, server, fingerprint, approvedBy); } + /** 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; + } + + /** Every recorded folder by workstream, across tenants (the read fence: workstreams are daemon-wide config). */ + workstreamFoldersByWorkstream(): Map { + const rows = this.#db.prepare("SELECT DISTINCT workstream, workdir FROM workstream_folders").all() as { workstream: string; workdir: string }[]; + const out = new Map(); + for (const r of rows) { + const list = out.get(r.workstream) ?? []; + list.push(r.workdir); + out.set(r.workstream, list); + } + return out; + } + + 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); + } + /** The (server, fingerprint) pairs a tenant approved for a workdir. */ workdirMcpApprovals(scope: { accountId: string; projectId: string; workdir: string }): Set { const rows = this.#db diff --git a/src/daemon/workstreams/binding.ts b/src/daemon/workstreams/binding.ts index cb55651c..5480f67a 100644 --- a/src/daemon/workstreams/binding.ts +++ b/src/daemon/workstreams/binding.ts @@ -6,6 +6,20 @@ import type { WorkstreamSource } from "../../protocol/types.js"; +/** + * Another workstream a session may read (§7.2): its folders through file + * tools and the sandbox (read-only), its memory through recall. Never its + * servers, env or accounts, and never a write. + */ +export interface WorkstreamReference { + workstream: string; + /** Folders inside it; absent = all of its folders. */ + paths?: string[]; + /** The subject that granted it (an `ask` workstream: the owner who confirmed). */ + grantedBy: string; + grantedAt: string; +} + /** A session's home, as persisted in its meta (TranscriptMeta.workstream). */ export interface WorkstreamBinding { /** Absent = no home (`source: "none"`). */ @@ -14,8 +28,57 @@ export interface WorkstreamBinding { matchedBy?: string; /** The config revision the binding was made under. */ revision: string; + references?: WorkstreamReference[]; + /** + * Every workstream the session has read from through a reference, for its + * lifetime (§14 q3): a later move or fork follows each one's share policy + * as it is then, even after the reference is gone. + */ + readFrom?: string[]; + /** + * The stored binding couldn't be read back, so what the session read from + * is unknown: it gets no home, and is never moved to another workstream + * (that would carry its conversation past a policy nobody can check). + */ + sealed?: true; +} + +const ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; +const MAX_REFERENCES = 32; +const MAX_REFERENCE_PATHS = 64; + +function readReference(raw: unknown): WorkstreamReference | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; + const r = raw as Record; + if (typeof r.workstream !== "string" || !ID.test(r.workstream)) return undefined; + if (typeof r.grantedBy !== "string" || r.grantedBy.length === 0 || r.grantedBy.length > 512) return undefined; + if (typeof r.grantedAt !== "string" || r.grantedAt.length > 64) return undefined; + if (r.paths !== undefined) { + if (!Array.isArray(r.paths) || r.paths.length > MAX_REFERENCE_PATHS) return undefined; + if (!r.paths.every((p) => typeof p === "string" && p.startsWith("/") && p.length <= 4096 && !p.includes("\0"))) return undefined; + } + return { + workstream: r.workstream, + ...(r.paths !== undefined ? { paths: [...(r.paths as string[])] } : {}), + grantedBy: r.grantedBy, + grantedAt: r.grantedAt, + }; } +/** Why a reference's folders can't be stored (the same bounds a binding is read back with), or undefined. */ +export function referencePathsProblem(paths: readonly string[] | undefined): string | undefined { + if (paths === undefined) return undefined; + if (paths.length === 0) return "A reference names its folders, or none for all of them"; + if (paths.length > MAX_REFERENCE_PATHS) return `A reference names at most ${MAX_REFERENCE_PATHS} folders`; + const bad = paths.find((p) => !p.startsWith("/") || p.length > 4096 || p.includes("\0")); + return bad === undefined ? undefined : `Not an absolute folder: ${JSON.stringify(bad)}`; +} + +/** Most references one session holds. */ +export const REFERENCE_LIMIT = MAX_REFERENCES; +/** Most workstreams one session's read-from record holds. */ +export const READ_FROM_LIMIT = 256; + const SOURCES: ReadonlySet = new Set(["explicit", "matched", "default", "override", "migrated", "reserved", "none"]); /** @@ -29,13 +92,34 @@ export function readWorkstreamBinding(raw: unknown): WorkstreamBinding | undefin const r = raw as Record; if (typeof r.source !== "string" || !SOURCES.has(r.source)) return undefined; if (typeof r.revision !== "string" || r.revision.length > 128) return undefined; - if (r.id !== undefined && (typeof r.id !== "string" || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(r.id))) return undefined; + if (r.id !== undefined && (typeof r.id !== "string" || !ID.test(r.id))) return undefined; if (r.matchedBy !== undefined && (typeof r.matchedBy !== "string" || r.matchedBy.length > 1024)) return undefined; if ((r.source === "none") !== (r.id === undefined)) return undefined; + // References and the read-from record fail the whole binding when malformed: + // a reference dropped silently would be harmless, but a read-from entry + // dropped would let a later move skip a share policy. + let references: WorkstreamReference[] | undefined; + if (r.references !== undefined) { + if (!Array.isArray(r.references) || r.references.length > MAX_REFERENCES) return undefined; + references = []; + for (const raw of r.references) { + const ref = readReference(raw); + if (!ref) return undefined; + references.push(ref); + } + } + let readFrom: string[] | undefined; + if (r.readFrom !== undefined) { + if (!Array.isArray(r.readFrom) || r.readFrom.length > READ_FROM_LIMIT || !r.readFrom.every((x) => typeof x === "string" && ID.test(x))) return undefined; + readFrom = [...new Set(r.readFrom as string[])]; + } return { ...(typeof r.id === "string" ? { id: r.id } : {}), source: r.source as WorkstreamSource, ...(typeof r.matchedBy === "string" ? { matchedBy: r.matchedBy } : {}), revision: r.revision, + ...(references && references.length > 0 ? { references } : {}), + ...(readFrom && readFrom.length > 0 ? { readFrom } : {}), + ...(r.sealed === true ? { sealed: true as const } : {}), }; } diff --git a/src/daemon/workstreams/egress.ts b/src/daemon/workstreams/egress.ts new file mode 100644 index 00000000..5ca08e79 --- /dev/null +++ b/src/daemon/workstreams/egress.ts @@ -0,0 +1,45 @@ +/** + * Workstream-homed egress (docs/workstreams-design.md §10, G6) — the stub W4 + * leaves until P6's Slack and email egress exists. + * + * The rule it holds: anything the conductor sends to the world names a + * target workstream and is carried out by a worker homed there, so a reply in + * one line of work is never drafted by a session that has seen another's. + * `planEgress` is that decision, pure; nothing calls it yet but its tests. + * When an egress verb lands it plans first, then spawns the worker it names + * (fleet_spawn's path: `#fleetSpawnHome`), with the plan on the approval card. + */ + +import type { WorkstreamService } from "./service.js"; + +export interface EgressRequest { + /** The workstream the message belongs to: required, never inferred. */ + workstream?: string; + /** Where it goes: a channel kind (`slack`, `email`, …) and its recipient. */ + channel: string; + recipient: string; + /** Who asks: only the conductor plans egress. */ + from: { role?: string; workstream?: string }; +} + +export type EgressPlan = + | { ok: true; worker: { workstream: string }; card: { workstream: string; channel: string; recipient: string } } + | { ok: false; error: string }; + +/** + * Where an outbound message is drafted and sent: by a worker homed in its + * workstream, never by the conductor itself, never in a workstream not named. + */ +export function planEgress(service: Pick, req: EgressRequest): EgressPlan { + if (req.from.role !== "conductor") return { ok: false, error: "Only the conductor plans egress; a worker sends what it was spawned to send" }; + if (!service.configured) return { ok: false, error: "Egress needs workstreams: each message is sent by a worker homed in its workstream" }; + if (!req.workstream) return { ok: false, error: "Name the workstream this message belongs to: egress is never sent from a guessed one" }; + const problem = service.choiceProblem(req.workstream); + if (problem) return { ok: false, error: problem }; + if (!req.channel || !req.recipient) return { ok: false, error: "Name the channel and the recipient" }; + return { + ok: true, + worker: { workstream: req.workstream }, + card: { workstream: req.workstream, channel: req.channel, recipient: req.recipient }, + }; +} diff --git a/src/daemon/workstreams/folders.ts b/src/daemon/workstreams/folders.ts new file mode 100644 index 00000000..8978e85e --- /dev/null +++ b/src/daemon/workstreams/folders.ts @@ -0,0 +1,162 @@ +/** + * A workstream's folders on this machine (docs/workstreams-design.md §7): the + * concrete directories its path rules name, plus the folders recorded as its + * home (`workstream_folders`: a remote rule names no path). What the read + * fence is built from — the sandbox's roots and hidden paths, the gate's + * check. + * + * Bounded and link-free: a rule is expanded one segment at a time with + * `readdir`, never `**`, at most `FOLDER_LIMIT` folders, and a symlinked + * entry is never followed (a link an agent can plant must not place another + * workstream's folder, nor make one look like the home's). What can't be + * expanded is said in `limits`, never guessed at. + */ + +import { lstatSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Most folders one workstream's rules expand to. */ +export const FOLDER_LIMIT = 512; + +const GLOB_CHARS = /[*?[\]{}]/; + +export interface ExpandedFolders { + folders: string[]; + /** Rules that couldn't be expanded fully, and why. */ + limits: string[]; +} + +/** A directory reached through no link (an entry a glob matched). */ +function isDir(path: string): boolean { + try { + return lstatSync(path).isDirectory(); + } catch { + return false; + } +} + +/** A directory, links followed: a segment the rule names literally (the user wrote it). */ +function isDirFollowed(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** + * The existing directories a path rule (absolute, `~` expanded, normalized) + * names. A literal rule is its own folder when it is a directory. + */ +export function expandPathRule(rule: string, limit = FOLDER_LIMIT): ExpandedFolders { + const limits: string[] = []; + const segments = rule.split("/").filter((s) => s.length > 0); + let level = ["/"]; + for (const seg of segments) { + if (seg === "**") { + // `/**` at the end is normalized away; one in the middle names any depth. + limits.push(`${rule}: a \`**\` in the middle isn't expanded`); + return { folders: [], limits }; + } + const next: string[] = []; + if (!GLOB_CHARS.test(seg)) { + for (const dir of level) if (isDirFollowed(join(dir, seg))) next.push(join(dir, seg)); + } else { + const glob = new Bun.Glob(seg); + for (const dir of level) { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + continue; + } + for (const name of names) { + if (!glob.match(name)) continue; + const path = join(dir, name); + if (isDir(path)) next.push(path); + if (next.length > limit) { + limits.push(`${rule}: more than ${limit} folders`); + return { folders: [], limits }; + } + } + } + } + level = next; + if (level.length === 0) break; + } + return { folders: segments.length === 0 ? ["/"] : level, limits }; +} + +/** + * The repository top of `dir`: the nearest ancestor holding `.git` (a dir or + * a file), or `dir` itself when there is none. A recorded home folder is a + * session's workdir, which may be a package inside a repository: the fence is + * the whole repository. Never `stopAt` (the home) or anything holding it: a + * home that is itself a repository (dotfiles) must not make every folder in + * it one workstream's. + */ +export function repositoryTopOf(dir: string, stopAt?: string): string { + let at = dir; + for (;;) { + if (at === "/" || (stopAt !== undefined && within(stopAt, at))) return dir; + try { + lstatSync(join(at, ".git")); + return at; + } catch {} + const up = dirname(at); + if (up === at) return dir; + at = up; + } +} + +/** Whether `path` is `folder` or inside it (both absolute, normalized). */ +export function within(path: string, folder: string): boolean { + return folder === "/" || path === folder || path.startsWith(`${folder}/`); +} + +/** The folders with any folder inside another dropped (the outer one kept). */ +export function outermost(folders: Iterable): string[] { + const sorted = [...new Set(folders)].sort((a, b) => a.length - b.length); + const out: string[] = []; + for (const f of sorted) if (!out.some((o) => within(f, o))) out.push(f); + return out; +} + +/** Most entries {@link carveOut} hides before it gives up (and the folder is hidden whole). */ +export const CARVE_LIMIT = 2000; + +/** + * What to hide of `folder` so that only `keep` (paths inside it) stays + * visible: every entry on the way to a kept path that doesn't lead to one, + * at each level. Null when that takes more than `limit` entries or a level + * can't be listed: then the folder is hidden whole (the kept paths with it — + * failing closed). + */ +export function carveOut(folder: string, keep: readonly string[], limit = CARVE_LIMIT): string[] | null { + const out: string[] = []; + const walk = (dir: string): boolean => { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return false; + } + for (const name of names) { + const path = join(dir, name); + if (keep.some((k) => within(path, k))) continue; // kept (or inside one) + if (keep.some((k) => within(k, path))) { + // Leads to a kept path: hide its other entries, never follow a link. + let real = false; + try { + real = lstatSync(path).isDirectory(); + } catch {} + if (!real || !walk(path)) return false; + continue; + } + out.push(path); + if (out.length > limit) return false; + } + return true; + }; + return walk(folder) ? out : null; +} diff --git a/src/daemon/workstreams/gate.ts b/src/daemon/workstreams/gate.ts new file mode 100644 index 00000000..b0684bb2 --- /dev/null +++ b/src/daemon/workstreams/gate.ts @@ -0,0 +1,122 @@ +/** + * The workstream fence at the tool gate (docs/workstreams-design.md §7.3): + * a file tool reading or searching another workstream's folder, or writing + * one or a reference (read-only), is refused — in every mode, on every + * backend, sandboxed or not. The sandbox fences commands (its hidden paths); + * this fences the file tools that run outside it and every tool of a session + * without one. + * + * Pure: the fence comes from the session (WorkstreamService.fenceFor). + */ + +import { realpathSync } from "node:fs"; +import { writeTargets } from "../held-writes.js"; +import { isSearchTool, toolPathForms, toolReadPaths } from "../secret-paths.js"; +import { within } from "./folders.js"; +import type { WorkstreamFence } from "./service.js"; + +export interface FenceRefusal { + /** The path as the call named it. */ + path: string; + /** The workstream whose folder it is. */ + workstream: string; + kind: "read" | "search" | "write" | "write_reference" | "unresolved"; +} + +function realOr(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +/** Each fenced folder in both spellings: as found and real. */ +function forms(folders: readonly string[]): string[] { + return [...new Set(folders.flatMap((f) => [f, realOr(f)]))]; +} + +/** + * The fence's verdict on one call: null when it stays out of other + * workstreams' folders (and writes no reference), else why not. + */ +interface Prepared { + readable: string[]; + foreign: Array<{ workstream: string; folders: Array<{ d: string; keep: string[] }> }>; + references: Array<{ workstream: string; folders: string[] }>; + mine: string[]; +} + +/** Each fence's folders in both spellings, worked out once per fence (the gate runs on every call). */ +const prepared = new WeakMap(); + +function prepare(fence: WorkstreamFence): Prepared { + const hit = prepared.get(fence); + if (hit) return hit; + const readable = forms(fence.readable); + const p: Prepared = { + readable, + // Each foreign folder with what is carved out of it: the readable paths inside it (never one holding it). + foreign: fence.foreign.map((f) => ({ workstream: f.workstream, folders: forms(f.folders).map((d) => ({ d, keep: readable.filter((r) => within(r, d)) })) })), + references: fence.references.map((r) => ({ workstream: r.workstream, folders: forms(r.folders) })), + // Where a write is the session's own: its home's folders and its own folder. + mine: forms([...fence.home, ...(fence.own ?? [])]), + }; + prepared.set(fence, p); + return p; +} + +export function fenceRefusal(toolName: string, input: unknown, opts: { workdir: string; cwd?: string | null; fence: WorkstreamFence }): FenceRefusal | null { + const base = typeof opts.cwd === "string" ? opts.cwd : opts.workdir; + // A relative path where the backend didn't say where it runs (null: it may have moved) can't be + // judged: refused while there is something to fence, never judged from a guess. + if (opts.cwd === null && opts.fence.foreign.length > 0) { + const writesRaw = input && typeof input === "object" ? (writeTargets(toolName, input as Record) ?? []) : []; + const relative = [...toolReadPaths(toolName, input), ...writesRaw.filter((p): p is string => typeof p === "string")].find((p) => !p.startsWith("/") && p !== "~" && !p.startsWith("~/")); + if (relative !== undefined) return { path: relative, workstream: opts.fence.workstream ?? "", kind: "unresolved" }; + } + const { foreign, references, mine } = prepare(opts.fence); + const inForeign = (p: string) => foreign.find((f) => f.folders.some(({ d, keep }) => within(p, d) && !keep.some((k) => within(p, k)))); + // Writes first: a write tool's path is also a read key, and the refusal should say it writes. + const writes = input && typeof input === "object" ? writeTargets(toolName, input as Record) : null; + for (const raw of writes ?? []) { + if (!raw) continue; + for (const p of toolPathForms(raw, base)) { + const hit = inForeign(p); + if (hit) return { path: raw, workstream: hit.workstream, kind: "write" }; + // A reference is read-only; a folder of the home's inside one is the home's. + const ref = references.find((r) => r.folders.some((d) => within(p, d))); + if (ref && !mine.some((h) => within(p, h))) return { path: raw, workstream: ref.workstream, kind: "write_reference" }; + } + } + const search = isSearchTool(toolName); + for (const raw of toolReadPaths(toolName, input)) { + for (const p of toolPathForms(raw, base)) { + const hit = inForeign(p); + if (hit) return { path: raw, workstream: hit.workstream, kind: "read" }; + // A search whose root holds a fenced folder reads into it. + if (search) { + // (A folder kept whole, a reference's, is no more fenced than any other readable one.) + const holds = foreign.find((f) => f.folders.some(({ d, keep }) => within(d, p) && !keep.some((k) => within(d, k)))); + if (holds) return { path: raw, workstream: holds.workstream, kind: "search" }; + } + } + } + return null; +} + +/** What the agent is told. */ +export function fenceRefusalText(r: FenceRefusal): string { + switch (r.kind) { + case "read": + return `${r.path} is in workstream "${r.workstream}"'s folders, which this session's workstream may not read. Ask the owner to add a reference to "${r.workstream}" if it should.`; + case "search": + return `Searching ${r.path} would read workstream "${r.workstream}"'s folders, which this session's workstream may not read: search a narrower folder.`; + case "write": + return `${r.path} is in workstream "${r.workstream}"'s folders: this session's workstream may not write there.`; + case "unresolved": + return `codeoid can't tell where ${r.path} is (a relative path, and where the call runs isn't known), so it can't be checked against other workstreams' folders: use an absolute path.`; + case "write_reference": + return `${r.path} is in workstream "${r.workstream}", which this session references read-only: write in the session's own workstream instead.`; + } +} diff --git a/src/daemon/workstreams/grant.ts b/src/daemon/workstreams/grant.ts new file mode 100644 index 00000000..887742b3 --- /dev/null +++ b/src/daemon/workstreams/grant.ts @@ -0,0 +1,50 @@ +/** + * 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 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}"` + : `MCP server "${name}" is not available to a session with no workstream`; +} 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/override.ts b/src/daemon/workstreams/override.ts new file mode 100644 index 00000000..b30d2b66 --- /dev/null +++ b/src/daemon/workstreams/override.ts @@ -0,0 +1,70 @@ +/** + * 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; + } + + /** Whether `token` is live and bound to exactly this request. Spends nothing. */ + valid(token: string, binding: OverrideBinding): boolean { + this.#sweep(); + const held = this.#tokens.get(hashOf(token)); + if (!held) return false; + const want = Buffer.from(keyOf(binding)); + const have = Buffer.from(held.key); + 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; + } + + #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/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..ff2b4670 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -14,19 +14,61 @@ import { createHash } from "node:crypto"; import { realpathSync } from "node:fs"; import type { SessionWorkstreamInfo, WorkstreamResolution, WorkstreamSummary } from "../../protocol/types.js"; import { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; +import { CARVE_LIMIT, carveOut, expandPathRule, repositoryTopOf, within } from "./folders.js"; 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"; +import type { CredentialDecl } from "../credentials/schema.js"; +import type { GrantCredential, McpGrant } from "./grant.js"; -export { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; +export { readWorkstreamBinding, type WorkstreamBinding, type WorkstreamReference } from "./binding.js"; + +/** + * A session's read fence (§7.3): the folders it may read (its home's, and + * its references'), and every other workstream's, which it may not. Built + * from the folders on this machine; `limits` says what couldn't be expanded. + */ +export interface WorkstreamFence { + workstream?: string; + /** The home's folders: readable and, as the sandbox allows, writable. */ + home: string[]; + /** The session's own folder (its workdir; its repository's top where no other workstream holds it). */ + own: string[]; + /** Readable only, never written. */ + references: Array<{ workstream: string; folders: string[] }>; + /** + * Every other workstream's folders: neither read nor written, but for what + * `readable` keeps inside them (a folder of another's holding the home, a + * reference's own folder, the session's workdir). + */ + foreign: Array<{ workstream: string; folders: string[] }>; + /** + * What the session may read wherever it is: its workdir and workspace, its + * home's folders, and its references' (as granted: a reference's paths, + * else its folders). Inside a foreign folder these stay readable; the + * rest of that folder doesn't. + */ + readable: string[]; + /** + * What a sandbox hides for it: each foreign folder whole, or, where + * something readable is inside one, every other entry on the way to it + * (folders.ts `carveOut`; hidden whole when that can't be done). + */ + hidden: string[]; + limits: string[]; +} + +/** What a share policy says to a read or a move: allowed, the owner asked, or refused (with why). */ +export type ShareVerdict = { ok: true; ask: string[] } | { ok: false; error: string }; /** The reserved home of the conductor (§10). */ 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. */ @@ -40,6 +82,15 @@ export interface ResumeMetaFacts { parentId?: string; } +/** The real path, or the path itself when it can't be resolved (missing). */ +function realOr(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + export class WorkstreamService { #cfg: WorkstreamsConfig; #effective: Map; @@ -48,9 +99,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 = []; @@ -69,7 +122,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); } /** @@ -103,10 +157,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; @@ -120,6 +197,11 @@ export class WorkstreamService { return this.#cfg.isolateGlobalServers; } + /** Whether any workstream is `closed` (its text withheld from the conductor, under isolation). */ + get anyClosed(): boolean { + return [...this.#effective.values()].some((w) => !w.abstract && w.share === "closed"); + } + get defaultId(): string | undefined { return this.#cfg.default; } @@ -128,6 +210,194 @@ export class WorkstreamService { return this.#effective.get(id); } + /** + * A workstream's folders on this machine: the directories its path rules + * name (each spelling: as written and, for `~`, the home's real path), plus + * `recorded` (folders recorded as its home, repository tops). + */ + /** Each workstream's path-rule folders on disk (no recorded ones), once per fence. */ + #ruleFolders(): Map { + const out = new Map(); + for (const m of this.#matchables) { + if (m.abstract) continue; + const folders: string[] = []; + const limits: string[] = []; + for (const rule of m.paths) { + const e = expandPathRule(rule); + folders.push(...e.folders); + limits.push(...e.limits); + } + out.set(m.id, { folders, limits }); + } + return out; + } + + /** + * A workstream's folders on this machine: the directories its path rules + * name (each spelling: as written and, for `~`, the home's real path), plus + * `recorded` (workdirs its sessions were homed in by their folder's match), + * each as its repository's top — unless another workstream has a folder + * inside that top (a monorepo split by path rules): then only the workdir. + * Never the home or what holds it. Folders are kept as found, nested ones + * too: each is its own (a nested one may be fenced where its parent isn't). + */ + foldersOf(id: string, recorded: readonly string[] = [], rules: Map = this.#ruleFolders()): { folders: string[]; limits: string[] } { + const m = this.#matchables.find((x) => x.id === id); + if (!m || m.abstract) return { folders: [], limits: [] }; + const own = rules.get(id) ?? { folders: [], limits: [] }; + const limits: string[] = [...own.limits]; + const others = [...rules.entries()].filter(([k]) => k !== id).flatMap(([, v]) => v.folders); + const found: string[] = [...own.folders]; + for (const w of recorded) { + const top = repositoryTopOf(w, this.#reader.home); + found.push(top !== w && others.some((f) => within(f, top)) ? w : top); + } + // The home (or what holds it) is never one workstream's folder: fencing it would fence everything. + const homes = [this.#reader.home, this.#realHome()]; + const kept = found.filter((f) => { + const holds = homes.some((h) => within(h, f)); + if (holds) limits.push(`${id}: ${f} is the home or holds it, so it isn't fenced`); + return !holds; + }); + return { folders: [...new Set(kept)], limits }; + } + + /** + * The read fence of a session homed by `binding`, or undefined when + * isolation is off (nothing is fenced then, as before W3). `recorded(id)` + * gives a workstream's recorded home folders. + */ + fenceFor( + binding: WorkstreamBinding | undefined, + recorded: (id: string) => readonly string[] = () => [], + own: readonly string[] = [], + ): WorkstreamFence | undefined { + if (!this.configured || !this.#cfg.isolateGlobalServers) return undefined; + const limits: string[] = []; + const homeId = binding?.id && binding.source !== "reserved" && this.#effective.has(binding.id) ? binding.id : undefined; + const rules = this.#ruleFolders(); + const cache = new Map(); + const of = (id: string) => { + let folders = cache.get(id); + if (!folders) { + const r = this.foldersOf(id, recorded(id), rules); + limits.push(...r.limits); + folders = r.folders; + cache.set(id, folders); + } + return folders; + }; + const home = homeId ? of(homeId) : []; + const references: WorkstreamFence["references"] = []; + for (const ref of binding?.references ?? []) { + const ws = this.#effective.get(ref.workstream); + // A reference whose workstream is gone or closed now gives nothing (§8). + if (!ws || ws.abstract || ws.share === "closed" || ref.workstream === homeId) continue; + const all = of(ref.workstream); + // A granted path counts only where it really is inside that workstream's folders, as written and + // as it resolves now: a link put there later (or a `..`) never leads the grant elsewhere. + const inside = (p: string) => all.some((f) => within(p, f) || within(p, realOr(f))); + const folders = ref.paths ? ref.paths.filter((p) => inside(p) && inside(realOr(p))) : all; + // Kept with no folders here too: it still gives its memory (§7.2). + references.push({ workstream: ref.workstream, folders }); + } + // The session's own: its workdir always (a session moved, or homed past a claim, keeps its folder); + // the rest (its repository's top) only where no other workstream's folder holds it — a package of a + // monorepo another workstream holds doesn't make the whole repository its own. + const [workdir, ...extra] = own; + const othersFolders = [...this.#effective.values()].filter((w) => !w.abstract && w.id !== homeId).flatMap((w) => of(w.id)); + const ownKept = [...(workdir ? [workdir] : []), ...extra.filter((o) => !othersFolders.some((f) => within(o, f)))]; + // What stays readable even inside another workstream's folder: the session's own, its home's + // folders, and what its references grant — exactly that, never the folder holding a granted path. + const readable = [...new Set([...ownKept, ...home, ...references.flatMap((r) => r.folders)])]; + const foreign: WorkstreamFence["foreign"] = []; + for (const ws of this.#effective.values()) { + if (ws.abstract || ws.id === homeId) continue; + // Every one of its folders, nested in the home's or holding it (the + // more specific rule decides): what is readable inside one is carved + // out of it (gate.ts, the sandbox). A reference's workstream is here + // too: only what the reference grants is carved out. + const folders = of(ws.id); + if (folders.length > 0) foreign.push({ workstream: ws.id, folders }); + } + const hidden: string[] = []; + for (const d of foreign.flatMap((f) => f.folders)) { + const keep = readable.filter((r) => within(r, d)); + if (keep.length === 0) { + hidden.push(d); + continue; + } + const carved = keep.some((k) => k === d) ? [] : (carveOut(d, keep) ?? carveOut(d, keep, CARVE_LIMIT * 10)); + if (carved === null) { + limits.push(`${d}: too much to carve around what this session reads there, so it is hidden whole`); + hidden.push(d); + } else hidden.push(...carved); + } + return { ...(homeId ? { workstream: homeId } : {}), home, own: ownKept, references, foreign, readable, hidden: [...new Set(hidden)], limits: [...new Set(limits)] }; + } + + /** + * Whether a session homed in `home` may read `target` (a reference, + * §7.1): `open` yes, `ask` with its owner's confirm, `closed` never. + */ + referenceVerdict(home: string | undefined, ref: { workstream: string; paths?: readonly string[] }, recorded: readonly string[] = []): ShareVerdict { + const ws = this.#effective.get(ref.workstream); + if (!ws) return { ok: false, error: `Unknown workstream "${ref.workstream}"` }; + if (ws.abstract) return { ok: false, error: `"${ws.id}" is a base workstream: it has no folders of its own` }; + if (ws.id === home) return { ok: false, error: `"${ws.id}" is this session's own workstream` }; + if (ws.share === "closed") return { ok: false, error: `Workstream "${ws.id}" is closed: other workstreams can't read it` }; + if (ref.paths) { + const { folders } = this.foldersOf(ws.id, recorded); + const outside = ref.paths.filter((p) => !folders.some((f) => within(p, f))); + if (outside.length > 0) return { ok: false, error: `Not ${ws.id}'s folders: ${outside.join(", ")}` }; + } + return { ok: true, ask: ws.share === "ask" ? [ws.id] : [] }; + } + + /** + * Whether a session homed in `chosen` may live in `workdir` when the + * folder is another workstream's (its own match, a claim of any kind): its + * workdir stays readable to it, so that is a read of the folder's + * workstream, judged by its `share` like a reference. Undefined `folder`: + * the folder is no other workstream's. Only under isolation. + */ + folderVerdict(workdir: string, chosen: string | undefined, given?: string): ShareVerdict & { folder?: string } { + if (!this.configured || !this.#cfg.isolateGlobalServers || !chosen) return { ok: true, ask: [] }; + const folder = this.resolve(workdir, undefined, given).home; + if (!folder || folder === chosen) return { ok: true, ask: [] }; + const ws = this.#effective.get(folder); + if (!ws) return { ok: true, ask: [] }; + if (ws.share === "closed") return { ok: false, error: `This folder is workstream "${folder}"'s, which is closed: a session of another workstream can't work in it` }; + return { ok: true, ask: ws.share === "ask" ? [folder] : [], folder }; + } + + /** + * Whether a session may move to `target` (§5.3): a move carries its whole + * transcript, so it is a share from the old home and from every workstream + * it has read from. From no home or the default workstream it is always + * allowed. + */ + moveVerdict(binding: WorkstreamBinding | undefined, target: string): ShareVerdict { + const problem = this.choiceProblem(target); + if (problem) return { ok: false, error: problem }; + if (binding?.source === "reserved") return { ok: false, error: "The conductor's home can't be changed" }; + if (binding?.sealed) return { ok: false, error: "This session's workstream record couldn't be read back, so what it read from is unknown: it can't move to another workstream" }; + const from = new Set(binding?.readFrom ?? []); + if (binding?.id && binding.id !== this.#cfg.default) from.add(binding.id); + from.delete(target); + const ask: string[] = []; + for (const id of [...from].sort()) { + const ws = this.#effective.get(id); + // A workstream since deleted has no policy left to keep. + if (!ws) continue; + if (ws.share === "closed") { + return { ok: false, error: id === binding?.id ? `Workstream "${id}" is closed: its sessions can't move to another` : `This session read from "${id}", which is closed: it can't move to another workstream` }; + } + if (ws.share === "ask") ask.push(id); + } + return { ok: true, ask }; + } + /** Why `id` can't be chosen as a home, or undefined when it can. */ choiceProblem(id: string): string | undefined { const ws = this.#effective.get(id); @@ -153,6 +423,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), })); } @@ -210,7 +481,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); @@ -231,13 +507,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): 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 { @@ -247,6 +544,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.`); @@ -307,7 +616,8 @@ export class WorkstreamService { parsed.source === "reserved" || parsed.id === CONDUCTOR_WORKSTREAM || (parsed.id !== undefined && this.#effective.get(parsed.id)?.abstract === true); - result = bad ? { binding: none(), persist: true } : { binding: parsed, persist: false }; + // Unreadable: no home, and sealed (what it read from is unknown, so it never moves). + result = bad ? { binding: { ...none(), sealed: true }, persist: true } : { binding: parsed, persist: false }; } else { const parent = m.parentId && depth < 32 ? byId.get(m.parentId) : undefined; const inherited = parent ? decide(parent, depth + 1).binding : undefined; @@ -336,6 +646,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 }; @@ -348,7 +684,11 @@ export class WorkstreamService { */ info(binding: WorkstreamBinding | undefined): SessionWorkstreamInfo | undefined { if (!binding || !this.configured) return undefined; - if (binding.id === undefined) return { source: binding.source }; + const reads = { + ...(binding.references?.length ? { references: binding.references.map((r) => ({ workstream: r.workstream, ...(r.paths ? { paths: [...r.paths] } : {}) })) } : {}), + ...(binding.readFrom?.length ? { readFrom: [...binding.readFrom] } : {}), + }; + if (binding.id === undefined) return { source: binding.source, ...reads }; if (binding.id === CONDUCTOR_WORKSTREAM && binding.source === "reserved") { return { id: CONDUCTOR_WORKSTREAM, name: "Conductor", source: "reserved" }; } @@ -358,6 +698,7 @@ export class WorkstreamService { ...(ws ? { name: ws.name, ...(ws.color ? { color: ws.color } : {}) } : { orphaned: true }), source: binding.source, ...(binding.matchedBy ? { matchedBy: binding.matchedBy } : {}), + ...reads, }; } } diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts index e7e8bbfd..577e86e4 100644 --- a/src/daemon/workstreams/workstreams.test.ts +++ b/src/daemon/workstreams/workstreams.test.ts @@ -8,6 +8,10 @@ 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 { expandPathRule, repositoryTopOf } from "./folders.js"; +import { fenceRefusal } from "./gate.js"; +import { EnvFileSecretStore } from "../credentials/store.js"; const ws = (id: string, over: Partial = {}): MatchableWorkstream => ({ id, @@ -164,6 +168,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); @@ -326,7 +337,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); @@ -505,9 +516,270 @@ describe("WorkstreamService", () => { expect(service([{ id: "b" }]).revision).not.toBe(a.revision); }); - test("summaries list env names, never values", () => { + 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 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" } } }); + }); +}); + +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); + }); +}); + + +describe("W3: folders, the fence, share verdicts", () => { + const service = (items: unknown[], extra: Record = {}) => + new WorkstreamService(workstreamsSchema.parse({ isolateGlobalServers: true, items, ...extra }), new FolderReader({ home: tmp, sshConfig: "" })); + + test("a glob rule expands to the folders it names, never its literal head; a link is never followed", () => { + for (const d of ["W/acme-api", "W/acme-web", "W/other", "elsewhere"]) mkdirSync(join(tmp, d), { recursive: true }); + writeFileSync(join(tmp, "W", "acme-file"), ""); + symlinkSync(join(tmp, "elsewhere"), join(tmp, "W", "acme-link")); + expect(expandPathRule(join(tmp, "W", "acme-*")).folders.sort()).toEqual([join(tmp, "W", "acme-api"), join(tmp, "W", "acme-web")]); + expect(expandPathRule(join(tmp, "W", "missing")).folders).toEqual([]); + expect(expandPathRule(join(tmp, "W", "**", "x")).limits[0]).toContain("**"); + expect(expandPathRule(join(tmp, "W", "*"), 1).limits[0]).toContain("more than 1"); + }); + + test("the fence: every other workstream's folders are foreign; what is readable inside one is carved out", () => { + for (const d of ["W/acme-api/src", "W/acme-web", "W/hf", "W/mine/inner", "W/stray"]) mkdirSync(join(tmp, d), { recursive: true }); + writeFileSync(join(tmp, "W", "acme-api", ".env"), "x"); + const s = service([ + { id: "acme", share: "ask", match: { paths: ["~/W/acme-*"] } }, + { id: "hf", share: "closed", match: { paths: ["~/W/hf"] } }, + { id: "mine", share: "open", match: { paths: ["~/W/mine"] } }, + { id: "broad", match: { paths: ["~/W"] } }, + ]); + const src = join(tmp, "W", "acme-api", "src"); + const fence = s.fenceFor({ id: "mine", source: "matched", revision: "r", references: [{ workstream: "acme", paths: [src], grantedBy: "u", grantedAt: "t" }] }, () => [], [join(tmp, "W", "mine")])!; + expect(fence.home).toEqual([join(tmp, "W", "mine")]); + expect(fence.references).toEqual([{ workstream: "acme", folders: [src] }]); + expect(fence.readable.sort()).toEqual([join(tmp, "W", "mine"), src].sort()); + // acme's folder holding the granted path is foreign still (only `src` is carved out), and `broad`, + // which holds the home, is foreign with the home carved out of it. + expect(fence.foreign.map((f) => f.workstream).sort()).toEqual(["acme", "broad", "hf"]); + expect(fence.hidden).toContain(join(tmp, "W", "acme-web")); + expect(fence.hidden).toContain(join(tmp, "W", "hf")); + expect(fence.hidden).toContain(join(tmp, "W", "acme-api", ".env")); + expect(fence.hidden).toContain(join(tmp, "W", "stray")); + expect(fence.hidden).not.toContain(src); + expect(fence.hidden).not.toContain(join(tmp, "W", "mine")); + // A catch-all home: every narrower workstream inside it is fenced (the more specific rule decides). + const wide = s.fenceFor({ id: "broad", source: "matched", revision: "r" }, () => [], [join(tmp, "W")])!; + expect(wide.foreign.find((f) => f.workstream === "hf")?.folders).toEqual([join(tmp, "W", "hf")]); + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "hf", "x") }, { workdir: join(tmp, "W"), fence: wide })).toMatchObject({ workstream: "hf" }); + // A closed workstream's reference gives nothing, even one granted before. + const closed = s.fenceFor({ id: "mine", source: "matched", revision: "r", references: [{ workstream: "hf", grantedBy: "u", grantedAt: "t" }] })!; + expect(closed.references).toEqual([]); + // Recorded homes (a remote-rule workstream) count as its folders. + expect(s.fenceFor(undefined, (id) => (id === "hf" ? [join(tmp, "W", "hf"), "/srv/hf-clone"] : []))!.foreign.find((f) => f.workstream === "hf")?.folders.sort()).toEqual(["/srv/hf-clone", join(tmp, "W", "hf")].sort()); + // Isolation off: no fence. + expect(new WorkstreamService(workstreamsSchema.parse({ items: [{ id: "a" }] }), new FolderReader({ home: tmp, sshConfig: "" })).fenceFor(undefined)).toBeUndefined(); + }); + + test("a session's own workdir stays readable whatever its home (moved, or homed past a claim)", () => { + mkdirSync(join(tmp, "W", "mine"), { recursive: true }); + const s = service([{ id: "mine", match: { paths: ["~/W/mine"] } }, { id: "pub" }]); + const fence = s.fenceFor({ id: "pub", source: "explicit", revision: "r" }, () => [], [join(tmp, "W", "mine")])!; + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "mine", "own.ts") }, { workdir: join(tmp, "W", "mine"), fence })).toBeNull(); + expect(fenceRefusal("Grep", { pattern: "x" }, { workdir: join(tmp, "W", "mine"), fence })).toBeNull(); + }); + + test("the home is never a workstream's folder, nor a repository top for one (a dotfiles home)", () => { + mkdirSync(join(tmp, ".git"), { recursive: true }); + mkdirSync(join(tmp, "plain", "pkg"), { recursive: true }); + expect(repositoryTopOf(join(tmp, "plain", "pkg"), tmp)).toBe(join(tmp, "plain", "pkg")); + const s = service([{ id: "all", match: { paths: ["~"] } }, { id: "mine" }]); + expect(s.foldersOf("all").folders).toEqual([]); + expect(s.foldersOf("all").limits[0]).toContain("home"); + }); + + test("a malformed stored binding seals the session: no home, and never moved", () => { + const s = service([{ id: "a" }, { id: "b" }]); + const r = s.forResume([{ sessionId: "x", workdir: tmp, stored: { id: "a", source: "matched", revision: "r", references: "bad" } }])("x"); + expect(r.binding).toMatchObject({ source: "none", sealed: true }); + expect(s.moveVerdict(r.binding, "b")).toMatchObject({ ok: false }); + expect(readWorkstreamBinding(r.binding)).toMatchObject({ sealed: true }); + }); + + test("reference verdicts follow the share policy of the workstream read", () => { + mkdirSync(join(tmp, "W", "acme-api"), { recursive: true }); + const s = service([ + { id: "acme", share: "ask", match: { paths: ["~/W/acme-*"] } }, + { id: "hf", share: "closed" }, + { id: "pub", share: "open" }, + { id: "base", abstract: true }, + ]); + expect(s.referenceVerdict("pub", { workstream: "acme" })).toEqual({ ok: true, ask: ["acme"] }); + expect(s.referenceVerdict("acme", { workstream: "pub" })).toEqual({ ok: true, ask: [] }); + expect(s.referenceVerdict("pub", { workstream: "hf" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "base" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "pub" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "nope" })).toMatchObject({ ok: false }); + expect(s.referenceVerdict("pub", { workstream: "acme", paths: [join(tmp, "W", "acme-api", "src")] })).toEqual({ ok: true, ask: ["acme"] }); + expect(s.referenceVerdict("pub", { workstream: "acme", paths: ["/etc"] })).toMatchObject({ ok: false }); + }); + + test("move verdicts: the old home's policy and every workstream read from; from the default, always", () => { + const s = service([{ id: "acme", share: "ask" }, { id: "hf", share: "closed" }, { id: "pub", share: "open" }, { id: "scratch", share: "closed" }], { default: "scratch" }); + const b = (id: string, readFrom?: string[]) => ({ id, source: "matched" as const, revision: "r", ...(readFrom ? { readFrom } : {}) }); + expect(s.moveVerdict(b("pub"), "acme")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict(b("acme"), "pub")).toEqual({ ok: true, ask: ["acme"] }); + expect(s.moveVerdict(b("hf"), "pub")).toMatchObject({ ok: false }); + expect(s.moveVerdict(b("pub", ["hf"]), "acme")).toMatchObject({ ok: false }); + expect(s.moveVerdict(b("pub", ["hf"]), "hf")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict(b("scratch"), "acme")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict(undefined, "acme")).toEqual({ ok: true, ask: [] }); + expect(s.moveVerdict({ id: "conductor", source: "reserved", revision: "r" }, "pub")).toMatchObject({ ok: false }); + }); + + test("a binding's references and read-from record round-trip; a malformed one fails the binding", () => { + const good = { id: "a", source: "matched" as const, revision: "r", references: [{ workstream: "b", paths: ["/x"], grantedBy: "u", grantedAt: "t" }], readFrom: ["b", "b"] }; + expect(readWorkstreamBinding(good)).toEqual({ ...good, readFrom: ["b"] }); + expect(readWorkstreamBinding({ ...good, references: [{ workstream: "B!", grantedBy: "u", grantedAt: "t" }] })).toBeUndefined(); + expect(readWorkstreamBinding({ ...good, references: [{ workstream: "b", paths: ["rel"], grantedBy: "u", grantedAt: "t" }] })).toBeUndefined(); + expect(readWorkstreamBinding({ ...good, readFrom: ["ok", 3] })).toBeUndefined(); + }); +}); + +describe("W3: the fence at the gate", () => { + const fence = () => ({ + workstream: "mine", + home: [join(tmp, "mine")], + references: [{ workstream: "pub", folders: [join(tmp, "pub")] }], + foreign: [ + { workstream: "hf", folders: [join(tmp, "hf")] }, + { workstream: "nested", folders: [join(tmp, "mine", "vendor-x")] }, + { workstream: "pub", folders: [join(tmp, "pub")] }, + ], + readable: [join(tmp, "mine"), join(tmp, "pub")], + own: [join(tmp, "mine")], + hidden: [], + limits: [], + }); + beforeEach(() => { + for (const d of ["mine/vendor-x", "pub", "hf", "elsewhere"]) mkdirSync(join(tmp, d), { recursive: true }); + }); + const judge = (tool: string, input: Record, cwd?: string) => fenceRefusal(tool, input, { workdir: join(tmp, "mine"), ...(cwd ? { cwd } : {}), fence: fence() }); + + test("reads and writes in another workstream's folders are refused; the home and elsewhere aren't", () => { + expect(judge("Read", { file_path: join(tmp, "hf", "secret.ts") })).toMatchObject({ workstream: "hf", kind: "read" }); + expect(judge("Read", { file_path: join(tmp, "mine", "a.ts") })).toBeNull(); + expect(judge("Read", { file_path: "/etc/hosts" })).toBeNull(); + expect(judge("Read", { file_path: "vendor-x/x.ts" })).toMatchObject({ workstream: "nested" }); + expect(judge("Read", { file_path: "../hf/x" })).toMatchObject({ workstream: "hf" }); + expect(judge("Write", { file_path: join(tmp, "hf", "x") })).toMatchObject({ kind: "write" }); + expect(judge("write_file", { file_path: join(tmp, "hf", "x") })).toMatchObject({ kind: "write" }); + }); + + test("a reference is read, never written", () => { + expect(judge("Read", { file_path: join(tmp, "pub", "x") })).toBeNull(); + expect(judge("Edit", { file_path: join(tmp, "pub", "x") })).toMatchObject({ workstream: "pub", kind: "write_reference" }); + }); + + test("a search whose root holds a fenced folder reads into it", () => { + expect(judge("Grep", { path: tmp, pattern: "x" })).toMatchObject({ workstream: "hf", kind: "search" }); + expect(judge("Grep", { pattern: "x" })).toMatchObject({ workstream: "nested", kind: "search" }); + expect(judge("Grep", { path: join(tmp, "pub"), pattern: "x" })).toBeNull(); + expect(judge("Glob", { pattern: `${join(tmp, "hf")}/**/*.ts` })).toMatchObject({ workstream: "hf" }); + }); + + test("what other backends name is judged too: a glob's partial segment, ACP locations, pi's @ and find/ls", () => { + expect(judge("Glob", { pattern: `${join(tmp, "h")}?/*` })).toMatchObject({ workstream: "hf" }); + expect(judge("Read", { title: "read", locations: [join(tmp, "hf", "secret.ts")] })).toMatchObject({ workstream: "hf" }); + expect(judge("Read", { locations: [{ path: join(tmp, "hf", "secret.ts") }] })).toMatchObject({ workstream: "hf" }); + expect(judge("read", { path: `@${join(tmp, "hf", "secret.ts")}` })).toMatchObject({ workstream: "hf" }); + expect(judge("find", { path: tmp })).toMatchObject({ kind: "search" }); + expect(judge("ls", { path: tmp })).toMatchObject({ kind: "search" }); + }); + + test("a relative path where the cwd isn't known is refused, never guessed", () => { + expect(fenceRefusal("Read", { file_path: "x.ts" }, { workdir: join(tmp, "mine"), cwd: null, fence: fence() })).toMatchObject({ kind: "unresolved" }); + expect(fenceRefusal("Read", { file_path: join(tmp, "mine", "x.ts") }, { workdir: join(tmp, "mine"), cwd: null, fence: fence() })).toBeNull(); + }); + + test("a link into a fenced folder is judged where it leads", () => { + symlinkSync(join(tmp, "hf"), join(tmp, "mine", "link")); + expect(judge("Read", { file_path: join(tmp, "mine", "link", "x") })).toMatchObject({ workstream: "hf" }); + expect(judge("Read", { file_path: "x" }, join(tmp, "hf"))).toMatchObject({ workstream: "hf" }); + }); +}); + +describe("W3 audit round 2: monorepos and granted paths", () => { + const service = (items: unknown[]) => + new WorkstreamService(workstreamsSchema.parse({ isolateGlobalServers: true, items }), new FolderReader({ home: tmp, sshConfig: "" })); + + test("a session in one workstream's package of another's monorepo reads its package, not the repository", () => { + for (const d of ["W/mono/.git", "W/mono/sdk", "W/mono/core"]) mkdirSync(join(tmp, d), { recursive: true }); + writeFileSync(join(tmp, "W", "mono", "core", "x.ts"), "x"); + const s = service([ + { id: "a", share: "closed", claim: "exclusive", match: { paths: ["~/W/mono"] } }, + { id: "b", claim: "exclusive", match: { paths: ["~/W/mono/sdk"] } }, + ]); + const sdk = join(tmp, "W", "mono", "sdk"); + const fence = s.fenceFor({ id: "b", source: "matched", revision: "r" }, () => [], [sdk, repositoryTopOf(sdk, tmp)])!; + expect(fence.own).toEqual([sdk]); + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "mono", "core", "x.ts") }, { workdir: sdk, fence })).toMatchObject({ workstream: "a" }); + expect(fenceRefusal("Write", { file_path: join(tmp, "W", "mono", "core", "x.ts") }, { workdir: sdk, fence })).toMatchObject({ kind: "write" }); + expect(fenceRefusal("Read", { file_path: join(sdk, "y.ts") }, { workdir: sdk, fence })).toBeNull(); + expect(fence.hidden).toContain(join(tmp, "W", "mono", "core")); + expect(fence.hidden).not.toContain(sdk); + }); + + test("a recorded session in a monorepo another workstream splits by path stays its package, never the top", () => { + for (const d of ["W/mono/.git", "W/mono/a", "W/mono/b"]) mkdirSync(join(tmp, d), { recursive: true }); + const s = service([ + { id: "a", share: "closed", match: { paths: ["~/W/mono/a"] } }, + { id: "b", match: { paths: ["~/W/mono/b"] } }, + ]); + expect(s.foldersOf("a", [join(tmp, "W", "mono", "a")]).folders).toEqual([join(tmp, "W", "mono", "a")]); + const b = join(tmp, "W", "mono", "b"); + const fence = s.fenceFor({ id: "b", source: "matched", revision: "r" }, (id) => (id === "a" ? [join(tmp, "W", "mono", "a")] : []), [b, repositoryTopOf(b, tmp)])!; + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "mono", "a", "k") }, { workdir: b, fence })).toMatchObject({ workstream: "a" }); + }); + + test("a granted path counts only where it really is: no `..`, no link into another workstream", () => { + for (const d of ["W/b", "W/c/s"]) mkdirSync(join(tmp, d), { recursive: true }); + writeFileSync(join(tmp, "W", "c", "s", "key.txt"), "k"); + symlinkSync(join(tmp, "W", "c"), join(tmp, "W", "b", "future")); + const s = service([ + { id: "home", match: { paths: ["~/W/home"] } }, + { id: "b", share: "open", match: { paths: ["~/W/b"] } }, + { id: "c", share: "closed", match: { paths: ["~/W/c"] } }, + ]); + const ref = (p: string) => ({ id: "home", source: "explicit" as const, revision: "r", references: [{ workstream: "b", paths: [p], grantedBy: "u", grantedAt: "t" }] }); + for (const p of [join(tmp, "W", "b", "future"), `${join(tmp, "W", "b")}/../c/s`]) { + const fence = s.fenceFor(ref(p))!; + expect(fence.references[0]?.folders).toEqual([]); + expect(fenceRefusal("Read", { file_path: join(tmp, "W", "c", "s", "key.txt") }, { workdir: tmp, fence })).toMatchObject({ workstream: "c" }); + } }); }); diff --git a/src/frontends/telegram/index.ts b/src/frontends/telegram/index.ts index 6f0dfc1b..9adedea2 100644 --- a/src/frontends/telegram/index.ts +++ b/src/frontends/telegram/index.ts @@ -1342,6 +1342,9 @@ export class TelegramFrontend implements Frontend { "collaborationCost" in tool.state && tool.state.collaborationCost ? `\n◇ ${formatCollaborationCost(tool.state.collaborationCost)}` : ""; + // The workstream it acts in (workstreams §10): an approval is never made without knowing which line of work. + const tw = "targetWorkstream" in tool.state ? tool.state.targetWorkstream : undefined; + const workstream = tw ? `${tw.workstream ? `\n⌂ workstream ${tw.workstream}` : ""}${tw.warning ? `\n⚠ ${tw.warning}` : ""}` : ""; // A shell command's risk (#431), in the same words as every other surface. const riskText = "risk" in tool.state ? formatToolRisk(tool.state.risk) : ""; const risk = riskText ? `\n▲ ${riskText}` : ""; @@ -1354,7 +1357,7 @@ export class TelegramFrontend implements Frontend { this.#bot.api .sendMessage( chatId, - `⚠️ Permission needed — ${tool.name}\n${String(desc).slice(0, 800)}${risk}${sandbox}${cost}`, + `⚠️ Permission needed — ${tool.name}\n${String(desc).slice(0, 800)}${workstream}${risk}${sandbox}${cost}`, { reply_markup: kb } as Record, ) .catch(() => {}); diff --git a/src/terminal/client.ts b/src/terminal/client.ts index 3005cd97..20fd9492 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -158,7 +158,7 @@ export function renderStreamMessage(msg: DaemonMessage, state: StreamRenderState } case "session.message": { - const sm = msg as { role?: string; content?: string; messageId?: string; tool?: { name?: string; state?: { phase?: string; approvalId?: string; description?: string; risk?: ToolRisk; sandboxed?: boolean; sandboxNote?: string; sandboxDocker?: boolean } }; identity?: { name?: string; type?: string } }; + const sm = msg as { role?: string; content?: string; messageId?: string; tool?: { name?: string; state?: { phase?: string; approvalId?: string; description?: string; risk?: ToolRisk; sandboxed?: boolean; sandboxNote?: string; sandboxDocker?: boolean; targetWorkstream?: { workstream?: string; warning?: string } } }; identity?: { name?: string; type?: string } }; const id = sm.identity?.name ? `${DIM}${S(sm.identity.name)}${RESET} ` : ""; switch (sm.role) { case "user": @@ -189,7 +189,9 @@ export function renderStreamMessage(msg: DaemonMessage, state: StreamRenderState state.lastPrompt = "tool"; const risk = formatToolRisk(sm.tool?.state?.risk); const sandbox = approvalSandboxLine(sm.tool?.state ?? {}); - return `\n${id}${RED}⚡ ${name}: ${S(sm.tool?.state?.description)}${RESET}\n${risk ? ` ${RED}▲ ${S(risk)}${RESET}\n` : ""}${sandbox ? ` ${S(sandbox)}\n` : ""} Type 'yes' to approve, 'no' to deny\n`; + const tw = sm.tool?.state?.targetWorkstream; + const ws = `${tw?.workstream ? ` ⌂ workstream ${S(tw.workstream)}\n` : ""}${tw?.warning ? ` ${YELLOW}⚠ ${S(tw.warning)}${RESET}\n` : ""}`; + return `\n${id}${RED}⚡ ${name}: ${S(sm.tool?.state?.description)}${RESET}\n${ws}${risk ? ` ${RED}▲ ${S(risk)}${RESET}\n` : ""}${sandbox ? ` ${S(sandbox)}\n` : ""} Type 'yes' to approve, 'no' to deny\n`; } return `\n${id}${YELLOW}⚡ ${name} [${phase}]${RESET}\n${reshow}`; } @@ -483,6 +485,38 @@ export class TerminalClient { if (line) console.log(` now: ${line}`); } + /** + * Change what workstreams a session reads, or move it to another + * (workstreams §5.3, §7): `session.add_reference` / `remove_reference` / + * `set_workstream`. An `ask` workstream may be asked of the owner on the session. + */ + async sessionWorkstream( + sessionIdOrName: string, + change: { kind: "add_reference"; workstream: string; paths?: string[] } | { kind: "remove_reference"; workstream: string } | { kind: "set_workstream"; workstream: string }, + ): Promise { + const sessionId = await this.#resolveSession(sessionIdOrName); + if (!sessionId) return; + const base = { id: randomUUID(), sessionId, workstream: change.workstream }; + const resp = await this.#request( + change.kind === "add_reference" + ? { type: "session.add_reference", ...base, ...(change.paths?.length ? { paths: change.paths } : {}) } + : change.kind === "remove_reference" + ? { type: "session.remove_reference", ...base } + : { type: "session.set_workstream", ...base }, + ); + if (resp.type !== "response.ok") { + this.#printError(resp); + return; + } + const data = resp.data as { pending?: boolean; message?: string; workstream?: SessionInfo["workstream"] }; + if (data.pending) console.log(`Asked on the session: it applies once its owner says yes (codeoid approve ${S(sessionIdOrName)}, or the web UI), from its next turn.`); + else console.log(change.kind === "set_workstream" ? "Moved: its servers, credentials and sandbox follow from its next turn." : "Done, from its next turn."); + const ws = workstreamSummary(data.workstream); + if (ws) console.log(` ${sanitizeTerminalOutput(ws)}`); + const refs = data.workstream?.references ?? []; + if (refs.length > 0) console.log(` reads: ${sanitizeTerminalOutput(refs.map((r) => `${r.workstream}${r.paths ? ` (${r.paths.join(", ")})` : ""}`).join("; "))}`); + } + /** The daemon's sandbox providers and whether each runs here (#448). */ async sandboxProviders(): Promise { const resp = await this.#request({ type: "sandbox.providers", id: randomUUID() }); @@ -521,6 +555,15 @@ export class TerminalClient { sandbox?: SessionSandboxSetting; /** Home workstream (docs/workstreams-design.md); absent = the folder's. */ workstream?: string; + /** Other workstreams it may read (§7.2). */ + references?: Array<{ workstream: string; paths?: 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 +584,23 @@ export class TerminalClient { ...(opts.collaboration ? { collaboration: opts.collaboration } : {}), ...(opts.sandbox ? { sandbox: opts.sandbox } : {}), ...(opts.workstream ? { workstream: opts.workstream } : {}), + ...(opts.overrideToken ? { override: { token: opts.overrideToken } } : {}), + ...(opts.references?.length ? { references: opts.references } : {}), }); + 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}]` : ""; @@ -960,20 +1018,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`); @@ -1271,6 +1330,9 @@ export class TerminalClient { if (risk) console.log(`▲ ${oneLine(risk)}`); const sandbox = approvalSandboxLine(pending); if (sandbox) console.log(oneLine(sandbox)); + // The workstream it acts in, and what approving confirms (workstreams §10), as the card says. + if (pending.targetWorkstream?.workstream) console.log(`⌂ workstream ${oneLine(pending.targetWorkstream.workstream)}`); + if (pending.targetWorkstream?.warning) console.log(`⚠ ${oneLine(pending.targetWorkstream.warning)}`); const shown = plainText(pending.input === undefined ? "" : JSON.stringify(pending.input, null, 2) ?? ""); const cut = shown.length > APPROVE_SHOW_MAX; console.log(cut ? shown.slice(0, APPROVE_SHOW_MAX) : shown); diff --git a/src/tests/collaboration.test.ts b/src/tests/collaboration.test.ts index ba9e0471..7dd8a22f 100644 --- a/src/tests/collaboration.test.ts +++ b/src/tests/collaboration.test.ts @@ -2711,7 +2711,7 @@ describe("the orchestrator's fleet surface is scoped to its own children", () => // because the shared harness injects no engine at all — a mutation that // handed `this.#memory` straight to the orchestrator went undetected until // this test was written this way. - const stub = { marker: "memory-engine" } as unknown as MemoryEngine; + const stub = { marker: "memory-engine", scopeSession: () => () => {} } as unknown as MemoryEngine; manager.setMemory(stub); const mine = await createGoal("sf5"); diff --git a/src/tests/fleet.test.ts b/src/tests/fleet.test.ts index 9c1b58e2..fac1c9de 100644 --- a/src/tests/fleet.test.ts +++ b/src/tests/fleet.test.ts @@ -470,3 +470,99 @@ describe("fleet handlers — send-class (P4, post-approval)", () => { expect(await handlers.fleet_tasks({})).toBe("Dispatch is disabled on this daemon."); }); }); + +describe("fleet handlers — workstreams (W4, §10)", () => { + /** authz-fix is in `hf` (closed), migration-work in `pub` (open). */ + const wsFleet = () => + fleet().map((s) => (s.id === "sess-authz-0001" ? { ...s, workstream: "hf" } : s.id === "sess-migr-0002" ? { ...s, workstream: "pub" } : { ...s, workstream: "conductor" })); + const withheld = (ref: { workstream?: string; sessionId?: string }) => (ref.workstream ?? (ref.sessionId === "sess-authz-0001" ? "hf" : undefined)) === "hf"; + beforeEach(() => { + memory.ingest({ + workspaceId: "ws-authz", + sessionId: "sess-authz-0001", + kind: "assistant_turn", + summary: "the authz secret customer detail", + content: "the authz secret customer detail in the closed workstream", + filePaths: [], + tokenEstimate: 10, + createdAt: Date.now() - 1000, + createdBy: "user:owner", + workstreamId: "hf", + }); + memory.ingest({ + workspaceId: "ws-admin", + sessionId: "sess-migr-0002", + kind: "assistant_turn", + summary: "the authz migration note in pub", + content: "the authz migration note in the open workstream", + filePaths: [], + tokenEstimate: 10, + createdAt: Date.now() - 500, + createdBy: "user:owner", + workstreamId: "pub", + }); + }); + + test("a closed workstream's text never reaches the conductor; status does, and results name workstreams", async () => { + const h = createFleetHandlers(makeDeps({ listSessions: wsFleet, textWithheld: withheld })); + const list = await h.fleet_list(); + expect(list).toContain("workstream=hf"); + // A closed workstream's session never comes back from a query: that it matched is text too. + const found = await h.fleet_find({ query: "authz secret customer" }); + expect(found).not.toContain("authz-fix"); + expect(found).not.toContain("secret customer detail"); + expect(found).toContain("[pub]"); + const summary = await h.fleet_summary({ session: "authz-fix" }); + expect(summary).toContain("idle"); + expect(summary).not.toContain("secret customer"); + const recalled = await h.fleet_recall({ query: "authz secret customer migration" }); + expect(recalled).not.toContain("secret customer detail"); + expect(recalled).not.toContain("hf"); + expect(recalled).toContain("· pub"); + }); + + test("an untagged episode (from before workstreams) is judged by its session: a closed one's never shows", async () => { + memory.ingest({ + workspaceId: "ws-authz", + sessionId: "sess-authz-0001", + kind: "assistant_turn", + summary: "pre-workstreams zebra secret", + content: "pre-workstreams zebra secret", + filePaths: [], + tokenEstimate: 5, + createdAt: Date.now() - 100, + createdBy: "user:owner", + }); + const h = createFleetHandlers(makeDeps({ listSessions: wsFleet, textWithheld: withheld })); + expect(await h.fleet_recall({ query: "zebra secret" })).not.toContain("zebra"); + expect(await h.fleet_summary({ session: "authz-fix" })).not.toContain("zebra"); + }); + + test("a workstream filter on list, find and recall", async () => { + const h = createFleetHandlers(makeDeps({ listSessions: wsFleet, textWithheld: withheld })); + expect(await h.fleet_list({ workstream: "pub" })).not.toContain("authz-fix"); + expect(await h.fleet_find({ query: "authz", workstream: "pub" })).not.toContain("authz-fix"); + expect(await h.fleet_recall({ query: "authz migration", workstream: "pub" })).not.toContain("[authz-fix"); + }); + + test("fleet_spawn is homed where checkWorkstream says, and a refusal reaches the conductor before anything is queued", async () => { + const enqueued: Array> = []; + const dispatch: FleetDispatchDeps = { + enqueue: (input) => { + enqueued.push(input); + return "task-ws-0001"; + }, + interrupt: async () => {}, + checkWorkdir: (p) => p, + resolveBackend: (provider, model) => ({ ok: true, provider, model }), + listTasks: () => [], + checkWorkstream: (_w, ws) => (ws === "nope" ? { ok: false, error: "Unknown workstream" } : ws === "acme" ? { ok: true, workstream: "acme", override: true } : { ok: true, workstream: "pub" }), + }; + const h = createFleetHandlers(makeDeps({ dispatch })); + expect(await h.fleet_spawn({ workdir: "/ok/x", task: "t", workstream: "nope" })).toContain("Unknown workstream"); + expect(enqueued).toEqual([]); + expect(await h.fleet_spawn({ workdir: "/ok/x", task: "t", workstream: "acme" })).toContain("homed in workstream acme (past the folder's own"); + expect(enqueued[0]).toMatchObject({ workstream: "acme", workstreamOverride: true }); + expect(await h.fleet_spawn({ workdir: "/ok/x", task: "t" })).toContain("homed in workstream pub"); + }); +}); 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/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/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/sandbox-workstream-fence.test.ts b/src/tests/sandbox-workstream-fence.test.ts new file mode 100644 index 00000000..2bbc8165 --- /dev/null +++ b/src/tests/sandbox-workstream-fence.test.ts @@ -0,0 +1,74 @@ +/** + * The workstream fence in the sandbox spec (workstreams §7.3): another + * workstream's folder under a shown root is hidden, and a root inside one + * isn't shown, even when the session's own setting names it. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { buildSandboxSpec } from "../daemon/sandbox/policy.js"; + +let base: string; +beforeEach(() => { + const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache"); + mkdirSync(parent, { recursive: true }); + base = realpathSync(mkdtempSync(join(parent, "codeoid-ws-fence-"))); +}); +afterEach(() => rmSync(base, { recursive: true, force: true })); + +describe("extraHidden", () => { + test("another workstream's folder under a shown root is hidden; a root inside one is dropped", () => { + const home = join(base, "home"); + const root = join(home, "Workspace"); + const mine = join(root, "mine"); + const theirs = join(root, "theirs"); + const inside = join(theirs, "pkg"); + for (const d of [mine, inside]) mkdirSync(d, { recursive: true }); + const tmp = join(base, "tmp"); + mkdirSync(tmp); + const spec = buildSandboxSpec({ + sessionId: "s", + workspace: mine, + tempDir: tmp, + network: { mode: "off" }, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + projectRoots: [root, inside], + extraHidden: [theirs], + }); + expect(spec.hidden).toContain(theirs); + expect(spec.reexposed).toContain(root); + expect(spec.reexposed).not.toContain(inside); + // Without the fence, as before. + const open = buildSandboxSpec({ + sessionId: "s", + workspace: mine, + tempDir: tmp, + network: { mode: "off" }, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + projectRoots: [root, inside], + }); + expect(open.hidden).not.toContain(theirs); + expect(open.reexposed).toContain(inside); + }); + + test("an added dir inside another workstream's folder isn't kept: adding it can't open the fence", () => { + const home = join(base, "home"); + const mine = join(home, "mine"); + const theirs = join(home, "theirs"); + for (const d of [mine, join(theirs, "sub")]) mkdirSync(d, { recursive: true }); + const tmp = join(base, "tmp"); + mkdirSync(tmp); + const spec = buildSandboxSpec({ + sessionId: "s", + workspace: mine, + tempDir: tmp, + network: { mode: "off" }, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + addedDirs: [{ dir: join(theirs, "sub") }], + extraHidden: [theirs], + }); + expect(spec.addedDirs ?? []).not.toContain(join(theirs, "sub")); + expect(spec.writable).not.toContain(join(theirs, "sub")); + }); +}); diff --git a/src/tests/sandbox-workstream-manager.test.ts b/src/tests/sandbox-workstream-manager.test.ts new file mode 100644 index 00000000..d49e7580 --- /dev/null +++ b/src/tests/sandbox-workstream-manager.test.ts @@ -0,0 +1,137 @@ +/** + * The workstream fence through a real SessionSandbox (workstreams §7.3): + * under isolation a session's project roots are its home's and references' + * folders (never the bare @parent), every other workstream's folder under a + * shown root is hidden, and a reference added later reaches the next build. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { CodeoidConfig } 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 { LocalSandboxHandle } from "../daemon/sandbox/local-handle.js"; +import { SandboxRegistry } from "../daemon/sandbox/registry.js"; +import type { SandboxProvider, SandboxSpec } from "../daemon/sandbox/types.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 { workstreamsSchema } from "../daemon/workstreams/schema.js"; +import { ALL_SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, DaemonMessage, SessionInfo, SessionMessage } from "../protocol/types.js"; + +const AUTH: AuthContext = { sub: "user:fence", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }; +const CLIENT = { id: "cli", auth: AUTH, send: () => {} }; +const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "claude", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } }; + +let specs: SandboxSpec[] = []; +void specs; +function fake(): SandboxProvider { + const open = (spec: SandboxSpec) => { + specs.push(spec); + return new LocalSandboxHandle("bwrap", spec, (argv, o) => ({ argv: [...argv], env: o.env })); + }; + return { + id: "bwrap", + displayName: "fake-bwrap", + capabilities: { isolation: "os", placement: "local", workspace: "mount", stdioProcess: true, commands: true, wrapsArgv: true, network: ["on", "off"], platforms: ["linux"] }, + probe: async () => ({ available: true }), + open: async (s) => open(s), + openSync: open, + }; +} + +let tmp: string; +let home: string; +let ws: string; +let store: Store; +let transcript: TranscriptStore; +const realHome = process.env.HOME; + +beforeEach(() => { + specs = []; + const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache"); + mkdirSync(parent, { recursive: true }); + tmp = realpathSync(mkdtempSync(join(parent, "codeoid-ws-fence-mgr-"))); + home = join(tmp, "home"); + ws = join(home, "Workspace"); + process.env.HOME = home; + for (const d of ["mine", "pub", "hf"]) mkdirSync(join(ws, d), { recursive: true }); + mkdirSync(join(tmp, "data")); + store = new Store(join(tmp, "data", "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "data", "transcripts")); +}); + +afterEach(async () => { + try { + await transcript.flush(); + } catch {} + try { + store.close(); + } catch {} + process.env.HOME = realHome; + rmSync(tmp, { recursive: true, force: true }); +}); + +function newManager(): SessionManager { + const config = { + daemonUrl: "ws://127.0.0.1:7400", + dbPath: join(tmp, "data", "codeoid.db"), + transcriptDir: join(tmp, "data", "transcripts"), + auth: { baseUrl: "http://localhost:8899" }, + zeroidUrl: "http://localhost:8899", + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session: {}, + conductor: { enabled: false, name: "conductor", provider: "claude" }, + workstreams: workstreamsSchema.parse({ + isolateGlobalServers: true, + items: [ + { id: "mine", match: { paths: ["~/Workspace/mine"] } }, + { id: "pub", share: "open", match: { paths: ["~/Workspace/pub"] } }, + { id: "hf", share: "closed", match: { paths: ["~/Workspace/hf"] } }, + ], + }), + } as unknown as CodeoidConfig; + const registry = new ProviderRegistry("claude"); + registry.register({ id: "claude", displayName: "claude", create: () => new MockSessionProvider("claude", [[DONE], [DONE], [DONE]]) }); + const sandboxRegistry = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`); + sandboxRegistry.register(fake()); + return new SessionManager(store, transcript, undefined, undefined, undefined, { config, providers: registry, sandboxRegistry, _testFolderReader: new FolderReader({ home, sshConfig: "" }) }); +} + +describe("the fence in the sandbox", () => { + it("roots are the home's and references' folders, another workstream's is hidden, and a fresh session says no change", async () => { + const m = newManager(); + const made = (await m.handle({ type: "session.create", id: "c", name: "s", workdir: join(ws, "mine") } as never, AUTH, CLIENT)) as DaemonMessage & { data?: SessionInfo }; + expect(made.data?.workstream?.id).toBe("mine"); + const session = m.findByName("s", AUTH)!; + const infos: string[] = []; + session.attach({ id: "w", auth: AUTH, send: (msg: { type: string; role?: string; content?: string; metadata?: Record }) => void (msg.type === "session.message" && msg.role === "info" && infos.push(String((msg as unknown as SessionMessage).content))) } as never); + await session.ensureSandbox(); + await session.ensureSandbox(); + expect(session.sandboxInfo?.projectRoots).toEqual([join(ws, "mine")]); + expect(infos.filter((i) => i.includes("Sandbox directories applied"))).toEqual([]); + + await m.handle({ type: "session.add_reference", id: "r", sessionId: session.id, workstream: "pub" } as never, AUTH, CLIENT); + await session.ensureSandbox(); + expect(session.sandboxInfo?.projectRoots?.sort()).toEqual([join(ws, "mine"), join(ws, "pub")].sort()); + expect(infos.filter((i) => i.includes("Sandbox directories applied")).length).toBe(1); + await m.drain(2_000); + }); + + it("a session's own project roots stand (the fence then only hides: sandbox-workstream-fence.test.ts)", async () => { + const m = newManager(); + await m.handle({ type: "session.create", id: "c", name: "t", workdir: join(ws, "mine"), sandbox: { projectRoots: [ws] } } as never, AUTH, CLIENT); + const session = m.findByName("t", AUTH)!; + await session.ensureSandbox(); + expect(session.sandboxInfo?.projectRoots).toEqual([ws]); + await m.drain(2_000); + }); +}); diff --git a/src/tests/workstreams-conductor.test.ts b/src/tests/workstreams-conductor.test.ts new file mode 100644 index 00000000..be199797 --- /dev/null +++ b/src/tests/workstreams-conductor.test.ts @@ -0,0 +1,205 @@ +/** + * Workstreams W4 (docs/workstreams-design.md §10): the conductor's spawns are + * homed in a named workstream (a conflict is the conductor's to resolve, an + * override is what the approval card says), their cards name the workstream, + * a closed workstream's worker reports status only, and egress is planned + * into a worker homed where the message belongs. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodeoidConfig } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { planEgress } from "../daemon/workstreams/egress.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { workstreamsSchema } from "../daemon/workstreams/schema.js"; +import { WorkstreamService } from "../daemon/workstreams/service.js"; +import { ALL_SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, SessionInfo } from "../protocol/types.js"; + +const AUTH: AuthContext = { sub: "user:cond", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc-c", projectId: "proj-c" }; +const CLIENT = { id: "client-c", auth: AUTH, send: () => {} }; +const done: ProviderEvent = { type: "turn_done", result: { providerId: "mock", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } } as ProviderEvent; +const say = (text: string): ProviderEvent[] => [{ type: "text_done", content: text } as ProviderEvent, done]; + +let tmp: string; +let home: string; +let store: Store; +let transcript: TranscriptStore; +let manager: SessionManager; +let script: ProviderEvent[][] | undefined; +let providers: MockSessionProvider[] = []; + +function items() { + return [ + { id: "mine", share: "open", match: { paths: ["~/mine"] } }, + { id: "acme", share: "ask", claim: "exclusive", match: { paths: ["~/acme"] } }, + { id: "hf", share: "closed", match: { paths: ["~/hf"] } }, + ]; +} + +beforeEach(() => { + script = undefined; + providers = []; + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-ws-cond-"))); + home = join(tmp, "home"); + for (const d of ["mine", "acme", "hf", "loose"]) mkdirSync(join(home, d), { recursive: true }); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + const config = { + daemonUrl: "ws://127.0.0.1:7400", + dbPath: join(tmp, "codeoid.db"), + transcriptDir: join(tmp, "transcripts"), + auth: { baseUrl: "http://localhost:8899" }, + zeroidUrl: "http://localhost:8899", + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session: {}, + conductor: { enabled: true, name: "conductor", provider: "claude" }, + dispatch: { enabled: true, tickMs: 999_999, leaseMs: 60_000, failureLimit: 1, maxConcurrentWorkers: 2, workerToolBudget: 7, retryBaseMs: 0 }, + workstreams: workstreamsSchema.parse({ isolateGlobalServers: true, items: items() }), + } as unknown as CodeoidConfig; + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config, + _testProviderFactory: () => { + const p = new MockSessionProvider("mock", script ?? [say("the closed customer secret result"), say("two"), say("three")]); + providers.push(p); + return p; + }, + _testFolderReader: new FolderReader({ home, sshConfig: "" }), + }); +}); + +afterEach(async () => { + manager.stopDispatcher(); + try { + await manager.drain(3_000); + } catch {} + try { + await transcript.flush(); + } catch {} + try { + store.close(); + } catch {} + rmSync(tmp, { recursive: true, force: true }); +}); + +const deps = () => manager._fleetDispatchDeps(AUTH.accountId, AUTH.projectId); + +async function until(cond: () => boolean, ms = 3_000): Promise { + const deadline = Date.now() + ms; + while (!cond()) { + if (Date.now() > deadline) throw new Error("condition not reached"); + await Bun.sleep(5); + } +} + +describe("a spawn's home", () => { + test("named or the folder's; none refused; past an exclusive claim an override; a closed folder never", () => { + const check = deps().checkWorkstream!; + expect(check(join(home, "mine"), undefined)).toEqual({ ok: true, workstream: "mine" }); + expect(check(join(home, "loose"), undefined)).toMatchObject({ ok: false }); + expect(check(join(home, "loose"), "mine")).toEqual({ ok: true, workstream: "mine" }); + // Past acme's exclusive claim: refused unless the owner approved this very call's card. + expect(check(join(home, "acme"), "mine")).toMatchObject({ ok: false }); + const card = manager._fleetCardWorkstreamForTest(AUTH.accountId, AUTH.projectId)("mcp__codeoid_fleet__fleet_spawn", { workdir: join(home, "acme"), workstream: "mine" })!; + const token = manager._issueFleetTokenForTest(AUTH.accountId, AUTH.projectId, card)!; + expect(check(join(home, "acme"), "mine", "a-made-up-token")).toMatchObject({ ok: false }); + expect(check(join(home, "acme"), "mine", token)).toMatchObject({ ok: true, workstream: "mine", override: true }); + // Spent: a second spawn needs its own approval. + expect(check(join(home, "acme"), "mine", token)).toMatchObject({ ok: false }); + expect(check(join(home, "hf"), "mine")).toMatchObject({ ok: false }); + // A closed workstream's worker works only in its own folders. + expect(check(join(home, "loose"), "hf")).toMatchObject({ ok: false }); + expect(check(join(home, "hf"), "hf")).toEqual({ ok: true, workstream: "hf" }); + expect(check(join(home, "mine"), "nope")).toMatchObject({ ok: false }); + }); + + test("the approval card names the workstream, and warns of an override or a refusal", () => { + const card = manager._fleetCardWorkstreamForTest(AUTH.accountId, AUTH.projectId); + expect(card("mcp__codeoid_fleet__fleet_spawn", { workdir: join(home, "mine"), task: "t" })).toEqual({ workstream: "mine" }); + expect(card("mcp__codeoid_fleet__fleet_spawn", { workdir: join(home, "acme"), task: "t", workstream: "mine" })).toMatchObject({ workstream: "mine", warning: expect.stringContaining('Past workstream "acme"') }); + expect(card("mcp__codeoid_fleet__fleet_spawn", { workdir: join(home, "loose"), task: "t" })?.warning).toContain("refused"); + }); + + test("the worker is homed in the named workstream, an override as such", async () => { + const card = manager._fleetCardWorkstreamForTest(AUTH.accountId, AUTH.projectId)("mcp__codeoid_fleet__fleet_spawn", { workdir: join(home, "acme"), workstream: "mine" })!; + const ok = deps().checkWorkstream!(join(home, "acme"), "mine", manager._issueFleetTokenForTest(AUTH.accountId, AUTH.projectId, card)); + if (!ok.ok) throw new Error(ok.error); + const id = deps().enqueue({ kind: "spawn", shape: "scout", workdir: join(home, "acme"), prompt: "go", workstream: "mine", workstreamOverride: true, ...(ok.revision ? { workstreamRevision: ok.revision } : {}) }); + expect(id).toBeTruthy(); + await manager.dispatcher.tick(); + const listed = (await manager.handle({ type: "session.list", id: "l" }, AUTH, CLIENT)) as { sessions: SessionInfo[] }; + const worker = listed.sessions.find((s) => s.role === "worker"); + expect(worker?.workstream).toMatchObject({ id: "mine", source: "override" }); + }); + + test("a closed workstream's worker reports its status, never its text", async () => { + deps().enqueue({ kind: "spawn", shape: "scout", workdir: join(home, "hf"), prompt: "go" }); + await manager.dispatcher.tick(); + const board = () => manager._fleetDepsForTest(AUTH.accountId, AUTH.projectId).dispatch!.listTasks(5); + await until(() => board()[0]?.status === "done"); + const digest = board()[0]?.resultDigest ?? ""; + expect(digest).toContain("closed"); + expect(digest).not.toContain("customer secret"); + }); +}); + +describe("approving a spawn card that warns", () => { + test("is the owner's own: a delegated token is refused; the owner's approval is what the handler counts", async () => { + script = [[{ type: "tool_start", toolId: "t1", sdkToolUseId: "s1", name: "mcp__codeoid_fleet__fleet_spawn", input: { workdir: join(home, "acme"), task: "t", workstream: "mine" }, approvalId: "ap-1" } as ProviderEvent, done]]; + const made = (await manager.handle({ type: "session.create", id: "c", name: "conductor", workdir: ".", role: "conductor" } as never, AUTH, CLIENT)) as { data: SessionInfo }; + const conductor = manager._sessionForTest(made.data.id)!; + void conductor.send("spawn", AUTH); + await until(() => conductor.pendingToolApprovals().length > 0); + const pending = conductor.pendingToolApprovals()[0]!; + expect(pending.targetWorkstream).toMatchObject({ workstream: "mine", warning: expect.stringContaining('Past workstream "acme"') }); + const delegated = { ...AUTH, delegationDepth: 1 }; + expect(await manager.handle({ type: "session.approve", id: "a1", sessionId: conductor.id, approvalId: pending.approvalId, approved: true } as never, delegated, { ...CLIENT, auth: delegated })).toMatchObject({ code: "forbidden" }); + // Another member's own token isn't the owner's either. + const member = { ...AUTH, sub: "user:other-member" }; + expect(await manager.handle({ type: "session.approve", id: "a3", sessionId: conductor.id, approvalId: pending.approvalId, approved: true } as never, member, { ...CLIENT, auth: member })).toMatchObject({ code: "forbidden" }); + expect(await manager.handle({ type: "session.approve", id: "a2", sessionId: conductor.id, approvalId: pending.approvalId, approved: true } as never, AUTH, CLIENT)).toMatchObject({ type: "response.ok" }); + // The approval put a token in the call's own input: the handler would present it. + await until(() => (providers.at(-1)?.canUseToolResults.length ?? 0) > 0); + const allowed = providers.at(-1)!.canUseToolResults[0]!; + expect(allowed.behavior).toBe("allow"); + const token = allowed.updatedInput?.confirmation as string; + expect(typeof token).toBe("string"); + expect(deps().checkWorkstream!(join(home, "acme"), "mine", token)).toMatchObject({ ok: true, override: true }); + }); +}); + +describe("a confirmation holds only where it was given", () => { + test("an override queued at one revision is refused at claim after the workstreams changed", async () => { + deps().enqueue({ kind: "spawn", shape: "scout", workdir: join(home, "acme"), prompt: "go", workstream: "mine", workstreamOverride: true, workstreamRevision: "an-old-revision" }); + await manager.dispatcher.tick(); + const listed = (await manager.handle({ type: "session.list", id: "l" }, AUTH, CLIENT)) as { sessions: SessionInfo[] }; + expect(listed.sessions.find((s) => s.role === "worker")).toBeUndefined(); + const board = manager._fleetDepsForTest(AUTH.accountId, AUTH.projectId).dispatch!.listTasks(5); + expect(board[0]?.error ?? "").toContain("workstream"); + }); +}); + +describe("egress (stub)", () => { + test("planned into a worker homed in the named workstream, never guessed, never by a worker", () => { + const s = new WorkstreamService(workstreamsSchema.parse({ items: items() }), new FolderReader({ home, sshConfig: "" })); + expect(planEgress(s, { workstream: "acme", channel: "slack", recipient: "#eng", from: { role: "conductor" } })).toEqual({ + ok: true, + worker: { workstream: "acme" }, + card: { workstream: "acme", channel: "slack", recipient: "#eng" }, + }); + expect(planEgress(s, { channel: "slack", recipient: "#eng", from: { role: "conductor" } })).toMatchObject({ ok: false }); + expect(planEgress(s, { workstream: "nope", channel: "slack", recipient: "#eng", from: { role: "conductor" } })).toMatchObject({ ok: false }); + expect(planEgress(s, { workstream: "acme", channel: "slack", recipient: "#eng", from: { role: "worker" } })).toMatchObject({ ok: false }); + expect(planEgress(new WorkstreamService(undefined), { workstream: "acme", channel: "slack", recipient: "x", from: { role: "conductor" } })).toMatchObject({ ok: false }); + }); +}); 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 new file mode 100644 index 00000000..cf4a97e1 --- /dev/null +++ b/src/tests/workstreams-isolation.test.ts @@ -0,0 +1,287 @@ +/** + * 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("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"]); + 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/); + // 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(); + }); +}); + +describe("Claude under isolation", () => { + 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)).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 () => { + 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); + } + }); +}); + +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 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", CODEOID_GITHUB_TOKEN: "mine", MINE: "ok" }; + expect(resolveEnvValue("${CODEOID_CRED_X}", env)).toBe(""); + expect(resolveEnvValue("${CODEOID_GITHUB_TOKEN}", env)).toBe("mine"); + expect(resolveEnvValue("${MINE}", env)).toBe("ok"); + 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 () => { + 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 94805374..b504e718 100644 --- a/src/tests/workstreams-manager.test.ts +++ b/src/tests/workstreams-manager.test.ts @@ -15,6 +15,11 @@ 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 { 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"; @@ -54,13 +59,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(), @@ -99,7 +104,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 }); } }); @@ -178,7 +183,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 +268,215 @@ 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"/); + }); +}); + +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('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" }); + }); +}); + +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"); + }); +}); + +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\""); + } + }); +}); + +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"') }); + }); +}); + +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"); + }); +}); + diff --git a/src/tests/workstreams-references.test.ts b/src/tests/workstreams-references.test.ts new file mode 100644 index 00000000..e3f45176 --- /dev/null +++ b/src/tests/workstreams-references.test.ts @@ -0,0 +1,200 @@ +/** + * Workstreams W3 through the manager: references at create and mid-session + * by each workstream's share policy, moves (§5.3), and the read fence + * refusing a file tool in another workstream's folders at the gate (§7.3). + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.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: "mock", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } }; +const OWNER: AuthContext = { sub: "user:ref", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }; +const SENDER: AuthContext = { ...OWNER, scopes: [SCOPES.SESSION_CREATE, SCOPES.SESSION_SEND, SCOPES.SESSION_LIST] as AuthContext["scopes"] }; +const client = { id: "c-ref", auth: OWNER, send: (_m: DaemonMessage) => {} }; + +let tmp: string; +let home: string; +let store: Store; +let transcript: TranscriptStore; +let savedXdg: string | undefined; +let manager: SessionManager; +let providers: MockSessionProvider[]; +let script: ProviderEvent[][]; + +beforeEach(() => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-ws-ref-"))); + home = join(tmp, "home"); + savedXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = join(tmp, "config"); + mkdirSync(join(tmp, "config", "codeoid"), { recursive: true }); + for (const d of ["mine", "acme-api", "acme-web", "hf", "pub"]) mkdirSync(join(home, d), { recursive: true }); + writeFileSync(join(home, "hf", "secret.ts"), "x"); + writeFileSync( + join(tmp, "config", "codeoid", "config.json"), + JSON.stringify({ + dbPath: join(tmp, "codeoid.db"), + transcriptDir: join(tmp, "transcripts"), + workstreams: { + isolateGlobalServers: true, + items: [ + { id: "mine", share: "open", match: { paths: ["~/mine"] } }, + { id: "acme", share: "ask", match: { paths: ["~/acme-*"] } }, + { id: "hf", share: "closed", claim: "exclusive", match: { paths: ["~/hf"] } }, + { id: "pub", share: "open", match: { paths: ["~/pub"] } }, + ], + }, + }), + ); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + providers = []; + script = [[DONE]]; + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: loadConfig({ env: {}, quiet: true }), + _testProviderFactory: () => { + const p = new MockSessionProvider("mock", script); + providers.push(p); + return p; + }, + _testFolderReader: new FolderReader({ home, sshConfig: "" }), + }); +}); + +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; + rmSync(tmp, { recursive: true, force: true }); +}); + +const h = (msg: Record, auth: AuthContext = OWNER) => manager.handle(msg as never, auth, { ...client, auth }); +const create = async (name: string, dir: string, extra: Record = {}, auth: AuthContext = OWNER) => + (await h({ type: "session.create", id: `c-${name}`, name, workdir: join(home, dir), ...extra }, auth)) as DaemonMessage & { data?: SessionInfo }; + +describe("references at create", () => { + it("open is granted; ask needs the creator's approval rights; closed never", async () => { + const open = await create("a", "mine", { references: [{ workstream: "pub" }] }); + expect(open.type).toBe("response.ok"); + expect(open.data?.workstream).toMatchObject({ id: "mine", references: [{ workstream: "pub" }], readFrom: ["pub"] }); + const ask = await create("b", "mine", { references: [{ workstream: "acme" }] }, SENDER); + expect(ask).toMatchObject({ type: "response.error", code: "forbidden" }); + const asked = await create("c", "mine", { references: [{ workstream: "acme", paths: [join(home, "acme-api")] }] }); + expect(asked.data?.workstream?.references).toEqual([{ workstream: "acme", paths: [join(home, "acme-api")] }]); + expect(await create("d", "mine", { references: [{ workstream: "hf" }] })).toMatchObject({ type: "response.error", code: "forbidden" }); + expect(await create("e", "mine", { references: [{ workstream: "acme", paths: ["/etc"] }] })).toMatchObject({ type: "response.error" }); + }); +}); + +describe("references and moves mid-session", () => { + it("add and remove a reference; an `ask` one from a sender without approval rights is asked on the session", async () => { + const made = await create("s", "mine", {}, SENDER); + const id = made.data!.id; + const added = await h({ type: "session.add_reference", id: "r1", sessionId: id, workstream: "pub" }, SENDER); + expect(added).toMatchObject({ type: "response.ok", data: { workstream: { references: [{ workstream: "pub" }] } } }); + const pending = await h({ type: "session.add_reference", id: "r2", sessionId: id, workstream: "acme" }, SENDER); + expect(pending).toMatchObject({ type: "response.ok", data: { pending: true } }); + expect(manager.findByName("s", OWNER)?.workstream?.references?.map((r) => r.workstream)).toEqual(["pub"]); + expect(await h({ type: "session.add_reference", id: "r3", sessionId: id, workstream: "hf" }, SENDER)).toMatchObject({ code: "forbidden" }); + const removed = await h({ type: "session.remove_reference", id: "r4", sessionId: id, workstream: "pub" }, SENDER); + expect(removed).toMatchObject({ type: "response.ok" }); + // Gone, but still counted as read from (a later move follows its policy). + expect(manager.findByName("s", OWNER)?.workstreamAhead).toMatchObject({ id: "mine", readFrom: ["pub"] }); + expect(manager.findByName("s", OWNER)?.workstreamAhead?.references).toBeUndefined(); + }); + + it("only the owner", async () => { + const made = await create("o", "mine"); + const other = { ...OWNER, sub: "user:other" }; + expect(await h({ type: "session.add_reference", id: "x", sessionId: made.data!.id, workstream: "pub" }, other)).toMatchObject({ type: "response.error" }); + }); + + it("a move follows the old home's policy and every workstream read from", async () => { + const fromMine = await create("m1", "mine"); + expect(await h({ type: "session.set_workstream", id: "w1", sessionId: fromMine.data!.id, workstream: "pub" })).toMatchObject({ type: "response.ok" }); + expect(manager.findByName("m1", OWNER)?.workstream).toMatchObject({ id: "pub", source: "explicit" }); + // Read from a closed workstream once: that conversation never leaves. + const read = await create("m2", "mine", { references: [{ workstream: "pub" }] }); + const sid = read.data!.id; + const s = manager.findByName("m2", OWNER)!; + s.setWorkstream({ ...s.workstream!, readFrom: ["pub", "hf"] }, "test"); + expect(await h({ type: "session.set_workstream", id: "w2", sessionId: sid, workstream: "acme" })).toMatchObject({ code: "forbidden" }); + // From an `ask` home, a sender without approval rights is asked on the session. + const fromAcme = await create("m3", "acme-api", {}, SENDER); + expect(await h({ type: "session.set_workstream", id: "w3", sessionId: fromAcme.data!.id, workstream: "pub" }, SENDER)).toMatchObject({ data: { pending: true } }); + // Into a folder another workstream holds exclusively: an override, needing approval rights. + const inHf = await create("m4", "hf"); + expect(inHf.data?.workstream?.id).toBe("hf"); + expect(await h({ type: "session.set_workstream", id: "w4", sessionId: inHf.data!.id, workstream: "pub" })).toMatchObject({ code: "forbidden" }); + }); +}); + +describe("the read fence at the gate", () => { + it("a file tool in another workstream's folder is refused, naming it; a reference is readable", async () => { + script = [[{ type: "tool_start", toolId: "t1", sdkToolUseId: "sdk-t1", name: "Read", input: { file_path: join(home, "hf", "secret.ts") }, approvalId: "ap-1" } as ProviderEvent, DONE]]; + const made = await create("g", "mine"); + const session = manager.findByName("g", OWNER)!; + await session.send("read it", OWNER); + for (let i = 0; i < 200 && providers.at(-1)?.canUseToolResults.length === 0; i++) await Bun.sleep(10); + const result = providers.at(-1)!.canUseToolResults[0]; + expect(result).toMatchObject({ behavior: "deny" }); + expect(result?.message).toContain('workstream "hf"'); + expect(made.data?.workstream?.id).toBe("mine"); + }); + + it("a session moved to another workstream still reads its own folder", async () => { + script = [[{ type: "tool_start", toolId: "t2", sdkToolUseId: "sdk-t2", name: "Read", input: { file_path: join(home, "mine", "own.ts") }, approvalId: "ap-2" } as ProviderEvent, DONE]]; + writeFileSync(join(home, "mine", "own.ts"), "x"); + const made = await create("mv", "mine"); + await h({ type: "session.set_workstream", id: "w", sessionId: made.data!.id, workstream: "pub" }); + const session = manager.findByName("mv", OWNER)!; + expect(session.workstream?.id).toBe("pub"); + await session.send("read", OWNER); + for (let i = 0; i < 100 && providers.at(-1)?.canUseToolResults.length === 0; i++) await Bun.sleep(10); + const r = providers.at(-1)?.canUseToolResults[0]; + // Not refused by the fence (a guarded Read may still wait on its card: either way, never fenced). + expect(r?.message ?? "").not.toContain("workstream"); + }); +}); + +describe("reference inputs are held to what a binding is read back with", () => { + it("a NUL in a folder, or one reference too many, is refused before it is stored", async () => { + const made = await create("v", "mine"); + const id = made.data!.id; + expect(await h({ type: "session.add_reference", id: "n", sessionId: id, workstream: "pub", paths: [`${join(home, "pub")}/\u0000x`] })).toMatchObject({ type: "response.error" }); + expect(await create("v2", "mine", { references: [{ workstream: "pub", paths: ["relative"] }] })).toMatchObject({ type: "response.error" }); + expect(manager.findByName("v", OWNER)?.workstream?.references).toBeUndefined(); + }); + + it("a move counts the old home as read from: moving on follows its policy", async () => { + const made = await create("chain", "acme-api"); + const id = made.data!.id; + expect(await h({ type: "session.set_workstream", id: "m1", sessionId: id, workstream: "pub" })).toMatchObject({ type: "response.ok" }); + expect(manager.findByName("chain", OWNER)?.workstreamAhead?.readFrom).toContain("acme"); + // From pub (open) to mine: acme (ask) was read, so a sender without approval rights is asked. + expect(await h({ type: "session.set_workstream", id: "m2", sessionId: id, workstream: "mine" }, SENDER)).toMatchObject({ data: { pending: true } }); + }); + + it("a home chosen in a closed workstream's folder is refused; in an `ask` one it needs approval rights", async () => { + expect(await create("in-hf", "hf", { workstream: "pub" })).toMatchObject({ type: "response.error" }); + expect(await create("in-acme", "acme-api", { workstream: "pub" }, SENDER)).toMatchObject({ type: "response.error", code: "forbidden" }); + const ok = await create("in-acme2", "acme-api", { workstream: "pub" }); + expect(ok.data?.workstream).toMatchObject({ id: "pub", readFrom: ["acme"] }); + }); +}); diff --git a/src/tui/ansi/render-message.ts b/src/tui/ansi/render-message.ts index c579a99b..d73736ce 100644 --- a/src/tui/ansi/render-message.ts +++ b/src/tui/ansi/render-message.ts @@ -277,6 +277,10 @@ function renderToolRow( if (cost) lines.push(dim(` \u25c7 ${formatCollaborationCost(cost)}`)); const risk = formatToolRisk((tool.state as { risk?: ToolRisk }).risk); if (risk) lines.push(red(` \u25b2 ${risk}`)); + // The workstream a fleet dispatch acts in, and what approving confirms (workstreams §10). + const tw = (tool.state as { targetWorkstream?: { workstream?: string; warning?: string } }).targetWorkstream; + if (tw?.workstream) lines.push(dim(` \u2302 workstream ${tw.workstream}`)); + if (tw?.warning) lines.push(red(` \u26a0 ${tw.warning}`)); } if (isEdit) { diff --git a/src/tui/components/MessageRow.tsx b/src/tui/components/MessageRow.tsx index f4e993e0..3914743f 100644 --- a/src/tui/components/MessageRow.tsx +++ b/src/tui/components/MessageRow.tsx @@ -230,6 +230,15 @@ function ToolRow({ {(tool.state as { description: string }).description} )} + {/* The workstream a fleet dispatch acts in, and what approving confirms (workstreams §10). */} + {phase === "waiting_confirmation" && (tool.state as { targetWorkstream?: { workstream?: string } }).targetWorkstream?.workstream && ( + ⌂ workstream {(tool.state as { targetWorkstream?: { workstream?: string } }).targetWorkstream?.workstream} + )} + {phase === "waiting_confirmation" && (tool.state as { targetWorkstream?: { warning?: string } }).targetWorkstream?.warning && ( + + ⚠ {(tool.state as { targetWorkstream?: { warning?: string } }).targetWorkstream?.warning} + + )} {phase === "waiting_confirmation" && formatToolRisk((tool.state as { risk?: ToolRisk }).risk) && ( ▲ {formatToolRisk((tool.state as { risk?: ToolRisk }).risk)} diff --git a/src/tui/store.tsx b/src/tui/store.tsx index a6a3e450..68a8b580 100644 --- a/src/tui/store.tsx +++ b/src/tui/store.tsx @@ -319,6 +319,7 @@ function applyMessage( toolName: msg.tool.name, description: st.description, ...(formatToolRisk(st.risk) ? { risk: formatToolRisk(st.risk) } : {}), + ...(st.targetWorkstream?.warning ? { risk: `${st.targetWorkstream.warning}${formatToolRisk(st.risk) ? ` · ${formatToolRisk(st.risk)}` : ""}` } : {}), }; } diff --git a/web/src/components/CenterPane.tsx b/web/src/components/CenterPane.tsx index 06dca1e4..3d339d8d 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/FleetRail.tsx b/web/src/components/FleetRail.tsx index 94ef5955..5510d2e4 100644 --- a/web/src/components/FleetRail.tsx +++ b/web/src/components/FleetRail.tsx @@ -109,8 +109,10 @@ const FleetRail: Component = () => { return fleetEconomics(b.conductor ? [b.conductor, ...b.workers] : b.workers); }); + // Docked beside the chat from a tablet up; on a phone it would leave the chat (and the approval card + // naming what a dispatch does) a sliver, so there the chat has the width and the fleet is in fleet_list. return ( -