From d959f94d7b6d7f1d6b1b3bdace620b1ae452615a Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sun, 23 Aug 2026 16:12:50 -0700 Subject: [PATCH 1/6] deslop(box): delete test-only seams, boot-wrapper rechecks, and dead guest code Actor: adapter revalidation catalogs, chat-session test query, turn-output export, refresh-seam params, stdout recheck. Gateway: nil proxy fallbacks, context identity stash, default transport field. Guest scripts: dead tmux helper, duplicate boot guards, silent skel skip. Guest files changed; ships with the next box image. Co-Authored-By: Claude Fable 5 --- packages/box/actor/src/adapters/claude.ts | 48 +++----- packages/box/actor/src/agent-config.ts | 22 ++-- packages/box/actor/src/agent-rules-refresh.ts | 27 ++--- packages/box/actor/src/chat-session.ts | 9 -- packages/box/actor/src/credentials.ts | 6 +- packages/box/actor/test/actor.test.ts | 3 +- .../test/adapter-object-contracts.test.ts | 106 ++++++++++++++---- packages/box/actor/test/agent-config.test.ts | 12 +- .../actor/test/agent-rules-refresh.test.ts | 90 +++++++-------- packages/box/gateway/main.go | 32 ++---- packages/box/gateway/main_test.go | 16 +-- .../rootfs/usr/local/libexec/blitz-init-state | 10 +- .../rootfs/usr/local/libexec/blitz-rules-boot | 22 ++-- .../box/rootfs/usr/local/libexec/blitz-term | 4 - 14 files changed, 196 insertions(+), 211 deletions(-) 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 From 137c010d9e49b640911441bc20bb5587bcf8ea10 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sun, 23 Aug 2026 16:12:57 -0700 Subject: [PATCH 2/6] deslop(go): remove test-shaped seams from broker and microvm-host, prove lock ownership Delete injectable runners, nullable HTTP clients, completion channels, mutable timing knobs, and the dead SyncState mirror; tests move to fake executables on PATH, httptest, and real short durations. Remove the LinuxBackend mutex with a new -race serialization test proving Manager owns every lifecycle path. Replace the microvm enroll timeout/kill stack with the bounded blitz-register wrapper; phone-home wire unchanged. Guest enroll script changed; ships with the next M2 guest rootfs. Co-Authored-By: Claude Fable 5 --- packages/broker/cmd/blitz-broker/main.go | 34 +-- packages/broker/cmd/blitz-broker/main_test.go | 51 ++++ packages/broker/cmd/blitz-cred/main.go | 13 +- packages/broker/internal/broker/deposit.go | 7 +- packages/broker/internal/broker/mint.go | 24 +- .../broker/internal/broker/roaming_test.go | 46 ++-- .../broker/internal/broker/security_test.go | 40 ++-- packages/broker/internal/broker/sync.go | 5 +- .../internal/controlplane/controlplane.go | 14 +- .../controlplane/controlplane_test.go | 14 +- .../broker/internal/controlplane/device.go | 10 +- packages/broker/internal/enroll/enroll.go | 5 +- packages/broker/internal/feed/feed.go | 9 +- packages/broker/internal/vendor/vendor.go | 20 +- packages/broker/internal/workspace/cp.go | 25 +- .../broker/internal/workspace/environment.go | 66 +++--- .../internal/workspace/environment_test.go | 108 +++++---- .../broker/internal/workspace/register.go | 7 +- .../broker/internal/workspace/roaming_test.go | 71 +++--- packages/broker/internal/workspace/watch.go | 18 +- .../internal/workspace/workspace_test.go | 65 +++--- .../guest/blitz-microvm-enroll.js | 64 ++--- packages/microvm-host/linux_backend.go | 74 +++--- packages/microvm-host/linux_backend_test.go | 218 ++++++++++++------ 24 files changed, 541 insertions(+), 467 deletions(-) create mode 100644 packages/broker/cmd/blitz-broker/main_test.go 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/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) From 71cedb904a99786469d29ccf37fde680f08b3e4e Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sun, 23 Aug 2026 16:13:04 -0700 Subject: [PATCH 3/6] deslop(webapp): one client, one transcript pass, one poller, one dialog shell Delete ApiAdapter and consume schema views directly with a single unauthorized chokepoint. Derive the chat transcript in one pass from reducer state. Delete the ignored standalone-ports subsystem, legacy lifecycle variants, tab-rename subsystem, desktop drawer resizer, dead store actions, and test-only seams. Merge preview-focus polling into the sources poller and unify Drive dialogs on ModalOverlay. Share dialogs and invite creation each get one owner. Co-Authored-By: Claude Fable 5 --- packages/webapp/src/CloudApp.tsx | 191 ++++++-------- packages/webapp/src/CreateWorkspaceDialog.tsx | 6 +- packages/webapp/src/FilesSidebar.tsx | 32 +-- packages/webapp/src/MachineCatalogGrid.tsx | 4 - packages/webapp/src/ModalOverlay.tsx | 6 +- packages/webapp/src/SettingsPage.tsx | 2 +- packages/webapp/src/ShareAccessDialog.tsx | 218 ++++++++++++++++ packages/webapp/src/ShareWorkspaceDialog.tsx | 188 +++----------- packages/webapp/src/StandaloneWebApp.tsx | 6 +- packages/webapp/src/TtydTerminal.tsx | 7 +- packages/webapp/src/WebAppHeader.tsx | 107 ++------ .../webapp/src/WorkspaceConnectionsPanel.tsx | 7 - packages/webapp/src/WorkspaceDrawer.tsx | 63 +---- packages/webapp/src/api-adapter.ts | 238 ------------------ packages/webapp/src/chat/ChatPanel.tsx | 11 +- packages/webapp/src/chat/chat-items.ts | 225 ----------------- packages/webapp/src/chat/chat-turn-views.tsx | 3 +- packages/webapp/src/chat/chat-turns.ts | 226 +++++++++++++++-- packages/webapp/src/device-state.ts | 32 --- packages/webapp/src/error-message.ts | 7 + .../webapp/src/files/AttachFolderDialog.tsx | 5 +- packages/webapp/src/files/DriveHome.tsx | 2 +- packages/webapp/src/files/FilesDrive.tsx | 2 +- .../webapp/src/files/ShareFolderDialog.tsx | 224 ++++------------- .../webapp/src/files/ShareToDriveDialog.tsx | 9 +- packages/webapp/src/files/drive-chrome.tsx | 9 +- packages/webapp/src/index.ts | 4 - packages/webapp/src/preview.ts | 15 +- packages/webapp/src/protocol.ts | 82 +++--- packages/webapp/src/resolver.ts | 18 -- packages/webapp/src/settings/MembersPanel.tsx | 35 +-- packages/webapp/src/storage.ts | 33 --- .../webapp/src/terminal-touch-controller.ts | 10 - packages/webapp/src/type-guards.ts | 3 - .../webapp/src/use-workspace-lifecycle.ts | 69 +++-- .../webapp/src/use-workspace-persistence.ts | 12 +- .../webapp/src/use-workspace-preview-focus.ts | 75 ------ .../src/use-workspace-preview-sources.ts | 53 +++- packages/webapp/src/webapp-workspace.css | 12 - packages/webapp/src/workspace-endpoints.ts | 15 +- packages/webapp/src/workspace-store.ts | 172 +++++-------- packages/webapp/test/FilesSidebar.test.tsx | 47 ++-- .../webapp/test/admin-connections.test.tsx | 1 - packages/webapp/test/api-adapter.test.ts | 159 ------------ packages/webapp/test/chat-transcript.test.ts | 108 ++++++++ .../test/credentials-surfaces-v2.test.tsx | 15 +- packages/webapp/test/files-drive.test.tsx | 2 +- .../test/persistence-object-contracts.test.ts | 29 ++- packages/webapp/test/preview-focus.test.ts | 99 ++++---- packages/webapp/test/preview-v2.test.ts | 63 ++--- packages/webapp/test/recipes.test.tsx | 6 +- packages/webapp/test/resolver.test.ts | 21 +- packages/webapp/test/setup.ts | 18 ++ packages/webapp/test/shell-smoke.test.tsx | 37 ++- packages/webapp/test/template-screen.test.tsx | 1 - ...=> use-workspace-preview-sources.test.tsx} | 61 +++-- packages/webapp/test/webapp-surface.test.ts | 2 +- .../webapp/test/workspace-endpoints.test.ts | 42 ++-- packages/webapp/test/workspace-wire.test.ts | 120 +++++++++ 59 files changed, 1311 insertions(+), 1958 deletions(-) create mode 100644 packages/webapp/src/ShareAccessDialog.tsx delete mode 100644 packages/webapp/src/api-adapter.ts delete mode 100644 packages/webapp/src/chat/chat-items.ts delete mode 100644 packages/webapp/src/device-state.ts delete mode 100644 packages/webapp/src/use-workspace-preview-focus.ts delete mode 100644 packages/webapp/test/api-adapter.test.ts create mode 100644 packages/webapp/test/chat-transcript.test.ts rename packages/webapp/test/{use-workspace-preview-focus.test.tsx => use-workspace-preview-sources.test.tsx} (63%) create mode 100644 packages/webapp/test/workspace-wire.test.ts 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); + }); +}); From 1de294f3f5068fef7b3657c7fb52261e5c8798b3 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sun, 23 Aug 2026 16:13:14 -0700 Subject: [PATCH 4/6] deslop(control-plane): derive state from facts, one owner per policy, drop dead subsystems Drop derived-state columns with migrations 0028-0030: boxes.is_broker derives from broker_boxes; invites and credential_leases store revoked_at and derive lifecycle from timestamps, deleting the lease sweep and invite write-on-read. Make invite signup one guarded atomic batch. Move cron policy into core runScheduledMaintenance, used by both entrypoints. Delete the unbound AWS volume implementation, test-only provider seams, the abortable re-race, duplicated destroy transaction, redundant rechecks, mintSession, principalSource plumbing, the rotation catalog field, and the dead workspace cap. One base64url codec lives in crypto.ts. Managed worker template and BLITZDEV schema mirror stay in sync. Co-Authored-By: Claude Fable 5 --- docs/SELF-HOST.md | 1 - packages/control-plane/core/agent-rules.ts | 19 +- packages/control-plane/core/app.ts | 8 +- packages/control-plane/core/bootstrap.ts | 43 ++- .../control-plane/core/compute/aws-sigv4.ts | 16 +- packages/control-plane/core/compute/aws.ts | 157 +-------- .../core/compute/cloudflare-tunnels.ts | 13 +- .../control-plane/core/compute/hetzner.ts | 55 +--- .../core/compute/microvm-host-registry.ts | 14 +- .../control-plane/core/compute/microvm.ts | 31 +- .../control-plane/core/compute/registry.ts | 12 +- packages/control-plane/core/compute/types.ts | 5 +- .../control-plane/core/connections/canary.ts | 4 +- .../core/connections/catalog/discord.ts | 78 +---- .../core/connections/catalog/generic.ts | 10 +- .../core/connections/catalog/github.ts | 71 +---- .../connections/catalog/google-workspace.ts | 71 +---- .../core/connections/catalog/index.ts | 1 - .../core/connections/catalog/linear.ts | 73 +---- .../core/connections/catalog/surfaces.ts | 15 +- .../core/connections/catalog/types.ts | 70 +--- .../core/connections/catalog/youtrack.ts | 22 +- .../control-plane/core/connections/connect.ts | 19 +- .../control-plane/core/connections/leases.ts | 100 +++--- .../control-plane/core/connections/mint.ts | 15 +- .../core/connections/minters/oauth.ts | 8 +- .../control-plane/core/connections/proxy.ts | 7 +- .../core/connections/registry.ts | 10 - .../control-plane/core/connections/types.ts | 13 +- .../core/connections/user-grants.ts | 4 +- packages/control-plane/core/crypto.ts | 24 +- .../control-plane/core/files/attachments.ts | 3 +- packages/control-plane/core/files/dav.ts | 4 +- packages/control-plane/core/files/schedule.ts | 15 +- packages/control-plane/core/files/sync.ts | 17 +- .../control-plane/core/identity/google.ts | 301 +++++++++--------- .../control-plane/core/identity/invites.ts | 171 +++++++--- .../control-plane/core/identity/members.ts | 16 +- packages/control-plane/core/index.ts | 11 +- packages/control-plane/core/janitors.ts | 91 +++--- packages/control-plane/core/oauth-state.ts | 22 +- packages/control-plane/core/oauth.ts | 10 +- packages/control-plane/core/principals.ts | 26 +- packages/control-plane/core/recipes.ts | 6 +- packages/control-plane/core/registry.ts | 36 +-- packages/control-plane/core/runtime.ts | 11 - packages/control-plane/core/webapp-tickets.ts | 19 +- .../control-plane/core/workspace-access.ts | 12 +- .../control-plane/core/workspace-tunnels.ts | 7 +- packages/control-plane/core/workspaces.ts | 63 ++-- .../migrations/0028_drop_boxes_is_broker.sql | 4 + .../migrations/0029_drop_invites_state.sql | 19 ++ .../0030_drop_credential_leases_state.sql | 33 ++ .../control-plane/scripts/deploy-helpers.mjs | 49 +-- .../control-plane/scripts/lib/managed-api.mjs | 62 ++-- .../scripts/lib/worker-source.mjs | 37 +-- packages/control-plane/src/worker.ts | 130 +++----- packages/control-plane/test/assets.test.ts | 10 +- .../control-plane/test/aws-provider.test.ts | 220 ++----------- packages/control-plane/test/aws-sigv4.test.ts | 1 - .../test/blitzdev-emitter.test.ts | 8 +- .../test/blitzdev-schema.test.ts | 14 +- packages/control-plane/test/bootstrap.test.ts | 4 +- .../test/connections-catalog-fixtures.ts | 291 +++++++++++++++++ .../test/connections-catalog.test.ts | 58 +++- .../control-plane/test/connections.test.ts | 65 ++-- .../control-plane/test/control-plane.test.ts | 11 +- .../test/dav-listing-fixtures.test.ts | 2 +- packages/control-plane/test/deploy.test.ts | 57 +++- .../control-plane/test/error-envelope.test.ts | 20 +- packages/control-plane/test/file-sync.test.ts | 52 ++- packages/control-plane/test/helpers.ts | 36 ++- .../test/identity-phase2.test.ts | 41 ++- .../test/identity-signup.test.ts | 46 ++- packages/control-plane/test/identity.test.ts | 4 +- .../control-plane/test/managed-api.test.ts | 36 ++- .../test/microvm-provider.test.ts | 58 ++-- .../control-plane/test/usage-push.test.ts | 2 +- .../test/vm-provider-registry.test.ts | 1 - .../control-plane/test/wire-drift.test.ts | 1 - .../test/workspace-environment.test.ts | 4 +- .../test/workspace-tunnels.test.ts | 25 +- packages/control-plane/vitest.config.ts | 1 - packages/control-plane/wrangler.toml.example | 2 - packages/schema/src/credential.ts | 1 - 85 files changed, 1535 insertions(+), 1700 deletions(-) create mode 100644 packages/control-plane/migrations/0028_drop_boxes_is_broker.sql create mode 100644 packages/control-plane/migrations/0029_drop_invites_state.sql create mode 100644 packages/control-plane/migrations/0030_drop_credential_leases_state.sql create mode 100644 packages/control-plane/test/connections-catalog-fixtures.ts 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/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/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; From bb498f8061d4f258d78f0294a3dc79f7a8b67433 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sun, 23 Aug 2026 16:13:14 -0700 Subject: [PATCH 5/6] chore(gates): ratchet lint baseline down and refresh CLAUDE.md reference counts no-runtime-typeof 27 to 25, no-unknown-parameters 52 to 51; anti-slop total 105. Known-debt list now names the six files over the 700-line warn, matching lint:gate output. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 23 +++++++++++++---------- lint-baseline.json | 4 ++-- 2 files changed, 15 insertions(+), 12 deletions(-) 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/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, From 45fe144e7a81c7e5642b2733b9090587b909a10b Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sun, 23 Aug 2026 16:13:26 -0700 Subject: [PATCH 6/6] deslop(lint-house): one shared global-member recognizer, drop schema-covered option rechecks no-console-in-core and no-raw-fetch share isGlobalValue in global-reference.ts. allowed-files reads options oxlint already validated against the rule schema; the SAFETY comment names that invariant. Vendored anti-slop untouched. Co-Authored-By: Claude Fable 5 --- .../oxlint/blitz-house/rules/allowed-files.ts | 11 +++--- .../blitz-house/rules/global-reference.ts | 29 +++++++++++++++ .../blitz-house/rules/no-console-in-core.ts | 36 ++----------------- .../oxlint/blitz-house/rules/no-raw-fetch.ts | 30 ++-------------- 4 files changed, 38 insertions(+), 68 deletions(-) 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" }); } },