diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e160714c..12ab4f45 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -447,6 +447,14 @@ Every reference to a credential is checked when the config loads: declared once, Under isolation a session you create in a folder with no workstream is refused until you choose one; sessions the daemon starts there (loops, routines, dispatch) get built-ins only. +### Editing them live + +Settings → Workstreams and Settings → Credentials in the web UI (or the `workstream.*` / `credential.*` messages) edit `config.json` and `.env` directly; the whole config is validated before anything is written. +A change applies at once to new sessions and to every call a session makes through codeoid; a running session's backend is rebuilt with its workstream's servers, env and credentials as they are now at its next turn. +A session keeps its workstream: editing rules never moves a session to another. +A value set in the daemon's own environment (`CODEOID_CRED_=… codeoid start`) wins over `.env`, so setting it from the UI is refused: change it where it is set. +Removing an OAuth credential signs every user out of that slot, so declaring the id again later starts from no sign-in. + ### Overrides Choosing a workstream for a folder another workstream holds exclusively is refused with what it would change. diff --git a/docs/workstreams-design.md b/docs/workstreams-design.md index 339bff21..fb614904 100644 --- a/docs/workstreams-design.md +++ b/docs/workstreams-design.md @@ -409,7 +409,8 @@ An unsandboxed session (`--sandbox off`) keeps the weaker rows as written. - The config has a **revision** (a hash of the `workstreams` block, so it is the same across restarts for the same config), and each binding records the revision it applied. - **Narrowing** changes take effect at the **next turn boundary** of every affected live session, with a backend restart. That includes removing a server, removing a reference, and tightening `share`. Waiting for a manual refresh would leave a revoked credential live, which is the wrong failure. -- **Widening** changes (adding a server, loosening `share`) take effect on the next refresh or the next new session, matching how the MCP session view works today ("what's applied changes only on `load()`"). +- **Widening** changes (adding a server, loosening `share`) take effect at the next turn boundary too (as built, W2: the session's MCP view fingerprint includes its grant, and a change either way rebuilds the backend — simpler, and a rebuild is cheap next to a turn). +- A session's **env** (its workstream's env credentials and git identity) is read live as well: a credential cleared or an env entry removed is gone from the backend's next start, which the same next-turn rebuild brings (W2 audit). - **Deleting a workstream** re-homes its sessions to the default only if they have no running turn. Otherwise they are marked `orphaned` and refuse new turns until re-homed. Memory keeps its tag. - **Changing match rules** never re-homes a live session. It affects only new sessions. The panel lists the sessions that would resolve differently. diff --git a/e2e/specs/workstreams-ui.spec.ts b/e2e/specs/workstreams-ui.spec.ts new file mode 100644 index 00000000..516a0823 --- /dev/null +++ b/e2e/specs/workstreams-ui.spec.ts @@ -0,0 +1,106 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { expect, openUi, test } from "../harness"; + +// The viewports the workspace rules ask layout changes be looked at in. +const VIEWPORTS = [ + [375, 667], + [390, 844], + [768, 1024], + [1280, 800], + [1470, 830], + [1920, 1080], + [1280, 300], +] as const; +const SHOTS = join(process.cwd(), "test-results", "workstreams-shots"); + +// Workstreams W2 in the web UI: Settings shows and edits them; the new-session +// dialog says what the folder resolves to, offers an override past an +// exclusive workstream with what it changes, and the session's chip shows it. +test("workstreams in the web UI: settings, the new-session picker, a confirmed override, the chip", async ({ page, codeoid }) => { + mkdirSync(SHOTS, { recursive: true }); + codeoid.configure({ + credentials: [{ id: "gh-acme", kind: "env" }], + workstreams: { + items: [ + { id: "acme", name: "Acme Corp", color: "orange", claim: "exclusive", match: { paths: [codeoid.workdir] }, env: { GH_TOKEN: { secret: "gh-acme" } } }, + { id: "mine", name: "Mine", match: { paths: ["~/never"] } }, + ], + }, + }); + await codeoid.restart(); + await openUi(page, codeoid); + + // Settings → Workstreams lists them; add one there. + await page.getByTitle("Settings", { exact: true }).first().click(); + await page.getByRole("button", { name: /Workstreams/ }).click(); + await expect(page.locator("span.font-medium", { hasText: "Acme Corp" })).toBeVisible(); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await page.screenshot({ path: join(SHOTS, `settings-${w}x${h}.png`), fullPage: false }); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await page.getByRole("button", { name: "Add workstream" }).click(); + const form = page.getByRole("form", { name: "New workstream" }); + await form.getByPlaceholder("acme", { exact: true }).fill("research"); + await form.getByPlaceholder("Acme Corp").fill("Research"); + await form.getByRole("button", { name: "Save" }).click(); + await expect(page.locator("span.font-medium", { hasText: "Research" })).toBeVisible(); + + // Credentials: a value set is never shown back. + await page.getByRole("button", { name: /Credentials/ }).click(); + await expect(page.getByTestId("credential-gh-acme")).toContainText("not set"); + await page.getByLabel("Value of gh-acme").fill("ghp_e2e_value"); + await page.getByRole("button", { name: "Set", exact: true }).click(); + await expect(page.getByTestId("credential-gh-acme")).toContainText("set (CODEOID_CRED_GH_ACME)"); + await expect(page.getByText("ghp_e2e_value")).toHaveCount(0); + await page.keyboard.press("Escape"); + + // New session in Acme's folder, choosing Mine: offered, with what changes. + await page.getByRole("button", { name: /new session/ }).click(); + await page.getByRole("textbox", { name: "Name", exact: true }).fill("past-claim"); + await page.getByRole("textbox", { name: /^Workdir/ }).fill(codeoid.workdir); + await expect(page.getByLabel("Workstream")).toContainText("The folder's: acme", { timeout: 10_000 }); + await page.getByLabel("Workstream").selectOption("mine"); + await page.getByRole("button", { name: "create", exact: true }).click(); + const offer = page.getByRole("alert").filter({ hasText: "belongs to workstream" }); + await expect(offer).toContainText("“Acme Corp” (exclusive)"); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await offer.scrollIntoViewIfNeeded(); + // Its buttons fit, whatever the window: nothing to confirm with is cut off. + const box = await page.getByRole("button", { name: "Start anyway" }).boundingBox(); + expect(box && box.width > 40 && box.height > 16 && box.x + box.width <= w).toBe(true); + await page.screenshot({ path: join(SHOTS, `override-${w}x${h}.png`), fullPage: false }); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await page.getByRole("button", { name: "Start anyway" }).click(); + await expect(page.getByRole("heading", { name: "past-claim", level: 2 })).toBeVisible(); + // The session header's chip (not the list row's): beside the session's own heading. + const header = page.getByRole("heading", { name: "past-claim", level: 2 }).locator("xpath=.."); + await expect(header.getByTestId("workstream-chip")).toContainText("Mine"); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await page.screenshot({ path: join(SHOTS, `session-${w}x${h}.png`), fullPage: false }); + } + + // A collaborative session has the picker too, and it fits every window. + await page.setViewportSize({ width: 1280, height: 800 }); + await page.getByRole("button", { name: /new session/ }).click(); + await page.getByRole("radio", { name: "Collaborative" }).click(); + await page.getByRole("textbox", { name: /^Workdir/ }).fill(codeoid.workdir); + const picker = page.getByLabel("Workstream"); + await expect(picker).toContainText("The folder's: acme", { timeout: 10_000 }); + for (const [w, h] of VIEWPORTS) { + await page.setViewportSize({ width: w, height: h }); + await picker.scrollIntoViewIfNeeded(); + const box = await picker.boundingBox(); + expect(box && box.width > 100 && box.height > 16 && box.x >= 0 && box.x + box.width <= w).toBe(true); + // The create button keeps its label below the picker, in every window. + const create = page.getByRole("button", { name: "create collaboration" }); + await create.scrollIntoViewIfNeeded(); + const cb = await create.boundingBox(); + expect(cb && cb.width > 60 && cb.height > 16 && cb.x + cb.width <= w && cb.y >= 0 && cb.y + cb.height <= h).toBe(true); + await page.screenshot({ path: join(SHOTS, `collaborate-${w}x${h}.png`), fullPage: false }); + } +}); diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index 0592839e..841efe9b 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -165,6 +165,13 @@ const samples: { [T in ClientTypes]: Extract } = { "worktree.trust": { type: "worktree.trust", id: "wt1", dir: "/tmp/repo", recipeHash: "0123456789abcdef0123456789abcdef01234567" }, "workstream.list": { type: "workstream.list", id: "ws1" }, "workstream.resolve": { type: "workstream.resolve", id: "ws2", workdir: "/tmp/repo", workstream: "acme" }, + "workstream.upsert": { type: "workstream.upsert", id: "ws3", item: { id: "acme" } }, + "workstream.delete": { type: "workstream.delete", id: "ws4", workstream: "acme" }, + "workstream.settings": { type: "workstream.settings", id: "ws5", isolateGlobalServers: true, default: null }, + "credential.list": { type: "credential.list", id: "c1" }, + "credential.upsert": { type: "credential.upsert", id: "c2", item: { id: "gh", kind: "env" } }, + "credential.delete": { type: "credential.delete", id: "c3", credential: "gh" }, + "credential.set_value": { type: "credential.set_value", id: "c4", credential: "gh", value: "x" }, "session.turns": { type: "session.turns", id: "r94", sessionId: "s1" }, "session.approvals": { type: "session.approvals", id: "r94a", sessionId: "s1" }, "claude.accounts.list": { type: "claude.accounts.list", id: "ca1" }, diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index b82590e5..0469af12 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -1102,6 +1102,29 @@ export const workstreamResolveSchema = z.object({ workstream: workstreamIdField.optional(), }); +// Items are bounded objects here: their SHAPE is the daemon's config schema's +// to judge (one rule, one error message), not the wire's. +const configItemField = z.record(z.string().max(128), z.unknown()).refine((o) => JSON.stringify(o).length <= 64 * 1024, "too large"); +export const workstreamUpsertSchema = z.object({ ...base, type: z.literal("workstream.upsert"), item: configItemField }); +export const workstreamDeleteSchema = z.object({ ...base, type: z.literal("workstream.delete"), workstream: workstreamIdField }); +export const workstreamSettingsSchema = z.object({ + ...base, + type: z.literal("workstream.settings"), + isolateGlobalServers: z.boolean().optional(), + default: workstreamIdField.nullable().optional(), +}); +export const credentialListSchema = z.object({ ...base, type: z.literal("credential.list") }); +export const credentialUpsertSchema = z.object({ ...base, type: z.literal("credential.upsert"), item: configItemField }); +// A credential's id shape (the daemon's config rule): no line breaks or spaces reach an audit row. +const credentialIdField = z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/); +export const credentialDeleteSchema = z.object({ ...base, type: z.literal("credential.delete"), credential: credentialIdField }); +export const credentialSetValueSchema = z.object({ + ...base, + type: z.literal("credential.set_value"), + credential: credentialIdField, + value: z.string().max(LIMITS.SETTING_VALUE_MAX).nullable(), +}); + export const skillGrantSchema = z.object({ ...base, type: z.literal("skill.grant"), @@ -1337,6 +1360,13 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ worktreeTrustSchema, workstreamListSchema, workstreamResolveSchema, + workstreamUpsertSchema, + workstreamDeleteSchema, + workstreamSettingsSchema, + credentialListSchema, + credentialUpsertSchema, + credentialDeleteSchema, + credentialSetValueSchema, sessionTurnsSchema, sessionApprovalsSchema, claudeAccountsListSchema, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index c4bf07ce..0f08e754 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -45,12 +45,21 @@ import type { import type { SkillGrantMsg, SkillGrantResultMsg } from "./skill-grant.js"; import type { WorktreeTrustMsg, WorktreeTrustResultMsg } from "./worktree-trust.js"; import type { + CredentialDeleteMsg, + CredentialListMsg, + CredentialListResultMsg, + CredentialSetValueMsg, + CredentialUpsertMsg, SessionWorkstreamInfo, + WorkstreamDeleteMsg, WorkstreamListMsg, WorkstreamListResultMsg, WorkstreamResolution, WorkstreamResolveMsg, WorkstreamResolveResultMsg, + WorkstreamSettingsMsg, + WorkstreamsChangedMsg, + WorkstreamUpsertMsg, } from "./workstreams.js"; import type { SessionRewindMsg, SessionRewindResultMsg, SessionTurnsMsg, SessionTurnsResultMsg } from "./turns.js"; import type { SessionApprovalsMsg, SessionApprovalsResultMsg } from "./approvals.js"; @@ -1357,6 +1366,13 @@ export type ClientMessage = | WorktreeTrustMsg | WorkstreamListMsg | WorkstreamResolveMsg + | WorkstreamUpsertMsg + | WorkstreamDeleteMsg + | WorkstreamSettingsMsg + | CredentialListMsg + | CredentialUpsertMsg + | CredentialDeleteMsg + | CredentialSetValueMsg | SessionTurnsMsg | SessionApprovalsMsg | ClaudeAccountsListMsg @@ -3281,6 +3297,8 @@ export type DaemonMessage = | WorktreeTrustResultMsg | WorkstreamListResultMsg | WorkstreamResolveResultMsg + | WorkstreamsChangedMsg + | CredentialListResultMsg | SessionTurnsResultMsg | SessionApprovalsResultMsg | ClaudeAccountsResultMsg diff --git a/packages/protocol/src/workstreams.ts b/packages/protocol/src/workstreams.ts index 36d25467..3708a26f 100644 --- a/packages/protocol/src/workstreams.ts +++ b/packages/protocol/src/workstreams.ts @@ -63,6 +63,8 @@ export interface WorkstreamSummary { env: string[]; git?: { name?: string; email?: string }; skillDirs: string[]; + /** The item as configured (bases NOT merged): what an editor edits and sends back to `workstream.upsert`. */ + config: Record; } export interface WorkstreamMatchInfo { @@ -141,3 +143,83 @@ export interface WorkstreamResolveResultMsg { revision: string; resolution: WorkstreamResolution; } + +// ── Editing (W2): settings:write on an undelegated token ───────────────────── + +/** Add or replace one workstream (by `item.id`). The whole config is validated; nothing is written if it doesn't. Replies `workstream.list.result`. */ +export interface WorkstreamUpsertMsg { + type: "workstream.upsert"; + id: string; + /** A `workstreams.items[]` entry as config.json takes it (docs/CONFIGURATION.md, Workstreams). */ + item: Record; +} + +/** Remove a workstream. Sessions homed there keep their binding, shown orphaned. Replies `workstream.list.result`. */ +export interface WorkstreamDeleteMsg { + type: "workstream.delete"; + id: string; + workstream: string; +} + +/** The block-wide settings. `default: null` removes the default. Replies `workstream.list.result`. */ +export interface WorkstreamSettingsMsg { + type: "workstream.settings"; + id: string; + isolateGlobalServers?: boolean; + default?: string | null; +} + +/** Sent to subscribed clients after the workstreams or credentials change. */ +export interface WorkstreamsChangedMsg { + type: "workstreams.changed"; + revision: string; +} + +// ── Credentials (W2) ───────────────────────────────────────────────────────── + +export interface CredentialInfo { + id: string; + kind: "bearer" | "header" | "env" | "oauth"; + header?: string; + server?: string; + description?: string; + /** Whether a value is set, and where (never the value). An `oauth` credential's is its sign-in, shown with its server. */ + status: { set: boolean; source?: string }; + /** Workstreams that reference it. */ + usedBy: string[]; +} + +/** The declared credentials and where their values live. Needs `settings:read`. */ +export interface CredentialListMsg { + type: "credential.list"; + id: string; +} + +/** Declare or replace one credential (by `item.id`). Replies `credential.list.result`. */ +export interface CredentialUpsertMsg { + type: "credential.upsert"; + id: string; + item: Record; +} + +/** Remove a credential's declaration (refused while a workstream references it) and its value. */ +export interface CredentialDeleteMsg { + type: "credential.delete"; + id: string; + credential: string; +} + +/** Set (or with null, clear) a credential's value in a writable store. The value is never sent back. */ +export interface CredentialSetValueMsg { + type: "credential.set_value"; + id: string; + credential: string; + value: string | null; +} + +export interface CredentialListResultMsg { + type: "credential.list.result"; + requestId: string; + store: { kind: string; writable: boolean }; + credentials: CredentialInfo[]; +} diff --git a/src/config.ts b/src/config.ts index c0213a70..becd5372 100644 --- a/src/config.ts +++ b/src/config.ts @@ -151,6 +151,16 @@ export function isLoopbackUrl(url: string): boolean { * optional surrounding single/double quotes are stripped. Intentionally * minimal — not a full dotenv dialect (no interpolation, no multiline). */ +const shadowedDotEnv = new Set(); + +/** + * Whether `.env` sets `key` but the daemon's real environment set it first, + * so the file's value isn't the one in use (recorded by `loadDotEnv`). + */ +export function dotEnvShadowed(key: string): boolean { + return shadowedDotEnv.has(key); +} + export function loadDotEnv(): string[] { const envPath = join(getConfigDir(), ".env"); if (!existsSync(envPath)) return []; @@ -169,7 +179,10 @@ export function loadDotEnv(): string[] { const key = line.slice(0, eq).trim(); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; // Real environment wins — file is the fallback, not an override. - if (process.env[key] !== undefined) continue; + if (process.env[key] !== undefined) { + shadowedDotEnv.add(key); + continue; + } let value = line.slice(eq + 1).trim(); if ( value.length >= 2 && diff --git a/src/daemon/mcp/oauth.ts b/src/daemon/mcp/oauth.ts index 38efcf89..79b137da 100644 --- a/src/daemon/mcp/oauth.ts +++ b/src/daemon/mcp/oauth.ts @@ -272,6 +272,21 @@ export class McpOAuth implements McpCredentialSource { this.#rejected.delete(keyOf(spec, tenant)); } + /** + * Forget a credential slot of a server entirely (its credential was + * removed): every tenant's tokens and client registration, at every URL. + * Returns the tenants that had one, so their clients can be dropped. + */ + forgetSlot(server: string, slot: string): McpTenant[] { + const tenants = this.#store.clearMcpOAuthSlot(server, slot); + for (const t of tenants) { + const key = keyOf({ name: server, slot }, t); + this.#signIns.set(key, (this.#signIns.get(key) ?? 0) + 1); + this.#rejected.delete(key); + } + return tenants; + } + // ── internals ────────────────────────────────────────────────────────────── async #refresh(spec: McpServerSpec, tenant: McpTenant): Promise { diff --git a/src/daemon/mcp/session-view.ts b/src/daemon/mcp/session-view.ts index d4e3c866..5094c7cc 100644 --- a/src/daemon/mcp/session-view.ts +++ b/src/daemon/mcp/session-view.ts @@ -110,9 +110,17 @@ export class SessionMcpView implements McpServerView { return this.#loaded; } - /** What's applied, as one comparable value. */ + /** + * What's applied, as one comparable value — the workstream grant included, + * read live: a workstream edited (a server removed, a credential swapped) + * rebuilds the backend at its next turn, as G4 and §8 promise, on backends + * that mount servers themselves too. + */ get fingerprint(): string { - return this.#fingerprint; + const grant = this.#opts.grant?.(); + if (!grant) return this.#fingerprint; + const servers = [...grant.servers].map(([n, s]) => `${n}=${s.credential?.id ?? ""}:${s.credential?.header ?? ""}:${s.oauthSlot ?? ""}`).sort(); + return `${this.#fingerprint}\u0003${grant.workstream ?? ""}\u0001${grant.workdirServers ? 1 : 0}\u0001${servers.join("\u0001")}`; } /** This session's own servers in use, once each: their hub clients are its to let go. */ @@ -477,7 +485,8 @@ function applyGrant(specs: readonly McpServerSpec[], grant: McpGrant): McpServer out.push({ ...spec, transport: { ...spec.transport, credential: { ...sel.credential } }, - variant: `${spec.variant ?? ""}#cred:${sel.credential.id}`, + // The header too: a credential redeclared with another header is another client. + variant: `${spec.variant ?? ""}#cred:${sel.credential.id}:${sel.credential.header ?? ""}`, }); continue; } diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 1515e7c2..e33a511c 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -14,7 +14,9 @@ import { grantRefusal, type McpGrant } from "./workstreams/grant.js"; import { OverrideTokens } from "./workstreams/override.js"; import { withSlot } from "./mcp/oauth.js"; import type { FolderReader } from "./workstreams/folder.js"; -import { createSecretStore, type SecretStore } from "./credentials/store.js"; +import { createSecretStore, credentialEnvName, type SecretStore } from "./credentials/store.js"; +import { credentialsSchema } from "./credentials/schema.js"; +import { workstreamsSchema } from "./workstreams/schema.js"; import { registerSecretPaths } from "./secret-paths.js"; import { type RequestDirectoryInput, type SandboxMcpMount, SandboxMcpHttp } from "./sandbox/request-directory.js"; import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs"; @@ -37,7 +39,7 @@ import type { HookBus } from "./hooks/bus.js"; import type { Store } from "./store.js"; import { createPushTransport, PushService } from "./push/index.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; -import { applyPatches, getManifest, getSnapshot, previewPatches, readMcpServerEntry, writeMcpServerEntry, writeWorktreeTrust } from "./settings/store.js"; +import { applyPatches, getManifest, getSnapshot, previewPatches, readMcpServerEntry, readDotEnvValueSet, writeConfigBlock, writeCredentialValue, writeMcpServerEntry, writeWorktreeTrust } from "./settings/store.js"; import { adoptability, adoptableName, adoptedEntry, adoptionRefusal, claudeMcpServers, fromRepoFile, fullUrlRequired, isSameAdoption, shellArg } from "./mcp/adopt.js"; import { readWorkdirServers } from "./mcp/workdir-servers.js"; import { cleanMcpError, commandSummary, MAX_SHOWN_KEYS, quotedVisible, redactMcpUrl, serverProgram, shownLabel, visibleCompact, visibleVerbatim } from "./mcp/redact.js"; @@ -154,7 +156,7 @@ import { redactCommand } from "./skill-command.js"; import type { McpHub } from "./mcp/hub.js"; import { mcpOAuthBindingCookieName, type McpOAuth, type McpOAuthProof, type McpTenant } from "./mcp/oauth.js"; import { isOAuthServer, type McpServerSpec, OAUTH_UNSUPPORTED_BACKENDS } from "./mcp/types.js"; -import { atomicWrite, type CodeoidConfig, DEFAULT_PROVIDER_ENV, getConfigDir, loadConfig, mutateConfigFile, validateConfigObject } from "../config.js"; +import { atomicWrite, type CodeoidConfig, DEFAULT_PROVIDER_ENV, dotEnvShadowed, getConfigDir, loadConfig, mutateConfigFile, validateConfigObject } from "../config.js"; import type { CompressionRegistry } from "./compress/index.js"; import { endpointNameOf, endpointProviderId, ORCHESTRATOR_ROLE, type ProviderInfo } from "../protocol/types.js"; import type { @@ -1663,6 +1665,16 @@ mcpHub: this.#mcpHub, return this.#workstreamList(msg, auth); case "workstream.resolve": return this.#workstreamResolve(msg, auth); + case "workstream.upsert": + case "workstream.delete": + case "workstream.settings": + return this.#workstreamEdit(msg, auth); + case "credential.list": + return this.#credentialList(msg, auth); + case "credential.upsert": + case "credential.delete": + case "credential.set_value": + return this.#credentialEdit(msg, auth); case "mcp.oauth.begin": return this.#mcpOAuthBegin(msg, auth); case "mcp.oauth.complete": @@ -2319,17 +2331,19 @@ mcpHub: this.#mcpHub, #workstreamOpts(binding: WorkstreamBinding | undefined): { workstream?: WorkstreamBinding; describeWorkstream: (b: WorkstreamBinding | undefined) => ReturnType; - workstreamEnv?: Readonly>; + workstreamEnv?: () => Readonly>; mcpGrant: () => McpGrant | undefined; } { - const { env, missing } = this.#workstreams.envFor(binding, this.#secrets); + const { missing } = this.#workstreams.envFor(binding, this.#secrets); if (missing.length > 0) { console.warn(`[codeoid] workstream "${binding?.id}": no value for ${missing.join(", ")} — set the credential (${this.#secrets.kind} store); the session starts without it`); } return { ...(binding ? { workstream: binding } : {}), describeWorkstream: this.#describeWorkstream, - ...(Object.keys(env).length > 0 ? { workstreamEnv: env } : {}), + // Read live: an edit to the workstream or a credential reaches the + // session's next backend start (its next turn), never only a restart. + ...(binding?.id ? { workstreamEnv: () => this.#workstreams.envFor(binding, this.#secrets).env } : {}), // Read live: the grant follows the config as it is at each listing. mcpGrant: () => this.#workstreams.grantFor(binding), }; @@ -3432,6 +3446,152 @@ mcpHub: this.#mcpHub, }; } + /** Who may change workstreams and credentials: settings:write, never a delegated token. */ + #configEditRefusal(requestId: string, auth: AuthContext): DaemonMessage | undefined { + if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_WRITE)) { + return { type: "response.error", requestId, error: "Missing scope: settings:write", code: "forbidden" }; + } + if (auth.delegationDepth > 0) { + return { type: "response.error", requestId, error: "A delegated token can't change workstreams or credentials", code: "forbidden" }; + } + return undefined; + } + + /** + * Apply a workstreams or credentials block just written to config.json: + * the live service (grants and new sessions follow it at once — live + * sessions keep their homes), this manager's config, and every subscriber. + */ + #applyWorkstreamConfig(config: Record): void { + // Both blocks from the file as written (W2 audit): a hand edit to the other + // block since boot is applied too, never shadowed by a stale copy. + const workstreams = workstreamsSchema.parse(config.workstreams); + const credentials = credentialsSchema.parse(config.credentials); + if (this.#config) this.#config = { ...this.#config, workstreams, credentials }; + this.#workstreams.update(workstreams, credentials); + this.#pushToProviderSubscribers({ type: "workstreams.changed", revision: this.#workstreams.revision }); + } + + #workstreamEdit(msg: Extract, auth: AuthContext): DaemonMessage { + const refused = this.#configEditRefusal(msg.id, auth); + if (refused) return refused; + const asObject = (v: unknown): Record => (v && typeof v === "object" && !Array.isArray(v) ? { ...(v as Record) } : {}); + const written = writeConfigBlock("workstreams", (current) => { + const block = asObject(current); + const items = Array.isArray(block.items) ? [...(block.items as unknown[])] : []; + const idOf = (i: unknown) => (i && typeof i === "object" ? (i as { id?: unknown }).id : undefined); + if (msg.type === "workstream.upsert") { + const at = items.findIndex((i) => idOf(i) === msg.item.id); + if (at >= 0) items[at] = msg.item; + else items.push(msg.item); + block.items = items; + } else if (msg.type === "workstream.delete") { + block.items = items.filter((i) => idOf(i) !== msg.workstream); + if (block.default === msg.workstream) delete block.default; + } else { + if (msg.isolateGlobalServers !== undefined) block.isolateGlobalServers = msg.isolateGlobalServers; + if (msg.default === null) delete block.default; + else if (msg.default !== undefined) block.default = msg.default; + } + return block; + }); + if (!written.ok) return { type: "response.error", requestId: msg.id, error: written.error, code: "invalid_request" }; + this.#store.audit(auth.sub, msg.type, "", msg.type === "workstream.upsert" ? `id=${String(msg.item.id)}` : msg.type === "workstream.delete" ? `id=${msg.workstream}` : `isolate=${msg.isolateGlobalServers ?? "-"} default=${msg.default === null ? "none" : (msg.default ?? "-")}`); + this.#applyWorkstreamConfig(written.config); + // The edit's own reply needs no settings:read: it is what the caller just wrote. + return this.#workstreamList({ type: "workstream.list", id: msg.id }, { ...auth, scopes: [...auth.scopes, SCOPES.SETTINGS_READ] as AuthContext["scopes"] }); + } + + #credentialList(msg: { id: string }, auth: AuthContext): DaemonMessage { + if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_READ)) { + return { type: "response.error", requestId: msg.id, error: "Missing scope: settings:read", code: "forbidden" }; + } + const items = this.#config?.workstreams?.items ?? []; + const usedBy = (id: string) => + items + .filter((w) => Object.values(w.env).some((r) => r.secret === id) || w.mcpServers.some((s) => typeof s !== "string" && s.credential === id)) + .map((w) => w.id); + const tenant = tenantOf(auth); + const credentials = (this.#config?.credentials ?? []).map((c) => { + let status: { set: boolean; source?: string }; + if (c.kind === "oauth") { + const spec = this.#mcpRegistry?.get(c.server); + const signedIn = spec && this.#mcpOAuth ? this.#mcpOAuth.signedIn(withSlot(spec, c.id), tenant) : false; + status = signedIn ? { set: true, source: `signed in to ${c.server}` } : { set: false }; + } else { + const s = this.#secrets.status(c.id); + status = s.set ? { set: true, source: s.source } : { set: false }; + } + return { + id: c.id, + kind: c.kind, + ...("header" in c && c.header ? { header: c.header } : {}), + ...("server" in c && c.server ? { server: c.server } : {}), + ...(c.description ? { description: c.description } : {}), + status, + usedBy: usedBy(c.id), + }; + }); + return { type: "credential.list.result", requestId: msg.id, store: { kind: this.#secrets.kind, writable: this.#secrets.writable }, credentials }; + } + + #credentialEdit(msg: Extract, auth: AuthContext): DaemonMessage { + const refused = this.#configEditRefusal(msg.id, auth); + if (refused) return refused; + const err = (error: string): DaemonMessage => ({ type: "response.error", requestId: msg.id, error, code: "invalid_request" }); + const declared = this.#config?.credentials ?? []; + if (msg.type === "credential.set_value") { + const decl = declared.find((c) => c.id === msg.credential); + if (!decl) return err(`No credential "${msg.credential}" is declared`); + if (decl.kind === "oauth") return err(`"${decl.id}" is an OAuth sign-in: codeoid mcp signin ${decl.server} --credential ${decl.id}`); + if (!this.#secrets.writable || this.#secrets.kind !== "env-file") return err(`The ${this.#secrets.kind} credential store is managed outside codeoid`); + // A value from the daemon's real environment (not .env) wins at the next + // start whatever is written here: say so rather than seem to change it. + // Set in both is the same: .env was skipped at start (dotEnvShadowed). + const name = credentialEnvName(decl.id); + if (process.env[name] !== undefined && (dotEnvShadowed(name) || !readDotEnvValueSet(name))) { + return err(`${credentialEnvName(decl.id)} is set in the daemon's own environment, which wins over .env: change it there`); + } + const w = writeCredentialValue(credentialEnvName(decl.id), msg.value); + if (!w.ok) return err(w.error); + // The value never goes to the audit, only that it changed. + this.#store.audit(auth.sub, "credential.set_value", "", `id=${decl.id} ${msg.value === null ? "cleared" : "set"}`); + } else { + const id = msg.type === "credential.upsert" ? String(msg.item.id ?? "") : msg.credential; + const existing = declared.find((c) => c.id === id); + if (msg.type === "credential.delete" && !existing) return err(`No credential "${id}" is declared`); + if (msg.type === "credential.delete") { + const users = (this.#config?.workstreams?.items ?? []).filter( + (w) => Object.values(w.env).some((r) => r.secret === id) || w.mcpServers.some((s) => typeof s !== "string" && s.credential === id), + ); + if (users.length > 0) return err(`"${id}" is used by ${users.map((u) => u.id).join(", ")}: remove it there first`); + } + const written = writeConfigBlock("credentials", (current) => { + const list = Array.isArray(current) ? [...(current as unknown[])] : []; + const idOf = (i: unknown) => (i && typeof i === "object" ? (i as { id?: unknown }).id : undefined); + if (msg.type === "credential.upsert") { + const at = list.findIndex((i) => idOf(i) === id); + if (at >= 0) list[at] = msg.item; + else list.push(msg.item); + return list; + } + return list.filter((i) => idOf(i) !== id); + }); + if (!written.ok) return err(written.error); + if (msg.type === "credential.delete" && existing?.kind !== "oauth" && this.#secrets.writable && this.#secrets.kind === "env-file") writeCredentialValue(credentialEnvName(id), null); + // An OAuth slot's sign-ins go with it, every tenant's: re-declaring the + // id later must not silently reuse an old sign-in. + if (msg.type === "credential.delete" && existing?.kind === "oauth") { + const tenants = this.#mcpOAuth?.forgetSlot(existing.server, existing.id) ?? []; + for (const t of tenants) this.#mcpHub?.dropTenant(existing.server, t); + } + this.#store.audit(auth.sub, msg.type, "", `id=${id}`); + this.#applyWorkstreamConfig(written.config); + } + if (msg.type === "credential.set_value") this.#pushToProviderSubscribers({ type: "workstreams.changed", revision: this.#workstreams.revision }); + return this.#credentialList(msg, { ...auth, scopes: [...auth.scopes, SCOPES.SETTINGS_READ] as AuthContext["scopes"] }); + } + /** * What a folder resolves to (and whether an explicit choice conflicts), for * the new-session picker. Gated like a create: it answers the question a diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 6264b1f8..960736a7 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -565,7 +565,7 @@ export interface SessionCreateOptions { * and held to the same denylist (the schema already refused anything it * would). Never persisted: recomputed from the config on every resume. */ - workstreamEnv?: Readonly>; + workstreamEnv?: () => Readonly>; /** * The home's MCP grant under isolation (workstreams/grant.ts), read live; * undefined result = no isolation. Applied by the session's MCP view, so @@ -1402,7 +1402,51 @@ export class Session { /** This turn's end-attempt interrupt has been scheduled (once per turn). */ #endAttemptInterruptQueued = false; /** See SessionCreateOptions.extraEnv (validated in the constructor). */ + #extraEnvStatic: Readonly> | undefined; + /** The home workstream's env, read live (SessionCreateOptions.workstreamEnv). */ + #workstreamEnv: (() => Readonly>) | undefined; + /** What a backend gets as extra env: a live view over both (see #liveExtraEnv). */ #extraEnv: Readonly> | undefined; + /** The extra env the current backend was built with, to rebuild it when the workstream's changes. */ + #envBuiltWith: string | undefined; + + /** The workstream's env under the caller's extra env, held to the extra-env denylist. */ + #composedEnv(): Record { + const out: Record = { ...(this.#workstreamEnv?.() ?? {}), ...(this.#extraEnvStatic ?? {}) }; + for (const k of extraEnvViolations(out)) delete out[k]; + return out; + } + + /** + * The extra env handed to the backend: a read-only view that is always the + * workstream's env as configured NOW. Providers read it when they start a + * process (spread or iterated), so a workstream edit — a credential cleared, + * an env entry removed — reaches the next backend start, which + * #rebuildForMcp brings to the next turn (design §8: narrowing applies at + * the next turn boundary). + */ + #liveExtraEnv(): Readonly> { + const current = () => this.#composedEnv(); + return new Proxy({} as Record, { + get: (_t, k) => (typeof k === "string" ? current()[k] : undefined), + has: (_t, k) => typeof k === "string" && k in current(), + ownKeys: () => Object.keys(current()), + getOwnPropertyDescriptor: (_t, k) => { + const v = typeof k === "string" ? current()[k] : undefined; + return v === undefined ? undefined : { value: v, enumerable: true, configurable: true, writable: false }; + }, + set: () => false, + deleteProperty: () => false, + defineProperty: () => false, + }); + } + + /** A hash of the composed env: held to compare, so the values themselves aren't kept around. */ + #envDigest(): string { + return createHash("sha256") + .update(JSON.stringify(Object.entries(this.#composedEnv()).sort(([a], [b]) => a.localeCompare(b)))) + .digest("hex"); + } /** The session's OS sandbox (#448); null on a Session built without a sandbox registry. */ #sandbox: SessionSandbox | null = null; /** The repository's git settings as last looked at (#448: #checkGitControl), from the first turn on. */ @@ -1752,7 +1796,8 @@ export class Session { this.#collaborationCost = opts.collaborationCost; this.#maxBudgetUsd = opts.maxBudgetUsd; this.#onBudgetExhausted = opts.onBudgetExhausted ?? "park"; - const extra = opts.workstreamEnv && Object.keys(opts.workstreamEnv).length > 0 ? { ...opts.workstreamEnv, ...(opts.extraEnv ?? {}) } : opts.extraEnv; + this.#workstreamEnv = opts.workstreamEnv; + const extra = opts.extraEnv; if (extra) { // Before any side effect: a refused key must fail the create, not run a // backend with half the env the caller asked for. @@ -1762,8 +1807,9 @@ export class Session { `extraEnv may not set ${refused.join(", ")}: codeoid owns credential, account and process-loading variables for the backend`, ); } - this.#extraEnv = Object.freeze({ ...extra }); + this.#extraEnvStatic = Object.freeze({ ...extra }); } + if (this.#extraEnvStatic || this.#workstreamEnv) this.#extraEnv = this.#liveExtraEnv(); if (opts.sandboxRegistry) { this.#sandbox = new SessionSandbox({ sessionId: this.id, @@ -2962,8 +3008,8 @@ export class Session { */ async #applyMcpView(): Promise { const view = this.#mcpView; - if (!view || this.#mcpBusy()) return; - if (!view.loaded || this.#mcpRefreshPending) { + if ((!view && !this.#workstreamEnv) || this.#mcpBusy()) return; + if (view && (!view.loaded || this.#mcpRefreshPending)) { this.#mcpRefreshPending = false; try { await this.#loadMcp(); @@ -3111,14 +3157,20 @@ export class Session { * Whether it was rebuilt. The first time, nothing was built yet: just noted. */ async #rebuildForMcp(): Promise { + // The workstream's env changed since the backend started (a credential + // cleared or swapped, an env entry removed): rebuilt too, like its servers + // — with or without an MCP view (a session may have none). + const envNow = this.#workstreamEnv ? this.#envDigest() : undefined; + if (envNow !== undefined) this.#envBuiltWith ??= envNow; + const envChanged = envNow !== undefined && envNow !== this.#envBuiltWith; const view = this.#mcpView; - if (!view?.loaded) return false; - if (this.#mcpBuiltWith === undefined) { + if (!view?.loaded) { + if (!envChanged) return false; + } else if (this.#mcpBuiltWith === undefined) { this.#mcpBuiltWith = view.fingerprint; this.#releaseMcp(); - return false; - } - if (view.fingerprint === this.#mcpBuiltWith) return false; + if (!envChanged) return false; + } else if (view.fingerprint === this.#mcpBuiltWith && !envChanged) return false; await this.#teardownProvider(); // records what's applied, and lets go of what was removed await this.#reseedAfterTeardown(); return true; @@ -6139,6 +6191,7 @@ export class Session { // still rebuilds it. Nothing still running can call a server the session // stopped using. if (this.#mcpView?.loaded) this.#mcpBuiltWith = this.#mcpView.fingerprint; + if (this.#workstreamEnv) this.#envBuiltWith = this.#envDigest(); this.#releaseMcp(); this.#mcpProjectNames.clear(); // the next backend reads its project files afresh try { await taskToAwait; } catch { /* consumer handles its own errors */ } diff --git a/src/daemon/settings/store.ts b/src/daemon/settings/store.ts index 59965e70..08f38a77 100644 --- a/src/daemon/settings/store.ts +++ b/src/daemon/settings/store.ts @@ -275,6 +275,55 @@ export function writeWorktreeTrust( return { ok: true, before, after }; } +/** + * Replace one top-level block of config.json (`workstreams`, `credentials`) + * with what `update` returns from the block as the file holds it NOW — so a + * hand edit made since the daemon started is never overwritten — then + * validate the whole config and write it atomically. Nothing is written if it + * doesn't validate; `undefined` from `update` removes the block. Returns the + * block as written. + */ +export function writeConfigBlock( + key: "workstreams" | "credentials", + update: (current: unknown) => unknown, +): { ok: true; value: unknown; config: Record } | { ok: false; error: string } { + let raw: Record; + try { + raw = readRawConfig(); + } catch (err) { + return { ok: false, error: `config.json is not valid JSON: ${errMsg(err)}` }; + } + const value = update(raw[key]); + if (value === undefined) delete raw[key]; + else raw[key] = value; + const validation = validateConfigObject(raw); + if (!validation.ok) return { ok: false, error: validation.issues.map((i) => `${i.path ? `${i.path}: ` : ""}${i.message}`).join("; ") }; + writeConfigJson(raw); + // The whole file as written: a caller applies every block it depends on from + // it, never one block from here and another from what it loaded at boot. + return { ok: true, value, config: raw }; +} + +/** Whether `.env` itself defines `key` (not merely the daemon's environment). */ +export function readDotEnvValueSet(key: string): boolean { + return readDotEnvKeys(configFilePaths().envPath).has(key); +} + +/** + * Set (or with `null`, clear) a named credential's value in the env-file + * secret store: `CODEOID_CRED_` in `.env` (0600) and the daemon's live + * environment, so it applies at the next use without a restart. The value is + * never read back out: callers get presence only (credentials/store.ts). + */ +export function writeCredentialValue(envName: string, value: string | null): { ok: true } | { ok: false; error: string } { + if (!/^CODEOID_CRED_[A-Z0-9_]{1,64}$/.test(envName)) return { ok: false, error: "not a credential's variable" }; + if (value !== null && (value.length === 0 || /[\r\n\0]/.test(value))) return { ok: false, error: "a credential value is one non-empty line" }; + writeDotEnv(new Map([[envName, value]])); + if (value === null) delete process.env[envName]; + else process.env[envName] = value; + return { ok: true }; +} + // ── config.json IO ────────────────────────────────────────────────────────── function readRawConfig(): Record { diff --git a/src/daemon/store.ts b/src/daemon/store.ts index e6222350..7dd61827 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -1908,6 +1908,20 @@ export class Store { .run(tenant.accountId, tenant.projectId, server.name, server.url, server.slot ?? "", JSON.stringify(tokens), savedAtMs); } + /** + * Delete one named credential slot of a server for every tenant and URL + * (the credential was removed). Never the default sign-in (slot ""). Returns + * the tenants that had a row. + */ + clearMcpOAuthSlot(server: string, slot: string): Array<{ accountId: string; projectId: string }> { + if (!slot) return []; + const rows = this.#db + .prepare("SELECT DISTINCT account_id, project_id FROM mcp_oauth_credentials WHERE server = ? AND credential = ?") + .all(server, slot) as Array<{ account_id: string; project_id: string }>; + this.#db.prepare("DELETE FROM mcp_oauth_credentials WHERE server = ? AND credential = ?").run(server, slot); + return rows.map((r) => ({ accountId: r.account_id, projectId: r.project_id })); + } + /** * Forget part of a tenant's OAuth state for a server: its tokens (sign out, * or the server rejected them), its client registration, or both. A hard diff --git a/src/daemon/workstreams/grant.ts b/src/daemon/workstreams/grant.ts index 43ba2604..887742b3 100644 --- a/src/daemon/workstreams/grant.ts +++ b/src/daemon/workstreams/grant.ts @@ -40,8 +40,9 @@ export function grantRefusal(grant: McpGrant | undefined, spec: { name: string; return grant.workdirServers ? undefined : `MCP server "${name}" is this folder's own, and the folder isn't workstream "${grant.workstream ?? "none"}"'s`; } const sel = grant.servers.get(name); - const credential = "credential" in spec.transport ? spec.transport.credential?.id : undefined; - if (sel && (sel.oauthSlot ?? sel.credential?.id) === credential) return undefined; + const cred = "credential" in spec.transport ? spec.transport.credential : undefined; + const same = sel?.oauthSlot ? cred?.id === sel.oauthSlot : (sel?.credential?.id === cred?.id && (sel?.credential?.header ?? "") === (cred && "header" in cred ? (cred.header ?? "") : "")); + if (sel && same) return undefined; if (sel) return `MCP server "${name}" is now called with another credential in workstream "${grant.workstream}" — it applies from the next turn`; return grant.workstream ? `MCP server "${name}" is not in workstream "${grant.workstream}"` diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index 92f349d3..7e2a873e 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -74,7 +74,8 @@ export class WorkstreamService { paths: w.match.paths.flatMap((p) => this.#pathRuleForms(p)), remotes: w.match.remotes.map((r) => r.toLowerCase()), })); - this.#revision = createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16); + // The credentials too: a credential edit changes what sessions get, and clients skip a repeated revision. + this.#revision = createHash("sha256").update(JSON.stringify([cfg, [...this.#credentials.values()]])).digest("hex").slice(0, 16); } /** @@ -181,6 +182,7 @@ export class WorkstreamService { env: Object.keys(w.env), ...(w.git ? { git: { ...w.git } } : {}), skillDirs: [...w.skillDirs], + config: structuredClone((this.#cfg.items.find((i) => i.id === w.id) ?? {}) as Record), })); } diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts index e100fd61..3fb55231 100644 --- a/src/daemon/workstreams/workstreams.test.ts +++ b/src/daemon/workstreams/workstreams.test.ts @@ -525,10 +525,11 @@ describe("WorkstreamService", () => { expect(s.envFor(undefined, store).env).toEqual({}); }); - test("summaries list env names, never values", () => { + test("summaries list env names and credential ids, never values", () => { const s = service([{ id: "a", env: { GH_TOKEN: { secret: "gh-a" } } }]); expect(s.summaries()[0].env).toEqual(["GH_TOKEN"]); - expect(JSON.stringify(s.summaries())).not.toContain("gh-a"); + // A credential id is a reference the editor shows; a value never is here. + expect(s.summaries()[0].config).toMatchObject({ env: { GH_TOKEN: { secret: "gh-a" } } }); }); }); diff --git a/src/tests/protocol.test.ts b/src/tests/protocol.test.ts index ba89f5c1..6acc4e37 100644 --- a/src/tests/protocol.test.ts +++ b/src/tests/protocol.test.ts @@ -466,6 +466,10 @@ describe("DaemonMessage routing", () => { return `workstream.list:${msg.workstreams.length}`; case "workstream.resolve.result": return `workstream.resolve:${msg.resolution.home ?? "none"}`; + case "workstreams.changed": + return `workstreams.changed:${msg.revision}`; + case "credential.list.result": + return `credential.list:${msg.credentials.length}`; case "session.turns.result": return `turns:${msg.turns.length}`; case "session.approvals.result": diff --git a/src/tests/workstreams-edit.test.ts b/src/tests/workstreams-edit.test.ts new file mode 100644 index 00000000..8bad41ea --- /dev/null +++ b/src/tests/workstreams-edit.test.ts @@ -0,0 +1,256 @@ +/** + * Editing workstreams and credentials live (W2): written to config.json / + * .env through the settings store (whole config validated, nothing written + * otherwise), applied to the running daemon at once, and announced. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, loadDotEnv } from "../config.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { ProviderRegistry } from "../daemon/providers/registry.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { ALL_SCOPES, SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, DaemonMessage, SessionInfo } from "../protocol/types.js"; + +const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "rec", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } }; +const OWNER: AuthContext = { sub: "user:edit", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }; +const pushed: DaemonMessage[] = []; +const client = { id: "c-edit", auth: OWNER, send: (m: DaemonMessage) => void pushed.push(m) }; + +let tmp: string; +let store: Store; +let transcript: TranscriptStore; +let savedXdg: string | undefined; +let manager: SessionManager; + +function configJson(): Record { + return JSON.parse(readFileSync(join(tmp, "config", "codeoid", "config.json"), "utf8")); +} + +beforeEach(async () => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-ws-edit-"))); + savedXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = join(tmp, "config"); + mkdirSync(join(tmp, "config", "codeoid"), { recursive: true }); + mkdirSync(join(tmp, "home", "acme"), { recursive: true }); + writeFileSync(join(tmp, "config", "codeoid", "config.json"), JSON.stringify({ dbPath: join(tmp, "codeoid.db"), transcriptDir: join(tmp, "transcripts") })); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: loadConfig({ env: {}, quiet: true }), + _testProviderFactory: () => new MockSessionProvider(), + _testFolderReader: new FolderReader({ home: join(tmp, "home"), sshConfig: "" }), + }); + // Subscribe the client to provider/workstream broadcasts, as a web client does. + await manager.handle({ type: "providers.list", id: "sub" } as never, OWNER, client); + pushed.length = 0; +}); + +afterEach(async () => { + try { + await manager.drain(2_000); + } catch {} + try { + await transcript.flush(); + } catch {} + store.close(); + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; + for (const k of Object.keys(process.env)) if (k.startsWith("CODEOID_CRED_")) delete process.env[k]; + rmSync(tmp, { recursive: true, force: true }); +}); + +const h = (msg: Record, auth: AuthContext = OWNER) => manager.handle(msg as never, auth, client); + +describe("editing workstreams", () => { + it("an upsert is validated, written, applied at once and announced; a create there gets it", async () => { + const r = await h({ type: "workstream.upsert", id: "u1", item: { id: "acme", claim: "exclusive", match: { paths: ["~/acme"] } } }); + expect(r).toMatchObject({ type: "workstream.list.result", workstreams: [{ id: "acme", claim: "exclusive" }] }); + expect((configJson().workstreams as { items: unknown[] }).items).toEqual([{ id: "acme", claim: "exclusive", match: { paths: ["~/acme"] } }]); + expect(pushed.some((m) => m.type === "workstreams.changed")).toBe(true); + const made = (await h({ type: "session.create", id: "c1", name: "s", workdir: join(tmp, "home", "acme") })) as { data: SessionInfo }; + expect(made.data.workstream).toMatchObject({ id: "acme", source: "matched" }); + }); + + it("an invalid item writes nothing and says why", async () => { + const before = readFileSync(join(tmp, "config", "codeoid", "config.json"), "utf8"); + const r = await h({ type: "workstream.upsert", id: "u2", item: { id: "Bad Id" } }); + expect(r).toMatchObject({ type: "response.error", code: "invalid_request" }); + if (r.type === "response.error") expect(r.error).toContain("workstream id"); + expect(readFileSync(join(tmp, "config", "codeoid", "config.json"), "utf8")).toBe(before); + }); + + it("settings and delete; a deleted default is removed with it", async () => { + await h({ type: "workstream.upsert", id: "u3", item: { id: "acme" } }); + const s = await h({ type: "workstream.settings", id: "u4", isolateGlobalServers: true, default: "acme" }); + expect(s).toMatchObject({ isolateGlobalServers: true, default: "acme" }); + const d = await h({ type: "workstream.delete", id: "u5", workstream: "acme" }); + expect(d).toMatchObject({ type: "workstream.list.result", workstreams: [] }); + expect((d as { default?: string }).default).toBeUndefined(); + }); + + it("needs settings:write on an undelegated token", async () => { + expect(await h({ type: "workstream.upsert", id: "u6", item: { id: "x" } }, { ...OWNER, scopes: [SCOPES.SETTINGS_READ] as AuthContext["scopes"] })).toMatchObject({ code: "forbidden" }); + expect(await h({ type: "workstream.upsert", id: "u7", item: { id: "x" } }, { ...OWNER, delegationDepth: 1 })).toMatchObject({ code: "forbidden" }); + }); +}); + +describe("editing credentials", () => { + it("declare, set a value (never returned), see it used, refuse deleting it while used, then delete", async () => { + const up = await h({ type: "credential.upsert", id: "k1", item: { id: "gh-acme", kind: "env" } }); + expect(up).toMatchObject({ type: "credential.list.result", store: { kind: "env-file", writable: true }, credentials: [{ id: "gh-acme", status: { set: false }, usedBy: [] }] }); + const set = await h({ type: "credential.set_value", id: "k2", credential: "gh-acme", value: "ghp_secret_value" }); + expect(JSON.stringify(set)).not.toContain("ghp_secret_value"); + expect(set).toMatchObject({ credentials: [{ id: "gh-acme", status: { set: true, source: "CODEOID_CRED_GH_ACME" } }] }); + expect(readFileSync(join(tmp, "config", "codeoid", ".env"), "utf8")).toContain("CODEOID_CRED_GH_ACME=ghp_secret_value"); + await h({ type: "workstream.upsert", id: "k3", item: { id: "acme", env: { GH_TOKEN: { secret: "gh-acme" } } } }); + expect(await h({ type: "credential.list", id: "k4" })).toMatchObject({ credentials: [{ usedBy: ["acme"] }] }); + const blocked = await h({ type: "credential.delete", id: "k5", credential: "gh-acme" }); + expect(blocked).toMatchObject({ type: "response.error" }); + if (blocked.type === "response.error") expect(blocked.error).toContain("used by acme"); + await h({ type: "workstream.delete", id: "k6", workstream: "acme" }); + expect(await h({ type: "credential.delete", id: "k7", credential: "gh-acme" })).toMatchObject({ credentials: [] }); + expect(readFileSync(join(tmp, "config", "codeoid", ".env"), "utf8")).not.toContain("CODEOID_CRED_GH_ACME"); + }); + + it("a multi-line value, an undeclared one, or an OAuth one's value is refused", async () => { + await h({ type: "credential.upsert", id: "m1", item: { id: "e", kind: "env" } }); + await h({ type: "credential.upsert", id: "m2", item: { id: "o", kind: "oauth", server: "slack" } }); + expect(await h({ type: "credential.set_value", id: "m3", credential: "e", value: "a\nb" })).toMatchObject({ type: "response.error" }); + expect(await h({ type: "credential.set_value", id: "m4", credential: "nope", value: "x" })).toMatchObject({ type: "response.error" }); + const o = await h({ type: "credential.set_value", id: "m5", credential: "o", value: "x" }); + if (o.type === "response.error") expect(o.error).toContain("codeoid mcp signin slack --credential o"); + }); +}); + +describe("live edits after the W2 audit", () => { + it("a hand edit to the other block since boot is applied too, never shadowed", async () => { + const path = join(tmp, "config", "codeoid", "config.json"); + writeFileSync(path, JSON.stringify({ ...configJson(), credentials: [{ id: "hand", kind: "env" }] })); + await h({ type: "workstream.upsert", id: "a1", item: { id: "acme", env: { GH_TOKEN: { secret: "hand" } } } }); + expect(await h({ type: "credential.list", id: "a2" })).toMatchObject({ credentials: [{ id: "hand", usedBy: ["acme"] }] }); + expect(await h({ type: "credential.set_value", id: "a3", credential: "hand", value: "v" })).toMatchObject({ type: "credential.list.result" }); + }); + + it("deleting an undeclared or malformed credential is refused, touching nothing", async () => { + writeFileSync(join(tmp, "config", "codeoid", ".env"), "CODEOID_CRED_GHOST=keep\n"); + expect(await h({ type: "credential.delete", id: "d1", credential: "ghost" })).toMatchObject({ type: "response.error" }); + expect(readFileSync(join(tmp, "config", "codeoid", ".env"), "utf8")).toContain("CODEOID_CRED_GHOST=keep"); + }); + + it("a write-only token gets its edit's reply", async () => { + const r = await h({ type: "workstream.upsert", id: "w1", item: { id: "acme" } }, { ...OWNER, scopes: [SCOPES.SETTINGS_WRITE] as AuthContext["scopes"] }); + expect(r).toMatchObject({ type: "workstream.list.result" }); + }); + + it("a value from the daemon's real environment is refused: it would win at the next start", async () => { + await h({ type: "credential.upsert", id: "e1", item: { id: "real", kind: "env" } }); + process.env.CODEOID_CRED_REAL = "from-env"; + const r = await h({ type: "credential.set_value", id: "e2", credential: "real", value: "x" }); + expect(r).toMatchObject({ type: "response.error" }); + if (r.type === "response.error") expect(r.error).toContain("daemon's own environment"); + }); + + it("a value set in both .env and the real environment is refused too: .env was skipped at start", async () => { + await h({ type: "credential.upsert", id: "b1", item: { id: "both", kind: "env" } }); + writeFileSync(join(tmp, "config", "codeoid", ".env"), "CODEOID_CRED_BOTH=from-file\n"); + process.env.CODEOID_CRED_BOTH = "from-env"; + loadDotEnv(); + const r = await h({ type: "credential.set_value", id: "b2", credential: "both", value: "x" }); + expect(r).toMatchObject({ type: "response.error" }); + expect(readFileSync(join(tmp, "config", "codeoid", ".env"), "utf8")).toContain("CODEOID_CRED_BOTH=from-file"); + }); + + it("forgetting an OAuth slot clears every tenant's sign-in to it, never the default sign-in", () => { + const a = { accountId: "a", projectId: "p" }; + const b = { accountId: "b", projectId: "p" }; + store.saveMcpOAuthTokens(a, { name: "slack", url: "https://s/mcp", slot: "work" }, { access_token: "1" }, 1); + store.saveMcpOAuthTokens(b, { name: "slack", url: "https://old/mcp", slot: "work" }, { access_token: "2" }, 1); + store.saveMcpOAuthTokens(a, { name: "slack", url: "https://s/mcp" }, { access_token: "3" }, 1); + const gone = store.clearMcpOAuthSlot("slack", "work"); + expect(gone.map((t) => t.accountId).sort()).toEqual(["a", "b"]); + expect(store.getMcpOAuthCredential(a, { name: "slack", url: "https://s/mcp", slot: "work" })).toBeUndefined(); + expect(store.getMcpOAuthCredential(b, { name: "slack", url: "https://old/mcp", slot: "work" })).toBeUndefined(); + expect(store.getMcpOAuthCredential(a, { name: "slack", url: "https://s/mcp" })?.tokens).toEqual({ access_token: "3" }); + expect(store.clearMcpOAuthSlot("slack", "")).toEqual([]); + }); + + it("a live session's backend env follows the workstream: a cleared credential is gone at its next start", async () => { + const seen: Array> = []; + const registry = new ProviderRegistry("rec"); + registry.register({ + id: "rec", + displayName: "rec", + create: (init) => { + seen.push(init.extraEnv as Record); + return new MockSessionProvider("rec", []); + }, + }); + const m = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: loadConfig({ env: {}, quiet: true }), + providers: registry, + _testFolderReader: new FolderReader({ home: join(tmp, "home"), sshConfig: "" }), + }); + const hm = (msg: Record) => m.handle(msg as never, OWNER, client); + await hm({ type: "credential.upsert", id: "l1", item: { id: "gh", kind: "env" } }); + await hm({ type: "credential.set_value", id: "l2", credential: "gh", value: "tok-1" }); + await hm({ type: "workstream.upsert", id: "l3", item: { id: "acme", match: { paths: ["~/acme"] }, env: { GH_TOKEN: { secret: "gh" } } } }); + expect((await hm({ type: "session.create", id: "l4", name: "s", workdir: join(tmp, "home", "acme") })).type).toBe("response.ok"); + const env = seen.at(-1)!; + expect({ ...env }.GH_TOKEN).toBe("tok-1"); + await hm({ type: "credential.set_value", id: "l5", credential: "gh", value: "tok-2" }); + expect({ ...env }.GH_TOKEN).toBe("tok-2"); + await hm({ type: "credential.set_value", id: "l6", credential: "gh", value: null }); + expect({ ...env }.GH_TOKEN).toBeUndefined(); + await m.drain(2_000); + }); + + it("a backend that ran is rebuilt at the next turn when its workstream's env changed", async () => { + const made_: MockSessionProvider[] = []; + // A rebuild tears the backend down: its next turn starts it afresh with the env as it is now. + const torn = () => made_.reduce((n, p) => n + p.teardownCount, 0); + const registry = new ProviderRegistry("rec"); + registry.register({ + id: "rec", + displayName: "rec", + create: () => { + const p = new MockSessionProvider("rec", [[DONE], [DONE], [DONE]]); + made_.push(p); + return p; + }, + }); + const m = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: loadConfig({ env: {}, quiet: true }), + providers: registry, + _testFolderReader: new FolderReader({ home: join(tmp, "home"), sshConfig: "" }), + }); + const hm = (msg: Record) => m.handle(msg as never, OWNER, client); + await hm({ type: "credential.upsert", id: "r1", item: { id: "gh", kind: "env" } }); + await hm({ type: "credential.set_value", id: "r2", credential: "gh", value: "tok-1" }); + await hm({ type: "workstream.upsert", id: "r3", item: { id: "acme", match: { paths: ["~/acme"] }, env: { GH_TOKEN: { secret: "gh" } } } }); + const made = (await hm({ type: "session.create", id: "r4", name: "s", workdir: join(tmp, "home", "acme") })) as { data: SessionInfo }; + const session = m.findByName(made.data.name, OWNER)!; + const idle = async () => { + for (let i = 0; i < 200 && session.toInfo().status !== "idle"; i++) await Bun.sleep(10); + }; + await session.send("one", OWNER); + await idle(); + const before = torn(); + await session.send("two", OWNER); + await idle(); + expect(torn()).toBe(before); // nothing changed: the same backend + await hm({ type: "credential.set_value", id: "r5", credential: "gh", value: "tok-2" }); + await session.send("three", OWNER); + await idle(); + expect(torn()).toBe(before + 1); + await m.drain(2_000); + }); +}); + diff --git a/src/tests/workstreams-isolation.test.ts b/src/tests/workstreams-isolation.test.ts index 297feb31..cf4a97e1 100644 --- a/src/tests/workstreams-isolation.test.ts +++ b/src/tests/workstreams-isolation.test.ts @@ -163,6 +163,9 @@ describe("every call is checked against the grant as it is now (G4)", () => { expect(grantRefusal(g, { name: "codeoid_memory", builtin: true, transport: { kind: "in-process" } })).toBeUndefined(); expect(grantRefusal(g, { name: "gh", builtin: false, transport: { kind: "http", credential: { id: "new" } } as never })).toBeUndefined(); expect(grantRefusal(g, { name: "gh", builtin: false, transport: { kind: "http", credential: { id: "old" } } as never })).toMatch(/another credential/); + // The same id with another header is another credential too (W2 audit). + const hg: McpGrant = { workstream: "acme", servers: new Map([["gh", { credential: { id: "new", header: "X-Api-Key" } }]]), workdirServers: false }; + expect(grantRefusal(hg, { name: "gh", builtin: false, transport: { kind: "http", credential: { id: "new" } } as never })).toMatch(/another credential/); expect(grantRefusal(g, { name: "mine", builtin: false, origin: "claude-local", transport: { kind: "stdio" } })).toMatch(/this folder's own/); expect(grantRefusal(undefined, { name: "x", builtin: false, transport: { kind: "stdio" } })).toBeUndefined(); }); diff --git a/web/src/components/CenterPane.tsx b/web/src/components/CenterPane.tsx index 06dca1e4..7eec877b 100644 --- a/web/src/components/CenterPane.tsx +++ b/web/src/components/CenterPane.tsx @@ -25,6 +25,7 @@ import PromptBox from "./prompt/PromptBox"; import SessionControls from "./SessionControls"; import { AccountChip } from "./AccountChip"; import { SandboxChip } from "./SandboxChip"; +import { WorkstreamChip } from "./WorkstreamChip"; import Transcript from "./transcript/Transcript"; import FleetRail from "./FleetRail"; import WorkerIndicator from "./transcript/WorkerIndicator"; @@ -119,6 +120,7 @@ const SessionHeader: Component = () => ( {s().model} + diff --git a/web/src/components/CredentialsPanel.tsx b/web/src/components/CredentialsPanel.tsx new file mode 100644 index 00000000..1169e572 --- /dev/null +++ b/web/src/components/CredentialsPanel.tsx @@ -0,0 +1,237 @@ +/** + * Settings → Credentials (docs/workstreams-design.md G2): named credentials + * workstreams use — declared by id and kind; values set here (into the + * daemon's env-file store) or managed outside codeoid (a `dir` store, an + * OAuth sign-in). A value is typed into a password field, sent once and + * cleared; the daemon only ever says whether one is set. + */ + +import { type Component, For, Show, createSignal, onMount } from "solid-js"; + +import type { CredentialInfo } from "../protocol/types"; +import { credentialsState, deleteCredential, fetchCredentials, saveCredential, setCredentialValue } from "../state/workstreams"; + +const label = "text-[11px] font-medium uppercase tracking-wider text-fg-faint"; +const input = + "w-full rounded border border-border bg-bg px-3 py-1.5 font-mono text-sm text-fg focus:border-accent focus:outline-none"; +const button = + "rounded border border-border bg-bg px-2.5 py-1 text-[12px] text-fg-muted transition hover:border-accent/40 hover:text-fg disabled:opacity-50"; + +const KINDS: Array<{ kind: CredentialInfo["kind"]; what: string }> = [ + { kind: "env", what: "an env var a workstream sets (GH_TOKEN…)" }, + { kind: "bearer", what: "sent to an MCP server as Authorization: Bearer" }, + { kind: "header", what: "sent to an MCP server in a named header" }, + { kind: "oauth", what: "a second OAuth sign-in to one server" }, +]; + +export const CredentialsPanel: Component = () => { + onMount(() => void fetchCredentials()); + const [busy, setBusy] = createSignal(null); + const [error, setError] = createSignal(null); + const [adding, setAdding] = createSignal(false); + const run = async (key: string, fn: () => Promise) => { + setBusy(key); + setError(null); + try { + await fn(); + return true; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + return false; + } finally { + setBusy(null); + } + }; + const store = () => credentialsState().store; + + return ( +
+
+

Credentials

+

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

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