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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<ID>=… 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.
Expand Down
3 changes: 2 additions & 1 deletion docs/workstreams-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
106 changes: 106 additions & 0 deletions e2e/specs/workstreams-ui.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
7 changes: 7 additions & 0 deletions packages/protocol/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
"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" },
Expand Down
30 changes: 30 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -1337,6 +1360,13 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
worktreeTrustSchema,
workstreamListSchema,
workstreamResolveSchema,
workstreamUpsertSchema,
workstreamDeleteSchema,
workstreamSettingsSchema,
credentialListSchema,
credentialUpsertSchema,
credentialDeleteSchema,
credentialSetValueSchema,
sessionTurnsSchema,
sessionApprovalsSchema,
claudeAccountsListSchema,
Expand Down
18 changes: 18 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1357,6 +1366,13 @@ export type ClientMessage =
| WorktreeTrustMsg
| WorkstreamListMsg
| WorkstreamResolveMsg
| WorkstreamUpsertMsg
| WorkstreamDeleteMsg
| WorkstreamSettingsMsg
| CredentialListMsg
| CredentialUpsertMsg
| CredentialDeleteMsg
| CredentialSetValueMsg
| SessionTurnsMsg
| SessionApprovalsMsg
| ClaudeAccountsListMsg
Expand Down Expand Up @@ -3281,6 +3297,8 @@ export type DaemonMessage =
| WorktreeTrustResultMsg
| WorkstreamListResultMsg
| WorkstreamResolveResultMsg
| WorkstreamsChangedMsg
| CredentialListResultMsg
| SessionTurnsResultMsg
| SessionApprovalsResultMsg
| ClaudeAccountsResultMsg
Expand Down
82 changes: 82 additions & 0 deletions packages/protocol/src/workstreams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

export interface WorkstreamMatchInfo {
Expand Down Expand Up @@ -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<string, unknown>;
}

/** 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<string, unknown>;
}

/** 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[];
}
15 changes: 14 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

/**
* 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 [];
Expand All @@ -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 &&
Expand Down
15 changes: 15 additions & 0 deletions src/daemon/mcp/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
Expand Down
Loading