diff --git a/CLAUDE.md b/CLAUDE.md index 37bb63ad..924873fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,10 +28,10 @@ npm test # control-plane, box actor, ui, guest node:test, fall. When you remove findings, lower the baseline in the same change. Never raise the baseline to make a change pass. -## Known debt (as of 2026-08-18) +## Known debt (as of 2026-08-23) -- 108 anti-slop findings remain, all Tier C: external-boundary code that - needs real parsers (52 no-unknown-parameters, 27 no-runtime-typeof in +- 105 anti-slop findings remain, all Tier C: external-boundary code that + needs real parsers (51 no-unknown-parameters, 25 no-runtime-typeof in plain JS, 23 no-unsafe-dictionary-type, 6 no-unknown-returns). Fixing one requires characterization tests FIRST — these fixes can change accepted inputs. Plan and history: GitHub issue #1. @@ -39,11 +39,14 @@ npm test # control-plane, box actor, ui, guest node:test, not actually enforced today (latent-bug candidates). Grep for the marker. - `TODO(house-canon):` markers flag direct fetch/console sites awaiting migration to the canon helpers. -- 4 files exceed the 700-line warn: `core/workspaces.ts`, - `control-plane/scripts/lib/worker-source.mjs`, `webapp/src/CloudApp.tsx`, - `webapp/src/terminal-touch-controller.ts`. Split on touch, never big-bang. - (`core/files/sync.ts` left the list 2026-08-21 when its transfer plumbing - split into `core/files/dav.ts`.) +- 6 files exceed the 700-line warn: `core/bootstrap.ts`, + `core/workspaces.ts`, `control-plane/scripts/lib/worker-source.mjs`, + `webapp/src/CloudApp.tsx`, `webapp/src/api.ts`, + `webapp/src/terminal-touch-controller.ts`. Split on touch, never + big-bang. (`core/files/sync.ts` left the list 2026-08-21 via the + `core/files/dav.ts` split; `core/bootstrap.ts` and `webapp/src/api.ts` + crossed the line before the 2026-08-23 sweep and are noted here so the + list matches what `lint:gate` prints.) ## Cross-runtime contracts (fixtures are the source of truth) @@ -102,8 +105,8 @@ Do not add aliases anywhere else. and both conformance tests present and passing. A new cross-runtime payload without fixtures is a finding. 6. Max-lines: the warn list printed by `lint:gate` should not grow. -7. Reference counts for comparison (2026-08-19): anti-slop 108 - (52/27/23/6), blitz-house 0, max-lines warnings 4. These are the numbers +7. Reference counts for comparison (2026-08-23): anti-slop 105 + (51/25/23/6), blitz-house 0, max-lines warnings 6. These are the numbers a sweep compares against, so lower them in the same change that removes findings — a stale reference hides the next regression. diff --git a/docs/SELF-HOST.md b/docs/SELF-HOST.md index e2c4df79..04296a08 100644 --- a/docs/SELF-HOST.md +++ b/docs/SELF-HOST.md @@ -71,7 +71,6 @@ not "refuse to deploy". So you can run step 4 immediately and come back here. | `BOX_IMAGE_TAG` | mode-dependent | Empty for registry mode; the archive's image tag for R2 modes. | | `BOX_IMAGE_SHA256` | mode-dependent | Empty for registry mode; the archive's SHA-256 for R2 modes. | | `SESSION_TTL_DAYS` | no | Session cookie lifetime in days, 1–3650. Default 30. | -| `MAX_CONCURRENT_WORKSPACES` | no | Per-principal cap on non-destroyed workspaces, 1–1000. Default 10. | | `MICROVM_HOSTS` | yes | JSON array of Firecracker hosts. **Set `'[]'` if you have none** — that cleanly disables the microVM provider and removes its token secret from the required set. Each configured host names a `tokenVar`; that Worker secret must then exist and be at least 32 characters with no whitespace, or **every request to the Worker fails with 500**. | | `HETZNER_MACHINE_TYPES` | no | Comma-separated `type@location` entries for the Hetzner machine catalog, e.g. `cpx21@hil,cx32@fsn1`. Unset or blank keeps the default catalog (`cpx21@hil`, `cpx31@hil`). Malformed entries are skipped with a logged warning. | | `SIGNUP_MODE` | no | `open` (default) or `invite`. In `invite` mode a Google sign-in that would create a new user is refused unless it carries a valid invite (step 7) or the verified bootstrap secret (step 6). Existing users always sign in. | diff --git a/lint-baseline.json b/lint-baseline.json index bfa6fce5..69f22fd8 100644 --- a/lint-baseline.json +++ b/lint-baseline.json @@ -1,6 +1,6 @@ { - "anti-slop/no-unknown-parameters": 52, - "anti-slop/no-runtime-typeof": 27, + "anti-slop/no-unknown-parameters": 51, + "anti-slop/no-runtime-typeof": 25, "anti-slop/no-unsafe-dictionary-type": 23, "anti-slop/no-unknown-returns": 6, "blitz-house/no-raw-fetch": 0, diff --git a/packages/box/actor/src/adapters/claude.ts b/packages/box/actor/src/adapters/claude.ts index 177fd5f0..bc1297c9 100644 --- a/packages/box/actor/src/adapters/claude.ts +++ b/packages/box/actor/src/adapters/claude.ts @@ -65,26 +65,6 @@ export function claudeEnv( return env; } -const PERMISSION_MODES: ReadonlySet = new Set([ - "default", - "acceptEdits", - "bypassPermissions", - "plan", -]); - -export function claudePermissionMode(value: string): NonNullable { - // SAFETY: The set above holds exactly the PermissionMode literals the SDK accepts. - return PERMISSION_MODES.has(value) ? (value as NonNullable) : "default"; -} - -const EFFORT_LEVELS: ReadonlySet = new Set(["low", "medium", "high", "xhigh", "max"]); - -/** "default" (or anything unknown) leaves the SDK's own effort choice alone. */ -export function claudeEffort(value: string): Options["effort"] | undefined { - // SAFETY: The set above holds exactly the EffortLevel literals the SDK accepts. - return EFFORT_LEVELS.has(value) ? (value as NonNullable) : undefined; -} - export interface ClaudeStreamChunk { messageId: string; text?: string; @@ -105,15 +85,6 @@ export function claudeStreamChunk( return { messageId: currentMessageId }; } -export function claudeTurnOutput( - stopReason: TurnOutput["stopReason"], - resumeId: string | undefined, -): TurnOutput { - const output: TurnOutput = { stopReason }; - if (resumeId) output.resumeId = resumeId; - return output; -} - export class ClaudeAdapter implements AgentAdapter { public async runTurn(input: TurnInput): Promise { const abortController = new AbortController(); @@ -146,7 +117,11 @@ export class ClaudeAdapter implements AgentAdapter { env: claudeEnv(input.token, input.environment), includePartialMessages: true, pathToClaudeCodeExecutable: CLAUDE_BINARY, - permissionMode: claudePermissionMode(input.config.permission), + // SAFETY: a session's config is only ever written by defaultAgentConfig/ + // applyAgentConfig from its provider's catalog, and the claude catalog's + // permission values are pinned to the SDK's PermissionMode literals + // (ClaudeCatalog in agent-config.ts). + permissionMode: input.config.permission as NonNullable, // The agent's rules live in ~/.claude/CLAUDE.md (installed each boot by // blitz-init-state), not in an appended system prompt. Load all // filesystem setting sources so that file is read. This is the SDK's own @@ -167,8 +142,11 @@ export class ClaudeAdapter implements AgentAdapter { // something a reviewer has to catch. if (input.resumeId) options.resume = input.resumeId; if (input.config.model !== "default") options.model = input.config.model; - const effort = claudeEffort(input.config.effort); - if (effort !== undefined) options.effort = effort; + if (input.config.effort !== "default") { + // SAFETY: same catalog pin as permissionMode above — every non-"default" + // claude effort value is one of the SDK's effort literals. + options.effort = input.config.effort as NonNullable; + } let resumeId = input.resumeId ?? undefined; let stopReason: TurnOutput["stopReason"] = "refusal"; let messageId = input.turnId; @@ -186,7 +164,11 @@ export class ClaudeAdapter implements AgentAdapter { } if (record.type === "result") stopReason = record.subtype === "success" ? "end_turn" : "refusal"; } - return claudeTurnOutput(stopReason, resumeId); + // A fresh session's output must not carry a resumeId key at all, the same + // omission rule the optional `options` above follow. + const output: TurnOutput = { stopReason }; + if (resumeId) output.resumeId = resumeId; + return output; } } diff --git a/packages/box/actor/src/agent-config.ts b/packages/box/actor/src/agent-config.ts index fe05814b..2827a1c4 100644 --- a/packages/box/actor/src/agent-config.ts +++ b/packages/box/actor/src/agent-config.ts @@ -1,4 +1,5 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { Options } from "@anthropic-ai/claude-agent-sdk"; import type { Provider } from "./types.js"; export const MODEL_CONFIG_ID = "model"; @@ -24,6 +25,16 @@ interface ProviderCatalog { defaults: AgentConfig; } +/** The claude lists are pinned to the agent SDK's own literals at compile + * time ("default" means: leave the SDK's own choice alone), so the claude + * adapter forwards a session's choices without revalidating them. A value + * the SDK does not accept fails `satisfies` below instead of shipping. */ +type ClaudeChoice = { value: Value; name: string }; +interface ClaudeCatalog extends ProviderCatalog { + efforts: ClaudeChoice<"default" | NonNullable>[]; + permissions: ClaudeChoice>[]; +} + const CATALOGS = { claude: { models: [ @@ -77,7 +88,7 @@ const CATALOGS = { ], defaults: { model: "default", effort: "medium", permission: "never" }, }, -} satisfies Record; +} satisfies Record & { claude: ClaudeCatalog }; export function defaultAgentConfig(provider: Provider): AgentConfig { return { ...CATALOGS[provider].defaults }; @@ -102,14 +113,11 @@ function select( export function agentConfigOptions(provider: Provider, config: AgentConfig): SessionConfigOption[] { const catalog = CATALOGS[provider]; - const options = [ + return [ select(MODEL_CONFIG_ID, "Model", "model", catalog.models, config.model), + select(EFFORT_CONFIG_ID, "Effort", "thought_level", catalog.efforts, config.effort), + select(PERMISSION_CONFIG_ID, "Permissions", "mode", catalog.permissions, config.permission), ]; - if (catalog.efforts.length > 0) { - options.push(select(EFFORT_CONFIG_ID, "Effort", "thought_level", catalog.efforts, config.effort)); - } - options.push(select(PERMISSION_CONFIG_ID, "Permissions", "mode", catalog.permissions, config.permission)); - return options; } /** Applies one selector change, ignoring unknown ids and values. */ diff --git a/packages/box/actor/src/agent-rules-refresh.ts b/packages/box/actor/src/agent-rules-refresh.ts index d3b07406..a296571f 100644 --- a/packages/box/actor/src/agent-rules-refresh.ts +++ b/packages/box/actor/src/agent-rules-refresh.ts @@ -12,32 +12,25 @@ import { spawn } from "node:child_process"; const REFRESH_TTL_MS = 5 * 60 * 1000; function detachedSync(): void { + // SAFETY-equivalent invariant, stated because callers rely on it: spawn with + // these fixed, valid arguments never throws synchronously — every failure, + // a missing binary included, arrives through the "error" event handled + // below, so the refresh stays invisible to the session that triggered it. const child = spawn("blitz-rules", ["sync"], { stdio: "ignore", detached: true }); - // A missing binary or spawn error surfaces asynchronously; swallow it so the - // refresh stays invisible to the session. child.on("error", () => undefined); child.unref(); } -/** Returns the "a session started" callback the actor service calls. The two - * optional arguments are test seams; production passes neither. The returned - * function never throws, so callers need no guard of their own. */ -export function createRulesRefresher( - run: () => void = detachedSync, - now: () => number = Date.now, - ttlMs: number = REFRESH_TTL_MS, -): () => void { +/** Returns the "a session started" callback the actor service calls. The + * returned function never throws, so callers need no guard of their own. */ +export function createRulesRefresher(): () => void { let lastAttempt: number | null = null; return () => { - const attemptedAt = now(); - if (lastAttempt !== null && attemptedAt - lastAttempt < ttlMs) return; + const attemptedAt = Date.now(); + if (lastAttempt !== null && attemptedAt - lastAttempt < REFRESH_TTL_MS) return; // The attempt counts against the TTL whether or not it works, so a box // whose spawn keeps failing does not spin on every session. lastAttempt = attemptedAt; - try { - run(); - } catch { - // Never let a synchronous spawn failure disturb the caller. - } + detachedSync(); }; } diff --git a/packages/box/actor/src/chat-session.ts b/packages/box/actor/src/chat-session.ts index a15443d1..db93e796 100644 --- a/packages/box/actor/src/chat-session.ts +++ b/packages/box/actor/src/chat-session.ts @@ -188,13 +188,4 @@ export class ChatSessionStore { .all(sessionId, limit) as JournalEvent[]; return rows; } - - public sequences(sessionId: string): number[] { - // SAFETY: The query projects the integer seq column from schema-owned event rows. - return ( - this.database.prepare("SELECT seq FROM events WHERE session_id = ? ORDER BY seq").all(sessionId) as Array<{ - seq: number; - }> - ).map(({ seq }) => seq); - } } diff --git a/packages/box/actor/src/credentials.ts b/packages/box/actor/src/credentials.ts index 1c0ea461..f991fa19 100644 --- a/packages/box/actor/src/credentials.ts +++ b/packages/box/actor/src/credentials.ts @@ -122,9 +122,9 @@ export class CredentialSource { const reason = error instanceof Error ? brokerReason(execFailure(error)) : ""; throw new Error(reason ? `broker mint failed: ${reason}` : "broker mint failed"); } - if (stdout.length === 0 || stdout.length > 1_048_576) { - throw new Error("broker returned an invalid token"); - } + // Size is already owned on both sides: execFile's maxBuffer above kills + // and rejects any child whose stdout exceeds it, and parseToken refuses + // an empty line. return parseToken(stdout); } diff --git a/packages/box/actor/test/actor.test.ts b/packages/box/actor/test/actor.test.ts index 570a7362..6249cd2a 100644 --- a/packages/box/actor/test/actor.test.ts +++ b/packages/box/actor/test/actor.test.ts @@ -300,7 +300,8 @@ describe("ACP actor", () => { const terminal = await client.take((frame) => frame.id === "prompt-fixture"); expect((terminal.result as { stopReason: string }).stopReason).toBe("end_turn"); expect(observed.slice(1)).toEqual(updates); - expect(item.store.sequences(sessionId)).toEqual(observed.map((_value, index) => index + 1)); + expect(item.store.replay(sessionId, observed.length).map(({ seq }) => seq)) + .toEqual(observed.map((_value, index) => index + 1)); client.close(); }); diff --git a/packages/box/actor/test/adapter-object-contracts.test.ts b/packages/box/actor/test/adapter-object-contracts.test.ts index 9e989939..94b08e2c 100644 --- a/packages/box/actor/test/adapter-object-contracts.test.ts +++ b/packages/box/actor/test/adapter-object-contracts.test.ts @@ -1,27 +1,95 @@ -import { describe, expect, it } from "vitest"; -import { claudeTurnOutput } from "../src/adapters/claude.js"; +import { describe, expect, it, vi } from "vitest"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { ClaudeAdapter } from "../src/adapters/claude.js"; import { codexThreadRequestParams } from "../src/adapters/codex.js"; import { defaultAgentConfig } from "../src/agent-config.js"; +import type { TurnInput } from "../src/types.js"; -// Claude's `resume` used to have a helper and a block of its own here. It does -// not need one: the SDK reads that option as `if (resume) push('--resume=…')`, -// so an absent key and an undefined one are the same argv, and nothing reads -// the object's key order. The conditional spread at the call site keeps the key -// absent anyway, and `Options` typing — not a runtime assertion — is what keeps -// a second token-delivery hook off the object. +// Claude's turn output and options are asserted through runTurn, the produced +// boundary, with the SDK's `query` stubbed: the SDK reads `resume` as +// `if (resume) push('--resume=…')` and nothing reads key order, but the ACP +// result frame is JSON.stringify(output), so key omission is wire-visible. // -// Codex is the opposite and stays: its params object IS the JSON-RPC wire. +// Codex is different and keeps its direct params test: that object IS the +// JSON-RPC wire. +const queryMock = vi.hoisted(() => vi.fn()); +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ query: queryMock })); + +function engineRun(...messages: Array>): void { + queryMock.mockImplementationOnce(() => + (async function* () { + // SAFETY: test fixtures stand in for engine messages; runTurn reads only + // the fields supplied here. + for (const message of messages) yield message as unknown as SDKMessage; + })(), + ); +} + +function turnInput(overrides: Partial = {}): TurnInput { + return { + sessionId: "session-fixture", + turnId: "turn-1", + cwd: "/workspace", + prompt: [{ type: "text", text: "Say hello." }], + resumeId: null, + signal: new AbortController().signal, + token: null, + environment: { HOME: "/var/lib/blitz/home" }, + config: defaultAgentConfig("claude"), + emit: async () => undefined, + requestPermission: async () => { + throw new Error("no permission request belongs in these turns"); + }, + ...overrides, + }; +} + +function lastQueryOptions(): Record { + // SAFETY: the adapter always calls query({ prompt, options }); the fixture + // above was invoked before this reader. + const call = queryMock.mock.calls.at(-1) as [{ options: Record }] | undefined; + if (!call) throw new Error("query was not called"); + return call[0].options; +} + describe("adapter object omission contracts", () => { - it("preserves Claude turn-output resume omission", () => { - const absent = claudeTurnOutput("end_turn", undefined); - expect(Object.keys(absent)).toEqual(["stopReason"]); - expect("resumeId" in absent).toBe(false); - expect(JSON.stringify(absent)).toBe('{"stopReason":"end_turn"}'); - - const present = claudeTurnOutput("end_turn", "session-1"); - expect(Object.keys(present)).toEqual(["stopReason", "resumeId"]); - expect("resumeId" in present).toBe(true); - expect(JSON.stringify(present)).toBe('{"stopReason":"end_turn","resumeId":"session-1"}'); + it("keeps resumeId absent from a fresh Claude turn's output", async () => { + engineRun({ type: "result", subtype: "success", uuid: "result-1" }); + const output = await new ClaudeAdapter().runTurn(turnInput()); + + expect(Object.keys(output)).toEqual(["stopReason"]); + expect("resumeId" in output).toBe(false); + expect(JSON.stringify(output)).toBe('{"stopReason":"end_turn"}'); + }); + + it("carries the engine's session id out as resumeId", async () => { + engineRun({ type: "result", subtype: "success", session_id: "session-1", uuid: "result-1" }); + const output = await new ClaudeAdapter().runTurn(turnInput()); + + expect(Object.keys(output)).toEqual(["stopReason", "resumeId"]); + expect("resumeId" in output).toBe(true); + expect(JSON.stringify(output)).toBe('{"stopReason":"end_turn","resumeId":"session-1"}'); + }); + + it("forwards catalog config to the engine and omits every 'default'", async () => { + engineRun({ type: "result", subtype: "success", uuid: "result-1" }); + await new ClaudeAdapter().runTurn(turnInput()); + const defaults = lastQueryOptions(); + expect(defaults.permissionMode).toBe("bypassPermissions"); + expect("model" in defaults).toBe(false); + expect("effort" in defaults).toBe(false); + expect("resume" in defaults).toBe(false); + + engineRun({ type: "result", subtype: "success", uuid: "result-2" }); + await new ClaudeAdapter().runTurn(turnInput({ + resumeId: "resume-1", + config: { model: "claude-fable-5", effort: "max", permission: "plan" }, + })); + const pinned = lastQueryOptions(); + expect(pinned.model).toBe("claude-fable-5"); + expect(pinned.effort).toBe("max"); + expect(pinned.permissionMode).toBe("plan"); + expect(pinned.resume).toBe("resume-1"); }); it("preserves Codex threadId omission before later request fields", () => { diff --git a/packages/box/actor/test/agent-config.test.ts b/packages/box/actor/test/agent-config.test.ts index 6f95beaa..b338d0c9 100644 --- a/packages/box/actor/test/agent-config.test.ts +++ b/packages/box/actor/test/agent-config.test.ts @@ -5,7 +5,7 @@ import { applyAgentConfig, defaultAgentConfig, } from "../src/agent-config.js"; -import { claudeEffort, claudeStreamChunk } from "../src/adapters/claude.js"; +import { claudeStreamChunk } from "../src/adapters/claude.js"; import { codexConfigArguments, codexThreadRequestParams } from "../src/adapters/codex.js"; describe("agent config selectors", () => { @@ -43,13 +43,9 @@ describe("agent config selectors", () => { .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); }); - it("maps claude effort choices to SDK levels and leaves default alone", () => { - expect(claudeEffort("default")).toBeUndefined(); - expect(claudeEffort("")).toBeUndefined(); - expect(claudeEffort("ludicrous")).toBeUndefined(); - expect(claudeEffort("low")).toBe("low"); - expect(claudeEffort("xhigh")).toBe("xhigh"); - expect(claudeEffort("max")).toBe("max"); + it("applies a claude effort choice through the catalog", () => { + // The adapter forwards catalog values verbatim; the "default" → omit rule + // is pinned at the runTurn boundary in adapter-object-contracts.test.ts. const chosen = applyAgentConfig("claude", defaultAgentConfig("claude"), "effort", "high"); expect(chosen.effort).toBe("high"); }); diff --git a/packages/box/actor/test/agent-rules-refresh.test.ts b/packages/box/actor/test/agent-rules-refresh.test.ts index 6e16dc2e..aa20d7e9 100644 --- a/packages/box/actor/test/agent-rules-refresh.test.ts +++ b/packages/box/actor/test/agent-rules-refresh.test.ts @@ -1,77 +1,65 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { spawn } from "node:child_process"; import { createRulesRefresher } from "../src/agent-rules-refresh.js"; -function fixedClock(start: number): { now: () => number; set(value: number): void } { - let current = start; - return { - now: () => current, - set: (value: number) => { - current = value; - }, - }; -} +// The refresher's whole job is the boundary call: a detached, unref'd +// `blitz-rules sync` at most once per TTL window. Stub the boundary itself +// (child_process) and drive the TTL with fake time. +vi.mock("node:child_process", () => ({ + spawn: vi.fn(() => ({ on: vi.fn(), unref: vi.fn() })), +})); + +const spawnMock = vi.mocked(spawn); +const TTL_MS = 5 * 60 * 1000; describe("createRulesRefresher", () => { - it("runs once and then suppresses repeats inside the TTL window", () => { - let runs = 0; - const clock = fixedClock(1_000); - const refresh = createRulesRefresher(() => { - runs += 1; - }, clock.now, 5 * 60 * 1000); + beforeEach(() => { + vi.useFakeTimers({ now: 1_000 }); + spawnMock.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("spawns one detached blitz-rules sync and suppresses repeats inside the TTL window", () => { + const refresh = createRulesRefresher(); refresh(); refresh(); - clock.set(1_000 + 4 * 60 * 1000); + vi.setSystemTime(1_000 + TTL_MS - 60_000); refresh(); - expect(runs).toBe(1); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledWith("blitz-rules", ["sync"], { + stdio: "ignore", + detached: true, + }); + // Detached AND unref'd, or the refresh would pin the actor's event loop. + const child = spawnMock.mock.results[0]?.value as { on: ReturnType; unref: ReturnType }; + expect(child.unref).toHaveBeenCalledTimes(1); + expect(child.on).toHaveBeenCalledWith("error", expect.any(Function)); }); it("runs again once the TTL has elapsed", () => { - let runs = 0; - const clock = fixedClock(0); - const refresh = createRulesRefresher(() => { - runs += 1; - }, clock.now, 5 * 60 * 1000); + const refresh = createRulesRefresher(); refresh(); - clock.set(5 * 60 * 1000); + vi.setSystemTime(1_000 + TTL_MS); refresh(); - clock.set(10 * 60 * 1000); + vi.setSystemTime(1_000 + 2 * TTL_MS); refresh(); - expect(runs).toBe(3); - }); - - it("swallows a synchronous spawn failure and stays gated", () => { - let attempts = 0; - const clock = fixedClock(0); - const refresh = createRulesRefresher(() => { - attempts += 1; - throw new Error("spawn failed"); - }, clock.now, 1000); - - expect(() => refresh()).not.toThrow(); - // The attempt still counts against the TTL, so a failing box does not spin. - refresh(); - expect(attempts).toBe(1); + expect(spawnMock).toHaveBeenCalledTimes(3); }); it("keeps each refresher's TTL to itself", () => { - let first = 0; - let second = 0; - const clock = fixedClock(0); - const refreshFirst = createRulesRefresher(() => { - first += 1; - }, clock.now, 1000); - const refreshSecond = createRulesRefresher(() => { - second += 1; - }, clock.now, 1000); + const refreshFirst = createRulesRefresher(); + const refreshSecond = createRulesRefresher(); refreshFirst(); refreshSecond(); - expect(first).toBe(1); - expect(second).toBe(1); + expect(spawnMock).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/box/gateway/main.go b/packages/box/gateway/main.go index 425da5b8..1b09d09b 100644 --- a/packages/box/gateway/main.go +++ b/packages/box/gateway/main.go @@ -3,7 +3,6 @@ package main import ( "bufio" "bytes" - "context" "crypto/hmac" "crypto/sha256" "crypto/subtle" @@ -109,7 +108,6 @@ type gateway struct { previewFocusPath string connectionsFocusPath string discover func() ([]portInfo, error) - transport http.RoundTripper authMu sync.Mutex authRequired bool lastWebAppToken string @@ -184,7 +182,6 @@ func main() { previewFocusPath: previewFocusPath, connectionsFocusPath: connectionsFocusPath, discover: func() ([]portInfo, error) { return discoverPorts("/proc", excludedPorts) }, - transport: http.DefaultTransport, } server := &http.Server{ Addr: listenAddress, @@ -266,7 +263,7 @@ func (g *gateway) ServeHTTP(response http.ResponseWriter, request *http.Request) } if request.URL.Path == "/terminal/ws" { removeWebAppTokenHeader(request.Header) - g.serveTerminal(response, request) + g.serveTerminal(response, request, identity) return } if request.URL.Path == "/acp" || strings.HasPrefix(request.URL.Path, "/acp/") { @@ -348,18 +345,14 @@ func (g *gateway) serveDrain(response http.ResponseWriter, request *http.Request response.WriteHeader(http.StatusNoContent) } -func (g *gateway) serveTerminal(response http.ResponseWriter, request *http.Request) { - if identity, ok := request.Context().Value(webAppIdentityContextKey{}).(webAppIdentity); ok && identity.Role == "viewer" { +func (g *gateway) serveTerminal(response http.ResponseWriter, request *http.Request, identity webAppIdentity) { + if identity.Role == "viewer" { if !forceReadOnlyTerminalArgs(request.URL) { http.Error(response, "terminal requires a session type and key", http.StatusBadRequest) return } } - target := g.terminal - if target == nil { - target = &url.URL{Scheme: "http", Host: terminalAddress} - } - proxy := g.reverseProxy(target, "/ws", "", request) + proxy := g.reverseProxy(g.terminal, "/ws", "", request) previousRewrite := proxy.Rewrite proxy.Rewrite = func(proxyRequest *httputil.ProxyRequest) { previousRewrite(proxyRequest) @@ -370,15 +363,11 @@ func (g *gateway) serveTerminal(response http.ResponseWriter, request *http.Requ } func (g *gateway) serveACP(response http.ResponseWriter, request *http.Request) { - target := g.actor - if target == nil { - target = &url.URL{Scheme: "http", Host: actorAddress} - } upstreamPath := strings.TrimPrefix(request.URL.Path, "/acp") if upstreamPath == "" { upstreamPath = "/" } - proxy := g.reverseProxy(target, upstreamPath, "/acp", request) + proxy := g.reverseProxy(g.actor, upstreamPath, "/acp", request) previousRewrite := proxy.Rewrite proxy.Rewrite = func(proxyRequest *httputil.ProxyRequest) { previousRewrite(proxyRequest) @@ -747,7 +736,6 @@ func (g *gateway) reverseProxy(target *url.URL, upstreamPath, prefix string, req originalHost := request.Host originalProto := request.Header.Get("X-Forwarded-Proto") return &httputil.ReverseProxy{ - Transport: g.transport, FlushInterval: -1, ErrorHandler: proxyError, Rewrite: func(proxyRequest *httputil.ProxyRequest) { @@ -822,8 +810,6 @@ func (g *gateway) currentWebAppAuth() (token string, workspaceID string, authReq return "", "", false, true } -type webAppIdentityContextKey struct{} - func webAppCredential(request *http.Request, secret, workspaceID string, now int64) (webAppIdentity, bool) { values := request.Header.Values(webAppTokenHeader) if len(values) != 1 { @@ -835,9 +821,7 @@ func webAppCredential(request *http.Request, secret, workspaceID string, now int if subtle.ConstantTimeCompare([]byte(credential), []byte(secret)) != 1 { return webAppIdentity{}, false } - identity := webAppIdentity{UserID: "legacy-owner", MembershipID: "legacy-owner", Role: "owner"} - *request = *request.WithContext(context.WithValue(request.Context(), webAppIdentityContextKey{}, identity)) - return identity, true + return webAppIdentity{UserID: "legacy-owner", MembershipID: "legacy-owner", Role: "owner"}, true } parts := strings.Split(credential, ".") if len(parts) != 3 || parts[0] != "v1" || workspaceID == "" { @@ -865,9 +849,7 @@ func webAppCredential(request *http.Request, secret, workspaceID string, now int if claims.UserID == "" || claims.MembershipID == "" || !validWebAppRole(claims.Role) { return webAppIdentity{}, false } - identity := webAppIdentity{UserID: claims.UserID, MembershipID: claims.MembershipID, Role: claims.Role} - *request = *request.WithContext(context.WithValue(request.Context(), webAppIdentityContextKey{}, identity)) - return identity, true + return webAppIdentity{UserID: claims.UserID, MembershipID: claims.MembershipID, Role: claims.Role}, true } func requireJSONEOF(decoder *json.Decoder) error { diff --git a/packages/box/gateway/main_test.go b/packages/box/gateway/main_test.go index 5f618e9a..8d18702d 100644 --- a/packages/box/gateway/main_test.go +++ b/packages/box/gateway/main_test.go @@ -66,7 +66,6 @@ func TestTicketVerificationAndViewerEnforcement(t *testing.T) { terminal: upstreamURL, webAppTokenPath: tokenPath, workspaceIDPath: workspacePath, - transport: http.DefaultTransport, } viewer := webAppTicketClaims{ WorkspaceID: workspaceID, UserID: "viewer-user", MembershipID: "viewer-member", @@ -266,7 +265,6 @@ func TestGatewayLegacyRoutesWhenBothTokensAbsent(t *testing.T) { webAppTokenPath: filepath.Join(authDir, "webapp-token"), tunnelTokenPath: filepath.Join(authDir, "tunnel-token"), discover: func() ([]portInfo, error) { return []portInfo{{Port: 3000, Process: "node"}}, nil }, - transport: http.DefaultTransport, } portsRequest := httptest.NewRequest(http.MethodGet, "http://box/ports", nil) @@ -315,7 +313,6 @@ func TestGatewayWebAppTokenAuthentication(t *testing.T) { dufs: httputil.NewSingleHostReverseProxy(dufsURL), webAppTokenPath: tokenPath, tunnelTokenPath: filepath.Join(filepath.Dir(tokenPath), "tunnel-token"), - transport: http.DefaultTransport, } request := func(token *string, webSocket bool) *httptest.ResponseRecorder { t.Helper() @@ -442,7 +439,6 @@ func TestGatewayEmptyWebAppTokenFailsClosedEveryRoute(t *testing.T) { discoverCalls++ return nil, nil }, - transport: http.DefaultTransport, } tests := []struct { @@ -580,7 +576,6 @@ func TestGatewayStripsWebAppTokenFromAllUpstreams(t *testing.T) { actor: upstreamURL, webAppTokenPath: webAppPath, tunnelTokenPath: filepath.Join(authDir, "tunnel-token"), - transport: http.DefaultTransport, } tests := []struct { name string @@ -641,7 +636,6 @@ func TestCORSPreflight(t *testing.T) { discoverCalls++ return nil, nil }, - transport: http.DefaultTransport, } t.Run("control plane PROPFIND", func(t *testing.T) { @@ -828,7 +822,6 @@ func TestCORSActualResponses(t *testing.T) { dufs: httputil.NewSingleHostReverseProxy(dufsURL), controlPlaneOriginPath: writeOriginFile(t, controlPlaneOrigin), discover: func() ([]portInfo, error) { return []portInfo{}, nil }, - transport: http.DefaultTransport, } t.Run("PROPFIND dufs passthrough exposes ETag", func(t *testing.T) { @@ -918,7 +911,6 @@ func TestWebSocketOriginPolicy(t *testing.T) { terminal: upstreamURL, controlPlaneOriginPath: writeOriginFile(t, "https://blitz-control-plane.example"), discover: func() ([]portInfo, error) { return nil, nil }, - transport: http.DefaultTransport, } routes := []string{ "/terminal/ws?arg=terminal&arg=origin-matrix", @@ -992,7 +984,6 @@ func TestTerminalProxyHandshakeContract(t *testing.T) { handler := &gateway{ terminal: upstreamURL, controlPlaneOriginPath: writeOriginFile(t, "https://blitz-control-plane.example"), - transport: http.DefaultTransport, } request := httptest.NewRequest( http.MethodGet, @@ -1057,7 +1048,6 @@ func TestACPProxyHTTPAndWebSocketContract(t *testing.T) { handler := &gateway{ actor: actorURL, controlPlaneOriginPath: writeOriginFile(t, controlPlaneOrigin), - transport: http.DefaultTransport, } httpRequest := httptest.NewRequest(http.MethodPost, "http://box/acp/v1/sessions?resume=true", strings.NewReader("prompt")) @@ -1127,9 +1117,8 @@ func TestACPExactPathAndQueryRouteToActor(t *testing.T) { } handler := &gateway{ - dufs: httputil.NewSingleHostReverseProxy(dufsURL), - actor: actorURL, - transport: http.DefaultTransport, + dufs: httputil.NewSingleHostReverseProxy(dufsURL), + actor: actorURL, } for _, test := range []struct { path string @@ -1189,7 +1178,6 @@ func TestWebSocketControlPlaneOriginAppearsAfterGatewayStart(t *testing.T) { handler := &gateway{ terminal: upstreamURL, controlPlaneOriginPath: originPath, - transport: http.DefaultTransport, } server := httptest.NewServer(handler) defer server.Close() diff --git a/packages/box/rootfs/usr/local/libexec/blitz-init-state b/packages/box/rootfs/usr/local/libexec/blitz-init-state index 10cf899b..045a908e 100644 --- a/packages/box/rootfs/usr/local/libexec/blitz-init-state +++ b/packages/box/rootfs/usr/local/libexec/blitz-init-state @@ -52,11 +52,11 @@ chown "$uid:$gid" /workspace # HOME (=$state_dir/home): Claude reads ~/.claude/CLAUDE.md, Codex reads # ~/.codex/AGENTS.md. Overwrite every boot so the platform rules always win; # project rules belong in /workspace/CLAUDE.md. `install -D` creates the parent -# directories. Skip silently if the skel doc is absent. -if [ -f /opt/blitz/skel/agent-rules.md ]; then - install -D -m 0644 -o "$uid" -g "$gid" /opt/blitz/skel/agent-rules.md "$state_dir/home/.claude/CLAUDE.md" - install -D -m 0644 -o "$uid" -g "$gid" /opt/blitz/skel/agent-rules.md "$state_dir/home/.codex/AGENTS.md" -fi +# directories. The skel doc is baked into every image (Dockerfile `COPY +# packages/box/rootfs/ /`), so its absence is a broken image and fails the +# boot loudly here (set -e), never a silent skip. +install -D -m 0644 -o "$uid" -g "$gid" /opt/blitz/skel/agent-rules.md "$state_dir/home/.claude/CLAUDE.md" +install -D -m 0644 -o "$uid" -g "$gid" /opt/blitz/skel/agent-rules.md "$state_dir/home/.codex/AGENTS.md" host_key="$state_dir/ssh/ssh_host_ed25519_key" if [ ! -f "$host_key" ]; then diff --git a/packages/box/rootfs/usr/local/libexec/blitz-rules-boot b/packages/box/rootfs/usr/local/libexec/blitz-rules-boot index 42c51dca..b0263f70 100644 --- a/packages/box/rootfs/usr/local/libexec/blitz-rules-boot +++ b/packages/box/rootfs/usr/local/libexec/blitz-rules-boot @@ -3,19 +3,11 @@ set -euo pipefail state_dir=$BLITZ_STATE_DIR -# Needs both the control-plane origin and the box credential that enroll/register -# wrote. Without them there is nothing to fetch with; the baked rules that -# blitz-init-state installed stay in place. -if [ ! -s "$state_dir/origin" ] || [ ! -s "$state_dir/box-credential.json" ]; then - echo "rules: skipped (no origin or box credential)" - exit 0 -fi - -# Runs after register, so the box access token is freshly issued. blitz-rules -# keeps the baked fallback and exits 0 on any failure, but guard here as well so -# a rules refresh can never fail the boot transition. -/command/s6-setuidgid blitz /usr/bin/env \ +# Runs after register, so the box access token is freshly issued. The +# prerequisites and every failure path are owned by blitz-rules itself: it +# reads $BLITZ_STATE_DIR/origin and box-credential.json, logs why a sync was +# skipped, keeps the baked rules blitz-init-state installed, and exits 0 — +# so this oneshot cannot fail the boot transition. +exec /command/s6-setuidgid blitz /usr/bin/env \ HOME="$state_dir/home" BLITZ_STATE_DIR="$state_dir" \ - /usr/local/bin/blitz-rules sync \ - || echo "rules: sync did not complete, keeping baked rules" -exit 0 + /usr/local/bin/blitz-rules sync diff --git a/packages/box/rootfs/usr/local/libexec/blitz-term b/packages/box/rootfs/usr/local/libexec/blitz-term index e28e0c79..5cce6a26 100644 --- a/packages/box/rootfs/usr/local/libexec/blitz-term +++ b/packages/box/rootfs/usr/local/libexec/blitz-term @@ -63,10 +63,6 @@ fi session="$session_prefix-$session_key" -new_session() { - tmux -u new-session -d -s "$session" -c "$workspace" "${session_cmd[@]}" -} - if [ "$mode" = ro ]; then # An observer attaches to what is already running and never starts # anything: creating on demand here would let a read-only visitor spawn diff --git a/packages/broker/cmd/blitz-broker/main.go b/packages/broker/cmd/blitz-broker/main.go index 45b1bc56..ea1dae8a 100644 --- a/packages/broker/cmd/blitz-broker/main.go +++ b/packages/broker/cmd/blitz-broker/main.go @@ -58,7 +58,7 @@ func run(args []string, output io.Writer, input io.Reader) error { if len(args) != 1 { return errors.New("sync takes no arguments") } - return broker.Sync(context.Background(), stateDir, nil) + return broker.Sync(context.Background(), stateDir) case "mint": return runMint(args[1:], output) case "deposit": @@ -84,7 +84,7 @@ func runEnroll(args []string, stateDir string, output io.Writer) error { if *host == "" || strings.ContainsAny(*host, " \t\r\n") || *port < 1 || *port > 65535 { return errors.New("invalid advertised broker address") } - if _, err := enroll.Run(context.Background(), stateDir, *origin, "blitz-broker", output, nil); err != nil { + if _, err := enroll.Run(context.Background(), stateDir, *origin, "blitz-broker", output); err != nil { return err } hostKeyData, err := os.ReadFile(filepath.Join(stateDir, "ssh", "ssh_host_ed25519_key.pub")) @@ -99,7 +99,7 @@ func runEnroll(args []string, stateDir string, output io.Writer) error { if err != nil { return err } - client, err := controlplane.New(storedOrigin, stateDir, nil) + client, err := controlplane.New(storedOrigin, stateDir) if err != nil { return err } @@ -107,13 +107,13 @@ func runEnroll(args []string, stateDir string, output io.Writer) error { } func runMint(args []string, output io.Writer) error { - _, allowed, definition, home, err := forcedCommand(args) + definition, home, err := forcedCommand(args) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) defer cancel() - token, err := broker.Mint(ctx, home, allowed, definition.Name, definition, nil) + token, err := broker.Mint(ctx, home, definition) if err != nil { return err } @@ -128,45 +128,49 @@ func runMint(args []string, output io.Writer) error { } func runDeposit(args []string, output io.Writer, input io.Reader) error { - _, _, definition, home, err := forcedCommand(args) + definition, home, err := forcedCommand(args) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) defer cancel() - if err := broker.Deposit(ctx, home, definition, input, nil); err != nil { + if err := broker.Deposit(ctx, home, definition, input); err != nil { return err } _, err = io.WriteString(output, "ok\n") return err } -func forcedCommand(args []string) (string, []string, vendor.Definition, string, error) { +// forcedCommand is the single gate on which harness a member may touch: the +// member name and allowlist come off the authorized_keys forced-command line +// this binary rendered, the requested harness off SSH_ORIGINAL_COMMAND, and +// nothing below this function checks them again. +func forcedCommand(args []string) (vendor.Definition, string, error) { if len(args) != 2 || os.Getenv("SSH_CONNECTION") == "" { - return "", nil, vendor.Definition{}, "", errors.New("command is restricted to forced-command SSH") + return vendor.Definition{}, "", errors.New("command is restricted to forced-command SSH") } member := args[0] if !feed.ValidUnixName(member) { - return "", nil, vendor.Definition{}, "", errors.New("invalid forced-command member") + return vendor.Definition{}, "", errors.New("invalid forced-command member") } allowed, err := parseAllowlist(args[1]) if err != nil { - return "", nil, vendor.Definition{}, "", err + return vendor.Definition{}, "", err } requested := os.Getenv("SSH_ORIGINAL_COMMAND") definition, err := vendor.Lookup(requested) if err != nil || !contains(allowed, requested) { - return "", nil, vendor.Definition{}, "", errors.New("requested harness is not allowed") + return vendor.Definition{}, "", errors.New("requested harness is not allowed") } current, err := user.Current() if err != nil || current.Username != member { - return "", nil, vendor.Definition{}, "", errors.New("forced-command Unix user mismatch") + return vendor.Definition{}, "", errors.New("forced-command Unix user mismatch") } account, err := user.Lookup(member) if err != nil { - return "", nil, vendor.Definition{}, "", errors.New("forced-command member does not exist") + return vendor.Definition{}, "", errors.New("forced-command member does not exist") } - return member, allowed, definition, account.HomeDir, nil + return definition, account.HomeDir, nil } func parseAllowlist(raw string) ([]string, error) { diff --git a/packages/broker/cmd/blitz-broker/main_test.go b/packages/broker/cmd/blitz-broker/main_test.go new file mode 100644 index 00000000..6118a85e --- /dev/null +++ b/packages/broker/cmd/blitz-broker/main_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "strings" + "testing" +) + +// TestForcedCommandRefusesHarnessOutsideAllowlist pins the ONE gate on which +// harness a member may mint or deposit. The allowlist arrives on the +// authorized_keys forced-command line and the request in SSH_ORIGINAL_COMMAND; +// broker.Mint and broker.Deposit trust this gate, so a harness outside the +// list must be refused here, before any account lookup or vendor CLI run. +func TestForcedCommandRefusesHarnessOutsideAllowlist(t *testing.T) { + t.Setenv("SSH_CONNECTION", "203.0.113.7 50000 203.0.113.1 22") + t.Setenv("SSH_ORIGINAL_COMMAND", "codex") + + if _, _, err := forcedCommand([]string{"m-0123456789ab", "claude"}); err == nil || !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("harness outside the allowlist = %v, want a refusal", err) + } + // An empty allowlist ("-") admits nothing. + if _, _, err := forcedCommand([]string{"m-0123456789ab", "-"}); err == nil || !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("harness against the empty allowlist = %v, want a refusal", err) + } + // An allowed harness passes the gate and fails LATER, on the Unix user + // identity — proof the allowlist is checked before any account state. + t.Setenv("SSH_ORIGINAL_COMMAND", "claude") + if _, _, err := forcedCommand([]string{"m-0123456789ab", "claude"}); err == nil || !strings.Contains(err.Error(), "user mismatch") { + t.Fatalf("allowed harness = %v, want to reach the user identity check", err) + } +} + +func TestForcedCommandRequiresSSH(t *testing.T) { + t.Setenv("SSH_CONNECTION", "") + if _, _, err := forcedCommand([]string{"m-0123456789ab", "claude"}); err == nil { + t.Fatal("forcedCommand ran outside a forced-command SSH session") + } +} + +func TestParseAllowlist(t *testing.T) { + if allowed, err := parseAllowlist("-"); err != nil || len(allowed) != 0 { + t.Fatalf(`parseAllowlist("-") = %v, %v`, allowed, err) + } + if allowed, err := parseAllowlist("claude,codex"); err != nil || len(allowed) != 2 { + t.Fatalf(`parseAllowlist("claude,codex") = %v, %v`, allowed, err) + } + for _, invalid := range []string{"", "claude,claude", "bash", "claude,"} { + if _, err := parseAllowlist(invalid); err == nil { + t.Errorf("parseAllowlist(%q) accepted an invalid allowlist", invalid) + } + } +} diff --git a/packages/broker/cmd/blitz-cred/main.go b/packages/broker/cmd/blitz-cred/main.go index 1cb8151b..5690445e 100644 --- a/packages/broker/cmd/blitz-cred/main.go +++ b/packages/broker/cmd/blitz-cred/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/blitzdotdev/blitz-core/broker/internal/enroll" + "github.com/blitzdotdev/blitz-core/broker/internal/vendor" "github.com/blitzdotdev/blitz-core/broker/internal/workspace" ) @@ -44,7 +45,7 @@ func runWithInput(args []string, input io.Reader, output io.Writer) error { if err := flags.Parse(args[1:]); err != nil || flags.NArg() != 0 || *origin == "" { return errors.New("usage: blitz-cred enroll --origin URL") } - _, err := enroll.Run(context.Background(), stateDir, *origin, "blitz-cred", output, nil) + _, err := enroll.Run(context.Background(), stateDir, *origin, "blitz-cred", output) return err case "register": if len(args) != 1 { @@ -57,12 +58,12 @@ func runWithInput(args []string, input io.Reader, output io.Writer) error { // out rather than one that never boots. ctx, cancel := context.WithTimeout(context.Background(), registerTimeout) defer cancel() - return workspace.Register(ctx, stateDir, nil) + return workspace.Register(ctx, stateDir) case "token": if len(args) != 2 || args[1] == "" { return errors.New("usage: blitz-cred token INTEGRATION") } - if args[1] == "claude" || args[1] == "codex" { + if _, err := vendor.Lookup(args[1]); err == nil { token, err := workspace.Token(context.Background(), stateDir, args[1]) if err != nil { return err @@ -70,7 +71,7 @@ func runWithInput(args []string, input io.Reader, output io.Writer) error { _, err = output.Write(token) return err } - result, err := workspace.MintIntegration(context.Background(), stateDir, args[1], nil) + result, err := workspace.MintIntegration(context.Background(), stateDir, args[1]) if requestID := workspace.AccessRequestID(err); requestID != "" { if errors.Is(err, workspace.ErrCredentialDenied) { return fmt.Errorf("blitz: access to %s requested (%s), awaiting approval", args[1], requestID) @@ -94,12 +95,12 @@ func runWithInput(args []string, input io.Reader, output io.Writer) error { if len(args) != 1 { return errors.New("sync takes no arguments") } - return workspace.Sync(context.Background(), stateDir, nil) + return workspace.Sync(context.Background(), stateDir) case "git-helper": if len(args) != 2 { return errors.New("usage: blitz-cred git-helper get|store|erase") } - err := workspace.GitHelper(context.Background(), stateDir, args[1], input, output, nil) + err := workspace.GitHelper(context.Background(), stateDir, args[1], input, output) if requestID := workspace.AccessRequestID(err); requestID != "" { if errors.Is(err, workspace.ErrCredentialDenied) { return fmt.Errorf("blitz: access to github requested (%s), awaiting approval", requestID) diff --git a/packages/broker/internal/broker/deposit.go b/packages/broker/internal/broker/deposit.go index 26b8b29d..c5151089 100644 --- a/packages/broker/internal/broker/deposit.go +++ b/packages/broker/internal/broker/deposit.go @@ -28,7 +28,7 @@ import ( // for: a full disk or a bad mode turned a deposit that had already succeeded // into a failure, the watcher kept its copy, and it re-deposited every second // — a real vendor round trip per tick, forever. -func Deposit(ctx context.Context, home string, definition vendor.Definition, input io.Reader, runner vendor.Runner) error { +func Deposit(ctx context.Context, home string, definition vendor.Definition, input io.Reader) error { blob, err := io.ReadAll(io.LimitReader(input, FeedMaxBytes+1)) if err != nil { return errors.New("could not read deposited credential") @@ -36,9 +36,6 @@ func Deposit(ctx context.Context, home string, definition vendor.Definition, inp if len(blob) > FeedMaxBytes { return errors.New("deposited credential exceeds 1 MiB") } - if runner == nil { - runner = vendor.Run - } return withMemberLock(ctx, home, func() error { stage, err := os.MkdirTemp(home, ".deposit-*") if err != nil { @@ -72,7 +69,7 @@ func Deposit(ctx context.Context, home string, definition vendor.Definition, inp // HOME points at the staging tree, so the vendor CLI rotates the // STAGED copy and never reaches the stored one. A failure here returns // before any write to the stored path. - if err := runner(ctx, definition.Command, definition.VerifyArgs, stage); err != nil { + if err := vendor.Run(ctx, definition.Command, definition.VerifyArgs, stage); err != nil { return fmt.Errorf("verification failed; stored credential unchanged: %w", err) } verified, err := readCredential(stagedPath) diff --git a/packages/broker/internal/broker/mint.go b/packages/broker/internal/broker/mint.go index 73ea0690..7dece82f 100644 --- a/packages/broker/internal/broker/mint.go +++ b/packages/broker/internal/broker/mint.go @@ -12,13 +12,12 @@ import ( const refreshWindow = 5 * time.Minute -func Mint(ctx context.Context, home string, allowed []string, requested string, definition vendor.Definition, runner vendor.Runner) (string, error) { - if !contains(allowed, requested) || requested != definition.Name { - return "", errors.New("requested harness is not allowed") - } - if runner == nil { - runner = vendor.Run - } +// Mint serves the member's access token for one harness, refreshing it through +// the vendor CLI when it is near expiry. Which harnesses the member may mint +// is decided by the forced command that calls this (cmd/blitz-broker), the +// only production entry point: it resolves the definition from the allowlist +// baked into authorized_keys before any credential is touched. +func Mint(ctx context.Context, home string, definition vendor.Definition) (string, error) { var token string err := withMemberLock(ctx, home, func() error { path := filepath.Join(home, filepath.FromSlash(definition.CredentialPath)) @@ -31,7 +30,7 @@ func Mint(ctx context.Context, home string, allowed []string, requested string, return err } if !expiry.After(time.Now().Add(refreshWindow)) { - if err := runner(ctx, definition.Command, definition.RefreshArgs, home); err != nil { + if err := vendor.Run(ctx, definition.Command, definition.RefreshArgs, home); err != nil { return err } data, err = readCredential(path) @@ -62,12 +61,3 @@ func Mint(ctx context.Context, home string, allowed []string, requested string, }) return token, err } - -func contains(values []string, value string) bool { - for _, candidate := range values { - if candidate == value { - return true - } - } - return false -} diff --git a/packages/broker/internal/broker/roaming_test.go b/packages/broker/internal/broker/roaming_test.go index 789f6966..9dc1266b 100644 --- a/packages/broker/internal/broker/roaming_test.go +++ b/packages/broker/internal/broker/roaming_test.go @@ -86,20 +86,20 @@ func TestMintKilledMidRefreshLeavesTheStoredCredentialIntact(t *testing.T) { if err := os.WriteFile(path, expired, 0o600); err != nil { t.Fatal(err) } + started := filepath.Join(t.TempDir(), "started") + // `exec` so the sleeping process IS the child exec kills, and detached + // stdio so nothing outlives the kill holding the exec pipes open. + fakeVendorCLI(t, "claude", ": > "+strconv.Quote(started)+"\nexec sleep 30 /dev/null 2>&1\n") - ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - slept := make(chan struct{}) - _, err := Mint(ctx, home, []string{"claude"}, "claude", vendor.Claude, - func(runContext context.Context, _ string, _ []string, _ string) error { - close(slept) - <-runContext.Done() - return runContext.Err() - }) - <-slept + _, err := Mint(ctx, home, vendor.Claude) if err == nil { t.Fatal("Mint returned a token after its vendor run was killed") } + if _, statErr := os.Stat(started); statErr != nil { + t.Fatalf("the vendor CLI never ran: %v", statErr) + } got, readErr := os.ReadFile(path) if readErr != nil { @@ -130,18 +130,19 @@ func TestMintRefusesAnAccessTokenCarryingWhitespace(t *testing.T) { if err := os.WriteFile(path, []byte(dirty), 0o600); err != nil { t.Fatal(err) } + ran := filepath.Join(t.TempDir(), "ran") + fakeVendorCLI(t, "claude", ": > "+strconv.Quote(ran)+"\n") - token, err := Mint(context.Background(), home, []string{"claude"}, "claude", vendor.Claude, - func(context.Context, string, []string, string) error { - t.Error("a live credential was sent to the vendor CLI for a refresh") - return nil - }) + token, err := Mint(context.Background(), home, vendor.Claude) if err == nil { t.Fatalf("Mint returned a token carrying whitespace: %q", token) } if token != "" { t.Errorf("Mint refused the token and returned it anyway: %q", token) } + if _, statErr := os.Stat(ran); statErr == nil { + t.Error("a live credential was sent to the vendor CLI for a refresh") + } } // TestFeedHeartbeatRestatesOnACadence pins the decision the provisioning gate @@ -185,16 +186,13 @@ func TestDepositRefusesACredentialWhoseRefreshTokenIsAlreadyDead(t *testing.T) { } dead := `{"claudeAiOauth":{"accessToken":"new","refreshToken":"spent","expiresAt":4102444800000,"refreshTokenExpiresAt":1}}` - ran := false - err := Deposit(context.Background(), home, vendor.Claude, strings.NewReader(dead), - func(context.Context, string, []string, string) error { - ran = true - return nil - }) + ran := filepath.Join(t.TempDir(), "ran") + fakeVendorCLI(t, "claude", ": > "+strconv.Quote(ran)+"\n") + err := Deposit(context.Background(), home, vendor.Claude, strings.NewReader(dead)) if err == nil { t.Fatal("Deposit stored a credential whose refresh token had already expired") } - if ran { + if _, statErr := os.Stat(ran); statErr == nil { t.Error("the vendor CLI was run for a credential that was refused on its face") } got, readErr := os.ReadFile(path) @@ -226,8 +224,8 @@ func TestDepositReplacesAcrossAccountsAndWritesNothingElse(t *testing.T) { } replacement := `{"claudeAiOauth":{"accessToken":"other-account","refreshToken":"other","expiresAt":4102444800000}}` - if err := Deposit(context.Background(), home, vendor.Claude, strings.NewReader(replacement), - func(context.Context, string, []string, string) error { return nil }); err != nil { + fakeVendorCLI(t, "claude", "exit 0\n") + if err := Deposit(context.Background(), home, vendor.Claude, strings.NewReader(replacement)); err != nil { t.Fatal(err) } @@ -352,7 +350,7 @@ func TestVendorRunsWithTheAutoUpdaterDisabled(t *testing.T) { t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("DISABLE_AUTOUPDATER", "0") - if _, err := Mint(context.Background(), home, []string{"claude"}, "claude", vendor.Claude, nil); err != nil { + if _, err := Mint(context.Background(), home, vendor.Claude); err != nil { t.Fatal(err) } got, err := os.ReadFile(seen) diff --git a/packages/broker/internal/broker/security_test.go b/packages/broker/internal/broker/security_test.go index 85df614b..da448071 100644 --- a/packages/broker/internal/broker/security_test.go +++ b/packages/broker/internal/broker/security_test.go @@ -14,6 +14,18 @@ import ( "github.com/blitzdotdev/blitz-core/broker/internal/vendor" ) +// fakeVendorCLI installs a script under the vendor CLI's name first on PATH. +// vendor.Run resolves the command by name, so this is the real seam: the test +// drives the exact exec path production takes, fake binary included. +func fakeVendorCLI(t *testing.T, name, body string) { + t.Helper() + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nset -eu\n"+body), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) +} + func TestDepositVerifyFailureLeavesCredentialUntouched(t *testing.T) { home := t.TempDir() definition := vendor.Claude @@ -25,10 +37,9 @@ func TestDepositVerifyFailureLeavesCredentialUntouched(t *testing.T) { if err := os.WriteFile(path, old, 0o600); err != nil { t.Fatal(err) } + fakeVendorCLI(t, "claude", "exit 1\n") - err := Deposit(context.Background(), home, definition, strings.NewReader(`{"claudeAiOauth":{"accessToken":"new"}}`), func(context.Context, string, []string, string) error { - return errors.New("verification failed") - }) + err := Deposit(context.Background(), home, definition, strings.NewReader(`{"claudeAiOauth":{"accessToken":"new"}}`)) if err == nil { t.Fatal("Deposit succeeded after vendor verification failed") } @@ -72,20 +83,6 @@ func TestDecodeFeedRequiresVersion(t *testing.T) { } } -func TestMintRefusesHarnessOutsideMemberList(t *testing.T) { - called := false - _, err := Mint(context.Background(), t.TempDir(), []string{"claude"}, "codex", vendor.Codex, func(context.Context, string, []string, string) error { - called = true - return nil - }) - if err == nil { - t.Fatal("Mint accepted a harness outside the member allowlist") - } - if called { - t.Fatal("vendor CLI ran before the harness gate") - } -} - func TestAuthorizedKeysUsesRootOwnedPathAndModes(t *testing.T) { if AuthorizedKeysDir != "/etc/blitz-broker/authorized_keys" { t.Fatalf("AuthorizedKeysDir = %q", AuthorizedKeysDir) @@ -129,10 +126,11 @@ func TestAuthorizedKeysUsesRootOwnedPathAndModes(t *testing.T) { } func TestDepositCapsInput(t *testing.T) { + // The cap must refuse the blob before anything else runs; the fake CLI is + // here only so a regression cannot fall through to a real vendor binary. + fakeVendorCLI(t, "claude", "exit 1\n") blob := io.LimitReader(strings.NewReader(strings.Repeat("x", FeedMaxBytes+1)), FeedMaxBytes+1) - err := Deposit(context.Background(), t.TempDir(), vendor.Claude, blob, func(context.Context, string, []string, string) error { - return nil - }) + err := Deposit(context.Background(), t.TempDir(), vendor.Claude, blob) if err == nil { t.Fatal("Deposit accepted a blob over 1 MiB") } @@ -169,7 +167,7 @@ func TestConcurrentMintsRunOneVendorRefresh(t *testing.T) { wait.Add(1) go func() { defer wait.Done() - token, err := Mint(context.Background(), home, []string{"claude"}, "claude", vendor.Claude, nil) + token, err := Mint(context.Background(), home, vendor.Claude) if err == nil && token != "fresh" { err = errors.New("mint returned the wrong token") } diff --git a/packages/broker/internal/broker/sync.go b/packages/broker/internal/broker/sync.go index dd83d222..a417bbe1 100644 --- a/packages/broker/internal/broker/sync.go +++ b/packages/broker/internal/broker/sync.go @@ -5,7 +5,6 @@ import ( "context" "errors" "log" - "net/http" "os" "time" @@ -41,7 +40,7 @@ func feedHeartbeatDue(lastStated, now time.Time) bool { return lastStated.IsZero() || !now.Before(lastStated.Add(feedHeartbeatInterval)) } -func Sync(ctx context.Context, stateDir string, httpClient *http.Client) error { +func Sync(ctx context.Context, stateDir string) error { if os.Geteuid() != 0 { return errors.New("sync must run as root") } @@ -58,7 +57,7 @@ func Sync(ctx context.Context, stateDir string, httpClient *http.Client) error { origin, originErr := store.LoadOrigin(stateDir) _, credentialErr := store.LoadCredential(stateDir) if originErr == nil && credentialErr == nil { - client, err := controlplane.New(origin, stateDir, httpClient) + client, err := controlplane.New(origin, stateDir) if err == nil { body, nextETag, unchanged, fetchErr := client.FetchFeed(ctx, etag) switch { diff --git a/packages/broker/internal/controlplane/controlplane.go b/packages/broker/internal/controlplane/controlplane.go index 727340fc..48fb7b00 100644 --- a/packages/broker/internal/controlplane/controlplane.go +++ b/packages/broker/internal/controlplane/controlplane.go @@ -53,18 +53,16 @@ func ValidateOrigin(raw string) (string, error) { return strings.TrimSuffix(parsed.String(), "/"), nil } -func New(origin, stateDir string, httpClient *http.Client) (*Client, error) { +func New(origin, stateDir string) (*Client, error) { validated, err := ValidateOrigin(origin) if err != nil { return nil, err } - if httpClient == nil { - httpClient = &http.Client{ - Timeout: 30 * time.Second, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } + httpClient := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, } return &Client{origin: validated, stateDir: stateDir, http: httpClient}, nil } diff --git a/packages/broker/internal/controlplane/controlplane_test.go b/packages/broker/internal/controlplane/controlplane_test.go index c73e5856..45fdc8f4 100644 --- a/packages/broker/internal/controlplane/controlplane_test.go +++ b/packages/broker/internal/controlplane/controlplane_test.go @@ -45,8 +45,14 @@ func TestDeviceFlowPendingThenSuccess(t *testing.T) { })) defer server.Close() + // The production caller (internal/enroll.Run) hands Enroll a validated + // origin; go through the same gate here. + origin, err := ValidateOrigin(server.URL) + if err != nil { + t.Fatal(err) + } var output strings.Builder - credential, err := (DeviceFlow{HTTP: server.Client(), Sleep: func(context.Context, time.Duration) error { return nil }}).Enroll(context.Background(), server.URL, "blitz-cred", &output) + credential, err := (DeviceFlow{HTTP: server.Client(), Sleep: func(context.Context, time.Duration) error { return nil }}).Enroll(context.Background(), origin, "blitz-cred", &output) if err != nil { t.Fatal(err) } @@ -94,7 +100,7 @@ func TestFeedETagAnd401RefreshRetry(t *testing.T) { } })) defer server.Close() - client, err := New(server.URL, stateDir, server.Client()) + client, err := New(server.URL, stateDir) if err != nil { t.Fatal(err) } @@ -134,7 +140,7 @@ func TestOversizedFeedIsRejected(t *testing.T) { io.WriteString(writer, strings.Repeat("x", feed.MaxBytes+1)) })) defer server.Close() - client, err := New(server.URL, stateDir, server.Client()) + client, err := New(server.URL, stateDir) if err != nil { t.Fatal(err) } @@ -154,7 +160,7 @@ func TestHTTPErrorDoesNotEchoResponseBody(t *testing.T) { io.WriteString(writer, `{"access_token":"body-secret"}`) })) defer server.Close() - client, err := New(server.URL, stateDir, server.Client()) + client, err := New(server.URL, stateDir) if err != nil { t.Fatal(err) } diff --git a/packages/broker/internal/controlplane/device.go b/packages/broker/internal/controlplane/device.go index f36b2261..d655143e 100644 --- a/packages/broker/internal/controlplane/device.go +++ b/packages/broker/internal/controlplane/device.go @@ -29,11 +29,11 @@ type deviceAuthorization struct { Interval int `json:"interval"` } -func (flow DeviceFlow) Enroll(ctx context.Context, rawOrigin, clientID string, output io.Writer) (store.Credential, error) { - origin, err := ValidateOrigin(rawOrigin) - if err != nil { - return store.Credential{}, err - } +// Enroll runs the device-authorization flow against origin, which the caller +// must have passed through ValidateOrigin already — the one production caller +// (internal/enroll.Run) validates before it stores the origin, and hands the +// validated form here. +func (flow DeviceFlow) Enroll(ctx context.Context, origin, clientID string, output io.Writer) (store.Credential, error) { if clientID == "" { return store.Credential{}, errors.New("client_id is required") } diff --git a/packages/broker/internal/enroll/enroll.go b/packages/broker/internal/enroll/enroll.go index 988b41ee..863d2a96 100644 --- a/packages/broker/internal/enroll/enroll.go +++ b/packages/broker/internal/enroll/enroll.go @@ -4,14 +4,13 @@ import ( "context" "errors" "io" - "net/http" "os" "github.com/blitzdotdev/blitz-core/broker/internal/controlplane" "github.com/blitzdotdev/blitz-core/broker/internal/store" ) -func Run(ctx context.Context, stateDir, origin, clientID string, output io.Writer, httpClient *http.Client) (store.Credential, error) { +func Run(ctx context.Context, stateDir, origin, clientID string, output io.Writer) (store.Credential, error) { validated, err := controlplane.ValidateOrigin(origin) if err != nil { return store.Credential{}, err @@ -26,7 +25,7 @@ func Run(ctx context.Context, stateDir, origin, clientID string, output io.Write if !errors.Is(err, os.ErrNotExist) { return store.Credential{}, err } - credential, err = (controlplane.DeviceFlow{HTTP: httpClient}).Enroll(ctx, validated, clientID, output) + credential, err = (controlplane.DeviceFlow{}).Enroll(ctx, validated, clientID, output) if err != nil { return store.Credential{}, err } diff --git a/packages/broker/internal/feed/feed.go b/packages/broker/internal/feed/feed.go index 47d22a20..8f6f709b 100644 --- a/packages/broker/internal/feed/feed.go +++ b/packages/broker/internal/feed/feed.go @@ -9,6 +9,8 @@ import ( "io" "regexp" "strings" + + "github.com/blitzdotdev/blitz-core/broker/internal/vendor" ) const MaxBytes = 1_048_576 @@ -108,7 +110,12 @@ func ValidUnixName(name string) bool { } func ValidHarness(name string) bool { - return name == "claude" || name == "codex" + for _, definition := range vendor.Definitions { + if name == definition.Name { + return true + } + } + return false } func validHarnesses(harnesses []string) bool { diff --git a/packages/broker/internal/vendor/vendor.go b/packages/broker/internal/vendor/vendor.go index 09035813..705a81a1 100644 --- a/packages/broker/internal/vendor/vendor.go +++ b/packages/broker/internal/vendor/vendor.go @@ -26,8 +26,6 @@ import ( // accidentally hand the vendor a shorter deadline. const TriggerTimeout = 60 * time.Second -type Runner func(context.Context, string, []string, string) error - type Definition struct { Name string Command string @@ -49,15 +47,19 @@ type Definition struct { ReadRefreshExpiry func([]byte) (time.Time, error) } +// Definitions is the ONE list of supported harnesses. Everything that needs +// the set — Lookup, the feed's member-harness gate, blitz-cred's token +// dispatch, the workspace credential watcher — derives from it, so a new +// harness lands everywhere by being added here and nowhere else. +var Definitions = []Definition{Claude, Codex} + func Lookup(name string) (Definition, error) { - switch name { - case Claude.Name: - return Claude, nil - case Codex.Name: - return Codex, nil - default: - return Definition{}, fmt.Errorf("unsupported harness %q", name) + for _, definition := range Definitions { + if definition.Name == name { + return definition, nil + } } + return Definition{}, fmt.Errorf("unsupported harness %q", name) } // Run executes one vendor CLI invocation. Notes on what is deliberate: diff --git a/packages/broker/internal/workspace/cp.go b/packages/broker/internal/workspace/cp.go index c77b6c22..3814b7d3 100644 --- a/packages/broker/internal/workspace/cp.go +++ b/packages/broker/internal/workspace/cp.go @@ -26,7 +26,6 @@ const ( environmentDirectory = "env.d" syncStateFile = "sync-state.json" syncLockFile = ".lock" - freshnessMarginMS = int64(60_000) // The workspace's own variables share creds/env.d so the box has exactly // one environment pipeline. The name sorts ahead of every integration file // (blitz-creds.sh sources the glob in order), so a minted credential always @@ -72,16 +71,16 @@ type MintResult struct { ExpiresAt int64 } +// SyncState is what applySync records for the box's shell to read. The +// freshness decision itself lives in the consumer, +// packages/box/rootfs/etc/profile.d/blitz-creds.sh, which parses expires_at +// and applies its own 60 s margin; nothing on the Go side re-implements it. type SyncState struct { SyncedAt int64 `json:"synced_at"` ExpiresAt int64 `json:"expires_at"` } -func (state SyncState) Fresh(nowMS int64) bool { - return nowMS < state.ExpiresAt-freshnessMarginMS -} - -func MintIntegration(ctx context.Context, stateDir, integration string, httpClient *http.Client) (MintResult, error) { +func MintIntegration(ctx context.Context, stateDir, integration string) (MintResult, error) { if !validIntegrationName(integration) { return MintResult{}, errors.New("invalid integration name") } @@ -91,7 +90,7 @@ func MintIntegration(ctx context.Context, stateDir, integration string, httpClie if err != nil { return MintResult{}, err } - data, err := mintRequest(ctx, stateDir, body, httpClient) + data, err := mintRequest(ctx, stateDir, body) if err != nil { return MintResult{}, err } @@ -128,9 +127,9 @@ func withCredentialsLock(stateDir string, run func(credsDir string) error) error return run(credsDir) } -func Sync(ctx context.Context, stateDir string, httpClient *http.Client) error { +func Sync(ctx context.Context, stateDir string) error { return withCredentialsLock(stateDir, func(string) error { - data, err := mintRequest(ctx, stateDir, []byte("{}"), httpClient) + data, err := mintRequest(ctx, stateDir, []byte("{}")) if err != nil { return err } @@ -142,14 +141,14 @@ func Sync(ctx context.Context, stateDir string, httpClient *http.Client) error { }) } -func GitHelper(ctx context.Context, stateDir, action string, input io.Reader, output io.Writer, httpClient *http.Client) error { +func GitHelper(ctx context.Context, stateDir, action string, input io.Reader, output io.Writer) error { if err := readCredentialInput(input); err != nil { return err } if action != "get" { return nil } - result, err := MintIntegration(ctx, stateDir, "github", httpClient) + result, err := MintIntegration(ctx, stateDir, "github") if errors.Is(err, ErrIntegrationNotConfigured) { if AccessRequestID(err) != "" { return err @@ -191,12 +190,12 @@ func readCredentialInput(input io.Reader) error { return nil } -func mintRequest(ctx context.Context, stateDir string, body []byte, httpClient *http.Client) ([]byte, error) { +func mintRequest(ctx context.Context, stateDir string, body []byte) ([]byte, error) { origin, err := store.LoadOrigin(stateDir) if err != nil { return nil, err } - client, err := controlplane.New(origin, stateDir, httpClient) + client, err := controlplane.New(origin, stateDir) if err != nil { return nil, err } diff --git a/packages/broker/internal/workspace/environment.go b/packages/broker/internal/workspace/environment.go index 690d616f..112def45 100644 --- a/packages/broker/internal/workspace/environment.go +++ b/packages/broker/internal/workspace/environment.go @@ -91,12 +91,12 @@ func decodeWorkspaceEnvironment(data []byte) (WorkspaceEnvironment, error) { }, nil } -func fetchWorkspaceEnvironment(ctx context.Context, stateDir string, httpClient *http.Client) (WorkspaceEnvironment, error) { +func fetchWorkspaceEnvironment(ctx context.Context, stateDir string) (WorkspaceEnvironment, error) { origin, err := store.LoadOrigin(stateDir) if err != nil { return WorkspaceEnvironment{}, err } - client, err := controlplane.New(origin, stateDir, httpClient) + client, err := controlplane.New(origin, stateDir) if err != nil { return WorkspaceEnvironment{}, err } @@ -215,54 +215,47 @@ func commandEnvironment(configured map[string]string) []string { return result } -var closedStartup = func() <-chan struct{} { - done := make(chan struct{}) - close(done) - return done -}() - // startStartupOnce claims the once-only marker and then starts the workspace's // startup script in its own goroutine. User code must never sit on the caller's // path: the deposit loop that calls this also ships vendor credentials, and a // script that legitimately never exits (a dev server, `tail -f`) would wedge it // forever. // -// The script gets its own deadline on top of ctx. Anything it backgrounds on -// purpose is reparented and keeps running past it, so the deadline costs a +// The script gets startupScriptTimeout on top of ctx. Anything it backgrounds +// on purpose is reparented and keeps running past it, so the deadline costs a // deliberate server nothing; what it buys is that a script that simply never // returns stops, and says so in its log, instead of holding a process for the // life of the box. // -// The returned channel closes when the script exits, and is already closed when -// nothing ran. Only tests wait on it. +// It returns once the script has STARTED; the exit is observable only through +// the marker, the log and the script's own effects. func startStartupOnce( ctx context.Context, stateDir, workspaceDir string, environment WorkspaceEnvironment, - timeout time.Duration, -) (<-chan struct{}, error) { +) error { directory := filepath.Join(stateDir, workspaceEnvironmentDirectory) markerPath := filepath.Join(directory, startupDoneFile) marker, err := os.OpenFile(markerPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if errors.Is(err, os.ErrExist) { - return closedStartup, nil + return nil } if err != nil { - return closedStartup, err + return err } if err := marker.Close(); err != nil { _ = os.Remove(markerPath) - return closedStartup, err + return err } log, err := os.OpenFile(filepath.Join(directory, startupLogFile), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { _ = os.Remove(markerPath) - return closedStartup, err + return err } if environment.StartupScript == nil { - return closedStartup, log.Close() + return log.Close() } - runContext, cancel := context.WithTimeout(ctx, timeout) + runContext, cancel := context.WithTimeout(ctx, startupScriptTimeout) command := exec.CommandContext(runContext, "bash", "-c", *environment.StartupScript) command.Dir = workspaceDir command.Env = commandEnvironment(environment.Env) @@ -271,11 +264,9 @@ func startStartupOnce( if err := command.Start(); err != nil { cancel() _ = log.Close() - return closedStartup, fmt.Errorf("workspace startup script failed to start: %w", err) + return fmt.Errorf("workspace startup script failed to start: %w", err) } - done := make(chan struct{}) go func() { - defer close(done) defer log.Close() defer cancel() err := command.Wait() @@ -283,36 +274,31 @@ func startStartupOnce( return } if errors.Is(runContext.Err(), context.DeadlineExceeded) { - fmt.Fprintf(log, "\nblitz: workspace startup script stopped after %s\n", timeout) + fmt.Fprintf(log, "\nblitz: workspace startup script stopped after %s\n", startupScriptTimeout) return } fmt.Fprintf(log, "\nblitz: workspace startup script failed: %v\n", err) }() - return done, nil + return nil } // environmentTick fetches and stores this box's environment, and once the // workspace files have landed, starts the startup script exactly once. It -// returns true when there is nothing left to converge. The channel closes when -// the startup script exits; callers on the credential path must not wait on it. -func environmentTick( - ctx context.Context, - stateDir, workspaceDir string, - httpClient *http.Client, -) (bool, <-chan struct{}, error) { - environment, err := fetchWorkspaceEnvironment(ctx, stateDir, httpClient) +// returns true when there is nothing left to converge. The script it may start +// runs detached; nothing on the credential path waits for it. +func environmentTick(ctx context.Context, stateDir, workspaceDir string) (bool, error) { + environment, err := fetchWorkspaceEnvironment(ctx, stateDir) if err != nil { - return false, closedStartup, err + return false, err } if err := storeWorkspaceEnvironment(stateDir, environment); err != nil { - return false, closedStartup, err + return false, err } if !environment.FilesReady { - return false, closedStartup, nil + return false, nil } - done, err := startStartupOnce(ctx, stateDir, workspaceDir, environment, startupScriptTimeout) - if err != nil { - return false, done, err + if err := startStartupOnce(ctx, stateDir, workspaceDir, environment); err != nil { + return false, err } - return true, done, nil + return true, nil } diff --git a/packages/broker/internal/workspace/environment_test.go b/packages/broker/internal/workspace/environment_test.go index 0df0e4f3..0be0554c 100644 --- a/packages/broker/internal/workspace/environment_test.go +++ b/packages/broker/internal/workspace/environment_test.go @@ -3,7 +3,6 @@ package workspace import ( "context" "encoding/json" - "io" "net/http" "net/http/httptest" "os" @@ -90,7 +89,7 @@ func TestEnvironmentTickStoresConfigAndRunsStartupOnce(t *testing.T) { t.Fatal(err) } - ready, _, err := environmentTick(context.Background(), stateDir, workspaceDir, server.Client()) + ready, err := environmentTick(context.Background(), stateDir, workspaceDir) if err != nil || ready { t.Fatalf("first tick ready=%v err=%v", ready, err) } @@ -109,15 +108,20 @@ func TestEnvironmentTickStoresConfigAndRunsStartupOnce(t *testing.T) { t.Fatal("startup marker exists before files are ready") } - ready, started, err := environmentTick(context.Background(), stateDir, workspaceDir, server.Client()) + ready, err = environmentTick(context.Background(), stateDir, workspaceDir) if err != nil || !ready { t.Fatalf("second tick ready=%v err=%v", ready, err) } - <-started - ready, _, err = environmentTick(context.Background(), stateDir, workspaceDir, server.Client()) + // The script runs detached; its exit is observable only through its + // effects, so poll for them the way the box's own readers would. + waitForFileContent(t, filepath.Join(workspaceDir, "runs.txt"), "run") + waitForFileContent(t, filepath.Join(envDir, startupLogFile), "it's $HOME\nnext\n") + ready, err = environmentTick(context.Background(), stateDir, workspaceDir) if err != nil || !ready { t.Fatalf("third tick ready=%v err=%v", ready, err) } + // The once-only marker was claimed on the second tick, so the third must + // not have started the script again. runs, err := os.ReadFile(filepath.Join(workspaceDir, "runs.txt")) if err != nil { t.Fatal(err) @@ -125,18 +129,6 @@ func TestEnvironmentTickStoresConfigAndRunsStartupOnce(t *testing.T) { if string(runs) != "run" { t.Fatalf("startup runs = %q", runs) } - log, err := os.Open(filepath.Join(envDir, startupLogFile)) - if err != nil { - t.Fatal(err) - } - defer log.Close() - logged, err := io.ReadAll(log) - if err != nil { - t.Fatal(err) - } - if string(logged) != "it's $HOME\nnext\n" { - t.Fatalf("startup log = %q", logged) - } for _, name := range []string{workspaceEnvironmentState, startupDoneFile, startupLogFile} { info, err := os.Stat(filepath.Join(envDir, name)) if err != nil { @@ -149,6 +141,24 @@ func TestEnvironmentTickStoresConfigAndRunsStartupOnce(t *testing.T) { assertFileMode(t, filepath.Join(credsEnvDir, workspaceEnvironmentEntry), 0o600) } +// waitForFileContent polls until path holds exactly want. The startup script +// runs detached from every caller, so its effects are the only way to observe +// it — the same way the box's own readers see it. +func waitForFileContent(t *testing.T, path, want string) { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + for { + got, err := os.ReadFile(path) + if err == nil && string(got) == want { + return + } + if time.Now().After(deadline) { + t.Fatalf("%s = %q, %v; want %q", path, got, err, want) + } + time.Sleep(20 * time.Millisecond) + } +} + // A startup script that never exits is a legitimate thing to ask for (a dev // server). It must not stop the watch loop, which is also the credential // deposit path. @@ -162,15 +172,15 @@ func TestStartupScriptNeverBlocksTheWatchLoop(t *testing.T) { defer cancel() script := "printf started > started.txt\nwhile true; do sleep 1; done\n" deposits := make(chan struct{}, 4) - watcher := NewWatcher(t.TempDir(), func(context.Context, string, []byte) error { - return nil - }) + // An empty home: the watcher finds no credentials, so its ticks are pure + // loop turns — exactly what must keep happening while the script runs. + watcher := NewWatcher(t.TempDir(), t.TempDir()) ticks := make(chan struct{}) go func() { defer close(ticks) - if _, err := startStartupOnce(ctx, stateDir, workspaceDir, WorkspaceEnvironment{ + if err := startStartupOnce(ctx, stateDir, workspaceDir, WorkspaceEnvironment{ Env: map[string]string{}, StartupScript: &script, - }, startupScriptTimeout); err != nil { + }); err != nil { t.Error(err) } // The watch loop keeps depositing while the script above still runs. @@ -188,17 +198,7 @@ func TestStartupScriptNeverBlocksTheWatchLoop(t *testing.T) { t.Fatalf("deposit ticks = %d", len(deposits)) } // The script is still running; it only had to start, not finish. - deadline := time.Now().Add(20 * time.Second) - for { - started, err := os.ReadFile(filepath.Join(workspaceDir, "started.txt")) - if err == nil && string(started) == "started" { - return - } - if time.Now().After(deadline) { - t.Fatalf("startup script did not run: %q %v", started, err) - } - time.Sleep(20 * time.Millisecond) - } + waitForFileContent(t, filepath.Join(workspaceDir, "started.txt"), "started") } // A credential sync rebuilds creds/env.d from scratch. The workspace entry has @@ -241,36 +241,44 @@ func TestCredentialSyncKeepsWorkspaceEnvironmentAndWinsCollisions(t *testing.T) } } +// TestStartupScriptStopsAtItsDeadline drives the kill-and-say-so path. The +// production deadline is startupScriptTimeout (10 minutes) — far too long for +// a test to sit out — so the test supplies the deadline through the caller's +// context, which reaches the script through exactly the same +// context.WithTimeout + CommandContext chain and takes the same +// DeadlineExceeded branch. func TestStartupScriptStopsAtItsDeadline(t *testing.T) { stateDir := t.TempDir() workspaceDir := t.TempDir() if err := os.MkdirAll(filepath.Join(stateDir, workspaceEnvironmentDirectory), 0o700); err != nil { t.Fatal(err) } - // A script that backgrounds work and then never returns. The parent context - // stays live for the whole test: only the deadline may end this. + // A script that backgrounds work and then never returns. Only the deadline + // may end this. script := "(sleep 120 &) \nwhile true; do sleep 1; done\n" started := time.Now() - done, err := startStartupOnce(context.Background(), stateDir, workspaceDir, WorkspaceEnvironment{ + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if err := startStartupOnce(ctx, stateDir, workspaceDir, WorkspaceEnvironment{ Env: map[string]string{}, StartupScript: &script, - }, 200*time.Millisecond) - if err != nil { + }); err != nil { t.Fatal(err) } - select { - case <-done: - case <-time.After(30 * time.Second): - t.Fatal("a non-exiting startup script outlived its deadline") + // The author has to be able to see why their script stopped. The exit is + // observable only through the log, so poll for the line. + logPath := filepath.Join(stateDir, workspaceEnvironmentDirectory, startupLogFile) + deadline := time.Now().Add(30 * time.Second) + for { + logged, err := os.ReadFile(logPath) + if err == nil && strings.Contains(string(logged), "stopped after") { + break + } + if time.Now().After(deadline) { + t.Fatalf("a non-exiting startup script outlived its deadline: log=%q err=%v", logged, err) + } + time.Sleep(20 * time.Millisecond) } if elapsed := time.Since(started); elapsed > 30*time.Second { t.Fatalf("startup script ran for %s", elapsed) } - logged, err := os.ReadFile(filepath.Join(stateDir, workspaceEnvironmentDirectory, startupLogFile)) - if err != nil { - t.Fatal(err) - } - // The author has to be able to see why their script stopped. - if !strings.Contains(string(logged), "stopped after 200ms") { - t.Fatalf("startup log = %q", logged) - } } diff --git a/packages/broker/internal/workspace/register.go b/packages/broker/internal/workspace/register.go index 21a51bca..7ec04caf 100644 --- a/packages/broker/internal/workspace/register.go +++ b/packages/broker/internal/workspace/register.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "net/http" "os" "os/exec" "path/filepath" @@ -46,7 +45,7 @@ const registerAttempts = 3 // runs on the boot path with other services waiting behind it, and the failure // it covers is a few hundred milliseconds of network, not an outage. An outage // is what the no-broker path below is for. -var registerRetryDelay = 500 * time.Millisecond +const registerRetryDelay = 500 * time.Millisecond // Register enrols this workspace with the credential broker and points the // harnesses at it. @@ -60,12 +59,12 @@ var registerRetryDelay = 500 * time.Millisecond // broker, or every broker full, means the feature is off for this box, and the // right outcome is a workspace that runs signed out with no stale wiring left // behind. See ErrNoBrokerCapacity. -func Register(ctx context.Context, stateDir string, httpClient *http.Client) error { +func Register(ctx context.Context, stateDir string) error { origin, err := store.LoadOrigin(stateDir) if err != nil { return err } - client, err := controlplane.New(origin, stateDir, httpClient) + client, err := controlplane.New(origin, stateDir) if err != nil { return err } diff --git a/packages/broker/internal/workspace/roaming_test.go b/packages/broker/internal/workspace/roaming_test.go index f547731a..02191577 100644 --- a/packages/broker/internal/workspace/roaming_test.go +++ b/packages/broker/internal/workspace/roaming_test.go @@ -10,7 +10,6 @@ import ( "strconv" "strings" "testing" - "time" "github.com/blitzdotdev/blitz-core/broker/internal/store" "github.com/blitzdotdev/blitz-core/broker/internal/vendor" @@ -42,7 +41,7 @@ func TestRegisterTreatsNoBrokerCapacityAsACleanSkip(t *testing.T) { t.Fatal(err) } - if err := Register(context.Background(), stateDir, server.Client()); err != nil { + if err := Register(context.Background(), stateDir); err != nil { t.Fatalf("Register failed on a capacity refusal instead of skipping: %v", err) } @@ -75,13 +74,14 @@ func TestRegisterTreatsNoBrokerCapacityAsACleanSkip(t *testing.T) { // registers at the moment its own network is coming up, and nothing retries // afterwards, so one attempt turns a lost half-second into a box with no // broker for its entire life. +// +// It waits out the real registerRetryDelay twice (~1 s): the delay is a +// production constant, and a second of wall clock is cheaper than a knob that +// exists only for this test. func TestRegisterRetriesATransientFailure(t *testing.T) { stateDir := t.TempDir() t.Setenv("HOME", t.TempDir()) seedBox(t, stateDir) - previous := registerRetryDelay - registerRetryDelay = time.Millisecond - defer func() { registerRetryDelay = previous }() var calls int server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { @@ -97,7 +97,7 @@ func TestRegisterRetriesATransientFailure(t *testing.T) { t.Fatal(err) } - if err := Register(context.Background(), stateDir, server.Client()); err != nil { + if err := Register(context.Background(), stateDir); err != nil { t.Fatal(err) } if calls != registerAttempts { @@ -279,14 +279,15 @@ func TestTokenRefusesAReplyThatHoldsOnlyATerminator(t *testing.T) { } } -// seedBrokerWiring writes the state a mint needs to reach the ssh binary: a -// broker to dial and a key to dial it with. +// seedBrokerWiring writes the state a mint or deposit needs to reach the ssh +// binary: a broker to dial and keys to dial it with. func seedBrokerWiring(t *testing.T) string { t.Helper() stateDir := t.TempDir() writeFile(t, filepath.Join(stateDir, BrokerFile), `{"host":"broker.example","port":22,"member":"m-0123456789ab"}`) writeFile(t, filepath.Join(stateDir, KnownHostsFile), "broker.example ssh-ed25519 AAAA\n") writeFile(t, filepath.Join(stateDir, MintKeyFile), "private") + writeFile(t, filepath.Join(stateDir, DepositKeyFile), "private") return stateDir } @@ -314,6 +315,27 @@ func liveModelProviderLines(config string) []string { return live } +// fakeDepositSSH wires a watcher's state directory at a fake broker: broker +// config and deposit key on disk, and an `ssh` script first on PATH that +// counts each deposit into countFile, runs extra first, and answers the exact +// "ok\n" ACK the deposit wire expects. The watcher then runs the REAL deposit +// path — LoadBroker, key selection, ssh argv, ACK parsing — end to end. +func fakeDepositSSH(t *testing.T, countFile, extra string) string { + t.Helper() + stateDir := seedBrokerWiring(t) + fakeSSH(t, "printf x >> "+strconv.Quote(countFile)+"\n"+extra+"printf 'ok\\n'\n") + return stateDir +} + +func depositCount(t *testing.T, countFile string) int { + t.Helper() + data, err := os.ReadFile(countFile) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + return len(data) +} + // TestWatcherDeletesTheWorkspaceCopyOnAck is "single copy by construction". The // broker is the only thing that refreshes a credential and the only place a // second workspace can get one; a workspace that kept its copy would be an @@ -322,12 +344,10 @@ func TestWatcherDeletesTheWorkspaceCopyOnAck(t *testing.T) { home := t.TempDir() path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) writeFile(t, path, "a-login") + countFile := filepath.Join(t.TempDir(), "deposits") + stateDir := fakeDepositSSH(t, countFile, "") - var deposits int - watcher := NewWatcher(home, func(context.Context, string, []byte) error { - deposits++ - return nil - }) + watcher := NewWatcher(home, stateDir) if err := watcher.Tick(context.Background()); err != nil { t.Fatal(err) } @@ -341,8 +361,8 @@ func TestWatcherDeletesTheWorkspaceCopyOnAck(t *testing.T) { if err := watcher.Tick(context.Background()); err != nil { t.Fatal(err) } - if deposits != 2 { - t.Fatalf("deposits = %d, want 2", deposits) + if got := depositCount(t, countFile); got != 2 { + t.Fatalf("deposits = %d, want 2", got) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatal("the second copy was not removed") @@ -357,11 +377,12 @@ func TestWatcherKeepsACopyTheBrokerNeverReceived(t *testing.T) { home := t.TempDir() path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) writeFile(t, path, "old-login") + countFile := filepath.Join(t.TempDir(), "deposits") + // The fake broker session lands a fresher login while the deposit is in + // flight — the mid-deposit race, staged by the ssh child itself. + stateDir := fakeDepositSSH(t, countFile, "printf 'fresher-login' > "+strconv.Quote(path)+"\n") - watcher := NewWatcher(home, func(context.Context, string, []byte) error { - writeFile(t, path, "fresher-login") - return nil - }) + watcher := NewWatcher(home, stateDir) if err := watcher.Tick(context.Background()); err != nil { t.Fatal(err) } @@ -381,24 +402,22 @@ func TestWatcherReportsACopyItCouldNotRemove(t *testing.T) { home := t.TempDir() path := filepath.Join(home, filepath.FromSlash(vendor.Claude.CredentialPath)) writeFile(t, path, "a-login") + countFile := filepath.Join(t.TempDir(), "deposits") + stateDir := fakeDepositSSH(t, countFile, "") if err := os.Chmod(filepath.Dir(path), 0o500); err != nil { t.Fatal(err) } defer os.Chmod(filepath.Dir(path), 0o700) - var deposits int - watcher := NewWatcher(home, func(context.Context, string, []byte) error { - deposits++ - return nil - }) + watcher := NewWatcher(home, stateDir) if err := watcher.Tick(context.Background()); err == nil { t.Fatal("an unremovable workspace copy was reported as success") } if err := watcher.Tick(context.Background()); err != nil { t.Fatalf("the second tick re-reported a copy it had already deposited: %v", err) } - if deposits != 1 { - t.Fatalf("deposits = %d, want 1 — the watcher hammered the broker", deposits) + if got := depositCount(t, countFile); got != 1 { + t.Fatalf("deposits = %d, want 1 — the watcher hammered the broker", got) } } diff --git a/packages/broker/internal/workspace/watch.go b/packages/broker/internal/workspace/watch.go index 18604179..9a3f18b2 100644 --- a/packages/broker/internal/workspace/watch.go +++ b/packages/broker/internal/workspace/watch.go @@ -13,16 +13,14 @@ import ( "github.com/blitzdotdev/blitz-core/broker/internal/vendor" ) -type Depositor func(context.Context, string, []byte) error - type Watcher struct { Home string - Deposit Depositor + StateDir string deposited map[string][sha256.Size]byte } -func NewWatcher(home string, deposit Depositor) *Watcher { - return &Watcher{Home: home, Deposit: deposit, deposited: make(map[string][sha256.Size]byte)} +func NewWatcher(home, stateDir string) *Watcher { + return &Watcher{Home: home, StateDir: stateDir, deposited: make(map[string][sha256.Size]byte)} } // Tick deposits any credential that changed since the last pass and then @@ -43,7 +41,7 @@ func (watcher *Watcher) Tick(ctx context.Context) error { // The credential paths come off the vendor Definition table, not a config // file: the broker's mint, the broker's deposit and this watcher then read // one field, and the list cannot go missing or drift out from under them. - for _, definition := range []vendor.Definition{vendor.Claude, vendor.Codex} { + for _, definition := range vendor.Definitions { path := filepath.Join(watcher.Home, filepath.FromSlash(definition.CredentialPath)) blob, err := readWatchedFile(path) if errors.Is(err, os.ErrNotExist) { @@ -57,7 +55,7 @@ func (watcher *Watcher) Tick(ctx context.Context) error { if previous, ok := watcher.deposited[definition.Name]; ok && previous == digest { continue } - if err := watcher.Deposit(ctx, definition.Name, blob); err != nil { + if err := Deposit(ctx, watcher.StateDir, definition.Name, blob); err != nil { failures = append(failures, err) continue } @@ -104,15 +102,13 @@ func removeIfUnchanged(path string, digest [sha256.Size]byte) (bool, error) { } func Watch(ctx context.Context, stateDir, home string) error { - watcher := NewWatcher(home, func(callContext context.Context, harness string, blob []byte) error { - return Deposit(callContext, stateDir, harness, blob) - }) + watcher := NewWatcher(home, stateDir) environmentReady := false for { if !environmentReady { // The startup script it may launch runs detached, so this call // never holds up the credential deposits below. - ready, _, _ := environmentTick(ctx, stateDir, "/workspace", nil) + ready, _ := environmentTick(ctx, stateDir, "/workspace") environmentReady = ready } _ = watcher.Tick(ctx) diff --git a/packages/broker/internal/workspace/workspace_test.go b/packages/broker/internal/workspace/workspace_test.go index 1dc56788..19cc5326 100644 --- a/packages/broker/internal/workspace/workspace_test.go +++ b/packages/broker/internal/workspace/workspace_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "slices" + "strconv" "testing" "github.com/blitzdotdev/blitz-core/broker/internal/feed" @@ -26,19 +27,22 @@ func TestWatcherRedepositsLoginThatChangesDuringDeposit(t *testing.T) { if err := os.WriteFile(path, []byte("old-login"), 0o600); err != nil { t.Fatal(err) } - var deposits [][]byte - watcher := NewWatcher(home, func(_ context.Context, harness string, blob []byte) error { - if harness != "claude" { - t.Fatalf("harness = %q", harness) - } - deposits = append(deposits, append([]byte(nil), blob...)) - if len(deposits) == 1 { - if err := os.WriteFile(path, []byte("fresher-login"), 0o600); err != nil { - t.Fatal(err) - } - } - return nil - }) + stateDir := seedBrokerWiring(t) + // The fake broker records each deposited blob off its stdin — the deposit + // wire — asserts the forced command names the right harness, lands a + // fresher login mid-first-deposit, and ACKs. + record := t.TempDir() + fakeSSH(t, `for last in "$@"; do :; done +[ "$last" = claude ] || { printf 'wrong harness %s\n' "$last" >&2; exit 9; } +n=$(cat `+strconv.Quote(filepath.Join(record, "count"))+` 2>/dev/null || printf 0) +n=$((n+1)) +printf %s "$n" > `+strconv.Quote(filepath.Join(record, "count"))+` +cat > "`+record+`/deposit.$n" +if [ "$n" = 1 ]; then printf 'fresher-login' > `+strconv.Quote(path)+`; fi +printf 'ok\n' +`) + + watcher := NewWatcher(home, stateDir) if err := watcher.Tick(context.Background()); err != nil { t.Fatal(err) } @@ -48,8 +52,20 @@ func TestWatcherRedepositsLoginThatChangesDuringDeposit(t *testing.T) { if err := watcher.Tick(context.Background()); err != nil { t.Fatal(err) } - if len(deposits) != 2 || string(deposits[0]) != "old-login" || string(deposits[1]) != "fresher-login" { - t.Fatalf("deposits = %q", deposits) + count, err := os.ReadFile(filepath.Join(record, "count")) + if err != nil || string(count) != "2" { + t.Fatalf("deposit count = %q, %v; want 2", count, err) + } + first, err := os.ReadFile(filepath.Join(record, "deposit.1")) + if err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(filepath.Join(record, "deposit.2")) + if err != nil { + t.Fatal(err) + } + if string(first) != "old-login" || string(second) != "fresher-login" { + t.Fatalf("deposits = %q, %q", first, second) } } @@ -102,14 +118,14 @@ func TestRegisterCreatesIdempotentKeysAndPinnedBrokerFiles(t *testing.T) { if err := store.SaveOrigin(stateDir, server.URL); err != nil { t.Fatal(err) } - if err := Register(context.Background(), stateDir, server.Client()); err != nil { + if err := Register(context.Background(), stateDir); err != nil { t.Fatal(err) } mintBefore, err := os.ReadFile(filepath.Join(stateDir, MintKeyFile)) if err != nil { t.Fatal(err) } - if err := Register(context.Background(), stateDir, server.Client()); err != nil { + if err := Register(context.Background(), stateDir); err != nil { t.Fatal(err) } mintAfter, err := os.ReadFile(filepath.Join(stateDir, MintKeyFile)) @@ -242,19 +258,6 @@ func TestEmptySyncUsesZeroExpiry(t *testing.T) { } } -func TestSyncStateFreshnessUsesSixtySecondMargin(t *testing.T) { - state := SyncState{ExpiresAt: 1_000_000} - if !state.Fresh(939_999) { - t.Fatal("state more than 60 seconds from expiry is stale") - } - if state.Fresh(940_000) { - t.Fatal("state exactly 60 seconds from expiry is fresh") - } - if (SyncState{ExpiresAt: 0}).Fresh(0) { - t.Fatal("zero expiry is fresh") - } -} - func TestSyncMintsAllOnce(t *testing.T) { stateDir := t.TempDir() if err := store.SaveCredential(stateDir, store.Credential{BoxID: "box", AccessToken: "access", RefreshToken: "refresh"}); err != nil { @@ -280,7 +283,7 @@ func TestSyncMintsAllOnce(t *testing.T) { if err := store.SaveOrigin(stateDir, server.URL); err != nil { t.Fatal(err) } - if err := Sync(context.Background(), stateDir, server.Client()); err != nil { + if err := Sync(context.Background(), stateDir); err != nil { t.Fatal(err) } if calls != 1 { diff --git a/packages/control-plane/core/agent-rules.ts b/packages/control-plane/core/agent-rules.ts index b16b46f2..064a3f62 100644 --- a/packages/control-plane/core/agent-rules.ts +++ b/packages/control-plane/core/agent-rules.ts @@ -1,6 +1,6 @@ import agentRulesMarkdown from "../../box/rootfs/opt/blitz/skel/agent-rules.md"; import type { Db } from "./db.js"; -import { first, rows, transaction } from "./db.js"; +import { first, rows } from "./db.js"; import { HttpError, isRecord, readJson, requiredString, type JsonValue } from "./http.js"; import { authenticateBox } from "./oauth.js"; import type { Principal } from "./principals.js"; @@ -224,8 +224,12 @@ export function addAgentRuleLibraryRoutes( }); // Deleting a referenced rule is allowed: the templates and workspaces that - // point at it fall back to the built-in doc. The nulling is done here so the - // ON DELETE SET NULL semantics hold whether or not D1 enforces the key. + // point at it fall back to the built-in doc. + // SAFETY: `workspaces.agent_rule_id` and `workspace_templates.agent_rule_id` + // are both declared REFERENCES agent_rules(id) ON DELETE SET NULL + // (migrations/0018_agent_rules.sql and the managed schema alike), and both + // deployment targets run SQLite with foreign keys enforced, so this DELETE + // nulls the holders itself. router.delete("/agent-rules/:id", async (context) => { const runtime = runtimeFactory(context); const orgId = await orgFor(context); @@ -235,14 +239,7 @@ export function addAgentRuleLibraryRoutes( v: [id, orgId], }); if (existing === null) throw new HttpError(404, "agent rule not found"); - await transaction(runtime.db, [ - { q: "UPDATE workspaces SET agent_rule_id = NULL WHERE agent_rule_id = ?1", v: [id] }, - { - q: "UPDATE workspace_templates SET agent_rule_id = NULL WHERE agent_rule_id = ?1", - v: [id], - }, - { q: "DELETE FROM agent_rules WHERE id = ?1", v: [id] }, - ]); + await rows(runtime.db, { q: "DELETE FROM agent_rules WHERE id = ?1", v: [id] }); return context.body(null, 204); }); } diff --git a/packages/control-plane/core/app.ts b/packages/control-plane/core/app.ts index 8d4af250..6a7a92fa 100644 --- a/packages/control-plane/core/app.ts +++ b/packages/control-plane/core/app.ts @@ -6,7 +6,7 @@ import { frameworkHttpError, HttpError } from "./http.js"; import { addFilesRoutes } from "./files/routes.js"; import { addIdentityRoutes } from "./identity/routes.js"; import { addOAuthRoutes } from "./oauth.js"; -import type { Principal } from "./principals.js"; +import { findSessionPrincipal, type Principal } from "./principals.js"; import { addMicrovmHostRoutes } from "./compute/microvm.js"; import { addRecipeRoutes } from "./recipes.js"; import { addRegistryRoutes } from "./registry.js"; @@ -42,10 +42,10 @@ export function installControlPlaneRoutes( async function requirePrincipal(context: CoreContext): Promise { const runtime = runtimeFactory(context); - const principal = await runtime.principalSource.authenticate(context.req.raw, runtime.db); + const principal = await findSessionPrincipal(context.req.raw, runtime.db); if (principal === null) throw new HttpError(401, "unauthorized"); - // Login (mintSession) already upserts the principal; re-upserting here - // added a D1 write to every authenticated request. + // Login already upserts the principal; re-upserting here added a D1 + // write to every authenticated request. return principal; } diff --git a/packages/control-plane/core/bootstrap.ts b/packages/control-plane/core/bootstrap.ts index af56dbc7..4cd0fb14 100644 --- a/packages/control-plane/core/bootstrap.ts +++ b/packages/control-plane/core/bootstrap.ts @@ -213,15 +213,20 @@ async function main() { } await request(socket, 'session/set_config_option', { sessionId: sessionId, configId: 'permission', value: PERMISSION }); nextId += 1; - socket.send(JSON.stringify({ - jsonrpc: '2.0', - id: nextId, - method: 'session/prompt', - params: { sessionId: sessionId, prompt: [{ type: 'text', text: prompt }] }, - })); + // ws runs the send callback once the frame is written out; the turn itself + // runs in the actor without us, so exit as soon as the frame has left. + await new Promise(function (resolve, reject) { + socket.send(JSON.stringify({ + jsonrpc: '2.0', + id: nextId, + method: 'session/prompt', + params: { sessionId: sessionId, prompt: [{ type: 'text', text: prompt }] }, + }), function (error) { + if (error) reject(error); + else resolve(undefined); + }); + }); log('prompt submitted to session ' + sessionId); - // Let the frame flush; the turn itself runs in the actor without us. - await sleep(2000); process.exit(0); } @@ -340,7 +345,14 @@ fi docker image inspect "$BOX_IMAGE_TAG" >/dev/null box_image="$BOX_IMAGE_TAG"` : String.raw`if ! docker image inspect "$BOX_IMAGE_REF" >/dev/null 2>&1; then - retry docker pull "$BOX_IMAGE_REF" + pull_attempt=1 + until docker pull "$BOX_IMAGE_REF"; do + if (( pull_attempt >= 10 )); then + fail "docker pull failed after $pull_attempt attempts: $BOX_IMAGE_REF" + fi + sleep $((pull_attempt * 3)) + pull_attempt=$((pull_attempt + 1)) + done fi docker image inspect "$BOX_IMAGE_REF" >/dev/null box_image="$BOX_IMAGE_REF"`; @@ -508,19 +520,6 @@ touch "$BOOTSTRAP_LOG" chmod 0600 "$BOOTSTRAP_LOG" exec >>"$BOOTSTRAP_LOG" 2>&1 -retry() { - local attempt=1 - local max_attempts=10 - until "$@"; do - if (( attempt >= max_attempts )); then - echo "command failed after $attempt attempts: $*" - return 1 - fi - sleep $((attempt * 3)) - attempt=$((attempt + 1)) - done -} - fail() { bootstrap_error="$*" echo "blitz bootstrap failed: $*" diff --git a/packages/control-plane/core/compute/aws-sigv4.ts b/packages/control-plane/core/compute/aws-sigv4.ts index 89a7b246..871023f6 100644 --- a/packages/control-plane/core/compute/aws-sigv4.ts +++ b/packages/control-plane/core/compute/aws-sigv4.ts @@ -11,7 +11,10 @@ const ALGORITHM = "AWS4-HMAC-SHA256"; const REQUEST_TERMINATOR = "aws4_request"; -const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded; charset=utf-8"; +/** The query protocol's content type, exactly as the published AWS SigV4 + * test-suite `post-x-www-form-urlencoded` vector signs it. No charset suffix: + * the encoded body is pure ASCII (see encodeAwsQueryComponent). */ +const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"; export interface AwsCredentials { readonly accessKeyId: string; @@ -31,10 +34,6 @@ export interface AwsQueryRequest { readonly host: string; readonly parameters: AwsQueryParameters; readonly signedAt: Date; - /** Overrides the form content type. Exists so `test/aws-sigv4.test.ts` can - * reproduce the published AWS `post-x-www-form-urlencoded` vector, which - * signs the header without the `charset` suffix. */ - readonly contentType?: string; } export interface SignedAwsRequest { @@ -107,10 +106,9 @@ export async function signAwsQueryRequest( const amzDate = amzDateStamp(request.signedAt); const date = amzDate.slice(0, 8); const sessionToken = request.credentials.sessionToken ?? ""; - const contentType = request.contentType ?? FORM_CONTENT_TYPE; const canonicalHeaders = sessionToken === "" - ? `content-type:${contentType}\nhost:${request.host}\nx-amz-date:${amzDate}\n` - : `content-type:${contentType}\nhost:${request.host}\nx-amz-date:${amzDate}\nx-amz-security-token:${sessionToken}\n`; + ? `content-type:${FORM_CONTENT_TYPE}\nhost:${request.host}\nx-amz-date:${amzDate}\n` + : `content-type:${FORM_CONTENT_TYPE}\nhost:${request.host}\nx-amz-date:${amzDate}\nx-amz-security-token:${sessionToken}\n`; const signedHeaders = sessionToken === "" ? "content-type;host;x-amz-date" : "content-type;host;x-amz-date;x-amz-security-token"; @@ -136,7 +134,7 @@ export async function signAwsQueryRequest( ), ); const headers: [string, string][] = [ - ["content-type", contentType], + ["content-type", FORM_CONTENT_TYPE], ["x-amz-date", amzDate], [ "authorization", diff --git a/packages/control-plane/core/compute/aws.ts b/packages/control-plane/core/compute/aws.ts index 5044fb70..5a96be1d 100644 --- a/packages/control-plane/core/compute/aws.ts +++ b/packages/control-plane/core/compute/aws.ts @@ -1,9 +1,8 @@ -import type { CreateVolumeRequest, Volume } from "../wire.js"; import { BOX_IMAGE_TICKETS_SINCE_MS, BOX_IMAGE_VIEWER_GUARDS_SINCE_MS, } from "../webapp-tickets.js"; -import { fetchBoundedText, type Fetcher } from "./json-fetch.js"; +import { fetchBoundedText } from "./json-fetch.js"; import { signAwsQueryRequest, type AwsCredentials, @@ -17,7 +16,6 @@ import type { ProviderMachineType, VmInspection, VmProvider, - VolumeProvider, } from "./types.js"; const EC2_API_VERSION = "2016-11-15"; @@ -45,7 +43,6 @@ const MACHINE_TYPE_PATTERN = const REGION_PATTERN = /^[a-z]{2}(?:-[a-z]+)+-\d+$/u; /** VM ids are the raw EC2 instance id: `i-` plus 8 or 17 lowercase hex digits. */ const VM_ID_PATTERN = /^i-[0-9a-f]{8}(?:[0-9a-f]{9})?$/u; -const VOLUME_ID_PATTERN = /^vol-[0-9a-f]{8}(?:[0-9a-f]{9})?$/u; const SECURITY_GROUP_ID_PATTERN = /^sg-[0-9a-f]{8}(?:[0-9a-f]{9})?$/u; const SUBNET_ID_PATTERN = /^subnet-[0-9a-f]{8}(?:[0-9a-f]{9})?$/u; const IMAGE_ID_PATTERN = /^ami-[0-9a-f]{8}(?:[0-9a-f]{9})?$/u; @@ -54,7 +51,6 @@ const CANONICAL_OWNER_ID = "099720109477"; const UBUNTU_IMAGE_NAME = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"; const ROOT_DEVICE_NAME = "/dev/sda1"; -const ATTACH_DEVICE_NAME = "/dev/sdf"; const DEFAULT_ROOT_DISK_GB = 40; const WORKSPACE_TAG = "blitz-workspace"; const PURPOSE_TAG = "blitz-purpose"; @@ -64,7 +60,6 @@ const POLL_INTERVAL_MS = 1_000; const POLL_TIMEOUT_MS = 45_000; const INSTANCE_GONE_CODES = ["InvalidInstanceID.NotFound", "InvalidInstanceId.NotFound"]; -const VOLUME_GONE_CODES = ["InvalidVolume.NotFound", "InvalidVolumeID.NotFound"]; interface AwsMachineType { readonly instanceType: string; @@ -105,12 +100,6 @@ export interface AwsProviderConfig { readonly securityGroupIds?: readonly string[]; } -export interface AwsProviderOptions { - fetcher?: Fetcher; - now?: () => number; - sleep?: (milliseconds: number) => Promise; -} - export interface AwsProviderEnv { AWS_ACCESS_KEY_ID?: string; AWS_SECRET_ACCESS_KEY?: string; @@ -155,14 +144,6 @@ function sleep(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } -function base64Utf8(value: string): string { - let binary = ""; - for (const byte of new TextEncoder().encode(value)) { - binary += String.fromCharCode(byte); - } - return btoa(binary); -} - function base64Bytes(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); @@ -199,12 +180,6 @@ function requiredField(element: XmlElement, name: string, label: string): string return value; } -function requiredInteger(element: XmlElement, name: string, label: string): number { - const value = Number(requiredField(element, name, label)); - if (!Number.isSafeInteger(value)) throw new Error(`invalid AWS ${label} response`); - return value; -} - /** `…` is the query protocol's fault shape; * a handful of actions answer with `` instead. */ function errorCode(document: XmlElement): string | null { @@ -229,22 +204,6 @@ function instanceViews(document: XmlElement): InstanceView[] { })); } -function volumeFromEc2(element: XmlElement): Volume { - const attachment = setItems(element, "attachmentSet")[0]; - const attachedTo = attachment === undefined ? null : childText(attachment, "instanceId"); - const name = setItems(element, "tagSet") - .find((tag) => childText(tag, "key") === "Name"); - const id = requiredField(element, "volumeId", "volume"); - return { - id, - name: (name === undefined ? null : childText(name, "value")) ?? id, - sizeGb: requiredInteger(element, "size", "volume"), - location: requiredField(element, "availabilityZone", "volume"), - status: attachedTo === null || attachedTo === "" ? "available" : "attached", - attachedTo: attachedTo === "" ? null : attachedTo, - }; -} - function tagParameters( prefix: string, resourceType: "instance" | "volume", @@ -273,7 +232,6 @@ function validated(value: string, pattern: RegExp, label: string): string { * provider whose credentials are present hides an operator typo. */ export function awsProviderFromEnv( env: AwsProviderEnv, - options: AwsProviderOptions = {}, ): AwsProvider | undefined { const accessKeyId = env.AWS_ACCESS_KEY_ID ?? ""; const secretAccessKey = env.AWS_SECRET_ACCESS_KEY ?? ""; @@ -306,22 +264,16 @@ export function awsProviderFromEnv( if (sessionToken !== "") settings.sessionToken = sessionToken; if (imageId !== "") settings.imageId = validated(imageId, IMAGE_ID_PATTERN, "AWS_IMAGE_ID"); if (subnetId !== "") settings.subnetId = validated(subnetId, SUBNET_ID_PATTERN, "AWS_SUBNET_ID"); - return new AwsProvider(settings, options); + return new AwsProvider(settings); } -export class AwsProvider implements VmProvider, VolumeProvider { +export class AwsProvider implements VmProvider { readonly id = "aws"; private readonly credentials: AwsCredentials; private readonly host: string; - private readonly fetcher: Fetcher; - private readonly now: () => number; - private readonly sleep: (milliseconds: number) => Promise; private cachedImageId: string | null; - constructor( - private readonly config: AwsProviderConfig, - options: AwsProviderOptions = {}, - ) { + constructor(private readonly config: AwsProviderConfig) { const credentials: AwsCredentialSettings = { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, @@ -329,15 +281,15 @@ export class AwsProvider implements VmProvider, VolumeProvider { if (config.sessionToken !== undefined) credentials.sessionToken = config.sessionToken; this.credentials = credentials; this.host = `ec2.${config.region}.amazonaws.com`; - this.fetcher = options.fetcher ?? fetch; - this.now = options.now ?? Date.now; - this.sleep = options.sleep ?? sleep; this.cachedImageId = config.imageId ?? null; } capabilities(): ProviderCapabilities { + // volumes: false — production routes `providers.volume` to Hetzner, so an + // EBS implementation here would never be called; the create route's volume + // gate refuses AWS machine types instead. return { - volumes: true, + volumes: false, maxUserDataBytes: AWS_USER_DATA_RAW_MAX_BYTES, webAppTicketsSinceMs: BOX_IMAGE_TICKETS_SINCE_MS, webAppViewerGuardsSinceMs: BOX_IMAGE_VIEWER_GUARDS_SINCE_MS, @@ -363,10 +315,10 @@ export class AwsProvider implements VmProvider, VolumeProvider { service: EC2_SERVICE, host: this.host, parameters: [["Action", action], ["Version", EC2_API_VERSION], ...parameters], - signedAt: new Date(this.now()), + signedAt: new Date(), }); const { response, body } = await fetchBoundedText( - this.fetcher, + fetch, signed.url, { method: "POST", headers: signed.headers, body: signed.body }, { responseLabel: `AWS EC2 ${action}`, bodyDisposition: () => "read" }, @@ -514,15 +466,15 @@ export class AwsProvider implements VmProvider, VolumeProvider { * has to be read back. Hetzner returns it inline; this is the cost of the * EC2 launch flow, not an extra feature. */ private async waitForPublicIp(id: string): Promise { - const deadline = this.now() + POLL_TIMEOUT_MS; + const deadline = Date.now() + POLL_TIMEOUT_MS; for (;;) { const instance = await this.describeInstance(id); if (instance !== null && instance.publicIp !== "") return instance.publicIp; - const remaining = deadline - this.now(); + const remaining = deadline - Date.now(); if (remaining <= 0) { throw new Error(`AWS instance ${id} did not receive a public IPv4 address`); } - await this.sleep(Math.min(POLL_INTERVAL_MS, remaining)); + await sleep(Math.min(POLL_INTERVAL_MS, remaining)); } } @@ -540,14 +492,14 @@ export class AwsProvider implements VmProvider, VolumeProvider { validated(id, VM_ID_PATTERN, "AWS instance id"); const stopping = await this.ec2("StopInstances", [["InstanceId.1", id]], INSTANCE_GONE_CODES); if (stopping === null) return; - const deadline = this.now() + POLL_TIMEOUT_MS; - while (this.now() < deadline) { + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { const instance = await this.describeInstance(id); if (instance === null) return; if (instance.state === "stopped" || instance.state === "terminated") return; - const remaining = deadline - this.now(); + const remaining = deadline - Date.now(); if (remaining <= 0) return; - await this.sleep(Math.min(POLL_INTERVAL_MS, remaining)); + await sleep(Math.min(POLL_INTERVAL_MS, remaining)); } } @@ -573,79 +525,4 @@ export class AwsProvider implements VmProvider, VolumeProvider { state: instance.state === "running" ? "running" : "stopped", }; } - - async createVolume(input: CreateVolumeRequest): Promise { - if (!input.location.startsWith(this.config.region)) { - throw new Error( - `volume location ${input.location} must be an availability zone in ${this.config.region}`, - ); - } - const document = await this.required("CreateVolume", [ - ["AvailabilityZone", input.location], - ["Size", String(input.sizeGb)], - ["VolumeType", "gp3"], - ["TagSpecification.1.ResourceType", "volume"], - ["TagSpecification.1.Tag.1.Key", "Name"], - ["TagSpecification.1.Tag.1.Value", input.name], - ["TagSpecification.1.Tag.2.Key", PURPOSE_TAG], - ["TagSpecification.1.Tag.2.Value", PURPOSE_VALUE], - ]); - return volumeFromEc2(document); - } - - /** EBS has no automount: the guest sees a raw block device at - * `/dev/sdf` (`/dev/nvme1n1` on Nitro) and the box image, which relies on - * Hetzner's `automount`, does not format or mount it. */ - async attachVolume(volumeId: string, vmId: string): Promise { - validated(volumeId, VOLUME_ID_PATTERN, "AWS volume id"); - validated(vmId, VM_ID_PATTERN, "AWS instance id"); - await this.required("AttachVolume", [ - ["VolumeId", volumeId], - ["InstanceId", vmId], - ["Device", ATTACH_DEVICE_NAME], - ]); - } - - async detachVolume(volumeId: string, vmId: string): Promise { - validated(volumeId, VOLUME_ID_PATTERN, "AWS volume id"); - const document = await this.ec2( - "DescribeVolumes", - [["VolumeId.1", volumeId]], - VOLUME_GONE_CODES, - ); - if (document === null) return; - const element = setItems(document, "volumeSet")[0]; - if (element === undefined) return; - if (volumeFromEc2(element).attachedTo !== vmId) return; - await this.ec2( - "DetachVolume", - [["VolumeId", volumeId], ["InstanceId", vmId]], - VOLUME_GONE_CODES, - ); - } - - async deleteVolume(id: string): Promise { - validated(id, VOLUME_ID_PATTERN, "AWS volume id"); - await this.ec2("DeleteVolume", [["VolumeId", id]], VOLUME_GONE_CODES); - } - - /** Tag-scoped on purpose. Unlike the dedicated Hetzner project the README - * mandates, an AWS account routinely holds unrelated volumes, and none of - * them belong in this listing. */ - async listVolumes(): Promise { - const volumes: Volume[] = []; - let nextToken: string | null = null; - for (;;) { - const parameters: (readonly [string, string])[] = [ - ["Filter.1.Name", `tag:${PURPOSE_TAG}`], - ["Filter.1.Value.1", PURPOSE_VALUE], - ]; - if (nextToken !== null) parameters.push(["NextToken", nextToken]); - const page: XmlElement = await this.required("DescribeVolumes", parameters); - volumes.push(...setItems(page, "volumeSet").map(volumeFromEc2)); - const token = childText(page, "nextToken"); - if (token === null || token === "") return volumes; - nextToken = token; - } - } } diff --git a/packages/control-plane/core/compute/cloudflare-tunnels.ts b/packages/control-plane/core/compute/cloudflare-tunnels.ts index 0efe35a9..4b815921 100644 --- a/packages/control-plane/core/compute/cloudflare-tunnels.ts +++ b/packages/control-plane/core/compute/cloudflare-tunnels.ts @@ -1,5 +1,5 @@ import { isNumber, isRecord, isString } from "../http.js"; -import { fetchBoundedJson, type Fetcher, type JsonValue } from "./json-fetch.js"; +import { fetchBoundedJson, type JsonValue } from "./json-fetch.js"; const API = "https://api.cloudflare.com/client/v4"; const ERROR_MESSAGE_MAX_LENGTH = 300; @@ -8,7 +8,6 @@ export interface CloudflareTunnelsOptions { accountId: string; zoneId: string; apiToken: string; - fetcher?: Fetcher; } export interface CreatedTunnel { @@ -44,16 +43,14 @@ export class CloudflareTunnels { private readonly accountId: string; private readonly zoneId: string; private readonly apiToken: string; - private readonly fetcher: Fetcher; + // Non-empty identifiers are the env boundary's job: the only production + // constructor call sits behind workspaceTunnelsFromEnv, which returns + // undefined unless every variable is a non-empty string. constructor(options: CloudflareTunnelsOptions) { - if (options.accountId === "") throw new Error("Cloudflare account id is required"); - if (options.zoneId === "") throw new Error("Cloudflare zone id is required"); - if (options.apiToken === "") throw new Error("Cloudflare API token is required"); this.accountId = options.accountId; this.zoneId = options.zoneId; this.apiToken = options.apiToken; - this.fetcher = options.fetcher ?? fetch; } private async request( @@ -69,7 +66,7 @@ export class CloudflareTunnels { init.body = JSON.stringify(body); } const { response, body: parsed } = await fetchBoundedJson( - this.fetcher, + fetch, `${API}${path}`, init, { diff --git a/packages/control-plane/core/compute/hetzner.ts b/packages/control-plane/core/compute/hetzner.ts index 1e48cc5d..8454f1ab 100644 --- a/packages/control-plane/core/compute/hetzner.ts +++ b/packages/control-plane/core/compute/hetzner.ts @@ -108,34 +108,6 @@ function sleep(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } -function abortable( - promise: Promise, - signal: AbortSignal, - abortReason: Error, -): Promise { - if (signal.aborted) { - void promise.catch(() => {}); - return Promise.reject(abortReason); - } - return new Promise((resolve, reject) => { - const onAbort = () => { - signal.removeEventListener("abort", onAbort); - reject(abortReason); - }; - signal.addEventListener("abort", onAbort, { once: true }); - void promise.then( - (value) => { - signal.removeEventListener("abort", onAbort); - resolve(value); - }, - (cause: unknown) => { - signal.removeEventListener("abort", onAbort); - reject(cause); - }, - ); - }); -} - function records(value: unknown, field: string): Record[] { if (!isRecord(value) || !Array.isArray(value[field])) { throw new Error(`invalid Hetzner ${field} response`); @@ -398,24 +370,19 @@ export class HetznerProvider implements VmProvider, VolumeProvider { }, SHUTDOWN_TIMEOUT_MS); const { signal } = controller; + // Every request below carries the deadline signal, so an in-flight call + // rejects with deadlineError the moment the timer aborts; the sleeps are + // already capped to the remaining window. try { - const action = await abortable( - this.request( - `/servers/${encodedId}/actions/shutdown`, - { method: "POST", body: "{}", signal }, - true, - ), - signal, - deadlineError, + const action = await this.request( + `/servers/${encodedId}/actions/shutdown`, + { method: "POST", body: "{}", signal }, + true, ); if (action === null) return; while (this.now() < deadline) { - const value = await abortable( - this.request(`/servers/${encodedId}`, { signal }, true), - signal, - deadlineError, - ); + const value = await this.request(`/servers/${encodedId}`, { signal }, true); if (value === null) return; if (!isRecord(value) || !isRecord(value.server)) { throw new Error("invalid Hetzner server response"); @@ -423,11 +390,7 @@ export class HetznerProvider implements VmProvider, VolumeProvider { if (stringField(value.server, "status") === "off") return; const remaining = deadline - this.now(); if (remaining > 0) { - await abortable( - this.sleep(Math.min(SHUTDOWN_POLL_INTERVAL_MS, remaining)), - signal, - deadlineError, - ); + await this.sleep(Math.min(SHUTDOWN_POLL_INTERVAL_MS, remaining)); } } } catch (error) { diff --git a/packages/control-plane/core/compute/microvm-host-registry.ts b/packages/control-plane/core/compute/microvm-host-registry.ts index 4fc8800d..c4b9ce91 100644 --- a/packages/control-plane/core/compute/microvm-host-registry.ts +++ b/packages/control-plane/core/compute/microvm-host-registry.ts @@ -40,10 +40,9 @@ function normalizedRegisteredHostUrl(raw: unknown): string { } export async function syncStaticMicrovmHosts( - db: Db | undefined, + db: Db, hosts: ResolvedMicrovmHost[], ): Promise { - if (db === undefined) return; const now = Date.now(); const upserts = hosts.flatMap((host) => isDynamicMicrovmHost(host) @@ -65,7 +64,7 @@ export async function syncStaticMicrovmHosts( export async function prepareMicrovmHostRegistration( hostsByName: ReadonlyMap, - db: Db | undefined, + db: Db, name: string, providedToken: string | null, ): Promise<(rawUrl: unknown) => Promise> { @@ -82,9 +81,6 @@ export async function prepareMicrovmHostRegistration( } return async (rawUrl: unknown) => { const url = normalizedRegisteredHostUrl(rawUrl); - if (db === undefined) { - throw new Error("microVM host registration database is unavailable"); - } const [previousRows] = await transaction(db, [ { q: "SELECT url, source FROM microvm_hosts WHERE name = ?1 LIMIT 1", @@ -127,13 +123,9 @@ function unavailableMicrovmHost(host: ResolvedMicrovmHost): HttpError { } export async function resolveMicrovmHost( - db: Db | undefined, + db: Db, host: ResolvedMicrovmHost, ): Promise { - if (db === undefined) { - if (isDynamicMicrovmHost(host)) throw unavailableMicrovmHost(host); - return { name: host.name, url: host.url, token: host.token }; - } const expectedSource = isDynamicMicrovmHost(host) ? "registered" : "static"; const row = await first(db, { q: `SELECT url, source FROM microvm_hosts diff --git a/packages/control-plane/core/compute/microvm.ts b/packages/control-plane/core/compute/microvm.ts index b0745d3d..965a5039 100644 --- a/packages/control-plane/core/compute/microvm.ts +++ b/packages/control-plane/core/compute/microvm.ts @@ -50,8 +50,7 @@ export { export { parseMicrovmHosts } from "./microvm-hosts.js"; export interface MicrovmPoolProviderOptions { - fetcher?: Fetcher; - db?: Db; + db: Db; } interface MachineHostSelection { @@ -68,14 +67,13 @@ export class MicrovmPoolProvider implements VmProvider { readonly id = "microvm"; private readonly hosts: ResolvedMicrovmHost[]; private readonly hostsByName: ReadonlyMap; - private readonly fetcher: Fetcher; - private readonly db: Db | undefined; + private readonly db: Db; private readonly agent: MicrovmAgentClient; constructor( rawHosts: unknown, resolveToken: (tokenVar: string) => unknown, - options: MicrovmPoolProviderOptions = {}, + options: MicrovmPoolProviderOptions, ) { this.hosts = parseMicrovmHosts(rawHosts).map((host) => { const token = resolveToken(host.tokenVar); @@ -89,11 +87,10 @@ export class MicrovmPoolProvider implements VmProvider { return { ...host, token }; }); this.hostsByName = new Map(this.hosts.map((host) => [host.name, host])); - this.fetcher = options.fetcher ?? fetch; this.db = options.db; this.agent = new MicrovmAgentClient( (host) => this.resolveHost(host), - this.fetcher, + fetch, ); } @@ -109,10 +106,6 @@ export class MicrovmPoolProvider implements VmProvider { return isMicrovmProviderId(vmId); } - owns(id: string): boolean { - return this.ownsVmId(id); - } - async syncStaticHosts(): Promise { await syncStaticMicrovmHosts(this.db, this.hosts); } @@ -124,15 +117,6 @@ export class MicrovmPoolProvider implements VmProvider { return prepareMicrovmHostRegistration(this.hostsByName, this.db, name, providedToken); } - async registerHost( - name: string, - providedToken: string | null, - rawUrl: unknown, - ): Promise { - const register = await this.prepareHostRegistration(name, providedToken); - await register(rawUrl); - } - private async resolveHost(host: ResolvedMicrovmHost): Promise { return resolveMicrovmHost(this.db, host); } @@ -267,8 +251,7 @@ export class MicrovmPoolProvider implements VmProvider { port: WebAppPort, pathAndQuery: string, request: Request, - ): Promise { - if (!this.owns(id)) return null; + ): Promise { const { host, agentVmId } = this.hostForProviderId(id); const activeHost = await this.resolveHost(host); const headers = new Headers(request.headers); @@ -276,7 +259,9 @@ export class MicrovmPoolProvider implements VmProvider { headers.delete("Host"); headers.set("Authorization", `Bearer ${activeHost.token}`); const hasBody = request.method !== "GET" && request.method !== "HEAD"; - const fetcher = this.fetcher; + // Raw streaming pass-through: bodies and responses are unbounded, so the + // bounded json-fetch helpers do not apply here. + const fetcher: Fetcher = fetch; return fetcher( `${activeHost.url}/vms/${encodeURIComponent(agentVmId)}/webapp/${port}${pathAndQuery}`, { diff --git a/packages/control-plane/core/compute/registry.ts b/packages/control-plane/core/compute/registry.ts index 05eed71c..b1dd12d9 100644 --- a/packages/control-plane/core/compute/registry.ts +++ b/packages/control-plane/core/compute/registry.ts @@ -12,23 +12,17 @@ export interface VmProviderListResult { export class VmProviderRegistry { private readonly providers: readonly VmProvider[]; - private readonly providersById: ReadonlyMap; constructor(providers: readonly VmProvider[]) { - const providersById = new Map(); + const seenIds = new Set(); for (const provider of providers) { if (provider.id === "") throw new Error("VM provider id must not be empty"); - if (providersById.has(provider.id)) { + if (seenIds.has(provider.id)) { throw new Error(`duplicate VM provider id: ${provider.id}`); } - providersById.set(provider.id, provider); + seenIds.add(provider.id); } this.providers = [...providers]; - this.providersById = providersById; - } - - get(id: string): VmProvider | undefined { - return this.providersById.get(id); } all(): readonly VmProvider[] { diff --git a/packages/control-plane/core/compute/types.ts b/packages/control-plane/core/compute/types.ts index fe0ca249..aa1b8ef7 100644 --- a/packages/control-plane/core/compute/types.ts +++ b/packages/control-plane/core/compute/types.ts @@ -7,7 +7,8 @@ export type ProviderMachineType = Omit< export interface ProviderCapabilities { volumes: boolean; - maxUserDataBytes?: number | null; + /** Raw user-data byte budget, or null when the provider takes no user data. */ + maxUserDataBytes: number | null; webAppActorBypassesGateway?: boolean; /** Epoch ms from which VMs this provider creates boot a guest that verifies * v1 webApp tickets. Guest channels version independently, so a workspace's @@ -55,7 +56,7 @@ export interface VmProvider { port: WebAppPort, pathAndQuery: string, request: Request, - ): Promise; + ): Promise; } export interface VolumeProvider { diff --git a/packages/control-plane/core/connections/canary.ts b/packages/control-plane/core/connections/canary.ts index 265dbcbe..22ad887e 100644 --- a/packages/control-plane/core/connections/canary.ts +++ b/packages/control-plane/core/connections/canary.ts @@ -39,7 +39,9 @@ export function evaluateProbe( body: string | null, ): ProbeOutcome { const expected = manifest.probe.expect; - if (status !== expected.status) { + // Every catalog probe is built to answer 200 when healthy; providers whose + // failure also answers 200 (Linear) declare jsonFields to tell them apart. + if (status !== 200) { return { healthy: false, detail: `status ${String(status)}` }; } if (expected.jsonFields.length === 0) return { healthy: true, detail: null }; diff --git a/packages/control-plane/core/connections/catalog/discord.ts b/packages/control-plane/core/connections/catalog/discord.ts index a291d895..7f095bb9 100644 --- a/packages/control-plane/core/connections/catalog/discord.ts +++ b/packages/control-plane/core/connections/catalog/discord.ts @@ -1,56 +1,4 @@ -import type { SkillRenderInput, StaticProviderManifest } from "./types.js"; - -function skill(input: SkillRenderInput): string { - const base = input.baseUrlEnv === null ? input.baseUrl : `$${input.baseUrlEnv}`; - const header = `${input.tokenHeader.name}: ${input.tokenHeader.prefix}$${input.tokenEnv}`; - return `--- -name: ${input.connection} -description: Act as the organization's Discord bot through the REST API and the gateway. ---- - -# ${input.connection} - -This workspace holds the organization's Discord bot token, configured once by -an admin. Everything the agent does renders as the bot in Discord. - -## Auth - -Send \`${header}\` on every REST call — the prefix is \`Bot\`, not \`Bearer\`. - -${input.mode === "proxy" - ? `\`$${input.tokenEnv}\` is a lease token that only works against \`${base}\`; the control plane swaps in the real bot token on the way out.` - : `\`$${input.tokenEnv}\` is the bot token itself, so libraries that open the gateway websocket (discord.js, discord.py) work with it directly. Do not echo it, and do not send it anywhere but discord.com.`} - -## Canonical calls - -\`\`\`sh -# Which bot am I -curl -sS -H '${header}' "${base}/users/@me" - -# Guilds the bot is installed in -curl -sS -H '${header}' "${base}/users/@me/guilds" - -# Send a message to a channel -curl -sS -X POST -H '${header}' -H 'Content-Type: application/json' \\ - "${base}/channels/{channel.id}/messages" \\ - -d '{"content":"..."}' -\`\`\` - -## Reach and limits - -- Scopes recorded for this connection: ${input.scopes.length === 0 ? "none — the bot's Discord-side permissions and installed guilds are the boundary" : input.scopes.join(", ")}. -- Receiving events in real time needs the gateway websocket - (\`wss://gateway.discord.gg\`), which REST alone does not cover. -- Rate limits are per-route buckets; honour \`Retry-After\` on a 429 instead - of hammering. - -## When a call returns 401 - -The lease expired. Start a new login shell (or run \`blitz-cred sync\`) and -retry once. If it still fails, the admin's bot token was revoked or reset in -the Discord developer portal — say so instead of retrying. -`; -} +import type { StaticProviderManifest } from "./types.js"; /** Admin-configured only: a Discord bot token belongs to the organization's * bot application, not to any member, so there is no per-member connect step — @@ -69,8 +17,6 @@ export const discordManifest = { summary: "Send and read messages as your organization's Discord bot.", docsUrl: "https://discord.com/developers/docs/reference#authentication", custody: "cp", - // Bot tokens live until an admin resets them in the developer portal. - rotation: "none", // The header quirk this manifest field exists for: bots authenticate with // `Bot `, and `Bearer` is rejected. tokenHeader: { name: "Authorization", prefix: "Bot " }, @@ -87,7 +33,10 @@ export const discordManifest = { defaultScopes: [], surfaces: { env: [{ name: "DISCORD_BOT_TOKEN", fill: "token" }], - skill: { path: ".claude/skills//SKILL.md", render: skill }, + // No grant path (no OAuth, no pasted personal token) means no mint ever + // renders a skill: the admin root mints through the connection row's own + // placements in the static minter. + skill: null, }, probe: { request: (input) => ({ @@ -98,21 +47,6 @@ export const discordManifest = { ], body: null, }), - expect: { status: 200, jsonFields: ["id"] }, + expect: { jsonFields: ["id"] }, }, - probeFixtures: [ - { - name: "the bot's own user", - status: 200, - response: '{"id":"1029384756000000000","username":"blitz-canary","bot":true}', - healthy: true, - }, - { - name: "reset bot token", - status: 401, - response: '{"message":"401: Unauthorized","code":0}', - healthy: false, - }, - ], - fixtures: [], } satisfies StaticProviderManifest; diff --git a/packages/control-plane/core/connections/catalog/generic.ts b/packages/control-plane/core/connections/catalog/generic.ts index caf4ff81..e81f6dd6 100644 --- a/packages/control-plane/core/connections/catalog/generic.ts +++ b/packages/control-plane/core/connections/catalog/generic.ts @@ -41,7 +41,6 @@ export const genericManifest = { summary: "Any vendor with a static key: you name the variable and, for proxy custody, the base URL.", docsUrl: "https://github.com/blitzdotdev/blitzos", custody: "cp", - rotation: "none", tokenHeader: { name: "Authorization", prefix: "Bearer " }, baseUrl: "https://example.invalid", auth: null, @@ -65,7 +64,7 @@ export const genericManifest = { { name: "SERVICE_API_KEY", fill: "token" }, { name: "SERVICE_BASE_URL", fill: "proxy-url" }, ], - skill: { path: ".claude/skills//SKILL.md", render: skill }, + skill, }, probe: { request: (input) => ({ @@ -78,11 +77,6 @@ export const genericManifest = { }), // A generic vendor promises no response shape, so reachability under the // credential is the whole contract. - expect: { status: 200, jsonFields: [] }, + expect: { jsonFields: [] }, }, - probeFixtures: [ - { name: "reachable", status: 200, response: "{}", healthy: true }, - { name: "rejected key", status: 401, response: '{"error":"unauthorized"}', healthy: false }, - ], - fixtures: [], } satisfies StaticProviderManifest; diff --git a/packages/control-plane/core/connections/catalog/github.ts b/packages/control-plane/core/connections/catalog/github.ts index 23e2a5b5..693fa99d 100644 --- a/packages/control-plane/core/connections/catalog/github.ts +++ b/packages/control-plane/core/connections/catalog/github.ts @@ -58,9 +58,6 @@ export const githubManifest = { summary: "Repos, pull requests, and issues as you, through a GitHub App user token.", docsUrl: "https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app", custody: "cp", - // Refresh tokens are strictly single-use: every refresh re-issues both - // tokens and kills the old pair, so refreshes serialize per grant. - rotation: "strict", tokenHeader: { name: "Authorization", prefix: "Bearer " }, baseUrl: "https://api.github.com", auth: { @@ -68,11 +65,9 @@ export const githubManifest = { tokenUrl: "https://github.com/login/oauth/access_token", clientIdVar: "GITHUB_APP_CLIENT_ID", clientSecretVar: "GITHUB_APP_CLIENT_SECRET", - pkce: true, authorizeParams: [], scopeDelimiter: " ", accessTtlMs: 8 * HOUR_MS, - redirectPath: "/connect/github/callback", }, personalToken: { label: "Fine-grained personal access token", @@ -110,7 +105,7 @@ export const githubManifest = { { name: "GITHUB_TOKEN", fill: "token" }, { name: "GITHUB_PERSONAL_ACCESS_TOKEN", fill: "token" }, ], - skill: { path: ".claude/skills//SKILL.md", render: skill }, + skill, }, probe: { request: (input) => ({ @@ -123,68 +118,6 @@ export const githubManifest = { ], body: null, }), - expect: { status: 200, jsonFields: ["login"] }, + expect: { jsonFields: ["login"] }, }, - probeFixtures: [ - { - name: "authenticated user", - status: 200, - response: '{"login":"blitz-canary","id":4242,"type":"User"}', - healthy: true, - }, - { - name: "expired user token", - status: 401, - response: '{"message":"Bad credentials","status":"401"}', - healthy: false, - }, - ], - fixtures: [ - { - name: "authorization code exchange", - grantType: "authorization_code", - request: [ - { name: "grant_type", value: "authorization_code" }, - { name: "code", value: "recorded-authorization-code" }, - { name: "client_id", value: "recorded-client-id" }, - { name: "client_secret", value: "recorded-client-secret" }, - { name: "redirect_uri", value: "https://cp.example/connect/github/callback" }, - { name: "code_verifier", value: "recorded-code-verifier" }, - ], - response: '{"access_token":"ghu_recorded_first","expires_in":28800,"refresh_token":"ghr_recorded_first","refresh_token_expires_in":15811200,"scope":"","token_type":"bearer"}', - expect: { - accessToken: "ghu_recorded_first", - refreshToken: "ghr_recorded_first", - expiresInMs: 28_800_000, - }, - }, - { - name: "single-use refresh rotation", - grantType: "refresh_token", - request: [ - { name: "grant_type", value: "refresh_token" }, - { name: "refresh_token", value: "ghr_recorded_first" }, - { name: "client_id", value: "recorded-client-id" }, - { name: "client_secret", value: "recorded-client-secret" }, - ], - response: '{"access_token":"ghu_recorded_second","expires_in":28800,"refresh_token":"ghr_recorded_second","refresh_token_expires_in":15811200,"scope":"","token_type":"bearer"}', - expect: { - accessToken: "ghu_recorded_second", - refreshToken: "ghr_recorded_second", - expiresInMs: 28_800_000, - }, - }, - { - name: "replayed single-use refresh", - grantType: "refresh_token", - request: [ - { name: "grant_type", value: "refresh_token" }, - { name: "refresh_token", value: "ghr_recorded_first" }, - { name: "client_id", value: "recorded-client-id" }, - { name: "client_secret", value: "recorded-client-secret" }, - ], - response: '{"error":"bad_refresh_token","error_description":"The refresh token passed is incorrect or expired."}', - expect: null, - }, - ], } satisfies OAuthProviderManifest; diff --git a/packages/control-plane/core/connections/catalog/google-workspace.ts b/packages/control-plane/core/connections/catalog/google-workspace.ts index 7200d795..283af9b8 100644 --- a/packages/control-plane/core/connections/catalog/google-workspace.ts +++ b/packages/control-plane/core/connections/catalog/google-workspace.ts @@ -62,8 +62,6 @@ export const googleWorkspaceManifest = { summary: "Drive file handoff, calendar scheduling, and outbound mail as you.", docsUrl: "https://developers.google.com/identity/protocols/oauth2/web-server", custody: "proxy", - // One refresh token mints unlimited parallel access tokens; nothing rotates. - rotation: "none", tokenHeader: { name: "Authorization", prefix: "Bearer " }, baseUrl: "https://www.googleapis.com", auth: { @@ -71,7 +69,6 @@ export const googleWorkspaceManifest = { tokenUrl: "https://oauth2.googleapis.com/token", clientIdVar: "GOOGLE_CONNECT_CLIENT_ID", clientSecretVar: "GOOGLE_CONNECT_CLIENT_SECRET", - pkce: true, // Without both of these Google returns no refresh token on re-consent, // and the grant silently becomes a one-hour connection. authorizeParams: [ @@ -81,7 +78,6 @@ export const googleWorkspaceManifest = { ], scopeDelimiter: " ", accessTtlMs: HOUR_MS, - redirectPath: "/connect/google-workspace/callback", }, // Google issues no user-createable API key for Workspace APIs. personalToken: null, @@ -113,7 +109,7 @@ export const googleWorkspaceManifest = { { name: "GOOGLE_OAUTH_TOKEN", fill: "token" }, { name: "GOOGLE_API_BASE_URL", fill: "proxy-url" }, ], - skill: { path: ".claude/skills//SKILL.md", render: skill }, + skill, }, probe: { request: (input) => ({ @@ -124,69 +120,6 @@ export const googleWorkspaceManifest = { ], body: null, }), - expect: { status: 200, jsonFields: ["user.emailAddress"] }, + expect: { jsonFields: ["user.emailAddress"] }, }, - probeFixtures: [ - { - name: "drive about", - status: 200, - response: '{"user":{"kind":"drive#user","emailAddress":"canary@example.com"}}', - healthy: true, - }, - { - name: "revoked grant", - status: 401, - response: '{"error":{"code":401,"message":"Invalid Credentials","status":"UNAUTHENTICATED"}}', - healthy: false, - }, - ], - fixtures: [ - { - name: "authorization code exchange", - grantType: "authorization_code", - request: [ - { name: "grant_type", value: "authorization_code" }, - { name: "code", value: "recorded-authorization-code" }, - { name: "client_id", value: "recorded-client-id" }, - { name: "client_secret", value: "recorded-client-secret" }, - { name: "redirect_uri", value: "https://cp.example/connect/google-workspace/callback" }, - { name: "code_verifier", value: "recorded-code-verifier" }, - ], - response: '{"access_token":"ya29.recorded-first","expires_in":3599,"refresh_token":"1//recorded-refresh","scope":"https://www.googleapis.com/auth/drive.file","token_type":"Bearer"}', - expect: { - accessToken: "ya29.recorded-first", - refreshToken: "1//recorded-refresh", - expiresInMs: 3_599_000, - }, - }, - { - name: "refresh keeps the same refresh token", - grantType: "refresh_token", - request: [ - { name: "grant_type", value: "refresh_token" }, - { name: "refresh_token", value: "1//recorded-refresh" }, - { name: "client_id", value: "recorded-client-id" }, - { name: "client_secret", value: "recorded-client-secret" }, - ], - // No refresh_token field: Google never rotates, so the stored one stands. - response: '{"access_token":"ya29.recorded-second","expires_in":3599,"scope":"https://www.googleapis.com/auth/drive.file","token_type":"Bearer"}', - expect: { - accessToken: "ya29.recorded-second", - refreshToken: null, - expiresInMs: 3_599_000, - }, - }, - { - name: "refresh token revoked at the account", - grantType: "refresh_token", - request: [ - { name: "grant_type", value: "refresh_token" }, - { name: "refresh_token", value: "1//recorded-refresh" }, - { name: "client_id", value: "recorded-client-id" }, - { name: "client_secret", value: "recorded-client-secret" }, - ], - response: '{"error":"invalid_grant","error_description":"Token has been expired or revoked."}', - expect: null, - }, - ], } satisfies OAuthProviderManifest; diff --git a/packages/control-plane/core/connections/catalog/index.ts b/packages/control-plane/core/connections/catalog/index.ts index b8c97dc4..c71f4156 100644 --- a/packages/control-plane/core/connections/catalog/index.ts +++ b/packages/control-plane/core/connections/catalog/index.ts @@ -82,7 +82,6 @@ export function catalogView( summary: manifest.summary, docsUrl: manifest.docsUrl, custody: manifest.custody, - rotation: manifest.rotation, oauthAvailable: auth !== null, oauthConfigured: configured, personalTokenLabel: manifest.personalToken?.label ?? null, diff --git a/packages/control-plane/core/connections/catalog/linear.ts b/packages/control-plane/core/connections/catalog/linear.ts index c9eccc62..56a5c762 100644 --- a/packages/control-plane/core/connections/catalog/linear.ts +++ b/packages/control-plane/core/connections/catalog/linear.ts @@ -65,9 +65,6 @@ export const linearManifest = { summary: "Issues, projects, and comments through Linear's single GraphQL endpoint.", docsUrl: "https://developers.linear.app/docs/oauth/authentication", custody: "proxy", - // Rotating refresh with a 30-minute replay grace: a lost race survives, so - // refreshes need no cross-request lock, only a compare-and-set. - rotation: "graceful", tokenHeader: { name: "Authorization", prefix: "Bearer " }, baseUrl: "https://api.linear.app", auth: { @@ -75,13 +72,11 @@ export const linearManifest = { tokenUrl: "https://api.linear.app/oauth/token", clientIdVar: "LINEAR_CLIENT_ID", clientSecretVar: "LINEAR_CLIENT_SECRET", - pkce: true, // actor=user keeps attribution on the human. actor=app is the agent-identity // path, parked with installation tokens. authorizeParams: [{ name: "actor", value: "user" }], scopeDelimiter: ",", accessTtlMs: 24 * HOUR_MS, - redirectPath: "/connect/linear/callback", }, personalToken: { label: "Personal API key", @@ -105,7 +100,7 @@ export const linearManifest = { { name: "LINEAR_API_KEY", fill: "token" }, { name: "LINEAR_API_URL", fill: "proxy-url" }, ], - skill: { path: ".claude/skills//SKILL.md", render: skill }, + skill, }, probe: { request: (input) => ({ @@ -117,68 +112,8 @@ export const linearManifest = { ], body: '{"query":"{ viewer { id name } }"}', }), - expect: { status: 200, jsonFields: ["data.viewer.id"] }, + // Linear answers failures as 200 with an errors array, so the healthy + // field is the whole signal. + expect: { jsonFields: ["data.viewer.id"] }, }, - probeFixtures: [ - { - name: "viewer", - status: 200, - response: '{"data":{"viewer":{"id":"6e5c1f7a-0000-4000-8000-000000000000","name":"Blitz Canary"}}}', - healthy: true, - }, - { - name: "authenticated 200 carrying an error array", - status: 200, - response: '{"errors":[{"message":"Authentication required, not authenticated"}]}', - healthy: false, - }, - ], - fixtures: [ - { - name: "authorization code exchange", - grantType: "authorization_code", - request: [ - { name: "grant_type", value: "authorization_code" }, - { name: "code", value: "recorded-authorization-code" }, - { name: "client_id", value: "recorded-client-id" }, - { name: "client_secret", value: "recorded-client-secret" }, - { name: "redirect_uri", value: "https://cp.example/connect/linear/callback" }, - { name: "code_verifier", value: "recorded-code-verifier" }, - ], - response: '{"access_token":"lin_oauth_recorded_first","token_type":"Bearer","expires_in":86400,"refresh_token":"lin_refresh_recorded_first","scope":"read,write"}', - expect: { - accessToken: "lin_oauth_recorded_first", - refreshToken: "lin_refresh_recorded_first", - expiresInMs: 86_400_000, - }, - }, - { - name: "rotating refresh inside the grace window", - grantType: "refresh_token", - request: [ - { name: "grant_type", value: "refresh_token" }, - { name: "refresh_token", value: "lin_refresh_recorded_first" }, - { name: "client_id", value: "recorded-client-id" }, - { name: "client_secret", value: "recorded-client-secret" }, - ], - response: '{"access_token":"lin_oauth_recorded_second","token_type":"Bearer","expires_in":86400,"refresh_token":"lin_refresh_recorded_second","scope":"read,write"}', - expect: { - accessToken: "lin_oauth_recorded_second", - refreshToken: "lin_refresh_recorded_second", - expiresInMs: 86_400_000, - }, - }, - { - name: "refresh past the grace window", - grantType: "refresh_token", - request: [ - { name: "grant_type", value: "refresh_token" }, - { name: "refresh_token", value: "lin_refresh_recorded_first" }, - { name: "client_id", value: "recorded-client-id" }, - { name: "client_secret", value: "recorded-client-secret" }, - ], - response: '{"error":"invalid_grant","error_description":"Refresh token is invalid or expired"}', - expect: null, - }, - ], } satisfies OAuthProviderManifest; diff --git a/packages/control-plane/core/connections/catalog/surfaces.ts b/packages/control-plane/core/connections/catalog/surfaces.ts index ec216218..f858fba2 100644 --- a/packages/control-plane/core/connections/catalog/surfaces.ts +++ b/packages/control-plane/core/connections/catalog/surfaces.ts @@ -32,8 +32,10 @@ function baseUrlFor(input: SurfaceInput, manifest: ProviderManifest): string { return input.overrides?.baseUrl ?? manifest.baseUrl; } -export function skillPath(manifest: ProviderManifest, connection: string): string { - return `${BOX_HOME}/${manifest.surfaces.skill.path.replace("", connection)}`; +/** Every provider's skill lands at the one place harnesses resolve + * "use @" from, so the path is derived, never declared. */ +export function skillPath(connection: string): string { + return `${BOX_HOME}/.claude/skills/${connection}/SKILL.md`; } /** Everything a lease delivers beyond the raw credential, compiled into the @@ -53,12 +55,14 @@ export function compileSurfaces( value: surface.fill === "proxy-url" ? baseUrl : input.token, }); } + const skill = manifest.surfaces.skill; + if (skill === null) return placements; const tokenEnv = surfaces.find((surface) => surface.fill === "token"); const baseUrlEnv = surfaces.find((surface) => surface.fill === "proxy-url"); placements.push({ kind: "file", - path: skillPath(manifest, input.connection), - value: manifest.surfaces.skill.render({ + path: skillPath(input.connection), + value: skill({ connection: input.connection, scopes: input.scopes, mode: input.mode, @@ -77,7 +81,6 @@ export function compileSurfaces( * environment names are unset. A stale skill for a dead connection would * gaslight the agent into retrying a credential that no longer exists. */ export function tombstoneSurfaces( - manifest: ProviderManifest, connection: string, environmentNames: readonly string[], ): Placement[] { @@ -87,7 +90,7 @@ export function tombstoneSurfaces( } placements.push({ kind: "file", - path: skillPath(manifest, connection), + path: skillPath(connection), value: "", mode: SKILL_MODE, }); diff --git a/packages/control-plane/core/connections/catalog/types.ts b/packages/control-plane/core/connections/catalog/types.ts index ed0704ab..287f406a 100644 --- a/packages/control-plane/core/connections/catalog/types.ts +++ b/packages/control-plane/core/connections/catalog/types.ts @@ -1,13 +1,5 @@ import type { Custody, Placement } from "../types.js"; -/** How a provider treats the refresh token it hands back. - * - `strict` single-use: every refresh invalidates the previous token, so - * refreshes must serialize per grant (GitHub). - * - `graceful` rotating with a replay window, so a lost race is survivable - * (Linear, 30 minutes). - * - `none` one refresh token mints access tokens forever (Google). */ -export type RotationMode = "strict" | "graceful" | "none"; - /** Bearer material is not always a Bearer: Linear personal API keys go in a * raw `Authorization: ` header, so the prefix belongs to the provider. */ export interface TokenHeader { @@ -28,6 +20,9 @@ export interface ProviderScope { detail: string; } +/** Every catalog OAuth flow runs PKCE, and the registered redirect URI is + * always `connectRedirectPath(manifest.id)` — both are facts of the connect + * routes, not per-provider choices, so neither is declared here. */ export interface ProviderAuth { authorizeUrl: string; tokenUrl: string; @@ -35,7 +30,6 @@ export interface ProviderAuth { * fails /connect with a message instead of redirecting into a broken flow. */ clientIdVar: string; clientSecretVar: string; - pkce: boolean; /** Sent verbatim on the authorize redirect (Google offline consent, Linear actor). */ authorizeParams: readonly ProviderParam[]; /** Documented access-token lifetime, used when an exchange omits expires_in. */ @@ -43,8 +37,6 @@ export interface ProviderAuth { /** How the provider joins requested scopes. Linear wants commas, the rest * want spaces, and getting it wrong reads as "invalid scope". */ scopeDelimiter: " " | ","; - /** Redirect URI path registered with the provider, for the ops runbook. */ - redirectPath: string; } /** The day-one path: a key the person creates in the provider's own UI. */ @@ -66,14 +58,6 @@ export interface ProviderEnvSurface { fill: PlacementFill; } -/** Rendered into the lease as a `file` placement. A skill named `` - * is what makes "use @" resolve in any harness that reads skills. */ -export interface ProviderSkillSurface { - /** Relative to the box HOME; the compiler makes it absolute. */ - path: string; - render(input: SkillRenderInput): string; -} - export interface SkillRenderInput { connection: string; scopes: readonly string[]; @@ -88,7 +72,13 @@ export interface SkillRenderInput { export interface ProviderSurfaces { env: readonly ProviderEnvSurface[]; - skill: ProviderSkillSurface; + /** Rendered into the lease as a `file` placement at `skillPath(connection)`. + * A skill named `` is what makes "use @" resolve in any + * harness that reads skills. Null exactly for admin-only providers (no + * OAuth, no personal token): they cannot back a grant, and grant mints are + * the only path that renders a skill — the static org-root minter fills the + * connection row's own placements instead. */ + skill: ((input: SkillRenderInput) => string) | null; } /** Declares the org-admin path: an admin stores one static root through @@ -126,11 +116,10 @@ export interface ProbeRequest { body: string | null; } -/** The shape a healthy answer has. `jsonFields` are dotted paths that must - * resolve to a non-empty string; an empty list means the status is the whole - * contract. */ +/** The shape a healthy answer has beyond the 200 every probe requires. + * `jsonFields` are dotted paths that must resolve to a non-empty string; an + * empty list means the status is the whole contract. */ export interface ProbeExpectation { - status: number; jsonFields: readonly string[]; } @@ -139,39 +128,12 @@ export interface ProviderProbe { expect: ProbeExpectation; } -export interface ProbeFixture { - name: string; - status: number; - response: string; - healthy: boolean; -} - -export interface ExchangeExpectation { - accessToken: string; - refreshToken: string | null; - expiresInMs: number; -} - -/** A recorded provider answer. The generic exchange is only ever proven - * against these, never against a live provider, so they are mandatory. */ -export interface ExchangeFixture { - name: string; - grantType: "authorization_code" | "refresh_token"; - /** Form fields the exchange must send, asserted field by field. */ - request: readonly ProviderParam[]; - response: string; - /** `null` records a rejection the exchange must surface as an error — - * a replayed single-use refresh is a fixture, not a hypothetical. */ - expect: ExchangeExpectation | null; -} - interface ProviderManifestBase { id: string; title: string; summary: string; docsUrl: string; custody: Custody; - rotation: RotationMode; /** Header shape for OAuth-issued tokens. */ tokenHeader: TokenHeader; /** Vendor API root; proxy custody rewrites box calls onto it. */ @@ -183,18 +145,18 @@ interface ProviderManifestBase { defaultScopes: readonly string[]; surfaces: ProviderSurfaces; probe: ProviderProbe; - probeFixtures: readonly [ProbeFixture, ...ProbeFixture[]]; } +/** Recorded provider answers proving each probe and exchange live in test + * land (test/connections-catalog-fixtures.ts), keyed by manifest id; the + * conformance suite refuses a manifest that has none. */ export interface OAuthProviderManifest extends ProviderManifestBase { auth: ProviderAuth; - fixtures: readonly [ExchangeFixture, ...ExchangeFixture[]]; } /** No authorize endpoint: the credential is pasted, not redirected for. */ export interface StaticProviderManifest extends ProviderManifestBase { auth: null; - fixtures: readonly []; } export type ProviderManifest = OAuthProviderManifest | StaticProviderManifest; diff --git a/packages/control-plane/core/connections/catalog/youtrack.ts b/packages/control-plane/core/connections/catalog/youtrack.ts index 6cb23c22..8dffc7b4 100644 --- a/packages/control-plane/core/connections/catalog/youtrack.ts +++ b/packages/control-plane/core/connections/catalog/youtrack.ts @@ -75,9 +75,6 @@ export const youtrackManifest = { summary: "Issues, comments, and work items on your organization's YouTrack instance.", docsUrl: "https://www.jetbrains.com/help/youtrack/devportal/api-authentication.html", custody: "proxy", - // A permanent token neither expires nor rotates; the member revokes it in - // YouTrack's own UI and re-pastes to replace it here. - rotation: "none", tokenHeader: { name: "Authorization", prefix: "Bearer " }, // Placeholder, like the generic entry's: every YouTrack instance has its // own URL. The real one lives on the grant (`vendor.baseUrl`) or on the org @@ -99,7 +96,7 @@ export const youtrackManifest = { { name: "YOUTRACK_TOKEN", fill: "token" }, { name: "YOUTRACK_BASE_URL", fill: "proxy-url" }, ], - skill: { path: ".claude/skills//SKILL.md", render: skill }, + skill, }, probe: { // fields= is mandatory: without it YouTrack answers with ids only and the @@ -113,21 +110,6 @@ export const youtrackManifest = { ], body: null, }), - expect: { status: 200, jsonFields: ["login"] }, + expect: { jsonFields: ["login"] }, }, - probeFixtures: [ - { - name: "token's own user", - status: 200, - response: '{"id":"1-1","login":"blitz-canary","name":"Blitz Canary","$type":"Me"}', - healthy: true, - }, - { - name: "revoked permanent token", - status: 401, - response: '{"error":"Unauthorized","error_description":"Cannot find user by authentication data"}', - healthy: false, - }, - ], - fixtures: [], } satisfies StaticProviderManifest; diff --git a/packages/control-plane/core/connections/connect.ts b/packages/control-plane/core/connections/connect.ts index 07d35172..03bde1ae 100644 --- a/packages/control-plane/core/connections/connect.ts +++ b/packages/control-plane/core/connections/connect.ts @@ -21,6 +21,13 @@ import type { GrantConfig } from "./user-grants.js"; * reads the query and says what happened. */ const CONNECT_RETURN_PATH = "/settings/connections"; +/** The redirect URI path registered with every provider — one derivation, + * beside the `/connect/:provider/callback` route that serves it, so a + * manifest cannot drift from the callback it lands on. */ +export function connectRedirectPath(providerId: string): string { + return `/connect/${providerId}/callback`; +} + /** Turns the grant this round trip just stored into a lease in the workspace it * started from. Injected by the mint module, which owns every path that writes * a lease. Answers false when the workspace refuses — the grant still stands. */ @@ -146,15 +153,15 @@ export function addConnectRoutes( const authorize = new URL(manifest.auth.authorizeUrl); const parameters = new URLSearchParams({ client_id: clientId, - redirect_uri: `${origin}${manifest.auth.redirectPath}`, + redirect_uri: `${origin}${connectRedirectPath(manifest.id)}`, response_type: "code", scope: manifest.defaultScopes.join(manifest.auth.scopeDelimiter), state: oauth.state, + // Every catalog flow runs PKCE; the paired verifier rides the signed + // state cookie and the exchange always sends it. + code_challenge: oauth.codeChallenge, + code_challenge_method: "S256", }); - if (manifest.auth.pkce) { - parameters.set("code_challenge", oauth.codeChallenge); - parameters.set("code_challenge_method", "S256"); - } for (const parameter of manifest.auth.authorizeParams) { parameters.set(parameter.name, parameter.value); } @@ -203,7 +210,7 @@ export function addConnectRoutes( manifest, clientId, clientSecret, - redirectUri: `${origin}${manifest.auth.redirectPath}`, + redirectUri: `${origin}${connectRedirectPath(manifest.id)}`, grantType: "authorization_code", code, codeVerifier: state.codeVerifier, diff --git a/packages/control-plane/core/connections/leases.ts b/packages/control-plane/core/connections/leases.ts index 8b0f8835..89580b05 100644 --- a/packages/control-plane/core/connections/leases.ts +++ b/packages/control-plane/core/connections/leases.ts @@ -1,8 +1,7 @@ import type { Db, Query } from "../db.js"; -import { changed, first, rows, transaction } from "../db.js"; +import { first, rows, transaction } from "../db.js"; import { HttpError, isNumber, isRecord, isString, type JsonValue } from "../http.js"; import type { Principal } from "../principals.js"; -import type { CoreRuntime } from "../runtime.js"; import { scopesFromJson } from "./manifest.js"; import type { Lease, MintResult } from "./types.js"; import { canControlWorkspace } from "../workspace-access.js"; @@ -18,7 +17,7 @@ interface LeaseRow { mode: "inject" | "proxy"; issued_at: number; expires_at: number; - state: Lease["state"]; + revoked_at: number | null; owner_id: string; org_id: string | null; owner_membership_id: string | null; @@ -39,6 +38,9 @@ interface CreateLeaseInput { grantId: string | null; scopes: string[]; result: MintResult; + /** Non-null exactly when `result.mode` is "proxy": the caller destructures + * this pair out of a `MinterResult`, whose union ties the hash to proxy + * mode, so the pairing holds by construction rather than by re-check here. */ tokenHash: string | null; now: number; principal: Principal; @@ -60,7 +62,20 @@ export interface CredentialEventView { createdAt: number; } -function leaseView(row: LeaseRow): Lease { +/** The state a reader sees. Storage keeps only what a writer decided — + * revoked_at at revocation — and expiry is a fact about expires_at, derived + * here instead of swept into the row. Revoked wins over expired, exactly as + * the old sweep — which only flipped active rows — left revoked labels alone + * as they aged. */ +function leaseState( + row: Pick, + now: number, +): Lease["state"] { + if (row.revoked_at !== null) return "revoked"; + return row.expires_at <= now ? "expired" : "active"; +} + +function leaseView(row: LeaseRow, now: number): Lease { return { id: row.id, workspaceId: row.workspace_id, @@ -71,7 +86,7 @@ function leaseView(row: LeaseRow): Lease { mode: row.mode, issuedAt: row.issued_at, expiresAt: row.expires_at, - state: row.state, + state: leaseState(row, now), }; } @@ -80,15 +95,12 @@ export async function createLease( input: CreateLeaseInput, ): Promise { const scopes = JSON.stringify(input.scopes); - if ((input.result.mode === "proxy") !== (input.tokenHash !== null)) { - throw new Error("proxy leases require a token hash"); - } await transaction(db, [ { q: `INSERT INTO credential_leases (id, workspace_id, box_id, connection_id, user_id, grant_id, scopes, - mode, token_hash, issued_at, expires_at, state) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'active')`, + mode, token_hash, issued_at, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`, v: [ input.id, input.workspaceId, @@ -166,7 +178,8 @@ export async function listLeases( ORDER BY lease.issued_at DESC, lease.id`, v: [workspaceId], }); - return result.map(leaseView); + const now = Date.now(); + return result.map((row) => leaseView(row, now)); } export async function revokeLease( @@ -188,13 +201,13 @@ export async function revokeLease( throw new HttpError(404, "credential lease not found"); } if (!canControlWorkspace(principal, row)) throw new HttpError(403, "forbidden"); - if (row.state !== "active") return; + if (leaseState(row, now) !== "active") return; await transaction(db, [ { q: `UPDATE credential_leases - SET state = 'revoked', token_hash = NULL - WHERE id = ?1 AND state = 'active'`, - v: [id], + SET revoked_at = ?2, token_hash = NULL + WHERE id = ?1 AND revoked_at IS NULL`, + v: [id, now], }, { q: `INSERT INTO credential_events (lease_id, event, detail, created_at) @@ -272,21 +285,27 @@ export async function listCredentialEvents( })); } -export function revokeWorkspaceLeasesQuery(workspaceId: string): Query { +export function revokeWorkspaceLeasesQuery( + workspaceId: string, + now = Date.now(), +): Query { return { q: `UPDATE credential_leases - SET state = 'revoked', token_hash = NULL - WHERE workspace_id = ?1 AND state = 'active'`, - v: [workspaceId], + SET revoked_at = ?2, token_hash = NULL + WHERE workspace_id = ?1 AND revoked_at IS NULL`, + v: [workspaceId, now], }; } -export function revokeConnectionLeasesQuery(connectionId: string): Query { +export function revokeConnectionLeasesQuery( + connectionId: string, + now = Date.now(), +): Query { return { q: `UPDATE credential_leases - SET state = 'revoked', token_hash = NULL - WHERE connection_id = ?1 AND state = 'active'`, - v: [connectionId], + SET revoked_at = ?2, token_hash = NULL + WHERE connection_id = ?1 AND revoked_at IS NULL`, + v: [connectionId, now], }; } @@ -296,23 +315,24 @@ export function revokeConnectionLeasesQuery(connectionId: string): Query { export function revokeWorkspaceConnectionLeasesQuery( workspaceId: string, connectionId: string, + now = Date.now(), ): Query { return { q: `UPDATE credential_leases - SET state = 'revoked', token_hash = NULL - WHERE workspace_id = ?1 AND connection_id = ?2 AND state = 'active'`, - v: [workspaceId, connectionId], + SET revoked_at = ?3, token_hash = NULL + WHERE workspace_id = ?1 AND connection_id = ?2 AND revoked_at IS NULL`, + v: [workspaceId, connectionId, now], }; } /** Revoking a grant kills every box that borrowed it, in the same transaction * that clears the ciphertext. */ -export function revokeGrantLeasesQuery(grantId: string): Query { +export function revokeGrantLeasesQuery(grantId: string, now = Date.now()): Query { return { q: `UPDATE credential_leases - SET state = 'revoked', token_hash = NULL - WHERE grant_id = ?1 AND state = 'active'`, - v: [grantId], + SET revoked_at = ?2, token_hash = NULL + WHERE grant_id = ?1 AND revoked_at IS NULL`, + v: [grantId, now], }; } @@ -337,7 +357,7 @@ export async function staleSurfaceConnections( now = Date.now(), ): Promise { const excluded = liveConnectionIds - .map((_id, index) => `?${String(index + 3)}`) + .map((_id, index) => `?${String(index + 4)}`) .join(", "); return rows(db, { q: `SELECT lease.connection_id, connection.scoped_name AS connection_name, @@ -348,21 +368,9 @@ export async function staleSurfaceConnections( AND lease.issued_at >= ?2 ${excluded === "" ? "" : `AND lease.connection_id NOT IN (${excluded})`} GROUP BY lease.connection_id - HAVING SUM(CASE WHEN lease.state = 'active' THEN 1 ELSE 0 END) = 0 + HAVING SUM(CASE WHEN lease.revoked_at IS NULL AND lease.expires_at > ?3 + THEN 1 ELSE 0 END) = 0 ORDER BY connection.scoped_name`, - v: [workspaceId, now - SURFACE_TOMBSTONE_WINDOW_MS, ...liveConnectionIds], - }); -} - -export async function runLeaseSweep( - runtime: CoreRuntime, - now = Date.now(), -): Promise { - return changed(runtime.db, { - q: `UPDATE credential_leases - SET state = 'expired', token_hash = NULL - WHERE state = 'active' AND expires_at <= ?1 - RETURNING id`, - v: [now], + v: [workspaceId, now - SURFACE_TOMBSTONE_WINDOW_MS, now, ...liveConnectionIds], }); } diff --git a/packages/control-plane/core/connections/mint.ts b/packages/control-plane/core/connections/mint.ts index ee0093fb..c6599091 100644 --- a/packages/control-plane/core/connections/mint.ts +++ b/packages/control-plane/core/connections/mint.ts @@ -6,7 +6,7 @@ import { authenticateBox } from "../oauth.js"; import { providerManifest } from "./catalog/index.js"; import type { ProviderManifest } from "./catalog/types.js"; import { tombstoneSurfaces } from "./catalog/surfaces.js"; -import { addConnectRoutes } from "./connect.js"; +import { addConnectRoutes, connectRedirectPath } from "./connect.js"; import { addGithubRepositoryRoutes } from "./github-repos.js"; import { addConnectionHealthRoutes } from "./health.js"; import { @@ -211,7 +211,7 @@ async function grantSecretForMint( key: runtime.credentialMasterKey, clientId, clientSecret, - redirectUri: `${origin}${auth.redirectPath}`, + redirectUri: `${origin}${connectRedirectPath(manifest.id)}`, }, { ...manifest, auth }, grant, @@ -344,7 +344,10 @@ async function mintOne( // Accepted tradeoff: a value already exported into a shell that is still // open dies on the next mint. Surfaces are login-fresh by design — a new // shell, a new agent run, and the proxy all read the freshest lease. - await rows(runtime.db, revokeWorkspaceConnectionLeasesQuery(workspace.id, connection.id)); + await rows( + runtime.db, + revokeWorkspaceConnectionLeasesQuery(workspace.id, connection.id, now), + ); const lease = await createLease(runtime.db, { id: leaseId, workspaceId: workspace.id, @@ -489,14 +492,14 @@ async function surfaceTombstones( )) { const declared = parseConnectionSurfaceConfig(stale.config); if (declared === null) continue; - const manifest = providerManifest(declared.manifestId); - if (manifest === null) continue; + // Only rows a catalog mint wrote surfaces for get tombstones; a manifest + // id the catalog no longer knows left nothing this could clean up. + if (providerManifest(declared.manifestId) === null) continue; results.push({ // FROZEN box wire key: the shipped broker requires "integration". integration: stale.connection_name, mode: "inject", placements: tombstoneSurfaces( - manifest, stale.connection_name, declared.environmentNames, ), diff --git a/packages/control-plane/core/connections/minters/oauth.ts b/packages/control-plane/core/connections/minters/oauth.ts index e2147ab9..5df3b3fc 100644 --- a/packages/control-plane/core/connections/minters/oauth.ts +++ b/packages/control-plane/core/connections/minters/oauth.ts @@ -47,10 +47,10 @@ export function exchangeForm(input: ExchangeInput): URLSearchParams { throw new Error("authorization_code exchange needs a redirect URI"); } form.set("redirect_uri", input.redirectUri); - if (input.manifest.auth.pkce) { - if (input.codeVerifier === null) throw new Error("PKCE exchange needs a code verifier"); - form.set("code_verifier", input.codeVerifier); - } + // Every catalog flow runs PKCE, so a code exchange always proves the + // verifier that paired the authorize redirect's challenge. + if (input.codeVerifier === null) throw new Error("PKCE exchange needs a code verifier"); + form.set("code_verifier", input.codeVerifier); } return form; } diff --git a/packages/control-plane/core/connections/proxy.ts b/packages/control-plane/core/connections/proxy.ts index fc193c2c..09abc39c 100644 --- a/packages/control-plane/core/connections/proxy.ts +++ b/packages/control-plane/core/connections/proxy.ts @@ -78,6 +78,11 @@ async function proxyLease( const nowParameter = candidates.length + 2; // A grant-backed lease carries its own secret and header shape, so it is not // held to the static-connection rule the org-root path needs. + // + // An expired lease retains its token_hash now that no sweep NULLs it: the + // expiry predicate below is the authority, so a hash of an expired token + // authorizes nothing. Revocation still NULLs the hash, and revoked_at IS + // NULL keeps even a hash collision from resurrecting a revoked lease. return first(db, { q: `SELECT lease.token_hash, connection.scoped_name AS connection_name, connection.root_ciphertext, connection.config, @@ -93,7 +98,7 @@ async function proxyLease( ON grant_row.id = lease.grant_id AND grant_row.revoked_at IS NULL WHERE lease.id = ?1 AND lease.token_hash IN (${tokenParameters}) - AND lease.state = 'active' + AND lease.revoked_at IS NULL AND lease.expires_at > ?${nowParameter} AND connection.revoked_at IS NULL AND connection.custody = 'proxy' diff --git a/packages/control-plane/core/connections/registry.ts b/packages/control-plane/core/connections/registry.ts index 583c89ad..65922106 100644 --- a/packages/control-plane/core/connections/registry.ts +++ b/packages/control-plane/core/connections/registry.ts @@ -357,16 +357,6 @@ export function connectionProxyBaseUrl(config: string): string | null { } } -/** Which catalog entry interprets a connection row, when one does. */ -export function connectionManifestId(connection: Connection): string | null { - try { - const value: unknown = JSON.parse(connection.config); - return isRecord(value) && isString(value.manifest_id) ? value.manifest_id : null; - } catch { - return null; - } -} - export function resolveMinter(connection: Connection): Minter | null { return ( minters.find( diff --git a/packages/control-plane/core/connections/types.ts b/packages/control-plane/core/connections/types.ts index 44795077..3f79e5aa 100644 --- a/packages/control-plane/core/connections/types.ts +++ b/packages/control-plane/core/connections/types.ts @@ -21,11 +21,13 @@ export interface MintResult { /** What a minter hands back. Everything beyond `MintResult` is control-plane * bookkeeping that `mintOne` consumes and strips before serialization — it can - * never ride the frozen wire. */ -export interface MinterResult extends MintResult { - tokenHash?: string; - grantedScopes?: string[]; -} + * never ride the frozen wire. The union discriminates on mode: a proxy lease + * IS its token, so a proxy result must carry the hash and an inject result + * cannot — an unhashed proxy lease is unrepresentable rather than re-checked + * at insert time. */ +export type MinterResult = + | (MintResult & { mode: "inject"; tokenHash?: never; grantedScopes?: string[] }) + | (MintResult & { mode: "proxy"; tokenHash: string; grantedScopes?: string[] }); export interface Connection { id: string; @@ -139,7 +141,6 @@ export interface CatalogEntryView { summary: string; docsUrl: string; custody: Custody; - rotation: "strict" | "graceful" | "none"; oauthAvailable: boolean; oauthConfigured: boolean; personalTokenLabel: string | null; diff --git a/packages/control-plane/core/connections/user-grants.ts b/packages/control-plane/core/connections/user-grants.ts index aff1dcf2..60f5d833 100644 --- a/packages/control-plane/core/connections/user-grants.ts +++ b/packages/control-plane/core/connections/user-grants.ts @@ -201,7 +201,7 @@ export async function storeGrant( // inject-mode lease carries the credential itself, so without this it // stays active — and reads as active in the lease UI — for its full hour // with a value the vendor has already invalidated. - ...(replaced === null ? [] : [revokeGrantLeasesQuery(replaced.id)]), + ...(replaced === null ? [] : [revokeGrantLeasesQuery(replaced.id, now)]), { q: `INSERT INTO user_oauth_grants (id, user_id, provider, manifest_id, kind, label, config, @@ -245,7 +245,7 @@ export async function revokeGrant( WHERE id = ?2 AND revoked_at IS NULL`, v: [now, grant.id], }, - revokeGrantLeasesQuery(grant.id), + revokeGrantLeasesQuery(grant.id, now), ]); return true; } diff --git a/packages/control-plane/core/crypto.ts b/packages/control-plane/core/crypto.ts index 81bec52f..57ba8d06 100644 --- a/packages/control-plane/core/crypto.ts +++ b/packages/control-plane/core/crypto.ts @@ -1,16 +1,34 @@ const encoder = new TextEncoder(); export const DUMMY_HASH = "0".repeat(64); -export function randomToken(bytes = 32): string { - const value = crypto.getRandomValues(new Uint8Array(bytes)); +/** Unpadded base64url of raw bytes: the one encoding for tokens, invite-code + * hashes, OAuth state payloads, and webApp ticket segments. */ +export function base64Url(bytes: Uint8Array): string { let binary = ""; - for (const byte of value) binary += String.fromCharCode(byte); + for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary) .replaceAll("+", "-") .replaceAll("/", "_") .replace(/=+$/u, ""); } +/** Inverse of {@link base64Url}; null for anything that is not unpadded + * base64url, so callers reject a malformed segment instead of decoding it. */ +export function decodeBase64Url(value: string): Uint8Array | null { + if (!/^[A-Za-z0-9_-]+$/u.test(value)) return null; + const padded = value.replaceAll("-", "+").replaceAll("_", "/") + + "=".repeat((4 - (value.length % 4)) % 4); + try { + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); + } catch { + return null; + } +} + +export function randomToken(bytes = 32): string { + return base64Url(crypto.getRandomValues(new Uint8Array(bytes))); +} + export async function hashSecret(value: string): Promise { const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); return [...new Uint8Array(digest)] diff --git a/packages/control-plane/core/files/attachments.ts b/packages/control-plane/core/files/attachments.ts index c20896ef..c6ac759f 100644 --- a/packages/control-plane/core/files/attachments.ts +++ b/packages/control-plane/core/files/attachments.ts @@ -19,7 +19,8 @@ import { requireFolderAccess, type FilesActor, } from "./access.js"; -import { runWorkspaceFileSync, scheduleSync } from "./sync.js"; +import { scheduleSync } from "./schedule.js"; +import { runWorkspaceFileSync } from "./sync.js"; interface AttachmentWorkspaceRow { id: string; diff --git a/packages/control-plane/core/files/dav.ts b/packages/control-plane/core/files/dav.ts index e4bda3ff..d5fe18a7 100644 --- a/packages/control-plane/core/files/dav.ts +++ b/packages/control-plane/core/files/dav.ts @@ -67,9 +67,7 @@ export async function guestRequest( ): Promise { const provider = runtime.providers.vmRegistry.forVmId(channel.vm_id); if (provider?.proxyWebApp !== undefined) { - const response = await provider.proxyWebApp(channel.vm_id, 7445, path, request); - if (response !== null) return response; - throw new Error("workspace WebDAV proxy returned no response"); + return provider.proxyWebApp(channel.vm_id, 7445, path, request); } const tunnels = runtime.providers.workspaceTunnels; if (tunnels === undefined || channel.tunnel_hostname === null) { diff --git a/packages/control-plane/core/files/schedule.ts b/packages/control-plane/core/files/schedule.ts index 1d938e37..dd958550 100644 --- a/packages/control-plane/core/files/schedule.ts +++ b/packages/control-plane/core/files/schedule.ts @@ -1,16 +1,9 @@ import type { CoreRuntime } from "../runtime.js"; import type { FileSyncResult } from "./sync.js"; -const pendingScheduledSyncs = new Set>(); - -/** Settles when every schedule-triggered pass started so far has finished. */ -export async function scheduledSyncsSettled(): Promise { - while (pendingScheduledSyncs.size > 0) { - await Promise.all([...pendingScheduledSyncs]); - } -} - -/** Fire-and-forget convergence; the scheduled sweep remains the backstop. */ +/** Fire-and-forget convergence; the scheduled sweep remains the backstop. + * The pass outlives the response through `runtime.waitUntil` — tests observe + * it by settling their ExecutionContext, not through any registry here. */ export function scheduleSync( runtime: CoreRuntime, run: (runtime: CoreRuntime) => Promise, @@ -19,8 +12,6 @@ export function scheduleSync( const error = caught instanceof Error ? caught : new Error("folder sync failed"); runtime.reportError("folder_sync_failed", error); }); - pendingScheduledSyncs.add(sync); - void sync.finally(() => pendingScheduledSyncs.delete(sync)); try { runtime.waitUntil(sync); } catch (caught) { diff --git a/packages/control-plane/core/files/sync.ts b/packages/control-plane/core/files/sync.ts index 1e32a79c..a9d193fe 100644 --- a/packages/control-plane/core/files/sync.ts +++ b/packages/control-plane/core/files/sync.ts @@ -25,16 +25,6 @@ import { import { scheduleSync } from "./schedule.js"; import { runUsageCapturePush } from "./usage-push.js"; -export { scheduleSync, scheduledSyncsSettled } from "./schedule.js"; -// The transfer plumbing and the shared per-tick budget moved to dav.ts when -// the usage-capture push leg became its second consumer; these re-exports -// keep the long-standing import sites (src/worker.ts, the test suites) flat. -export { - FILE_SYNC_MAX_BYTES_PER_TICK, - FILE_SYNC_MAX_FILES_PER_TICK, - parseDavListing, -} from "./dav.js"; - interface SyncAttachmentRow extends GuestChannel { folder_id: string; folder_name: string; @@ -333,13 +323,16 @@ export async function runWorkspaceFileSync( return (await runAttachmentPass(runtime, await attachmentRows(runtime, { workspaceId }))).result; } +/** How long an incomplete ready-time pass waits before retrying, sized for a + * tunnel that is seconds from connecting; anything slower is the sweep's job. */ +const READY_RETRY_DELAYS_MS = [8_000, 15_000] as const; + /** Materializes a just-booted workspace's attachments. The guest phones home * while its tunnel may still be connecting, so an incomplete first pass retries * briefly before the five-minute sweep takes over as the backstop. */ export async function runReadyWorkspaceFileSync( runtime: CoreRuntime, workspaceId: string, - retryDelaysMs: readonly number[] = [8_000, 15_000], ): Promise { let pass = await runAttachmentPass(runtime, await attachmentRows(runtime, { workspaceId })); const settled = async (): Promise => convergeFilesReady( @@ -348,7 +341,7 @@ export async function runReadyWorkspaceFileSync( pass.syncedByWorkspace.get(workspaceId) ?? 0, ); let complete = await settled(); - for (const delayMs of retryDelaysMs) { + for (const delayMs of READY_RETRY_DELAYS_MS) { if (complete) break; await new Promise((resolve) => setTimeout(resolve, delayMs)); pass = await runAttachmentPass(runtime, await attachmentRows(runtime, { workspaceId })); diff --git a/packages/control-plane/core/identity/google.ts b/packages/control-plane/core/identity/google.ts index 1c43c1f4..fcbe4f78 100644 --- a/packages/control-plane/core/identity/google.ts +++ b/packages/control-plane/core/identity/google.ts @@ -15,7 +15,7 @@ import { verifyGoogleOAuthStateCookie, } from "../oauth-state.js"; import { availableOrgSlug, DEFAULT_ORG_VM_LIMIT } from "./orgs.js"; -import { inviteCodeHash, redeemInviteSession } from "./invites.js"; +import { redeemableInvite, redeemInviteSession } from "./invites.js"; const GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; @@ -152,27 +152,8 @@ function assertAllowedEmailDomain(policy: SignupPolicy, email: string): void { } } -/** Pre-check used only in invite mode, BEFORE the user row is created. - * Without it, a well-formed but bogus invite code would still create the - * user (redemption fails later), and the next plain sign-in would pass the - * gate as an "existing user". Redemption itself stays transactional in - * redeemInviteSession and re-validates everything checked here. */ -async function hasRedeemableInvite( - db: Db, - inviteCode: string | undefined, - email: string, - now: number, -): Promise { - if (inviteCode === undefined) return false; - const invite = await first<{ id: string }>(db, { - q: `SELECT id FROM invites - WHERE code_hash = ?1 AND state = 'ready' AND expires_at > ?2 - AND (email IS NULL OR email = ?3) - LIMIT 1`, - v: [await inviteCodeHash(inviteCode), now, email], - }); - return invite !== null; -} +const INVITE_ONLY_MESSAGE = + "sign-ups are invite-only on this deployment; ask an organization admin for an invite link"; async function activeMembership(db: Db, userId: string): Promise { return first(db, { @@ -186,111 +167,107 @@ async function activeMembership(db: Db, userId: string): Promise { - await transaction(db, [ - { q: "DELETE FROM sessions WHERE principal_id = ?1", v: [userId] }, - { q: "DELETE FROM memberships WHERE user_id = ?1", v: [userId] }, - { q: "DELETE FROM users WHERE id = ?1", v: [userId] }, - { q: "DELETE FROM principals WHERE id = ?1", v: [userId] }, - ]); +async function assertEmailUnclaimed( + db: Db, + email: string, + ownerId: string | null, +): Promise { + const emailOwner = await first<{ id: string }>(db, { + q: "SELECT id FROM users WHERE email = ?1 LIMIT 1", + v: [email], + }); + if (emailOwner !== null && emailOwner.id !== ownerId) { + throw new HttpError(409, "email belongs to another Google identity"); + } } -async function resolveUser( +async function upsertLoginPrincipal(db: Db, userId: string): Promise { + await rows(db, { + q: `INSERT INTO principals (id, unix_name, harnesses) + VALUES (?1, 'blitz', '["claude","codex"]') + ON CONFLICT(id) DO UPDATE SET unix_name = 'blitz', harnesses = '["claude","codex"]'`, + v: [userId], + }); +} + +async function userById(db: Db, id: string): Promise { + const user = await first(db, { + q: "SELECT id, platform_operator, name FROM users WHERE id = ?1 LIMIT 1", + v: [id], + }); + if (user === null) throw new Error("Google user disappeared during login"); + return user; +} + +/** Refreshes the account this Google identity already owns, or returns null + * for a first sign-in. */ +async function refreshExistingUser( db: Db, profile: GoogleProfile, now: number, bootstrapEnabled: boolean, - gate: { requireInvite: boolean; inviteCode: string | undefined }, -): Promise<{ user: UserRow; created: boolean }> { - let user = await first(db, { +): Promise { + const user = await first(db, { q: "SELECT id, platform_operator, name FROM users WHERE google_user_id = ?1 LIMIT 1", v: [profile.googleUserId], }); - const created = user === null; - if (user !== null) { - const emailOwner = await first<{ id: string }>(db, { - q: "SELECT id FROM users WHERE email = ?1 LIMIT 1", - v: [profile.email], - }); - if (emailOwner !== null && emailOwner.id !== user.id) { - throw new HttpError(409, "email belongs to another Google identity"); - } - await rows(db, { - q: `UPDATE users SET email = ?1, name = ?2, avatar_url = ?3, updated_at = ?4 - WHERE id = ?5`, - v: [profile.email, profile.name, profile.avatarUrl, now, user.id], - }); - if (bootstrapEnabled) { - // The bootstrap secret was already verified at /auth/google/start, so - // this only widens WHICH user can become the first operator: an - // existing row now promotes exactly like the insert branch below. The - // NOT EXISTS guard keeps promotion first-operator-only, and a wrong or - // absent secret still never reaches this branch. - await rows(db, { - q: `UPDATE users SET platform_operator = 1, updated_at = ?2 - WHERE id = ?1 AND NOT EXISTS ( - SELECT 1 FROM users WHERE platform_operator = 1 - )`, - v: [user.id, now], - }); - } - } else { - if ( - gate.requireInvite - && !(await hasRedeemableInvite(db, gate.inviteCode, profile.email, now)) - ) { - throw new HttpError( - 403, - "sign-ups are invite-only on this deployment; ask an organization admin for an invite link", - ); - } - const emailOwner = await first<{ id: string }>(db, { - q: "SELECT id FROM users WHERE email = ?1 LIMIT 1", - v: [profile.email], - }); - if (emailOwner !== null) { - throw new HttpError(409, "email belongs to another Google identity"); - } - const id = crypto.randomUUID(); + if (user === null) return null; + await assertEmailUnclaimed(db, profile.email, user.id); + await rows(db, { + q: `UPDATE users SET email = ?1, name = ?2, avatar_url = ?3, updated_at = ?4 + WHERE id = ?5`, + v: [profile.email, profile.name, profile.avatarUrl, now, user.id], + }); + if (bootstrapEnabled) { + // The bootstrap secret was already verified at /auth/google/start, so + // this only widens WHICH user can become the first operator: an + // existing row now promotes exactly like a created one. The NOT EXISTS + // guard keeps promotion first-operator-only, and a wrong or absent + // secret still never reaches this branch. await rows(db, { - q: `INSERT INTO users - (id, google_user_id, email, name, avatar_url, platform_operator, - created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, - CASE WHEN ?6 = 1 - AND NOT EXISTS (SELECT 1 FROM users WHERE platform_operator = 1) - THEN 1 ELSE 0 END, - ?7, ?7)`, - v: [ - id, - profile.googleUserId, - profile.email, - profile.name, - profile.avatarUrl, - bootstrapEnabled ? 1 : 0, - now, - ], + q: `UPDATE users SET platform_operator = 1, updated_at = ?2 + WHERE id = ?1 AND NOT EXISTS ( + SELECT 1 FROM users WHERE platform_operator = 1 + )`, + v: [user.id, now], }); - user = { id, platform_operator: 0, name: profile.name }; } + await upsertLoginPrincipal(db, user.id); + return userById(db, user.id); +} + +/** Creates the account for a sign-up that needs no invite: open signup or + * the verified bootstrap secret. Invite-admitted accounts are instead + * created inside the redemption batch (redeemInviteSession), conditional on + * the invite, so no account ever outlives the invite that admitted it. */ +async function createOpenUser( + db: Db, + profile: GoogleProfile, + now: number, + bootstrapEnabled: boolean, +): Promise { + const id = crypto.randomUUID(); await rows(db, { - q: `INSERT INTO principals (id, unix_name, harnesses) - VALUES (?1, 'blitz', '["claude","codex"]') - ON CONFLICT(id) DO UPDATE SET unix_name = 'blitz', harnesses = '["claude","codex"]'`, - v: [user.id], - }); - const refreshed = await first(db, { - q: "SELECT id, platform_operator, name FROM users WHERE id = ?1 LIMIT 1", - v: [user.id], + q: `INSERT INTO users + (id, google_user_id, email, name, avatar_url, platform_operator, + created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, + CASE WHEN ?6 = 1 + AND NOT EXISTS (SELECT 1 FROM users WHERE platform_operator = 1) + THEN 1 ELSE 0 END, + ?7, ?7)`, + v: [ + id, + profile.googleUserId, + profile.email, + profile.name, + profile.avatarUrl, + bootstrapEnabled ? 1 : 0, + now, + ], }); - if (refreshed === null) throw new Error("Google user disappeared during login"); - return { user: refreshed, created }; + await upsertLoginPrincipal(db, id); + return userById(db, id); } async function bootstrapMembership( @@ -431,26 +408,62 @@ export function addGoogleAuthRoutes( assertAllowedEmailDomain(policy, profile.email); const bootstrapEnabled = state.bootstrap === true && runtime.vars.bootstrapSecret !== ""; - const { user, created } = await resolveUser( + // The verified bootstrap secret is the operator credential, so it may + // create the first account on an invite-only deployment; the domain + // allowlist above still applies even to bootstrap. + const requireInvite = policy.inviteOnly && !bootstrapEnabled; + const token = randomToken(); + const existing = await refreshExistingUser( runtime.db, profile, now, bootstrapEnabled, - { - // The verified bootstrap secret is the operator credential, so it - // may create the first account on an invite-only deployment; the - // domain allowlist above still applies even to bootstrap. - requireInvite: policy.inviteOnly && !bootstrapEnabled, - inviteCode: state.inviteCode, - }, ); - let membership = await activeMembership(runtime.db, user.id); - if (membership === null && user.platform_operator === 1) { - membership = await bootstrapMembership(runtime.db, user, now); - } - const token = randomToken(); - if (state.inviteCode !== undefined) { - try { + if (existing === null && state.inviteCode !== undefined) { + // First sign-in arriving with an invite: the user row is created by + // the redemption batch itself, conditional on the invite. A brand-new + // operator (bootstrap secret while no operator exists) who also + // presents a valid invite therefore joins only the inviting org — no + // personal org is bootstrapped, because the account exists only once + // its invite membership does. + if (requireInvite) { + // The invite-only gate answers uniformly: a sign-up presenting a + // bad invite learns it needs a working one, not what became of the + // code it tried. + try { + await redeemableInvite(runtime.db, state.inviteCode, profile.email, now); + } catch { + throw new HttpError(403, INVITE_ONLY_MESSAGE); + } + } + await assertEmailUnclaimed(runtime.db, profile.email, null); + await redeemInviteSession( + runtime.db, + state.inviteCode, + crypto.randomUUID(), + profile.email, + await hashSecret(token), + runtime.vars.sessionTtlMs, + now, + { + googleUserId: profile.googleUserId, + name: profile.name, + avatarUrl: profile.avatarUrl, + bootstrapOperator: bootstrapEnabled, + }, + ); + } else { + let user = existing; + if (user === null) { + if (requireInvite) throw new HttpError(403, INVITE_ONLY_MESSAGE); + await assertEmailUnclaimed(runtime.db, profile.email, null); + user = await createOpenUser(runtime.db, profile, now, bootstrapEnabled); + } + let membership = await activeMembership(runtime.db, user.id); + if (membership === null && user.platform_operator === 1) { + membership = await bootstrapMembership(runtime.db, user, now); + } + if (state.inviteCode !== undefined) { await redeemInviteSession( runtime.db, state.inviteCode, @@ -460,26 +473,20 @@ export function addGoogleAuthRoutes( runtime.vars.sessionTtlMs, now, ); - } catch (error) { - // hasRedeemableInvite only pre-checked the invite; redemption can - // still lose a race for a single-use code, or find the invite - // revoked. The account this invite admitted must not survive it. - if (created) await deleteProvisionalUser(runtime.db, user.id); - throw error; + } else { + await rows(runtime.db, { + q: `INSERT INTO sessions + (token_hash, principal_id, created_at, expires_at, membership_id) + VALUES (?1, ?2, ?3, ?4, ?5)`, + v: [ + await hashSecret(token), + user.id, + now, + now + runtime.vars.sessionTtlMs, + membership?.id ?? null, + ], + }); } - } else { - await rows(runtime.db, { - q: `INSERT INTO sessions - (token_hash, principal_id, created_at, expires_at, membership_id) - VALUES (?1, ?2, ?3, ?4, ?5)`, - v: [ - await hashSecret(token), - user.id, - now, - now + runtime.vars.sessionTtlMs, - membership?.id ?? null, - ], - }); } context.header("Set-Cookie", sessionCookie(token, runtime.vars.sessionTtlMs)); context.header("Set-Cookie", clearGoogleOAuthStateCookie(), { append: true }); diff --git a/packages/control-plane/core/identity/invites.ts b/packages/control-plane/core/identity/invites.ts index e41757c3..bbbca24d 100644 --- a/packages/control-plane/core/identity/invites.ts +++ b/packages/control-plane/core/identity/invites.ts @@ -1,5 +1,5 @@ -import { randomToken } from "../crypto.js"; -import type { Db } from "../db.js"; +import { base64Url, randomToken } from "../crypto.js"; +import type { Db, Query } from "../db.js"; import { first, rows, transaction } from "../db.js"; import { HttpError, isRecord, type JsonValue, readJson, requiredString } from "../http.js"; import type { Principal } from "../principals.js"; @@ -17,20 +17,14 @@ interface InviteRow { email: string | null; target_org_id: string; role: InviteRole; - state: InviteState; created_at: number; expires_at: number; redeemed_at: number | null; + revoked_at: number | null; org_name: string; creator_name: string; } -function base64Url(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); -} - export async function inviteCodeHash(code: string): Promise { return base64Url(new Uint8Array(await crypto.subtle.digest( "SHA-256", @@ -52,12 +46,27 @@ function optionalEmail(value: JsonValue | undefined): string | null { return email; } -function inviteView(row: InviteRow) { +/** The state a reader sees. Storage keeps only what a writer decided — + * `redeemed_at` at redemption, `revoked_at` at revocation — and expiry is a + * fact about `expires_at`, derived here instead of synced into the row by + * writes-on-read. Revoked wins over redeemed (the write guards make the pair + * unreachable anyway), and both beat expiry: a decision made while the + * invite was open does not age into a different answer. */ +function inviteState( + invite: Pick, + now: number, +): InviteState { + if (invite.revoked_at !== null) return "revoked"; + if (invite.redeemed_at !== null) return "redeemed"; + return invite.expires_at <= now ? "expired" : "ready"; +} + +function inviteView(row: InviteRow, now: number) { return { id: row.id, email: row.email, role: row.role, - state: row.state, + state: inviteState(row, now), createdAt: row.created_at, expiresAt: row.expires_at, redeemedAt: row.redeemed_at, @@ -73,14 +82,6 @@ function requireAdmin(principal: Principal): string { return principal.orgId; } -async function expireInvites(db: Db, now: number, orgId?: string): Promise { - await rows(db, { - q: `UPDATE invites SET state = 'expired' - WHERE state = 'ready' AND expires_at <= ?1${orgId === undefined ? "" : " AND target_org_id = ?2"}`, - v: orgId === undefined ? [now] : [now, orgId], - }); -} - async function inviteByHash(db: Db, hash: string): Promise { return first(db, { q: `SELECT invite.*, org.name AS org_name, creator.name AS creator_name @@ -93,6 +94,40 @@ async function inviteByHash(db: Db, hash: string): Promise { }); } +/** Loads the invite a redemption names and vets it against the redeeming + * email, throwing the caller-visible refusal (400/404/409/403) when it is + * not redeemable right now. The redemption batch re-guards every condition + * in SQL, so this read decides only WHICH error a doomed attempt gets. */ +export async function redeemableInvite( + db: Db, + code: string, + email: string, + now: number, +): Promise { + if (!/^[A-Za-z0-9_-]{43}$/u.test(code)) throw new HttpError(400, "invalid invite code"); + const invite = await inviteByHash(db, await inviteCodeHash(code)); + if (invite === null) throw new HttpError(404, "invite not found"); + const state = inviteState(invite, now); + if (state !== "ready") throw new HttpError(409, `invite is ${state}`); + if (invite.email !== null && invite.email !== email) { + throw new HttpError(403, "invite is for a different email address"); + } + return invite; +} + +/** A first sign-in that arrives with an invite. The user and principal rows + * are created inside the redemption batch, conditional on the invite still + * being redeemable, so the account exists exactly when its invite membership + * does — a lost race creates nothing and needs no compensating deletion. */ +export interface InviteSignupSeed { + googleUserId: string; + name: string; + avatarUrl: string | null; + /** A verified bootstrap secret was presented: the created row may claim + * the first-operator slot exactly like an open-mode signup would. */ + bootstrapOperator: boolean; +} + export async function redeemInviteSession( db: Db, code: string, @@ -101,37 +136,68 @@ export async function redeemInviteSession( sessionTokenHash: string, sessionTtlMs: number, now = Date.now(), + newUser?: InviteSignupSeed, ): Promise { - if (!/^[A-Za-z0-9_-]{43}$/u.test(code)) throw new HttpError(400, "invalid invite code"); - const hash = await inviteCodeHash(code); - await expireInvites(db, now); - const invite = await inviteByHash(db, hash); - if (invite === null) throw new HttpError(404, "invite not found"); - if (invite.state !== "ready") throw new HttpError(409, `invite is ${invite.state}`); - if (invite.email !== null && invite.email !== email) { - throw new HttpError(403, "invite is for a different email address"); - } + const invite = await redeemableInvite(db, code, email, now); + const hash = invite.code_hash; const existing = await first<{ id: string }>(db, { q: "SELECT id FROM memberships WHERE user_id = ?1 AND org_id = ?2 LIMIT 1", v: [userId, invite.target_org_id], }); const membershipId = existing?.id ?? crypto.randomUUID(); - const result = await transaction(db, [ + const queries: Query[] = []; + if (newUser !== undefined) { + queries.push( + { + q: `INSERT INTO users + (id, google_user_id, email, name, avatar_url, platform_operator, + created_at, updated_at) + SELECT ?1, ?2, ?3, ?4, ?5, + CASE WHEN ?6 = 1 + AND NOT EXISTS (SELECT 1 FROM users WHERE platform_operator = 1) + THEN 1 ELSE 0 END, + ?7, ?7 + WHERE EXISTS ( + SELECT 1 FROM invites + WHERE code_hash = ?8 AND revoked_at IS NULL AND redeemed_at IS NULL + AND expires_at > ?7 AND (email IS NULL OR email = ?3) + ) + RETURNING id`, + v: [ + userId, + newUser.googleUserId, + email, + newUser.name, + newUser.avatarUrl, + newUser.bootstrapOperator ? 1 : 0, + now, + hash, + ], + }, + { + q: `INSERT INTO principals (id, unix_name, harnesses) + SELECT ?1, 'blitz', '["claude","codex"]' + WHERE EXISTS (SELECT 1 FROM users WHERE id = ?1) + RETURNING id`, + v: [userId], + }, + ); + } + queries.push( { q: `INSERT INTO memberships (id, user_id, org_id, role, status) SELECT ?1, ?2, target_org_id, role, 'active' FROM invites - WHERE code_hash = ?3 AND state = 'ready' AND expires_at > ?4 - AND (email IS NULL OR email = ?5) + WHERE code_hash = ?3 AND revoked_at IS NULL AND redeemed_at IS NULL + AND expires_at > ?4 AND (email IS NULL OR email = ?5) ON CONFLICT(user_id, org_id) DO UPDATE SET role = excluded.role, status = 'active' RETURNING id`, v: [membershipId, userId, hash, now, email], }, { - q: `UPDATE invites SET state = 'redeemed', redeemed_by_user_id = ?1, - redeemed_at = ?2 - WHERE code_hash = ?3 AND state = 'ready' AND expires_at > ?2 - AND (email IS NULL OR email = ?4) + q: `UPDATE invites SET redeemed_by_user_id = ?1, redeemed_at = ?2 + WHERE code_hash = ?3 AND revoked_at IS NULL AND redeemed_at IS NULL + AND expires_at > ?2 AND (email IS NULL OR email = ?4) RETURNING id`, v: [userId, now, hash, email], }, @@ -140,13 +206,14 @@ export async function redeemInviteSession( (token_hash, principal_id, created_at, expires_at, membership_id) SELECT ?1, ?2, ?3, ?4, ?5 WHERE EXISTS ( - SELECT 1 FROM invites WHERE code_hash = ?6 AND state = 'redeemed' + SELECT 1 FROM invites WHERE code_hash = ?6 AND redeemed_by_user_id = ?2 AND redeemed_at = ?3 ) RETURNING token_hash`, v: [sessionTokenHash, userId, now, now + sessionTtlMs, membershipId, hash], }, - ]); - if (result[0]?.length !== 1 || result[1]?.length !== 1 || result[2]?.length !== 1) { + ); + const result = await transaction(db, queries); + if (queries.some((_query, index) => result[index]?.length !== 1)) { throw new HttpError(409, "invite could not be redeemed"); } return membershipId; @@ -169,18 +236,16 @@ export function addInviteRoutes( ) { return runtime.assets.fetch(context.req.raw); } - const now = Date.now(); - await expireInvites(runtime.db, now); const invite = await inviteByHash(runtime.db, await inviteCodeHash(code)); if (invite === null) throw new HttpError(404, "invite not found"); - return context.json({ invite: inviteView(invite), ttlDays: INVITE_TTL_DAYS }); + return context.json({ invite: inviteView(invite, Date.now()), ttlDays: INVITE_TTL_DAYS }); }); router.get("/invites", async (context) => { const principal = await requirePrincipal(context); const orgId = requireAdmin(principal); const runtime = runtimeFactory(context); - await expireInvites(runtime.db, Date.now(), orgId); + const now = Date.now(); const invites = await rows(runtime.db, { q: `SELECT invite.*, org.name AS org_name, creator.name AS creator_name FROM invites invite @@ -191,7 +256,10 @@ export function addInviteRoutes( ORDER BY invite.created_at DESC, invite.id`, v: [orgId], }); - return context.json({ invites: invites.map(inviteView), ttlDays: INVITE_TTL_DAYS }); + return context.json({ + invites: invites.map((invite) => inviteView(invite, now)), + ttlDays: INVITE_TTL_DAYS, + }); }); router.post("/invites", async (context) => { @@ -206,10 +274,9 @@ export function addInviteRoutes( const now = Date.now(); await rows(runtimeFactory(context).db, { q: `INSERT INTO invites - (id, code_hash, email, target_org_id, role, state, - created_by_membership_id, redeemed_by_user_id, created_at, - expires_at, redeemed_at) - VALUES (?1, ?2, ?3, ?4, ?5, 'ready', ?6, NULL, ?7, ?8, NULL)`, + (id, code_hash, email, target_org_id, role, + created_by_membership_id, created_at, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`, v: [ id, await inviteCodeHash(code), @@ -231,11 +298,15 @@ export function addInviteRoutes( router.delete("/invites/:id", async (context) => { const principal = await requirePrincipal(context); const orgId = requireAdmin(principal); + // The guard is the derived 'ready' predicate: an already-expired invite + // reads as expired everywhere, so revoking it is the same 409 it always + // was once a listing had synced the row. const changed = await rows<{ id: string }>(runtimeFactory(context).db, { - q: `UPDATE invites SET state = 'revoked' - WHERE id = ?1 AND target_org_id = ?2 AND state = 'ready' + q: `UPDATE invites SET revoked_at = ?3 + WHERE id = ?1 AND target_org_id = ?2 AND revoked_at IS NULL + AND redeemed_at IS NULL AND expires_at > ?3 RETURNING id`, - v: [context.req.param("id"), orgId], + v: [context.req.param("id"), orgId, Date.now()], }); if (changed.length === 0) { const exists = await first<{ id: string }>(runtimeFactory(context).db, { diff --git a/packages/control-plane/core/identity/members.ts b/packages/control-plane/core/identity/members.ts index 38afdcf5..51b81e92 100644 --- a/packages/control-plane/core/identity/members.ts +++ b/packages/control-plane/core/identity/members.ts @@ -106,20 +106,8 @@ export function addMemberRoutes( const mutation = parseMutation(await readJson(context.req.raw)); const nextRole = mutation.role ?? member.role; const nextStatus = mutation.status ?? member.status; - if ( - member.role === "admin" - && member.status === "active" - && (nextRole !== "admin" || nextStatus !== "active") - ) { - const activeAdmins = await first<{ count: number }>(runtime.db, { - q: `SELECT COUNT(*) AS count FROM memberships - WHERE org_id = ?1 AND role = 'admin' AND status = 'active'`, - v: [orgId], - }); - if ((activeAdmins?.count ?? 0) <= 1) { - throw new HttpError(409, "the last active admin cannot be changed"); - } - } + // The last-active-admin rule lives in the UPDATE's own guard below, where + // it holds under concurrency; zero changed rows is that 409. const changed = await rows<{ id: string }>(runtime.db, { q: `UPDATE memberships SET role = ?1, status = ?2 WHERE id = ?3 AND org_id = ?4 diff --git a/packages/control-plane/core/index.ts b/packages/control-plane/core/index.ts index b2dfe492..df42b848 100644 --- a/packages/control-plane/core/index.ts +++ b/packages/control-plane/core/index.ts @@ -7,13 +7,12 @@ export { changed, first, rows, transaction } from "./db.js"; /** Shared string guard: the Worker entry points narrow catalog-named * bindings with the same predicate core parses boundaries with. */ export { isString } from "./http.js"; -export { runLeaseSweep } from "./connections/leases.js"; export { runProviderCanary } from "./connections/canary.js"; export { FILE_SYNC_MAX_BYTES_PER_TICK, FILE_SYNC_MAX_FILES_PER_TICK, - runFileSyncSweep, -} from "./files/sync.js"; +} from "./files/dav.js"; +export { runFileSyncSweep } from "./files/sync.js"; export { credentialMasterKeyFor, importCredentialMasterKey, @@ -23,11 +22,11 @@ export { maybeScheduleLazySweep, runInvariantSweep, runOrphanSweep, + runScheduledMaintenance, runWorkspaceTunnelSweep, runSessionSweep, } from "./janitors.js"; -export { createSessionPrincipalSource } from "./principals.js"; -export type { Principal, PrincipalSource } from "./principals.js"; +export type { Principal } from "./principals.js"; export { HetznerProvider } from "./compute/hetzner.js"; export { HETZNER_USER_DATA_MAX_BYTES } from "./compute/hetzner.js"; export { @@ -38,7 +37,6 @@ export { export type { AwsProviderConfig, AwsProviderEnv, - AwsProviderOptions, } from "./compute/aws.js"; export { VmProviderRegistry } from "./compute/registry.js"; export { WorkspaceTunnels, workspaceTunnelsFromEnv, WEBAPP_TOKEN_HEADER } from "./workspace-tunnels.js"; @@ -86,7 +84,6 @@ export type { } from "./runtime.js"; export { allowedEmailDomainsFromEnv, - maxConcurrentWorkspacesFromEnv, sessionTtlMsFromEnv, signupModeFromEnv, } from "./runtime.js"; diff --git a/packages/control-plane/core/janitors.ts b/packages/control-plane/core/janitors.ts index ac692919..5dff4172 100644 --- a/packages/control-plane/core/janitors.ts +++ b/packages/control-plane/core/janitors.ts @@ -1,10 +1,8 @@ import { changed, rows, transaction } from "./db.js"; -import { - revokeWorkspaceLeasesQuery, - runLeaseSweep, -} from "./connections/leases.js"; +import { runProviderCanary } from "./connections/canary.js"; +import { runFileSyncSweep } from "./files/sync.js"; import type { CoreRuntime } from "./runtime.js"; -import type { WorkspaceRow } from "./workspaces.js"; +import { finalizeWorkspaceDestroyQueries, type WorkspaceRow } from "./workspaces.js"; const STUCK_CREATING_MS = 60 * 60 * 1000; export const LAZY_SWEEP_INTERVAL_MS = 5 * 60_000; @@ -34,9 +32,13 @@ function sweepPath(path: string): boolean { } export async function runOrphanSweep(runtime: CoreRuntime): Promise { + // SAFETY: only phase 'destroying' can hold a VM. Both destroy finalizers — + // the DELETE route and this sweep, via finalizeWorkspaceDestroyQueries — + // clear vm_id in the same UPDATE that sets phase = 'destroyed', so a + // destroyed row with a non-null vm_id cannot exist. const result = await rows(runtime.db, { q: `SELECT * FROM workspaces - WHERE vm_id IS NOT NULL AND phase IN ('destroying', 'destroyed') + WHERE vm_id IS NOT NULL AND phase = 'destroying' ORDER BY updated_at, id`, v: [], }); @@ -60,28 +62,11 @@ export async function runOrphanSweep(runtime: CoreRuntime): Promise { if ((await provider.inspect(row.vm_id)) !== null) { await provider.destroy(row.vm_id); } - if (row.phase === "destroying") { - const transition = await transaction(runtime.db, [ - revokeWorkspaceLeasesQuery(row.id), - { q: "DELETE FROM boxes WHERE workspace_id = ?1", v: [row.id] }, - { q: "DELETE FROM webapp_state WHERE workspace_id = ?1", v: [row.id] }, - { - q: `UPDATE workspaces - SET phase = 'destroyed', vm_id = NULL, ssh_host = NULL, ssh_port = NULL, - ssh_user = NULL, ssh_host_public_key = NULL, error = NULL, - revision = revision + 1, updated_at = ?1 - WHERE id = ?2 AND phase = 'destroying' - RETURNING id`, - v: [Date.now(), row.id], - }, - ]); - if (transition[3]?.length !== 1) continue; - } else { - await rows(runtime.db, { - q: "UPDATE workspaces SET vm_id = NULL WHERE id = ?1", - v: [row.id], - }); - } + const transition = await transaction( + runtime.db, + finalizeWorkspaceDestroyQueries(row.id, Date.now()), + ); + if (transition[3]?.length !== 1) continue; destroyed += 1; } return destroyed; @@ -114,10 +99,8 @@ export async function runWorkspaceTunnelSweep(runtime: CoreRuntime): Promise { +export async function runInvariantSweep(runtime: CoreRuntime): Promise { + const now = Date.now(); return changed(runtime.db, { q: `UPDATE workspaces SET phase = 'error', error = 'workspace creation timed out', @@ -128,13 +111,10 @@ export async function runInvariantSweep( }); } -export async function runSessionSweep( - runtime: CoreRuntime, - now = Date.now(), -): Promise { +export async function runSessionSweep(runtime: CoreRuntime): Promise { return changed(runtime.db, { q: "DELETE FROM sessions WHERE expires_at <= ?1 RETURNING token_hash", - v: [now], + v: [Date.now()], }); } @@ -150,7 +130,6 @@ export function maybeScheduleLazySweep(runtime: CoreRuntime, path: string): void inFlight = (async () => { try { await runSessionSweep(runtime); - await runLeaseSweep(runtime); await runInvariantSweep(runtime); await runOrphanSweep(runtime); await runWorkspaceTunnelSweep(runtime); @@ -167,3 +146,39 @@ export function maybeScheduleLazySweep(runtime: CoreRuntime, path: string): void })(); runtime.waitUntil(inFlight); } + +/** Must stay one of wrangler.toml's `triggers.crons` entries verbatim: the + * scheduled handler routes on the literal expression Cloudflare hands back. */ +const HOURLY_CRON = "0 * * * *"; +const DAILY_CRON = "0 3 * * *"; + +/** The whole cron-tick policy, owned here so the Worker entry point only + * builds a runtime and forwards `event.cron`. */ +export async function runScheduledMaintenance( + runtime: CoreRuntime, + cron: string, +): Promise { + // Only the hourly and daily schedules run the full janitor set. Any + // other tick (the */5 backstop today) converges folder sync alone, so + // renaming that cron can never silently multiply the heavy sweeps. + if (cron !== HOURLY_CRON && cron !== DAILY_CRON) { + const swept = await runFileSyncSweep(runtime); + console.log(JSON.stringify({ event: "file_sync_tick", cron, ...swept })); + return; + } + await runtime.providers.microvm?.syncStaticHosts(); + await runSessionSweep(runtime); + await runInvariantSweep(runtime); + await runOrphanSweep(runtime); + await runWorkspaceTunnelSweep(runtime); + // The canary is the one sweep that costs an authenticated call to a + // third party per provider, so it takes the hourly tick alone. On the + // daily tick as well it would be counted twice against the same rate + // limit for no extra signal. + if (cron === HOURLY_CRON) { + const probed = await runProviderCanary(runtime); + console.log(JSON.stringify({ event: "provider_canary_tick", cron, probed })); + } + const swept = await runFileSyncSweep(runtime); + console.log(JSON.stringify({ event: "file_sync_tick", cron, ...swept })); +} diff --git a/packages/control-plane/core/oauth-state.ts b/packages/control-plane/core/oauth-state.ts index 45a7524a..32352823 100644 --- a/packages/control-plane/core/oauth-state.ts +++ b/packages/control-plane/core/oauth-state.ts @@ -1,4 +1,4 @@ -import { randomToken, safeEqualSecret } from "./crypto.js"; +import { base64Url, decodeBase64Url, randomToken, safeEqualSecret } from "./crypto.js"; import { isNumber, isRecord, isString, type JsonObject, type JsonValue } from "./http.js"; const OAUTH_STATE_TTL_MS = 10 * 60 * 1_000; @@ -39,26 +39,6 @@ interface OAuthStateFlow { read(parsed: JsonObject): Extra | null; } -function base64Url(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replace(/=+$/u, ""); -} - -function decodeBase64Url(value: string): Uint8Array | null { - if (!/^[A-Za-z0-9_-]+$/u.test(value)) return null; - const padding = "=".repeat((4 - (value.length % 4)) % 4); - try { - const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/") + padding); - return Uint8Array.from(binary, (character) => character.charCodeAt(0)); - } catch { - return null; - } -} - async function hmac(value: string, secret: string): Promise { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( diff --git a/packages/control-plane/core/oauth.ts b/packages/control-plane/core/oauth.ts index 81db37f7..e243d3dc 100644 --- a/packages/control-plane/core/oauth.ts +++ b/packages/control-plane/core/oauth.ts @@ -108,7 +108,10 @@ async function refreshGrant( const oldHash = await hashSecret(refreshToken); const row = await first(db, { q: `SELECT f.access_hash, f.refresh_hash, f.access_issued_at, - b.id, b.principal_id, b.workspace_id, b.is_broker + b.id, b.principal_id, b.workspace_id, + EXISTS( + SELECT 1 FROM broker_boxes broker WHERE broker.box_id = b.id + ) AS is_broker FROM box_token_families f JOIN boxes b ON b.id = f.box_id WHERE f.refresh_hash = ?1 LIMIT 1`, v: [oldHash], @@ -145,7 +148,10 @@ export async function authenticateBox( const hash = await hashSecret(token); const row = await first(db, { q: `SELECT f.access_hash, f.refresh_hash, f.access_issued_at, - b.id, b.principal_id, b.workspace_id, b.is_broker, + b.id, b.principal_id, b.workspace_id, + EXISTS( + SELECT 1 FROM broker_boxes broker WHERE broker.box_id = b.id + ) AS is_broker, COALESCE(u.platform_operator, 0) AS platform_operator FROM box_token_families f JOIN boxes b ON b.id = f.box_id diff --git a/packages/control-plane/core/principals.ts b/packages/control-plane/core/principals.ts index 3c4af22c..1cb35b14 100644 --- a/packages/control-plane/core/principals.ts +++ b/packages/control-plane/core/principals.ts @@ -1,4 +1,4 @@ -import { DUMMY_HASH, hashSecret, matchesStoredHash, randomToken } from "./crypto.js"; +import { DUMMY_HASH, hashSecret, matchesStoredHash } from "./crypto.js"; import type { Db } from "./db.js"; import { first, rows } from "./db.js"; @@ -12,10 +12,6 @@ export interface Principal { platformOperator: boolean; } -export interface PrincipalSource { - authenticate(request: Request, db: Db): Promise; -} - interface SessionRow { token_hash: string; id: string; @@ -75,10 +71,6 @@ export async function findSessionPrincipal( }; } -export function createSessionPrincipalSource(): PrincipalSource { - return { authenticate: findSessionPrincipal }; -} - /** Re-resolves the principal a signed OAuth state names. OAuth callbacks * arrive as cross-site navigations, which the SameSite=Strict session cookie * deliberately does not accompany, so the connect flow binds the principal @@ -120,22 +112,6 @@ export async function ensurePrincipal(db: Db, principal: Principal): Promise { - await ensurePrincipal(db, principal); - const token = randomToken(); - await rows(db, { - q: `INSERT INTO sessions (token_hash, principal_id, created_at, expires_at) - VALUES (?1, ?2, ?3, ?4)`, - v: [await hashSecret(token), principal.id, now, now + ttlMs], - }); - return token; -} - export function sessionCookie(token: string, ttlMs: number): string { return `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${Math.floor(ttlMs / 1000)}`; } diff --git a/packages/control-plane/core/recipes.ts b/packages/control-plane/core/recipes.ts index a16d1c56..3be8fb70 100644 --- a/packages/control-plane/core/recipes.ts +++ b/packages/control-plane/core/recipes.ts @@ -280,9 +280,9 @@ export function addRecipeRoutes( const principal = await memberFor(context); const recipe = await recipeForOrg(runtime.db, context.req.param("id"), principal.orgId); requireRecipeEditRights(principal, recipe); - // Workspaces keep running when their recipe goes; provenance nulls out - // first so the delete cannot trip the foreign key (same pattern as - // agent-rules deletion). + // Workspaces keep running when their recipe goes. Unlike agent_rules, + // `workspaces.recipe_id` declares no ON DELETE action (migrations/0021), + // so provenance must null out here or the enforced key rejects the delete. await transaction(runtime.db, [ { q: "UPDATE workspaces SET recipe_id = NULL WHERE recipe_id = ?1", v: [recipe.id] }, { q: "DELETE FROM recipes WHERE id = ?1", v: [recipe.id] }, diff --git a/packages/control-plane/core/registry.ts b/packages/control-plane/core/registry.ts index 7a216ab7..61c77f3f 100644 --- a/packages/control-plane/core/registry.ts +++ b/packages/control-plane/core/registry.ts @@ -26,6 +26,8 @@ interface BoxRow { principal_id: string; workspace_id: string | null; broker_box_id: string | null; + /** Derived: 1 when the box is enrolled in broker_boxes. The boxes table + * carries no broker flag; enrollment rows are the single source of truth. */ is_broker: number; } @@ -117,7 +119,11 @@ async function requireOwnBox( async function boxRow(db: Db, id: string): Promise { return first(db, { - q: "SELECT * FROM boxes WHERE id = ?1 LIMIT 1", + q: `SELECT b.id, b.principal_id, b.workspace_id, b.broker_box_id, + EXISTS( + SELECT 1 FROM broker_boxes broker WHERE broker.box_id = b.id + ) AS is_broker + FROM boxes b WHERE b.id = ?1 LIMIT 1`, v: [id], }); } @@ -169,21 +175,15 @@ async function leastLoadedBroker(db: Db, excludeBoxId: string): Promise { + // SAFETY: broker_members.broker_box_id is NOT NULL REFERENCES + // broker_boxes(box_id) ON DELETE CASCADE (migrations/0020_broker_members.sql), + // so a membership row can only name a currently enrolled broker; a + // de-enrolled broker deletes its memberships in the same statement. const row = await first<{ broker_box_id: string }>(db, { - q: `SELECT member.broker_box_id AS broker_box_id - FROM broker_members member - JOIN broker_boxes broker ON broker.box_id = member.broker_box_id - WHERE member.principal_id = ?1 + q: `SELECT broker_box_id FROM broker_members + WHERE principal_id = ?1 LIMIT 1`, v: [principalId], }); @@ -224,7 +224,7 @@ export function addRegistryRoutes( } await transaction(runtimeFactory(context).db, [ { - q: "UPDATE boxes SET is_broker = 1, broker_box_id = NULL WHERE id = ?1", + q: "UPDATE boxes SET broker_box_id = NULL WHERE id = ?1", v: [box.id], }, { @@ -241,10 +241,10 @@ export function addRegistryRoutes( router.delete("/boxes/:id/broker", async (context) => { const box = await requireOwnBox(context, runtimeFactory); - await transaction(runtimeFactory(context).db, [ - { q: "DELETE FROM broker_boxes WHERE box_id = ?1", v: [box.id] }, - { q: "UPDATE boxes SET is_broker = 0 WHERE id = ?1", v: [box.id] }, - ]); + await rows(runtimeFactory(context).db, { + q: "DELETE FROM broker_boxes WHERE box_id = ?1", + v: [box.id], + }); return context.body(null, 204); }); diff --git a/packages/control-plane/core/runtime.ts b/packages/control-plane/core/runtime.ts index 18e5edde..f62dbf1b 100644 --- a/packages/control-plane/core/runtime.ts +++ b/packages/control-plane/core/runtime.ts @@ -1,7 +1,6 @@ import type { BlobStore } from "./blobs.js"; import type { Db } from "./db.js"; import { isNumber } from "./http.js"; -import type { PrincipalSource } from "./principals.js"; import type { MicrovmPoolProvider } from "./compute/microvm.js"; import type { VmProviderRegistry } from "./compute/registry.js"; import type { VolumeProvider } from "./compute/types.js"; @@ -55,7 +54,6 @@ export interface RuntimeVariables { boxImageSha256: string; boxImageTag: string; sessionTtlMs: number; - maxConcurrentWorkspaces: number; googleClientId: string; googleClientSecret: string; bootstrapSecret: string; @@ -82,14 +80,6 @@ export function sessionTtlMsFromEnv(value: string | number | null | undefined): return days * DAY_MS; } -export function maxConcurrentWorkspacesFromEnv(value: string | number | null | undefined): number { - const limit = isNumber(value) ? value : Number(value); - if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) { - throw new Error("MAX_CONCURRENT_WORKSPACES must be an integer from 1 through 1000"); - } - return limit; -} - // Re-exported, not reimplemented: scripts/deploy-helpers.mjs imports the same // module to reject a bad SIGNUP_MODE or ALLOWED_EMAIL_DOMAINS at deploy time. export { allowedEmailDomainsFromEnv, signupModeFromEnv } from "./signup-config.js"; @@ -107,7 +97,6 @@ export interface CoreRuntime { workspaceTunnels?: WorkspaceTunnels; webAppAuth?: WorkspaceWebAppAuth; }; - principalSource: PrincipalSource; /** Serves the webApp shell for browser navigations that land on API paths * shared with SPA pages (a refresh on /workspaces/:id). Absent where the * deployment has no programmatic asset access. */ diff --git a/packages/control-plane/core/webapp-tickets.ts b/packages/control-plane/core/webapp-tickets.ts index f8c6f5c5..89313ea7 100644 --- a/packages/control-plane/core/webapp-tickets.ts +++ b/packages/control-plane/core/webapp-tickets.ts @@ -1,3 +1,4 @@ +import { base64Url, decodeBase64Url } from "./crypto.js"; import { HttpError, isNumber, isRecord, isString, type JsonValue } from "./http.js"; import type { WorkspaceRole } from "./wire.js"; @@ -32,24 +33,6 @@ export type VerifiedWebAppCredential = const encoder = new TextEncoder(); -function base64Url(bytes: Uint8Array): string { - return btoa(String.fromCharCode(...bytes)) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replaceAll("=", ""); -} - -function decodeBase64Url(value: string): Uint8Array | null { - if (!/^[A-Za-z0-9_-]+$/u.test(value)) return null; - const padded = value.replaceAll("-", "+").replaceAll("_", "/") - + "=".repeat((4 - value.length % 4) % 4); - try { - return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); - } catch { - return null; - } -} - async function hmacKey(secret: string): Promise { return crypto.subtle.importKey( "raw", diff --git a/packages/control-plane/core/workspace-access.ts b/packages/control-plane/core/workspace-access.ts index 8d26c483..91015817 100644 --- a/packages/control-plane/core/workspace-access.ts +++ b/packages/control-plane/core/workspace-access.ts @@ -3,7 +3,7 @@ import { first } from "./db.js"; import { HttpError } from "./http.js"; import type { Principal } from "./principals.js"; import { cookieValue, SESSION_COOKIE } from "./principals.js"; -import { hashSecret, matchesStoredHash } from "./crypto.js"; +import { hashSecret } from "./crypto.js"; import type { CoreContext, CoreRuntime } from "./runtime.js"; import { workspaceById, type WorkspaceRow } from "./workspace-records.js"; import type { WorkspaceRole } from "./wire.js"; @@ -63,15 +63,17 @@ export async function webAppWorkspaceForRequest( const token = cookieValue(context.req.raw, SESSION_COOKIE); if (token !== null) { const hash = await hashSecret(token); + // SAFETY: sessions are selected by exact equality on the deterministic, + // unsalted SHA-256 of the presented token (`s.token_hash = ?1`), so a + // returned row's stored hash IS the recomputed hash — re-verifying it + // could never reject. Row presence is the authentication result. const row = await first(runtime.db, { - q: `SELECT w.*, s.token_hash AS session_token_hash, - s.principal_id AS session_principal_id, + q: `SELECT w.*, s.principal_id AS session_principal_id, m.id AS session_membership_id, m.org_id AS session_org_id, m.role AS session_member_role, grant.role AS grant_role, owner_user.name AS owner_name, @@ -90,7 +92,7 @@ export async function webAppWorkspaceForRequest( LIMIT 1`, v: [hash, Date.now(), id], }); - if (row !== null && (await matchesStoredHash(token, row.session_token_hash))) { + if (row !== null) { const principal: Principal = { id: row.session_principal_id, unixName: "blitz", diff --git a/packages/control-plane/core/workspace-tunnels.ts b/packages/control-plane/core/workspace-tunnels.ts index 9d74de97..bce8a414 100644 --- a/packages/control-plane/core/workspace-tunnels.ts +++ b/packages/control-plane/core/workspace-tunnels.ts @@ -4,7 +4,6 @@ import { CloudflareTunnels, type SurfaceCleanupResult, } from "./compute/cloudflare-tunnels.js"; -import type { Fetcher } from "./compute/json-fetch.js"; import type { WebAppPort } from "./compute/types.js"; import { WEBAPP_TOKEN_HEADER, WorkspaceWebAppAuth } from "./webapp-tickets.js"; @@ -39,18 +38,15 @@ export class WorkspaceTunnels { private readonly client: CloudflareTunnels; private readonly zone: string; private readonly auth: WorkspaceWebAppAuth; - private readonly fetcher: Fetcher; constructor( client: CloudflareTunnels, zone: string, webAppTokenSecret: string, - fetcher: Fetcher = fetch, ) { this.client = client; this.zone = zone; this.auth = new WorkspaceWebAppAuth(webAppTokenSecret); - this.fetcher = fetcher; } hostnameFor(workspaceId: string): string { @@ -127,8 +123,7 @@ export class WorkspaceTunnels { headers.delete("Authorization"); headers.set(WEBAPP_TOKEN_HEADER, credential ?? await this.webAppTokenFor(workspaceId)); const hasBody = request.method !== "GET" && request.method !== "HEAD"; - const fetcher = this.fetcher; - return fetcher(`https://${hostname}${upstreamPath}`, { + return fetch(`https://${hostname}${upstreamPath}`, { method: request.method, headers, body: hasBody ? request.body : undefined, diff --git a/packages/control-plane/core/workspaces.ts b/packages/control-plane/core/workspaces.ts index e475d109..3969c5e7 100644 --- a/packages/control-plane/core/workspaces.ts +++ b/packages/control-plane/core/workspaces.ts @@ -6,7 +6,7 @@ import { revokeWorkspaceLeasesQuery } from "./connections/leases.js"; import { mintWorkspaceConnection, workspaceForMint } from "./connections/mint.js"; import { connectionByName } from "./connections/registry.js"; import { hashSecret, matchesStoredHash, randomToken } from "./crypto.js"; -import type { Db } from "./db.js"; +import type { Db, Query } from "./db.js"; import { first, rows, transaction } from "./db.js"; import { parseWorkspaceEnvironment, @@ -38,7 +38,8 @@ import { templateWorkspaceName, workspaceTemplateForCreate, } from "./workspace-templates.js"; -import { runReadyWorkspaceFileSync, scheduleSync } from "./files/sync.js"; +import { scheduleSync } from "./files/schedule.js"; +import { runReadyWorkspaceFileSync } from "./files/sync.js"; import type { CoreContext, CoreRouter, @@ -362,7 +363,12 @@ async function connectRequested( ): Promise { if (connectionNames.length === 0) return; const workspace = await workspaceForMint(runtime, workspaceId); - if (workspace === null || workspace.org_id === null) return; + // SAFETY: the sole caller invokes this right after inserting the workspace + // row with the principal's non-null org id, and nothing deletes workspace + // rows between that insert and this read — destroy only flips phase. + if (workspace === null || workspace.org_id === null) { + throw new Error("workspace row vanished between insert and connection minting"); + } for (const name of connectionNames) { const connection = await connectionByName(runtime.db, name, workspace.org_id); if (connection === null) continue; @@ -533,7 +539,7 @@ export async function performWorkspaceCreate( undefined, shaping, ); - const maxUserDataBytes = providerCapabilities.maxUserDataBytes ?? null; + const maxUserDataBytes = providerCapabilities.maxUserDataBytes; if (maxUserDataBytes !== null) { const encoder = new TextEncoder(); const callerBytes = encoder.encode(input.userData ?? "").byteLength; @@ -612,6 +618,27 @@ export async function performWorkspaceCreate( return row; } +/** The one destroy-finalize transaction, shared by the DELETE route and the + * orphan sweep: revoke leases, drop the box and webApp state, and tombstone + * the row — clearing vm_id atomically with the phase change. The final query + * RETURNING lets callers detect a lost race on `phase = 'destroying'`. */ +export function finalizeWorkspaceDestroyQueries(id: string, now: number): Query[] { + return [ + revokeWorkspaceLeasesQuery(id, now), + { q: "DELETE FROM boxes WHERE workspace_id = ?1", v: [id] }, + { q: "DELETE FROM webapp_state WHERE workspace_id = ?1", v: [id] }, + { + q: `UPDATE workspaces + SET phase = 'destroyed', vm_id = NULL, ssh_host = NULL, ssh_port = NULL, + ssh_user = NULL, ssh_host_public_key = NULL, error = NULL, + revision = revision + 1, updated_at = ?1 + WHERE id = ?2 AND phase = 'destroying' + RETURNING id`, + v: [now, id], + }, + ]; +} + export function addWorkspaceRoutes( router: CoreRouter, runtimeFactory: RuntimeFactory, @@ -742,7 +769,7 @@ export function addWorkspaceRoutes( : await webAppAuth.tokenFor(row.id); const authenticatedRequest = requestWithWebAppCredential(context.req.raw, credential); const workspaceTunnels = runtime.providers.workspaceTunnels; - let upstream: Response | null; + let upstream: Response; try { if (provider.proxyWebApp !== undefined) { upstream = await provider.proxyWebApp( @@ -768,12 +795,10 @@ export function addWorkspaceRoutes( const detail = error instanceof Error ? error.message : String(error); throw new HttpError(502, `workspace webApp proxy is unavailable: ${detail}`); } - if (upstream === null) { - throw new HttpError(409, "workspace VM is not owned by its resolved provider"); - } if (!isWebSocketUpgrade(context.req.raw)) return upstream; - if (upstream.status !== 101 || upstream.webSocket === null) return upstream; - return websocketProxyResponse(upstream); + const upstreamSocket = upstream.webSocket; + if (upstream.status !== 101 || upstreamSocket === null) return upstream; + return websocketProxyResponse(upstream, upstreamSocket); }; router.all("/workspaces/:id/webapp/:port", webApp); @@ -829,19 +854,7 @@ export function addWorkspaceRoutes( } } - await transaction(runtime.db, [ - revokeWorkspaceLeasesQuery(id), - { q: "DELETE FROM boxes WHERE workspace_id = ?1", v: [id] }, - { q: "DELETE FROM webapp_state WHERE workspace_id = ?1", v: [id] }, - { - q: `UPDATE workspaces - SET phase = 'destroyed', vm_id = NULL, ssh_host = NULL, ssh_port = NULL, - ssh_user = NULL, ssh_host_public_key = NULL, error = NULL, - revision = revision + 1, updated_at = ?1 - WHERE id = ?2 AND phase = 'destroying'`, - v: [Date.now(), id], - }, - ]); + await transaction(runtime.db, finalizeWorkspaceDestroyQueries(id, Date.now())); const destroyed = await workspaceById(runtime.db, id); if (destroyed === null) throw new Error("workspace disappeared after destroy"); return context.json({ @@ -972,9 +985,7 @@ function pipeWebSocket(source: WebSocket, target: WebSocket): void { }); } -function websocketProxyResponse(upstream: Response): Response { - const upstreamSocket = upstream.webSocket; - if (upstreamSocket === null) return upstream; +function websocketProxyResponse(upstream: Response, upstreamSocket: WebSocket): Response { const pair = new WebSocketPair(); const client = pair[0]; const server = pair[1]; diff --git a/packages/control-plane/migrations/0028_drop_boxes_is_broker.sql b/packages/control-plane/migrations/0028_drop_boxes_is_broker.sql new file mode 100644 index 00000000..732867e3 --- /dev/null +++ b/packages/control-plane/migrations/0028_drop_boxes_is_broker.sql @@ -0,0 +1,4 @@ +-- boxes.is_broker mirrored membership in broker_boxes; enrollment rows are +-- the single source of truth, and every reader now derives broker status +-- with an EXISTS against broker_boxes. +ALTER TABLE boxes DROP COLUMN is_broker; diff --git a/packages/control-plane/migrations/0029_drop_invites_state.sql b/packages/control-plane/migrations/0029_drop_invites_state.sql new file mode 100644 index 00000000..ae59bdf6 --- /dev/null +++ b/packages/control-plane/migrations/0029_drop_invites_state.sql @@ -0,0 +1,19 @@ +-- Invite lifecycle becomes stored facts instead of a synced label. Redemption +-- already stamps redeemed_at; revocation now stamps revoked_at; expiry was +-- never a decision at all, just what expires_at says at read time. Readers +-- derive the old four answers (revoked_at → 'revoked', else redeemed_at → +-- 'redeemed', else past expires_at → 'expired', else 'ready'), so the state +-- column has no writer or reader left. Rows the old write-on-read sync had +-- flipped to 'expired' derive the same answer from their expires_at. +-- +-- SQLite refuses to drop a column an index mentions, so invites_org steps +-- aside and returns without state: the surviving list query filters by org +-- and orders by created_at. +DROP INDEX invites_org; +ALTER TABLE invites ADD COLUMN revoked_at INTEGER; +-- Revocation predates the timestamp, so the exact moment is gone; migration +-- time is the closest true epoch-ms fact, in the unit created_at uses. +UPDATE invites SET revoked_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000 +WHERE state = 'revoked'; +ALTER TABLE invites DROP COLUMN state; +CREATE INDEX invites_org ON invites(target_org_id, created_at); diff --git a/packages/control-plane/migrations/0030_drop_credential_leases_state.sql b/packages/control-plane/migrations/0030_drop_credential_leases_state.sql new file mode 100644 index 00000000..d9ef4b32 --- /dev/null +++ b/packages/control-plane/migrations/0030_drop_credential_leases_state.sql @@ -0,0 +1,33 @@ +-- Lease lifecycle becomes stored facts instead of a swept label. The five +-- revocation writers now stamp revoked_at (still NULLing token_hash); expiry +-- is what expires_at says at read time; and the sweep that synced 'expired' +-- into rows is deleted. Readers derive the old three answers: revoked_at → +-- 'revoked', else past expires_at → 'expired', else 'active'. Revoked wins +-- over expired, exactly as the old sweep — which only flipped active rows — +-- left revoked labels alone as they aged. +-- +-- SQLite refuses to drop a column an index mentions, so the three +-- state-bearing indexes step aside. They return keyed on what the surviving +-- queries actually filter by: workspace_id (lease list, supersede-on-mint, +-- surface tombstones, workspace destroy) and grant_id (grant revocation). +-- No surviving query filters on expires_at alone — the proxy authorizes by +-- primary key — so leases_expiry, which existed for the deleted sweep, does +-- not come back. leases_token (partial, token_hash only) never mentioned +-- state and stays put. +DROP INDEX leases_workspace; +DROP INDEX leases_expiry; +DROP INDEX leases_grant; +ALTER TABLE credential_leases ADD COLUMN revoked_at INTEGER; +-- Backfill from the audit trail where it exists: the single-lease revoke +-- route logged each revocation as a credential_events row. Bulk revocations +-- (workspace destroy, connection delete, grant replace, supersede-on-mint) +-- wrote no event, so those rows get migration time — the closest true +-- epoch-ms fact, in the unit issued_at uses. +UPDATE credential_leases SET revoked_at = COALESCE( + (SELECT MIN(event.created_at) FROM credential_events event + WHERE event.lease_id = credential_leases.id AND event.event = 'revoked'), + CAST(strftime('%s', 'now') AS INTEGER) * 1000 +) WHERE state = 'revoked'; +ALTER TABLE credential_leases DROP COLUMN state; +CREATE INDEX leases_workspace ON credential_leases(workspace_id); +CREATE INDEX leases_grant ON credential_leases(grant_id); diff --git a/packages/control-plane/scripts/deploy-helpers.mjs b/packages/control-plane/scripts/deploy-helpers.mjs index 61339d22..14846f55 100644 --- a/packages/control-plane/scripts/deploy-helpers.mjs +++ b/packages/control-plane/scripts/deploy-helpers.mjs @@ -38,43 +38,34 @@ export function parseD1Binding(rawConfig, binding) { if (!isRecord(rawConfig) || !Array.isArray(rawConfig.d1_databases)) { throw new Error("wrangler config must define d1_databases"); } - const matches = rawConfig.d1_databases.filter( - (database) => isRecord(database) && database.binding === binding, + const indexes = rawConfig.d1_databases.flatMap((database, index) => + isRecord(database) && database.binding === binding ? [index] : [], ); - if (matches.length !== 1) { + if (indexes.length !== 1) { throw new Error(`wrangler config must define exactly one ${binding} D1 binding`); } - const database = matches[0]; + const database = rawConfig.d1_databases[indexes[0]]; if (typeof database.database_name !== "string" || database.database_name === "") { throw new Error(`${binding} must define database_name`); } return { binding, + index: indexes[0], databaseName: database.database_name, databaseId: typeof database.database_id === "string" ? database.database_id : null, }; } -export function d1DatabasePatch(rawConfig, binding, databaseName, databaseId) { - if (!isRecord(rawConfig) || !Array.isArray(rawConfig.d1_databases)) { - throw new Error("wrangler config must define d1_databases"); - } +// `parsed` is parseD1Binding's result for the same rawConfig, so the patch +// target is the entry it already validated — no re-matching by name here. +export function d1DatabasePatch(rawConfig, parsed, databaseId) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(databaseId)) { - throw new Error(`Cloudflare returned an invalid D1 database UUID for ${databaseName}`); - } - let replacements = 0; - const d1Databases = rawConfig.d1_databases.map((database) => { - if (!isRecord(database) || database.binding !== binding) return database; - replacements += 1; - if (database.database_name !== databaseName) { - throw new Error(`${binding} database_name changed while preparing deployment`); - } - return { ...database, database_id: databaseId }; - }); - if (replacements !== 1) { - throw new Error(`wrangler config must define exactly one ${binding} D1 binding`); + throw new Error(`Cloudflare returned an invalid D1 database UUID for ${parsed.databaseName}`); } + const d1Databases = rawConfig.d1_databases.map((database, index) => + index === parsed.index ? { ...database, database_id: databaseId } : database, + ); return { d1_databases: d1Databases }; } @@ -296,14 +287,7 @@ export async function deployControlPlane({ } if (databaseBinding.databaseId !== databaseId) { - await patchConfig( - d1DatabasePatch( - rawConfig, - DB_BINDING, - databaseBinding.databaseName, - databaseId, - ), - ); + await patchConfig(d1DatabasePatch(rawConfig, databaseBinding, databaseId)); } await wrangler(["d1", "migrations", "apply", DB_BINDING, "--remote"]); @@ -313,12 +297,7 @@ export async function deployControlPlane({ await wrangler(["r2", "bucket", "create", bucketBinding.bucketName], true); } - let secrets; - try { - secrets = await wrangler(["secret", "list", "--format", "json"], true); - } catch { - throw new Error(missingSecretsMessage(REQUIRED_SECRETS)); - } + const secrets = await wrangler(["secret", "list", "--format", "json"], true); const present = secretNames(parseJson(secrets.stdout, "Wrangler secret list")); const missing = requiredSecretsForConfig(rawConfig).filter((name) => !present.has(name)); if (missing.length > 0) throw new Error(missingSecretsMessage(missing)); diff --git a/packages/control-plane/scripts/lib/managed-api.mjs b/packages/control-plane/scripts/lib/managed-api.mjs index 1d29a120..0ca6c75c 100644 --- a/packages/control-plane/scripts/lib/managed-api.mjs +++ b/packages/control-plane/scripts/lib/managed-api.mjs @@ -41,8 +41,8 @@ export async function projectAccess(probeFile) { }; } -export async function managedApiRequest(access, route, init = {}, fetcher = fetch, { allowMissing = false } = {}) { - const response = await fetcher(`${access.base}${route}`, { +export async function managedApiRequest(access, route, init = {}, { allowMissing = false } = {}) { + const response = await fetch(`${access.base}${route}`, { ...init, headers: { Authorization: `Bearer ${access.token}`, @@ -61,28 +61,6 @@ export async function managedApiRequest(access, route, init = {}, fetcher = fetc return { response, body }; } -async function managedDataRequest(access, route, init = {}, { allowMissing = false, fetcher = fetch } = {}) { - const response = await fetcher(`${access.base}${route}`, { - ...init, - headers: { - Authorization: `Bearer ${access.token}`, - ...init.headers, - }, - }); - const text = await response.text(); - let body = text; - try { - body = JSON.parse(text); - } catch { - // Preserve the managed platform's exact text response for a redacted diagnostic. - } - if (allowMissing && response.status === 404) return { response, body: null }; - if (!response.ok) { - throw new Error(`managed data API ${response.status}: ${redactSecrets(typeof body === "string" ? body : JSON.stringify(body))}`); - } - return { response, body }; -} - function assertManagedFileRow(row, file) { if ( row === null || @@ -97,13 +75,13 @@ function assertManagedFileRow(row, file) { } } -async function readManagedFileRow(access, file, fetcher) { +async function readManagedFileRow(access, file) { const id = managedFileId(file.kind, file.logicalPath); - const selected = await managedDataRequest( + const selected = await managedApiRequest( access, `/exec/blitz_files/view/${encodeURIComponent(id)}`, {}, - { allowMissing: true, fetcher }, + { allowMissing: true }, ); return selected.body; } @@ -138,11 +116,11 @@ async function fileFormData(file, existing) { return form; } -export async function uploadManagedAssets(assetSet, access, projectPassword, { fetcher = fetch } = {}) { +export async function uploadManagedAssets(assetSet, access, projectPassword) { if (typeof projectPassword !== "string" || projectPassword.length === 0) throw new Error("project password is empty"); const result = { inserted: 0, replaced: 0, skipped: 0, verified: 0 }; for (const file of assetSet.files) { - const existing = await readManagedFileRow(access, file, fetcher); + const existing = await readManagedFileRow(access, file); if (existing !== null && existing.sha256 === file.sha256 && existing.size_bytes === file.sizeBytes) { assertManagedFileRow(existing, file); result.skipped += 1; @@ -154,12 +132,12 @@ export async function uploadManagedAssets(assetSet, access, projectPassword, { f const route = existing === null ? "/exec_write/blitz_files/insert" : `/exec_write/blitz_files/edit/${encodeURIComponent(id)}`; - await managedDataRequest(access, route, { + await managedApiRequest(access, route, { method: "POST", headers: { "X-Project-Password": projectPassword }, body: form, - }, { fetcher }); - const verified = await readManagedFileRow(access, file, fetcher); + }); + const verified = await readManagedFileRow(access, file); assertManagedFileRow(verified, file); if (existing === null) result.inserted += 1; else result.replaced += 1; @@ -199,22 +177,22 @@ export function migrationText(body) { return value; } -export async function fetchMigrationText(access, fetcher = fetch) { - const migration = await managedApiRequest(access, "/files?path=%40migration.sql", {}, fetcher, { allowMissing: true }); +export async function fetchMigrationText(access) { + const migration = await managedApiRequest(access, "/files?path=%40migration.sql", {}, { allowMissing: true }); return migrationText(migration.body); } -export async function uploadManagedSet(uploadSet, probeFile, { commit = false, fetcher = fetch, out = process.stdout } = {}) { - return pushManagedSet(uploadSet, await projectAccess(probeFile), { commit, fetcher, out }); +export async function uploadManagedSet(uploadSet, probeFile, { commit = false, out = process.stdout } = {}) { + return pushManagedSet(uploadSet, await projectAccess(probeFile), { commit, out }); } -export async function pushManagedSet(uploadSet, access, { commit = false, fetcher = fetch, out = process.stdout } = {}) { +export async function pushManagedSet(uploadSet, access, { commit = false, out = process.stdout } = {}) { await managedApiRequest(access, "/vars/APP_URL", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value: access.appUrl }), - }, fetcher); - const listed = await managedApiRequest(access, "/files", {}, fetcher); + }); + const listed = await managedApiRequest(access, "/files"); let version = saveVersion(listed.response, listed.body); if (version === undefined) throw new Error("file listing omitted x-save-version"); const byPath = new Map(uploadSet.files.map((file) => [file.path, file])); @@ -229,7 +207,7 @@ export async function pushManagedSet(uploadSet, access, { commit = false, fetche method: "PUT", headers: { "Content-Type": "text/plain; charset=utf-8", "If-Match": version }, body: file.source, - }, fetcher); + }); if (typeof saved.body !== "object" || saved.body === null || saved.body.success !== true) { throw new Error(`save failed for ${file.path}: ${redactSecrets(JSON.stringify(saved.body))}`); } @@ -246,7 +224,7 @@ export async function pushManagedSet(uploadSet, access, { commit = false, fetche // An empty migration still commits: a re-run whose schema is unchanged may // carry changed worker sources, and the commit is what deploys them. - const pending = await fetchMigrationText(access, fetcher); + const pending = await fetchMigrationText(access); const migration = pending === "" ? "" : normalizeSource(pending); writeRedacted(`migration-schema-sha256\t${byPath.get("teenybase.ts").sha256}\n`, out); writeRedacted(`migration-sql-sha256\t${sha256(migration)}\n`, out); @@ -257,7 +235,7 @@ export async function pushManagedSet(uploadSet, access, { commit = false, fetche method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: `blitz-core managed release ${uploadSet.releaseHash}` }), - }, fetcher); + }); if (typeof committed.body !== "object" || committed.body === null || committed.body.success !== true) { throw new Error(`commit failed: ${redactSecrets(JSON.stringify(committed.body))}`); } diff --git a/packages/control-plane/scripts/lib/worker-source.mjs b/packages/control-plane/scripts/lib/worker-source.mjs index 6d4d06bf..ea3e90b2 100644 --- a/packages/control-plane/scripts/lib/worker-source.mjs +++ b/packages/control-plane/scripts/lib/worker-source.mjs @@ -75,12 +75,6 @@ export const TEXT_ASSETS = Object.freeze([ }), ]); export const GENERATED_MANIFEST = Object.freeze(TEXT_ASSETS.map((asset) => asset.uploadPath)); -export const UPLOAD_MANIFEST = Object.freeze([ - "teenybase.ts", - "worker.ts", - ...CORE_MANIFEST, - ...GENERATED_MANIFEST, -]); export const UPLOAD_ORDER = Object.freeze([ ...CORE_MANIFEST, ...GENERATED_MANIFEST, @@ -210,9 +204,9 @@ export const BLITZDEV_CONFIG = Object.freeze({ fields: [ { name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "code_hash", type: "text", sqlType: "text", notNull: true, unique: true, check: "length(code_hash) = 43" }, { name: "email", type: "email", sqlType: "text", check: "email IS NULL OR email = lower(email)" }, { name: "target_org_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "orgs", column: "id" } }, - { name: "role", type: "text", sqlType: "text", notNull: true, check: "role IN ('admin', 'member')" }, { name: "state", type: "text", sqlType: "text", notNull: true, check: "state IN ('ready', 'redeemed', 'revoked', 'expired')" }, + { name: "role", type: "text", sqlType: "text", notNull: true, check: "role IN ('admin', 'member')" }, { name: "created_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "redeemed_by_user_id", type: "text", sqlType: "text", foreignKey: { table: "users", column: "id" } }, - { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "expires_at", type: "integer", sqlType: "integer", notNull: true }, { name: "redeemed_at", type: "integer", sqlType: "integer" }, + { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "expires_at", type: "integer", sqlType: "integer", notNull: true }, { name: "redeemed_at", type: "integer", sqlType: "integer" }, { name: "revoked_at", type: "integer", sqlType: "integer" }, ], extensions: [DENY_ALL_RULES], }, @@ -275,7 +269,6 @@ export const BLITZDEV_CONFIG = Object.freeze({ { name: "principal_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "principals", column: "id" } }, { name: "workspace_id", type: "text", sqlType: "text", unique: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "broker_box_id", type: "text", sqlType: "text", foreignKey: { table: "broker_boxes", column: "box_id", onDelete: "SET NULL" } }, - { name: "is_broker", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "is_broker IN (0, 1)" }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, ], indexes: [ @@ -361,13 +354,12 @@ export const BLITZDEV_CONFIG = Object.freeze({ { name: "token_hash", type: "text", sqlType: "text", unique: true }, { name: "issued_at", type: "integer", sqlType: "integer", notNull: true }, { name: "expires_at", type: "integer", sqlType: "integer", notNull: true }, - { name: "state", type: "text", sqlType: "text", notNull: true, check: "state IN ('active','revoked','expired')" }, + { name: "revoked_at", type: "integer", sqlType: "integer" }, ], indexes: [ - { name: "workspace", fields: ["workspace_id", "state"] }, - { name: "expiry", fields: ["state", "expires_at"] }, + { name: "workspace", fields: "workspace_id" }, { name: "token", fields: "token_hash", where: { q: "token_hash IS NOT NULL" } }, - { name: "grant", fields: ["grant_id", "state"] }, + { name: "grant", fields: "grant_id" }, ], extensions: [DENY_ALL_RULES], }, @@ -475,15 +467,12 @@ import config from "virtual:teenybase"; import { awsProviderFromEnv, credentialMasterKeyFor, - createSessionPrincipalSource, HetznerProvider, installControlPlaneRoutes, isString, MicrovmPoolProvider, maybeScheduleLazySweep, - maxConcurrentWorkspacesFromEnv, - runFileSyncSweep, runInvariantSweep, runLeaseSweep, runOrphanSweep, - runProviderCanary, runSessionSweep, runWorkspaceTunnelSweep, + runScheduledMaintenance, sessionTtlMsFromEnv, workspaceTunnelsFromEnv, workspaceWebAppAuthFromEnv, @@ -512,7 +501,6 @@ type ManagedBindings = { BOX_IMAGE_SHA256: string; BOX_IMAGE_TAG: string; SESSION_TTL_DAYS: string; - MAX_CONCURRENT_WORKSPACES: string; MICROVM_HOSTS: string; CRED_MASTER_KEY: string; // Workspace tunnels and webApp auth, named exactly as self-host names them @@ -663,14 +651,12 @@ function runtimeFor(context: CoreContext | ManagedContext): CoreRuntime { boxImageSha256: env.BOX_IMAGE_SHA256, boxImageTag: env.BOX_IMAGE_TAG, sessionTtlMs: sessionTtlMsFromEnv(env.SESSION_TTL_DAYS), - maxConcurrentWorkspaces: maxConcurrentWorkspacesFromEnv(env.MAX_CONCURRENT_WORKSPACES), googleClientId: env.GOOGLE_CLIENT_ID, googleClientSecret: env.GOOGLE_CLIENT_SECRET, bootstrapSecret: env.OPERATOR_API_KEY, connectSecret: (name) => nonEmptyString(dynamicBinding(env, name)), }, providers: providersFor(env, db), - principalSource: createSessionPrincipalSource(), // SAFETY: Both routed context variants satisfy the webApp blob response contract used for the SPA shell. assets: { fetch: async () => webAppResponse(context as WebAppContext, "/index.html") }, waitUntil: (promise) => context.executionCtx.waitUntil(promise), @@ -708,11 +694,12 @@ const worker = Object.assign(app, { env, executionCtx: executionContext, get: (name) => name === "$db" ? db : key, }); - await runtime.providers.microvm?.syncStaticHosts(); - await runSessionSweep(runtime); await runLeaseSweep(runtime); - await runInvariantSweep(runtime); await runOrphanSweep(runtime); - await runWorkspaceTunnelSweep(runtime); await runProviderCanary(runtime); - await runFileSyncSweep(runtime); + // The managed platform registers a single trigger whose spelling this + // source cannot see. Passing the hourly literal keeps every managed + // tick on the full hourly set (host sync, janitors, canary, file + // sync) — exactly the behavior this entrypoint always had — while + // core/janitors.ts stays the one owner of the cron policy. + await runScheduledMaintenance(runtime, "0 * * * *"); })()); }, }); diff --git a/packages/control-plane/src/worker.ts b/packages/control-plane/src/worker.ts index 99359532..14477260 100644 --- a/packages/control-plane/src/worker.ts +++ b/packages/control-plane/src/worker.ts @@ -6,20 +6,12 @@ import { allowedEmailDomainsFromEnv, awsProviderFromEnv, credentialMasterKeyFor, - createSessionPrincipalSource, HetznerProvider, installControlPlaneRoutes, isString, MicrovmPoolProvider, maybeScheduleLazySweep, - maxConcurrentWorkspacesFromEnv, - runInvariantSweep, - runFileSyncSweep, - runLeaseSweep, - runOrphanSweep, - runProviderCanary, - runSessionSweep, - runWorkspaceTunnelSweep, + runScheduledMaintenance, sessionTtlMsFromEnv, signupModeFromEnv, workspaceTunnelsFromEnv, @@ -33,10 +25,6 @@ import { } from "../core/index.js"; import config from "../teenybase.js"; -/** Must stay one of wrangler.toml's `triggers.crons` entries verbatim: the - * scheduled handler routes on the literal expression Cloudflare hands back. */ -const HOURLY_CRON = "0 * * * *"; - type WorkerBindings = Env & { ASSETS: { fetch(request: Request): Promise }; HETZNER_API_TOKEN: string; @@ -46,7 +34,6 @@ type WorkerBindings = Env & { GOOGLE_CLIENT_SECRET: string; MICROVM_HOSTS: string; SESSION_TTL_DAYS: string; - MAX_CONCURRENT_WORKSPACES: string; SIGNUP_MODE?: string; ALLOWED_EMAIL_DOMAINS?: string; CRED_MASTER_KEY: string; @@ -114,28 +101,31 @@ function providersFor(env: WorkerBindings, db: Db): CoreRuntime["providers"] { }; } -function runtimeFor(context: CoreContext): CoreRuntime; -function runtimeFor(context: TargetContext): CoreRuntime; -function runtimeFor(context: CoreContext | TargetContext): CoreRuntime { - // SAFETY: Both routed Hono context variants carry the declared WorkerBindings environment. - const env = context.env as WorkerBindings; - // SAFETY: The database middleware installs a Db instance under $db before routed handlers run. - const db = context.get("$db") as Db; +interface RuntimeSeed { + env: WorkerBindings; + db: Db; + credentialMasterKey: CryptoKey; + waitUntil(promise: Promise): void; + /** The routed fetch path serves the SPA shell; cron runs have no assets. */ + assets?: CoreRuntime["assets"]; +} + +/** The one place a CoreRuntime is assembled from Worker bindings: the routed + * fetch path and the cron path feed different context shapes in, but the vars + * block and provider wiring exist exactly once. */ +function buildRuntime(seed: RuntimeSeed): CoreRuntime { + const { env, db } = seed; return { db, // SAFETY: WorkerBindings declares BOX_IMAGES as the configured R2 bucket implementing BlobStore. blobs: env.BOX_IMAGES as BlobStore, fileObjects: env.BOX_IMAGES, - // SAFETY: Authentication middleware installs the imported CryptoKey under $credentialMasterKey. - credentialMasterKey: context.get("$credentialMasterKey") as CryptoKey, + credentialMasterKey: seed.credentialMasterKey, vars: { boxImageRef: env.BOX_IMAGE_REF, boxImageSha256: env.BOX_IMAGE_SHA256, boxImageTag: env.BOX_IMAGE_TAG, sessionTtlMs: sessionTtlMsFromEnv(env.SESSION_TTL_DAYS), - maxConcurrentWorkspaces: maxConcurrentWorkspacesFromEnv( - env.MAX_CONCURRENT_WORKSPACES, - ), googleClientId: env.GOOGLE_CLIENT_ID, googleClientSecret: env.GOOGLE_CLIENT_SECRET, bootstrapSecret: env.OPERATOR_API_KEY, @@ -144,46 +134,26 @@ function runtimeFor(context: CoreContext | TargetContext): CoreRuntime { allowedEmailDomains: allowedEmailDomainsFromEnv(env.ALLOWED_EMAIL_DOMAINS), }, providers: providersFor(env, db), - principalSource: createSessionPrincipalSource(), - assets: { fetch: (request) => env.ASSETS.fetch(request) }, - waitUntil: (promise) => context.executionCtx.waitUntil(promise), + assets: seed.assets, + waitUntil: seed.waitUntil, reportError: (event, error) => console.error(JSON.stringify({ event, error: error.message })), }; } -function runtimeForScheduled( - env: WorkerBindings, - db: Db, - executionContext: ExecutionContext, - credentialMasterKey: CryptoKey, -): CoreRuntime { - const providers = providersFor(env, db); - return { - db, - // SAFETY: WorkerBindings declares BOX_IMAGES as the configured R2 bucket implementing BlobStore. - blobs: env.BOX_IMAGES as BlobStore, - fileObjects: env.BOX_IMAGES, - credentialMasterKey, - vars: { - boxImageRef: env.BOX_IMAGE_REF, - boxImageSha256: env.BOX_IMAGE_SHA256, - boxImageTag: env.BOX_IMAGE_TAG, - sessionTtlMs: sessionTtlMsFromEnv(env.SESSION_TTL_DAYS), - maxConcurrentWorkspaces: maxConcurrentWorkspacesFromEnv( - env.MAX_CONCURRENT_WORKSPACES, - ), - googleClientId: env.GOOGLE_CLIENT_ID, - googleClientSecret: env.GOOGLE_CLIENT_SECRET, - bootstrapSecret: env.OPERATOR_API_KEY, - connectSecret: (name) => connectSecretFrom(env, name), - signupMode: signupModeFromEnv(env.SIGNUP_MODE), - allowedEmailDomains: allowedEmailDomainsFromEnv(env.ALLOWED_EMAIL_DOMAINS), - }, - providers, - principalSource: createSessionPrincipalSource(), - waitUntil: (promise) => executionContext.waitUntil(promise), - reportError: (event, error) => console.error(JSON.stringify({ event, error: error.message })), - }; +function runtimeFor(context: CoreContext): CoreRuntime; +function runtimeFor(context: TargetContext): CoreRuntime; +function runtimeFor(context: CoreContext | TargetContext): CoreRuntime { + // SAFETY: Both routed Hono context variants carry the declared WorkerBindings environment. + const env = context.env as WorkerBindings; + return buildRuntime({ + env, + // SAFETY: The database middleware installs a Db instance under $db before routed handlers run. + db: context.get("$db") as Db, + // SAFETY: Authentication middleware installs the imported CryptoKey under $credentialMasterKey. + credentialMasterKey: context.get("$credentialMasterKey") as CryptoKey, + waitUntil: (promise) => context.executionCtx.waitUntil(promise), + assets: { fetch: (request) => env.ASSETS.fetch(request) }, + }); } let lastSyncedHostsConfig: string | undefined; @@ -250,39 +220,15 @@ export default { env: WorkerBindings, executionContext: ExecutionContext, ): Promise { - const db = rawDb(env.DB); executionContext.waitUntil( (async () => { - const runtime = runtimeForScheduled( + const runtime = buildRuntime({ env, - db, - executionContext, - await credentialMasterKeyFor(env.CRED_MASTER_KEY), - ); - // Only the hourly and daily schedules run the full janitor set. Any - // other tick (the */5 backstop today) converges folder sync alone, so - // renaming that cron can never silently multiply the heavy sweeps. - if (event.cron !== HOURLY_CRON && event.cron !== "0 3 * * *") { - const swept = await runFileSyncSweep(runtime); - console.log(JSON.stringify({ event: "file_sync_tick", cron: event.cron, ...swept })); - return; - } - await runtime.providers.microvm?.syncStaticHosts(); - await runSessionSweep(runtime); - await runLeaseSweep(runtime); - await runInvariantSweep(runtime); - await runOrphanSweep(runtime); - await runWorkspaceTunnelSweep(runtime); - // The canary is the one sweep that costs an authenticated call to a - // third party per provider, so it takes the hourly tick alone. On the - // daily tick as well it would be counted twice against the same rate - // limit for no extra signal. - if (event.cron === HOURLY_CRON) { - const probed = await runProviderCanary(runtime); - console.log(JSON.stringify({ event: "provider_canary_tick", cron: event.cron, probed })); - } - const swept = await runFileSyncSweep(runtime); - console.log(JSON.stringify({ event: "file_sync_tick", cron: event.cron, ...swept })); + db: rawDb(env.DB), + credentialMasterKey: await credentialMasterKeyFor(env.CRED_MASTER_KEY), + waitUntil: (promise) => executionContext.waitUntil(promise), + }); + await runScheduledMaintenance(runtime, event.cron); })(), ); }, diff --git a/packages/control-plane/test/assets.test.ts b/packages/control-plane/test/assets.test.ts index 0b96a40f..780ae731 100644 --- a/packages/control-plane/test/assets.test.ts +++ b/packages/control-plane/test/assets.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { env } from "cloudflare:test"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { WORKER_SOURCE, createWebAppAssetSet, @@ -18,6 +18,7 @@ const managedToolchainEnabled = env.BLITZDEV_MANAGED === "1"; const temporaryDirectories: string[] = []; afterEach(async () => { + vi.restoreAllMocks(); await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); @@ -85,15 +86,16 @@ describe.skipIf(!managedToolchainEnabled)("managed webapp assets [vendor-only: s rows.set(id, { ...previous, ...values, object: physical }); return Response.json(rows.get(id)); }; + vi.spyOn(globalThis, "fetch").mockImplementation(fetcher); const first = await createWebAppAssetSet(directory); - await expect(uploadManagedAssets(first, access, "project-password", { fetcher })).resolves.toEqual({ + await expect(uploadManagedAssets(first, access, "project-password")).resolves.toEqual({ inserted: 3, replaced: 0, skipped: 0, verified: 3, }); - await expect(uploadManagedAssets(first, access, "project-password", { fetcher })).resolves.toEqual({ + await expect(uploadManagedAssets(first, access, "project-password")).resolves.toEqual({ inserted: 0, replaced: 0, skipped: 3, @@ -101,7 +103,7 @@ describe.skipIf(!managedToolchainEnabled)("managed webapp assets [vendor-only: s }); await writeFile(path.join(directory, "index.html"), "
changed
"); const changed = await createWebAppAssetSet(directory); - await expect(uploadManagedAssets(changed, access, "project-password", { fetcher })).resolves.toEqual({ + await expect(uploadManagedAssets(changed, access, "project-password")).resolves.toEqual({ inserted: 0, replaced: 1, skipped: 2, diff --git a/packages/control-plane/test/aws-provider.test.ts b/packages/control-plane/test/aws-provider.test.ts index 47a2c6f4..a3ea309c 100644 --- a/packages/control-plane/test/aws-provider.test.ts +++ b/packages/control-plane/test/aws-provider.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { rawDb } from "../src/raw-db.js"; import { HetznerProvider } from "../core/compute/hetzner.js"; import { MicrovmPoolProvider } from "../core/compute/microvm.js"; import { VmProviderRegistry } from "../core/compute/registry.js"; @@ -15,7 +17,6 @@ import type { CreateVmInput } from "../core/compute/types.js"; const REGION = "us-east-1"; const INSTANCE_ID = "i-0123456789abcdef0"; -const VOLUME_ID = "vol-0123456789abcdef0"; const IMAGE_ID = "ami-0123456789abcdef0"; const NOW_MS = Date.UTC(2026, 7, 18, 12, 0, 0); const PHONE_HOME_URL = "https://cp.example/workspaces/workspace-id/phone-home/capability"; @@ -77,34 +78,28 @@ function fakeEc2(handler: Ec2Handler): FakeEc2 { }; } -interface TestClock { - now: () => number; - sleep: (milliseconds: number) => Promise; -} +// The provider reads global fetch and the real clock. Fake timers pin the +// signing date to NOW_MS and let the bounded poll loops run without waiting; +// each provider() call points global fetch at that test's fake EC2. +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); +}); -/** Advances with each awaited sleep so the bounded poll loops terminate. */ -function testClock(): TestClock { - let current = NOW_MS; - return { - now: () => current, - sleep: async (milliseconds: number) => { - current += milliseconds; - }, - }; -} +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); function provider(fake: FakeEc2, overrides: Partial = {}): AwsProvider { - const clock = testClock(); - return new AwsProvider( - { - accessKeyId: "AKIDEXAMPLE", - secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", - region: REGION, - imageId: IMAGE_ID, - ...overrides, - }, - { fetcher: fake.fetcher, now: clock.now, sleep: clock.sleep }, - ); + vi.stubGlobal("fetch", fake.fetcher); + return new AwsProvider({ + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + region: REGION, + imageId: IMAGE_ID, + ...overrides, + }); } function createInput(machineTypeId: string, userData = "#!/bin/bash\necho hi\n"): CreateVmInput { @@ -153,22 +148,11 @@ function describeInstances(state: string, publicIp: string): Ec2Reply { ); } -function volumeXml(id: string, name: string, attachedTo: string | null): string { - const attachment = attachedTo === null - ? "" - : `${id}` - + `${attachedTo}/dev/sdf` - + `attached`; - return `${id}50` - + `us-east-1aavailable` - + `${attachment}Name${name}`; -} - describe("AWS provider ownership", () => { it("claims aws- machine types and i- VM ids without colliding with the other providers", () => { const aws = provider(fakeEc2(() => ok(""))); const hetzner = new HetznerProvider("test-token"); - const microvm = new MicrovmPoolProvider("[]", () => undefined, {}); + const microvm = new MicrovmPoolProvider("[]", () => undefined, { db: rawDb(env.DB) }); const registry = new VmProviderRegistry([hetzner, microvm, aws]); expect(registry.forMachineType("aws-t3.medium@us-east-1")).toBe(aws); @@ -190,7 +174,7 @@ describe("AWS provider ownership", () => { expect(AWS_USER_DATA_MAX_BYTES).toBe(16_384); expect(AWS_USER_DATA_RAW_MAX_BYTES).toBe(49_152); expect(provider(fakeEc2(() => ok(""))).capabilities()).toEqual({ - volumes: true, + volumes: false, maxUserDataBytes: 49_152, webAppTicketsSinceMs: 1_786_993_800_000, webAppViewerGuardsSinceMs: 1_787_043_600_000, @@ -243,7 +227,7 @@ describe("AWS provider createVm", () => { const call = fake.calls[0]; if (call === undefined) throw new Error("expected a RunInstances call"); expect(call.url).toBe("https://ec2.us-east-1.amazonaws.com/"); - expect(call.contentType).toBe("application/x-www-form-urlencoded; charset=utf-8"); + expect(call.contentType).toBe("application/x-www-form-urlencoded"); // The signed body embeds base64(gzip(userData)), and workerd's // CompressionStream emits different deflate bytes on different platform // builds (linux vs darwin), so a golden signature cannot hold on both. @@ -345,7 +329,9 @@ describe("AWS provider createVm", () => { : describeInstances("running", "203.0.113.11"); }); - const created = await provider(fake).createVm(createInput("aws-t3.medium@us-east-1")); + const pending = provider(fake).createVm(createInput("aws-t3.medium@us-east-1")); + await vi.runAllTimersAsync(); + const created = await pending; expect(created.host).toBe("203.0.113.11"); expect(fake.calls.map((call) => call.action)).toEqual([ @@ -361,8 +347,10 @@ describe("AWS provider createVm", () => { action === "RunInstances" ? runInstances("") : describeInstances("pending", "") ); - await expect(provider(fake).createVm(createInput("aws-t3.medium@us-east-1"))) + const expectation = expect(provider(fake).createVm(createInput("aws-t3.medium@us-east-1"))) .rejects.toThrow(`AWS instance ${INSTANCE_ID} did not receive a public IPv4 address`); + await vi.runAllTimersAsync(); + await expectation; }); it("refuses user data whose gzip exceeds EC2's cap, before the API", async () => { @@ -475,7 +463,9 @@ describe("AWS provider lifecycle", () => { : describeInstances("stopped", ""); }); - await provider(fake).shutdown(INSTANCE_ID); + const pending = provider(fake).shutdown(INSTANCE_ID); + await vi.runAllTimersAsync(); + await pending; expect(fake.calls.map((call) => call.action)).toEqual([ "StopInstances", @@ -559,148 +549,6 @@ describe("AWS provider lifecycle", () => { }); }); -describe("AWS provider volumes", () => { - it("creates a tagged gp3 volume in the requested availability zone", async () => { - const fake = fakeEc2(() => - ok(`${volumeXml(VOLUME_ID, "scratch", null)}`) - ); - - await expect( - provider(fake).createVolume({ name: "scratch", sizeGb: 50, location: "us-east-1a" }), - ).resolves.toEqual({ - id: VOLUME_ID, - name: "scratch", - sizeGb: 50, - location: "us-east-1a", - status: "available", - attachedTo: null, - }); - const call = fake.calls[0]; - if (call === undefined) throw new Error("expected a CreateVolume call"); - expect(call.parameters.get("AvailabilityZone")).toBe("us-east-1a"); - expect(call.parameters.get("Size")).toBe("50"); - expect(call.parameters.get("VolumeType")).toBe("gp3"); - expect(call.parameters.get("TagSpecification.1.Tag.2.Key")).toBe("blitz-purpose"); - }); - - it("refuses an availability zone outside the configured region", async () => { - const fake = fakeEc2(() => ok("")); - - await expect( - provider(fake).createVolume({ name: "scratch", sizeGb: 50, location: "eu-west-1a" }), - ).rejects.toThrow( - "volume location eu-west-1a must be an availability zone in us-east-1", - ); - expect(fake.calls).toHaveLength(0); - }); - - it("attaches at a fixed device name", async () => { - const fake = fakeEc2(() => - ok( - `${VOLUME_ID}` - + `${INSTANCE_ID}/dev/sdf` - + `attaching`, - ) - ); - - await provider(fake).attachVolume(VOLUME_ID, INSTANCE_ID); - - const call = fake.calls[0]; - if (call === undefined) throw new Error("expected an AttachVolume call"); - expect(call.action).toBe("AttachVolume"); - expect(call.parameters.get("VolumeId")).toBe(VOLUME_ID); - expect(call.parameters.get("InstanceId")).toBe(INSTANCE_ID); - expect(call.parameters.get("Device")).toBe("/dev/sdf"); - }); - - it("detaches only when the volume is attached to that instance", async () => { - const attached = fakeEc2((action) => - action === "DescribeVolumes" - ? ok( - `` - + `${volumeXml(VOLUME_ID, "scratch", INSTANCE_ID)}` - + ``, - ) - : ok("detaching") - ); - const elsewhere = fakeEc2(() => - ok( - `` - + `${volumeXml(VOLUME_ID, "scratch", "i-0000000000000dead")}` - + ``, - ) - ); - const missing = fakeEc2(() => - ec2Error("InvalidVolume.NotFound", `The volume '${VOLUME_ID}' does not exist.`) - ); - - await provider(attached).detachVolume(VOLUME_ID, INSTANCE_ID); - await provider(elsewhere).detachVolume(VOLUME_ID, INSTANCE_ID); - await provider(missing).detachVolume(VOLUME_ID, INSTANCE_ID); - - expect(attached.calls.map((call) => call.action)).toEqual([ - "DescribeVolumes", - "DetachVolume", - ]); - expect(elsewhere.calls.map((call) => call.action)).toEqual(["DescribeVolumes"]); - expect(missing.calls.map((call) => call.action)).toEqual(["DescribeVolumes"]); - }); - - it("deletes idempotently and propagates other failures", async () => { - const gone = fakeEc2(() => - ec2Error("InvalidVolume.NotFound", `The volume '${VOLUME_ID}' does not exist.`) - ); - const inUse = fakeEc2(() => - ec2Error("VolumeInUse", `Volume ${VOLUME_ID} is currently attached to ${INSTANCE_ID}`) - ); - - await expect(provider(gone).deleteVolume(VOLUME_ID)).resolves.toBeUndefined(); - await expect(provider(inUse).deleteVolume(VOLUME_ID)).rejects.toThrow( - "AWS EC2 DeleteVolume failed: VolumeInUse:", - ); - await expect(provider(gone).deleteVolume("42")).rejects.toThrow("invalid AWS volume id: 42"); - }); - - it("lists only blitz-tagged volumes and follows the pagination token", async () => { - const fake = fakeEc2((_action, parameters) => - parameters.get("NextToken") === null - ? ok( - `` - + `${volumeXml(VOLUME_ID, "scratch", null)}` - + `page-two`, - ) - : ok( - `` - + `${volumeXml("vol-00000000000000002", "second", INSTANCE_ID)}` - + ``, - ) - ); - - await expect(provider(fake).listVolumes()).resolves.toEqual([ - { - id: VOLUME_ID, - name: "scratch", - sizeGb: 50, - location: "us-east-1a", - status: "available", - attachedTo: null, - }, - { - id: "vol-00000000000000002", - name: "second", - sizeGb: 50, - location: "us-east-1a", - status: "attached", - attachedTo: INSTANCE_ID, - }, - ]); - expect(fake.calls).toHaveLength(2); - expect(fake.calls[0]?.parameters.get("Filter.1.Name")).toBe("tag:blitz-purpose"); - expect(fake.calls[0]?.parameters.get("Filter.1.Value.1")).toBe("workspace"); - expect(fake.calls[1]?.parameters.get("NextToken")).toBe("page-two"); - }); -}); - describe("AWS provider configuration", () => { it("is absent, not broken, when no AWS variable is set", () => { expect(awsProviderFromEnv({})).toBeUndefined(); diff --git a/packages/control-plane/test/aws-sigv4.test.ts b/packages/control-plane/test/aws-sigv4.test.ts index 3fc3da0f..90a5aebf 100644 --- a/packages/control-plane/test/aws-sigv4.test.ts +++ b/packages/control-plane/test/aws-sigv4.test.ts @@ -34,7 +34,6 @@ describe("AWS SigV4 query signing", () => { host: SUITE_HOST, parameters: [["Param1", "value1"]], signedAt: SUITE_SIGNED_AT, - contentType: "application/x-www-form-urlencoded", }); expect(signed.url).toBe("https://example.amazonaws.com/"); diff --git a/packages/control-plane/test/blitzdev-emitter.test.ts b/packages/control-plane/test/blitzdev-emitter.test.ts index f5c982c6..3a263e51 100644 --- a/packages/control-plane/test/blitzdev-emitter.test.ts +++ b/packages/control-plane/test/blitzdev-emitter.test.ts @@ -1,7 +1,6 @@ import { env } from "cloudflare:test"; import { describe, expect, it } from "vitest"; import { - UPLOAD_MANIFEST, WORKER_SOURCE, importSpecifiers, isRelative, @@ -132,7 +131,6 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed emitter [vendor-onl it("emits the exact deterministic manifest within platform limits", () => { const first = managedUploadSet(); const second = managedUploadSet(); - expect(UPLOAD_MANIFEST).toEqual(expected); expect(first.files.map((file) => file.path)).toEqual(expected); expect(first).toEqual(second); expect(first.files).toHaveLength(93); @@ -203,10 +201,12 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed emitter [vendor-onl expect(teenybase?.source).toContain('name: "users"'); }); - it("wires the managed worker file bucket and scheduled folder sweep", () => { + it("wires the managed worker file bucket and scheduled maintenance", () => { expect(WORKER_SOURCE).toContain("fileObjects: env.TEENY_PRIMARY_R2 as R2Bucket"); expect(WORKER_SOURCE).toContain("async scheduled("); - expect(WORKER_SOURCE).toContain("await runFileSyncSweep(runtime)"); + // One cron-policy owner: the managed tick pins the hourly literal so + // every managed run keeps the full sweep set it always had. + expect(WORKER_SOURCE).toContain('await runScheduledMaintenance(runtime, "0 * * * *")'); }); // Run-3 report, B2. The emitted worker registered only vmRegistry, volume diff --git a/packages/control-plane/test/blitzdev-schema.test.ts b/packages/control-plane/test/blitzdev-schema.test.ts index 705b6c5a..285d7f9b 100644 --- a/packages/control-plane/test/blitzdev-schema.test.ts +++ b/packages/control-plane/test/blitzdev-schema.test.ts @@ -213,12 +213,17 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "invites")).toMatchObject({ fields: expect.arrayContaining([ expect.objectContaining({ name: "code_hash", check: "length(code_hash) = 43" }), - expect.objectContaining({ - name: "state", - check: "state IN ('ready', 'redeemed', 'revoked', 'expired')", - }), + // Lifecycle is derived from facts: redeemed_at / revoked_at / expires_at. + // No stored state label (migration 0029). + expect.objectContaining({ name: "redeemed_at", sqlType: "integer" }), + expect.objectContaining({ name: "revoked_at", sqlType: "integer" }), ]), }); + expect( + BLITZDEV_CONFIG.tables + .find(({ name }) => name === "invites") + ?.fields.some((field) => field.name === "state"), + ).toBe(false); expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "workspace_grants")).toMatchObject({ fields: expect.arrayContaining([ expect.objectContaining({ name: "role", check: "role IN ('editor', 'viewer')" }), @@ -367,7 +372,6 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only "idx_user_oauth_grants_provider", "idx_workspace_template_connections_provider", "idx_credential_leases_workspace", - "idx_credential_leases_expiry", "idx_credential_leases_token", "idx_credential_leases_grant", "idx_credential_requests_pending", diff --git a/packages/control-plane/test/bootstrap.test.ts b/packages/control-plane/test/bootstrap.test.ts index 0b1ed2d6..86f13a31 100644 --- a/packages/control-plane/test/bootstrap.test.ts +++ b/packages/control-plane/test/bootstrap.test.ts @@ -74,7 +74,7 @@ describe("production VM bootstrap", () => { // the move by behavior: a listener on :2222 must appear. expect(userData).toContain("host sshd never bound :2222"); expect(userData).toContain(`readonly BOX_IMAGE_REF='${BOX_IMAGE_REF}'`); - expect(userData).toContain('retry docker pull "$BOX_IMAGE_REF"'); + expect(userData).toContain('until docker pull "$BOX_IMAGE_REF"; do'); expect(userData).toContain("--privileged"); expect(userData).toContain("--restart unless-stopped"); expect(userData).toContain("--env-file /etc/blitz/env.defaults"); @@ -541,7 +541,7 @@ write_files: const inspectGuard = userData.indexOf( 'if ! docker image inspect "$BOX_IMAGE_REF" >/dev/null 2>&1; then', ); - const pull = userData.indexOf('retry docker pull "$BOX_IMAGE_REF"'); + const pull = userData.indexOf('until docker pull "$BOX_IMAGE_REF"; do'); const guardEnd = userData.indexOf("\nfi\n", pull); const provenPresent = userData.indexOf( 'docker image inspect "$BOX_IMAGE_REF" >/dev/null', diff --git a/packages/control-plane/test/connections-catalog-fixtures.ts b/packages/control-plane/test/connections-catalog-fixtures.ts new file mode 100644 index 00000000..dc0009ff --- /dev/null +++ b/packages/control-plane/test/connections-catalog-fixtures.ts @@ -0,0 +1,291 @@ +import type { ProviderParam } from "../core/connections/catalog/types.js"; + +/** A recorded provider probe answer, replayed against `evaluateProbe`. */ +export interface ProbeFixture { + name: string; + status: number; + response: string; + healthy: boolean; +} + +export interface ExchangeExpectation { + accessToken: string; + refreshToken: string | null; + expiresInMs: number; +} + +/** A recorded token-endpoint round trip. The generic exchange is only ever + * proven against these, never against a live provider, so the conformance + * suite demands at least one per OAuth manifest. */ +export interface ExchangeFixture { + name: string; + grantType: "authorization_code" | "refresh_token"; + /** Form fields the exchange must send, asserted field by field. */ + request: readonly ProviderParam[]; + response: string; + /** `null` records a rejection the exchange must surface as an error — + * a replayed single-use refresh is a fixture, not a hypothetical. */ + expect: ExchangeExpectation | null; +} + +/** Keyed by manifest id; the conformance suite asserts the key set matches + * the catalog exactly, so a new provider cannot land without recordings. */ +export const PROBE_FIXTURES: ReadonlyMap< + string, + readonly [ProbeFixture, ...ProbeFixture[]] +> = new Map([ + [ + "github", + [ + { + name: "authenticated user", + status: 200, + response: '{"login":"blitz-canary","id":4242,"type":"User"}', + healthy: true, + }, + { + name: "expired user token", + status: 401, + response: '{"message":"Bad credentials","status":"401"}', + healthy: false, + }, + ], + ], + [ + "google-workspace", + [ + { + name: "drive about", + status: 200, + response: '{"user":{"kind":"drive#user","emailAddress":"canary@example.com"}}', + healthy: true, + }, + { + name: "revoked grant", + status: 401, + response: '{"error":{"code":401,"message":"Invalid Credentials","status":"UNAUTHENTICATED"}}', + healthy: false, + }, + ], + ], + [ + "linear", + [ + { + name: "viewer", + status: 200, + response: '{"data":{"viewer":{"id":"6e5c1f7a-0000-4000-8000-000000000000","name":"Blitz Canary"}}}', + healthy: true, + }, + { + name: "authenticated 200 carrying an error array", + status: 200, + response: '{"errors":[{"message":"Authentication required, not authenticated"}]}', + healthy: false, + }, + ], + ], + [ + "discord", + [ + { + name: "the bot's own user", + status: 200, + response: '{"id":"1029384756000000000","username":"blitz-canary","bot":true}', + healthy: true, + }, + { + name: "reset bot token", + status: 401, + response: '{"message":"401: Unauthorized","code":0}', + healthy: false, + }, + ], + ], + [ + "youtrack", + [ + { + name: "token's own user", + status: 200, + response: '{"id":"1-1","login":"blitz-canary","name":"Blitz Canary","$type":"Me"}', + healthy: true, + }, + { + name: "revoked permanent token", + status: 401, + response: '{"error":"Unauthorized","error_description":"Cannot find user by authentication data"}', + healthy: false, + }, + ], + ], + [ + "generic", + [ + { name: "reachable", status: 200, response: "{}", healthy: true }, + { name: "rejected key", status: 401, response: '{"error":"unauthorized"}', healthy: false }, + ], + ], +]); + +/** Keyed by manifest id; the conformance suite asserts the key set matches + * the catalog's OAuth manifests exactly. */ +export const EXCHANGE_FIXTURES: ReadonlyMap< + string, + readonly [ExchangeFixture, ...ExchangeFixture[]] +> = new Map([ + [ + "github", + [ + { + name: "authorization code exchange", + grantType: "authorization_code", + request: [ + { name: "grant_type", value: "authorization_code" }, + { name: "code", value: "recorded-authorization-code" }, + { name: "client_id", value: "recorded-client-id" }, + { name: "client_secret", value: "recorded-client-secret" }, + { name: "redirect_uri", value: "https://cp.example/connect/github/callback" }, + { name: "code_verifier", value: "recorded-code-verifier" }, + ], + response: '{"access_token":"ghu_recorded_first","expires_in":28800,"refresh_token":"ghr_recorded_first","refresh_token_expires_in":15811200,"scope":"","token_type":"bearer"}', + expect: { + accessToken: "ghu_recorded_first", + refreshToken: "ghr_recorded_first", + expiresInMs: 28_800_000, + }, + }, + { + name: "single-use refresh rotation", + grantType: "refresh_token", + request: [ + { name: "grant_type", value: "refresh_token" }, + { name: "refresh_token", value: "ghr_recorded_first" }, + { name: "client_id", value: "recorded-client-id" }, + { name: "client_secret", value: "recorded-client-secret" }, + ], + response: '{"access_token":"ghu_recorded_second","expires_in":28800,"refresh_token":"ghr_recorded_second","refresh_token_expires_in":15811200,"scope":"","token_type":"bearer"}', + expect: { + accessToken: "ghu_recorded_second", + refreshToken: "ghr_recorded_second", + expiresInMs: 28_800_000, + }, + }, + { + name: "replayed single-use refresh", + grantType: "refresh_token", + request: [ + { name: "grant_type", value: "refresh_token" }, + { name: "refresh_token", value: "ghr_recorded_first" }, + { name: "client_id", value: "recorded-client-id" }, + { name: "client_secret", value: "recorded-client-secret" }, + ], + response: '{"error":"bad_refresh_token","error_description":"The refresh token passed is incorrect or expired."}', + expect: null, + }, + ], + ], + [ + "google-workspace", + [ + { + name: "authorization code exchange", + grantType: "authorization_code", + request: [ + { name: "grant_type", value: "authorization_code" }, + { name: "code", value: "recorded-authorization-code" }, + { name: "client_id", value: "recorded-client-id" }, + { name: "client_secret", value: "recorded-client-secret" }, + { name: "redirect_uri", value: "https://cp.example/connect/google-workspace/callback" }, + { name: "code_verifier", value: "recorded-code-verifier" }, + ], + response: '{"access_token":"ya29.recorded-first","expires_in":3599,"refresh_token":"1//recorded-refresh","scope":"https://www.googleapis.com/auth/drive.file","token_type":"Bearer"}', + expect: { + accessToken: "ya29.recorded-first", + refreshToken: "1//recorded-refresh", + expiresInMs: 3_599_000, + }, + }, + { + name: "refresh keeps the same refresh token", + grantType: "refresh_token", + request: [ + { name: "grant_type", value: "refresh_token" }, + { name: "refresh_token", value: "1//recorded-refresh" }, + { name: "client_id", value: "recorded-client-id" }, + { name: "client_secret", value: "recorded-client-secret" }, + ], + // No refresh_token field: Google never rotates, so the stored one stands. + response: '{"access_token":"ya29.recorded-second","expires_in":3599,"scope":"https://www.googleapis.com/auth/drive.file","token_type":"Bearer"}', + expect: { + accessToken: "ya29.recorded-second", + refreshToken: null, + expiresInMs: 3_599_000, + }, + }, + { + name: "refresh token revoked at the account", + grantType: "refresh_token", + request: [ + { name: "grant_type", value: "refresh_token" }, + { name: "refresh_token", value: "1//recorded-refresh" }, + { name: "client_id", value: "recorded-client-id" }, + { name: "client_secret", value: "recorded-client-secret" }, + ], + response: '{"error":"invalid_grant","error_description":"Token has been expired or revoked."}', + expect: null, + }, + ], + ], + [ + "linear", + [ + { + name: "authorization code exchange", + grantType: "authorization_code", + request: [ + { name: "grant_type", value: "authorization_code" }, + { name: "code", value: "recorded-authorization-code" }, + { name: "client_id", value: "recorded-client-id" }, + { name: "client_secret", value: "recorded-client-secret" }, + { name: "redirect_uri", value: "https://cp.example/connect/linear/callback" }, + { name: "code_verifier", value: "recorded-code-verifier" }, + ], + response: '{"access_token":"lin_oauth_recorded_first","token_type":"Bearer","expires_in":86400,"refresh_token":"lin_refresh_recorded_first","scope":"read,write"}', + expect: { + accessToken: "lin_oauth_recorded_first", + refreshToken: "lin_refresh_recorded_first", + expiresInMs: 86_400_000, + }, + }, + { + name: "rotating refresh inside the grace window", + grantType: "refresh_token", + request: [ + { name: "grant_type", value: "refresh_token" }, + { name: "refresh_token", value: "lin_refresh_recorded_first" }, + { name: "client_id", value: "recorded-client-id" }, + { name: "client_secret", value: "recorded-client-secret" }, + ], + response: '{"access_token":"lin_oauth_recorded_second","token_type":"Bearer","expires_in":86400,"refresh_token":"lin_refresh_recorded_second","scope":"read,write"}', + expect: { + accessToken: "lin_oauth_recorded_second", + refreshToken: "lin_refresh_recorded_second", + expiresInMs: 86_400_000, + }, + }, + { + name: "refresh past the grace window", + grantType: "refresh_token", + request: [ + { name: "grant_type", value: "refresh_token" }, + { name: "refresh_token", value: "lin_refresh_recorded_first" }, + { name: "client_id", value: "recorded-client-id" }, + { name: "client_secret", value: "recorded-client-secret" }, + ], + response: '{"error":"invalid_grant","error_description":"Refresh token is invalid or expired"}', + expect: null, + }, + ], + ], +]); diff --git a/packages/control-plane/test/connections-catalog.test.ts b/packages/control-plane/test/connections-catalog.test.ts index 72dd7102..9946870d 100644 --- a/packages/control-plane/test/connections-catalog.test.ts +++ b/packages/control-plane/test/connections-catalog.test.ts @@ -14,7 +14,6 @@ import { tombstoneSurfaces, } from "../core/connections/catalog/surfaces.js"; import type { - ExchangeFixture, OAuthProviderManifest, ProviderManifest, StaticProviderManifest, @@ -25,6 +24,11 @@ import { parseExchangeResponse, } from "../core/connections/minters/oauth.js"; import type { MintResult, Placement } from "../core/connections/types.js"; +import { + EXCHANGE_FIXTURES, + PROBE_FIXTURES, + type ExchangeFixture, +} from "./connections-catalog-fixtures.js"; /** The repository's env contract. Worker vars carry no value there — only a * documented name and what it is for — and a connect binding missing from it @@ -121,6 +125,22 @@ describe("provider catalog conformance", () => { expect(providerManifest("no-such-provider")).toBeNull(); }); + /** Recorded corpora live beside the suite, keyed by manifest id: a catalog + * entry cannot land without probe recordings, an OAuth entry cannot land + * without exchange recordings, and an orphan recording flags a removal. */ + it("keeps a recorded probe corpus per entry and an exchange corpus per OAuth entry", () => { + const ids = CATALOG.map(({ id }) => id); + expect([...PROBE_FIXTURES.keys()].sort()).toEqual([...ids].sort()); + const oauthIds = CATALOG.filter(({ auth }) => auth !== null).map(({ id }) => id); + expect([...EXCHANGE_FIXTURES.keys()].sort()).toEqual([...oauthIds].sort()); + }); + + it("compiles every skill onto the one path harnesses resolve skills from", () => { + expect(skillPath("instance")).toBe( + `${BOX_HOME}/.claude/skills/instance/SKILL.md`, + ); + }); + /** Only the generic entry makes the person name a variable and a base URL, * and that is a fact about which catalog entry it is. Inferring it from a * missing authorize endpoint would put the vendor form in front of the next @@ -183,7 +203,6 @@ describe("provider catalog conformance", () => { expect(new URL(manifest.docsUrl).protocol).toBe("https:"); expect(new URL(manifest.baseUrl).protocol).toBe("https:"); expect(["cp", "broker", "proxy"]).toContain(manifest.custody); - expect(["strict", "graceful", "none"]).toContain(manifest.rotation); // A header shape that cannot be set would fail only at proxy time. expect(() => { new Headers().set( @@ -206,11 +225,7 @@ describe("provider catalog conformance", () => { expect(new URL(auth.tokenUrl).protocol).toBe("https:"); expect(auth.clientIdVar).toMatch(ENVIRONMENT_NAME); expect(auth.clientSecretVar).toMatch(ENVIRONMENT_NAME); - expect(auth.redirectPath).toBe(`/connect/${manifest.id}/callback`); expect(auth.accessTtlMs).toBeGreaterThan(0); - expect(manifest.fixtures.length, "recorded exchanges are mandatory").toBeGreaterThan(0); - } else { - expect(manifest.fixtures).toEqual([]); } if (manifest.personalToken !== null) { expect(() => { @@ -233,11 +248,6 @@ describe("provider catalog conformance", () => { for (const surface of manifest.surfaces.env) { expect(surface.name).toMatch(ENVIRONMENT_NAME); } - expect(manifest.surfaces.skill.path).toContain(""); - expect(manifest.surfaces.skill.path.startsWith("/")).toBe(false); - expect(skillPath(manifest, "instance")).toBe( - `${BOX_HOME}/${manifest.surfaces.skill.path.replace("", "instance")}`, - ); }); /** The admin form submits `PUT /connections/:id` from this view alone, @@ -287,7 +297,14 @@ describe("provider catalog conformance", () => { }); it("renders a skill that tells an agent how to authenticate", () => { - const rendered = manifest.surfaces.skill.render({ + // Grant mints are the only path that renders skills, so a provider + // has one exactly when a member can hold a grant for it (OAuth or a + // pasted personal token); admin-only providers declare none. + const grantCapable = manifest.auth !== null || manifest.personalToken !== null; + expect(manifest.surfaces.skill !== null, "skill exactly for grant-capable providers") + .toBe(grantCapable); + if (manifest.surfaces.skill === null) return; + const rendered = manifest.surfaces.skill({ connection: "acme", scopes: manifest.defaultScopes, mode: "proxy", @@ -307,7 +324,11 @@ describe("provider catalog conformance", () => { const placements = compileSurfaces(manifest, surfaceInput(manifest)); assertPlacementsRideTheFrozenWire(placements, `${manifest.id} mint`); const skill = placements.find((placement) => placement.kind === "file"); - expect(skill?.kind === "file" && skill.value.length > 0).toBe(true); + if (manifest.surfaces.skill === null) { + expect(skill, "no skill surface, no file placement").toBeUndefined(); + } else { + expect(skill?.kind === "file" && skill.value.length > 0).toBe(true); + } const result: MintResult = { integration: manifest.id, @@ -319,7 +340,6 @@ describe("provider catalog conformance", () => { .toEqual(FROZEN_MINT_KEYS); const tombstone = tombstoneSurfaces( - manifest, manifest.id, manifest.surfaces.env.map(({ name }) => name), ); @@ -346,11 +366,13 @@ describe("provider catalog conformance", () => { }); it("replays every recorded exchange", () => { + const fixtures = EXCHANGE_FIXTURES.get(manifest.id); if (manifest.auth === null) { - expect(manifest.fixtures).toEqual([]); + expect(fixtures, "a pasted credential records no exchange").toBeUndefined(); return; } - for (const fixture of manifest.fixtures) replayExchange(manifest, fixture); + if (fixtures === undefined) throw new Error("recorded exchanges are mandatory"); + for (const fixture of fixtures) replayExchange(manifest, fixture); }); it("replays every recorded probe answer", () => { @@ -367,7 +389,9 @@ describe("provider catalog conformance", () => { expect(authorization?.value).toContain("test-only-probe-token"); if (request.method === "GET") expect(request.body).toBeNull(); - for (const fixture of manifest.probeFixtures) { + const fixtures = PROBE_FIXTURES.get(manifest.id); + if (fixtures === undefined) throw new Error("recorded probe answers are mandatory"); + for (const fixture of fixtures) { const outcome = evaluateProbe(manifest, fixture.status, fixture.response); expect(outcome.healthy, `${manifest.id}/${fixture.name}`).toBe(fixture.healthy); // A recorded failure must never echo the provider's own body. diff --git a/packages/control-plane/test/connections.test.ts b/packages/control-plane/test/connections.test.ts index fa74849f..4f382fc7 100644 --- a/packages/control-plane/test/connections.test.ts +++ b/packages/control-plane/test/connections.test.ts @@ -16,7 +16,7 @@ import { waitOnExecutionContext, } from "cloudflare:test"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { runLeaseSweep, runProviderCanary } from "../core/index.js"; +import { runProviderCanary } from "../core/index.js"; import worker from "../src/worker.js"; import { CANARY_GRANT_LABEL } from "../core/connections/canary.js"; import { @@ -834,9 +834,9 @@ describe("connections: connect flow and canary", () => { // The lease carries the rotated expiry, not the one the pre-refresh row // still remembers. const lease = await env.DB.prepare( - "SELECT expires_at, state FROM credential_leases ORDER BY issued_at DESC, id LIMIT 1", - ).first<{ expires_at: number; state: string }>(); - expect(lease?.state).toBe("active"); + "SELECT expires_at, revoked_at FROM credential_leases ORDER BY issued_at DESC, id LIMIT 1", + ).first<{ expires_at: number; revoked_at: number | null }>(); + expect(lease?.revoked_at).toBeNull(); expect(lease?.expires_at).toBeGreaterThan(Date.now()); // And the mint after it is an ordinary fresh-token mint, not a second 409. @@ -1997,10 +1997,12 @@ describe("connections: org-root rows, proxy transport, and the request inbox", ( ).toBe(204); expect( await env.DB - .prepare("SELECT state, token_hash FROM credential_leases WHERE id = ?1") + .prepare( + "SELECT revoked_at IS NOT NULL AS revoked, token_hash FROM credential_leases WHERE id = ?1", + ) .bind(revoked.leaseId) .first(), - ).toEqual({ state: "revoked", token_hash: null }); + ).toEqual({ revoked: 1, token_hash: null }); const secondMint = await mintFor(app, workspace.id, box.access_token, { integration: "dead-proxy", @@ -2300,7 +2302,7 @@ describe("connections: org-root rows, proxy transport, and the request inbox", ( }); }); - it("revokes a lease by clearing its token hash in the same state update", async () => { + it("revokes a lease by clearing its token hash in the same update", async () => { const { app, providers } = harness(); const cookie = await operatorSession(app); await putStaticConnection(app, cookie, "hetzner-prod"); @@ -2327,10 +2329,12 @@ describe("connections: org-root rows, proxy transport, and the request inbox", ( expect(response.status).toBe(204); expect( await env.DB - .prepare("SELECT state, token_hash FROM credential_leases WHERE id = ?1") + .prepare( + "SELECT revoked_at IS NOT NULL AS revoked, token_hash FROM credential_leases WHERE id = ?1", + ) .bind(lease.id) .first(), - ).toEqual({ state: "revoked", token_hash: null }); + ).toEqual({ revoked: 1, token_hash: null }); }); it("destroys a workspace with an active lease while preserving revoked audit", async () => { @@ -2365,14 +2369,14 @@ describe("connections: org-root rows, proxy transport, and the request inbox", ( expect( await env.DB .prepare( - "SELECT box_id, state FROM credential_leases WHERE workspace_id = ?1", + "SELECT box_id, revoked_at IS NOT NULL AS revoked FROM credential_leases WHERE workspace_id = ?1", ) .bind(workspace.id) .first(), - ).toEqual({ box_id: null, state: "revoked" }); + ).toEqual({ box_id: null, revoked: 1 }); }); - it("expires overdue active leases without deleting their audit rows", async () => { + it("derives lease expiry at read time and lets revocation win past it", async () => { const { app, providers } = harness(); const cookie = await operatorSession(app); await putStaticConnection(app, cookie, "hetzner-prod"); @@ -2386,17 +2390,32 @@ describe("connections: org-root rows, proxy transport, and the request inbox", ( ) .run(); - expect(await runLeaseSweep(testRuntime(providers), 11)).toBe(1); + const listed = await appRequest(app, `/workspaces/${workspace.id}/leases`, { + headers: { Cookie: cookie }, + }); + const { leases } = await listed.json<{ leases: CredentialLeaseView[] }>(); + expect(leases.map(({ state }) => state)).toEqual(["expired"]); + // The read wrote nothing back: no revocation stamp, and the hash stays + // put — the proxy's expiry predicate is the authority, so a hash of an + // expired token authorizes nothing anyway. expect( await env.DB - .prepare("SELECT state, token_hash FROM credential_leases") + .prepare("SELECT revoked_at, token_hash FROM credential_leases") .first(), - ).toEqual({ state: "expired", token_hash: null }); - expect( - await env.DB - .prepare("SELECT COUNT(*) AS count FROM credential_leases") - .first("count"), - ).toBe(1); + ).toEqual({ revoked_at: null, token_hash: "overdue-token-hash" }); + + // Minting again supersedes the pair's previous lease whatever its age, + // and revocation wins over expiry at read time — the old sweep never + // relabeled a revoked row as it aged either. + await mintFor(app, workspace.id, box.access_token, { + integration: "hetzner-prod", + }); + const after = await appRequest(app, `/workspaces/${workspace.id}/leases`, { + headers: { Cookie: cookie }, + }); + const { leases: relisted } = await after.json<{ leases: CredentialLeaseView[] }>(); + expect(relisted.find(({ id }) => id === leases[0]?.id)?.state).toBe("revoked"); + expect(relisted.filter(({ state }) => state === "active")).toHaveLength(1); }); it("returns an array when a sync-style request mints every allowed connection", async () => { @@ -2485,9 +2504,11 @@ describe("connections: org-root rows, proxy transport, and the request inbox", ( ).toEqual({ revoked: 1, root_ciphertext: null }); expect( await env.DB - .prepare("SELECT state, token_hash FROM credential_leases") + .prepare( + "SELECT revoked_at IS NOT NULL AS revoked, token_hash FROM credential_leases", + ) .first(), - ).toEqual({ state: "revoked", token_hash: null }); + ).toEqual({ revoked: 1, token_hash: null }); }); it("lists minted leases through the session-authenticated audit route", async () => { diff --git a/packages/control-plane/test/control-plane.test.ts b/packages/control-plane/test/control-plane.test.ts index 88d9eef7..46c14e4e 100644 --- a/packages/control-plane/test/control-plane.test.ts +++ b/packages/control-plane/test/control-plane.test.ts @@ -827,7 +827,6 @@ describe("control plane security and lifecycle", () => { sshPublicKey: "ssh-ed25519 AAAAC3Nzatest caller", }), }, - { MAX_CONCURRENT_WORKSPACES: "1" }, ) ).status, ).toBe(201); @@ -848,7 +847,6 @@ describe("control plane security and lifecycle", () => { sshPublicKey: "ssh-ed25519 AAAAC3Nzatest caller", }), }, - { MAX_CONCURRENT_WORKSPACES: "1" }, ); expect(second.status).toBe(201); @@ -1521,7 +1519,14 @@ describe("control plane security and lifecycle", () => { .bind(workspace.id) .run(); const runtime = testRuntime(providers); - expect(await runInvariantSweep(runtime, 2 * 60 * 60 * 1000)).toBe(1); + // The stuck-creating cutoff reads Date.now(); pin it past the one-hour + // window (the row's updated_at was zeroed above). + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(2 * 60 * 60 * 1000); + try { + expect(await runInvariantSweep(runtime)).toBe(1); + } finally { + nowSpy.mockRestore(); + } const errored = await env.DB .prepare("SELECT phase, revision FROM workspaces WHERE id = ?1") .bind(workspace.id) diff --git a/packages/control-plane/test/dav-listing-fixtures.test.ts b/packages/control-plane/test/dav-listing-fixtures.test.ts index 4be8cba9..c49d0441 100644 --- a/packages/control-plane/test/dav-listing-fixtures.test.ts +++ b/packages/control-plane/test/dav-listing-fixtures.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseDavListing } from "../core/files/sync.js"; +import { parseDavListing } from "../core/files/dav.js"; /** The dufs PROPFIND shape is a cross-runtime contract: the box image's file * server produces it, this parser consumes it. The fixtures pin the accepted diff --git a/packages/control-plane/test/deploy.test.ts b/packages/control-plane/test/deploy.test.ts index fb885170..b1c88a93 100644 --- a/packages/control-plane/test/deploy.test.ts +++ b/packages/control-plane/test/deploy.test.ts @@ -111,16 +111,17 @@ describe("control-plane deploy command", () => { }, ], }; - expect(parseD1Binding(rawConfig, "DB")).toEqual({ + const parsed = parseD1Binding(rawConfig, "DB"); + expect(parsed).toEqual({ binding: "DB", + index: 1, databaseName: "blitz-control-plane", databaseId: "old-id", }); const patch = d1DatabasePatch( rawConfig, - "DB", - "blitz-control-plane", + parsed, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", ); expect(patch).toEqual({ @@ -389,6 +390,56 @@ describe("control-plane deploy command", () => { expect(calls.some(([tool, command]) => tool === "wrangler" && command === "deploy")).toBe(false); }); + it("propagates a secret-list command failure instead of diagnosing missing secrets", async () => { + // The catch that translated every `wrangler secret list` failure into the + // missing-secrets recipe discarded what wrangler actually said (an expired + // login, an API outage) and sent the operator off to re-put secrets. + const run = async ( + tool: string, + args: string[], + _options: { capture: boolean; env: Record }, + ) => { + if (tool === "wrangler" && args[0] === "whoami") return { stdout: "{}" }; + if (tool === "wrangler" && args[0] === "d1" && args[1] === "list") { + return { + stdout: JSON.stringify([ + { uuid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", name: "blitz-control-plane" }, + ]), + }; + } + if (tool === "wrangler" && args[0] === "r2" && args[2] === "list") { + return { stdout: "name: blitz-box-images" }; + } + if (tool === "wrangler" && args[0] === "secret") { + throw new Error("wrangler secret list --format json failed with exit 1\nAuthentication error [code: 10000]"); + } + return { stdout: "" }; + }; + + await expect( + deployControlPlane({ + configPath: "packages/control-plane/wrangler.toml", + rawConfig: { + name: "blitz-control-plane", + vars: { MICROVM_HOSTS: "[]" }, + r2_buckets: [{ binding: "BOX_IMAGES", bucket_name: "blitz-box-images" }], + d1_databases: [ + { + binding: "DB", + database_name: "blitz-control-plane", + database_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + }, + ], + }, + run, + async patchConfig() { + throw new Error("matching D1 config should not be rewritten"); + }, + secretValues: {}, + }), + ).rejects.toThrow("Authentication error [code: 10000]"); + }); + it("ships a template a fresh clone can deploy in the documented order", async () => { // The deploy is what prints the Worker origin, so APP_URL cannot be known // before it; the same is true of the tunnel IDs (step 8) and the box image diff --git a/packages/control-plane/test/error-envelope.test.ts b/packages/control-plane/test/error-envelope.test.ts index dcbebedc..5b64e9b2 100644 --- a/packages/control-plane/test/error-envelope.test.ts +++ b/packages/control-plane/test/error-envelope.test.ts @@ -68,14 +68,18 @@ const access = { /** The uploader's read of a not-yet-uploaded asset row, driven by a real * response from the real handler instead of a hand-written status. */ async function readAsUploader(response: Response) { - const read = await managedApiRequest( - access, - "/exec/blitz_files/view/webapp-index-html", - {}, - async () => response, - { allowMissing: true }, - ); - return read.body; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => response); + try { + const read = await managedApiRequest( + access, + "/exec/blitz_files/view/webapp-index-html", + {}, + { allowMissing: true }, + ); + return read.body; + } finally { + fetchSpy.mockRestore(); + } } describe("control-plane error envelope", () => { diff --git a/packages/control-plane/test/file-sync.test.ts b/packages/control-plane/test/file-sync.test.ts index 687d4758..b9dc5c96 100644 --- a/packages/control-plane/test/file-sync.test.ts +++ b/packages/control-plane/test/file-sync.test.ts @@ -1,16 +1,16 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CreateVmInput, WebAppPort } from "../core/compute/types.js"; +import { parseDavListing } from "../core/files/dav.js"; import { - parseDavListing, runFileSyncSweep, runReadyWorkspaceFileSync, runWorkspaceFileSync, - scheduledSyncsSettled, } from "../core/files/sync.js"; import { appRequest, appWithProviders, + backgroundTasksSettled, createWorkspace, FakeProviders, operatorSession, @@ -19,6 +19,26 @@ import { testRuntime, } from "./helpers.js"; +/** The ready-time pass sleeps through its real retry delays; tests drive + * those with fake timers, yielding a real macrotask between advances so the + * pass's own D1 and R2 round trips can settle and register the next timer. */ +async function settleThroughRetryDelays(run: () => Promise): Promise { + const realSetTimeout = globalThis.setTimeout.bind(globalThis); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const pending = run(); + let settled = false; + void pending.then(() => { settled = true; }, () => { settled = true; }); + while (!settled) { + await vi.advanceTimersByTimeAsync(15_000); + await new Promise((resolve) => realSetTimeout(resolve, 1)); + } + return await pending; + } finally { + vi.useRealTimers(); + } +} + interface GuestFile { body: string; mtime: number; @@ -85,7 +105,7 @@ class WebDavProviders extends FakeProviders { _port: WebAppPort, path: string, request: Request, - ): Promise { + ): Promise { for (const name of this.unreachableFolders) { if (path.startsWith(`/workspace/shared/${encodeURIComponent(name)}/`)) { throw new Error("tunnel is still connecting"); @@ -155,7 +175,7 @@ async function attachedFolder(providers: WebDavProviders): Promise<{ expect(attached.status).toBe(201); // The attach fires a background convergence pass; settle it so tests own // the sync sequencing from here. - await scheduledSyncsSettled(); + await backgroundTasksSettled(); return { app, cookie, folderId, workspaceId: workspace.id }; } @@ -300,7 +320,7 @@ describe("control-plane folder sync", () => { expect(await env.DB.prepare( "SELECT files_ready FROM workspaces WHERE id = ?1", ).bind(workspace.id).first("files_ready")).toBe(0); - await scheduledSyncsSettled(); + await backgroundTasksSettled(); expect(providers.files.get("note.txt")?.body).toBe("remote"); expect(await env.DB.prepare( "SELECT files_ready FROM workspaces WHERE id = ?1", @@ -329,7 +349,7 @@ describe("control-plane folder sync", () => { headers: { Cookie: cookie, "Content-Type": "application/json" }, body: JSON.stringify({ folderId }), })).status).toBe(201); - await scheduledSyncsSettled(); + await backgroundTasksSettled(); expect(providers.files.get("note.txt")?.body).toBe("remote"); }); @@ -345,7 +365,7 @@ describe("control-plane folder sync", () => { headers: { Cookie: cookie, "Content-Type": "application/json" }, body: JSON.stringify({ folderId }), })).status).toBe(201); - await scheduledSyncsSettled(); + await backgroundTasksSettled(); await env.BOX_IMAGES.put(`org/personal/${folderId}/note.txt`, "remote", { customMetadata: { mtime: "1000", "edited-by": "Operator" }, }); @@ -379,11 +399,8 @@ describe("control-plane folder sync", () => { } return healthy(...args); }; - const result = await runReadyWorkspaceFileSync( - testRuntime(providers), - workspaceId, - [1, 1], - ); + const result = await settleThroughRetryDelays(() => + runReadyWorkspaceFileSync(testRuntime(providers), workspaceId)); expect(failuresLeft).toBe(0); expect(result.attachments).toBe(1); expect(providers.files.get("note.txt")?.body).toBe("remote"); @@ -490,7 +507,7 @@ describe("control-plane folder sync", () => { })).status).toBe(201); folderIds.push(folderId); } - await scheduledSyncsSettled(); + await backgroundTasksSettled(); await env.DB.prepare("UPDATE workspaces SET files_ready = 0 WHERE id = ?1") .bind(workspace.id).run(); return { workspaceId: workspace.id, folderIds }; @@ -502,8 +519,10 @@ describe("control-plane folder sync", () => { const { workspaceId } = await readyWorkspaceWithFolders(providers, ["Notes"]); const runtime = testRuntime(providers); - // No retry delays: the in-request retries are what a slow tunnel exhausts. - const result = await runReadyWorkspaceFileSync(runtime, workspaceId, []); + // The pass exhausts its in-request retries against a tunnel that never + // comes up, and the readiness bit stays with the sweep. + const result = await settleThroughRetryDelays(() => + runReadyWorkspaceFileSync(runtime, workspaceId)); expect(result.attachments).toBe(0); expect(await filesReady(workspaceId)).toBe(0); }); @@ -514,7 +533,8 @@ describe("control-plane folder sync", () => { const { workspaceId } = await readyWorkspaceWithFolders(providers, ["Quick", "Slow"]); const runtime = testRuntime(providers); - expect(await runReadyWorkspaceFileSync(runtime, workspaceId, [])) + expect(await settleThroughRetryDelays(() => + runReadyWorkspaceFileSync(runtime, workspaceId))) .toMatchObject({ attachments: 1 }); expect(await filesReady(workspaceId)).toBe(0); diff --git a/packages/control-plane/test/helpers.ts b/packages/control-plane/test/helpers.ts index df3e83bf..f36e54f4 100644 --- a/packages/control-plane/test/helpers.ts +++ b/packages/control-plane/test/helpers.ts @@ -5,10 +5,8 @@ import { rawDb } from "../src/raw-db.js"; import type { $Env } from "teenybase/worker"; import { allowedEmailDomainsFromEnv, - createSessionPrincipalSource, credentialMasterKeyFor, installControlPlaneRoutes, - maxConcurrentWorkspacesFromEnv, sessionTtlMsFromEnv, signupModeFromEnv, VmProviderRegistry, @@ -41,17 +39,36 @@ const webAppAuth = new WorkspaceWebAppAuth("test-webapp-root-secret"); * /connect fill it; everything else sees an unconfigured instance. */ export const testConnectSecrets = new Map(); +interface TestExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; +} + interface TestApp { request( input: RequestInfo | URL, init?: RequestInit, env?: Record, + executionCtx?: TestExecutionContext, ): Promise; } +/** Work the app parked on `runtime.waitUntil` during appRequest calls. In + * production that seam is the Worker's ExecutionContext; here appRequest + * supplies this collector so tests can settle background passes + * deterministically instead of racing them. */ +const backgroundTasks: Promise[] = []; + +/** Settles when every background task scheduled so far has finished, + * including tasks those tasks scheduled while settling. */ +export async function backgroundTasksSettled(): Promise { + while (backgroundTasks.length > 0) { + await Promise.allSettled(backgroundTasks.splice(0)); + } +} + type TestBindings = Env & { SESSION_TTL_DAYS: string; - MAX_CONCURRENT_WORKSPACES: string; SIGNUP_MODE?: string; ALLOWED_EMAIL_DOMAINS?: string; CRED_MASTER_KEY: string; @@ -197,10 +214,6 @@ export function appWithVmProviders( sessionTtlMs: sessionTtlMsFromEnv( (context.env as TestBindings).SESSION_TTL_DAYS ?? env.SESSION_TTL_DAYS, ), - maxConcurrentWorkspaces: maxConcurrentWorkspacesFromEnv( - (context.env as TestBindings).MAX_CONCURRENT_WORKSPACES ?? - env.MAX_CONCURRENT_WORKSPACES, - ), googleClientId: "test-google-client-id", googleClientSecret: "test-google-client-secret", bootstrapSecret: (context.env as TestBindings).OPERATOR_API_KEY ?? OPERATOR_KEY, @@ -216,7 +229,6 @@ export function appWithVmProviders( workspaceTunnels, webAppAuth, }, - principalSource: createSessionPrincipalSource(), assets: { fetch: async () => new Response("webapp shell", { headers: { "Content-Type": "text/html" }, @@ -249,9 +261,6 @@ export function testRuntime( boxImageSha256: env.BOX_IMAGE_SHA256, boxImageTag: env.BOX_IMAGE_TAG, sessionTtlMs: sessionTtlMsFromEnv(env.SESSION_TTL_DAYS), - maxConcurrentWorkspaces: maxConcurrentWorkspacesFromEnv( - env.MAX_CONCURRENT_WORKSPACES, - ), googleClientId: "test-google-client-id", googleClientSecret: "test-google-client-secret", bootstrapSecret: OPERATOR_KEY, @@ -263,7 +272,6 @@ export function testRuntime( workspaceTunnels, webAppAuth, }, - principalSource: createSessionPrincipalSource(), waitUntil: () => undefined, reportError: () => undefined, }; @@ -281,13 +289,15 @@ export async function appRequest( BOX_IMAGE_SHA256: env.BOX_IMAGE_SHA256, BOX_IMAGE_TAG: env.BOX_IMAGE_TAG, SESSION_TTL_DAYS: env.SESSION_TTL_DAYS, - MAX_CONCURRENT_WORKSPACES: env.MAX_CONCURRENT_WORKSPACES, DB: env.DB, CRED_MASTER_KEY, GOOGLE_CLIENT_ID: "test-google-client-id", GOOGLE_CLIENT_SECRET: "test-google-client-secret", OPERATOR_API_KEY: OPERATOR_KEY, ...bindings, + }, { + waitUntil: (promise) => backgroundTasks.push(promise), + passThroughOnException: () => undefined, }); } diff --git a/packages/control-plane/test/identity-phase2.test.ts b/packages/control-plane/test/identity-phase2.test.ts index c4b74e47..6ab40b25 100644 --- a/packages/control-plane/test/identity-phase2.test.ts +++ b/packages/control-plane/test/identity-phase2.test.ts @@ -332,7 +332,7 @@ describe("identity phase 2", () => { const callback = await googleCallback(app, "invitee@example.com", `/auth/google/start?invite=${body.code}`); expect(callback.status).toBe(302); - expect(await env.DB.prepare("SELECT state FROM invites WHERE id = ?1").bind(body.invite.id).first("state")).toBe("redeemed"); + expect(await env.DB.prepare("SELECT redeemed_at IS NOT NULL AS redeemed FROM invites WHERE id = ?1").bind(body.invite.id).first("redeemed")).toBe(1); expect(await env.DB.prepare( `SELECT m.org_id FROM sessions s JOIN memberships m ON m.id = s.membership_id WHERE s.principal_id = (SELECT id FROM users WHERE email = 'invitee@example.com') @@ -413,7 +413,7 @@ describe("identity phase 2", () => { "anyone@example.com", `/auth/google/start?invite=${openBody.code}`, )).status).toBe(302); - expect(await env.DB.prepare("SELECT state FROM invites WHERE id = ?1").bind(openBody.invite.id).first("state")).toBe("redeemed"); + expect(await env.DB.prepare("SELECT redeemed_at IS NOT NULL AS redeemed FROM invites WHERE id = ?1").bind(openBody.invite.id).first("redeemed")).toBe(1); const second = await appRequest(app, "/invites", { ...json({ role: "member" }), @@ -424,6 +424,43 @@ describe("identity phase 2", () => { await expect(appRequest(app, `/invite/${secondBody.code}`).then((response) => response.json())).resolves.toMatchObject({ invite: { state: "revoked" } }); }); + it("derives 'expired' at read time without writing it back", async () => { + const { app } = harness(); + const adminCookie = await operatorSession(app); + const created = await appRequest(app, "/invites", { + ...json({ role: "member" }), + headers: { Cookie: adminCookie, "Content-Type": "application/json" }, + }); + const body = await created.json<{ code: string; invite: { id: string } }>(); + await env.DB.prepare("UPDATE invites SET expires_at = ?1 WHERE id = ?2") + .bind(Date.now() - 1_000, body.invite.id).run(); + + await expect(appRequest(app, `/invite/${body.code}`).then((response) => response.json())) + .resolves.toMatchObject({ invite: { state: "expired" } }); + await expect(appRequest(app, "/invites", { headers: { Cookie: adminCookie } }) + .then((response) => response.json())) + .resolves.toMatchObject({ invites: [expect.objectContaining({ state: "expired" })] }); + // The reads above synced nothing: no stamp appeared on the row. + expect(await env.DB.prepare("SELECT revoked_at, redeemed_at FROM invites WHERE id = ?1") + .bind(body.invite.id).first()).toEqual({ revoked_at: null, redeemed_at: null }); + + // Expired refuses writes too: revocation 409s and redemption creates nothing. + expect((await appRequest(app, `/invites/${body.invite.id}`, { + method: "DELETE", + headers: { Cookie: adminCookie }, + })).status).toBe(409); + const callback = await googleCallback( + app, + "late@example.com", + `/auth/google/start?invite=${body.code}`, + ); + expect(callback.status).toBe(409); + await expect(callback.json()).resolves.toMatchObject({ error: "invite is expired" }); + expect(await env.DB.prepare( + "SELECT COUNT(*) AS count FROM users WHERE email = 'late@example.com'", + ).first("count")).toBe(0); + }); + it("switches sessions among active memberships and scopes volumes and connections", async () => { const { app, providers } = harness(); const operatorCookie = await operatorSession(app); diff --git a/packages/control-plane/test/identity-signup.test.ts b/packages/control-plane/test/identity-signup.test.ts index fe968a0d..1feb357e 100644 --- a/packages/control-plane/test/identity-signup.test.ts +++ b/packages/control-plane/test/identity-signup.test.ts @@ -130,6 +130,50 @@ describe("identity signup gate", () => { WHERE u.google_user_id = 'google-invitee@example.com'`, ).first<{ role: string; status: string }>(); expect(membership).toEqual({ role: "member", status: "active" }); + // The redemption batch creates the principal row too — the broker member + // roster joins principals, so an invited account must not be missing one. + expect(await env.DB.prepare( + `SELECT COUNT(*) AS count FROM principals + WHERE id = (SELECT id FROM users WHERE google_user_id = 'google-invitee@example.com')`, + ).first("count")).toBe(1); + }); + + it("open mode reports the invite fault and creates nothing for a bad code", async () => { + const { app } = harness(); + const callback = await signIn(app, "walkin@example.com", { + startPath: `/auth/google/start?invite=${"A".repeat(43)}`, + }); + expect(callback.status).toBe(404); + await expect(callback.json()).resolves.toMatchObject({ + error: "invite not found", + }); + expect(await userCount("google-walkin@example.com")).toBe(0); + }); + + it("a single-use invite admits exactly one account", async () => { + const { app } = harness(); + const operatorCookie = await operatorSession(app); + const minted = await appRequest(app, "/invites", { + method: "POST", + headers: { Cookie: operatorCookie, "Content-Type": "application/json" }, + body: JSON.stringify({ role: "member" }), + }); + expect(minted.status).toBe(201); + const { code } = await minted.json<{ code: string }>(); + expect((await signIn(app, "first@example.com", { + startPath: `/auth/google/start?invite=${code}`, + bindings: { SIGNUP_MODE: "invite" }, + })).status).toBe(302); + expect((await signIn(app, "second@example.com", { + startPath: `/auth/google/start?invite=${code}`, + bindings: { SIGNUP_MODE: "invite" }, + })).status).toBe(403); + expect(await userCount("google-second@example.com")).toBe(0); + expect(await env.DB.prepare( + `SELECT COUNT(*) AS count FROM memberships m + JOIN users u ON u.id = m.user_id + WHERE u.google_user_id IN ('google-first@example.com', 'google-second@example.com')`, + ).first("count")).toBe(1); }); it("invite mode leaves no account behind when redemption fails after the gate", async () => { @@ -149,7 +193,7 @@ describe("identity signup gate", () => { // trigger below fails exactly that write and nothing else. await env.DB.prepare( `CREATE TRIGGER invite_taken_concurrently - BEFORE UPDATE OF state ON invites WHEN NEW.state = 'redeemed' + BEFORE UPDATE OF redeemed_at ON invites WHEN NEW.redeemed_at IS NOT NULL BEGIN SELECT RAISE(ABORT, 'invite was redeemed concurrently'); END`, ).run(); try { diff --git a/packages/control-plane/test/identity.test.ts b/packages/control-plane/test/identity.test.ts index aa9ccfa1..4c96b7f4 100644 --- a/packages/control-plane/test/identity.test.ts +++ b/packages/control-plane/test/identity.test.ts @@ -242,8 +242,8 @@ describe("identity phase 1", () => { VALUES ('legacy-workspace', 'operator', 'small', 'ready', 1, ?1, ?1)`, ).bind(now), env.DB.prepare( - `INSERT INTO boxes (id, principal_id, workspace_id, is_broker, created_at) - VALUES ('legacy-box', 'operator', 'legacy-workspace', 0, ?1)`, + `INSERT INTO boxes (id, principal_id, workspace_id, created_at) + VALUES ('legacy-box', 'operator', 'legacy-workspace', ?1)`, ).bind(now), env.DB.prepare( `INSERT INTO webapp_state (principal_id, workspace_id, doc, updated_at) diff --git a/packages/control-plane/test/managed-api.test.ts b/packages/control-plane/test/managed-api.test.ts index 9994d71a..63001459 100644 --- a/packages/control-plane/test/managed-api.test.ts +++ b/packages/control-plane/test/managed-api.test.ts @@ -1,5 +1,5 @@ import { env } from "cloudflare:test"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { UPLOAD_ORDER } from "../scripts/lib/worker-source.mjs"; import { fetchMigrationText, @@ -70,8 +70,12 @@ function fakeProject(migrationSql = "CREATE TABLE users (id TEXT);"): { } describe.skipIf(!managedToolchainEnabled)("blitz.dev managed API response contract [vendor-only: set BLITZDEV_MANAGED=1 to run]", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("constructs authenticated requests and preserves JSON and text bodies", async () => { - const fetcher = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response('{"success":true}', { headers: { "x-save-version": "7" }, })); @@ -84,10 +88,9 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed API response contra headers: { "Content-Type": "text/plain; charset=utf-8", "If-Match": "6" }, body: "source", }, - fetcher, ); - expect(fetcher).toHaveBeenCalledWith( + expect(fetchSpy).toHaveBeenCalledWith( "https://blitz.dev/api/v1/projects/example/files?path=core%2Findex.ts", { method: "PUT", @@ -102,8 +105,8 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed API response contra expect(result.body).toEqual({ success: true }); expect(saveVersion(result.response, result.body)).toBe("7"); - const textFetcher = vi.fn(async () => new Response("SELECT 1;")); - await expect(managedApiRequest(access, "/files?path=%40migration.sql", {}, textFetcher)) + fetchSpy.mockImplementation(async () => new Response("SELECT 1;")); + await expect(managedApiRequest(access, "/files?path=%40migration.sql")) .resolves.toMatchObject({ body: "SELECT 1;" }); }); @@ -125,12 +128,12 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed API response contra }); it("rejects non-success responses with redacted credentials", async () => { - const fetcher = vi.fn(async () => new Response( + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response( JSON.stringify({ token: "tp__secret-value", password: "project-secret" }), { status: 409 }, )); - await expect(managedApiRequest(access, "/files", {}, fetcher)).rejects.toThrow( + await expect(managedApiRequest(access, "/files")).rejects.toThrow( 'API 409: {"token":"[REDACTED]","password":"[REDACTED]"}', ); }); @@ -158,25 +161,27 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed API response contra expect(migrationText("file_not_found")).toBe(""); expect(migrationText(null)).toBe(""); - const notFound = vi.fn(async () => new Response('{"error":"file_not_found"}', { status: 404 })); - await expect(fetchMigrationText(access, notFound)).resolves.toBe(""); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockImplementation(async () => new Response('{"error":"file_not_found"}', { status: 404 })); + await expect(fetchMigrationText(access)).resolves.toBe(""); - const pending = vi.fn(async () => new Response("ALTER TABLE users ADD COLUMN x TEXT;")); - await expect(fetchMigrationText(access, pending)).resolves.toBe("ALTER TABLE users ADD COLUMN x TEXT;"); + fetchSpy.mockImplementation(async () => new Response("ALTER TABLE users ADD COLUMN x TEXT;")); + await expect(fetchMigrationText(access)).resolves.toBe("ALTER TABLE users ADD COLUMN x TEXT;"); }); it("re-runs a deploy against an already-current project as a clean no-op", async () => { const platform = fakeProject(); + vi.spyOn(globalThis, "fetch").mockImplementation(platform.fetcher); const uploadSet = fakeUploadSet(); - const first = await pushManagedSet(uploadSet, access, { commit: true, fetcher: platform.fetcher, out: platform.out }); + const first = await pushManagedSet(uploadSet, access, { commit: true, out: platform.out }); expect(first.migration).toContain("CREATE TABLE"); expect(first.committed).toBe(true); expect(platform.commits).toBe(1); // Second run: same artifact, schema already applied, so the platform // serves no migration. This is the run that used to throw and exit 1. - const second = await pushManagedSet(uploadSet, access, { commit: true, fetcher: platform.fetcher, out: platform.out }); + const second = await pushManagedSet(uploadSet, access, { commit: true, out: platform.out }); expect(second.migration).toBe(""); expect(second.committed).toBe(true); expect(second.saves).toHaveLength(UPLOAD_ORDER.length); @@ -188,7 +193,8 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed API response contra // platform's own migration preview printed to stdout raw. it("redacts what the deploy prints, not only what it throws", async () => { const platform = fakeProject(`-- restoring agent link\n-- https://blitz.dev/agent/tp_a1b2c3d4e5f6g7h8/agents.md\nCREATE TABLE users (id TEXT);`); - await pushManagedSet(fakeUploadSet(), access, { fetcher: platform.fetcher, out: platform.out }); + vi.spyOn(globalThis, "fetch").mockImplementation(platform.fetcher); + await pushManagedSet(fakeUploadSet(), access, { out: platform.out }); expect(platform.written()).not.toContain("tp_a1b2c3d4e5f6g7h8"); expect(platform.written()).toContain("[REDACTED]"); diff --git a/packages/control-plane/test/microvm-provider.test.ts b/packages/control-plane/test/microvm-provider.test.ts index 047c9908..afc3f44b 100644 --- a/packages/control-plane/test/microvm-provider.test.ts +++ b/packages/control-plane/test/microvm-provider.test.ts @@ -1,7 +1,7 @@ import type { WorkspaceView } from "@blitzos/schema"; import { env } from "cloudflare:workers"; import { rawDb } from "../src/raw-db.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Db } from "../core/db.js"; import { VmProviderRegistry } from "../core/compute/registry.js"; import { @@ -45,12 +45,16 @@ function provider( MICROVM_LAB_TOKEN: LAB_TOKEN, MICROVM_EDGE_TOKEN: EDGE_TOKEN, }, - db?: Db, + db: Db = rawDb(env.DB), ): MicrovmPoolProvider { + // The provider talks through global fetch. Stubbing before construction + // pins this test's fake into the instance (the agent client captures the + // global at construction), so several providers in one test stay isolated. + vi.stubGlobal("fetch", fetcher); return new MicrovmPoolProvider( rawHosts, (tokenVar) => Reflect.get(secrets, tokenVar), - { fetcher, db }, + { db }, ); } @@ -145,6 +149,10 @@ describe("microVM pool provider", () => { await resetDatabase(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("parses general mv-cg@ machine IDs and rejects malformed IDs", () => { expect(parseMicrovmMachineTypeId("mv-2c2g@lab")).toEqual({ cpu: 2, @@ -300,7 +308,8 @@ describe("microVM pool provider", () => { undefined, db, ); - await microvm.registerHost("home", EDGE_TOKEN, "https://one.trycloudflare.com/"); + const register = await microvm.prepareHostRegistration("home", EDGE_TOKEN); + await register("https://one.trycloudflare.com/"); const pointAt = async (url: string) => { await env.DB @@ -390,6 +399,7 @@ describe("microVM pool provider", () => { ), fetcher, ); + await microvm.syncStaticHosts(); expect(await microvm.listMachineTypes()).toEqual([ { @@ -423,30 +433,17 @@ describe("microVM pool provider", () => { expect(fetcher).toHaveBeenCalledTimes(3); }); - it("invokes a stored fetcher without a receiver", async () => { - let observedThis: unknown = "not called"; - async function fetcher(this: unknown): Promise { - observedThis = this; - return Response.json(capacity()); - } - const microvm = provider( - hosts({ name: "lab", url: "https://lab.example", tokenVar: "MICROVM_LAB_TOKEN" }), - fetcher, - ); - - await expect(microvm.listMachineTypes()).resolves.toHaveLength(2); - expect(observedThis).toBeUndefined(); - }); - it("accepts legacy and extended capacity responses during a rolling upgrade", async () => { const raw = hosts({ name: "lab", url: "https://lab.example", tokenVar: "MICROVM_LAB_TOKEN" }); const legacyProvider = provider(raw, async () => Response.json(capacity())); + await legacyProvider.syncStaticHosts(); + await expect(legacyProvider.listMachineTypes()).resolves.toHaveLength(2); + const extendedProvider = provider(raw, async () => Response.json(capacity({ physical_cpu: 4, effective_cpu: 8, }))); - - await expect(legacyProvider.listMachineTypes()).resolves.toHaveLength(2); + await extendedProvider.syncStaticHosts(); await expect(extendedProvider.listMachineTypes()).resolves.toHaveLength(2); }); @@ -463,6 +460,7 @@ describe("microVM pool provider", () => { for (const [description, response] of invalidResponses) { const microvm = provider(raw, async () => Response.json(response)); + await microvm.syncStaticHosts(); await expect(microvm.listMachineTypes(), description).rejects.toThrow(); } }); @@ -472,11 +470,13 @@ describe("microVM pool provider", () => { const errorProvider = provider(raw, async () => Response.json({ error: "host is draining" }, { status: 503 }), ); + await errorProvider.syncStaticHosts(); await expect(errorProvider.listMachineTypes()).rejects.toThrow(/^host is draining$/u); const malformedProvider = provider(raw, async () => Response.json({ ...capacity(), unexpected: true }), ); + await malformedProvider.syncStaticHosts(); await expect(malformedProvider.listMachineTypes()).rejects.toThrow( "invalid microVM agent capacity response fields", ); @@ -487,6 +487,7 @@ describe("microVM pool provider", () => { unexpected: true, }), ); + await extendedUnknownFieldProvider.syncStaticHosts(); await expect(extendedUnknownFieldProvider.listMachineTypes()).rejects.toThrow( "invalid microVM agent capacity response fields", ); @@ -494,6 +495,7 @@ describe("microVM pool provider", () => { const oversizedProvider = provider(raw, async () => new Response("{}", { headers: { "Content-Length": "65537" } }), ); + await oversizedProvider.syncStaticHosts(); await expect(oversizedProvider.listMachineTypes()).rejects.toThrow( "microVM agent response is too large", ); @@ -543,6 +545,7 @@ describe("microVM pool provider", () => { hosts({ name: "lab", url: "https://lab.example/", tokenVar: "MICROVM_LAB_TOKEN" }), fetcher, ); + await microvm.syncStaticHosts(); const created = await microvm.createVm(createInput("mv-2c2g@lab")); @@ -563,6 +566,7 @@ describe("microVM pool provider", () => { ssh_port: 65_536, }, { status: 201 }), ); + await invalidPortProvider.syncStaticHosts(); await expect( invalidPortProvider.createVm(createInput("mv-2c2g@lab")), ).rejects.toThrow("invalid microVM agent create response ssh_port"); @@ -581,6 +585,7 @@ describe("microVM pool provider", () => { }, { status: 201 }); }, ); + await microvm.syncStaticHosts(); for (const sshPublicKey of [undefined, "", " \t\n "]) { await microvm.createVm({ @@ -628,6 +633,7 @@ describe("microVM pool provider", () => { hosts({ name: "lab", url: "https://lab.example", tokenVar: "MICROVM_LAB_TOKEN" }), fetcher, ); + await microvm.syncStaticHosts(); const created = await microvm.createVm(createInput("mv-2c4g@lab")); await expect(microvm.inspect(created.id)).resolves.toEqual({ @@ -645,6 +651,7 @@ describe("microVM pool provider", () => { hosts({ name: "lab", url: "https://lab.example", tokenVar: "MICROVM_LAB_TOKEN" }), async () => Response.json({ error: "insufficient microVM capacity" }, { status: 409 }), ); + await microvm.syncStaticHosts(); const app = appWithProviders(microvm, new FakeProviders()); const cookie = await operatorSession(app); @@ -679,6 +686,7 @@ describe("microVM pool provider", () => { }, { status: 201 }); }, ); + await microvm.syncStaticHosts(); const cloud = new FakeProviders(); const app = appWithVmProviders([cloud, microvm], cloud); const cookie = await operatorSession(app); @@ -746,6 +754,7 @@ describe("microVM pool provider", () => { }, { status: 201 }); }, ); + await microvm.syncStaticHosts(); const app = appWithProviders(microvm, new FakeProviders()); const cookie = await operatorSession(app); const created = await appRequest(app, "/workspaces", { @@ -832,6 +841,7 @@ describe("microVM pool provider", () => { hosts({ name: "lab", url: "https://lab.example", tokenVar: "MICROVM_LAB_TOKEN" }), fetcher, ); + await microvm.syncStaticHosts(); const app = appWithProviders(microvm, new FakeProviders()); const cookie = await operatorSession(app); const created = await appRequest(app, "/workspaces", { @@ -935,6 +945,7 @@ describe("microVM pool provider", () => { hosts({ name: "lab", url: "https://lab.example", tokenVar: "MICROVM_LAB_TOKEN" }), fetcher, ); + await microvm.syncStaticHosts(); const app = appWithProviders(microvm, new FakeProviders()); const cookie = await operatorSession(app); const created = await appRequest(app, "/workspaces", { @@ -994,6 +1005,7 @@ describe("microVM pool provider", () => { hosts({ name: "lab", url: "https://lab.example", tokenVar: "MICROVM_LAB_TOKEN" }), fetcher, ); + await microvm.syncStaticHosts(); const registry = new VmProviderRegistry([hetzner, microvm]); expect((await registry.listMachineTypes()).machineTypes.map(({ id }) => id)).toEqual([ @@ -1030,9 +1042,5 @@ describe("microVM pool provider", () => { expect(fetcher.mock.calls.some(([input, init]) => String(input).endsWith("/v1/vms/vm-1-abcdef123456") && init?.method === "DELETE" )).toBe(true); - expect(registry.get("hetzner")?.capabilities()).toEqual({ - volumes: true, - maxUserDataBytes: 32 * 1_024, - }); }); }); diff --git a/packages/control-plane/test/usage-push.test.ts b/packages/control-plane/test/usage-push.test.ts index eb3ab6c8..aff06899 100644 --- a/packages/control-plane/test/usage-push.test.ts +++ b/packages/control-plane/test/usage-push.test.ts @@ -71,7 +71,7 @@ class UsageGuestProviders extends FakeProviders { _port: WebAppPort, path: string, request: Request, - ): Promise { + ): Promise { if (request.method === "PROPFIND") { if (!this.hasUsageRoot || !path.startsWith(USAGE_ROOT)) { return new Response("not found", { status: 404 }); diff --git a/packages/control-plane/test/vm-provider-registry.test.ts b/packages/control-plane/test/vm-provider-registry.test.ts index 573f79c2..dab59204 100644 --- a/packages/control-plane/test/vm-provider-registry.test.ts +++ b/packages/control-plane/test/vm-provider-registry.test.ts @@ -72,7 +72,6 @@ describe("VM provider registry", () => { const microvm = new StubVmProvider("microvm", "mv-2c2g@lab", "microvm:v1:lab:1"); const registry = new VmProviderRegistry([cloud, microvm]); - expect(registry.get("cloud")).toBe(cloud); expect(registry.all()).toEqual([cloud, microvm]); expect(registry.forMachineType("cx22@fsn1")).toBe(cloud); expect(registry.forMachineType("mv-2c2g@lab")).toBe(microvm); diff --git a/packages/control-plane/test/wire-drift.test.ts b/packages/control-plane/test/wire-drift.test.ts index 0f629ca8..0943fd4a 100644 --- a/packages/control-plane/test/wire-drift.test.ts +++ b/packages/control-plane/test/wire-drift.test.ts @@ -375,7 +375,6 @@ const catalogEntry: SharedShape< summary: "Issues, projects, and comments through one GraphQL endpoint.", docsUrl: "https://linear.app/developers", custody: "proxy", - rotation: "graceful", oauthAvailable: true, oauthConfigured: false, personalTokenLabel: "Personal API key", diff --git a/packages/control-plane/test/workspace-environment.test.ts b/packages/control-plane/test/workspace-environment.test.ts index 91b0f557..f22353c2 100644 --- a/packages/control-plane/test/workspace-environment.test.ts +++ b/packages/control-plane/test/workspace-environment.test.ts @@ -1,9 +1,9 @@ import type { WorkspaceEnvironmentResponse, WorkspaceView } from "@blitzos/schema"; import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import { scheduledSyncsSettled } from "../core/files/sync.js"; import { appRequest, + backgroundTasksSettled, createWorkspace, harness, operatorSession, @@ -67,7 +67,7 @@ describe("workspace environments", () => { body: JSON.stringify({ pub_key_ed25519: "ssh-ed25519 AAAAhost" }), }); const box = await ready.json<{ access_token: string }>(); - await scheduledSyncsSettled(); + await backgroundTasksSettled(); await env.DB.prepare("UPDATE workspaces SET files_ready = 0 WHERE id = ?1") .bind(workspace.id).run(); diff --git a/packages/control-plane/test/workspace-tunnels.test.ts b/packages/control-plane/test/workspace-tunnels.test.ts index 0b0fe233..e99ba999 100644 --- a/packages/control-plane/test/workspace-tunnels.test.ts +++ b/packages/control-plane/test/workspace-tunnels.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { rows } from "../core/db.js"; import { CloudflareTunnels } from "../core/compute/cloudflare-tunnels.js"; import { WorkspaceTunnels } from "../core/workspace-tunnels.js"; @@ -14,10 +14,22 @@ import { } from "./helpers.js"; describe("workspace tunnels", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("provisions, proxies, and cleans up a cloud workspace tunnel end to end", async () => { const cfCalls: string[] = []; - const cfFetcher = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const proxied: { request?: Request } = {}; + // One global fetch serves both upstreams the tunnels module talks to: the + // Cloudflare API (provision/cleanup) and the per-workspace tunnel hostname + // (the webApp proxy). + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit): Promise => { const url = new URL(String(input)); + if (url.hostname !== "api.cloudflare.com") { + proxied.request = new Request(String(input), init); + return Response.json({ ok: true }); + } const method = init?.method ?? "GET"; cfCalls.push(`${method} ${url.pathname}`); if (method === "DELETE") return Response.json({ success: false, errors: [] }, { status: 404 }); @@ -26,16 +38,11 @@ describe("workspace tunnels", () => { if (url.pathname.includes("/dns_records")) return Response.json({ success: true, result: { id: "dns-1" } }); if (url.pathname.endsWith("/token")) return Response.json({ success: true, result: "TUNNEL-RUN-TOKEN" }); return Response.json({ success: false, errors: [] }, { status: 500 }); - }; - const proxied: { request?: Request } = {}; + }); const workspaceTunnels = new WorkspaceTunnels( - new CloudflareTunnels({ accountId: "test-account", zoneId: "test-zone-id", apiToken: "test-api-token", fetcher: cfFetcher }), + new CloudflareTunnels({ accountId: "test-account", zoneId: "test-zone-id", apiToken: "test-api-token" }), "webapp.test", "test-webapp-root-secret", - async (input, init) => { - proxied.request = new Request(String(input), init); - return Response.json({ ok: true }); - }, ); const providers = new FakeProviders(); const app = appWithVmProviders([providers], providers, workspaceTunnels); diff --git a/packages/control-plane/vitest.config.ts b/packages/control-plane/vitest.config.ts index f771da48..190bbc5b 100644 --- a/packages/control-plane/vitest.config.ts +++ b/packages/control-plane/vitest.config.ts @@ -21,7 +21,6 @@ export default defineConfig(async () => { BOX_IMAGE_TAG: "blitz-box:test-amd64", BOX_IMAGE_SHA256: "ad".repeat(32), SESSION_TTL_DAYS: "30", - MAX_CONCURRENT_WORKSPACES: "10", MICROVM_HOSTS: '[{"name":"lab","tokenVar":"MICROVM_LAB_TOKEN","dynamic":true}]', // Vendor-only blitz.dev managed-toolchain suites run only when the // host environment opts in with BLITZDEV_MANAGED=1. diff --git a/packages/control-plane/wrangler.toml.example b/packages/control-plane/wrangler.toml.example index b7b95b2a..d07998c8 100644 --- a/packages/control-plane/wrangler.toml.example +++ b/packages/control-plane/wrangler.toml.example @@ -33,8 +33,6 @@ BOX_IMAGE_TAG = "" BOX_IMAGE_SHA256 = "" # Session cookie lifetime in days (1-3650). Optional, default 30. SESSION_TTL_DAYS = "30" -# Per-principal cap on non-destroyed workspaces (1-1000). Optional, default 10. -MAX_CONCURRENT_WORKSPACES = "10" # JSON array of Firecracker microVM hosts. '[]' disables the microVM provider. Shapes below. # WARNING: every configured host requires its MICROVM__TOKEN Worker secret # (>=32 chars, no whitespace). A missing or weak token makes EVERY request to diff --git a/packages/microvm-host/guest/blitz-microvm-enroll.js b/packages/microvm-host/guest/blitz-microvm-enroll.js index b99e1a9b..9e623e42 100755 --- a/packages/microvm-host/guest/blitz-microvm-enroll.js +++ b/packages/microvm-host/guest/blitz-microvm-enroll.js @@ -10,8 +10,14 @@ const {spawn} = require('child_process'); const stateDir = '/var/lib/blitz'; const errorPath = path.join(stateDir, 'bootstrap-error.log'); -const registerTimeoutMs = 30000; -const registerKillGraceMs = 5000; +// The image's own register oneshot wrapper. It is the ONE bounded way to run +// `blitz-cred register` on a box: it drops to the blitz account, points HOME +// and BLITZ_STATE_DIR at the state volume, and carries a `timeout 60` backstop +// over blitz-cred's own 45 s deadline — and it never exits nonzero on an +// enrolment failure, because a workspace that boots signed out beats one that +// never boots. This script adds no second timeout-and-kill stack on top: a +// kill from out here lands mid-write on the only copy of the box credential. +const registerWrapper = '/usr/local/libexec/blitz-register'; function decode(name) { const raw = process.env[name] || ''; @@ -180,77 +186,35 @@ function parsePhoneHomeResponse(value) { } async function pokeRegister() { - process.stdout.write(`microvm-enroll: register start timeout_ms=${registerTimeoutMs}\n`); + process.stdout.write('microvm-enroll: register start\n'); await new Promise((resolve) => { let settled = false; - let timer; - let killGraceTimer; - let timedOut = false; - let timeoutKillSent = false; - let timeoutKillError = ''; - let spawnError; const finish = (message, failed) => { if (settled) return; settled = true; - if (timer) clearTimeout(timer); - if (killGraceTimer) clearTimeout(killGraceTimer); (failed ? process.stderr : process.stdout).write(`microvm-enroll: ${message}\n`); resolve(); }; let child; try { - child = spawn('blitz-cred', ['register'], { - stdio: ['ignore', 'inherit', 'inherit'], - uid: 1000, - gid: 1000, - detached: true, - env: { - ...process.env, - BLITZ_STATE_DIR: stateDir, - HOME: '/var/lib/blitz/home', - USER: 'blitz', - }, - }); + // Root on purpose: the wrapper does its own s6-setuidgid drop to the + // blitz account, exactly as it does under the s6 register oneshot. + // Inherited stdio lands the wrapper's output in microvm-enroll.log. + child = spawn(registerWrapper, [], {stdio: ['ignore', 'inherit', 'inherit']}); } catch (error) { finish(`register failed: ${safeError(error)}`, true); return; } child.once('error', (error) => { - spawnError = error; + finish(`register failed: ${safeError(error)}`, true); }); child.once('close', (code, signal) => { - if (timedOut) { - const killError = timeoutKillError ? ` kill_error=${timeoutKillError}` : ''; - finish(`register timeout after ${registerTimeoutMs}ms kill_sent=${timeoutKillSent}${killError}`, true); - return; - } - if (spawnError) { - finish(`register failed: ${safeError(spawnError)}`, true); - return; - } if (code === 0) { finish('register complete', false); return; } finish(`register failed: exit_code=${code === null ? 'none' : code} signal=${signal || 'none'}`, true); }); - timer = setTimeout(() => { - timedOut = true; - if (typeof child.pid === 'number') { - try { - process.kill(-child.pid, 'SIGKILL'); - timeoutKillSent = true; - } catch (error) { - timeoutKillError = safeError(error); - } - } else { - timeoutKillError = 'child PID unavailable'; - } - killGraceTimer = setTimeout(() => { - const killError = timeoutKillError ? ` kill_error=${timeoutKillError}` : ''; - finish(`register timeout after ${registerTimeoutMs}ms kill_sent=${timeoutKillSent} close_grace_expired_after=${registerKillGraceMs}ms${killError}`, true); - }, registerKillGraceMs); - }, registerTimeoutMs); }); } diff --git a/packages/microvm-host/linux_backend.go b/packages/microvm-host/linux_backend.go index 5c0ee310..60cb9ed7 100644 --- a/packages/microvm-host/linux_backend.go +++ b/packages/microvm-host/linux_backend.go @@ -15,18 +15,11 @@ import ( "path/filepath" "strconv" "strings" - "sync" "syscall" "time" ) -type commandRunner interface { - Run(context.Context, string, ...string) ([]byte, error) -} - -type realRunner struct{} - -func (realRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { +func runCommand(ctx context.Context, name string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, name, args...) output, err := cmd.CombinedOutput() if err != nil { @@ -35,14 +28,16 @@ func (realRunner) Run(ctx context.Context, name string, args ...string) ([]byte, return output, nil } +// LinuxBackend holds no mutable state — cfg is read-only after construction. +// Serialization of the filesystem and process work it does is owned by +// Manager.mu: every call into a backend happens under it (Reconcile, Create, +// Delete), except Versions, which only reads cfg. type LinuxBackend struct { - cfg Config - runner commandRunner - mu sync.Mutex + cfg Config } func NewLinuxBackend(cfg Config) *LinuxBackend { - return &LinuxBackend{cfg: cfg, runner: realRunner{}} + return &LinuxBackend{cfg: cfg} } func (b *LinuxBackend) runtimeRoot() string { return filepath.Join(b.cfg.StateDir, "runtime") } @@ -52,8 +47,6 @@ func (b *LinuxBackend) socketPath(vm *VM) string { } func (b *LinuxBackend) Boot(ctx context.Context, vm *VM, req CreateRequest) (int, error) { - b.mu.Lock() - defer b.mu.Unlock() runtimeDir := b.runtimeDir(vm) imageRoot := filepath.Join(runtimeDir, "image-root") if err := os.MkdirAll(filepath.Join(imageRoot, "seed"), 0700); err != nil { @@ -74,20 +67,20 @@ func (b *LinuxBackend) Boot(ctx context.Context, vm *VM, req CreateRequest) (int if err := f.Close(); err != nil { return 0, err } - if _, err := b.runner.Run(ctx, "mkfs.ext4", "-q", "-F", "-E", "lazy_itable_init=1,lazy_journal_init=1", "-d", imageRoot, upper); err != nil { + if _, err := runCommand(ctx, "mkfs.ext4", "-q", "-F", "-E", "lazy_itable_init=1,lazy_journal_init=1", "-d", imageRoot, upper); err != nil { return 0, err } hostIP, _, tap, _, err := NetworkFor(b.cfg, vm.Slot) if err != nil { return 0, err } - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, "ip", "tuntap", "add", "dev", tap, "mode", "tap", "user", currentUsername()); err != nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, "ip", "tuntap", "add", "dev", tap, "mode", "tap", "user", currentUsername()); err != nil { return 0, err } - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, "ip", "addr", "add", hostIP+"/30", "dev", tap); err != nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, "ip", "addr", "add", hostIP+"/30", "dev", tap); err != nil { return 0, err } - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, "ip", "link", "set", "dev", tap, "up"); err != nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, "ip", "link", "set", "dev", tap, "up"); err != nil { return 0, err } if err := b.addRules(ctx, vm); err != nil { @@ -178,7 +171,7 @@ func (b *LinuxBackend) Inspect(ctx context.Context, vm *VM) (bool, bool) { } for _, rule := range b.rules(vm) { args := append([]string{"iptables", "-t", rule.table, "-C"}, rule.args...) - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, args...); err != nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, args...); err != nil { return true, false } } @@ -186,8 +179,6 @@ func (b *LinuxBackend) Inspect(ctx context.Context, vm *VM) (bool, bool) { } func (b *LinuxBackend) Stop(ctx context.Context, vm *VM) error { - b.mu.Lock() - defer b.mu.Unlock() pid := b.effectivePID(vm) if pid <= 0 || !processExists(pid) { return nil @@ -216,35 +207,32 @@ func (b *LinuxBackend) Stop(ctx context.Context, vm *VM) error { } func (b *LinuxBackend) Cleanup(ctx context.Context, vm *VM) error { - b.mu.Lock() - defer b.mu.Unlock() var errs []error if err := b.removeRules(ctx, vm); err != nil { errs = append(errs, err) } _, _, tap, _, _ := NetworkFor(b.cfg, vm.Slot) if _, err := os.Stat(filepath.Join("/sys/class/net", tap)); err == nil { - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, "ip", "link", "delete", "dev", tap); err != nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, "ip", "link", "delete", "dev", tap); err != nil { errs = append(errs, err) } } - runtimeDir := b.runtimeDir(vm) - if !pathWithin(runtimeDir, b.runtimeRoot()) { - errs = append(errs, fmt.Errorf("unsafe runtime path %s", runtimeDir)) - } else if err := os.RemoveAll(runtimeDir); err != nil { + // SAFETY: runtimeDir is runtimeRoot()/, and every VM this method + // ever sees carries a vmIDPattern-validated ID: StateStore.Load and Save + // both enforce `^[a-zA-Z0-9-]+$` and newVMID only produces that shape, so + // the join cannot name anything outside the runtime root. + if err := os.RemoveAll(b.runtimeDir(vm)); err != nil { errs = append(errs, err) } return errors.Join(errs...) } func (b *LinuxBackend) CleanupOrphans(ctx context.Context, active map[int]string) error { - b.mu.Lock() - defer b.mu.Unlock() var errs []error if raw, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward"); err != nil { errs = append(errs, err) } else if strings.TrimSpace(string(raw)) != "1" { - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, "sysctl", "-q", "-w", "net.ipv4.ip_forward=1"); err != nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, "sysctl", "-q", "-w", "net.ipv4.ip_forward=1"); err != nil { errs = append(errs, err) } } @@ -292,10 +280,11 @@ func (b *LinuxBackend) CleanupOrphans(ctx context.Context, active map[int]string _ = terminatePID(pid) } } - if pathWithin(dir, root) { - if err := os.RemoveAll(dir); err != nil { - errs = append(errs, err) - } + // SAFETY: dir is root/ where entry came from ReadDir on + // root itself — a real directory entry, never "." or "..", never + // containing a separator — so the join cannot escape the runtime root. + if err := os.RemoveAll(dir); err != nil { + errs = append(errs, err) } } for slot := 1; slot <= b.cfg.SlotCount; slot++ { @@ -308,7 +297,7 @@ func (b *LinuxBackend) CleanupOrphans(ctx context.Context, active map[int]string } _, _, tap, _, _ := NetworkFor(b.cfg, slot) if _, err := os.Stat(filepath.Join("/sys/class/net", tap)); err == nil { - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, "ip", "link", "delete", "dev", tap); err != nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, "ip", "link", "delete", "dev", tap); err != nil { errs = append(errs, err) } } @@ -341,11 +330,11 @@ func (b *LinuxBackend) rules(vm *VM) []iptablesRule { func (b *LinuxBackend) addRules(ctx context.Context, vm *VM) error { for _, rule := range b.rules(vm) { check := append([]string{"iptables", "-t", rule.table, "-C"}, rule.args...) - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, check...); err == nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, check...); err == nil { continue } add := append([]string{"iptables", "-t", rule.table, "-A"}, rule.args...) - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, add...); err != nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, add...); err != nil { return err } } @@ -356,11 +345,11 @@ func (b *LinuxBackend) removeRules(ctx context.Context, vm *VM) error { var errs []error for _, rule := range b.rules(vm) { check := append([]string{"iptables", "-t", rule.table, "-C"}, rule.args...) - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, check...); err != nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, check...); err != nil { continue } remove := append([]string{"iptables", "-t", rule.table, "-D"}, rule.args...) - if _, err := b.runner.Run(ctx, b.cfg.SudoWrapper, remove...); err != nil { + if _, err := runCommand(ctx, b.cfg.SudoWrapper, remove...); err != nil { errs = append(errs, err) } } @@ -457,11 +446,6 @@ func terminatePID(pid int) error { return nil } -func pathWithin(path, root string) bool { - rel, err := filepath.Rel(root, path) - return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) -} - func currentUsername() string { if user := os.Getenv("USER"); user != "" { return user diff --git a/packages/microvm-host/linux_backend_test.go b/packages/microvm-host/linux_backend_test.go index 2f42b0b1..0743d8ff 100644 --- a/packages/microvm-host/linux_backend_test.go +++ b/packages/microvm-host/linux_backend_test.go @@ -6,46 +6,57 @@ import ( "os" "path/filepath" "reflect" + "strconv" "strings" + "sync" "testing" + "time" ) -type runnerCall struct { - name string - args []string -} - -type iptablesRunner struct { - calls []runnerCall - rules map[string]bool +// installFakeIptables writes a real executable at cfg.SudoWrapper — the exact +// path the backend execs — that emulates iptables -C/-A/-D statefulness in a +// rules directory and appends every argv line to a calls file. The backend +// then runs its true exec path end to end; nothing in it is substituted. +func installFakeIptables(t *testing.T, cfg Config) (callsFile, rulesDir string) { + t.Helper() + callsFile = filepath.Join(t.TempDir(), "calls") + rulesDir = t.TempDir() + script := "#!/bin/sh\nset -eu\n" + + "printf '%s\\n' \"$*\" >> " + strconv.Quote(callsFile) + "\n" + + "[ \"$1\" = iptables ] && [ \"$2\" = -t ] || exit 2\n" + + "table=$3\nop=$4\nshift 4\n" + + "key=$(printf '%s %s' \"$table\" \"$*\" | cksum | tr -dc 0-9)\n" + + "case $op in\n" + + "-C) [ -e \"" + rulesDir + "/$key\" ] || exit 1 ;;\n" + + "-A) [ ! -e \"" + rulesDir + "/$key\" ] || exit 1\n : > \"" + rulesDir + "/$key\" ;;\n" + + "-D) rm \"" + rulesDir + "/$key\" ;;\n" + + "*) exit 2 ;;\n" + + "esac\n" + if err := os.WriteFile(cfg.SudoWrapper, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return callsFile, rulesDir } -func (r *iptablesRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { - r.calls = append(r.calls, runnerCall{name: name, args: append([]string(nil), args...)}) - if len(args) < 5 || args[0] != "iptables" || args[1] != "-t" { - return nil, errors.New("unexpected command") +func recordedCalls(t *testing.T, callsFile string) []string { + t.Helper() + data, err := os.ReadFile(callsFile) + if errors.Is(err, os.ErrNotExist) { + return nil } + if err != nil { + t.Fatal(err) + } + return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") +} - key := strings.Join(append([]string{args[2]}, args[4:]...), "\x00") - switch args[3] { - case "-C": - if !r.rules[key] { - return nil, errors.New("rule does not exist") - } - case "-A": - if r.rules[key] { - return nil, errors.New("duplicate rule") - } - r.rules[key] = true - case "-D": - if !r.rules[key] { - return nil, errors.New("rule does not exist") - } - delete(r.rules, key) - default: - return nil, errors.New("unexpected iptables operation") +func installedRuleCount(t *testing.T, rulesDir string) int { + t.Helper() + entries, err := os.ReadDir(rulesDir) + if err != nil { + t.Fatal(err) } - return nil, nil + return len(entries) } func expectedRulesForSlotOne() []iptablesRule { @@ -71,59 +82,120 @@ func TestLinuxBackendRulesAreCompleteAndTagged(t *testing.T) { func TestLinuxBackendRuleLifecycleIsIdempotent(t *testing.T) { cfg := testConfig(t.TempDir()) - runner := &iptablesRunner{rules: make(map[string]bool)} + callsFile, rulesDir := installFakeIptables(t, cfg) b := NewLinuxBackend(cfg) - b.runner = runner vm := &VM{Slot: 1} rules := expectedRulesForSlotOne() + resetCalls := func() { + if err := os.Remove(callsFile); err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + } if err := b.addRules(context.Background(), vm); err != nil { t.Fatal(err) } - if len(runner.rules) != len(rules) { - t.Fatalf("first add installed %d rules; want %d", len(runner.rules), len(rules)) + if got := installedRuleCount(t, rulesDir); got != len(rules) { + t.Fatalf("first add installed %d rules; want %d", got, len(rules)) } - assertRuleCalls(t, runner.calls, cfg.SudoWrapper, rules, "-C", "-A") + assertRuleCalls(t, recordedCalls(t, callsFile), rules, "-C", "-A") - runner.calls = nil + resetCalls() if err := b.addRules(context.Background(), vm); err != nil { t.Fatal(err) } - if len(runner.rules) != len(rules) { - t.Fatalf("second add left %d rules; want %d", len(runner.rules), len(rules)) + if got := installedRuleCount(t, rulesDir); got != len(rules) { + t.Fatalf("second add left %d rules; want %d", got, len(rules)) } - assertRuleCalls(t, runner.calls, cfg.SudoWrapper, rules, "-C") + assertRuleCalls(t, recordedCalls(t, callsFile), rules, "-C") - runner.calls = nil + resetCalls() if err := b.removeRules(context.Background(), vm); err != nil { t.Fatal(err) } - if len(runner.rules) != 0 { - t.Fatalf("first remove left %d rules; want 0", len(runner.rules)) + if got := installedRuleCount(t, rulesDir); got != 0 { + t.Fatalf("first remove left %d rules; want 0", got) } - assertRuleCalls(t, runner.calls, cfg.SudoWrapper, rules, "-C", "-D") + assertRuleCalls(t, recordedCalls(t, callsFile), rules, "-C", "-D") - runner.calls = nil + resetCalls() if err := b.removeRules(context.Background(), vm); err != nil { t.Fatal(err) } - if len(runner.rules) != 0 { - t.Fatalf("second remove left %d rules; want 0", len(runner.rules)) + if got := installedRuleCount(t, rulesDir); got != 0 { + t.Fatalf("second remove left %d rules; want 0", got) } - assertRuleCalls(t, runner.calls, cfg.SudoWrapper, rules, "-C") + assertRuleCalls(t, recordedCalls(t, callsFile), rules, "-C") } -func assertRuleCalls(t *testing.T, got []runnerCall, wrapper string, rules []iptablesRule, operations ...string) { +func assertRuleCalls(t *testing.T, got []string, rules []iptablesRule, operations ...string) { t.Helper() - want := make([]runnerCall, 0, len(rules)*len(operations)) + want := make([]string, 0, len(rules)*len(operations)) for _, rule := range rules { for _, operation := range operations { args := append([]string{"iptables", "-t", rule.table, operation}, rule.args...) - want = append(want, runnerCall{name: wrapper, args: args}) + want = append(want, strings.Join(args, " ")) } } if !reflect.DeepEqual(got, want) { - t.Fatalf("runner calls = %#v; want %#v", got, want) + t.Fatalf("iptables calls = %#v; want %#v", got, want) + } +} + +// TestConcurrentManagerLifecycleOverTheRealBackendIsSerialized is the race +// proof behind LinuxBackend carrying no mutex of its own: Manager.mu is the +// single serializer for every lifecycle call into the backend. It drives +// Create/Delete/Reconcile/List/Capacity from several goroutines against the +// REAL LinuxBackend — boots fail fast on the missing mkfs/sudo binaries, which +// still walks the Boot-prefix, Stop, Cleanup and CleanupOrphans paths — so a +// hole in the manager's serialization shows up under -race as a torn vms map +// or VM field. Run with `go test -race`. +func TestConcurrentManagerLifecycleOverTheRealBackendIsSerialized(t *testing.T) { + dir := t.TempDir() + cfg := testConfig(dir) + // A failing mkfs.ext4 first on PATH: identical fast Boot failures on every + // platform, instead of depending on which host binaries happen to exist. + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "mkfs.ext4"), []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + store := NewStateStore(cfg.StateDir) + now := time.Now().UTC() + for slot := 1; slot <= 2; slot++ { + dead := &VM{VMID: "vm-" + strconv.Itoa(slot) + "-dead", Slot: slot, CPU: 1, MemMB: 256, + Status: StatusRunning, CreatedAt: now} + if err := store.Save(dead); err != nil { + t.Fatal(err) + } + } + manager, err := NewManager(cfg, store, NewLinuxBackend(cfg)) + if err != nil { + t.Fatal(err) + } + + var wait sync.WaitGroup + for worker := 0; worker < 4; worker++ { + wait.Add(1) + go func(worker int) { + defer wait.Done() + for round := 0; round < 3; round++ { + name := "ws-" + strconv.Itoa(worker) + "-" + strconv.Itoa(round) + if created, err := manager.Create(context.Background(), validRequest(name, 1, 128)); err == nil { + _ = manager.Delete(context.Background(), created.VMID) + } + // Reconcile's errors are expected here (no /proc net state, no + // sudo wrapper); the serialization, not the plumbing, is on trial. + _ = manager.Reconcile(context.Background()) + _ = manager.List() + _ = manager.Capacity() + } + }(worker) + } + wait.Wait() + + if vms := manager.List(); len(vms) != 0 { + t.Fatalf("VMs survived failed boots and reconciles: %#v", vms) } } @@ -193,6 +265,13 @@ func TestMicroVMInitWritesRegularResolvConfBeforeEnrollment(t *testing.T) { } } +// TestMicroVMEnrollmentPokesRegisterAfterAtomicWrites pins the register poke +// contract: after the phone-home reply and the control-plane origin are on +// disk, enrollment awaits the image's own bounded register wrapper +// (/usr/local/libexec/blitz-register) — the SAME oneshot every other box runs +// — rather than rebuilding a private spawn/timeout/kill stack around +// blitz-cred. The wrapper owns the account drop, the state-dir environment and +// the timeout backstop; blitz-cred register carries its own 45 s deadline. func TestMicroVMEnrollmentPokesRegisterAfterAtomicWrites(t *testing.T) { contents, err := os.ReadFile(filepath.Join("guest", "blitz-microvm-enroll.js")) if err != nil { @@ -210,48 +289,35 @@ func TestMicroVMEnrollmentPokesRegisterAfterAtomicWrites(t *testing.T) { t.Fatalf("register poke must be awaited after both writes and before completion: credential=%d origin=%d register=%d complete=%d", credential, origin, register, complete) } for _, required := range []string{ - "const registerTimeoutMs = 30000", - "const registerKillGraceMs = 5000", - "...process.env", - "BLITZ_STATE_DIR: stateDir", - "HOME: '/var/lib/blitz/home'", - "USER: 'blitz'", - "blitz-cred", - "register", - "timer = setTimeout(", - "killGraceTimer = setTimeout(", - "process.kill(-child.pid, 'SIGKILL')", + "'/usr/local/libexec/blitz-register'", + "spawn(registerWrapper, [], {stdio: ['ignore', 'inherit', 'inherit']})", "child.once('error'", "child.once('close'", - "stdio: ['ignore', 'inherit', 'inherit']", - "uid: 1000", - "gid: 1000", - "detached: true", "if (settled) return", "settled = true", - "register timeout", "register failed", "register complete", } { if !strings.Contains(script, required) { - t.Fatalf("guest enrollment is missing bounded/logged register behavior %q", required) + t.Fatalf("guest enrollment is missing register-poke behavior %q", required) } } for _, forbidden := range []string{ + // The wrapper is the one bounded runner; a rebuilt private stack around + // blitz-cred is exactly what this test exists to keep out. + "spawn('blitz-cred'", + "registerTimeoutMs", + "process.kill(-", + "detached: true", + "uid: 1000", "spawn('/usr/bin/env'", "child.kill('SIGKILL')", "child.unref()", } { if strings.Contains(script, forbidden) { - t.Fatalf("guest enrollment contains unsafe register behavior %q", forbidden) + t.Fatalf("guest enrollment contains register behavior the wrapper owns: %q", forbidden) } } - timeout := strings.Index(script, "timer = setTimeout(") - groupKill := strings.Index(script, "process.kill(-child.pid, 'SIGKILL')") - killGrace := strings.Index(script, "killGraceTimer = setTimeout(") - if timeout < 0 || groupKill <= timeout || killGrace <= groupKill { - t.Fatalf("register timeout must kill the process group before starting bounded close grace: timeout=%d kill=%d grace=%d", timeout, groupKill, killGrace) - } initContents, err := os.ReadFile(filepath.Join("guest", "microvm-init")) if err != nil { t.Fatal(err) diff --git a/packages/schema/src/credential.ts b/packages/schema/src/credential.ts index 368537f4..9d851d07 100644 --- a/packages/schema/src/credential.ts +++ b/packages/schema/src/credential.ts @@ -145,7 +145,6 @@ export interface CatalogEntryView { summary: string; docsUrl: string; custody: Custody; - rotation: "strict" | "graceful" | "none"; oauthAvailable: boolean; oauthConfigured: boolean; personalTokenLabel: string | null; diff --git a/packages/webapp/src/CloudApp.tsx b/packages/webapp/src/CloudApp.tsx index abacedea..27f0f5d2 100644 --- a/packages/webapp/src/CloudApp.tsx +++ b/packages/webapp/src/CloudApp.tsx @@ -9,13 +9,12 @@ import { type ReactNode, } from 'react'; import { createClient, type WebDAVClient } from 'webdav'; -import { - ApiAdapter, - ApiError, - type V2WorkspaceRecord, -} from './api-adapter'; import type { ControlPlaneClient } from './api'; -import type { CredentialRequestView, FolderAttachmentView } from '@blitzos/schema'; +import type { + CreateWorkspaceResponse, + CredentialRequestView, + FolderAttachmentView, +} from '@blitzos/schema'; import { WebAppHeader, SPAWN_SESSION_LABELS, @@ -35,12 +34,11 @@ import { type CreateWorkspaceDialogInput, } from './CreateWorkspaceDialog'; import { ConfirmationDialog } from './ConfirmationDialog'; -import { caughtErrorMessage } from './error-message'; +import { caughtErrorMessage, isUnauthorized } from './error-message'; import { WebAppLoadingPane, WebAppLoadingShell, } from './LoadingSkeleton'; -import { machineTypeLabel } from './MachineCatalogGrid'; import { SettingsHeader, SettingsPage } from './SettingsPage'; import { ShareWorkspaceDialog } from './ShareWorkspaceDialog'; import { @@ -64,7 +62,6 @@ import { import { defaultWorkspaceFiles, maxDrawerWidth, - removeDismissedChatAuthProviders, tabRegion, withPreviewTabPath, type StorageNamespace, @@ -115,13 +112,14 @@ import { decideUpdateAction, extractIndexAsset } from './update-check'; import { LoginForm } from './components/LoginForm'; import { CreateOrgPage } from './components/CreateOrgPage'; import type { IdentityRecord } from './protocol'; -import { FILES_DAV_ROOT, type EndpointResolver } from './resolver'; import { - type ConnectionsPanelFocus, WorkspaceDrawer, WorkspacePanelContent } from './WorkspaceDrawer'; + FILES_DAV_ROOT, + type BoxEndpoints, + type EndpointResolver, +} from './resolver'; import { - rememberWorkspaceEndpoints, - type WorkspaceEndpoints, -} from './workspace-endpoints'; + type ConnectionsPanelFocus, WorkspaceDrawer, WorkspacePanelContent } from './WorkspaceDrawer'; +import { rememberWorkspaceEndpoints } from './workspace-endpoints'; import { useWorkspacePersistence } from './use-workspace-persistence'; import { useWorkspaceBootstrap, @@ -129,7 +127,6 @@ import { } from './use-workspace-lifecycle'; import { useWorkspacePreviewSources } from './use-workspace-preview-sources'; import { useWorkspaceConnectionsFocus } from './use-workspace-connections-focus'; -import { useWorkspacePreviewFocus } from './use-workspace-preview-focus'; const UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1_000; const UPDATE_RELOAD_MARKER_PREFIX = 'blitzos:update-reloaded:'; @@ -285,7 +282,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { const shellRef = useRef(null); const activeWorkspaceIdRef = useRef(activeWorkspaceId); const storeRef = useRef(store); - const workspaceEndpoints = useRef(new Map()); + const workspaceEndpoints = useRef(new Map()); const firstWorkspacePrompted = useRef(false); // Visit once, then retain: tab switches preserve live state without eagerly // opening every saved terminal, WebGL surface, and chat SDK connection. @@ -346,27 +343,33 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { }; }, [drawerOpen, mobileWebApp]); + // The one sink for control-plane 401s: the bootstrap, the workspace poll, + // and the state-persistence chokepoints all land here. One-shot calls + // surface their own failure message; the recurring poll flips to login + // within one tick of the session dying. const handleUnauthorized = useCallback(() => { setSignedOut(true); setLoaded(true); }, []); - const api = useMemo( - () => new ApiAdapter(client, handleUnauthorized), - [client, handleUnauthorized], - ); const handlePersistenceError = useCallback((cause: Error) => { - if (cause instanceof ApiError && cause.status === 401) return; + if (isUnauthorized(cause)) { + handleUnauthorized(); + return; + } setError(caughtErrorMessage(cause, 'Could not save webApp state.')); - }, []); + }, [handleUnauthorized]); const signOut = useCallback(async () => { try { - await api.logout(); + await client.logout(); } finally { setSignedOut(true); } - }, [api]); - const listMachineTypes = useCallback(() => api.listMachineTypes(), [api]); - const listVolumes = useCallback(() => api.listVolumes(), [api]); + }, [client]); + const listMachineTypes = useCallback(() => client.listMachineTypes(), [client]); + const listVolumes = useCallback( + () => client.listVolumes().then(({ volumes }) => volumes), + [client], + ); // Every template load also refreshes which template is the org default, so // the create dialog can preselect it (its own load runs through here). const [orgDefaultTemplateId, setOrgDefaultTemplateId] = useState(null); @@ -381,15 +384,14 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { ); const refreshWorkspaceRecords = useCallback(async () => { try { - const records = await api.listWorkspaces(); - rememberWorkspaceEndpoints(workspaceEndpoints.current, records, resolver, true); - dispatch({ type: 'workspace_records_refreshed', records }); + const { workspaces } = await client.poll(); + rememberWorkspaceEndpoints(workspaceEndpoints.current, workspaces, resolver, true); + dispatch({ type: 'workspace_records_refreshed', records: workspaces }); } catch (refreshError) { - if (!(refreshError instanceof ApiError && refreshError.status === 401)) { - console.warn('Unable to refresh workspace status', refreshError); - } + if (isUnauthorized(refreshError)) handleUnauthorized(); + else console.warn('Unable to refresh workspace status', refreshError); } - }, [api, resolver]); + }, [client, handleUnauthorized, resolver]); const activeWorkspace = useMemo( () => store.workspaces.find(({ id, canControl }) => id === activeWorkspaceId && canControl), @@ -409,7 +411,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { workspaceFiles, setWorkspaceFiles, } = useWorkspacePersistence( - api, + client, storageNamespace !== null, activeWorkspaceId, persistenceMetadata, @@ -457,17 +459,12 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { : activeWorkspaceTabs !== null && filesTab !== null && regionActiveId(activeWorkspaceTabs, tabRegion(filesTab)) === filesTab.id; - const { livePorts, previewLinks } = useWorkspacePreviewSources( - route.page === 'webApp' && activeWorkspaceRunning, - activeWorkspaceId, - activeFilesBase, - ); // The in-box agent's `blitz preview open` raises a focus marker; open it as a // tab so the user never hunts for the preview. `openPreviewPort` is defined // below and only referenced when a focus arrives (after render), so its // temporal position is fine. The pre-split drawer-segment nudge is gone: // panels are tabs now, and forcing one open would steal the pane. - useWorkspacePreviewFocus( + const { livePorts, previewLinks } = useWorkspacePreviewSources( route.page === 'webApp' && activeWorkspaceRunning, activeWorkspaceId, activeFilesBase, @@ -551,13 +548,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { if (!activeFilesBase) return null; return createClient(activeFilesBase, { withCredentials: true, remoteBasePath: FILES_DAV_ROOT }); }, [activeFilesBase]); - const getFilesClient = useCallback((): WebDAVClient | null => { - const workspaceId = activeWorkspaceIdRef.current; - const workspace = storeRef.current.workspaces.find(({ id }) => id === workspaceId); - const filesBase = workspaceEndpoints.current.get(workspaceId)?.filesBase; - if (workspace?.lifecycleStatus !== 'running' || !filesBase) return null; - return createClient(filesBase, { withCredentials: true, remoteBasePath: FILES_DAV_ROOT }); - }, []); const [dropActive, setDropActive] = useState(false); const [dropBusy, setDropBusy] = useState(false); // Drop a screenshot on a tab and its path lands in the TUI. Upload reuses the @@ -588,9 +578,10 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { } }, [filesClient]); useWorkspaceBootstrap({ - api, + client, bootstrapVersion, signedOut, + onUnauthorized: handleUnauthorized, resolver, workspaceEndpoints, activeWorkspaceIdRef, @@ -704,12 +695,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { }, []); const transitioningWorkspaceCount = store.workspaces.filter( - ({ lifecycleStatus }) => ( - lifecycleStatus === 'creating' - || lifecycleStatus === 'provisioning' - || lifecycleStatus === 'parking' - || lifecycleStatus === 'resuming' - ), + ({ lifecycleStatus }) => lifecycleStatus === 'creating', ).length; useWorkspacePolling({ @@ -722,14 +708,14 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { useEffect(() => { if (!loaded || !storageNamespace) return; const timer = window.setTimeout(() => { - void api.putGlobalWebAppState({ + void client.putGlobalWebAppState({ version: 1, activeWorkspaceId, order: store.workspaces.map(({ id }) => id), }).catch(handlePersistenceError); }, 150); return () => window.clearTimeout(timer); - }, [activeWorkspaceId, api, handlePersistenceError, loaded, storageNamespace, store.workspaces]); + }, [activeWorkspaceId, client, handlePersistenceError, loaded, storageNamespace, store.workspaces]); const navigateToWorkspacePage = useCallback((workspaceId: string) => { window.history.pushState({}, '', workspacePath(workspaceId)); @@ -777,11 +763,8 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { setRoute({ workspaceId: null, page: 'drive' }); } } - void api.deleteWorkspace(workspaceId) + void client.destroy(workspaceId) .then(() => { - if (storageNamespace) { - removeDismissedChatAuthProviders(storageNamespace, workspaceId); - } workspaceEndpoints.current.delete(workspaceId); }) .catch((cause: unknown) => { @@ -792,7 +775,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { }); setError(`Could not delete “${workspace.title}”: ${caughtErrorMessage(cause, 'The control plane request failed.')}`); }); - }, [api, navigateToWorkspacePage, storageNamespace]); + }, [client, navigateToWorkspacePage]); const selectWorkspace = useCallback((workspaceId: string) => { if (!store.workspaces.some(({ id, canControl }) => id === workspaceId && canControl)) return; @@ -806,20 +789,21 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { }, [navigateToWorkspacePage, store.workspaces]); // One tail for everything that mints a workspace: the create dialog and - // recipe launches both adopt the record and navigate to it the same way. + // recipe launches both answer with the create-workspace envelope, adopt the + // record, and navigate to it the same way. const adoptCreatedWorkspace = useCallback(async ( - create: () => Promise, + create: () => Promise, ) => { setCreateWorkspaceBusy(true); setCreateWorkspaceError(null); try { - const record = await create(); - rememberWorkspaceEndpoints(workspaceEndpoints.current, [record], resolver); - dispatch({ type: 'workspace_created', record, agentDefault: 'claude' }); - if (record.canControl) { - activeWorkspaceIdRef.current = record.id; - setActiveWorkspaceId(record.id); - navigateToWorkspacePage(record.id); + const { workspace } = await create(); + rememberWorkspaceEndpoints(workspaceEndpoints.current, [workspace], resolver); + dispatch({ type: 'workspace_created', record: workspace, agentDefault: 'claude' }); + if (workspace.role !== null) { + activeWorkspaceIdRef.current = workspace.id; + setActiveWorkspaceId(workspace.id); + navigateToWorkspacePage(workspace.id); } setShowCreateWorkspace(false); } catch (createFailure) { @@ -829,14 +813,14 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { } }, [navigateToWorkspacePage, resolver]); const createWorkspace = useCallback( - (input: CreateWorkspaceDialogInput) => adoptCreatedWorkspace(() => api.createWorkspace(input)), - [adoptCreatedWorkspace, api], + (input: CreateWorkspaceDialogInput) => adoptCreatedWorkspace(() => client.create(input)), + [adoptCreatedWorkspace, client], ); // The launch failure (for example the vm-limit 409) surfaces through the // same notice the create dialog uses, with the control plane's message. const launchRecipe = useCallback( - (recipeId: string) => adoptCreatedWorkspace(() => api.launchRecipe(recipeId)), - [adoptCreatedWorkspace, api], + (recipeId: string) => adoptCreatedWorkspace(() => client.launchRecipe(recipeId)), + [adoptCreatedWorkspace, client], ); const setSidePaneWidth = useCallback((width: number) => { @@ -1257,24 +1241,13 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { const webAppBooting = route.page === 'webApp' && ( !loaded || (hasControllableWorkspace && !activeWorkspace) ); - const workspaceProvisioning = activeWorkspace?.lifecycleStatus === 'creating' - || activeWorkspace?.lifecycleStatus === 'provisioning'; - const workspaceWaking = activeWorkspace?.lifecycleStatus === 'parked' - || activeWorkspace?.lifecycleStatus === 'resuming'; - const workspaceWakingStage = workspaceWaking - ? activeWorkspace?.lifecycleStatus === 'parked' - ? 'waking · requesting compute' - : 'waking · reattaching drive' - : undefined; - const workspaceErrored = activeWorkspace !== undefined && ( - activeWorkspace.lifecycleStatus === 'error' - || (activeWorkspace.lifecycleStatus === 'parked' && activeWorkspace.errorDetail !== null) - ); + const workspaceCreating = activeWorkspace?.lifecycleStatus === 'creating'; + const workspaceErrored = activeWorkspace?.lifecycleStatus === 'error'; // Terminals and chat need a live box; files, previews and panels draw their - // own unavailable states and stay mounted while the box wakes. + // own unavailable states and stay mounted while the box comes up. const sessionsRenderable = !workspaceErrored && activeSessionUrl !== null - && !workspaceProvisioning + && !workspaceCreating && tabsLoaded; const retainedSessions = retainedSessionIdsRef.current; // One renderer for every surface, all siblings in the pane grid. A tab that @@ -1293,22 +1266,10 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { && retainedSessions.ids.has(sessionId) ); }); - const loadingStage = activeWorkspace === undefined - ? 'starting · workspace terminal' - : workspaceProvisioning - ? activeWorkspace.lifecycleStatus === 'creating' - ? `allocating · ${activeWorkspace.machineType - ? machineTypeLabel(activeWorkspace.machineType) - : 'workspace VM'}` - : `starting · ${activeWorkspace.machineType - ? machineTypeLabel(activeWorkspace.machineType) - : 'workspace VM'}` - : workspaceWakingStage ?? 'starting · workspace terminal'; - const loadingLabel = workspaceProvisioning - ? activeWorkspace?.lifecycleStatus === 'creating' - ? 'Creating workspace' - : 'Provisioning workspace' - : workspaceWaking ? 'Waking workspace' : 'Loading workspace'; + const loadingStage = activeWorkspace !== undefined && workspaceCreating + ? `allocating · ${activeWorkspace.machineType}` + : 'starting · workspace terminal'; + const loadingLabel = workspaceCreating ? 'Creating workspace' : 'Loading workspace'; /** What a column shows when its active tab cannot draw itself yet. Files, * previews and panels always draw themselves, so they never see this. */ const paneFallback = (region: WorkspaceRegion): ReactNode => { @@ -1342,7 +1303,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { ); } - if (activeSessionUrl === null || workspaceProvisioning) { + if (activeSessionUrl === null || workspaceCreating) { return ; } if (!tabsLoaded) { @@ -1357,13 +1318,11 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { key={activeWorkspace.id} client={filesClient} expanded={activeFiles.expanded} - getClient={getFilesClient} mobile={mobileWebApp} open={filesOpen} ready={activeWorkspaceRunning} refreshVersion={filesRefreshVersion} visible={filesSegmentVisible} - wakingStage={workspaceWakingStage} width={activeFiles.width} sharedFolders={workspaceAttachments.workspaceId === activeWorkspace.id ? workspaceAttachments.folders @@ -1433,14 +1392,14 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { ); if (signedOut) { - return ; + return ; } if (identityOnly !== null) { return ( { - await api.createOrg(name); + await client.createOrg(name); setIdentityOnly(null); setLoaded(false); setBootstrapVersion((version) => version + 1); @@ -1685,9 +1644,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { workspaceName={workspace.title} orgName={store.viewer?.org.name ?? 'your org'} orgShareRole={workspace.orgShareRole} - owner={workspace.owner ?? (workspace.accessRole === 'owner' && store.viewer - ? { name: store.viewer.identity.name || store.viewer.identity.email, avatarUrl: store.viewer.identity.avatarUrl ?? null } - : null)} + owner={workspace.owner} viewerIsOwner={workspace.accessRole === 'owner'} onClose={() => setShareWorkspaceId(null)} /> @@ -1701,7 +1658,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { View access is almost here. Read-only terminals arrive with the next platform update. Until - then, ask {activeWorkspace.owner?.name ?? 'the owner'} for + then, ask {activeWorkspace.owner.name || 'the owner'} for editor access to use this workspace. @@ -1727,7 +1684,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { tabs={paneTabModels(region)} activeSessionId={paneActiveId(region) ?? ''} sessionBusy={false} - terminalDisabled={workspaceWaking || !tabsLoaded} + terminalDisabled={!tabsLoaded} mobile={mobileWebApp} paneStrips={false} drawerOpen={drawerOpen} @@ -1835,7 +1792,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { active={active} client={filesClient} filePath={session.filePath} - unavailableStage={workspaceWakingStage} onDirtyChange={(dirty) => updateFileDirty(sessionId, dirty)} onSaved={() => setFilesRefreshVersion((version) => version + 1)} onTreeRefresh={() => setFilesRefreshVersion((version) => version + 1)} @@ -2098,9 +2054,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { { updateWorkspaceTabs((tabs) => showPanelTab(tabs, panel)); }} diff --git a/packages/webapp/src/CreateWorkspaceDialog.tsx b/packages/webapp/src/CreateWorkspaceDialog.tsx index ba459a17..471ba69d 100644 --- a/packages/webapp/src/CreateWorkspaceDialog.tsx +++ b/packages/webapp/src/CreateWorkspaceDialog.tsx @@ -9,7 +9,7 @@ import type { import { useEffect, useRef, useState, type FormEvent } from 'react'; import { AgentRulesPicker, type AgentRulesApi } from './AgentRulesPicker'; import { OutlinedLoadingRows } from './LoadingSkeleton'; -import { MachineCatalogGrid, machineTypeLabel } from './MachineCatalogGrid'; +import { MachineCatalogGrid } from './MachineCatalogGrid'; import { EMPTY_WORKSPACE_ENVIRONMENT, EnvironmentEditor, @@ -199,7 +199,7 @@ export function CreateWorkspaceDialog({ }} > {template.name} - {machineTypeLabel(template.machineTypeId)} + {template.machineTypeId} {template.folders.length === 1 ? '1 folder' : `${template.folders.length} folders`} {' · by '}{template.createdBy.name} @@ -224,7 +224,7 @@ export function CreateWorkspaceDialog({

{selectedTemplate.name}

- {machineTypeLabel(selectedTemplate.machineTypeId)} + {selectedTemplate.machineTypeId} {' · shared with everyone at '}{orgName} {' · the workspace is named after the template'}

diff --git a/packages/webapp/src/FilesSidebar.tsx b/packages/webapp/src/FilesSidebar.tsx index 7832a70f..ceefd7c6 100644 --- a/packages/webapp/src/FilesSidebar.tsx +++ b/packages/webapp/src/FilesSidebar.tsx @@ -32,7 +32,6 @@ import { maxDrawerWidth } from './storage'; type FilesSidebarProps = { client: WebDAVClient | null; expanded: string[]; - getClient: () => WebDAVClient | null; mobile: boolean; open: boolean; ready: boolean; @@ -68,7 +67,6 @@ function transientDavStatus(status: number | undefined): boolean { export function FilesSidebar({ client, expanded, - getClient, mobile, open, ready, @@ -143,17 +141,11 @@ export function FilesSidebar({ return () => observer.disconnect(); }, []); - const currentClient = useCallback( - () => client ?? getClient(), - [client, getClient], - ); - const loadDirectory = useCallback(async ( path: string, rootFailureState: 'loading' | 'error' = 'error', ): Promise => { - const requestClient = currentClient(); - if (!requestClient) return 'unavailable'; + if (client === null) return 'unavailable'; const token = Symbol(path); requestTokens.current.set(path, token); setLoadingPaths((current) => new Set(current).add(path)); @@ -166,7 +158,7 @@ export function FilesSidebar({ )); } try { - const result = await requestClient.getDirectoryContents(path || '/'); + const result = await client.getDirectoryContents(path || '/'); if (requestTokens.current.get(path) !== token) return 'superseded'; const children = listedNodes(path, result); if (path) { @@ -207,18 +199,17 @@ export function FilesSidebar({ }); } } - }, [currentClient, onUnauthorized]); + }, [client, onUnauthorized]); // Reloads root plus expanded directories without loading placeholders, so // files created outside the sidebar (terminal, agents, editor) show up. const silentRefresh = useCallback(async () => { - const requestClient = currentClient(); - if (!requestClient) return; + if (client === null) return; const listings = new Map(); let unauthorized = false; await Promise.all(['', ...expandedRef.current].map(async (path) => { try { - listings.set(path, await requestClient.getDirectoryContents(path || '/')); + listings.set(path, await client.getDirectoryContents(path || '/')); } catch (listError) { if (davErrorStatus(listError) === 401) unauthorized = true; } @@ -230,10 +221,10 @@ export function FilesSidebar({ if (!listings.has('')) return; setData((current) => mergedTree(current, listings)); setRootState('ready'); - }, [currentClient, onUnauthorized]); + }, [client, onUnauthorized]); const openContextMenu = useCallback((event: ReactMouseEvent, directory: string) => { - if (!currentClient()) return; + if (client === null) return; event.preventDefault(); event.stopPropagation(); setCreateName(''); @@ -243,7 +234,7 @@ export function FilesSidebar({ y: Math.max(8, Math.min(event.clientY, window.innerHeight - 164)), directory, }); - }, [currentClient]); + }, [client]); const chooseCreateKind = (createKind: 'file' | 'folder') => { setCreateName(''); @@ -253,8 +244,7 @@ export function FilesSidebar({ const createEntry = async (event: FormEvent) => { event.preventDefault(); - const requestClient = currentClient(); - if (!requestClient || !contextMenu?.createKind || creating) return; + if (client === null || !contextMenu?.createKind || creating) return; const name = createName.trim(); if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\0')) { setCreateError('Enter a name without “/”.'); @@ -265,8 +255,8 @@ export function FilesSidebar({ setCreateError(null); try { if (contextMenu.createKind === 'folder') { - await requestClient.createDirectory(path); - } else if (!await requestClient.putFileContents(path, '', { overwrite: false })) { + await client.createDirectory(path); + } else if (!await client.putFileContents(path, '', { overwrite: false })) { setCreateError('A file or folder with that name already exists.'); return; } diff --git a/packages/webapp/src/MachineCatalogGrid.tsx b/packages/webapp/src/MachineCatalogGrid.tsx index c4fa772e..b3d17940 100644 --- a/packages/webapp/src/MachineCatalogGrid.tsx +++ b/packages/webapp/src/MachineCatalogGrid.tsx @@ -1,9 +1,5 @@ import type { MachineType } from '@blitzos/schema'; -export function machineTypeLabel(typeId: string): string { - return typeId; -} - function machineGroup(machine: MachineType): string { const location = machine.location || machine.id.split('@').at(-1) || 'unknown'; if (machine.providerId === 'microvm') return 'Local lab'; diff --git a/packages/webapp/src/ModalOverlay.tsx b/packages/webapp/src/ModalOverlay.tsx index 20186651..781eb287 100644 --- a/packages/webapp/src/ModalOverlay.tsx +++ b/packages/webapp/src/ModalOverlay.tsx @@ -16,12 +16,16 @@ const openOverlays: string[] = []; export function ModalOverlay({ onDismiss, dismissible = true, + className = 'webapp-modal-screen', children, }: { onDismiss: () => void; /** False while a request is in flight, so a stray Escape or backdrop click * cannot close a dialog whose save is still running. */ dismissible?: boolean; + /** The backdrop's visual class — Drive dialogs keep their `drive-scrim` + * stacking and wash while sharing the shell's behavior. */ + className?: string; children: ReactNode; }) { const id = useId(); @@ -49,7 +53,7 @@ export function ModalOverlay({ return (
{ if (event.target === event.currentTarget && dismissible) onDismiss(); diff --git a/packages/webapp/src/SettingsPage.tsx b/packages/webapp/src/SettingsPage.tsx index 1b84dfc3..c530ea60 100644 --- a/packages/webapp/src/SettingsPage.tsx +++ b/packages/webapp/src/SettingsPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import type { TenantMe } from './api-adapter'; +import type { TenantMe } from './protocol'; import type { ControlPlaneClient } from './api'; import { appliedTheme, chooseTheme, type ThemeChoice } from './theme'; import type { SettingsSection } from './sessions-page-state'; diff --git a/packages/webapp/src/ShareAccessDialog.tsx b/packages/webapp/src/ShareAccessDialog.tsx new file mode 100644 index 00000000..d51927ed --- /dev/null +++ b/packages/webapp/src/ShareAccessDialog.tsx @@ -0,0 +1,218 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import type { ControlPlaneClient, MemberView } from './api'; +import { DriveAvatar } from './files/DriveAvatar'; +import { ModalOverlay } from './ModalOverlay'; + +/** The grant row both resources share: workspace and folder grants are + * structurally identical on the wire, so one dialog renders either. */ +export type ShareGrantRow = { + id: string; + membershipId: string; + role: 'editor' | 'viewer'; + member: { name: string; email: string; avatarUrl: string | null }; +}; + +/** The Drive sharing popup, shared by workspaces and folders: add-people + * suggestions, general org access, and per-person roles. The resource side + * stays with the wrapper — it supplies the grants to draw and async actions + * that carry their own reload (and snackbar) chains; failures land in this + * dialog's error slot. */ +export function ShareAccessDialog({ + client, + name, + orgName, + manage = true, + owner, + viewerEmail, + orgRole, + grants, + note, + onLoad, + onSetOrgRole, + onGrant, + onChangeRole, + onRevoke, + onClose, +}: { + client: Pick; + /** The resource's display name, quoted in the heading. */ + name: string; + orgName: string; + /** False renders the read-only shape: no add-people, static role labels. */ + manage?: boolean; + owner: { name: string; avatarUrl: string | null; isViewer: boolean } | null; + /** Marks the viewer's own grant row with "(you)" when it matches. */ + viewerEmail?: string; + orgRole: 'editor' | 'viewer' | null; + grants: readonly ShareGrantRow[]; + /** The explanatory footnote under the people list. */ + note: ReactNode; + /** Loads the wrapper's grant state once on mount; errors land here. */ + onLoad?: () => Promise; + onSetOrgRole: (next: 'editor' | 'viewer' | null) => Promise; + onGrant: (member: MemberView) => Promise; + onChangeRole: (grant: ShareGrantRow, role: 'editor' | 'viewer') => Promise; + onRevoke: (grant: ShareGrantRow) => Promise; + onClose: () => void; +}) { + const [members, setMembers] = useState([]); + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + + const run = (action: Promise) => { + void action.catch((caught: Error) => setError(caught.message)); + }; + + useEffect(() => { + if (!manage) return; + void client.listMembers() + .then(({ members: loaded }) => setMembers(loaded)) + .catch((caught: Error) => setError(caught.message)); + }, [client, manage]); + + useEffect(() => { + if (onLoad === undefined) return; + void onLoad().catch((caught: Error) => setError(caught.message)); + }, [onLoad]); + + const granted = new Set(grants.map((grant) => grant.membershipId)); + const trimmed = query.trim().toLowerCase(); + const candidates = members + .filter((member) => member.status === 'active' && !granted.has(member.id)) + .filter((member) => trimmed === '' + || member.name.toLowerCase().includes(trimmed) + || member.email.toLowerCase().includes(trimmed)) + .slice(0, 5); + + return ( + +
+

Share “{name}”

+
+ {manage && ( +
+ setOpen(true)} + onChange={(event) => { setQuery(event.currentTarget.value); setOpen(true); }} + /> + {open && ( +
+ {candidates.length === 0 + ?
No one else to add
+ : candidates.map((member) => ( + + ))} +
+ )} +
+ )} +
+

General access

+
+
+ + + Everyone at {orgName} + {orgRole === null + ? 'No general access' + : orgRole === 'editor' ? 'Anyone in the org can edit' : 'Anyone in the org can view'} + + {manage ? ( + + ) : ( + + {orgRole === null ? 'Off' : orgRole === 'editor' ? 'Editor' : 'Viewer'} + + )} +
+
+
+
+

People with access

+
+ {owner && ( +
+ + + {owner.isViewer ? `${owner.name} (you)` : owner.name} + + Owner +
+ )} + {grants.map((grant) => { + const mine = viewerEmail !== undefined && grant.member.email === viewerEmail; + return ( +
+ + + {mine ? `${grant.member.name} (you)` : grant.member.name || grant.member.email} + {grant.member.email} + + {manage ? ( + + ) : ( + {grant.role === 'editor' ? 'Editor' : 'Viewer'} + )} +
+ ); + })} +
+
+ {error &&

{error}

} +

{note}

+
+
+ +
+
+
+ ); +} diff --git a/packages/webapp/src/ShareWorkspaceDialog.tsx b/packages/webapp/src/ShareWorkspaceDialog.tsx index 1be6bab1..e440675b 100644 --- a/packages/webapp/src/ShareWorkspaceDialog.tsx +++ b/packages/webapp/src/ShareWorkspaceDialog.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { ControlPlaneClient, MemberView, WorkspaceGrantView } from './api'; -import { DriveAvatar } from './files/DriveAvatar'; +import { useCallback, useState } from 'react'; +import type { ControlPlaneClient, WorkspaceGrantView } from './api'; +import { ShareAccessDialog } from './ShareAccessDialog'; -/** The Drive sharing popup, for workspaces: centered scrim dialog with - * add-people suggestions, general org access, and per-person roles. */ +/** Workspace sharing: the shared access dialog over workspace grants. The + * dialog owns its grant list — nothing upstream re-renders on a share — so + * every action reloads it here. */ export function ShareWorkspaceDialog({ client, workspaceId, @@ -23,165 +24,36 @@ export function ShareWorkspaceDialog({ viewerIsOwner?: boolean; onClose: () => void; }) { - const [members, setMembers] = useState([]); const [grants, setGrants] = useState([]); const [orgRole, setOrgRole] = useState<'editor' | 'viewer' | null>(orgShareRole); - const [query, setQuery] = useState(''); - const [open, setOpen] = useState(false); - const [error, setError] = useState(null); const load = useCallback(async () => { - try { - const [memberResponse, grantResponse] = await Promise.all([ - client.listMembers(), - client.listWorkspaceGrants(workspaceId), - ]); - setMembers(memberResponse.members); - setGrants(grantResponse.grants); - setError(null); - } catch (caught) { - setError(caught instanceof Error ? caught.message : 'Could not load sharing settings.'); - } + const { grants: loaded } = await client.listWorkspaceGrants(workspaceId); + setGrants(loaded); }, [client, workspaceId]); - useEffect(() => { void load(); }, [load]); - - const granted = useMemo(() => new Set(grants.map((grant) => grant.membershipId)), [grants]); - const trimmed = query.trim().toLowerCase(); - const candidates = members - .filter((member) => member.status === 'active' && !granted.has(member.id)) - .filter((member) => trimmed === '' - || member.name.toLowerCase().includes(trimmed) - || member.email.toLowerCase().includes(trimmed)) - .slice(0, 5); - - const run = (action: Promise) => { - void action - .then(() => load()) - .catch((caught: Error) => setError(caught.message)); - }; return ( -
{ if (event.target === event.currentTarget) onClose(); }}> -
-

Share “{workspaceName}”

-
-
- setOpen(true)} - onChange={(event) => { setQuery(event.currentTarget.value); setOpen(true); }} - /> - {open && ( -
- {candidates.length === 0 - ?
No one else to add
- : candidates.map((member) => ( - - ))} -
- )} -
-
-

General access

-
-
- - - Everyone at {orgName} - {orgRole === null - ? 'No general access' - : orgRole === 'editor' ? 'Anyone in the org can edit' : 'Anyone in the org can view'} - - -
-
-
-
-

People with access

-
- {owner && ( -
- - - {viewerIsOwner ? `${owner.name} (you)` : owner.name} - - Owner -
- )} - {grants.map((grant) => ( -
- - - {grant.member.name || grant.member.email} - {grant.member.email} - - -
- ))} -
-
- {error &&

{error}

} -

- Editors can write in terminal, chat, and files. Viewers get a - read-only terminal, files, and chat replay. -

-
-
- -
-
-
+ client.setWorkspaceOrgRole(workspaceId, next) + .then(() => setOrgRole(next))} + onGrant={(member) => client.createWorkspaceGrant(workspaceId, member.id, 'editor') + .then(load)} + onChangeRole={(grant, role) => client.createWorkspaceGrant(workspaceId, grant.membershipId, role) + .then(load)} + onRevoke={(grant) => client.revokeWorkspaceGrant(workspaceId, grant.id) + .then(load)} + onClose={onClose} + /> ); } diff --git a/packages/webapp/src/StandaloneWebApp.tsx b/packages/webapp/src/StandaloneWebApp.tsx index b567ae44..622329c2 100644 --- a/packages/webapp/src/StandaloneWebApp.tsx +++ b/packages/webapp/src/StandaloneWebApp.tsx @@ -1,20 +1,18 @@ import { useMemo } from "react"; import { createControlPlaneClient } from "./api.js"; import CloudApp from "./CloudApp.js"; -import { useStandalonePorts } from "./device-state.js"; import { standaloneResolver } from "./resolver.js"; import { InviteLandingPage } from "./components/InviteLandingPage.js"; export function StandaloneWebApp({ controlPlaneBaseUrl = "" }: { controlPlaneBaseUrl?: string }): React.JSX.Element { - const [ports] = useStandalonePorts(); const client = useMemo(() => createControlPlaneClient(controlPlaneBaseUrl), [controlPlaneBaseUrl]); const controlPlaneOrigin = useMemo( () => new URL(controlPlaneBaseUrl || window.location.origin, window.location.href).origin, [controlPlaneBaseUrl], ); const resolver = useMemo( - () => standaloneResolver(ports, controlPlaneOrigin), - [controlPlaneOrigin, ports], + () => standaloneResolver(controlPlaneOrigin), + [controlPlaneOrigin], ); const invite = window.location.pathname.match(/^\/invite\/([A-Za-z0-9_-]{43})\/?$/u); if (invite?.[1] !== undefined) return ; diff --git a/packages/webapp/src/TtydTerminal.tsx b/packages/webapp/src/TtydTerminal.tsx index 16255fea..ece33504 100644 --- a/packages/webapp/src/TtydTerminal.tsx +++ b/packages/webapp/src/TtydTerminal.tsx @@ -265,9 +265,8 @@ export function TtydTerminal({ attributes: true, attributeFilter: ['data-theme'], }); - // jsdom has no matchMedia; live scheme tracking is browser-only. - const schemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)') ?? null; - schemeQuery?.addEventListener('change', applyTheme); + const schemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); + schemeQuery.addEventListener('change', applyTheme); const fit = new FitAddon(); terminal.loadAddon(fit); terminal.open(host); @@ -401,7 +400,7 @@ export function TtydTerminal({ if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); if (resizeTimer !== null) window.clearTimeout(resizeTimer); themeObserver.disconnect(); - schemeQuery?.removeEventListener('change', applyTheme); + schemeQuery.removeEventListener('change', applyTheme); observer.disconnect(); input.dispose(); socket?.close(); diff --git a/packages/webapp/src/WebAppHeader.tsx b/packages/webapp/src/WebAppHeader.tsx index b316d1b8..6285947f 100644 --- a/packages/webapp/src/WebAppHeader.tsx +++ b/packages/webapp/src/WebAppHeader.tsx @@ -6,7 +6,6 @@ import type { TerminalAgent } from './protocol'; export type WebAppSessionType = TerminalAgent | 'terminal' | 'chat' | 'file' | 'preview' | 'panel'; export type SpawnSessionType = 'claude' | 'codex' | 'terminal' | 'chat'; -export const SESSION_TITLE_MAX_LENGTH = 64; export const SPAWN_SESSION_LABELS = { chat: 'Chat', @@ -27,8 +26,6 @@ export type WebAppTabModel = { label: string; agent: WebAppSessionType; pending: boolean; - customTitle?: string; - renameable?: boolean; dirty?: boolean; filePath?: string; title?: string; @@ -47,7 +44,6 @@ type WebAppHeaderProps = { onOpenDrawer?: () => void; onSelect: (sessionId: string) => void; onClose: (sessionId: string) => void; - onRename?: (sessionId: string, title: string | undefined) => void; onSpawn: (type: SpawnSessionType) => void; livePorts?: LivePort[]; previewLinks?: PreviewLink[]; @@ -113,7 +109,6 @@ export function WebAppHeader({ onOpenDrawer = () => undefined, onSelect, onClose, - onRename, onSpawn, livePorts = [], previewLinks = [], @@ -128,12 +123,9 @@ export function WebAppHeader({ draggingSessionId = null, }: WebAppHeaderProps) { const [menuOpen, setMenuOpen] = useState(false); - const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null); const newTabControl = useRef(null); const newSessionButton = useRef(null); const tabstrip = useRef(null); - const renameInput = useRef(null); - const renameFinished = useRef(false); useEffect(() => { if (!menuOpen) return; @@ -164,28 +156,6 @@ export function WebAppHeader({ active?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }, [activeSessionId, tabs.length]); - useEffect(() => { - renameInput.current?.focus(); - renameInput.current?.select(); - }, [renaming?.id]); - - const beginRename = (tab: WebAppTabModel) => { - if (!tab.renameable || !onRename) return; - renameFinished.current = false; - setRenaming({ id: tab.id, value: tab.customTitle ?? tab.label }); - }; - const finishRename = (tab: WebAppTabModel) => { - if (!renaming || renaming.id !== tab.id || renameFinished.current) return; - renameFinished.current = true; - setRenaming(null); - const title = renaming.value.trim() || undefined; - if (title !== (tab.customTitle ?? tab.label)) onRename?.(tab.id, title); - }; - const cancelRename = () => { - renameFinished.current = true; - setRenaming(null); - }; - const spawnSession = (agent: SpawnSessionType) => { setMenuOpen(false); onMenuOpenChange(false); @@ -233,62 +203,27 @@ export function WebAppHeader({ draggingSessionId === tab.id ? ' webapp-tab-cell--dragging' : ''}`} data-session-id={tab.id} > - {renaming?.id === tab.id ? ( -
- - setRenaming({ - id: tab.id, - value: event.target.value.slice(0, SESSION_TITLE_MAX_LENGTH), - })} - onBlur={() => finishRename(tab)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - finishRename(tab); - } else if (event.key === 'Escape') { - event.preventDefault(); - cancelRename(); - } - }} - /> -
- ) : ( - - )} - {active && renaming?.id !== tab.id && ( + + {active && (
-
+ ); } diff --git a/packages/webapp/src/files/DriveHome.tsx b/packages/webapp/src/files/DriveHome.tsx index 5b76ae07..d43b5ca4 100644 --- a/packages/webapp/src/files/DriveHome.tsx +++ b/packages/webapp/src/files/DriveHome.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import type { ControlPlaneClient } from '../api'; -import type { TenantMe } from '../api-adapter'; +import type { TenantMe } from '../protocol'; import { FilesDrive, type DrivePageRoute } from './FilesDrive'; import { SearchGlyph } from './DriveIcons'; diff --git a/packages/webapp/src/files/FilesDrive.tsx b/packages/webapp/src/files/FilesDrive.tsx index 4ed19577..1b4c453c 100644 --- a/packages/webapp/src/files/FilesDrive.tsx +++ b/packages/webapp/src/files/FilesDrive.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ControlPlaneClient } from '../api'; -import type { TenantMe } from '../api-adapter'; +import type { TenantMe } from '../protocol'; import type { WorkspaceView } from '@blitzos/schema'; import type { FolderObjectView, FolderView } from '../file-library-api'; import { drivePath, folderPagePath, type DriveScope } from '../sessions-page-state'; diff --git a/packages/webapp/src/files/ShareFolderDialog.tsx b/packages/webapp/src/files/ShareFolderDialog.tsx index 577115fb..b965d0c5 100644 --- a/packages/webapp/src/files/ShareFolderDialog.tsx +++ b/packages/webapp/src/files/ShareFolderDialog.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from 'react'; -import type { ControlPlaneClient, MemberView } from '../api'; +import type { ControlPlaneClient } from '../api'; import type { FolderView } from '../file-library-api'; -import { DriveAvatar } from './DriveAvatar'; +import { ShareAccessDialog } from '../ShareAccessDialog'; import { canManageFolder } from './drive-model'; +/** Folder sharing: the shared access dialog over folder grants. Grants and + * the org role live on the folder prop, so every action refreshes through + * `onChanged` (the Drive's own folder reload) and then reports a snack. */ export function ShareFolderDialog({ client, folder, @@ -22,185 +24,45 @@ export function ShareFolderDialog({ onSnack: (message: React.ReactNode) => void; }) { const manage = canManageFolder(folder.role); - const [members, setMembers] = useState([]); - const [query, setQuery] = useState(''); - const [open, setOpen] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!manage) return; - void client.listMembers() - .then(({ members: loaded }) => setMembers(loaded)) - .catch((caught: Error) => setError(caught.message)); - }, [client, manage]); - - const granted = new Set(folder.grants?.map((grant) => grant.membershipId) ?? []); - const trimmed = query.trim().toLowerCase(); - const candidates = members - .filter((member) => member.status === 'active' && !granted.has(member.id)) - .filter((member) => trimmed === '' - || member.name.toLowerCase().includes(trimmed) - || member.email.toLowerCase().includes(trimmed)) - .slice(0, 5); - - const run = (action: Promise, done?: React.ReactNode) => { - void action - .then(() => onChanged()) - .then(() => { - if (done !== undefined) onSnack(done); - }) - .catch((caught: Error) => setError(caught.message)); - }; return ( -
{ if (event.target === event.currentTarget) onClose(); }}> -
-

Share “{folder.name}”

-
- {manage && ( -
- setOpen(true)} - onChange={(event) => { setQuery(event.currentTarget.value); setOpen(true); }} - /> - {open && ( -
- {candidates.length === 0 - ?
No one else to add
- : candidates.map((member) => ( - - ))} -
- )} -
- )} -
-

General access

-
-
- - - Everyone at {orgName} - {folder.orgRole === null - ? 'No general access' - : folder.orgRole === 'editor' ? 'Anyone in the org can edit' : 'Anyone in the org can view'} - - {manage ? ( - - ) : ( - - {folder.orgRole === null ? 'Off' : folder.orgRole === 'editor' ? 'Editor' : 'Viewer'} - - )} -
-
-
-
-

People with access

-
-
- - - {folder.role === 'owner' ? `${folder.owner.name} (you)` : folder.owner.name} - - Owner -
- {folder.grants?.map((grant) => { - const mine = grant.member.email === viewerEmail; - return ( -
- - - {mine ? `${grant.member.name} (you)` : grant.member.name || grant.member.email} - {grant.member.email} - - {manage ? ( - - ) : ( - {grant.role === 'editor' ? 'Editor' : 'Viewer'} - )} -
- ); - })} -
-
- {error &&

{error}

} -

- {manage - ? 'Everyone here can open every file in this folder. Removing access takes effect immediately.' - : `You have ${folder.role === 'editor' ? 'editor' : 'viewer'} access. Only ${folder.owner.name} or an organization admin can change access.`} -

-
-
- -
-
-
+ client.setFolderOrgRole(folder.id, next) + .then(() => onChanged()) + .then(() => onSnack(next === null + ? General access removed — only invited people keep access to {folder.name} + : Everyone at {orgName} can now {next === 'editor' ? 'edit' : 'view'} {folder.name}))} + onGrant={(member) => client.createFolderGrant(folder.id, member.id, 'editor') + .then(() => onChanged()) + .then(() => onSnack( + {member.name || member.email} can now edit {folder.name}, + ))} + onChangeRole={(grant, role) => client.createFolderGrant(folder.id, grant.membershipId, role) + .then(() => onChanged()) + .then(() => onSnack( + {grant.member.name || grant.member.email} is now a {role} on {folder.name}, + ))} + onRevoke={(grant) => client.revokeFolderGrant(folder.id, grant.id) + .then(() => onChanged()) + .then(() => onSnack( + Access revoked immediately · {grant.member.name || grant.member.email}’s next request — including the next chunk of an upload in flight — is refused, + ))} + onClose={onClose} + /> ); } diff --git a/packages/webapp/src/files/ShareToDriveDialog.tsx b/packages/webapp/src/files/ShareToDriveDialog.tsx index bc3d0d0d..5098b715 100644 --- a/packages/webapp/src/files/ShareToDriveDialog.tsx +++ b/packages/webapp/src/files/ShareToDriveDialog.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import type { ControlPlaneClient } from '../api'; +import { ModalOverlay } from '../ModalOverlay'; import { normalizeFolderName } from './drive-model'; /** Confirms publishing one live workspace directory to Drive. Reached from @@ -41,11 +42,7 @@ export function ShareToDriveDialog({ }; return ( -
{ if (event.target === event.currentTarget) onCancel(); }} - > +

Share “{path}” to Drive

@@ -68,6 +65,6 @@ export function ShareToDriveDialog({
-
+ ); } diff --git a/packages/webapp/src/files/drive-chrome.tsx b/packages/webapp/src/files/drive-chrome.tsx index c82c43f4..2ff2469c 100644 --- a/packages/webapp/src/files/drive-chrome.tsx +++ b/packages/webapp/src/files/drive-chrome.tsx @@ -2,6 +2,7 @@ import { CheckGlyph, CloseGlyph, DownloadGlyph, KebabGlyph, LinkGlyph } from './ import { DocDuoIcon } from '../files-icons'; import { DriveAvatar } from './DriveAvatar'; import { formatBytes, formatWhen, type DriveFileEntry } from './drive-model'; +import { ModalOverlay } from '../ModalOverlay'; /** Presentational chrome shared by the Drive surface: the context menu, the * small confirm/name dialogs, the upload progress card, and the snackbar. @@ -90,7 +91,7 @@ export function FolderNameDialog({ onSubmit: () => void; }) { return ( -
{ if (event.target === event.currentTarget) onCancel(); }}> +

{title}

{ @@ -117,7 +118,7 @@ export function FolderNameDialog({
- + ); } @@ -133,7 +134,7 @@ export function DriveDeleteDialog({ onConfirm: () => void; }) { return ( -
{ if (event.target === event.currentTarget) onCancel(); }}> +

Delete “{name}”

@@ -146,7 +147,7 @@ export function DriveDeleteDialog({
-
+ ); } diff --git a/packages/webapp/src/index.ts b/packages/webapp/src/index.ts index adcb9a91..d323b0f5 100644 --- a/packages/webapp/src/index.ts +++ b/packages/webapp/src/index.ts @@ -1,12 +1,8 @@ export { default as CloudApp, type CloudAppProps } from "./CloudApp.js"; export { StandaloneWebApp } from "./StandaloneWebApp.js"; -export { ApiAdapter } from "./api-adapter.js"; export { createControlPlaneClient, type ControlPlaneClient } from "./api.js"; export { - DEFAULT_PORTS, - endpointTarget, standaloneResolver, type BoxEndpoints, type EndpointResolver, - type StandalonePorts, } from "./resolver.js"; diff --git a/packages/webapp/src/preview.ts b/packages/webapp/src/preview.ts index e87d9f1f..5501776f 100644 --- a/packages/webapp/src/preview.ts +++ b/packages/webapp/src/preview.ts @@ -3,7 +3,6 @@ import type { WorkspaceTabs } from './storage'; import { asJsonObject, hasObjectType, - isDefined, isNumber, isString, type JsonValue, @@ -209,11 +208,10 @@ export function previewFocusEndpointUrl(filesBase: string): string { export async function fetchWorkspacePorts( filesBase: string, - fetcher: typeof fetch = fetch, signal?: AbortSignal, ): Promise> { try { - const response = await fetcher(portsEndpointUrl(filesBase), { + const response = await fetch(portsEndpointUrl(filesBase), { credentials: 'include', signal, }); @@ -227,11 +225,10 @@ export async function fetchWorkspacePorts( export async function fetchWorkspacePreviews( filesBase: string, - fetcher: typeof fetch = fetch, signal?: AbortSignal, ): Promise { try { - const response = await fetcher(previewsEndpointUrl(filesBase), { + const response = await fetch(previewsEndpointUrl(filesBase), { credentials: 'include', signal, }); @@ -254,11 +251,10 @@ export type PreviewFocusResult = export async function fetchWorkspacePreviewFocus( filesBase: string, - fetcher: typeof fetch = fetch, signal?: AbortSignal, ): Promise { try { - const response = await fetcher(previewFocusEndpointUrl(filesBase), { + const response = await fetch(previewFocusEndpointUrl(filesBase), { credentials: 'include', signal, }); @@ -371,10 +367,7 @@ export function previewUrl( export function previewPortFromLocalUrl(href: string): number | null { try { - const target = new URL( - href, - !isDefined(globalThis.window) ? 'http://localhost/' : window.location.href, - ); + const target = new URL(href, window.location.href); if (target.protocol !== 'http:' && target.protocol !== 'https:') return null; const localhost = target.hostname === 'localhost' || target.hostname === '127.0.0.1'; if (!localhost || !target.port) return null; diff --git a/packages/webapp/src/protocol.ts b/packages/webapp/src/protocol.ts index d8457822..462cdbfd 100644 --- a/packages/webapp/src/protocol.ts +++ b/packages/webapp/src/protocol.ts @@ -1,39 +1,7 @@ +import type { MeResponse } from './api.js'; + export type Agent = 'claude' | 'codex'; export type TerminalAgent = Agent | 'opencode' | 'pi' | 'kimi' | 'prime'; -export type RestWorkspaceStatus = - | 'creating' - | 'provisioning' - | 'running' - | 'stopped' - | 'parking' - | 'parked' - | 'resuming' - | 'destroying' - | 'destroyed' - | 'error'; - -export type WorkspaceRecord = { - id: string; - ownerMembershipId: string; - canControl: boolean; - shared?: boolean; - owner?: { - name: string; - avatarUrl: string | null; - }; - accessRole?: WorkspaceRole | null; - orgShareRole?: 'editor' | 'viewer' | null; - machineType?: string | null; - name: string; - status: RestWorkspaceStatus; - errorDetail?: string | null; - retryAction: RetryAction; - createdAt: number; - updatedAt: number; - /** Connection names the workspace ceiling enables; the drawer's - * connections panel draws a status row per name. */ - connections?: string[]; -}; export type IdentityRecord = { id: string; @@ -54,4 +22,48 @@ export type OrgRecord = { name: string; vmLimit: number; }; -import type { RetryAction, WorkspaceRole } from '@blitzos/schema'; + +export type TenantMe = { + identity: IdentityRecord; + membership: MembershipRecord; + org: OrgRecord; + organizations: Array<{ membership: MembershipRecord; org: OrgRecord }>; +}; + +export type Me = { + identity: IdentityRecord; + membership: MembershipRecord | null; + org: OrgRecord | null; + organizations: Array<{ membership: MembershipRecord; org: OrgRecord }>; +}; + +export function isTenantMe(viewer: Me): viewer is TenantMe { + return viewer.membership !== null && viewer.org !== null; +} + +export function meFromWire(me: MeResponse): Me { + return { + identity: { + id: me.user.id, + email: me.user.email, + name: me.user.name, + avatarUrl: me.user.avatarUrl, + platformOperator: me.user.platformOperator, + }, + membership: me.membership === null + ? null + : { id: me.membership.id, role: me.membership.role }, + org: me.org === null + ? null + : { id: me.org.id, slug: me.org.slug, name: me.org.name, vmLimit: me.org.vmLimit }, + organizations: me.organizations.map((item) => ({ + membership: { id: item.membership.id, role: item.membership.role }, + org: { + id: item.org.id, + slug: item.org.slug, + name: item.org.name, + vmLimit: item.org.vmLimit, + }, + })), + }; +} diff --git a/packages/webapp/src/resolver.ts b/packages/webapp/src/resolver.ts index 9ca5d461..50e99cb8 100644 --- a/packages/webapp/src/resolver.ts +++ b/packages/webapp/src/resolver.ts @@ -11,20 +11,12 @@ export interface EndpointResolver { previewUrl(workspace: WorkspaceView, port: number): string; } -export interface StandalonePorts { - acp: number; - files: number; -} - -export const DEFAULT_PORTS: StandalonePorts = { acp: 7444, files: 7445 }; - /** The guest's dufs serves the workspace tree under this path and emits DAV * hrefs rooted at it — without the control-plane proxy prefix. WebDAV clients * must parse responses against this base, not the full proxied URL, or every * listing keeps its own collection as a phantom child. */ export const FILES_DAV_ROOT = "/workspace"; export function standaloneResolver( - _ports: StandalonePorts, controlPlaneOrigin = globalThis.location?.origin ?? "", ): EndpointResolver { const cpOrigin = controlPlaneOrigin.replace(/\/+$/u, ""); @@ -44,13 +36,3 @@ export function standaloneResolver( previewUrl: (workspace, port) => `${cpOrigin}/workspaces/${encodeURIComponent(workspace.id)}/webapp/7445/preview/${port}/`, }; } - -export function endpointTarget(url: string): string { - const parsed = new URL(url); - const defaultPort = parsed.protocol === "https:" || parsed.protocol === "wss:" ? "443" : "80"; - return `${parsed.hostname}:${parsed.port || defaultPort}`; -} - -export function validPort(value: number): boolean { - return Number.isInteger(value) && value >= 1 && value <= 65_535; -} diff --git a/packages/webapp/src/settings/MembersPanel.tsx b/packages/webapp/src/settings/MembersPanel.tsx index 510d1b55..554bea91 100644 --- a/packages/webapp/src/settings/MembersPanel.tsx +++ b/packages/webapp/src/settings/MembersPanel.tsx @@ -2,11 +2,10 @@ import { useCallback, useEffect, useState } from 'react'; import type { ControlPlaneClient, MemberView } from '../api'; import { DriveAvatar } from '../files/DriveAvatar'; +/** Membership management only: roles, disable/enable. Adding people is invite + * creation and lives in InvitesPanel, one settings tab over. */ export function MembersPanel({ client, admin }: { client: ControlPlaneClient; admin: boolean }) { const [members, setMembers] = useState([]); - const [email, setEmail] = useState(''); - const [role, setRole] = useState<'admin' | 'member'>('member'); - const [oneTimeLink, setOneTimeLink] = useState(null); const [error, setError] = useState(null); const load = useCallback(async () => { try { @@ -20,35 +19,7 @@ export function MembersPanel({ client, admin }: { client: ControlPlaneClient; ad return (
-

Organization

Members

People who can work in this organization.
- {admin && ( -
{ - event.preventDefault(); - void client.createInvite({ email, role }).then((created) => { - setOneTimeLink(`${window.location.origin}/invite/${created.code}`); - setEmail(''); - }).catch((caught: Error) => setError(caught.message)); - }}> - - - -
- )} - {oneTimeLink && ( -
- Copy this link now — it is shown once. -
- event.currentTarget.select()} /> - -
-
- )} +

Organization

Members

People who can work in this organization. Add people from the Invites tab.
{error &&

{error}

}
{members.map((member) => ( diff --git a/packages/webapp/src/storage.ts b/packages/webapp/src/storage.ts index 1dcec59a..b4f2067f 100644 --- a/packages/webapp/src/storage.ts +++ b/packages/webapp/src/storage.ts @@ -8,7 +8,6 @@ import { isString, } from './type-guards'; -const CHAT_AUTH_DISMISSALS_KEY_PREFIX = 'blitz-chat-auth-dismissals-v1:'; type OptionalJsonValue = JsonValue | undefined; export type StorageNamespace = { @@ -16,8 +15,6 @@ export type StorageNamespace = { membershipId: string; }; -type StorageBackend = Pick; - export type WorkspacePreference = { title?: string; agentDefault?: Agent; @@ -124,17 +121,6 @@ export function createStorageNamespace(orgId: string, membershipId: string): Sto return { orgId, membershipId }; } -function namespacePrefix(namespace: StorageNamespace): string { - return `${namespace.orgId}:${namespace.membershipId}:`; -} - -function chatAuthDismissalsStorageKey( - namespace: StorageNamespace, - workspaceId: string, -): string { - return `${namespacePrefix(namespace)}${CHAT_AUTH_DISMISSALS_KEY_PREFIX}${workspaceId}`; -} - function isSafeRelativePath(value: Value): value is Value & string { return isString(value) && value.length > 0 @@ -142,17 +128,6 @@ function isSafeRelativePath(value: Value): value is Value & string { && !value.split('/').includes('..'); } -export function storedWorkspacePreference( - title: string, - serverName: string, - agentDefault: Agent, -): WorkspacePreference { - const preference: WorkspacePreference = {}; - if (title !== serverName) preference.title = title; - preference.agentDefault = agentDefault; - return preference; -} - export function defaultWorkspaceTabs(): WorkspaceTabs { // A fresh workspace opens straight into Claude; Files rides along in the // side pane, the way the drawer used to default open. Tab 3 is the @@ -577,11 +552,3 @@ export function decodeWorkspaceWebAppStateResponse( ): WebAppStateResponse { return decodeStateResponse(json, parseWorkspaceDoc); } - -export function removeDismissedChatAuthProviders( - namespace: StorageNamespace, - workspaceId: string, - storage: StorageBackend = localStorage, -): void { - storage.removeItem(chatAuthDismissalsStorageKey(namespace, workspaceId)); -} diff --git a/packages/webapp/src/terminal-touch-controller.ts b/packages/webapp/src/terminal-touch-controller.ts index c839ac52..28ba2099 100644 --- a/packages/webapp/src/terminal-touch-controller.ts +++ b/packages/webapp/src/terminal-touch-controller.ts @@ -171,17 +171,7 @@ export function bindTerminalTouch({ setShowPasteHint(true); pasteHintTimeout = window.setTimeout(() => setShowPasteHint(false), 5_000); }; - const clipboardPermissionOutcome = async () => { - if (!navigator.permissions?.query) return; - try { - // SAFETY: Chromium implements clipboard-read although older lib.dom PermissionName unions may omit it. - await navigator.permissions.query({ name: 'clipboard-read' as PermissionName }); - } catch { - // Clipboard reads still get their own browser permission check. - } - }; const pasteFromClipboard = async () => { - await clipboardPermissionOutcome(); try { const text = await navigator.clipboard.readText(); if (clipboardReadDecision(disposed, text, false) === 'paste') { diff --git a/packages/webapp/src/type-guards.ts b/packages/webapp/src/type-guards.ts index e0c4d299..d4aae000 100644 --- a/packages/webapp/src/type-guards.ts +++ b/packages/webapp/src/type-guards.ts @@ -20,9 +20,6 @@ export function asJsonObject(value: Value): JsonObject | null { return value as JsonObject; } -export function isDefined(value: Value): value is Exclude { - return typeof value !== 'undefined'; -} export type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; export interface JsonObject { diff --git a/packages/webapp/src/use-workspace-lifecycle.ts b/packages/webapp/src/use-workspace-lifecycle.ts index 2c96ae3e..0603be9e 100644 --- a/packages/webapp/src/use-workspace-lifecycle.ts +++ b/packages/webapp/src/use-workspace-lifecycle.ts @@ -1,6 +1,6 @@ import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; -import { ApiAdapter, ApiError, isTenantMe } from './api-adapter.js'; -import { caughtErrorMessage } from './error-message.js'; +import type { ControlPlaneClient } from './api.js'; +import { caughtErrorMessage, isUnauthorized } from './error-message.js'; import { parseAppRoute } from './sessions-page-state.js'; import { createStorageNamespace, @@ -9,22 +9,20 @@ import { type StorageNamespace, type WorkspaceWebAppStateV1, } from './storage.js'; -import type { IdentityRecord } from './protocol.js'; -import type { EndpointResolver } from './resolver.js'; -import type { WorkspaceAction } from './workspace-store.js'; -import { - rememberWorkspaceEndpoints, - type WorkspaceEndpoints, -} from './workspace-endpoints.js'; +import { isTenantMe, meFromWire, type IdentityRecord } from './protocol.js'; +import type { BoxEndpoints, EndpointResolver } from './resolver.js'; +import { isVisibleWorkspace, type WorkspaceAction } from './workspace-store.js'; +import { rememberWorkspaceEndpoints } from './workspace-endpoints.js'; type StateSetter = Dispatch>; type WorkspaceBootstrapOptions = { - api: ApiAdapter; + client: ControlPlaneClient; bootstrapVersion: number; signedOut: boolean; + onUnauthorized: () => void; resolver: EndpointResolver; - workspaceEndpoints: MutableRefObject>; + workspaceEndpoints: MutableRefObject>; activeWorkspaceIdRef: MutableRefObject; setActiveWorkspaceId: StateSetter; setStorageNamespace: StateSetter; @@ -35,9 +33,10 @@ type WorkspaceBootstrapOptions = { }; export function useWorkspaceBootstrap({ - api, + client, bootstrapVersion, signedOut, + onUnauthorized, resolver, workspaceEndpoints, activeWorkspaceIdRef, @@ -53,8 +52,9 @@ export function useWorkspaceBootstrap({ let mounted = true; setLoaded(false); setError(null); - void api.getMe() - .then(async (me) => { + void client.me() + .then(async (wireMe) => { + const me = meFromWire(wireMe); if (!isTenantMe(me)) { if (!mounted) return; setIdentityOnly(me.identity); @@ -64,17 +64,18 @@ export function useWorkspaceBootstrap({ } setIdentityOnly(null); const namespace = createStorageNamespace(me.org.id, me.membership.id); - const [globalState, records] = await Promise.all([ - api.getGlobalWebAppState(), - api.listWorkspaces(), + const [globalState, poll] = await Promise.all([ + client.getGlobalWebAppState(), + client.poll(), ]); + const records = poll.workspaces.filter(isVisibleWorkspace); const workspaceStates = new Map(); await Promise.all(records.map(async ({ id }) => { // A preferences doc is never worth failing the boot over: missing, // stale, or malformed state falls back to defaults for that // workspace while the rest of the app loads normally. try { - const state = await api.getWorkspaceWebAppState(id); + const state = await client.getWorkspaceWebAppState(id); if (state.doc !== null) workspaceStates.set(id, state.doc); } catch { // Defaults apply. @@ -96,7 +97,11 @@ export function useWorkspaceBootstrap({ setLoaded(true); }) .catch((cause: unknown) => { - if (!mounted || (cause instanceof ApiError && cause.status === 401)) return; + if (!mounted) return; + if (isUnauthorized(cause)) { + onUnauthorized(); + return; + } setLoaded(true); setError(caughtErrorMessage(cause, 'The control plane request failed.')); }); @@ -105,9 +110,10 @@ export function useWorkspaceBootstrap({ }; }, [ activeWorkspaceIdRef, - api, bootstrapVersion, + client, dispatch, + onUnauthorized, resolver, setActiveWorkspaceId, setError, @@ -132,30 +138,19 @@ export function useWorkspacePolling({ transitioningWorkspaceCount, refreshWorkspaceRecords, }: WorkspacePollingOptions): void { + // One poller, two cadences: 15s at rest, 5s (plus an immediate refresh) + // while any workspace is transitioning, so lifecycle changes land fast + // without a second timer or a doubled focus listener. useEffect(() => { if (!loaded) return; + const transitioning = !signedOut && transitioningWorkspaceCount > 0; const refresh = () => { void refreshWorkspaceRecords(); }; - const interval = window.setInterval(refresh, 15_000); - window.addEventListener('focus', refresh); - return () => { - window.clearInterval(interval); - window.removeEventListener('focus', refresh); - }; - }, [loaded, refreshWorkspaceRecords, signedOut]); - - useEffect(() => { - if (!loaded || signedOut || transitioningWorkspaceCount === 0) return; - let mounted = true; - const refresh = async () => { - if (mounted) await refreshWorkspaceRecords(); - }; - void refresh(); - const interval = window.setInterval(() => { void refresh(); }, 5_000); + if (transitioning) refresh(); + const interval = window.setInterval(refresh, transitioning ? 5_000 : 15_000); window.addEventListener('focus', refresh); return () => { - mounted = false; window.clearInterval(interval); window.removeEventListener('focus', refresh); }; diff --git a/packages/webapp/src/use-workspace-persistence.ts b/packages/webapp/src/use-workspace-persistence.ts index 0d0a899d..9c0ff752 100644 --- a/packages/webapp/src/use-workspace-persistence.ts +++ b/packages/webapp/src/use-workspace-persistence.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { ApiAdapter } from './api-adapter.js'; +import type { ControlPlaneClient } from './api.js'; import { defaultWorkspaceFiles, defaultWorkspaceTabs, @@ -26,7 +26,7 @@ interface WorkspacePersistenceMetadata { } export function useWorkspacePersistence( - api: ApiAdapter, + client: Pick, enabled: boolean, activeWorkspaceId: string, metadata: WorkspacePersistenceMetadata | null, @@ -67,7 +67,7 @@ export function useWorkspacePersistence( value: defaultWorkspaceTabs(), loaded: false, }); - void api.getWorkspaceWebAppState(activeWorkspaceId) + void client.getWorkspaceWebAppState(activeWorkspaceId) .then((response) => { if (!active) return; const state = response.doc ?? defaultWorkspaceWebAppState(); @@ -94,7 +94,7 @@ export function useWorkspacePersistence( return () => { active = false; }; - }, [activeWorkspaceId, api, enabled, onError]); + }, [activeWorkspaceId, client, enabled, onError]); useEffect(() => { if ( @@ -120,7 +120,7 @@ export function useWorkspacePersistence( } const timer = window.setTimeout(() => { syncedDoc.current = { workspaceId: activeWorkspaceId, json }; - void api.putWorkspaceWebAppState(activeWorkspaceId, doc).catch((cause: Error) => { + void client.putWorkspaceWebAppState(activeWorkspaceId, doc).catch((cause: Error) => { // The attempt stays recorded so a rejected doc is not re-sent on every // poll tick; the next real edit produces different JSON and retries. onError(cause); @@ -129,7 +129,7 @@ export function useWorkspacePersistence( return () => window.clearTimeout(timer); }, [ activeWorkspaceId, - api, + client, enabled, metadata, onError, diff --git a/packages/webapp/src/use-workspace-preview-focus.ts b/packages/webapp/src/use-workspace-preview-focus.ts deleted file mode 100644 index f04d45e9..00000000 --- a/packages/webapp/src/use-workspace-preview-focus.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { - fetchWorkspacePreviewFocus, - PORTS_POLL_INTERVAL_MS, - type PreviewFocus, - type PreviewFocusResult, -} from './preview'; - -/** Polls the box's `/preview-focus` marker on the ports cadence, while the tab - * is visible, and calls `onFocus` at most once per focus the in-box agent - * raises with `blitz preview open` (newest `requestedAt` wins). - * - * Entering a workspace adopts whatever focus the box already reports as the - * consumed baseline and never opens it, so a workspace switch — or a return — - * cannot replay an old focus; only a strictly-newer focus that arrives while - * the workspace is on screen opens. Only a poll that actually reached the box - * may set that baseline. The baseline is ephemeral (a ref), never persisted to - * webApp-state. */ -export function useWorkspacePreviewFocus( - enabled: boolean, - workspaceId: string, - filesBase: string | null, - onFocus: (focus: PreviewFocus) => void, -): void { - const onFocusRef = useRef(onFocus); - onFocusRef.current = onFocus; - const consumedRef = useRef<{ workspaceId: string; requestedAt: number | null }>({ - workspaceId: '', - requestedAt: null, - }); - - useEffect(() => { - if (!enabled || filesBase === null || workspaceId === '') return; - let disposed = false; - let request: AbortController | null = null; - const consume = (result: PreviewFocusResult) => { - // A poll that failed says nothing about the box's focus. Adopting its - // empty answer as the baseline would make the next successful poll look - // like a brand-new focus and re-open a marker from a previous visit. - if (!result.ok) return; - const focus = result.focus; - if (consumedRef.current.workspaceId !== workspaceId) { - // First successful observation after entering this workspace: adopt the - // box's current focus as already-consumed and do not auto-open it. - consumedRef.current = { - workspaceId, - requestedAt: focus === null ? null : focus.requestedAt, - }; - return; - } - if (focus === null) return; - const last = consumedRef.current.requestedAt; - if (last !== null && focus.requestedAt <= last) return; - consumedRef.current = { workspaceId, requestedAt: focus.requestedAt }; - onFocusRef.current(focus); - }; - const poll = async () => { - if (request !== null || document.visibilityState !== 'visible') return; - request = new AbortController(); - const current = request; - const result = await fetchWorkspacePreviewFocus(filesBase, fetch, current.signal); - if (!disposed && request === current && !current.signal.aborted) { - consume(result); - } - if (request === current) request = null; - }; - void poll(); - const timer = window.setInterval(() => { void poll(); }, PORTS_POLL_INTERVAL_MS); - return () => { - disposed = true; - request?.abort(); - window.clearInterval(timer); - }; - }, [enabled, filesBase, workspaceId]); -} diff --git a/packages/webapp/src/use-workspace-preview-sources.ts b/packages/webapp/src/use-workspace-preview-sources.ts index 42e31df6..29f7f5ed 100644 --- a/packages/webapp/src/use-workspace-preview-sources.ts +++ b/packages/webapp/src/use-workspace-preview-sources.ts @@ -1,11 +1,14 @@ -import { useEffect, useReducer } from 'react'; +import { useEffect, useReducer, useRef } from 'react'; import { fetchWorkspacePorts, + fetchWorkspacePreviewFocus, fetchWorkspacePreviews, initialPortsState, PORTS_POLL_INTERVAL_MS, portsReducer, type LivePort, + type PreviewFocus, + type PreviewFocusResult, type PreviewLink, } from './preview'; @@ -14,12 +17,31 @@ export type WorkspacePreviewSources = { previewLinks: PreviewLink[]; }; +/** One gateway poller for everything preview-shaped: live ports, public + * preview links, and the `/preview-focus` marker, fetched together on the + * same cadence while the tab is visible. + * + * `onPreviewFocus` fires at most once per focus the in-box agent raises with + * `blitz preview open` (newest `requestedAt` wins). Entering a workspace + * adopts whatever focus the box already reports as the consumed baseline and + * never opens it, so a workspace switch — or a return — cannot replay an old + * focus; only a strictly-newer focus that arrives while the workspace is on + * screen opens. Only a poll that actually reached the box may set that + * baseline. The baseline is ephemeral (a ref), never persisted to + * webApp-state. */ export function useWorkspacePreviewSources( enabled: boolean, workspaceId: string, filesBase: string | null, + onPreviewFocus: (focus: PreviewFocus) => void, ): WorkspacePreviewSources { const [state, dispatch] = useReducer(portsReducer, initialPortsState); + const onFocusRef = useRef(onPreviewFocus); + onFocusRef.current = onPreviewFocus; + const consumedRef = useRef<{ workspaceId: string; requestedAt: number | null }>({ + workspaceId: '', + requestedAt: null, + }); useEffect(() => { dispatch({ type: 'reset', workspaceId }); @@ -29,16 +51,39 @@ export function useWorkspacePreviewSources( if (!enabled || filesBase === null || workspaceId === '') return; let disposed = false; let request: AbortController | null = null; + const consumeFocus = (result: PreviewFocusResult) => { + // A poll that failed says nothing about the box's focus. Adopting its + // empty answer as the baseline would make the next successful poll look + // like a brand-new focus and re-open a marker from a previous visit. + if (!result.ok) return; + const focus = result.focus; + if (consumedRef.current.workspaceId !== workspaceId) { + // First successful observation after entering this workspace: adopt the + // box's current focus as already-consumed and do not auto-open it. + consumedRef.current = { + workspaceId, + requestedAt: focus === null ? null : focus.requestedAt, + }; + return; + } + if (focus === null) return; + const last = consumedRef.current.requestedAt; + if (last !== null && focus.requestedAt <= last) return; + consumedRef.current = { workspaceId, requestedAt: focus.requestedAt }; + onFocusRef.current(focus); + }; const poll = async () => { if (request !== null || document.visibilityState !== 'visible') return; request = new AbortController(); const current = request; - const [ports, previews] = await Promise.all([ - fetchWorkspacePorts(filesBase, fetch, current.signal), - fetchWorkspacePreviews(filesBase, fetch, current.signal), + const [ports, previews, focusResult] = await Promise.all([ + fetchWorkspacePorts(filesBase, current.signal), + fetchWorkspacePreviews(filesBase, current.signal), + fetchWorkspacePreviewFocus(filesBase, current.signal), ]); if (!disposed && request === current && !current.signal.aborted) { dispatch({ type: 'received', workspaceId, ports, previews, now: Date.now() }); + consumeFocus(focusResult); } if (request === current) request = null; }; diff --git a/packages/webapp/src/webapp-workspace.css b/packages/webapp/src/webapp-workspace.css index deebd830..6409d56f 100644 --- a/packages/webapp/src/webapp-workspace.css +++ b/packages/webapp/src/webapp-workspace.css @@ -89,18 +89,6 @@ max-width: 168px; } -.webapp-tab-rename { - width: 140px; - min-width: 48px; - padding: 1px 4px; - border: 1px solid var(--accent); - border-radius: 2px; - outline: 0; - background: var(--paper); - color: var(--ink); - font: inherit; -} - .webapp-tab-dirty { flex: none; color: var(--accent); diff --git a/packages/webapp/src/workspace-endpoints.ts b/packages/webapp/src/workspace-endpoints.ts index 2f678a7e..1904fc0c 100644 --- a/packages/webapp/src/workspace-endpoints.ts +++ b/packages/webapp/src/workspace-endpoints.ts @@ -1,11 +1,6 @@ -import type { V2WorkspaceRecord } from './api-adapter.js'; +import type { WorkspaceView } from '@blitzos/schema'; import type { BoxEndpoints, EndpointResolver } from './resolver.js'; -export type WorkspaceEndpoints = BoxEndpoints & { - label: string; - wire: V2WorkspaceRecord['wire']; -}; - export function terminalWebSocketUrl(value: string): string { const target = new URL(value, window.location.href); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -16,8 +11,8 @@ export function terminalWebSocketUrl(value: string): string { } export function rememberWorkspaceEndpoints( - entries: Map, - records: V2WorkspaceRecord[], + entries: Map, + records: WorkspaceView[], resolver: EndpointResolver, authoritative = false, ): void { @@ -28,12 +23,10 @@ export function rememberWorkspaceEndpoints( } } for (const record of records) { - const endpoints = resolver.resolve(record.wire); + const endpoints = resolver.resolve(record); entries.set(record.id, { ...endpoints, terminalUrl: terminalWebSocketUrl(endpoints.terminalUrl), - label: record.ingressLabel, - wire: record.wire, }); } } diff --git a/packages/webapp/src/workspace-store.ts b/packages/webapp/src/workspace-store.ts index 3cb736dc..5b22f11e 100644 --- a/packages/webapp/src/workspace-store.ts +++ b/packages/webapp/src/workspace-store.ts @@ -1,27 +1,24 @@ -import type { TenantMe } from './api-adapter'; -import type { RetryAction } from '@blitzos/schema'; -import type { - Agent, - RestWorkspaceStatus, - WorkspaceRecord, -} from './protocol'; +import type { RetryAction, WorkspaceView } from '@blitzos/schema'; +import type { Agent, TenantMe } from './protocol'; import type { UiPreferences } from './storage'; +/** The only statuses the wire can produce for a visible workspace: phases + * `destroying` and `destroyed` are filtered before the store, `ready` reads + * as running, and the rest pass through. */ +export type WorkspaceLifecycleStatus = 'creating' | 'running' | 'error'; + export type CloudWorkspaceModel = { id: string; - ownerMembershipId: string; canControl: boolean; - shared: boolean; - owner: WorkspaceRecord['owner'] | null; - accessRole: WorkspaceRecord['accessRole']; + owner: WorkspaceView['owner']; + accessRole: WorkspaceView['role']; orgShareRole: 'editor' | 'viewer' | null; serverName: string; title: string; - machineType: string | null; - lifecycleStatus: RestWorkspaceStatus; + machineType: string; + lifecycleStatus: WorkspaceLifecycleStatus; errorDetail: string | null; retryAction: RetryAction; - createdAt: number; updatedAt: number; /** Stipulated connection names off the workspace ceiling. */ connections: string[]; @@ -34,59 +31,69 @@ export type WorkspaceStoreState = { }; export type WorkspaceAction = - | { type: 'workspaces_loaded'; records: WorkspaceRecord[]; viewer: TenantMe; preferences: UiPreferences } - | { type: 'workspace_created'; record: WorkspaceRecord; agentDefault: Agent } - | { type: 'workspace_records_refreshed'; records: WorkspaceRecord[] } - | { type: 'workspace_record_updated'; record: WorkspaceRecord } - | { type: 'workspace_resume_failed'; workspaceId: string; errorDetail: string } + | { type: 'workspaces_loaded'; records: WorkspaceView[]; viewer: TenantMe; preferences: UiPreferences } + | { type: 'workspace_created'; record: WorkspaceView; agentDefault: Agent } + | { type: 'workspace_records_refreshed'; records: WorkspaceView[] } | { type: 'workspace_deleted'; workspaceId: string } - | { type: 'workspace_delete_rolled_back'; workspace: CloudWorkspaceModel; index: number } - | { type: 'workspace_renamed'; workspaceId: string; title: string } - | { type: 'agent_default_changed'; workspaceId: string; agent: Agent } - | { type: 'workspace_reordered'; sourceId: string; targetId: string }; + | { type: 'workspace_delete_rolled_back'; workspace: CloudWorkspaceModel; index: number }; export const initialWorkspaceStore: WorkspaceStoreState = { workspaces: [], viewer: null }; -function isVisibleWorkspace(record: WorkspaceRecord): boolean { - return record.status !== 'destroying' && record.status !== 'destroyed'; +export function isVisibleWorkspace(record: WorkspaceView): boolean { + return record.phase !== 'destroying' && record.phase !== 'destroyed'; +} + +function lifecycleStatusOf(record: WorkspaceView): WorkspaceLifecycleStatus { + if (record.phase === 'creating') return 'creating'; + if (record.phase === 'error') return 'error'; + return 'running'; } function createWorkspaceModel( - record: WorkspaceRecord, + record: WorkspaceView, preferences: UiPreferences, ): CloudWorkspaceModel { const preference = preferences.workspaces[record.id]; + const canControl = record.role !== null; return { id: record.id, - ownerMembershipId: record.ownerMembershipId, - canControl: record.canControl, - shared: record.shared === true, - owner: record.owner ?? null, - accessRole: record.accessRole ?? null, - orgShareRole: record.orgShareRole ?? null, + canControl, + owner: record.owner, + accessRole: record.role, + orgShareRole: record.orgShareRole, serverName: record.name, - title: record.canControl ? preference?.title || record.name : record.name, - machineType: record.machineType ?? null, - lifecycleStatus: record.status, - errorDetail: record.errorDetail ?? null, + title: canControl ? preference?.title || record.name : record.name, + machineType: record.machineTypeId, + lifecycleStatus: lifecycleStatusOf(record), + errorDetail: record.error, retryAction: record.retryAction, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - connections: record.connections ?? [], + updatedAt: record.revision, + connections: record.connections, agentDefault: preference?.agentDefault ?? 'claude', }; } -function mapWorkspace( - state: WorkspaceStoreState, - workspaceId: string, - update: (workspace: CloudWorkspaceModel) => CloudWorkspaceModel, -): WorkspaceStoreState { +/** Everything a poll refresh may overwrite on a model it already holds; the + * locally-owned title and agentDefault stay put. */ +function refreshedFields( + existing: CloudWorkspaceModel, + record: WorkspaceView, +): CloudWorkspaceModel { + const canControl = record.role !== null; return { - ...state, - workspaces: state.workspaces.map((workspace) => ( - workspace.id === workspaceId && workspace.canControl ? update(workspace) : workspace - )), + ...existing, + canControl, + owner: record.owner, + accessRole: record.role, + orgShareRole: record.orgShareRole, + serverName: record.name, + title: canControl ? existing.title : record.name, + machineType: record.machineTypeId, + lifecycleStatus: lifecycleStatusOf(record), + errorDetail: record.error, + retryAction: record.retryAction, + updatedAt: Math.max(existing.updatedAt, record.revision), + connections: record.connections, }; } @@ -97,32 +104,14 @@ export function workspaceReducer(state: WorkspaceStoreState, action: WorkspaceAc const visibleRecords = action.records.filter(isVisibleWorkspace); const models = visibleRecords.map((record) => { const existing = oldById.get(record.id); - if (!existing || existing.canControl !== record.canControl) { + if (!existing || existing.canControl !== (record.role !== null)) { return createWorkspaceModel(record, action.preferences); } - return { - ...existing, - ownerMembershipId: record.ownerMembershipId, - canControl: record.canControl, - shared: record.shared === true, - owner: record.owner ?? existing.owner, - accessRole: record.accessRole ?? null, - orgShareRole: record.orgShareRole ?? null, - serverName: record.name, - title: record.canControl ? existing.title : record.name, - machineType: record.machineType ?? null, - lifecycleStatus: record.status, - errorDetail: record.errorDetail ?? null, - retryAction: record.retryAction, - createdAt: record.createdAt, - updatedAt: Math.max(existing.updatedAt, record.updatedAt), - connections: record.connections ?? existing.connections, - }; + return refreshedFields(existing, record); }); const order = new Map(action.preferences.order.map((id, index) => [id, index])); models.sort((left, right) => ( (order.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(right.id) ?? Number.MAX_SAFE_INTEGER) - || right.createdAt - left.createdAt )); return { workspaces: models, viewer: action.viewer }; } @@ -144,41 +133,10 @@ export function workspaceReducer(state: WorkspaceStoreState, action: WorkspaceAc workspaces: state.workspaces.flatMap((workspace) => { const record = recordsById.get(workspace.id); if (!record || !isVisibleWorkspace(record)) return []; - return [{ - ...workspace, - ownerMembershipId: record.ownerMembershipId, - canControl: record.canControl, - shared: record.shared === true, - owner: record.owner ?? workspace.owner, - accessRole: record.accessRole ?? null, - orgShareRole: record.orgShareRole ?? null, - serverName: record.name, - title: record.canControl ? workspace.title : record.name, - machineType: record.machineType ?? null, - lifecycleStatus: record.status, - errorDetail: record.errorDetail ?? null, - retryAction: record.retryAction, - createdAt: record.createdAt, - updatedAt: Math.max(workspace.updatedAt, record.updatedAt), - connections: record.connections ?? workspace.connections, - }]; + return [refreshedFields(workspace, record)]; }), }; } - case 'workspace_record_updated': - return mapWorkspace(state, action.record.id, (workspace) => ({ - ...workspace, - machineType: action.record.machineType ?? workspace.machineType, - lifecycleStatus: action.record.status, - errorDetail: action.record.errorDetail ?? null, - retryAction: action.record.retryAction, - updatedAt: action.record.updatedAt, - })); - case 'workspace_resume_failed': - return mapWorkspace(state, action.workspaceId, (workspace) => ({ - ...workspace, - errorDetail: action.errorDetail, - })); case 'workspace_deleted': return { ...state, workspaces: state.workspaces.filter(({ id }) => id !== action.workspaceId) }; case 'workspace_delete_rolled_back': { @@ -191,20 +149,6 @@ export function workspaceReducer(state: WorkspaceStoreState, action: WorkspaceAc ); return { ...state, workspaces }; } - case 'workspace_renamed': - return mapWorkspace(state, action.workspaceId, (workspace) => ({ ...workspace, title: action.title })); - case 'agent_default_changed': - return mapWorkspace(state, action.workspaceId, (workspace) => ({ ...workspace, agentDefault: action.agent })); - case 'workspace_reordered': { - const sourceIndex = state.workspaces.findIndex(({ id }) => id === action.sourceId); - const targetIndex = state.workspaces.findIndex(({ id }) => id === action.targetId); - if (sourceIndex < 0 || targetIndex < 0) return state; - const workspaces = [...state.workspaces]; - const [moved] = workspaces.splice(sourceIndex, 1); - if (!moved) return state; - workspaces.splice(targetIndex, 0, moved); - return { ...state, workspaces }; - } } } diff --git a/packages/webapp/test/FilesSidebar.test.tsx b/packages/webapp/test/FilesSidebar.test.tsx index 64dcd50d..35c0ccd8 100644 --- a/packages/webapp/test/FilesSidebar.test.tsx +++ b/packages/webapp/test/FilesSidebar.test.tsx @@ -20,7 +20,6 @@ function webDavClient(getDirectoryContents: ReturnType): WebDAVCli function sidebar( client: WebDAVClient | null, - getClient: () => WebDAVClient | null, visible = true, onUnauthorized = () => undefined, ) { @@ -28,7 +27,6 @@ function sidebar( { .mockRejectedValueOnce(badGateway()) .mockResolvedValue([]); const client = webDavClient(getDirectoryContents); - const view = await render(sidebar(client, () => client)); + const view = await render(sidebar(client)); await flushPromises(); expect(getDirectoryContents).toHaveBeenCalledTimes(1); @@ -94,12 +92,10 @@ describe("FilesSidebar root listing retries", () => { await view.unmount(); }); - it("manual root retry reacquires a client when the client prop is null", async () => { - const failingListing = vi.fn().mockRejectedValue(badGateway()); - const recoveredListing = vi.fn().mockResolvedValue([]); - let currentClient = webDavClient(failingListing); - const getClient = vi.fn(() => currentClient); - const view = await render(sidebar(null, getClient)); + it("manual root retry recovers after the retry window is exhausted", async () => { + const getDirectoryContents = vi.fn().mockRejectedValue(badGateway()); + const client = webDavClient(getDirectoryContents); + const view = await render(sidebar(client)); await flushPromises(); await act(async () => { @@ -107,7 +103,7 @@ describe("FilesSidebar root listing retries", () => { }); expect(view.container.textContent).toContain("couldn't list"); - currentClient = webDavClient(recoveredListing); + getDirectoryContents.mockResolvedValue([]); const retry = [...view.container.querySelectorAll("button")] .find((button) => button.textContent === "retry"); expect(retry).toBeDefined(); @@ -117,29 +113,27 @@ describe("FilesSidebar root listing retries", () => { await Promise.resolve(); }); - expect(getClient).toHaveBeenCalled(); - expect(recoveredListing).toHaveBeenCalledWith("/"); + expect(getDirectoryContents).toHaveBeenLastCalledWith("/"); expect(view.container.textContent).toContain("(empty)"); await view.unmount(); }); - it("keeps retrying while the client is unavailable and reacquires it", async () => { + it("waits while the client is null and loads once the client prop arrives", async () => { const recoveredListing = vi.fn().mockResolvedValue([]); - let currentClient: WebDAVClient | null = null; - const getClient = vi.fn(() => currentClient); - const view = await render(sidebar(null, getClient)); + const view = await render(sidebar(null)); await flushPromises(); - expect(getClient).toHaveBeenCalledTimes(1); expect(view.container.querySelector('[aria-label="Loading workspace files"]')).not.toBeNull(); - - currentClient = webDavClient(recoveredListing); await act(async () => { await vi.advanceTimersByTimeAsync(ROOT_RETRY_INTERVAL_MS); }); + expect(recoveredListing).not.toHaveBeenCalled(); + + // Endpoints resolving re-renders the app with a client, as CloudApp does. + await act(async () => view.root.render(sidebar(webDavClient(recoveredListing)))); + await flushPromises(); - expect(getClient).toHaveBeenCalledTimes(2); expect(recoveredListing).toHaveBeenCalledWith("/"); expect(view.container.textContent).toContain("(empty)"); @@ -149,12 +143,11 @@ describe("FilesSidebar root listing retries", () => { it("stops root retries when the sidebar becomes hidden", async () => { const getDirectoryContents = vi.fn().mockRejectedValue(badGateway()); const client = webDavClient(getDirectoryContents); - const getClient = () => client; - const view = await render(sidebar(client, getClient)); + const view = await render(sidebar(client)); await flushPromises(); expect(getDirectoryContents).toHaveBeenCalledTimes(1); - await act(async () => view.root.render(sidebar(client, getClient, false))); + await act(async () => view.root.render(sidebar(client, false))); await act(async () => { await vi.advanceTimersByTimeAsync(ROOT_RETRY_WINDOW_MS); }); @@ -170,12 +163,11 @@ describe("FilesSidebar root listing retries", () => { rejectListing = reject; })); const client = webDavClient(getDirectoryContents); - const getClient = () => client; const onUnauthorized = vi.fn(); - const view = await render(sidebar(client, getClient, true, onUnauthorized)); + const view = await render(sidebar(client, true, onUnauthorized)); await flushPromises(); - await act(async () => view.root.render(sidebar(client, getClient, false, onUnauthorized))); + await act(async () => view.root.render(sidebar(client, false, onUnauthorized))); await act(async () => { rejectListing(Object.assign(new Error("Unauthorized"), { status: 401 })); await Promise.resolve(); @@ -205,7 +197,6 @@ describe("FilesSidebar root listing retries", () => { client} mobile={false} open ready @@ -271,7 +262,7 @@ describe("FilesSidebar root listing retries", () => { it("shows files created outside the sidebar on the next poll", async () => { const getDirectoryContents = vi.fn().mockResolvedValue([]); const client = webDavClient(getDirectoryContents); - const view = await render(sidebar(client, () => client)); + const view = await render(sidebar(client)); await flushPromises(); expect(view.container.textContent).toContain("(empty)"); diff --git a/packages/webapp/test/admin-connections.test.tsx b/packages/webapp/test/admin-connections.test.tsx index 106671a9..c19f05c1 100644 --- a/packages/webapp/test/admin-connections.test.tsx +++ b/packages/webapp/test/admin-connections.test.tsx @@ -100,7 +100,6 @@ function adminEntry(id: string, title: string, proxy: boolean): CatalogEntryView summary: `${title} for the whole organization`, docsUrl: `https://example.com/${id}`, custody: proxy ? 'proxy' : 'cp', - rotation: 'none', oauthAvailable: false, oauthConfigured: false, personalTokenLabel: null, diff --git a/packages/webapp/test/api-adapter.test.ts b/packages/webapp/test/api-adapter.test.ts deleted file mode 100644 index 14af0eac..00000000 --- a/packages/webapp/test/api-adapter.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { WorkspaceView } from "@blitzos/schema"; -import { describe, expect, it, vi } from "vitest"; -import { - ApiAdapter, - workspaceFromWire, -} from "../src/api-adapter.js"; -import type { ControlPlaneClient } from "../src/api.js"; -import { defaultGlobalWebAppState, defaultWorkspaceWebAppState } from "../src/storage.js"; - -function workspace(phase: WorkspaceView["phase"], retryAction: WorkspaceView["retryAction"]): WorkspaceView { - return { - id: `workspace-${phase}`, - name: `name-${phase}`, - machineTypeId: "mv-2c2g@lab", - phase, - retryAction, - canObserve: phase === "ready", - launchable: phase === "ready", - revision: 7, - ssh: null, - volumeId: null, - error: phase === "error" ? "provider failed" : null, - role: "owner", - orgShareRole: null, - connections: [], - owner: { name: "Owner", avatarUrl: null }, - environment: null, - agentRuleId: null, - }; -} - -function client(overrides: Partial = {}): ControlPlaneClient { - return { - googleLoginUrl: () => "/auth/google/start", - inviteGoogleLoginUrl: (code) => `/auth/google/start?invite=${code}`, - inviteStatus: vi.fn(async () => { throw new Error("unused"); }), - switchOrg: vi.fn(async () => undefined), - listMembers: vi.fn(async () => ({ members: [] })), - updateMember: vi.fn(async () => { throw new Error("unused"); }), - listInvites: vi.fn(async () => ({ invites: [], ttlDays: 7 })), - createInvite: vi.fn(async () => { throw new Error("unused"); }), - revokeInvite: vi.fn(async () => undefined), - listWorkspaceGrants: vi.fn(async () => ({ grants: [] })), - createWorkspaceGrant: vi.fn(async () => { throw new Error("unused"); }), - revokeWorkspaceGrant: vi.fn(async () => undefined), - listFolders: vi.fn(async () => ({ folders: [] })), - createFolder: vi.fn(async () => { throw new Error("unused"); }), - deleteFolder: vi.fn(async () => undefined), - createFolderGrant: vi.fn(async () => { throw new Error("unused"); }), - revokeFolderGrant: vi.fn(async () => undefined), - listFolderObjects: vi.fn(async () => ({ objects: [], cursor: null, truncated: false })), - downloadFolderObject: vi.fn(async () => new Blob()), - uploadFolderObject: vi.fn(async () => undefined), - listWorkspaceFolders: vi.fn(async () => ({ folders: [] })), - attachFolder: vi.fn(async () => { throw new Error("unused"); }), - detachFolder: vi.fn(async () => undefined), - renameFolder: vi.fn(async () => undefined), - setFolderOrgRole: async () => undefined, - listAgentRules: async () => ({ rules: [] }), - putAgentRule: async () => { throw new Error('unused'); }, - deleteAgentRule: async () => undefined, - listWorkspaceTemplates: async () => ({ templates: [] }), - createWorkspaceTemplate: async () => { throw new Error('unused'); }, - updateWorkspaceTemplate: async () => { throw new Error('unused'); }, - deleteWorkspaceTemplate: async () => undefined, - listRecipes: async () => ({ recipes: [] }), - getRecipe: async () => { throw new Error("unused"); }, - createRecipe: async () => { throw new Error("unused"); }, - updateRecipe: async () => { throw new Error("unused"); }, - deleteRecipe: async () => undefined, - launchRecipe: async () => ({ workspace: workspace("creating", "poll") }), - getUsageCapture: async () => ({ enabled: false, folderId: null }), - putUsageCapture: async (enabled: boolean) => ({ enabled, folderId: null }), - setWorkspaceOrgRole: async () => undefined, - deleteFolderObject: vi.fn(async () => undefined), - logout: vi.fn(async () => undefined), - me: vi.fn(async () => ({ - user: { - id: "user-one", - email: "person@example.com", - name: "Person", - avatarUrl: null, - platformOperator: false, - }, - membership: { id: "membership-one", role: "admin" as const, status: "active" as const }, - org: { id: "org-one", slug: "example", name: "Example", vmLimit: 10 }, - organizations: [{ - membership: { id: "membership-one", role: "admin" as const, status: "active" as const }, - org: { id: "org-one", slug: "example", name: "Example", vmLimit: 10 }, - }], - })), - createOrg: vi.fn(async () => ({ - org: { id: "org-one", slug: "example", name: "Example", vmLimit: 10 }, - membership: { id: "membership-one", role: "admin" as const, status: "active" as const }, - })), - getGlobalWebAppState: vi.fn(async () => ({ doc: null, updatedAt: null })), - putGlobalWebAppState: vi.fn(async (doc) => ({ doc, updatedAt: 1 })), - getWorkspaceWebAppState: vi.fn(async () => ({ doc: null, updatedAt: null })), - putWorkspaceWebAppState: vi.fn(async (_id, doc) => ({ doc, updatedAt: 1 })), - poll: vi.fn(async () => ({ workspaces: [] })), - create: vi.fn(async () => ({ workspace: workspace("creating", "poll") })), - destroy: vi.fn(async () => ({ workspace: workspace("destroying", "poll") })), - listMachineTypes: vi.fn(async () => ({ machineTypes: [], failures: [] })), - listVolumes: vi.fn(async () => ({ volumes: [] })), - listConnections: vi.fn(async () => ({ connections: [] })), - putConnection: vi.fn(async () => undefined), - deleteConnection: vi.fn(async () => undefined), - listLeases: vi.fn(async () => ({ leases: [] })), - listCredentialEvents: vi.fn(async () => ({ events: [] })), - mintWorkspaceConnection: vi.fn(async () => { throw new Error("unused"); }), - revokeLease: vi.fn(async () => undefined), - listCredentialRequests: vi.fn(async () => ({ requests: [] })), - approveCredentialRequest: vi.fn(async () => undefined), - denyCredentialRequest: vi.fn(async () => undefined), - listConnectionCatalog: vi.fn(async () => ({ providers: [] })), - listConnectionGrants: vi.fn(async () => ({ grants: [] })), - listGithubRepositories: vi.fn(async () => ({ repositories: [] })), - putConnectionGrant: vi.fn(async () => undefined), - deleteConnectionGrant: vi.fn(async () => undefined), - listProviderHealth: vi.fn(async () => ({ providers: [] })), - connectStartUrl: (provider: string) => `/connect/${provider}/start`, - ...overrides, - }; -} - -describe("webapp API adapter", () => { - it("maps blitz phases and filters terminal phases", () => { - expect(workspaceFromWire(workspace("ready", null), "membership-one")).toMatchObject({ - status: "running", - canControl: true, - ownerMembershipId: "membership-one", - machineType: "mv-2c2g@lab", - }); - expect(workspaceFromWire(workspace("creating", "poll"))?.status).toBe("creating"); - expect(workspaceFromWire(workspace("error", "destroy"))).toMatchObject({ - status: "error", - errorDetail: "provider failed", - retryAction: "destroy", - }); - expect(workspaceFromWire(workspace("destroying", "poll"))).toBeNull(); - expect(workspaceFromWire(workspace("destroyed", "create"))).toBeNull(); - }); - - it("uses real identity data and sends a keyless create body unchanged", async () => { - const poll = vi.fn(async () => ({ workspaces: [workspace("ready", null)] })); - const create = vi.fn(async () => ({ workspace: workspace("creating", "poll") })); - const adapter = new ApiAdapter(client({ poll, create }), () => undefined); - - expect((await adapter.getMe()).org?.id).toBe("org-one"); - expect((await adapter.listWorkspaces()).map(({ status }) => status)).toEqual(["running"]); - expect(poll).toHaveBeenCalledOnce(); - - await adapter.createWorkspace({ machineTypeId: "mv-2c2g@lab" }); - expect(create).toHaveBeenCalledWith({ machineTypeId: "mv-2c2g@lab" }); - - await adapter.putGlobalWebAppState(defaultGlobalWebAppState()); - await adapter.putWorkspaceWebAppState("workspace-ready", defaultWorkspaceWebAppState()); - }); -}); diff --git a/packages/webapp/test/chat-transcript.test.ts b/packages/webapp/test/chat-transcript.test.ts new file mode 100644 index 00000000..12dab726 --- /dev/null +++ b/packages/webapp/test/chat-transcript.test.ts @@ -0,0 +1,108 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + chatReducer, + initialChatState, + reduceAcpFrame, + type ChatState, +} from "../src/chat/reducer.js"; +import { deriveChatTranscript } from "../src/chat/chat-turns.js"; + +const fixturesDirectory = resolve(process.cwd(), "../schema/fixtures/acp"); + +function replay(name: string): ChatState { + return readFileSync(`${fixturesDirectory}/${name}`, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as unknown) + .reduce(reduceAcpFrame, initialChatState); +} + +function userTurn(state: ChatState, turnId: string, text: string): ChatState { + let next = chatReducer(state, { type: "turn-started", turnId }); + next = chatReducer(next, { + type: "update", + update: { + sessionUpdate: "user_message_chunk", + messageId: `${turnId}-user`, + content: { type: "text", text }, + }, + }); + return chatReducer(next, { + type: "update", + update: { + sessionUpdate: "agent_message_chunk", + messageId: `${turnId}-agent`, + content: { type: "text", text: `re: ${text}` }, + }, + }); +} + +describe("chat transcript derivation", () => { + it("groups a settled fixture turn with a successful synthetic result", () => { + const derived = deriveChatTranscript(replay("text-turn.jsonl")); + expect(derived.entries).toHaveLength(1); + const entry = derived.entries[0]; + if (entry?.kind !== "turn") throw new Error("expected a turn entry"); + expect(entry.turn.prompt.text).toBe("Say hello."); + expect(entry.turn.status).toBe("complete"); + expect(entry.turn.result?.meta.success).toBe(true); + expect(entry.turn.finalAssistantId).toBeDefined(); + }); + + it("indexes fixture tool calls into the shared result map", () => { + const derived = deriveChatTranscript(replay("tool-call.jsonl")); + expect(derived.toolResults["tool-1"]).toMatchObject({ isError: false }); + }); + + it("keeps rows before the first prompt loose", () => { + const state = chatReducer(initialChatState, { type: "generic", label: "connected late" }); + const derived = deriveChatTranscript(userTurn(state, "turn-1", "hi")); + expect(derived.entries[0]).toMatchObject({ + kind: "loose", + item: { kind: "system", text: "connected late" }, + }); + expect(derived.entries[1]?.kind).toBe("turn"); + }); + + it("marks the open turn working with its streaming assistant in flight", () => { + const running = userTurn(initialChatState, "turn-1", "hi"); + const derived = deriveChatTranscript(running); + const entry = derived.entries[0]; + if (entry?.kind !== "turn") throw new Error("expected a turn entry"); + expect(entry.turn.status).toBe("working"); + expect(entry.turn.result).toBeUndefined(); + const assistant = entry.turn.items.find((item) => item.kind === "assistant"); + expect(assistant).toMatchObject({ inFlight: true }); + }); + + it("settles earlier turns successfully and the last turn from its stop reason", () => { + let state = userTurn(initialChatState, "turn-1", "first"); + state = chatReducer(state, { type: "turn-ended", turnId: "turn-1", stopReason: "end_turn" }); + state = userTurn(state, "turn-2", "second"); + state = chatReducer(state, { type: "turn-ended", turnId: "turn-2", stopReason: "refusal" }); + const derived = deriveChatTranscript(state); + expect(derived.entries.map((entry) => entry.kind)).toEqual(["turn", "turn"]); + const [first, second] = derived.entries; + if (first?.kind !== "turn" || second?.kind !== "turn") throw new Error("expected turns"); + expect(first.turn.status).toBe("complete"); + expect(second.turn.status).toBe("failed"); + expect(second.turn.result?.meta.success).toBe(false); + }); + + it("surfaces only an unanswered permission as active", () => { + const state = replay("permission.jsonl"); + const derived = deriveChatTranscript(state); + expect(derived.activePermission).toBeNull(); + const answered = chatReducer(state, { + type: "permission-request", + request: { + sessionId: "session-permission", + toolCall: { toolCallId: "tool-unanswered", title: "Write file" }, + options: [{ optionId: "allow-once", name: "Allow", kind: "allow_once" }], + }, + }); + expect(deriveChatTranscript(answered).activePermission?.toolCallId).toBe("tool-unanswered"); + }); +}); diff --git a/packages/webapp/test/credentials-surfaces-v2.test.tsx b/packages/webapp/test/credentials-surfaces-v2.test.tsx index 439fb10e..7913f99c 100644 --- a/packages/webapp/test/credentials-surfaces-v2.test.tsx +++ b/packages/webapp/test/credentials-surfaces-v2.test.tsx @@ -14,7 +14,7 @@ import { } from '../src/WorkspaceDrawer.js'; import { WorkspaceRailStrip } from '../src/WorkspaceRailStrip.js'; import { ConnectPicker } from '../src/settings/ConnectPicker.js'; -import { MembersPanel } from '../src/settings/MembersPanel.js'; +import { InvitesPanel } from '../src/settings/InvitesPanel.js'; import { render, settle } from './dom.js'; function client(overrides: Partial = {}): ControlPlaneClient { @@ -105,7 +105,6 @@ function catalogEntry(id: string, title: string): CatalogEntryView { summary: `${title} for agents`, docsUrl: `https://example.com/${id}`, custody: 'proxy', - rotation: 'none', oauthAvailable: false, oauthConfigured: false, personalTokenLabel: 'API key', @@ -163,7 +162,7 @@ function connectionsPanel(wire: ControlPlaneClient) { } describe('v2 credential surfaces', () => { - it('mints an email-pinned invite from the members panel and shows its link once', async () => { + it('mints an email-pinned invite from the invites panel and shows its link once', async () => { const createInvite = vi.fn(async () => ({ invite: { id: 'invite-one', @@ -177,7 +176,7 @@ describe('v2 credential surfaces', () => { code: 'one-time-code', ttlDays: 7, })); - const view = await render(); + const view = await render(); await settle(); const input = view.container.querySelector('input[type="email"]')!; const setInputValue = Object.getOwnPropertyDescriptor( @@ -195,7 +194,7 @@ describe('v2 credential surfaces', () => { await settle(); expect(createInvite).toHaveBeenCalledWith({ email: 'person@example.com', role: 'member' }); - expect(view.container.querySelector('[aria-label="Member invite link"]')?.value) + expect(view.container.querySelector('[aria-label="Invite link"]')?.value) .toBe(`${window.location.origin}/invite/one-time-code`); await view.unmount(); }); @@ -209,9 +208,7 @@ describe('v2 credential surfaces', () => { client={wire} workspaceId="workspace-one" orgName="Example" - mobile={false} open - width={264} segment={segment} pendingRequests={[]} livePorts={[]} @@ -221,7 +218,6 @@ describe('v2 credential surfaces', () => { onOpenPreview={() => undefined} onOpenPreviewLink={() => undefined} files={
File tree
} - onWidthChange={() => undefined} onSegmentChange={setSegment} onResolveRequest={async () => undefined} /> @@ -336,9 +332,7 @@ describe('v2 credential surfaces', () => { client={client()} workspaceId="workspace-one" orgName="Example" - mobile={false} open - width={264} segment="connections" pendingRequests={requests} livePorts={[]} @@ -348,7 +342,6 @@ describe('v2 credential surfaces', () => { onOpenPreview={() => undefined} onOpenPreviewLink={() => undefined} files={
File tree
} - onWidthChange={() => undefined} onSegmentChange={() => undefined} onResolveRequest={async (entry, action) => { if (action === 'deny') await dismiss(entry.id); diff --git a/packages/webapp/test/files-drive.test.tsx b/packages/webapp/test/files-drive.test.tsx index 24fd373a..e4c7a97a 100644 --- a/packages/webapp/test/files-drive.test.tsx +++ b/packages/webapp/test/files-drive.test.tsx @@ -1,7 +1,7 @@ import { act } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createControlPlaneClient } from '../src/api.js'; -import type { TenantMe } from '../src/api-adapter.js'; +import type { TenantMe } from '../src/protocol.js'; import { FilesDrive } from '../src/files/FilesDrive.js'; import { render, settle } from './dom.js'; diff --git a/packages/webapp/test/persistence-object-contracts.test.ts b/packages/webapp/test/persistence-object-contracts.test.ts index ff2adeba..831f1b26 100644 --- a/packages/webapp/test/persistence-object-contracts.test.ts +++ b/packages/webapp/test/persistence-object-contracts.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { decodeWorkspaceWebAppStateResponse, defaultWorkspaceFiles, - storedWorkspacePreference, + defaultWorkspaceTabs, withPreviewTabPath, workspaceWebAppState, type WorkspaceTab, @@ -25,16 +25,27 @@ describe("UI protocol and persistence object contracts", () => { expect(JSON.stringify(tenant)).toBe('{"AuthToken":"","columns":120,"rows":40}'); }); - it("preserves stored workspace-title omission before agentDefault", () => { - const defaultTitle = storedWorkspacePreference("box-1", "box-1", "claude"); - expect(Object.keys(defaultTitle)).toEqual(["agentDefault"]); + it("omits the title key from the outgoing document unless it differs from the server name", () => { + const defaultTitle = workspaceWebAppState( + "box-1", + "box-1", + "claude", + defaultWorkspaceTabs(), + defaultWorkspaceFiles(), + ); + expect(Object.keys(defaultTitle)).toEqual(["version", "agentDefault", "tabs", "drawer"]); expect("title" in defaultTitle).toBe(false); - expect(JSON.stringify(defaultTitle)).toBe('{"agentDefault":"claude"}'); - const customTitle = storedWorkspacePreference("Docs", "box-1", "codex"); - expect(Object.keys(customTitle)).toEqual(["title", "agentDefault"]); - expect("title" in customTitle).toBe(true); - expect(JSON.stringify(customTitle)).toBe('{"title":"Docs","agentDefault":"codex"}'); + const customTitle = workspaceWebAppState( + "Docs", + "box-1", + "codex", + defaultWorkspaceTabs(), + defaultWorkspaceFiles(), + ); + expect(Object.keys(customTitle)).toEqual(["version", "agentDefault", "tabs", "drawer", "title"]); + expect(customTitle.title).toBe("Docs"); + expect(customTitle.agentDefault).toBe("codex"); }); it("preserves restored chat optional-key absence, order, and serialization", () => { diff --git a/packages/webapp/test/preview-focus.test.ts b/packages/webapp/test/preview-focus.test.ts index ee902564..fcc336b1 100644 --- a/packages/webapp/test/preview-focus.test.ts +++ b/packages/webapp/test/preview-focus.test.ts @@ -121,52 +121,57 @@ describe('preview-focus browser consumer contract', () => { 'https://cp.example/workspaces/one/webapp/7445/preview-focus', ); - const okFetcher = vi.fn(async () => new Response(JSON.stringify({ - focus: { version: 1, port: 5173, path: '/dashboard', title: 'Docs', requestedAt: 1787000001000 }, - }), { status: 200, headers: { 'content-type': 'application/json' } })); - await expect(fetchWorkspacePreviewFocus(base, okFetcher)).resolves.toEqual({ - ok: true, - focus: { - version: 1, - port: 5173, - path: '/dashboard', - title: 'Docs', - requestedAt: 1787000001000, - }, - }); - expect(okFetcher).toHaveBeenCalledWith('https://box.example/preview-focus', { - credentials: 'include', - signal: undefined, - }); - - // A read that never reached the box is reported as a failure, not as - // "the box has no focus": the caller must not adopt it as a baseline. - // Old boxes 404 the route. - const notFound = vi.fn(async () => new Response('not found', { status: 404 })); - await expect(fetchWorkspacePreviewFocus(base, notFound)).resolves.toEqual({ ok: false }); - - // Empty 200 body — unparseable, so also a failed read. - const empty = vi.fn(async () => new Response('', { status: 200 })); - await expect(fetchWorkspacePreviewFocus(base, empty)).resolves.toEqual({ ok: false }); - - // Network errors are swallowed, still as a failed read. - const throws = vi.fn(async () => { throw new Error('offline'); }); - await expect(fetchWorkspacePreviewFocus(base, throws)).resolves.toEqual({ ok: false }); - - // A successful read with nothing focused is a real answer. - const nullFocus = vi.fn(async () => new Response(JSON.stringify({ focus: null }), { - status: 200, - headers: { 'content-type': 'application/json' }, - })); - await expect(fetchWorkspacePreviewFocus(base, nullFocus)) - .resolves.toEqual({ ok: true, focus: null }); - - // So is a successful read the client's own guards reject: a reserved port - // on a 200 means the box has nothing this browser may open. - const reserved = vi.fn(async () => new Response(JSON.stringify({ - focus: { version: 1, port: 7445, path: '/', title: 'gateway', requestedAt: 1787000002000 }, - }), { status: 200, headers: { 'content-type': 'application/json' } })); - await expect(fetchWorkspacePreviewFocus(base, reserved)) - .resolves.toEqual({ ok: true, focus: null }); + try { + const okFetcher = vi.fn(async () => new Response(JSON.stringify({ + focus: { version: 1, port: 5173, path: '/dashboard', title: 'Docs', requestedAt: 1787000001000 }, + }), { status: 200, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', okFetcher); + await expect(fetchWorkspacePreviewFocus(base)).resolves.toEqual({ + ok: true, + focus: { + version: 1, + port: 5173, + path: '/dashboard', + title: 'Docs', + requestedAt: 1787000001000, + }, + }); + expect(okFetcher).toHaveBeenCalledWith('https://box.example/preview-focus', { + credentials: 'include', + signal: undefined, + }); + + // A read that never reached the box is reported as a failure, not as + // "the box has no focus": the caller must not adopt it as a baseline. + // Old boxes 404 the route. + vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 }))); + await expect(fetchWorkspacePreviewFocus(base)).resolves.toEqual({ ok: false }); + + // Empty 200 body — unparseable, so also a failed read. + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 200 }))); + await expect(fetchWorkspacePreviewFocus(base)).resolves.toEqual({ ok: false }); + + // Network errors are swallowed, still as a failed read. + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); + await expect(fetchWorkspacePreviewFocus(base)).resolves.toEqual({ ok: false }); + + // A successful read with nothing focused is a real answer. + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ focus: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + await expect(fetchWorkspacePreviewFocus(base)) + .resolves.toEqual({ ok: true, focus: null }); + + // So is a successful read the client's own guards reject: a reserved port + // on a 200 means the box has nothing this browser may open. + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + focus: { version: 1, port: 7445, path: '/', title: 'gateway', requestedAt: 1787000002000 }, + }), { status: 200, headers: { 'content-type': 'application/json' } }))); + await expect(fetchWorkspacePreviewFocus(base)) + .resolves.toEqual({ ok: true, focus: null }); + } finally { + vi.unstubAllGlobals(); + } }); }); diff --git a/packages/webapp/test/preview-v2.test.ts b/packages/webapp/test/preview-v2.test.ts index a2a2dc5d..d34908ce 100644 --- a/packages/webapp/test/preview-v2.test.ts +++ b/packages/webapp/test/preview-v2.test.ts @@ -47,41 +47,46 @@ describe('box preview contract', () => { 'https://cp.example/workspaces/one/webapp/7445/preview/3000/docs?mode=dark', ); - const fetcher = vi.fn(async () => new Response(JSON.stringify({ - ports: [ + try { + const fetcher = vi.fn(async () => new Response(JSON.stringify({ + ports: [ + { port: 3000, process: 'node' }, + { port: 8080, process: 'cloudflared' }, + ], + }), { status: 200, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', fetcher); + await expect(fetchWorkspacePorts('https://box.example/workspace/')).resolves.toEqual([ { port: 3000, process: 'node' }, - { port: 8080, process: 'cloudflared' }, - ], - }), { status: 200, headers: { 'content-type': 'application/json' } })); - await expect(fetchWorkspacePorts('https://box.example/workspace/', fetcher)).resolves.toEqual([ - { port: 3000, process: 'node' }, - ]); - expect(fetcher).toHaveBeenCalledWith('https://box.example/ports', { - credentials: 'include', - signal: undefined, - }); + ]); + expect(fetcher).toHaveBeenCalledWith('https://box.example/ports', { + credentials: 'include', + signal: undefined, + }); - const previewFetcher = vi.fn(async () => new Response(JSON.stringify({ - previews: [{ + const previewFetcher = vi.fn(async () => new Response(JSON.stringify({ + previews: [{ + url: 'https://demo.blitz.dev', + title: 'Demo', + source: 'agent', + createdAt: 1786900000000, + }], + }), { status: 200, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', previewFetcher); + await expect(fetchWorkspacePreviews( + 'https://box.example/workspace/', + )).resolves.toEqual([{ url: 'https://demo.blitz.dev', title: 'Demo', source: 'agent', createdAt: 1786900000000, - }], - }), { status: 200, headers: { 'content-type': 'application/json' } })); - await expect(fetchWorkspacePreviews( - 'https://box.example/workspace/', - previewFetcher, - )).resolves.toEqual([{ - url: 'https://demo.blitz.dev', - title: 'Demo', - source: 'agent', - createdAt: 1786900000000, - }]); - expect(previewFetcher).toHaveBeenCalledWith('https://box.example/previews', { - credentials: 'include', - signal: undefined, - }); + }]); + expect(previewFetcher).toHaveBeenCalledWith('https://box.example/previews', { + credentials: 'include', + signal: undefined, + }); + } finally { + vi.unstubAllGlobals(); + } }); it('parses the shared public-preview fixtures', () => { diff --git a/packages/webapp/test/recipes.test.tsx b/packages/webapp/test/recipes.test.tsx index 326fc0cc..5d3f09f8 100644 --- a/packages/webapp/test/recipes.test.tsx +++ b/packages/webapp/test/recipes.test.tsx @@ -7,7 +7,7 @@ import { createControlPlaneClient, type ControlPlaneClient, } from '../src/api.js'; -import type { TenantMe } from '../src/api-adapter.js'; +import type { TenantMe } from '../src/protocol.js'; import { CreateRecipeScreen } from '../src/files/CreateRecipeScreen.js'; import { RecipesHome } from '../src/files/RecipesHome.js'; import { TemplatesHome } from '../src/files/TemplatesHome.js'; @@ -656,7 +656,7 @@ describe('recipe run flow', () => { const view = await render( , ); await settle(); @@ -683,7 +683,7 @@ describe('recipe run flow', () => { const view = await render( , ); await settle(); diff --git a/packages/webapp/test/resolver.test.ts b/packages/webapp/test/resolver.test.ts index d58d0dce..6d9f626d 100644 --- a/packages/webapp/test/resolver.test.ts +++ b/packages/webapp/test/resolver.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { WorkspaceView } from "@blitzos/schema"; -import { endpointTarget, standaloneResolver, validPort } from "../src/resolver.js"; +import { standaloneResolver } from "../src/resolver.js"; import { terminalWebSocketUrl } from "../src/CloudApp.js"; const workspace: WorkspaceView = { @@ -25,10 +25,7 @@ const workspace: WorkspaceView = { describe("standalone endpoint resolver", () => { it("routes all workspace surfaces through the control-plane origin", () => { - const resolver = standaloneResolver( - { acp: 8444, files: 8445 }, - "https://cp.example.test/", - ); + const resolver = standaloneResolver("https://cp.example.test/"); const target = { ...workspace, id: "workspace one/two" }; expect(resolver.resolve(target)).toEqual({ terminalUrl: "https://cp.example.test/workspaces/workspace%20one%2Ftwo/webapp/7445/terminal/", @@ -43,20 +40,6 @@ describe("standalone endpoint resolver", () => { ); }); - it("accepts only whole ports from 1 to 65535", () => { - expect(validPort(1)).toBe(true); - expect(validPort(65_535)).toBe(true); - expect(validPort(0)).toBe(false); - expect(validPort(65_536)).toBe(false); - expect(validPort(1.5)).toBe(false); - }); - - it("formats resolver URLs as explicit host:port targets", () => { - expect(endpointTarget("http://localhost:7443/")).toBe("localhost:7443"); - expect(endpointTarget("wss://box.example/ws")).toBe("box.example:443"); - expect(endpointTarget("ws://box.example/ws")).toBe("box.example:80"); - }); - it("constructs the ttyd websocket path at the webapp boundary", () => { expect(terminalWebSocketUrl("http://localhost:7445/terminal/")).toBe("ws://localhost:7445/terminal/ws"); expect(terminalWebSocketUrl("https://box.example/terminal/")).toBe("wss://box.example/terminal/ws"); diff --git a/packages/webapp/test/setup.ts b/packages/webapp/test/setup.ts index ef8c8760..cc0f0011 100644 --- a/packages/webapp/test/setup.ts +++ b/packages/webapp/test/setup.ts @@ -2,3 +2,21 @@ Object.defineProperty(HTMLCanvasElement.prototype, "getContext", { configurable: true, value: () => null, }); + +// jsdom ships no matchMedia; source code may call it unconditionally. Suites +// that care about specific queries redefine it per-test (configurable: true). +if (typeof window.matchMedia !== "function") { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: (query: string): MediaQueryList => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + }), + }); +} diff --git a/packages/webapp/test/shell-smoke.test.tsx b/packages/webapp/test/shell-smoke.test.tsx index b72e55dc..a681efbb 100644 --- a/packages/webapp/test/shell-smoke.test.tsx +++ b/packages/webapp/test/shell-smoke.test.tsx @@ -337,7 +337,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -351,7 +351,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -377,7 +377,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -412,7 +412,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -445,7 +445,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -481,7 +481,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -511,10 +511,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -545,7 +542,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -569,7 +566,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -607,7 +604,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -662,7 +659,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -691,7 +688,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -731,7 +728,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -787,7 +784,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -827,7 +824,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -868,7 +865,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); @@ -916,7 +913,7 @@ describe("webapp shell smoke", () => { const view = await render( , ); await settle(); diff --git a/packages/webapp/test/template-screen.test.tsx b/packages/webapp/test/template-screen.test.tsx index b5b1e7b3..28e649b2 100644 --- a/packages/webapp/test/template-screen.test.tsx +++ b/packages/webapp/test/template-screen.test.tsx @@ -1030,7 +1030,6 @@ describe('template screen org-credential config', () => { summary: 'Bot messaging', docsUrl: 'https://example.com/discord', custody: 'cp', - rotation: 'none', oauthAvailable: false, oauthConfigured: false, personalTokenLabel: null, diff --git a/packages/webapp/test/use-workspace-preview-focus.test.tsx b/packages/webapp/test/use-workspace-preview-sources.test.tsx similarity index 63% rename from packages/webapp/test/use-workspace-preview-focus.test.tsx rename to packages/webapp/test/use-workspace-preview-sources.test.tsx index c926c246..5fb57142 100644 --- a/packages/webapp/test/use-workspace-preview-focus.test.tsx +++ b/packages/webapp/test/use-workspace-preview-sources.test.tsx @@ -1,7 +1,7 @@ import { act } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PORTS_POLL_INTERVAL_MS, type PreviewFocus } from "../src/preview.js"; -import { useWorkspacePreviewFocus } from "../src/use-workspace-preview-focus.js"; +import { useWorkspacePreviewSources } from "../src/use-workspace-preview-sources.js"; import { render } from "./dom.js"; const FILES_BASE = "https://box.example/workspace/"; @@ -10,20 +10,35 @@ function marker(requestedAt: number, path = "/dashboard"): PreviewFocus { return { version: 1, port: 3000, path, title: "Docs", requestedAt }; } -function focusResponse(focus: PreviewFocus | null): Response { - return new Response(JSON.stringify({ focus }), { +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, }); } +function focusResponse(focus: PreviewFocus | null): Response { + return jsonResponse({ focus }); +} + +/** Routes the poller's three sibling fetches: ports and previews always + * answer empty, the `/preview-focus` read is the scenario under test. */ +function stubGatewayFetch(focusImpl: () => Promise): void { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL): Promise => { + const url = String(input); + if (url.endsWith("/preview-focus")) return focusImpl(); + if (url.endsWith("/ports")) return jsonResponse({ ports: [] }); + return jsonResponse({ previews: [] }); + })); +} + function Poller({ onFocus }: { onFocus: (focus: PreviewFocus) => void }) { - useWorkspacePreviewFocus(true, "workspace-1", FILES_BASE, onFocus); + useWorkspacePreviewSources(true, "workspace-1", FILES_BASE, onFocus); return null; } -/** Each poll is one `fetch`; this drives the interval the hook installs and - * lets the in-flight promise settle before the next tick. */ +/** Each poll is one gateway round; this drives the interval the hook installs + * and lets the in-flight promises settle before the next tick. */ async function poll(times = 1): Promise { for (let index = 0; index < times; index += 1) { await act(async () => { @@ -32,7 +47,7 @@ async function poll(times = 1): Promise { } } -describe("useWorkspacePreviewFocus", () => { +describe("useWorkspacePreviewSources focus consumption", () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -49,10 +64,13 @@ describe("useWorkspacePreviewFocus", () => { // and re-opened a marker from a previous visit. it("does not adopt a baseline from a failed poll", async () => { const stale = marker(1_787_000_000_000); - const fetcher = vi.fn() - .mockRejectedValueOnce(new Error("offline")) - .mockResolvedValue(focusResponse(stale)); - vi.stubGlobal("fetch", fetcher); + let focusReads = 0; + let current: () => Promise = async () => { + focusReads += 1; + if (focusReads === 1) throw new Error("offline"); + return focusResponse(stale); + }; + stubGatewayFetch(() => current()); const onFocus = vi.fn(); const { unmount } = await render(); @@ -60,13 +78,13 @@ describe("useWorkspacePreviewFocus", () => { // must be adopted as the baseline rather than opened. await poll(2); - expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(focusReads).toBeGreaterThanOrEqual(2); expect(onFocus).not.toHaveBeenCalled(); // A strictly newer focus, raised while the workspace is on screen, still // opens: the baseline suppressed the replay, not the feature. const fresh = marker(1_787_000_005_000, "/reports"); - fetcher.mockResolvedValue(focusResponse(fresh)); + current = async () => focusResponse(fresh); await poll(); expect(onFocus).toHaveBeenCalledTimes(1); @@ -76,10 +94,13 @@ describe("useWorkspacePreviewFocus", () => { it("treats an old box's 404 as a failed read, not as an absent focus", async () => { const stale = marker(1_787_000_000_000); - const fetcher = vi.fn() - .mockResolvedValueOnce(new Response("not found", { status: 404 })) - .mockResolvedValue(focusResponse(stale)); - vi.stubGlobal("fetch", fetcher); + let focusReads = 0; + stubGatewayFetch(async () => { + focusReads += 1; + return focusReads === 1 + ? new Response("not found", { status: 404 }) + : focusResponse(stale); + }); const onFocus = vi.fn(); const { unmount } = await render(); @@ -90,8 +111,8 @@ describe("useWorkspacePreviewFocus", () => { }); it("adopts an absent focus as the baseline and opens the next one", async () => { - const fetcher = vi.fn().mockResolvedValue(focusResponse(null)); - vi.stubGlobal("fetch", fetcher); + let current: () => Promise = async () => focusResponse(null); + stubGatewayFetch(() => current()); const onFocus = vi.fn(); const { unmount } = await render(); @@ -99,7 +120,7 @@ describe("useWorkspacePreviewFocus", () => { expect(onFocus).not.toHaveBeenCalled(); const focus = marker(1_787_000_001_000); - fetcher.mockResolvedValue(focusResponse(focus)); + current = async () => focusResponse(focus); await poll(); expect(onFocus).toHaveBeenCalledTimes(1); expect(onFocus).toHaveBeenCalledWith(focus); diff --git a/packages/webapp/test/webapp-surface.test.ts b/packages/webapp/test/webapp-surface.test.ts index 5573fbf0..19ebcf5d 100644 --- a/packages/webapp/test/webapp-surface.test.ts +++ b/packages/webapp/test/webapp-surface.test.ts @@ -43,7 +43,7 @@ describe("webApp box surface", () => { } it("only builds URLs the control plane will forward", () => { - const resolver = standaloneResolver({ acp: 7444, files: 7445 }, origin); + const resolver = standaloneResolver(origin); const endpoints = resolver.resolve(workspace); const urls = [ endpoints.acpUrl, diff --git a/packages/webapp/test/workspace-endpoints.test.ts b/packages/webapp/test/workspace-endpoints.test.ts index 46773be7..5e3b0dc0 100644 --- a/packages/webapp/test/workspace-endpoints.test.ts +++ b/packages/webapp/test/workspace-endpoints.test.ts @@ -1,32 +1,35 @@ import { describe, expect, it } from 'vitest'; -import type { V2WorkspaceRecord } from '../src/api-adapter.js'; +import type { WorkspaceView } from '@blitzos/schema'; +import type { BoxEndpoints } from '../src/resolver.js'; import { rememberWorkspaceEndpoints, terminalWebSocketUrl, - type WorkspaceEndpoints, } from '../src/workspace-endpoints.js'; -function record(id: string): V2WorkspaceRecord { +function record(id: string): WorkspaceView { return { id, - ingressLabel: `box-${id}`, - wire: { - id, - machineTypeId: 'cx23@fsn1', - phase: 'ready', - retryAction: null, - canObserve: true, - launchable: true, - revision: 1, - ssh: null, - volumeId: null, - error: null, - }, - } as V2WorkspaceRecord; + name: `box-${id}`, + machineTypeId: 'cx23@fsn1', + phase: 'ready', + retryAction: null, + canObserve: true, + launchable: true, + revision: 1, + ssh: null, + volumeId: null, + error: null, + role: 'owner', + orgShareRole: null, + owner: { name: 'Owner', avatarUrl: null }, + environment: null, + agentRuleId: null, + connections: [], + }; } const resolver = { - resolve: (wire: V2WorkspaceRecord['wire']) => ({ + resolve: (wire: WorkspaceView) => ({ terminalUrl: `https://box.example/${wire.id}/terminal/?token=discarded#fragment`, acpUrl: `wss://box.example/${wire.id}/acp`, filesBase: `https://box.example/${wire.id}/workspace/`, @@ -42,7 +45,7 @@ describe('workspace endpoint selection', () => { }); it('merges incremental records and prunes stale records only for authoritative refreshes', () => { - const entries = new Map(); + const entries = new Map(); rememberWorkspaceEndpoints(entries, [record('one'), record('two')], resolver); rememberWorkspaceEndpoints(entries, [record('three')], resolver); expect([...entries.keys()]).toEqual(['one', 'two', 'three']); @@ -50,7 +53,6 @@ describe('workspace endpoint selection', () => { rememberWorkspaceEndpoints(entries, [record('two')], resolver, true); expect([...entries.keys()]).toEqual(['two']); expect(entries.get('two')).toMatchObject({ - label: 'box-two', terminalUrl: 'wss://box.example/two/terminal/ws', }); }); diff --git a/packages/webapp/test/workspace-wire.test.ts b/packages/webapp/test/workspace-wire.test.ts new file mode 100644 index 00000000..2ef8f22d --- /dev/null +++ b/packages/webapp/test/workspace-wire.test.ts @@ -0,0 +1,120 @@ +import type { WorkspaceView } from "@blitzos/schema"; +import { describe, expect, it } from "vitest"; +import type { MeResponse } from "../src/api.js"; +import { isTenantMe, meFromWire, type TenantMe } from "../src/protocol.js"; +import { + initialWorkspaceStore, + workspaceReducer, +} from "../src/workspace-store.js"; +import type { UiPreferences } from "../src/storage.js"; + +function workspace(phase: WorkspaceView["phase"], retryAction: WorkspaceView["retryAction"]): WorkspaceView { + return { + id: `workspace-${phase}`, + name: `name-${phase}`, + machineTypeId: "mv-2c2g@lab", + phase, + retryAction, + canObserve: phase === "ready", + launchable: phase === "ready", + revision: 7, + ssh: null, + volumeId: null, + error: phase === "error" ? "provider failed" : null, + role: "owner", + orgShareRole: null, + connections: [], + owner: { name: "Owner", avatarUrl: null }, + environment: null, + agentRuleId: null, + }; +} + +const wireMe: MeResponse = { + user: { + id: "user-one", + email: "person@example.com", + name: "Person", + avatarUrl: null, + platformOperator: false, + }, + membership: { id: "membership-one", role: "admin", status: "active" }, + org: { id: "org-one", slug: "example", name: "Example", vmLimit: 10 }, + organizations: [{ + membership: { id: "membership-one", role: "admin", status: "active" }, + org: { id: "org-one", slug: "example", name: "Example", vmLimit: 10 }, + }], +}; + +const viewer: TenantMe = { + identity: { id: "user-one", email: "person@example.com", name: "Person", avatarUrl: null }, + membership: { id: "membership-one", role: "admin" }, + org: { id: "org-one", slug: "example", name: "Example", vmLimit: 10 }, + organizations: [], +}; + +const preferences: UiPreferences = { + version: 1, + activeWorkspaceId: "", + railWidth: 240, + order: [], + workspaces: {}, +}; + +describe("wire workspace views in the store", () => { + it("maps blitz phases and filters terminal phases", () => { + const state = workspaceReducer(initialWorkspaceStore, { + type: "workspaces_loaded", + records: [ + workspace("ready", null), + workspace("creating", "poll"), + workspace("error", "destroy"), + workspace("destroying", "poll"), + workspace("destroyed", "create"), + ], + viewer, + preferences, + }); + expect(state.workspaces.map(({ id, lifecycleStatus }) => [id, lifecycleStatus])).toEqual([ + ["workspace-ready", "running"], + ["workspace-creating", "creating"], + ["workspace-error", "error"], + ]); + expect(state.workspaces[0]).toMatchObject({ + canControl: true, + machineType: "mv-2c2g@lab", + updatedAt: 7, + }); + expect(state.workspaces[2]).toMatchObject({ + errorDetail: "provider failed", + retryAction: "destroy", + }); + }); + + it("marks role-less views uncontrollable and keeps the server name as title", () => { + const shared: WorkspaceView = { ...workspace("ready", null), role: null }; + const state = workspaceReducer(initialWorkspaceStore, { + type: "workspaces_loaded", + records: [shared], + viewer, + preferences, + }); + expect(state.workspaces[0]).toMatchObject({ + canControl: false, + title: "name-ready", + }); + }); + + it("maps /me to the viewer view and detects tenant membership", () => { + const me = meFromWire(wireMe); + expect(me.identity).toEqual({ + id: "user-one", + email: "person@example.com", + name: "Person", + avatarUrl: null, + platformOperator: false, + }); + expect(isTenantMe(me)).toBe(true); + expect(isTenantMe(meFromWire({ ...wireMe, membership: null, org: null }))).toBe(false); + }); +}); diff --git a/tools/oxlint/blitz-house/rules/allowed-files.ts b/tools/oxlint/blitz-house/rules/allowed-files.ts index d3a1af56..9a56a261 100644 --- a/tools/oxlint/blitz-house/rules/allowed-files.ts +++ b/tools/oxlint/blitz-house/rules/allowed-files.ts @@ -17,12 +17,11 @@ export const allowedFilesSchema = [ ] as const; export function currentFileIsAllowed(context: Context): boolean { - const option = context.options[0]; - if (typeof option !== "object" || option === null || Array.isArray(option)) { - return false; - } - const allowFiles = option.allowFiles; - if (!Array.isArray(allowFiles)) return false; + // SAFETY: before any linting runs, oxlint merges the rules' defaultOptions + // ([{ allowFiles: [] }]) into the config options and AJV-validates the + // result against allowedFilesSchema, aborting the whole run on mismatch — + // so options[0] is always an object whose allowFiles is a string array. + const { allowFiles } = context.options[0] as { allowFiles: string[] }; const filename = relative(context.cwd, context.physicalFilename).replaceAll( "\\", "/", diff --git a/tools/oxlint/blitz-house/rules/global-reference.ts b/tools/oxlint/blitz-house/rules/global-reference.ts index ced30785..ca9c863b 100644 --- a/tools/oxlint/blitz-house/rules/global-reference.ts +++ b/tools/oxlint/blitz-house/rules/global-reference.ts @@ -23,3 +23,32 @@ export function isUnshadowedGlobal( const variable = resolveVariable(sourceCode, expression); return variable === null || variable.defs.length === 0; } + +const GLOBAL_OWNERS = new Set(["globalThis", "self", "window"]); + +/** + * Whether the expression evaluates to the global value `name`: the bare + * unshadowed identifier, or a `.name`/`["name"]` member access on an + * unshadowed `globalThis`, `self`, or `window`. + */ +export function isGlobalValue( + sourceCode: SourceCode, + expression: ESTree.Expression, + name: string, +): boolean { + if (expression.type === "Identifier") { + return isUnshadowedGlobal(sourceCode, expression, name); + } + if (expression.type !== "MemberExpression" || expression.object.type !== "Identifier") { + return false; + } + if ( + !GLOBAL_OWNERS.has(expression.object.name) || + !isUnshadowedGlobal(sourceCode, expression.object, expression.object.name) + ) { + return false; + } + return expression.computed + ? expression.property.type === "Literal" && expression.property.value === name + : expression.property.type === "Identifier" && expression.property.name === name; +} diff --git a/tools/oxlint/blitz-house/rules/no-console-in-core.ts b/tools/oxlint/blitz-house/rules/no-console-in-core.ts index f27e6404..a9288ff2 100644 --- a/tools/oxlint/blitz-house/rules/no-console-in-core.ts +++ b/tools/oxlint/blitz-house/rules/no-console-in-core.ts @@ -1,39 +1,7 @@ import { defineRule } from "@oxlint/plugins"; -import type { ESTree, SourceCode } from "@oxlint/plugins"; - import { allowedFilesSchema, currentFileIsAllowed } from "./allowed-files.ts"; -import { isUnshadowedGlobal } from "./global-reference.ts"; - -const GLOBAL_CONSOLE_OWNERS = new Set(["globalThis", "self", "window"]); - -function isGlobalConsole( - sourceCode: SourceCode, - node: ESTree.Expression, -): boolean { - if (node.type === "Identifier") { - return isUnshadowedGlobal(sourceCode, node, "console"); - } - if (node.type !== "MemberExpression" || node.object.type !== "Identifier") { - return false; - } - if ( - !GLOBAL_CONSOLE_OWNERS.has(node.object.name) || - !isUnshadowedGlobal(sourceCode, node.object, node.object.name) - ) { - return false; - } - return node.computed - ? node.property.type === "Literal" && node.property.value === "console" - : node.property.type === "Identifier" && node.property.name === "console"; -} - -function isConsoleMember( - sourceCode: SourceCode, - node: ESTree.MemberExpression, -): boolean { - return isGlobalConsole(sourceCode, node.object); -} +import { isGlobalValue } from "./global-reference.ts"; /** Keep core logging at explicit structured-logging chokepoints. */ export const noConsoleInCoreRule = defineRule({ @@ -53,7 +21,7 @@ export const noConsoleInCoreRule = defineRule({ if (currentFileIsAllowed(context)) return {}; return { MemberExpression(node) { - if (isConsoleMember(context.sourceCode, node)) { + if (isGlobalValue(context.sourceCode, node.object, "console")) { context.report({ node, messageId: "consoleInCore" }); } }, diff --git a/tools/oxlint/blitz-house/rules/no-raw-fetch.ts b/tools/oxlint/blitz-house/rules/no-raw-fetch.ts index 9701c8e5..aec35245 100644 --- a/tools/oxlint/blitz-house/rules/no-raw-fetch.ts +++ b/tools/oxlint/blitz-house/rules/no-raw-fetch.ts @@ -1,33 +1,7 @@ import { defineRule } from "@oxlint/plugins"; -import type { ESTree, SourceCode } from "@oxlint/plugins"; - import { allowedFilesSchema, currentFileIsAllowed } from "./allowed-files.ts"; -import { isUnshadowedGlobal } from "./global-reference.ts"; - -const GLOBAL_FETCH_OWNERS = new Set(["globalThis", "self", "window"]); - -function isDirectFetchCall( - sourceCode: SourceCode, - node: ESTree.CallExpression, -): boolean { - const callee = node.callee; - if (callee.type === "Identifier") { - return isUnshadowedGlobal(sourceCode, callee, "fetch"); - } - if (callee.type !== "MemberExpression" || callee.object.type !== "Identifier") { - return false; - } - if ( - !GLOBAL_FETCH_OWNERS.has(callee.object.name) || - !isUnshadowedGlobal(sourceCode, callee.object, callee.object.name) - ) { - return false; - } - return callee.computed - ? callee.property.type === "Literal" && callee.property.value === "fetch" - : callee.property.type === "Identifier" && callee.property.name === "fetch"; -} +import { isGlobalValue } from "./global-reference.ts"; /** Require core network requests to pass through the repository's fetch boundary. */ export const noRawFetchRule = defineRule({ @@ -47,7 +21,7 @@ export const noRawFetchRule = defineRule({ if (currentFileIsAllowed(context)) return {}; return { CallExpression(node) { - if (isDirectFetchCall(context.sourceCode, node)) { + if (isGlobalValue(context.sourceCode, node.callee, "fetch")) { context.report({ node, messageId: "rawFetch" }); } },