From fb7fe0dc7cba61ce3f8c0a213249b6feb5da8640 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 10:37:26 +0100 Subject: [PATCH 001/109] refactor: centralize URL security policy --- CHANGELOG.md | 1 + src/fleet-state.ts | 32 ++---------- src/oauth.ts | 11 +--- src/runtime-adapter.ts | 51 ++---------------- src/trusted-proxy-auth.ts | 36 ++----------- src/url-security.ts | 103 +++++++++++++++++++++++++++++++++---- tests/fleet-state.test.ts | 4 ++ tests/url-security.test.ts | 69 ++++++++++++++++++++++++- tsconfig.json | 1 + 9 files changed, 182 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a76917..c2aba42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Centralize secure URL, origin, and literal-loopback validation across OAuth, trusted proxy, runtime adapter, and Fleet routing. - De-duplicate the Go CLI and SSH gateway around shared control-plane models, authentication, lifecycle semantics, API calls, terminal operations, and terminal-safe session rendering. - Unify managed terminal clients on the multiplex `/api/terminal/ws` protocol, remove direct PTY routes, and share one framed Go transport across the CLI and SSH gateway. - Connect Crabfleet lifecycle and terminal traffic to Crabbox through a Cloudflare service binding and deploy an identical route-scoped credential atomically across both coordinators. diff --git a/src/fleet-state.ts b/src/fleet-state.ts index ce115a1..aadb977 100644 --- a/src/fleet-state.ts +++ b/src/fleet-state.ts @@ -1,3 +1,5 @@ +import { normalizedSecureHttpUrl, normalizedSecureWebSocketUrl } from "./url-security.ts"; + export type FleetStatus = | "provisioning" | "pending_adapter" @@ -341,35 +343,9 @@ function configuredBridgeWebSocketUrl(value: string | null | undefined): string } function safePtyWebSocketUrl(value: string | null | undefined): string | null { - return safePtyUrl(value, "wss:", "ws:"); + return normalizedSecureWebSocketUrl(value); } function safePtyHttpUrl(value: string | null | undefined): string | null { - return safePtyUrl(value, "https:", "http:"); -} - -function safePtyUrl( - value: string | null | undefined, - secureProtocol: string, - loopbackProtocol: string, -): string | null { - if (!value) return null; - try { - const url = new URL(value); - if (url.username || url.password) return null; - if (url.protocol === secureProtocol) return url.toString(); - if (url.protocol !== loopbackProtocol || !isPtyLoopbackHostname(url.hostname)) return null; - return url.toString(); - } catch { - return null; - } -} - -function isPtyLoopbackHostname(hostname: string): boolean { - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname === "[::1]" - ); + return normalizedSecureHttpUrl(value); } diff --git a/src/oauth.ts b/src/oauth.ts index abd1dd6..b9a0243 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,3 +1,5 @@ +import { isLiteralLoopbackHostname } from "./url-security.ts"; + export const githubOAuthCallbackPath = "/auth/github/callback"; export const githubOAuthLoginPath = "/login/github"; @@ -108,12 +110,3 @@ function configuredGitHubOAuthRedirectUri(configured: string): string { } return `${url.origin}${githubOAuthCallbackPath}`; } - -function isLiteralLoopbackHostname(hostname: string): boolean { - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname === "[::1]" - ); -} diff --git a/src/runtime-adapter.ts b/src/runtime-adapter.ts index 0a9549c..c574e57 100644 --- a/src/runtime-adapter.ts +++ b/src/runtime-adapter.ts @@ -1,3 +1,5 @@ +import { exactSecureHttpUrl, exactSecureWebSocketUrl } from "./url-security.ts"; + export const runtimeAdapterVersion = "crabfleet/v1"; export const runtimeAdapterName = "runtime-v1"; export const runtimeAdapterDesktopMaxTtlMs = 15 * 60 * 1000; @@ -660,47 +662,11 @@ export function currentAdapterDesktopConnection( } export function safeDesktopUrl(value: unknown): string | null { - const raw = exactUrlString(value); - if (!raw) return null; - try { - const url = new URL(raw); - if (url.username || url.password) return null; - if (url.protocol === "https:") return raw; - if (url.protocol !== "http:" || !isExactLiteralLoopbackUrl(raw, url)) return null; - return raw; - } catch { - return null; - } + return exactSecureHttpUrl(value); } export function safeWebSocketUrl(value: unknown): string | null { - const raw = exactUrlString(value); - if (!raw) return null; - try { - const url = new URL(raw); - if (url.username || url.password) return null; - if (url.protocol === "wss:") return raw; - if (url.protocol !== "ws:" || !isExactLiteralLoopbackUrl(raw, url)) return null; - return raw; - } catch { - return null; - } -} - -function exactUrlString(value: unknown): string | null { - if (typeof value !== "string" || !value) return null; - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - if (code <= 0x20 || (code >= 0x7f && code <= 0x9f)) return null; - } - return value; -} - -function isExactLiteralLoopbackUrl(raw: string, parsed: URL): boolean { - if (!isLiteralLoopbackHostname(parsed.hostname)) return false; - return /^[a-z][a-z0-9+.-]*:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?(?:[/?#]|$)/iu.test( - raw, - ); + return exactSecureWebSocketUrl(value); } function adapterStatus(value: unknown): AdapterSessionStatus | null { @@ -935,12 +901,3 @@ function escapedConnectionRepresentations(connection: string): string[] { } return [...representations].sort((left, right) => right.length - left.length); } - -function isLiteralLoopbackHostname(hostname: string): boolean { - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname === "[::1]" - ); -} diff --git a/src/trusted-proxy-auth.ts b/src/trusted-proxy-auth.ts index 3b6ae09..5360992 100644 --- a/src/trusted-proxy-auth.ts +++ b/src/trusted-proxy-auth.ts @@ -1,3 +1,5 @@ +import { strictSecureHttpOrigin } from "./url-security.ts"; + export const trustedProxySecretHeader = "x-crabfleet-proxy-secret"; const defaultTrustedUserHeader = "x-authenticated-user"; const headerNamePattern = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; @@ -127,43 +129,13 @@ function trustedProxyConfig(env: TrustedProxyEnv): TrustedProxyConfig { ) { return { kind: "disabled" }; } - const origin = strictHttpsOrigin(originValue); - const publicOrigin = publicOriginValue ? strictHttpsOrigin(publicOriginValue) : origin; + const origin = strictSecureHttpOrigin(originValue); + const publicOrigin = publicOriginValue ? strictSecureHttpOrigin(publicOriginValue) : origin; const userHeader = trustedUserHeader(env); if (!origin || !publicOrigin || !secret || !userHeader) return { kind: "invalid" }; return { kind: "configured", origin, publicOrigin, secret, userHeader }; } -function strictHttpsOrigin(value: string | undefined): string | null { - if (!value || value !== value.trim()) return null; - try { - const url = new URL(value); - if ( - url.username || - url.password || - url.pathname !== "/" || - url.search || - url.hash || - (url.protocol !== "https:" && - !(url.protocol === "http:" && isLiteralLoopbackHostname(url.hostname.toLowerCase()))) - ) { - return null; - } - return url.origin; - } catch { - return null; - } -} - -function isLiteralLoopbackHostname(hostname: string): boolean { - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname === "[::1]" - ); -} - function safeRequestOrigin(value: string): string | null { try { return new URL(value).origin; diff --git a/src/url-security.ts b/src/url-security.ts index 9fffcdc..cd77cf3 100644 --- a/src/url-security.ts +++ b/src/url-security.ts @@ -1,16 +1,64 @@ +type ProtocolPair = { + secure: "https:" | "wss:"; + loopback: "http:" | "ws:"; +}; + +const httpProtocols: ProtocolPair = { secure: "https:", loopback: "http:" }; +const webSocketProtocols: ProtocolPair = { secure: "wss:", loopback: "ws:" }; +const exactLoopbackAuthorityPattern = + /^[a-z][a-z0-9+.-]*:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?(?:[/?#]|$)/iu; + export function isLiteralLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname === "[::1]" + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "[::1]" ); } +export function exactSecureHttpUrl(value: unknown): string | null { + return secureOrLoopbackUrl(value, httpProtocols, true); +} + +export function exactSecureWebSocketUrl(value: unknown): string | null { + return secureOrLoopbackUrl(value, webSocketProtocols, true); +} + +export function normalizedSecureHttpUrl(value: unknown): string | null { + return secureOrLoopbackUrl(value, httpProtocols, false); +} + +export function normalizedSecureWebSocketUrl(value: unknown): string | null { + return secureOrLoopbackUrl(value, webSocketProtocols, false); +} + +export function strictSecureHttpOrigin(value: string | undefined): string | null { + const raw = exactUrlString(value); + if (!raw) return null; + try { + const url = new URL(raw); + if ( + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + !usesSecureOrLoopbackProtocol(raw, url, httpProtocols) + ) { + return null; + } + return url.origin; + } catch { + return null; + } +} + export function developmentIdentityEnabled(value: string | undefined, requestUrl: string): boolean { if (value !== "true") return false; try { - return isLiteralLoopbackHostname(new URL(requestUrl).hostname.toLowerCase()); + return isLiteralLoopbackHostname(new URL(requestUrl).hostname); } catch { return false; } @@ -23,11 +71,48 @@ export function configuredHttpOrigin(value: string | undefined, fallback: string if (!candidate) return fallback; try { const url = new URL(candidate); - if (url.username || url.password) return fallback; - if (url.protocol === "https:") return url.origin; - if (url.protocol === "http:" && isLiteralLoopbackHostname(url.hostname)) return url.origin; - return fallback; + return url.username || + url.password || + !usesSecureOrLoopbackProtocol(candidate, url, httpProtocols) + ? fallback + : url.origin; } catch { return fallback; } } + +function secureOrLoopbackUrl( + value: unknown, + protocols: ProtocolPair, + preserveExact: boolean, +): string | null { + const raw = exactUrlString(value); + if (!raw) return null; + try { + const url = new URL(raw); + if (url.username || url.password || !usesSecureOrLoopbackProtocol(raw, url, protocols)) { + return null; + } + return preserveExact ? raw : url.toString(); + } catch { + return null; + } +} + +function usesSecureOrLoopbackProtocol(raw: string, url: URL, protocols: ProtocolPair): boolean { + if (url.protocol === protocols.secure) return true; + return ( + url.protocol === protocols.loopback && + isLiteralLoopbackHostname(url.hostname) && + exactLoopbackAuthorityPattern.test(raw) + ); +} + +function exactUrlString(value: unknown): string | null { + if (typeof value !== "string" || !value) return null; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x20 || (code >= 0x7f && code <= 0x9f)) return null; + } + return value; +} diff --git a/tests/fleet-state.test.ts b/tests/fleet-state.test.ts index 7db3d69..16b8c99 100644 --- a/tests/fleet-state.test.ts +++ b/tests/fleet-state.test.ts @@ -240,6 +240,10 @@ test("fleet attachability follows resolvable PTY routes", () => { summary({ ...baseSession, leaseId: null, attachUrl: "ws://127.0.0.1:9000/session" }), true, ); + assert.equal( + summary({ ...baseSession, leaseId: null, attachUrl: "ws://127.1:9000/session" }), + false, + ); assert.equal( summary( { ...baseSession, leaseId: "cloudflare:workspace-1", attachUrl: null }, diff --git a/tests/url-security.test.ts b/tests/url-security.test.ts index f7b94f0..05a412d 100644 --- a/tests/url-security.test.ts +++ b/tests/url-security.test.ts @@ -1,7 +1,74 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { configuredHttpOrigin, developmentIdentityEnabled } from "../src/url-security.ts"; +import { + configuredHttpOrigin, + developmentIdentityEnabled, + exactSecureHttpUrl, + exactSecureWebSocketUrl, + normalizedSecureHttpUrl, + normalizedSecureWebSocketUrl, + strictSecureHttpOrigin, +} from "../src/url-security.ts"; + +test("secure URL validators share exact loopback and credential rules", () => { + const cases = [ + ["https://service.example/path", true], + ["http://localhost:8787/path", true], + ["http://127.0.0.1:8787/path", true], + ["http://[::1]:8787/path", true], + ["http://service.example/path", false], + ["http://127.1:8787/path", false], + ["http://2130706433:8787/path", false], + ["https://user:secret@service.example/path", false], + [" https://service.example/path", false], + ["https://service.example/path\n", false], + ] as const; + + for (const [value, accepted] of cases) { + assert.equal(exactSecureHttpUrl(value) !== null, accepted, value); + assert.equal(normalizedSecureHttpUrl(value) !== null, accepted, value); + } + + const signed = "https://Service.Example:443/%7Epath?signature=a%2Bb%2Fc%3D&dup=1&dup=2"; + assert.equal(exactSecureHttpUrl(signed), signed); + assert.equal( + normalizedSecureHttpUrl(signed), + "https://service.example/%7Epath?signature=a%2Bb%2Fc%3D&dup=1&dup=2", + ); +}); + +test("WebSocket URL validators require WSS except exact literal loopback WS", () => { + for (const [value, accepted] of [ + ["wss://terminal.example/path", true], + ["ws://localhost:8787/path", true], + ["ws://127.0.0.1:8787/path", true], + ["ws://[::1]:8787/path", true], + ["ws://terminal.example/path", false], + ["ws://127.1:8787/path", false], + ["wss://user:secret@terminal.example/path", false], + ] as const) { + assert.equal(exactSecureWebSocketUrl(value) !== null, accepted, value); + assert.equal(normalizedSecureWebSocketUrl(value) !== null, accepted, value); + } +}); + +test("strict origins reject paths, credentials, queries, fragments, and inexact input", () => { + assert.equal(strictSecureHttpOrigin("https://fleet.example"), "https://fleet.example"); + assert.equal(strictSecureHttpOrigin("https://fleet.example/"), "https://fleet.example"); + assert.equal(strictSecureHttpOrigin("http://localhost:8787"), "http://localhost:8787"); + for (const value of [ + " https://fleet.example", + "https://fleet.example/path", + "https://fleet.example?query=1", + "https://fleet.example#fragment", + "https://user@fleet.example", + "http://fleet.example", + "http://127.1:8787", + ]) { + assert.equal(strictSecureHttpOrigin(value), null, value); + } +}); test("configured origins require HTTPS except literal loopback HTTP", () => { const fallback = "https://fleet.example"; diff --git a/tsconfig.json b/tsconfig.json index 9a59dc5..50f0445 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, "lib": ["ES2022"], "types": ["@cloudflare/workers-types"], "strict": true, From 09f9008a4dd2fe823f066a1131ead7d78981d282 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 10:44:02 +0100 Subject: [PATCH 002/109] refactor: extract Worker deployment foundations --- CHANGELOG.md | 1 + src/index.ts | 154 +++------------------------------ src/worker/deployment.ts | 112 ++++++++++++++++++++++++ src/worker/env.ts | 65 ++++++++++++++ tests/deployment.test.ts | 96 ++++++++++++++++++++ tests/runtime-adapter.test.ts | 43 ++++++--- tests/runtime-profiles.test.ts | 32 +++++-- 7 files changed, 341 insertions(+), 162 deletions(-) create mode 100644 src/worker/deployment.ts create mode 100644 src/worker/env.ts create mode 100644 tests/deployment.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c2aba42..17d4bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Extract Worker environment and deployment/profile policy into testable foundation modules, replacing source inspection with behavioral coverage for public and client configuration. - Centralize secure URL, origin, and literal-loopback validation across OAuth, trusted proxy, runtime adapter, and Fleet routing. - De-duplicate the Go CLI and SSH gateway around shared control-plane models, authentication, lifecycle semantics, API calls, terminal operations, and terminal-safe session rendering. - Unify managed terminal clients on the multiplex `/api/terminal/ws` protocol, remove direct PTY routes, and share one framed Go transport across the CLI and SSH gateway. diff --git a/src/index.ts b/src/index.ts index 0104a6a..386e676 100644 --- a/src/index.ts +++ b/src/index.ts @@ -124,7 +124,7 @@ import { type AdapterWorkspaceResult, } from "./runtime-adapter"; import { allocateInteractiveSessionIdSql, formatInteractiveSessionId } from "./session-id"; -import { configuredHttpOrigin, developmentIdentityEnabled } from "./url-security"; +import { developmentIdentityEnabled } from "./url-security"; import { D1Connection } from "./d1-execution"; import { preferredEnabledRepo } from "./repo-selection"; import { sandboxGitAuthorEmail } from "./git-identity"; @@ -162,99 +162,26 @@ import { type CredentialPolicyLegacyMigration, } from "./credential-policy-fence"; import { - parseRuntimeProfiles, resolveRuntimeProfileCodexSsh, runtimeProfileByID, runtimeProfileCapabilities, type ResolvedRuntimeProfileCodexSsh, - type RuntimeProfileDescriptor, } from "./runtime-profiles"; +import { + browserAppOrigin, + clientDeploymentConfig, + defaultPreferredRepo, + deploymentConfig, + publicDeploymentConfig, + selectedRuntimeProfile, +} from "./worker/deployment"; +import type { RuntimeEnv } from "./worker/env"; type Role = "viewer" | "maintainer" | "owner"; type InteractiveRuntime = "crabbox" | "container" | "github_actions"; const defaultInteractiveCommand = "codex --yolo"; -type DeploymentConfig = { - label: string; - canonicalUrl: string; - productUrl: string; - sshHost: string; - preferredRepo: string; - defaultRuntime: "crabbox" | "container"; - defaultProfile: string; - runtimeProfiles: RuntimeProfileDescriptor[]; -}; - -type PublicDeploymentConfig = Pick< - DeploymentConfig, - "label" | "canonicalUrl" | "productUrl" | "sshHost" ->; - -type RuntimeEnv = Env & { - DB: D1Database; - BACKUP_BUCKET?: R2Bucket; - SESSION_LOGS?: R2Bucket; - SANDBOX?: DurableObjectNamespace; - SESSION_CONTROL?: DurableObjectNamespace; - CRABBOX_BOOTSTRAP_TOKEN?: string; - GITHUB_CLIENT_ID?: string; - GITHUB_CLIENT_SECRET?: string; - GITHUB_REDIRECT_URI?: string; - GITHUB_TOKEN?: string; - GITHUB_ORG?: string; - CRABBOX_INTERACTIVE_PROVISION_URL?: string; - CRABBOX_INTERACTIVE_PROVISION_TOKEN?: string; - CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS?: string; - CRABBOX_RUNTIME_PROVISION_URL?: string; - CRABBOX_RUNTIME_PROVISION_TOKEN?: string; - CRABBOX_RUNTIME_ADAPTER_URL?: string; - CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE?: string; - CRABBOX_RUNTIME_ADAPTER_TOKEN?: string; - CRABBOX_RUNTIME_ADAPTER_NAMESPACE?: string; - CRABBOX_RUNTIME_ADAPTER_TTL_SECONDS?: string; - CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS?: string; - CRABBOX_COORDINATOR?: Fetcher; - CRABBOX_COORDINATOR_ORIGIN?: string; - CRABBOX_CLOUDFLARE_RUNNER_URL?: string; - CRABBOX_CLOUDFLARE_RUNNER_TOKEN?: string; - CRABBOX_CLOUDFLARE_RUNNER_INSTANCE_TYPE?: string; - CRABBOX_CLOUDFLARE_RUNNER_WORKDIR?: string; - CRABBOX_CLOUDFLARE_RUNNER_TTL_SECONDS?: string; - CRABBOX_CLOUDFLARE_RUNNER_IDLE_SECONDS?: string; - CRABBOX_PTY_BRIDGE_URL?: string; - CRABBOX_PTY_BRIDGE_TOKEN?: string; - CRABBOX_CLAWFLEET_URL?: string; - CRABBOX_CLAWFLEET_TOKEN?: string; - CRABBOX_CLAWFLEET_PUBLIC_URL?: string; - CRABBOX_SSH_GATEWAY_TOKEN?: string; - CRABFLEET_SSH_GATEWAY_TOKEN?: string; - CRABBOX_OPENCLAW_TOKEN?: string; - CRABBOX_MULTICODEX_TOKEN?: string; - CRABBOX_TOKEN_ENCRYPTION_KEY?: string; - BACKUP_BUCKET_NAME?: string; - CLOUDFLARE_ACCOUNT_ID?: string; - CRABFLEET_LOCAL_SANDBOX_BACKUPS?: string; - CRABFLEET_LABEL?: string; - CRABFLEET_CANONICAL_URL?: string; - CRABFLEET_PRODUCT_URL?: string; - CRABFLEET_SSH_HOST?: string; - CRABFLEET_PREFERRED_REPO?: string; - CRABFLEET_DEFAULT_RUNTIME?: string; - CRABFLEET_DEFAULT_PROFILE?: string; - CRABFLEET_RUNTIME_PROFILES_JSON?: string; - CRABFLEET_DEV_LOGIN_ENABLED?: string; - CRABFLEET_TRUSTED_PROXY_ORIGIN?: string; - CRABFLEET_TRUSTED_PROXY_PUBLIC_ORIGIN?: string; - CRABFLEET_TRUSTED_USER_HEADER?: string; - CRABFLEET_TRUSTED_PROXY_SECRET?: string; - OPENAI_API_KEY?: string; - OPENAI_BASE_URL?: string; - OPENAI_ORG_ID?: string; - R2_ACCESS_KEY_ID?: string; - R2_SECRET_ACCESS_KEY?: string; -}; - const sandboxPlaceholderOpenAIKey = "crabfleet-worker-injected"; const sandboxPlaceholderGitHubToken = "crabfleet-worker-injected"; @@ -1044,7 +971,6 @@ const githubSessionSeconds = 60 * 15; const sshLinkSeconds = 5 * 60; const terminalClipboardMaxBytes = 10 * 1024 * 1024; const lanes = ["Todo", "Running", "Human Review", "Done"]; -const preferredRepo = "openclaw/crabfleet"; const sandboxLeasePrefix = "sandbox:"; const sandboxLeaseProfile = "autostart-v4"; const activeRunStatuses: readonly RunStatus[] = ["queued", "leasing", "running"]; @@ -1104,62 +1030,6 @@ function runtimeAdapterCreateSettings( }; } -function deploymentConfig(env: RuntimeEnv): DeploymentConfig { - const defaultProfile = clean(env.CRABFLEET_DEFAULT_PROFILE, 120) || "default"; - const runtimeProfiles = parseRuntimeProfiles(env.CRABFLEET_RUNTIME_PROFILES_JSON); - if (runtimeProfiles.length > 0 && !runtimeProfileByID(runtimeProfiles, defaultProfile)) { - throw new TypeError("CRABFLEET_DEFAULT_PROFILE must name a configured runtime profile"); - } - return { - label: clean(env.CRABFLEET_LABEL, 80) || "Crabfleet", - canonicalUrl: configuredHttpOrigin(env.CRABFLEET_CANONICAL_URL, appCanonicalOrigin), - productUrl: configuredHttpOrigin(env.CRABFLEET_PRODUCT_URL, "https://crabfleet.ai"), - sshHost: clean(env.CRABFLEET_SSH_HOST, 240) || "crabd.sh", - preferredRepo: normalizeRepo(env.CRABFLEET_PREFERRED_REPO) || preferredRepo, - defaultRuntime: oneOf( - env.CRABFLEET_DEFAULT_RUNTIME, - ["crabbox", "container"] as const, - "container", - ), - defaultProfile, - runtimeProfiles, - }; -} - -function selectedRuntimeProfile( - deployment: DeploymentConfig, - value: unknown, -): { profile: string; descriptor: RuntimeProfileDescriptor | undefined } { - const profile = clean(value, 120) || deployment.defaultProfile; - const descriptor = runtimeProfileByID(deployment.runtimeProfiles, profile); - if (deployment.runtimeProfiles.length > 0 && !descriptor) { - throw badRequest("profile is not configured"); - } - return { profile, descriptor }; -} - -function publicDeploymentConfig(env: RuntimeEnv): PublicDeploymentConfig { - const { label, canonicalUrl, productUrl, sshHost } = deploymentConfig(env); - return { - label, - canonicalUrl: trustedProxyPublicOrigin(env) ?? canonicalUrl, - productUrl, - sshHost, - }; -} - -function clientDeploymentConfig(env: RuntimeEnv): DeploymentConfig { - const config = deploymentConfig(env); - return { - ...config, - runtimeProfiles: config.runtimeProfiles.map(({ codexSsh: _serverOnly, ...profile }) => profile), - }; -} - -function browserAppOrigin(env: RuntimeEnv): string { - return trustedProxyPublicOrigin(env) ?? deploymentConfig(env).canonicalUrl; -} - class D1Dialect implements Dialect { constructor(private readonly d1: D1Database) {} @@ -16525,11 +16395,11 @@ function numberSetting(value: string | undefined, fallback: number): number { return Number.isFinite(parsed) ? parsed : fallback; } -function sortRepos(repos: string[], preferred = preferredRepo): string[] { +function sortRepos(repos: string[], preferred = defaultPreferredRepo): string[] { return [...repos].sort((left, right) => sortRepoNames(left, right, preferred)); } -function sortRepoNames(left: string, right: string, preferred = preferredRepo): number { +function sortRepoNames(left: string, right: string, preferred = defaultPreferredRepo): number { if (left === preferred) return -1; if (right === preferred) return 1; return left.localeCompare(right); diff --git a/src/worker/deployment.ts b/src/worker/deployment.ts new file mode 100644 index 0000000..10321a5 --- /dev/null +++ b/src/worker/deployment.ts @@ -0,0 +1,112 @@ +import { appCanonicalOrigin } from "../canonical-host.ts"; +import { + parseRuntimeProfiles, + runtimeProfileByID, + type RuntimeProfileDescriptor, +} from "../runtime-profiles.ts"; +import { trustedProxyPublicOrigin, type TrustedProxyEnv } from "../trusted-proxy-auth.ts"; +import { configuredHttpOrigin } from "../url-security.ts"; + +export const defaultPreferredRepo = "openclaw/crabfleet"; + +export type DeploymentConfig = { + label: string; + canonicalUrl: string; + productUrl: string; + sshHost: string; + preferredRepo: string; + defaultRuntime: "crabbox" | "container"; + defaultProfile: string; + runtimeProfiles: RuntimeProfileDescriptor[]; +}; + +export type PublicDeploymentConfig = Pick< + DeploymentConfig, + "label" | "canonicalUrl" | "productUrl" | "sshHost" +>; + +export type DeploymentEnv = TrustedProxyEnv & { + CRABFLEET_LABEL?: string; + CRABFLEET_CANONICAL_URL?: string; + CRABFLEET_PRODUCT_URL?: string; + CRABFLEET_SSH_HOST?: string; + CRABFLEET_PREFERRED_REPO?: string; + CRABFLEET_DEFAULT_RUNTIME?: string; + CRABFLEET_DEFAULT_PROFILE?: string; + CRABFLEET_RUNTIME_PROFILES_JSON?: string; +}; + +export function deploymentConfig(env: DeploymentEnv): DeploymentConfig { + const defaultProfile = clean(env.CRABFLEET_DEFAULT_PROFILE, 120) || "default"; + const runtimeProfiles = parseRuntimeProfiles(env.CRABFLEET_RUNTIME_PROFILES_JSON); + if (runtimeProfiles.length > 0 && !runtimeProfileByID(runtimeProfiles, defaultProfile)) { + throw new TypeError("CRABFLEET_DEFAULT_PROFILE must name a configured runtime profile"); + } + return { + label: clean(env.CRABFLEET_LABEL, 80) || "Crabfleet", + canonicalUrl: configuredHttpOrigin(env.CRABFLEET_CANONICAL_URL, appCanonicalOrigin), + productUrl: configuredHttpOrigin(env.CRABFLEET_PRODUCT_URL, "https://crabfleet.ai"), + sshHost: clean(env.CRABFLEET_SSH_HOST, 240) || "crabd.sh", + preferredRepo: normalizeRepo(env.CRABFLEET_PREFERRED_REPO) || defaultPreferredRepo, + defaultRuntime: oneOf( + env.CRABFLEET_DEFAULT_RUNTIME, + ["crabbox", "container"] as const, + "container", + ), + defaultProfile, + runtimeProfiles, + }; +} + +export function selectedRuntimeProfile( + deployment: DeploymentConfig, + value: unknown, +): { profile: string; descriptor: RuntimeProfileDescriptor | undefined } { + const profile = clean(value, 120) || deployment.defaultProfile; + const descriptor = runtimeProfileByID(deployment.runtimeProfiles, profile); + if (deployment.runtimeProfiles.length > 0 && !descriptor) { + throw Object.assign(new Error("profile is not configured"), { status: 400 }); + } + return { profile, descriptor }; +} + +export function publicDeploymentConfig(env: DeploymentEnv): PublicDeploymentConfig { + const { label, canonicalUrl, productUrl, sshHost } = deploymentConfig(env); + return { + label, + canonicalUrl: trustedProxyPublicOrigin(env) ?? canonicalUrl, + productUrl, + sshHost, + }; +} + +export function clientDeploymentConfig(env: DeploymentEnv): DeploymentConfig { + const config = deploymentConfig(env); + return { + ...config, + runtimeProfiles: config.runtimeProfiles.map(({ codexSsh: _serverOnly, ...profile }) => profile), + }; +} + +export function browserAppOrigin(env: DeploymentEnv): string { + return trustedProxyPublicOrigin(env) ?? deploymentConfig(env).canonicalUrl; +} + +function normalizeRepo(value: unknown): string { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/^https:\/\/github\.com\//, "") + .replace(/\.git$/, "") + .replace(/\/+$/, ""); +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} + +function oneOf(value: unknown, options: readonly T[], fallback: T): T { + return options.includes(value as T) ? (value as T) : fallback; +} diff --git a/src/worker/env.ts b/src/worker/env.ts new file mode 100644 index 0000000..c00d295 --- /dev/null +++ b/src/worker/env.ts @@ -0,0 +1,65 @@ +import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox"; + +export type RuntimeEnv = Env & { + DB: D1Database; + BACKUP_BUCKET?: R2Bucket; + SESSION_LOGS?: R2Bucket; + SANDBOX?: DurableObjectNamespace; + SESSION_CONTROL?: DurableObjectNamespace; + CRABBOX_BOOTSTRAP_TOKEN?: string; + GITHUB_CLIENT_ID?: string; + GITHUB_CLIENT_SECRET?: string; + GITHUB_REDIRECT_URI?: string; + GITHUB_TOKEN?: string; + GITHUB_ORG?: string; + CRABBOX_INTERACTIVE_PROVISION_URL?: string; + CRABBOX_INTERACTIVE_PROVISION_TOKEN?: string; + CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS?: string; + CRABBOX_RUNTIME_PROVISION_URL?: string; + CRABBOX_RUNTIME_PROVISION_TOKEN?: string; + CRABBOX_RUNTIME_ADAPTER_URL?: string; + CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE?: string; + CRABBOX_RUNTIME_ADAPTER_TOKEN?: string; + CRABBOX_RUNTIME_ADAPTER_NAMESPACE?: string; + CRABBOX_RUNTIME_ADAPTER_TTL_SECONDS?: string; + CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS?: string; + CRABBOX_COORDINATOR?: Fetcher; + CRABBOX_COORDINATOR_ORIGIN?: string; + CRABBOX_CLOUDFLARE_RUNNER_URL?: string; + CRABBOX_CLOUDFLARE_RUNNER_TOKEN?: string; + CRABBOX_CLOUDFLARE_RUNNER_INSTANCE_TYPE?: string; + CRABBOX_CLOUDFLARE_RUNNER_WORKDIR?: string; + CRABBOX_CLOUDFLARE_RUNNER_TTL_SECONDS?: string; + CRABBOX_CLOUDFLARE_RUNNER_IDLE_SECONDS?: string; + CRABBOX_PTY_BRIDGE_URL?: string; + CRABBOX_PTY_BRIDGE_TOKEN?: string; + CRABBOX_CLAWFLEET_URL?: string; + CRABBOX_CLAWFLEET_TOKEN?: string; + CRABBOX_CLAWFLEET_PUBLIC_URL?: string; + CRABBOX_SSH_GATEWAY_TOKEN?: string; + CRABFLEET_SSH_GATEWAY_TOKEN?: string; + CRABBOX_OPENCLAW_TOKEN?: string; + CRABBOX_MULTICODEX_TOKEN?: string; + CRABBOX_TOKEN_ENCRYPTION_KEY?: string; + BACKUP_BUCKET_NAME?: string; + CLOUDFLARE_ACCOUNT_ID?: string; + CRABFLEET_LOCAL_SANDBOX_BACKUPS?: string; + CRABFLEET_LABEL?: string; + CRABFLEET_CANONICAL_URL?: string; + CRABFLEET_PRODUCT_URL?: string; + CRABFLEET_SSH_HOST?: string; + CRABFLEET_PREFERRED_REPO?: string; + CRABFLEET_DEFAULT_RUNTIME?: string; + CRABFLEET_DEFAULT_PROFILE?: string; + CRABFLEET_RUNTIME_PROFILES_JSON?: string; + CRABFLEET_DEV_LOGIN_ENABLED?: string; + CRABFLEET_TRUSTED_PROXY_ORIGIN?: string; + CRABFLEET_TRUSTED_PROXY_PUBLIC_ORIGIN?: string; + CRABFLEET_TRUSTED_USER_HEADER?: string; + CRABFLEET_TRUSTED_PROXY_SECRET?: string; + OPENAI_API_KEY?: string; + OPENAI_BASE_URL?: string; + OPENAI_ORG_ID?: string; + R2_ACCESS_KEY_ID?: string; + R2_SECRET_ACCESS_KEY?: string; +}; diff --git a/tests/deployment.test.ts b/tests/deployment.test.ts new file mode 100644 index 0000000..de008dd --- /dev/null +++ b/tests/deployment.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + browserAppOrigin, + clientDeploymentConfig, + deploymentConfig, + publicDeploymentConfig, + selectedRuntimeProfile, + type DeploymentEnv, +} from "../src/worker/deployment.ts"; + +const runtimeProfiles = JSON.stringify([ + { + id: "linux", + label: "Linux", + target: "linux", + capabilities: { terminal: true, desktop: true }, + codexSsh: { + aliasTemplate: "codex-{providerResourceId}", + setupCommand: ["fleet-connect", "{providerResourceId}"], + }, + }, +]); + +test("deployment configuration validates defaults and normalizes public values", () => { + const deployment = deploymentConfig({ + CRABFLEET_LABEL: " Tenant Fleet ", + CRABFLEET_CANONICAL_URL: "https://fleet.example/app", + CRABFLEET_PRODUCT_URL: "http://product.example", + CRABFLEET_SSH_HOST: " ssh.example ", + CRABFLEET_PREFERRED_REPO: "https://github.com/OpenClaw/Crabfleet.git", + CRABFLEET_DEFAULT_RUNTIME: "crabbox", + CRABFLEET_DEFAULT_PROFILE: "linux", + CRABFLEET_RUNTIME_PROFILES_JSON: runtimeProfiles, + }); + + assert.equal(deployment.label, "Tenant Fleet"); + assert.equal(deployment.canonicalUrl, "https://fleet.example"); + assert.equal(deployment.productUrl, "https://crabfleet.ai"); + assert.equal(deployment.sshHost, "ssh.example"); + assert.equal(deployment.preferredRepo, "openclaw/crabfleet"); + assert.equal(deployment.defaultRuntime, "crabbox"); + assert.equal(deployment.defaultProfile, "linux"); + assert.equal(deployment.runtimeProfiles[0]?.target, "linux"); +}); + +test("configured runtime profiles are allowlisted behaviorally", () => { + const deployment = deploymentConfig({ + CRABFLEET_DEFAULT_PROFILE: "linux", + CRABFLEET_RUNTIME_PROFILES_JSON: runtimeProfiles, + }); + + assert.equal(selectedRuntimeProfile(deployment, undefined).profile, "linux"); + assert.equal(selectedRuntimeProfile(deployment, "linux").descriptor?.label, "Linux"); + assert.throws( + () => selectedRuntimeProfile(deployment, "unknown"), + (error: unknown) => + error instanceof Error && + error.message === "profile is not configured" && + "status" in error && + error.status === 400, + ); + assert.throws( + () => + deploymentConfig({ + CRABFLEET_DEFAULT_PROFILE: "unknown", + CRABFLEET_RUNTIME_PROFILES_JSON: runtimeProfiles, + }), + /must name a configured runtime profile/, + ); +}); + +test("public and client deployment views exclude server-only routing data", () => { + const env: DeploymentEnv = { + CRABFLEET_CANONICAL_URL: "https://backend.example", + CRABFLEET_TRUSTED_PROXY_ORIGIN: "https://backend.example", + CRABFLEET_TRUSTED_PROXY_PUBLIC_ORIGIN: "https://fleet.example", + CRABFLEET_TRUSTED_PROXY_SECRET: "edge-secret", + CRABFLEET_DEFAULT_PROFILE: "linux", + CRABFLEET_RUNTIME_PROFILES_JSON: runtimeProfiles, + }; + + assert.deepEqual(publicDeploymentConfig(env), { + label: "Crabfleet", + canonicalUrl: "https://fleet.example", + productUrl: "https://crabfleet.ai", + sshHost: "crabd.sh", + }); + assert.equal(browserAppOrigin(env), "https://fleet.example"); + + const client = clientDeploymentConfig(env); + assert.equal(client.preferredRepo, "openclaw/crabfleet"); + assert.equal(client.runtimeProfiles[0]?.codexSsh, undefined); + assert.equal("CRABBOX_RUNTIME_ADAPTER_URL" in client, false); +}); diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 1423fd4..480ae27 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -36,6 +36,11 @@ import { shouldReplayRuntimeAdapterCreate, validatedRuntimeAdapterCreatePayloadJson, } from "../src/runtime-adapter.ts"; +import { + deploymentConfig, + publicDeploymentConfig, + selectedRuntimeProfile, +} from "../src/worker/deployment.ts"; test("adapter create payload matches the strict controller contract", () => { const payload = runtimeAdapterCreatePayload({ @@ -111,9 +116,6 @@ test("configured profiles fence every adapter runtime and preserve requested cap const createStart = source.indexOf("async function createInteractiveSessionFromInput"); const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); const createSource = source.slice(createStart, createEnd); - const profileStart = source.indexOf("function selectedRuntimeProfile"); - const profileEnd = source.indexOf("function publicDeploymentConfig", profileStart); - const profileSource = source.slice(profileStart, profileEnd); const resultStart = source.indexOf("function runtimeAdapterProvisionResult"); const resultEnd = source.indexOf( "async function reconcileStoppingRuntimeAdapterWorkspace", @@ -122,7 +124,12 @@ test("configured profiles fence every adapter runtime and preserve requested cap const resultSource = source.slice(resultStart, resultEnd); assert.match(createSource, /selectedRuntimeProfile\(deployment, body\.profile\)/); - assert.match(profileSource, /deployment\.runtimeProfiles\.length > 0 && !descriptor/); + const deployment = deploymentConfig({ + CRABFLEET_DEFAULT_PROFILE: "linux", + CRABFLEET_RUNTIME_PROFILES_JSON: JSON.stringify([{ id: "linux", label: "Linux" }]), + }); + assert.equal(selectedRuntimeProfile(deployment, "linux").descriptor?.id, "linux"); + assert.throws(() => selectedRuntimeProfile(deployment, "unknown"), /profile is not configured/); assert.match(resultSource, /session\.adapterRequestedCapabilities \?\?/); assert.match(resultSource, /profile: session\.profile/); assert.doesNotMatch(resultSource, /profile: result\.profile/); @@ -735,15 +742,25 @@ test("recurring terminal authorization never awaits provider reconciliation", as assert.doesNotMatch(bridgeSource, /await reconcileSubscription/); }); -test("public auth deployment metadata excludes runtime routing", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const publicStart = source.indexOf("function publicDeploymentConfig"); - const publicEnd = source.indexOf("class D1Dialect", publicStart); - const publicSource = source.slice(publicStart, publicEnd); - - assert.match(source, /deployment: publicDeploymentConfig\(env\)/); - assert.match(publicSource, /label, canonicalUrl, productUrl, sshHost/); - assert.doesNotMatch(publicSource, /preferredRepo|defaultRuntime|defaultProfile|RUNTIME_ADAPTER/); +test("public auth deployment metadata excludes runtime routing", () => { + assert.deepEqual( + publicDeploymentConfig({ + CRABFLEET_LABEL: "Tenant Fleet", + CRABFLEET_CANONICAL_URL: "https://fleet.example", + CRABFLEET_PREFERRED_REPO: "private/repo", + CRABFLEET_DEFAULT_RUNTIME: "crabbox", + CRABFLEET_DEFAULT_PROFILE: "desktop", + CRABFLEET_RUNTIME_PROFILES_JSON: JSON.stringify([ + { id: "desktop", label: "Desktop", target: "provider-a" }, + ]), + }), + { + label: "Tenant Fleet", + canonicalUrl: "https://fleet.example", + productUrl: "https://crabfleet.ai", + sshHost: "crabd.sh", + }, + ); }); test("worker deployment installs the shared runtime adapter credential", async () => { diff --git a/tests/runtime-profiles.test.ts b/tests/runtime-profiles.test.ts index b67ffec..a7ee9b5 100644 --- a/tests/runtime-profiles.test.ts +++ b/tests/runtime-profiles.test.ts @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { test } from "node:test"; +import { + clientDeploymentConfig, + deploymentConfig, + selectedRuntimeProfile, +} from "../src/worker/deployment.ts"; import { parseRuntimeProfiles, resolveRuntimeProfileCodexSsh, @@ -174,19 +179,32 @@ test("runtime profiles resolve bounded manager-only Codex SSH handoff data", asy decoration, /configuredRuntimeAdapterControlPlane\(env, session\.profile\) ===\s+session\[interactiveSessionAdapterControlPlane\]/, ); - const clientConfigStart = source.indexOf("function clientDeploymentConfig"); - const clientConfigEnd = source.indexOf("function browserAppOrigin", clientConfigStart); - assert.match(source.slice(clientConfigStart, clientConfigEnd), /codexSsh: _serverOnly/); + const client = clientDeploymentConfig({ + CRABFLEET_DEFAULT_PROFILE: "linux", + CRABFLEET_RUNTIME_PROFILES_JSON: JSON.stringify([ + { + id: "linux", + label: "Linux", + codexSsh: { + aliasTemplate: "codex-{providerResourceId}", + setupCommand: ["fleet-connect", "{providerResourceId}"], + }, + }, + ]), + }); + assert.equal(client.runtimeProfiles[0]?.codexSsh, undefined); }); test("profile allowlisting and capability withdrawals stay enforced at provisioning", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const selectionStart = source.indexOf("const profile = clean(body.profile"); assert.equal(selectionStart, -1); - const helperStart = source.indexOf("function selectedRuntimeProfile"); - const helperEnd = source.indexOf("function publicDeploymentConfig", helperStart); - const helper = source.slice(helperStart, helperEnd); - assert.match(helper, /deployment\.runtimeProfiles\.length > 0 && !descriptor/); + const deployment = deploymentConfig({ + CRABFLEET_DEFAULT_PROFILE: "linux", + CRABFLEET_RUNTIME_PROFILES_JSON: JSON.stringify([{ id: "linux", label: "Linux" }]), + }); + assert.equal(selectedRuntimeProfile(deployment, "linux").descriptor?.id, "linux"); + assert.throws(() => selectedRuntimeProfile(deployment, "unknown"), /profile is not configured/); assert.ok(source.indexOf("selectedRuntimeProfile(deploymentConfig(env), session.profile)") > 0); const resultStart = source.indexOf("function runtimeAdapterProvisionResult"); From 5f29cf61a4738d1c6094faa0b5c705d0a99a20b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 10:49:41 +0100 Subject: [PATCH 003/109] refactor: extract Worker database foundation --- CHANGELOG.md | 1 + src/index.ts | 426 ++-------------------------------- src/worker/database.ts | 393 +++++++++++++++++++++++++++++++ src/worker/models.ts | 26 +++ tests/d1-execution.test.ts | 35 +++ tests/runtime-adapter.test.ts | 1 - 6 files changed, 475 insertions(+), 407 deletions(-) create mode 100644 src/worker/database.ts create mode 100644 src/worker/models.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 17d4bea..64ec97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Give shared Worker models and the complete Kysely/D1 schema, dialect, factory, and batch execution dedicated foundation modules. - Extract Worker environment and deployment/profile policy into testable foundation modules, replacing source inspection with behavioral coverage for public and client configuration. - Centralize secure URL, origin, and literal-loopback validation across OAuth, trusted proxy, runtime adapter, and Fleet routing. - De-duplicate the Go CLI and SSH gateway around shared control-plane models, authentication, lifecycle semantics, API calls, terminal operations, and terminal-safe session rendering. diff --git a/src/index.ts b/src/index.ts index 386e676..ea8f1e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,4 @@ -import { - Kysely, - SqliteAdapter, - SqliteIntrospector, - SqliteQueryCompiler, - sql, - type CompiledQuery, - type DatabaseConnection, - type DatabaseIntrospector, - type Dialect, - type Driver, - type Generated, - type RawBuilder, - type Selectable, - type UpdateObject, -} from "kysely"; +import { sql, type Kysely, type RawBuilder, type Selectable, type UpdateObject } from "kysely"; import { ContainerProxy, Sandbox as CloudflareSandboxBase, @@ -125,7 +110,6 @@ import { } from "./runtime-adapter"; import { allocateInteractiveSessionIdSql, formatInteractiveSessionId } from "./session-id"; import { developmentIdentityEnabled } from "./url-security"; -import { D1Connection } from "./d1-execution"; import { preferredEnabledRepo } from "./repo-selection"; import { sandboxGitAuthorEmail } from "./git-identity"; import { completeTerminalFinalization } from "./terminal-finalization"; @@ -176,9 +160,25 @@ import { selectedRuntimeProfile, } from "./worker/deployment"; import type { RuntimeEnv } from "./worker/env"; - -type Role = "viewer" | "maintainer" | "owner"; -type InteractiveRuntime = "crabbox" | "container" | "github_actions"; +import { + database, + executeBatch, + type CompilableQuery, + type Database, + type InteractiveSessionCredentialPolicyTable, + type InteractiveSessionLogArchiveTable, + type InteractiveSessionRow, + type RepoWorkflowTable, + type RunAttemptTable, + type StandaloneSandboxProvisionTable, +} from "./worker/database"; +import type { + InteractiveRuntime, + InteractiveSessionStatus, + Role, + RunStatus, + WorkflowStatus, +} from "./worker/models"; const defaultInteractiveCommand = "codex --yolo"; @@ -275,8 +275,6 @@ type GitHubContentPayload = { sha?: string; }; -type WorkflowStatus = "ok" | "missing" | "invalid" | "error"; - type WorkflowConfig = { runtime?: string; policy?: string; @@ -331,16 +329,6 @@ type Card = { type DiffFileStatus = "added" | "deleted" | "modified" | "renamed"; -type RunStatus = - | "queued" - | "leasing" - | "running" - | "review" - | "completed" - | "failed" - | "stalled" - | "canceled"; - type RunAttempt = { id: string; cardId: string; @@ -363,17 +351,6 @@ type RunAttempt = { error: string | null; }; -type InteractiveSessionStatus = - | "provisioning" - | "pending_adapter" - | "ready" - | "attached" - | "detached" - | "stopping" - | "stopped" - | "expired" - | "failed"; - const interactiveSessionAdapterControlPlane = Symbol("interactiveSessionAdapterControlPlane"); type InteractiveSession = { @@ -657,309 +634,6 @@ type CardChanges = { }; }; -type SettingsTable = { - key: string; - value: string; -}; - -type AllowEntryTable = { - value: string; - role: Role; - created_at: number; - updated_at: number; -}; - -type RepoTable = { - repo: string; - enabled: number; - created_at: number; - updated_at: number; -}; - -type UserTable = { - subject: string; - login: string | null; - email: string | null; - name: string | null; - role: Role; - allowed: number; - teams: string; - created_at: number; - updated_at: number; - last_seen_at: number; -}; - -type SessionTable = { - token_hash: string; - subject: string; - expires_at: number; - created_at: number; - github_token_ciphertext: string | null; -}; - -type CardTable = { - id: string; - title: string; - prompt: string; - repo: string; - source: string; - runtime: string; - policy: string; - lane: string; - owner: string; - started_at: number | null; - created_at: number; - updated_at: number; - last_event: string | null; - changed_files: string; - diff_patch: string; - active_run_id: string | null; -}; - -type RunAttemptTable = { - id: string; - card_id: string; - attempt: number; - runtime: string; - status: RunStatus; - control_intent: string | null; - lease_id: string | null; - attach_url: string | null; - vnc_url: string | null; - selection_reason: string | null; - capabilities_json: string; - operator: string | null; - last_heartbeat_at: number; - started_at: number | null; - ended_at: number | null; - created_at: number; - updated_at: number; - error: string | null; -}; - -type InteractiveSessionTable = { - id: string; - parent_session_id: string | null; - root_session_id: string | null; - repo: string; - branch: string; - runtime: InteractiveRuntime; - adapter: string | null; - profile: string; - adapter_workspace_id: string | null; - adapter_control_plane: string | null; - provider_resource_id: string | null; - capabilities_json: string; - expires_at: number | null; - last_reconciled_at: number | null; - reconcile_error: string | null; - terminal_status: "failed" | null; - terminal_failure_reason: Generated; - adapter_ttl_seconds: number | null; - adapter_idle_timeout_seconds: number | null; - adapter_requested_capabilities_json: string | null; - adapter_create_payload_json: string | null; - adapter_create_pending: number; - preparation_pending: Generated; - openclaw_request_id: Generated; - openclaw_request_hash: Generated; - openclaw_admission_closed: Generated; - terminal_finalize_pending: Generated; - credential_cleanup_terminal_status: Generated<"stopped" | "expired" | "failed" | null>; - sandbox_refresh_sandbox_id: Generated; - sandbox_refresh_claim: Generated; - sandbox_refresh_claim_expires_at: Generated; - command: string; - prompt: string; - purpose: string; - summary: string; - owner: string; - created_by: string; - status: InteractiveSessionStatus; - lease_id: string | null; - attach_url: string | null; - vnc_url: string | null; - last_event: string; - created_at: number; - updated_at: number; - last_seen_at: number; - stopped_at: number | null; - share_mode: "private" | "link_read"; - share_token_hash: string | null; - share_token_preview: string | null; - control_requested_by: string | null; - control_requested_at: number | null; - controller: string | null; - control_granted_at: number | null; - control_expires_at: number | null; - multiplayer_mode: number; - agent_token_hash: string | null; - work_key: string | null; - work_kind: string | null; - work_state: string; - work_phase: string; - source_url: string | null; - github_run_url: string | null; - codex_thread_id: string | null; - codex_turn_id: string | null; - last_heartbeat_at: number | null; - completion_reason: string | null; -}; - -type InteractiveSessionRow = Selectable; - -type OpenClawRequestReplayTable = { - request_id: string; - request_hash: string; - session_id: string; - created_at: number; - updated_at: number; -}; - -type RepoWorkflowTable = { - repo: string; - status: WorkflowStatus; - source_path: string; - source_sha: string | null; - config_json: string; - prompt: string; - error: string | null; - evaluated_at: number; - updated_at: number; -}; - -type EventTable = { - id: Generated; - card_id: string; - actor: string; - message: string; - created_at: number; -}; - -type InteractiveSessionEventTable = { - id: Generated; - session_id: string; - actor: string; - message: string; - created_at: number; -}; - -type InteractiveSessionLogArchiveTable = { - session_id: string; - event_count: number; - session_updated_at: number | null; - events_key: string | null; - transcript_key: string | null; - summary_key: string | null; - archived_at: number; - updated_at: number; -}; - -type InteractiveSessionCredentialPolicyTable = { - session_id: string; - sandbox_id: string; - lookup_id: string; - state: "registering" | "active" | "cleanup_pending"; - registration_generation: string; - registration_claim: string | null; - registration_claim_expires_at: number | null; - attempt_count: Generated; - last_attempt_at: number | null; - last_error: string | null; - cleanup_claim: string | null; - cleanup_claim_expires_at: number | null; - created_at: number; - updated_at: number; -}; - -type CredentialPolicyReconcileStateTable = { - id: number; - last_rowid: number; - scan_max_rowid: number; - group_session_id: string; - group_sandbox_id: string; - group_max_session_id: string; - group_max_sandbox_id: string; - updated_at: number; -}; - -type StandaloneSandboxProvisionTable = { - id: string; - request_hash: string; - sandbox_id: string; - state: "provisioning" | "active" | "cleanup_pending"; - ownership_claim: string | null; - ownership_claim_expires_at: number | null; - lease_id: string | null; - attach_url: string | null; - vnc_url: string | null; - expires_at: Generated; - message: string; - created_at: number; - updated_at: number; -}; - -type AuditEventTable = { - id: Generated; - actor: string; - message: string; - created_at: number; -}; - -type SshKeyTable = { - fingerprint: string; - subject: string; - public_key: string; - label: string | null; - github_token_ciphertext: string | null; - created_at: number; - last_used_at: number; - revoked_at: number | null; -}; - -type SshLinkCodeTable = { - code_hash: string; - fingerprint: string; - public_key: string; - label: string | null; - remote_ip: string | null; - expires_at: number; - consumed_at: number | null; - created_at: number; -}; - -type IdSequenceTable = { - name: string; - last_id: number; -}; - -type Database = { - settings: SettingsTable; - allow_entries: AllowEntryTable; - repos: RepoTable; - users: UserTable; - sessions: SessionTable; - cards: CardTable; - run_attempts: RunAttemptTable; - interactive_sessions: InteractiveSessionTable; - openclaw_request_replays: OpenClawRequestReplayTable; - interactive_session_events: InteractiveSessionEventTable; - interactive_session_log_archives: InteractiveSessionLogArchiveTable; - interactive_session_credential_policies: InteractiveSessionCredentialPolicyTable; - credential_policy_reconcile_state: CredentialPolicyReconcileStateTable; - standalone_sandbox_provisions: StandaloneSandboxProvisionTable; - repo_workflows: RepoWorkflowTable; - events: EventTable; - audit_events: AuditEventTable; - ssh_keys: SshKeyTable; - ssh_link_codes: SshLinkCodeTable; - id_sequences: IdSequenceTable; -}; - -type CompilableQuery = { - compile(executorProvider: Kysely): CompiledQuery; -}; - const encoder = new TextEncoder(); const decoder = new TextDecoder(); const terminalInputStates = new Map(); @@ -1030,66 +704,6 @@ function runtimeAdapterCreateSettings( }; } -class D1Dialect implements Dialect { - constructor(private readonly d1: D1Database) {} - - createDriver(): Driver { - return new D1Driver(this.d1); - } - - createQueryCompiler(): SqliteQueryCompiler { - return new SqliteQueryCompiler(); - } - - createAdapter(): SqliteAdapter { - return new SqliteAdapter(); - } - - createIntrospector(db: Kysely): DatabaseIntrospector { - return new SqliteIntrospector(db); - } -} - -class D1Driver implements Driver { - private readonly connection: D1Connection; - - constructor(d1: D1Database) { - this.connection = new D1Connection(d1); - } - - async init(): Promise {} - - async acquireConnection(): Promise { - return this.connection; - } - - async beginTransaction(): Promise { - throw new Error("D1 batch transactions are not exposed through this Kysely dialect"); - } - - async commitTransaction(): Promise {} - - async rollbackTransaction(): Promise {} - - async releaseConnection(): Promise {} - - async destroy(): Promise {} -} - -function database(env: RuntimeEnv): Kysely { - return new Kysely({ dialect: new D1Dialect(env.DB) }); -} - -async function executeBatch(env: RuntimeEnv, queries: readonly CompilableQuery[]): Promise { - const db = database(env); - await env.DB.batch( - queries.map((query) => { - const compiled = query.compile(db); - return env.DB.prepare(compiled.sql).bind(...compiled.parameters); - }), - ); -} - const defaultSandboxEgressHosts = [ "api.github.com", "api.openai.com", diff --git a/src/worker/database.ts b/src/worker/database.ts new file mode 100644 index 0000000..7a2641d --- /dev/null +++ b/src/worker/database.ts @@ -0,0 +1,393 @@ +import { + Kysely, + SqliteAdapter, + SqliteIntrospector, + SqliteQueryCompiler, + type CompiledQuery, + type DatabaseConnection, + type DatabaseIntrospector, + type Dialect, + type Driver, + type Generated, + type Selectable, +} from "kysely"; + +import { D1Connection } from "../d1-execution.ts"; +import type { RuntimeEnv } from "./env.ts"; +import type { + InteractiveRuntime, + InteractiveSessionStatus, + Role, + RunStatus, + WorkflowStatus, +} from "./models.ts"; + +export type SettingsTable = { + key: string; + value: string; +}; + +export type AllowEntryTable = { + value: string; + role: Role; + created_at: number; + updated_at: number; +}; + +export type RepoTable = { + repo: string; + enabled: number; + created_at: number; + updated_at: number; +}; + +export type UserTable = { + subject: string; + login: string | null; + email: string | null; + name: string | null; + role: Role; + allowed: number; + teams: string; + created_at: number; + updated_at: number; + last_seen_at: number; +}; + +export type SessionTable = { + token_hash: string; + subject: string; + expires_at: number; + created_at: number; + github_token_ciphertext: string | null; +}; + +export type CardTable = { + id: string; + title: string; + prompt: string; + repo: string; + source: string; + runtime: string; + policy: string; + lane: string; + owner: string; + started_at: number | null; + created_at: number; + updated_at: number; + last_event: string | null; + changed_files: string; + diff_patch: string; + active_run_id: string | null; +}; + +export type RunAttemptTable = { + id: string; + card_id: string; + attempt: number; + runtime: string; + status: RunStatus; + control_intent: string | null; + lease_id: string | null; + attach_url: string | null; + vnc_url: string | null; + selection_reason: string | null; + capabilities_json: string; + operator: string | null; + last_heartbeat_at: number; + started_at: number | null; + ended_at: number | null; + created_at: number; + updated_at: number; + error: string | null; +}; + +export type InteractiveSessionTable = { + id: string; + parent_session_id: string | null; + root_session_id: string | null; + repo: string; + branch: string; + runtime: InteractiveRuntime; + adapter: string | null; + profile: string; + adapter_workspace_id: string | null; + adapter_control_plane: string | null; + provider_resource_id: string | null; + capabilities_json: string; + expires_at: number | null; + last_reconciled_at: number | null; + reconcile_error: string | null; + terminal_status: "failed" | null; + terminal_failure_reason: Generated; + adapter_ttl_seconds: number | null; + adapter_idle_timeout_seconds: number | null; + adapter_requested_capabilities_json: string | null; + adapter_create_payload_json: string | null; + adapter_create_pending: number; + preparation_pending: Generated; + openclaw_request_id: Generated; + openclaw_request_hash: Generated; + openclaw_admission_closed: Generated; + terminal_finalize_pending: Generated; + credential_cleanup_terminal_status: Generated<"stopped" | "expired" | "failed" | null>; + sandbox_refresh_sandbox_id: Generated; + sandbox_refresh_claim: Generated; + sandbox_refresh_claim_expires_at: Generated; + command: string; + prompt: string; + purpose: string; + summary: string; + owner: string; + created_by: string; + status: InteractiveSessionStatus; + lease_id: string | null; + attach_url: string | null; + vnc_url: string | null; + last_event: string; + created_at: number; + updated_at: number; + last_seen_at: number; + stopped_at: number | null; + share_mode: "private" | "link_read"; + share_token_hash: string | null; + share_token_preview: string | null; + control_requested_by: string | null; + control_requested_at: number | null; + controller: string | null; + control_granted_at: number | null; + control_expires_at: number | null; + multiplayer_mode: number; + agent_token_hash: string | null; + work_key: string | null; + work_kind: string | null; + work_state: string; + work_phase: string; + source_url: string | null; + github_run_url: string | null; + codex_thread_id: string | null; + codex_turn_id: string | null; + last_heartbeat_at: number | null; + completion_reason: string | null; +}; + +export type InteractiveSessionRow = Selectable; + +export type OpenClawRequestReplayTable = { + request_id: string; + request_hash: string; + session_id: string; + created_at: number; + updated_at: number; +}; + +export type RepoWorkflowTable = { + repo: string; + status: WorkflowStatus; + source_path: string; + source_sha: string | null; + config_json: string; + prompt: string; + error: string | null; + evaluated_at: number; + updated_at: number; +}; + +export type EventTable = { + id: Generated; + card_id: string; + actor: string; + message: string; + created_at: number; +}; + +export type InteractiveSessionEventTable = { + id: Generated; + session_id: string; + actor: string; + message: string; + created_at: number; +}; + +export type InteractiveSessionLogArchiveTable = { + session_id: string; + event_count: number; + session_updated_at: number | null; + events_key: string | null; + transcript_key: string | null; + summary_key: string | null; + archived_at: number; + updated_at: number; +}; + +export type InteractiveSessionCredentialPolicyTable = { + session_id: string; + sandbox_id: string; + lookup_id: string; + state: "registering" | "active" | "cleanup_pending"; + registration_generation: string; + registration_claim: string | null; + registration_claim_expires_at: number | null; + attempt_count: Generated; + last_attempt_at: number | null; + last_error: string | null; + cleanup_claim: string | null; + cleanup_claim_expires_at: number | null; + created_at: number; + updated_at: number; +}; + +export type CredentialPolicyReconcileStateTable = { + id: number; + last_rowid: number; + scan_max_rowid: number; + group_session_id: string; + group_sandbox_id: string; + group_max_session_id: string; + group_max_sandbox_id: string; + updated_at: number; +}; + +export type StandaloneSandboxProvisionTable = { + id: string; + request_hash: string; + sandbox_id: string; + state: "provisioning" | "active" | "cleanup_pending"; + ownership_claim: string | null; + ownership_claim_expires_at: number | null; + lease_id: string | null; + attach_url: string | null; + vnc_url: string | null; + expires_at: Generated; + message: string; + created_at: number; + updated_at: number; +}; + +export type AuditEventTable = { + id: Generated; + actor: string; + message: string; + created_at: number; +}; + +export type SshKeyTable = { + fingerprint: string; + subject: string; + public_key: string; + label: string | null; + github_token_ciphertext: string | null; + created_at: number; + last_used_at: number; + revoked_at: number | null; +}; + +export type SshLinkCodeTable = { + code_hash: string; + fingerprint: string; + public_key: string; + label: string | null; + remote_ip: string | null; + expires_at: number; + consumed_at: number | null; + created_at: number; +}; + +export type IdSequenceTable = { + name: string; + last_id: number; +}; + +export type Database = { + settings: SettingsTable; + allow_entries: AllowEntryTable; + repos: RepoTable; + users: UserTable; + sessions: SessionTable; + cards: CardTable; + run_attempts: RunAttemptTable; + interactive_sessions: InteractiveSessionTable; + openclaw_request_replays: OpenClawRequestReplayTable; + interactive_session_events: InteractiveSessionEventTable; + interactive_session_log_archives: InteractiveSessionLogArchiveTable; + interactive_session_credential_policies: InteractiveSessionCredentialPolicyTable; + credential_policy_reconcile_state: CredentialPolicyReconcileStateTable; + standalone_sandbox_provisions: StandaloneSandboxProvisionTable; + repo_workflows: RepoWorkflowTable; + events: EventTable; + audit_events: AuditEventTable; + ssh_keys: SshKeyTable; + ssh_link_codes: SshLinkCodeTable; + id_sequences: IdSequenceTable; +}; + +export type CompilableQuery = { + compile(executorProvider: Kysely): CompiledQuery; +}; + +class D1Dialect implements Dialect { + private readonly d1: D1Database; + + constructor(d1: D1Database) { + this.d1 = d1; + } + + createDriver(): Driver { + return new D1Driver(this.d1); + } + + createQueryCompiler(): SqliteQueryCompiler { + return new SqliteQueryCompiler(); + } + + createAdapter(): SqliteAdapter { + return new SqliteAdapter(); + } + + createIntrospector(db: Kysely): DatabaseIntrospector { + return new SqliteIntrospector(db); + } +} + +class D1Driver implements Driver { + private readonly connection: D1Connection; + + constructor(d1: D1Database) { + this.connection = new D1Connection(d1); + } + + async init(): Promise {} + + async acquireConnection(): Promise { + return this.connection; + } + + async beginTransaction(): Promise { + throw new Error("D1 batch transactions are not exposed through this Kysely dialect"); + } + + async commitTransaction(): Promise {} + + async rollbackTransaction(): Promise {} + + async releaseConnection(): Promise {} + + async destroy(): Promise {} +} + +export function database(env: Pick): Kysely { + return new Kysely({ dialect: new D1Dialect(env.DB) }); +} + +export async function executeBatch( + env: Pick, + queries: readonly CompilableQuery[], +): Promise { + const db = database(env); + await env.DB.batch( + queries.map((query) => { + const compiled = query.compile(db); + return env.DB.prepare(compiled.sql).bind(...compiled.parameters); + }), + ); +} diff --git a/src/worker/models.ts b/src/worker/models.ts new file mode 100644 index 0000000..6328f82 --- /dev/null +++ b/src/worker/models.ts @@ -0,0 +1,26 @@ +export type Role = "viewer" | "maintainer" | "owner"; + +export type InteractiveRuntime = "crabbox" | "container" | "github_actions"; + +export type WorkflowStatus = "ok" | "missing" | "invalid" | "error"; + +export type RunStatus = + | "queued" + | "leasing" + | "running" + | "review" + | "completed" + | "failed" + | "stalled" + | "canceled"; + +export type InteractiveSessionStatus = + | "provisioning" + | "pending_adapter" + | "ready" + | "attached" + | "detached" + | "stopping" + | "stopped" + | "expired" + | "failed"; diff --git a/tests/d1-execution.test.ts b/tests/d1-execution.test.ts index e2cd4e6..11cc631 100644 --- a/tests/d1-execution.test.ts +++ b/tests/d1-execution.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { D1Connection, executeD1Statement } from "../src/d1-execution.ts"; +import { database, executeBatch } from "../src/worker/database.ts"; test("D1 connection executes INSERT RETURNING through all and preserves rows", async () => { let allCalls = 0; @@ -68,3 +69,37 @@ test("D1 executes non-returning mutations through run", async () => { assert.deepEqual(result.rows, []); assert.equal(result.changes, 2); }); + +test("database batches compile Kysely queries into bound D1 statements", async () => { + const prepared: Array<{ sql: string; parameters: unknown[] }> = []; + let batchSize = 0; + const env = { + DB: { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + const statement = { sql, parameters }; + prepared.push(statement); + return statement; + }, + }; + }, + async batch(statements: unknown[]) { + batchSize = statements.length; + return []; + }, + } as unknown as D1Database, + }; + const db = database(env); + + await executeBatch(env, [ + db.insertInto("id_sequences").values({ name: "interactive_sessions", last_id: 41 }), + db.updateTable("id_sequences").set({ last_id: 42 }).where("name", "=", "interactive_sessions"), + ]); + + assert.equal(batchSize, 2); + assert.match(prepared[0]?.sql ?? "", /^insert into "id_sequences"/i); + assert.deepEqual(prepared[0]?.parameters, ["interactive_sessions", 41]); + assert.match(prepared[1]?.sql ?? "", /^update "id_sequences"/i); + assert.deepEqual(prepared[1]?.parameters, [42, "interactive_sessions"]); +}); diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 480ae27..9c7e31c 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -808,7 +808,6 @@ test("strict session rows and cleanup preserve terminal finalization anchors", a const cleanupEnd = source.indexOf("async function mutateInteractiveSession", cleanupStart); const cleanupSource = source.slice(cleanupStart, cleanupEnd); - assert.match(source, /type InteractiveSessionRow = Selectable/); assert.match(cleanupSource, /where\("terminal_finalize_pending", "=", 0\)/); assert.match(cleanupSource, /deleteFinalizedInteractiveSession\(env, row, archive\)/); assert.match(cleanupSource, /terminalCleanupDeletePending/); From 691d73258b2c7b5d3d0be9353ab637cd461eea48 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 10:55:13 +0100 Subject: [PATCH 004/109] refactor: extract Worker HTTP foundation --- CHANGELOG.md | 1 + src/index.ts | 132 ++++++--------------------------------- src/worker/deployment.ts | 3 +- src/worker/http.ts | 125 ++++++++++++++++++++++++++++++++++++ tests/http.test.ts | 118 ++++++++++++++++++++++++++++++++++ 5 files changed, 265 insertions(+), 114 deletions(-) create mode 100644 src/worker/http.ts create mode 100644 tests/http.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ec97a..05340c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Centralize Worker HTTP responses, security headers, status errors, JSON parsing, bearer authentication, and cookie handling behind a directly tested module. - Give shared Worker models and the complete Kysely/D1 schema, dialect, factory, and batch execution dedicated foundation modules. - Extract Worker environment and deployment/profile policy into testable foundation modules, replacing source inspection with behavioral coverage for public and client configuration. - Centralize secure URL, origin, and literal-loopback validation across OAuth, trusted proxy, runtime adapter, and Fleet routing. diff --git a/src/index.ts b/src/index.ts index ea8f1e7..664637b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -179,6 +179,25 @@ import type { RunStatus, WorkflowStatus, } from "./worker/models"; +import { + badRequest, + bearer, + bearerToken, + conflict, + cookie, + cookies, + forbidden, + json, + notFound, + readJson, + redirect, + securityHeaders, + serviceUnavailable, + text, + tooManyRequests, + unauthorized, + wantsMarkdown, +} from "./worker/http"; const defaultInteractiveCommand = "codex --yolo"; @@ -3969,12 +3988,6 @@ function agentSessionId(request: Request): string { ); } -function bearerToken(request: Request): string { - const authorization = request.headers.get("authorization") ?? ""; - const [scheme, token] = authorization.split(/\s+/, 2); - return scheme?.toLowerCase() === "bearer" ? clean(token, 200) : ""; -} - function sshGatewayTokens(env: RuntimeEnv): string[] { return [env.CRABFLEET_SSH_GATEWAY_TOKEN, env.CRABBOX_SSH_GATEWAY_TOKEN].filter( (token): token is string => Boolean(token), @@ -14990,14 +15003,6 @@ function devIdentityId(value: unknown): string { return id || "dev"; } -async function readJson(request: Request): Promise { - try { - return (await request.json()) as T; - } catch { - throw badRequest("invalid json"); - } -} - function parseJson(value: string, fallback: T): T { try { return JSON.parse(value) as T; @@ -15588,21 +15593,6 @@ function systemUser(): User { }; } -function cookies(request: Request): Map { - const result = new Map(); - for (const part of (request.headers.get("cookie") ?? "").split(";")) { - const index = part.indexOf("="); - if (index === -1) continue; - result.set(part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())); - } - return result; -} - -function cookie(request: Request, name: string, value: string, maxAge: number): string { - const secure = new URL(request.url).protocol === "https:" ? "; Secure" : ""; - return `${name}=${encodeURIComponent(value)}; HttpOnly${secure}; SameSite=Lax; Path=/; Max-Age=${maxAge}`; -} - async function sha256(value: string): Promise { const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); @@ -15737,10 +15727,6 @@ function httpToWebSocketUrl(rawUrl: string): string { } } -function bearer(token: string | undefined): string | null { - return token ? `Bearer ${token}` : null; -} - function sandboxIdForSession(id: string): string { return clean(`crabbox-${id}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"), 63); } @@ -16023,58 +16009,6 @@ function isConstraintError(error: unknown): boolean { return error instanceof Error && /constraint|unique/i.test(error.message); } -function wantsMarkdown(request: Request): boolean { - const accept = request.headers.get("accept") ?? ""; - return accept.includes("text/markdown"); -} - -function text( - body: string, - contentType: string, - extraHeaders: HeadersInit = {}, - status = 200, -): Response { - return new Response(body, { - status, - headers: { - ...securityHeaders(contentType), - ...extraHeaders, - "content-length": String(encoder.encode(body).byteLength), - }, - }); -} - -function json(body: unknown, init: ResponseInit & { headers?: HeadersInit } = {}): Response { - const textBody = JSON.stringify(body); - return new Response(textBody, { - ...init, - headers: { - ...securityHeaders("application/json; charset=utf-8", false), - ...init.headers, - "content-length": String(encoder.encode(textBody).byteLength), - }, - }); -} - -function redirect(location: string, headers: HeadersInit = {}): Response { - return new Response(null, { - status: 302, - headers: { - location, - ...headers, - }, - }); -} - -function securityHeaders(contentType: string, cache = true): HeadersInit { - return { - "content-type": contentType, - "x-content-type-options": "nosniff", - "referrer-policy": "no-referrer", - "cache-control": cache ? "public, max-age=300" : "no-store", - }; -} - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -16087,31 +16021,3 @@ function base64Bytes(value: string): Uint8Array { } return bytes; } - -function unauthorized(): Error { - return Object.assign(new Error("unauthorized"), { status: 401 }); -} - -function forbidden(message: string): Error { - return Object.assign(new Error(message), { status: 403 }); -} - -function conflict(message: string): Error { - return Object.assign(new Error(message), { status: 409 }); -} - -function serviceUnavailable(message: string): Error { - return Object.assign(new Error(message), { status: 503 }); -} - -function badRequest(message: string): Error { - return Object.assign(new Error(message), { status: 400 }); -} - -function tooManyRequests(message: string): Error { - return Object.assign(new Error(message), { status: 429 }); -} - -function notFound(message: string): Error { - return Object.assign(new Error(message), { status: 404 }); -} diff --git a/src/worker/deployment.ts b/src/worker/deployment.ts index 10321a5..f2109f2 100644 --- a/src/worker/deployment.ts +++ b/src/worker/deployment.ts @@ -6,6 +6,7 @@ import { } from "../runtime-profiles.ts"; import { trustedProxyPublicOrigin, type TrustedProxyEnv } from "../trusted-proxy-auth.ts"; import { configuredHttpOrigin } from "../url-security.ts"; +import { badRequest } from "./http.ts"; export const defaultPreferredRepo = "openclaw/crabfleet"; @@ -65,7 +66,7 @@ export function selectedRuntimeProfile( const profile = clean(value, 120) || deployment.defaultProfile; const descriptor = runtimeProfileByID(deployment.runtimeProfiles, profile); if (deployment.runtimeProfiles.length > 0 && !descriptor) { - throw Object.assign(new Error("profile is not configured"), { status: 400 }); + throw badRequest("profile is not configured"); } return { profile, descriptor }; } diff --git a/src/worker/http.ts b/src/worker/http.ts new file mode 100644 index 0000000..e357bff --- /dev/null +++ b/src/worker/http.ts @@ -0,0 +1,125 @@ +const encoder = new TextEncoder(); + +export type HttpError = Error & { status: number }; + +export function text( + body: string, + contentType: string, + extraHeaders: HeadersInit = {}, + status = 200, +): Response { + return new Response(body, { + status, + headers: { + ...securityHeaders(contentType), + ...extraHeaders, + "content-length": String(encoder.encode(body).byteLength), + }, + }); +} + +export function json(body: unknown, init: ResponseInit & { headers?: HeadersInit } = {}): Response { + const textBody = JSON.stringify(body); + return new Response(textBody, { + ...init, + headers: { + ...securityHeaders("application/json; charset=utf-8", false), + ...init.headers, + "content-length": String(encoder.encode(textBody).byteLength), + }, + }); +} + +export function redirect(location: string, headers: HeadersInit = {}): Response { + return new Response(null, { + status: 302, + headers: { + location, + ...headers, + }, + }); +} + +export function securityHeaders(contentType: string, cache = true): HeadersInit { + return { + "content-type": contentType, + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "cache-control": cache ? "public, max-age=300" : "no-store", + }; +} + +export function wantsMarkdown(request: Request): boolean { + return (request.headers.get("accept") ?? "").includes("text/markdown"); +} + +export async function readJson(request: Request): Promise { + try { + return (await request.json()) as T; + } catch { + throw badRequest("invalid json"); + } +} + +export function bearerToken(request: Request): string { + const authorization = request.headers.get("authorization") ?? ""; + const [scheme, token] = authorization.split(/\s+/, 2); + return scheme?.toLowerCase() === "bearer" ? clean(token, 200) : ""; +} + +export function bearer(token: string | undefined): string | null { + return token ? `Bearer ${token}` : null; +} + +export function cookies(request: Request): Map { + const result = new Map(); + for (const part of (request.headers.get("cookie") ?? "").split(";")) { + const index = part.indexOf("="); + if (index === -1) continue; + result.set(part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())); + } + return result; +} + +export function cookie(request: Request, name: string, value: string, maxAge: number): string { + const secure = new URL(request.url).protocol === "https:" ? "; Secure" : ""; + return `${name}=${encodeURIComponent(value)}; HttpOnly${secure}; SameSite=Lax; Path=/; Max-Age=${maxAge}`; +} + +export function unauthorized(): HttpError { + return httpError(401, "unauthorized"); +} + +export function forbidden(message: string): HttpError { + return httpError(403, message); +} + +export function conflict(message: string): HttpError { + return httpError(409, message); +} + +export function serviceUnavailable(message: string): HttpError { + return httpError(503, message); +} + +export function badRequest(message: string): HttpError { + return httpError(400, message); +} + +export function tooManyRequests(message: string): HttpError { + return httpError(429, message); +} + +export function notFound(message: string): HttpError { + return httpError(404, message); +} + +function httpError(status: number, message: string): HttpError { + return Object.assign(new Error(message), { status }); +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/http.test.ts b/tests/http.test.ts new file mode 100644 index 0000000..33a8a47 --- /dev/null +++ b/tests/http.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + badRequest, + bearer, + bearerToken, + conflict, + cookie, + cookies, + forbidden, + json, + notFound, + readJson, + redirect, + serviceUnavailable, + text, + tooManyRequests, + unauthorized, + wantsMarkdown, +} from "../src/worker/http.ts"; + +test("JSON and text responses apply security, cache, and byte-length headers", async () => { + const jsonResponse = json({ message: "crab" }, { status: 201 }); + assert.equal(jsonResponse.status, 201); + assert.equal(jsonResponse.headers.get("content-type"), "application/json; charset=utf-8"); + assert.equal(jsonResponse.headers.get("cache-control"), "no-store"); + assert.equal(jsonResponse.headers.get("x-content-type-options"), "nosniff"); + assert.equal( + Number(jsonResponse.headers.get("content-length")), + new TextEncoder().encode(await jsonResponse.clone().text()).byteLength, + ); + + const textResponse = text("hello\n", "text/plain; charset=utf-8"); + assert.equal(textResponse.headers.get("cache-control"), "public, max-age=300"); + assert.equal(textResponse.headers.get("referrer-policy"), "no-referrer"); + assert.equal(textResponse.headers.get("content-length"), "6"); +}); + +test("redirects preserve caller headers and Markdown negotiation is explicit", () => { + const response = redirect("https://fleet.example/app", { "cache-control": "no-store" }); + assert.equal(response.status, 302); + assert.equal(response.headers.get("location"), "https://fleet.example/app"); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.equal( + wantsMarkdown( + new Request("https://fleet.example/docs", { headers: { accept: "text/markdown" } }), + ), + true, + ); + assert.equal(wantsMarkdown(new Request("https://fleet.example/docs")), false); +}); + +test("JSON parsing and status errors retain stable messages and status codes", async () => { + assert.deepEqual( + await readJson<{ value: number }>( + new Request("https://fleet.example", { method: "POST", body: '{"value":42}' }), + ), + { value: 42 }, + ); + await assert.rejects( + readJson(new Request("https://fleet.example", { method: "POST", body: "{" })), + (error: unknown) => + error instanceof Error && + error.message === "invalid json" && + "status" in error && + error.status === 400, + ); + + for (const [error, status, message] of [ + [unauthorized(), 401, "unauthorized"], + [forbidden("blocked"), 403, "blocked"], + [notFound("missing"), 404, "missing"], + [conflict("raced"), 409, "raced"], + [tooManyRequests("slow down"), 429, "slow down"], + [serviceUnavailable("offline"), 503, "offline"], + [badRequest("invalid"), 400, "invalid"], + ] as const) { + assert.equal(error.status, status); + assert.equal(error.message, message); + } +}); + +test("bearer and cookie helpers normalize only their owned protocol surface", () => { + assert.equal( + bearerToken( + new Request("https://fleet.example", { headers: { authorization: "bEaReR token-value" } }), + ), + "token-value", + ); + assert.equal( + bearerToken( + new Request("https://fleet.example", { headers: { authorization: "Basic token-value" } }), + ), + "", + ); + assert.equal(bearer("token-value"), "Bearer token-value"); + assert.equal(bearer(undefined), null); + + const request = new Request("https://fleet.example", { + headers: { cookie: "session=hello%20world; mode=read" }, + }); + assert.deepEqual( + [...cookies(request)], + [ + ["session", "hello world"], + ["mode", "read"], + ], + ); + assert.equal( + cookie(request, "session", "hello world", 60), + "session=hello%20world; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=60", + ); + assert.equal( + cookie(new Request("http://localhost:8787"), "session", "local", 60).includes("; Secure"), + false, + ); +}); From a0d21b8840d05ff954e1769b67d33f41d5bd69a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:00:17 +0100 Subject: [PATCH 005/109] refactor: extract Worker ingress policy --- CHANGELOG.md | 1 + src/index.ts | 36 +----- src/worker/ingress.ts | 53 +++++++++ tests/ingress.test.ts | 149 ++++++++++++++++++++++++ tests/trusted-proxy-integration.test.ts | 51 -------- 5 files changed, 208 insertions(+), 82 deletions(-) create mode 100644 src/worker/ingress.ts create mode 100644 tests/ingress.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 05340c1..2bce558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Isolate Worker ingress authentication, trusted-proxy credential stripping, and independent service-route policy behind direct behavioral tests. - Centralize Worker HTTP responses, security headers, status errors, JSON parsing, bearer authentication, and cookie handling behind a directly tested module. - Give shared Worker models and the complete Kysely/D1 schema, dialect, factory, and batch execution dedicated foundation modules. - Extract Worker environment and deployment/profile policy into testable foundation modules, replacing source inspection with behavioral coverage for public and client configuration. diff --git a/src/index.ts b/src/index.ts index 664637b..f35f102 100644 --- a/src/index.ts +++ b/src/index.ts @@ -128,7 +128,6 @@ import { openClawServiceAuthorized, } from "./openclaw-service"; import { - inspectTrustedProxyAssertion, sanitizeTrustedProxyRequest, trustedProxyConfigured, trustedProxyPublicOrigin, @@ -198,6 +197,7 @@ import { unauthorized, wantsMarkdown, } from "./worker/http"; +import { enforceWorkerIngressAuth, prepareWorkerIngress } from "./worker/ingress"; const defaultInteractiveCommand = "codex --yolo"; @@ -1401,15 +1401,9 @@ export default { const url = new URL(request.url); try { - const trustedProxy = inspectTrustedProxyAssertion(request, env); - if (trustedProxy.kind === "rejected") throw unauthorized(); - request = sanitizeTrustedProxyRequest(request, env); - if (trustedProxy.kind === "authenticated") { - const headers = new Headers(request.headers); - if (!usesIndependentServiceAuth(request)) headers.delete("authorization"); - headers.delete("cookie"); - request = new Request(request, { headers }); - } + const ingress = prepareWorkerIngress(request, env); + request = ingress.request; + const { trustedProxy } = ingress; const productResponse = await productHostResponse(request); if (productResponse) return productResponse; @@ -1420,9 +1414,7 @@ export default { return text("ok\n", "text/plain; charset=utf-8"); } - if (trustedProxy.kind === "missing" && !usesIndependentServiceAuth(request)) { - throw unauthorized(); - } + enforceWorkerIngressAuth(ingress); if (trustedProxy.kind !== "authenticated") { const canonicalRedirect = canonicalAppRedirect(url); if (canonicalRedirect) return canonicalRedirect; @@ -2119,24 +2111,6 @@ async function api( return json({ error: "not found" }, { status: 404 }); } -function usesIndependentServiceAuth(request: Request): boolean { - const pathname = new URL(request.url).pathname; - if (pathname === "/api/terminal/ws") { - const headers = request.headers; - const hasAuthorization = Boolean(headers.get("authorization")); - const hasSshIdentity = Boolean( - headers.get("x-crabfleet-ssh-fingerprint") || headers.get("x-crabbox-ssh-fingerprint"), - ); - const hasAgentIdentity = Boolean( - headers.get("x-crabfleet-session-id") || headers.get("x-crabbox-session-id"), - ); - return hasAuthorization && (hasSshIdentity || hasAgentIdentity); - } - return ["/api/ssh/", "/api/agent/", "/api/openclaw/", "/api/provision/"].some((prefix) => - pathname.startsWith(prefix), - ); -} - async function tokenLogin(request: Request, env: RuntimeEnv): Promise { const { token } = await readJson<{ token?: string }>(request); if (!env.CRABBOX_BOOTSTRAP_TOKEN || token !== env.CRABBOX_BOOTSTRAP_TOKEN) { diff --git a/src/worker/ingress.ts b/src/worker/ingress.ts new file mode 100644 index 0000000..343043f --- /dev/null +++ b/src/worker/ingress.ts @@ -0,0 +1,53 @@ +import { + inspectTrustedProxyAssertion, + sanitizeTrustedProxyRequest, + type TrustedProxyAuthResult, + type TrustedProxyEnv, +} from "../trusted-proxy-auth.ts"; +import { unauthorized } from "./http.ts"; + +export type WorkerIngress = { + request: Request; + trustedProxy: TrustedProxyAuthResult; + independentServiceAuth: boolean; +}; + +export function prepareWorkerIngress(request: Request, env: TrustedProxyEnv): WorkerIngress { + const trustedProxy = inspectTrustedProxyAssertion(request, env); + if (trustedProxy.kind === "rejected") throw unauthorized(); + + request = sanitizeTrustedProxyRequest(request, env); + const independentServiceAuth = usesIndependentServiceAuth(request); + if (trustedProxy.kind === "authenticated") { + const headers = new Headers(request.headers); + if (!independentServiceAuth) headers.delete("authorization"); + headers.delete("cookie"); + request = new Request(request, { headers }); + } + + return { request, trustedProxy, independentServiceAuth }; +} + +export function enforceWorkerIngressAuth(ingress: WorkerIngress): void { + if (ingress.trustedProxy.kind === "missing" && !ingress.independentServiceAuth) { + throw unauthorized(); + } +} + +export function usesIndependentServiceAuth(request: Request): boolean { + const pathname = new URL(request.url).pathname; + if (pathname === "/api/terminal/ws") { + const headers = request.headers; + const hasAuthorization = Boolean(headers.get("authorization")); + const hasSshIdentity = Boolean( + headers.get("x-crabfleet-ssh-fingerprint") || headers.get("x-crabbox-ssh-fingerprint"), + ); + const hasAgentIdentity = Boolean( + headers.get("x-crabfleet-session-id") || headers.get("x-crabbox-session-id"), + ); + return hasAuthorization && (hasSshIdentity || hasAgentIdentity); + } + return ["/api/ssh/", "/api/agent/", "/api/openclaw/", "/api/provision/"].some((prefix) => + pathname.startsWith(prefix), + ); +} diff --git a/tests/ingress.test.ts b/tests/ingress.test.ts new file mode 100644 index 0000000..f4088db --- /dev/null +++ b/tests/ingress.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { trustedProxySecretHeader } from "../src/trusted-proxy-auth.ts"; +import { + enforceWorkerIngressAuth, + prepareWorkerIngress, + usesIndependentServiceAuth, +} from "../src/worker/ingress.ts"; + +const env = { + CRABFLEET_TRUSTED_PROXY_ORIGIN: "https://backend.example", + CRABFLEET_TRUSTED_PROXY_PUBLIC_ORIGIN: "https://fleet.example", + CRABFLEET_TRUSTED_PROXY_SECRET: "edge-secret", +}; + +const assertion = { + "x-authenticated-user": "owner@example.com", + [trustedProxySecretHeader]: "edge-secret", +}; + +test("authenticated proxy ingress strips browser and proxy credentials before routing", () => { + const ingress = prepareWorkerIngress( + new Request("https://backend.example/api/session", { + headers: { + ...assertion, + authorization: "Bearer upstream-credential", + cookie: "crabbox_session=stale", + "x-safe": "preserved", + }, + }), + env, + ); + + assert.equal(ingress.trustedProxy.kind, "authenticated"); + assert.equal(ingress.independentServiceAuth, false); + assert.equal(ingress.request.headers.has("x-authenticated-user"), false); + assert.equal(ingress.request.headers.has(trustedProxySecretHeader), false); + assert.equal(ingress.request.headers.has("authorization"), false); + assert.equal(ingress.request.headers.has("cookie"), false); + assert.equal(ingress.request.headers.get("x-safe"), "preserved"); +}); + +test("authenticated proxy ingress preserves independent service authorization only", () => { + const ingress = prepareWorkerIngress( + new Request("https://backend.example/api/openclaw/rooms", { + headers: { + ...assertion, + authorization: "Bearer service-token", + cookie: "crabbox_session=stale", + }, + }), + env, + ); + + assert.equal(ingress.independentServiceAuth, true); + assert.equal(ingress.request.headers.get("authorization"), "Bearer service-token"); + assert.equal(ingress.request.headers.has("cookie"), false); + assert.equal(ingress.request.headers.has("x-authenticated-user"), false); + assert.equal(ingress.request.headers.has(trustedProxySecretHeader), false); +}); + +test("rejected proxy assertions fail before a request can be routed", () => { + assert.throws( + () => + prepareWorkerIngress( + new Request("https://backend.example/api/session", { + headers: { + ...assertion, + [trustedProxySecretHeader]: "wrong-secret", + }, + }), + env, + ), + (error: unknown) => + error instanceof Error && + error.message === "unauthorized" && + "status" in error && + error.status === 401, + ); +}); + +test("missing proxy assertions bypass enforcement only on independent service routes", () => { + const browserIngress = prepareWorkerIngress( + new Request("https://backend.example/api/session"), + env, + ); + assert.throws(() => enforceWorkerIngressAuth(browserIngress), { message: "unauthorized" }); + + for (const pathname of [ + "/api/ssh/keys", + "/api/agent/register", + "/api/openclaw/rooms", + "/api/provision/session", + ]) { + const ingress = prepareWorkerIngress(new Request(`https://backend.example${pathname}`), env); + assert.equal(ingress.independentServiceAuth, true); + assert.doesNotThrow(() => enforceWorkerIngressAuth(ingress)); + } +}); + +test("terminal ingress requires authorization plus an SSH or agent identity", () => { + for (const headers of [ + {}, + { authorization: "Bearer terminal-token" }, + { "x-crabfleet-ssh-fingerprint": "SHA256:key" }, + { "x-crabfleet-session-id": "session-1" }, + ]) { + assert.equal( + usesIndependentServiceAuth( + new Request("https://backend.example/api/terminal/ws", { headers }), + ), + false, + ); + } + + for (const headers of [ + { + authorization: "Bearer terminal-token", + "x-crabfleet-ssh-fingerprint": "SHA256:key", + }, + { + authorization: "Bearer terminal-token", + "x-crabfleet-session-id": "session-1", + }, + ]) { + assert.equal( + usesIndependentServiceAuth( + new Request("https://backend.example/api/terminal/ws", { headers }), + ), + true, + ); + } +}); + +test("disabled proxy mode leaves ordinary requests routable but rejects assertion headers", () => { + const ingress = prepareWorkerIngress(new Request("https://fleet.example/api/session"), {}); + assert.equal(ingress.trustedProxy.kind, "disabled"); + assert.doesNotThrow(() => enforceWorkerIngressAuth(ingress)); + + assert.throws( + () => + prepareWorkerIngress( + new Request("https://fleet.example/api/session", { headers: assertion }), + {}, + ), + { message: "unauthorized" }, + ); +}); diff --git a/tests/trusted-proxy-integration.test.ts b/tests/trusted-proxy-integration.test.ts index 3aafd15..38221cc 100644 --- a/tests/trusted-proxy-integration.test.ts +++ b/tests/trusted-proxy-integration.test.ts @@ -2,34 +2,6 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { test } from "node:test"; -test("trusted proxy authentication is resolved and sanitized before routing", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const fetchStart = source.indexOf("export default {"); - const fetchEnd = source.indexOf("async scheduled(", fetchStart); - const fetchSource = source.slice(fetchStart, fetchEnd); - assert.ok( - fetchSource.indexOf("inspectTrustedProxyAssertion(request, env)") < - fetchSource.indexOf("sanitizeTrustedProxyRequest(request, env)"), - ); - assert.ok( - fetchSource.indexOf("inspectTrustedProxyAssertion(request, env)") < - fetchSource.indexOf("productHostResponse(request)"), - ); - assert.ok( - fetchSource.indexOf('headers.delete("authorization")') < - fetchSource.indexOf("api(request, env, context, trustedProxy)"), - ); - assert.ok( - fetchSource.indexOf('headers.delete("cookie")') < - fetchSource.indexOf("api(request, env, context, trustedProxy)"), - ); - assert.match(fetchSource, /api\(request, env, context, trustedProxy\)/); - assert.match( - fetchSource, - /trustedProxy\.kind !== "authenticated"[\s\S]*canonicalAppRedirect\(url\)/, - ); -}); - test("proxy users cannot consume a cookie session GitHub credential", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const createStart = source.indexOf("async function createInteractiveSession("); @@ -55,11 +27,6 @@ test("trusted proxy requests stay sanitized on terminal forwarding paths", async const openStart = source.indexOf("async function openSandboxTerminalResponse("); const openEnd = source.indexOf("async function ensureSandboxTerminalPrepared", openStart); assert.match(source.slice(openStart, openEnd), /terminalSession\.terminal\(request, options\)/); - assert.match(source, /request = sanitizeTrustedProxyRequest\(request, env\)/); - assert.match( - source, - /trustedProxy\.kind === "authenticated"[\s\S]*headers\.delete\("authorization"\)/, - ); }); test("trusted proxy sign-in cannot pretend that local logout will end the session", async () => { @@ -69,24 +36,6 @@ test("trusted proxy sign-in cannot pretend that local logout will end the sessio assert.match(source, /Signed in by your organization/); }); -test("service-token routes bypass only the mandatory proxy assertion", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const serviceStart = source.indexOf("function usesIndependentServiceAuth("); - const serviceEnd = source.indexOf("async function tokenLogin", serviceStart); - const serviceSource = source.slice(serviceStart, serviceEnd); - for (const prefix of ["/api/ssh/", "/api/agent/", "/api/openclaw/", "/api/provision/"]) { - assert.match(serviceSource, new RegExp(prefix.replaceAll("/", "\\/"))); - } - assert.match(serviceSource, /pathname === "\/api\/terminal\/ws"/); - assert.match(serviceSource, /hasAuthorization && \(hasSshIdentity \|\| hasAgentIdentity\)/); - assert.match(source, /requestAuth\.kind === "missing"\) throw unauthorized\(\)/); - assert.match(source, /trustedProxy\.kind === "rejected"/); - assert.match( - source, - /if \(!usesIndependentServiceAuth\(request\)\) headers\.delete\("authorization"\)/, - ); -}); - test("split-origin links use the browser-visible proxy origin", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); assert.match(source, /trustedProxyPublicOrigin\(env\) \?\? new URL\(request\.url\)\.origin/); From 2a2c290887b1a584c0e985de2495c15e83d34be7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:06:46 +0100 Subject: [PATCH 006/109] refactor: extract Worker authentication --- CHANGELOG.md | 1 + src/index.ts | 373 ++---------------------- src/worker/auth.ts | 297 +++++++++++++++++++ src/worker/crypto.ts | 72 +++++ src/worker/models.ts | 10 + tests/auth.test.ts | 224 ++++++++++++++ tests/runtime-adapter.test.ts | 26 -- tests/trusted-proxy-integration.test.ts | 6 +- 8 files changed, 625 insertions(+), 384 deletions(-) create mode 100644 src/worker/auth.ts create mode 100644 src/worker/crypto.ts create mode 100644 tests/auth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bce558..596a731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Give Worker users, allowlist roles, cookie sessions, trusted-proxy identities, session-owned GitHub credentials, and secret encryption dedicated auth modules with behavioral coverage. - Isolate Worker ingress authentication, trusted-proxy credential stripping, and independent service-route policy behind direct behavioral tests. - Centralize Worker HTTP responses, security headers, status errors, JSON parsing, bearer authentication, and cookie handling behind a directly tested module. - Give shared Worker models and the complete Kysely/D1 schema, dialect, factory, and batch execution dedicated foundation modules. diff --git a/src/index.ts b/src/index.ts index f35f102..a55c0f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,7 +109,6 @@ import { type AdapterWorkspaceResult, } from "./runtime-adapter"; import { allocateInteractiveSessionIdSql, formatInteractiveSessionId } from "./session-id"; -import { developmentIdentityEnabled } from "./url-security"; import { preferredEnabledRepo } from "./repo-selection"; import { sandboxGitAuthorEmail } from "./git-identity"; import { completeTerminalFinalization } from "./terminal-finalization"; @@ -129,10 +128,8 @@ import { } from "./openclaw-service"; import { sanitizeTrustedProxyRequest, - trustedProxyConfigured, trustedProxyPublicOrigin, type TrustedProxyAuthResult, - type TrustedProxyIdentity, } from "./trusted-proxy-auth"; import { credentialPolicyCleanupMatches, @@ -176,6 +173,7 @@ import type { InteractiveSessionStatus, Role, RunStatus, + User, WorkflowStatus, } from "./worker/models"; import { @@ -198,6 +196,24 @@ import { wantsMarkdown, } from "./worker/http"; import { enforceWorkerIngressAuth, prepareWorkerIngress } from "./worker/ingress"; +import { + actor, + authMethods, + authorize, + bootstrapSubject, + createSession, + devIdentityEnabled, + devIdentityId, + githubSessionSeconds, + logout, + optionalUser, + parseRole, + requireRole, + requireUser, + sessionGitHubToken, + upsertUser, +} from "./worker/auth"; +import { base64FromBytes, openSecret, sealSecret, sha256 } from "./worker/crypto"; const defaultInteractiveCommand = "codex --yolo"; @@ -237,16 +253,6 @@ export class Sandbox extends CloudflareSandboxBase { export { ContainerProxy }; -type User = { - subject: string; - login: string | null; - email: string | null; - name: string | null; - role: Role; - allowed: boolean; - teams: string[]; -}; - type GitHubProfile = { id: number; login: string; @@ -654,13 +660,9 @@ type CardChanges = { }; const encoder = new TextEncoder(); -const decoder = new TextDecoder(); const terminalInputStates = new Map(); -const sessionCookie = "crabbox_session"; const oauthStateCookie = "crabbox_oauth_state"; const sshLinkCookie = "crabbox_ssh_link"; -const bootstrapSessionSeconds = 60 * 60; -const githubSessionSeconds = 60 * 15; const sshLinkSeconds = 5 * 60; const terminalClipboardMaxBytes = 10 * 1024 * 1024; const lanes = ["Todo", "Running", "Human Review", "Done"]; @@ -672,7 +674,6 @@ const deadInteractiveSessionStatuses: readonly InteractiveSessionStatus[] = [ "expired", "failed", ]; -const roleOptions = new Set(["viewer", "maintainer", "owner"]); const runtimeOptions = ["auto", "container", "crabbox"] as const; const mergePolicyOptions = ["open_pr", "merge_when_green", "fix_until_green_and_merge"] as const; const defaultStallMs = 5 * 60 * 1000; @@ -2141,7 +2142,7 @@ async function devIdentityLogin(request: Request, env: RuntimeEnv): Promise(request); const id = devIdentityId(body.id); - const role = roleOptions.has(body.role as Role) ? (body.role as Role) : "owner"; + const role = parseRole(body.role); const user: User = { subject: `dev:${id}`, login: id, @@ -2411,100 +2412,6 @@ function sshLinkConfirmHtml( `; } -async function logout(request: Request, env: RuntimeEnv): Promise { - const token = cookies(request).get(sessionCookie); - if (token) { - await database(env) - .deleteFrom("sessions") - .where("token_hash", "=", await sha256(token)) - .execute(); - } - return json({ ok: true }, { headers: { "set-cookie": cookie(request, sessionCookie, "", 0) } }); -} - -async function requireUser( - request: Request, - env: RuntimeEnv, - requestAuth: TrustedProxyAuthResult, -): Promise { - if (requestAuth.kind === "authenticated") { - return requireTrustedProxyUser(env, requestAuth.identity); - } - if (requestAuth.kind === "missing") throw unauthorized(); - - const token = cookies(request).get(sessionCookie); - if (!token) throw unauthorized(); - const tokenHash = await sha256(token); - const db = database(env); - const row = await db - .selectFrom("sessions as s") - .innerJoin("users as u", "u.subject", "s.subject") - .select(["u.subject", "u.login", "u.email", "u.name", "u.role", "u.allowed", "u.teams"]) - .where("s.token_hash", "=", tokenHash) - .where("s.expires_at", ">", Date.now()) - .executeTakeFirst(); - if (!row) throw unauthorized(); - - const user = { - subject: row.subject, - login: row.login, - email: row.email, - name: row.name, - role: row.role, - allowed: row.allowed === 1, - teams: parseJson(row.teams, []), - }; - - if (user.subject.startsWith("bootstrap:")) { - if (!env.CRABBOX_BOOTSTRAP_TOKEN || user.subject !== (await bootstrapSubject(env))) { - await db.deleteFrom("sessions").where("token_hash", "=", tokenHash).execute(); - throw unauthorized(); - } - return user; - } - - if (user.subject.startsWith("dev:")) { - if (!devIdentityEnabled(env, request)) { - await db.deleteFrom("sessions").where("token_hash", "=", tokenHash).execute(); - throw unauthorized(); - } - return user; - } - - if (!user.subject.startsWith("github:")) return user; - - const authorized = await authorize(env, user); - if (!authorized.allowed) { - await db.deleteFrom("sessions").where("token_hash", "=", tokenHash).execute(); - throw forbidden("user is no longer allowlisted"); - } - if (authorized.role !== user.role || authorized.allowed !== user.allowed) { - await upsertUser(env, authorized, Date.now()); - } - return authorized; -} - -async function optionalUser( - request: Request, - env: RuntimeEnv, - requestAuth: TrustedProxyAuthResult, -): Promise { - try { - return await requireUser(request, env, requestAuth); - } catch (error) { - const status = - typeof error === "object" && error && "status" in error ? Number(error.status) : 0; - const url = new URL(request.url); - if ( - status === 401 || - (status === 403 && url.pathname === "/api/terminal/ws" && url.searchParams.has("token")) - ) { - return null; - } - throw error; - } -} - async function terminalHubUser( request: Request, env: RuntimeEnv, @@ -2519,39 +2426,6 @@ async function terminalHubUser( return optionalUser(request, env, requestAuth); } -async function requireTrustedProxyUser( - env: RuntimeEnv, - identity: TrustedProxyIdentity, -): Promise { - const user = await authorize(env, { - subject: identity.subject, - login: identity.login, - email: identity.email, - name: identity.name, - role: "viewer", - allowed: false, - teams: [], - }); - if (!user.allowed) throw forbidden("trusted proxy user is not allowlisted"); - - const existing = await database(env) - .selectFrom("users") - .select(["login", "email", "name", "role", "allowed"]) - .where("subject", "=", user.subject) - .executeTakeFirst(); - if ( - !existing || - existing.login !== user.login || - existing.email !== user.email || - existing.name !== user.name || - existing.role !== user.role || - existing.allowed !== 1 - ) { - await upsertUser(env, user, Date.now()); - } - return user; -} - async function sshAuth(request: Request, env: RuntimeEnv): Promise> { requireSshGateway(request, env); const body = await readJson<{ @@ -14502,101 +14376,6 @@ async function readSettings(env: RuntimeEnv): Promise> { return Object.fromEntries(rows.map((row) => [row.key, row.value])); } -async function authorize(env: RuntimeEnv, user: User): Promise { - const entries = await database(env) - .selectFrom("allow_entries") - .select(["value", "role"]) - .execute(); - const candidates = new Set([ - user.login ? `@${user.login.toLowerCase()}` : "", - user.email ? user.email.toLowerCase() : "", - ...user.teams.map((team) => team.toLowerCase()), - ]); - let role: Role | null = null; - for (const row of entries) { - if (!candidates.has(row.value.toLowerCase())) continue; - role = strongerRole(role, row.role); - } - return { ...user, role: role ?? "viewer", allowed: role !== null }; -} - -async function upsertUser(env: RuntimeEnv, user: User, now: number): Promise { - const row = { - subject: user.subject, - login: user.login, - email: user.email, - name: user.name, - role: user.role, - allowed: user.allowed ? 1 : 0, - teams: JSON.stringify(user.teams), - created_at: now, - updated_at: now, - last_seen_at: now, - }; - await database(env) - .insertInto("users") - .values(row) - .onConflict((oc) => - oc.column("subject").doUpdateSet({ - login: row.login, - email: row.email, - name: row.name, - role: row.role, - allowed: row.allowed, - teams: row.teams, - updated_at: row.updated_at, - last_seen_at: row.last_seen_at, - }), - ) - .execute(); -} - -async function createSession( - env: RuntimeEnv, - request: Request, - subject: string, - now: number, - maxAgeSeconds = bootstrapSessionSeconds, - githubToken?: string, -): Promise { - const token = crypto.randomUUID() + crypto.randomUUID(); - const tokenHash = await sha256(token); - const expires = now + maxAgeSeconds * 1000; - const githubTokenCiphertext = githubToken ? await sealSecret(env, githubToken) : null; - const db = database(env); - await db.deleteFrom("sessions").where("expires_at", "<", now).execute(); - await db - .insertInto("sessions") - .values({ - token_hash: tokenHash, - subject, - expires_at: expires, - created_at: now, - github_token_ciphertext: githubTokenCiphertext, - }) - .execute(); - return cookie(request, sessionCookie, token, maxAgeSeconds); -} - -async function sessionGitHubToken( - request: Request, - env: RuntimeEnv, - expectedSubject: string, -): Promise { - const token = cookies(request).get(sessionCookie); - if (!token) return undefined; - const row = await database(env) - .selectFrom("sessions") - .select("github_token_ciphertext") - .where("token_hash", "=", await sha256(token)) - .where("subject", "=", expectedSubject) - .where("expires_at", ">", Date.now()) - .executeTakeFirst(); - return row?.github_token_ciphertext - ? ((await openSecret(env, row.github_token_ciphertext)) ?? undefined) - : undefined; -} - async function sandboxSessionWithGitHubToken( request: Request, env: RuntimeEnv, @@ -14611,45 +14390,6 @@ async function sandboxSessionWithGitHubToken( return githubToken ? { ...session, githubToken } : session; } -async function sealSecret(env: RuntimeEnv, value: string): Promise { - const key = await secretEncryptionKey(env); - if (!key) return null; - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - encoder.encode(value), - ); - return `v1.${base64UrlFromBytes(iv)}.${base64UrlFromBytes(new Uint8Array(ciphertext))}`; -} - -async function openSecret(env: RuntimeEnv, sealed: string): Promise { - const [version, iv, ciphertext] = sealed.split("."); - if (version !== "v1" || !iv || !ciphertext) return null; - const key = await secretEncryptionKey(env); - if (!key) return null; - try { - const plaintext = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: bytesFromBase64Url(iv) }, - key, - bytesFromBase64Url(ciphertext), - ); - return decoder.decode(plaintext); - } catch { - return null; - } -} - -async function secretEncryptionKey(env: RuntimeEnv): Promise { - const material = env.CRABBOX_TOKEN_ENCRYPTION_KEY || env.GITHUB_CLIENT_SECRET; - if (!material) return null; - const digest = await crypto.subtle.digest( - "SHA-256", - encoder.encode(`crabbox-secret-v1:${material}`), - ); - return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]); -} - async function nextCardId(env: RuntimeEnv): Promise { const row = await database(env) .selectFrom("cards") @@ -14938,45 +14678,6 @@ class GitHubApiError extends Error { } } -function requireRole(user: User, needed: Role): void { - const rank: Record = { viewer: 1, maintainer: 2, owner: 3 }; - if (rank[user.role] < rank[needed]) throw forbidden("insufficient role"); -} - -function strongerRole(left: Role | null, right: Role): Role { - const rank: Record = { viewer: 1, maintainer: 2, owner: 3 }; - if (!left) return right; - return rank[right] > rank[left] ? right : left; -} - -function authMethods(env: RuntimeEnv, request?: Request): Record { - return { - github: Boolean(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET), - token: Boolean(env.CRABBOX_BOOTSTRAP_TOKEN), - devIdentity: request ? devIdentityEnabled(env, request) : false, - trustedProxy: trustedProxyConfigured(env), - }; -} - -function devIdentityEnabled(env: RuntimeEnv, request: Request): boolean { - return developmentIdentityEnabled(env.CRABFLEET_DEV_LOGIN_ENABLED, request.url); -} - -function actor(user: Pick): string { - return user.login ?? user.email ?? user.subject; -} - -function devIdentityId(value: unknown): string { - const id = String(value ?? "") - .trim() - .toLowerCase() - .replace(/^dev:/, "") - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80); - return id || "dev"; -} - function parseJson(value: string, fallback: T): T { try { return JSON.parse(value) as T; @@ -15567,16 +15268,6 @@ function systemUser(): User { }; } -async function sha256(value: string): Promise { - const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -async function bootstrapSubject(env: RuntimeEnv): Promise { - if (!env.CRABBOX_BOOTSTRAP_TOKEN) throw unauthorized(); - return `bootstrap:${(await sha256(env.CRABBOX_BOOTSTRAP_TOKEN)).slice(0, 24)}`; -} - function normalizeRepo(value: unknown): string { return String(value ?? "") .trim() @@ -15616,30 +15307,6 @@ function decodeHeaderValue(value: string | null): string { } } -function base64FromBytes(bytes: Uint8Array): string { - let binary = ""; - const chunkSize = 0x8000; - for (let offset = 0; offset < bytes.length; offset += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); - } - return btoa(binary); -} - -function base64UrlFromBytes(bytes: Uint8Array): string { - return base64FromBytes(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); -} - -function bytesFromBase64Url(value: string): Uint8Array { - const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); - const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); - const binary = atob(padded); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes; -} - function safeClipboardFilename(value: unknown, mediaType: string): string { const raw = String(value ?? "") diff --git a/src/worker/auth.ts b/src/worker/auth.ts new file mode 100644 index 0000000..78ca3bb --- /dev/null +++ b/src/worker/auth.ts @@ -0,0 +1,297 @@ +import { developmentIdentityEnabled } from "../url-security.ts"; +import { + trustedProxyConfigured, + type TrustedProxyAuthResult, + type TrustedProxyIdentity, +} from "../trusted-proxy-auth.ts"; +import { openSecret, sealSecret, sha256 } from "./crypto.ts"; +import { database } from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { cookie, cookies, forbidden, json, unauthorized } from "./http.ts"; +import type { Role, User } from "./models.ts"; + +const sessionCookie = "crabbox_session"; +const bootstrapSessionSeconds = 60 * 60; +export const githubSessionSeconds = 60 * 15; +const roleRank: Record = { viewer: 1, maintainer: 2, owner: 3 }; +const roleOptions = new Set(["viewer", "maintainer", "owner"]); + +export async function requireUser( + request: Request, + env: RuntimeEnv, + requestAuth: TrustedProxyAuthResult, +): Promise { + if (requestAuth.kind === "authenticated") { + return requireTrustedProxyUser(env, requestAuth.identity); + } + if (requestAuth.kind === "missing") throw unauthorized(); + + const token = cookies(request).get(sessionCookie); + if (!token) throw unauthorized(); + const tokenHash = await sha256(token); + const db = database(env); + const row = await db + .selectFrom("sessions as s") + .innerJoin("users as u", "u.subject", "s.subject") + .select(["u.subject", "u.login", "u.email", "u.name", "u.role", "u.allowed", "u.teams"]) + .where("s.token_hash", "=", tokenHash) + .where("s.expires_at", ">", Date.now()) + .executeTakeFirst(); + if (!row) throw unauthorized(); + + const user: User = { + subject: row.subject, + login: row.login, + email: row.email, + name: row.name, + role: row.role, + allowed: row.allowed === 1, + teams: parseJson(row.teams, []), + }; + + if (user.subject.startsWith("bootstrap:")) { + if (!env.CRABBOX_BOOTSTRAP_TOKEN || user.subject !== (await bootstrapSubject(env))) { + await deleteSession(db, tokenHash); + throw unauthorized(); + } + return user; + } + + if (user.subject.startsWith("dev:")) { + if (!devIdentityEnabled(env, request)) { + await deleteSession(db, tokenHash); + throw unauthorized(); + } + return user; + } + + if (!user.subject.startsWith("github:")) return user; + + const authorized = await authorize(env, user); + if (!authorized.allowed) { + await deleteSession(db, tokenHash); + throw forbidden("user is no longer allowlisted"); + } + if (authorized.role !== user.role || authorized.allowed !== user.allowed) { + await upsertUser(env, authorized, Date.now()); + } + return authorized; +} + +export async function optionalUser( + request: Request, + env: RuntimeEnv, + requestAuth: TrustedProxyAuthResult, +): Promise { + try { + return await requireUser(request, env, requestAuth); + } catch (error) { + const status = + typeof error === "object" && error && "status" in error ? Number(error.status) : 0; + const url = new URL(request.url); + if ( + status === 401 || + (status === 403 && url.pathname === "/api/terminal/ws" && url.searchParams.has("token")) + ) { + return null; + } + throw error; + } +} + +export async function authorize(env: RuntimeEnv, user: User): Promise { + const entries = await database(env) + .selectFrom("allow_entries") + .select(["value", "role"]) + .execute(); + const candidates = new Set([ + user.login ? `@${user.login.toLowerCase()}` : "", + user.email ? user.email.toLowerCase() : "", + ...user.teams.map((team) => team.toLowerCase()), + ]); + let role: Role | null = null; + for (const row of entries) { + if (!candidates.has(row.value.toLowerCase())) continue; + role = strongerRole(role, row.role); + } + return { ...user, role: role ?? "viewer", allowed: role !== null }; +} + +export async function upsertUser(env: RuntimeEnv, user: User, now: number): Promise { + const row = { + subject: user.subject, + login: user.login, + email: user.email, + name: user.name, + role: user.role, + allowed: user.allowed ? 1 : 0, + teams: JSON.stringify(user.teams), + created_at: now, + updated_at: now, + last_seen_at: now, + }; + await database(env) + .insertInto("users") + .values(row) + .onConflict((oc) => + oc.column("subject").doUpdateSet({ + login: row.login, + email: row.email, + name: row.name, + role: row.role, + allowed: row.allowed, + teams: row.teams, + updated_at: row.updated_at, + last_seen_at: row.last_seen_at, + }), + ) + .execute(); +} + +export async function createSession( + env: RuntimeEnv, + request: Request, + subject: string, + now: number, + maxAgeSeconds = bootstrapSessionSeconds, + githubToken?: string, +): Promise { + const token = crypto.randomUUID() + crypto.randomUUID(); + const tokenHash = await sha256(token); + const expires = now + maxAgeSeconds * 1000; + const githubTokenCiphertext = githubToken ? await sealSecret(env, githubToken) : null; + const db = database(env); + await db.deleteFrom("sessions").where("expires_at", "<", now).execute(); + await db + .insertInto("sessions") + .values({ + token_hash: tokenHash, + subject, + expires_at: expires, + created_at: now, + github_token_ciphertext: githubTokenCiphertext, + }) + .execute(); + return cookie(request, sessionCookie, token, maxAgeSeconds); +} + +export async function sessionGitHubToken( + request: Request, + env: RuntimeEnv, + expectedSubject: string, +): Promise { + const token = cookies(request).get(sessionCookie); + if (!token) return undefined; + const row = await database(env) + .selectFrom("sessions") + .select("github_token_ciphertext") + .where("token_hash", "=", await sha256(token)) + .where("subject", "=", expectedSubject) + .where("expires_at", ">", Date.now()) + .executeTakeFirst(); + return row?.github_token_ciphertext + ? ((await openSecret(env, row.github_token_ciphertext)) ?? undefined) + : undefined; +} + +export async function logout(request: Request, env: RuntimeEnv): Promise { + const token = cookies(request).get(sessionCookie); + if (token) { + await database(env) + .deleteFrom("sessions") + .where("token_hash", "=", await sha256(token)) + .execute(); + } + return json({ ok: true }, { headers: { "set-cookie": cookie(request, sessionCookie, "", 0) } }); +} + +export function authMethods(env: RuntimeEnv, request?: Request): Record { + return { + github: Boolean(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET), + token: Boolean(env.CRABBOX_BOOTSTRAP_TOKEN), + devIdentity: request ? devIdentityEnabled(env, request) : false, + trustedProxy: trustedProxyConfigured(env), + }; +} + +export function devIdentityEnabled(env: RuntimeEnv, request: Request): boolean { + return developmentIdentityEnabled(env.CRABFLEET_DEV_LOGIN_ENABLED, request.url); +} + +export function devIdentityId(value: unknown): string { + const id = String(value ?? "") + .trim() + .toLowerCase() + .replace(/^dev:/, "") + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return id || "dev"; +} + +export function parseRole(value: unknown, fallback: Role = "owner"): Role { + return roleOptions.has(value as Role) ? (value as Role) : fallback; +} + +export function requireRole(user: User, needed: Role): void { + if (roleRank[user.role] < roleRank[needed]) throw forbidden("insufficient role"); +} + +export function actor(user: Pick): string { + return user.login ?? user.email ?? user.subject; +} + +export async function bootstrapSubject(env: RuntimeEnv): Promise { + if (!env.CRABBOX_BOOTSTRAP_TOKEN) throw unauthorized(); + return `bootstrap:${(await sha256(env.CRABBOX_BOOTSTRAP_TOKEN)).slice(0, 24)}`; +} + +async function requireTrustedProxyUser( + env: RuntimeEnv, + identity: TrustedProxyIdentity, +): Promise { + const user = await authorize(env, { + subject: identity.subject, + login: identity.login, + email: identity.email, + name: identity.name, + role: "viewer", + allowed: false, + teams: [], + }); + if (!user.allowed) throw forbidden("trusted proxy user is not allowlisted"); + + const existing = await database(env) + .selectFrom("users") + .select(["login", "email", "name", "role", "allowed"]) + .where("subject", "=", user.subject) + .executeTakeFirst(); + if ( + !existing || + existing.login !== user.login || + existing.email !== user.email || + existing.name !== user.name || + existing.role !== user.role || + existing.allowed !== 1 + ) { + await upsertUser(env, user, Date.now()); + } + return user; +} + +function strongerRole(left: Role | null, right: Role): Role { + if (!left) return right; + return roleRank[right] > roleRank[left] ? right : left; +} + +function parseJson(value: string, fallback: T): T { + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +async function deleteSession(db: ReturnType, tokenHash: string): Promise { + await db.deleteFrom("sessions").where("token_hash", "=", tokenHash).execute(); +} diff --git a/src/worker/crypto.ts b/src/worker/crypto.ts new file mode 100644 index 0000000..b9da2a1 --- /dev/null +++ b/src/worker/crypto.ts @@ -0,0 +1,72 @@ +import type { RuntimeEnv } from "./env.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function sealSecret(env: RuntimeEnv, value: string): Promise { + const key = await secretEncryptionKey(env); + if (!key) return null; + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + encoder.encode(value), + ); + return `v1.${base64UrlFromBytes(iv)}.${base64UrlFromBytes(new Uint8Array(ciphertext))}`; +} + +export async function openSecret(env: RuntimeEnv, sealed: string): Promise { + const [version, iv, ciphertext] = sealed.split("."); + if (version !== "v1" || !iv || !ciphertext) return null; + const key = await secretEncryptionKey(env); + if (!key) return null; + try { + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: bytesFromBase64Url(iv) }, + key, + bytesFromBase64Url(ciphertext), + ); + return decoder.decode(plaintext); + } catch { + return null; + } +} + +export function base64FromBytes(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + } + return btoa(binary); +} + +async function secretEncryptionKey(env: RuntimeEnv): Promise { + const material = env.CRABBOX_TOKEN_ENCRYPTION_KEY || env.GITHUB_CLIENT_SECRET; + if (!material) return null; + const digest = await crypto.subtle.digest( + "SHA-256", + encoder.encode(`crabbox-secret-v1:${material}`), + ); + return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]); +} + +function base64UrlFromBytes(bytes: Uint8Array): string { + return base64FromBytes(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function bytesFromBase64Url(value: string): Uint8Array { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} diff --git a/src/worker/models.ts b/src/worker/models.ts index 6328f82..506de1c 100644 --- a/src/worker/models.ts +++ b/src/worker/models.ts @@ -1,5 +1,15 @@ export type Role = "viewer" | "maintainer" | "owner"; +export type User = { + subject: string; + login: string | null; + email: string | null; + name: string | null; + role: Role; + allowed: boolean; + teams: string[]; +}; + export type InteractiveRuntime = "crabbox" | "container" | "github_actions"; export type WorkflowStatus = "ok" | "missing" | "invalid" | "error"; diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..850f45a --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + actor, + authMethods, + authorize, + bootstrapSubject, + createSession, + devIdentityId, + logout, + parseRole, + requireRole, + requireUser, + sessionGitHubToken, +} from "../src/worker/auth.ts"; +import { openSecret, sealSecret, sha256 } from "../src/worker/crypto.ts"; +import type { RuntimeEnv } from "../src/worker/env.ts"; +import type { User } from "../src/worker/models.ts"; + +type D1Result = { results?: unknown[]; changes?: number }; +type D1Handler = (sql: string, parameters: unknown[], kind: "all" | "run") => D1Result; + +function d1(handler: D1Handler): D1Database { + return { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + return { + async all() { + const result = handler(sql, parameters, "all"); + return { results: result.results ?? [], meta: { changes: result.changes ?? 0 } }; + }, + async run() { + const result = handler(sql, parameters, "run"); + return { meta: { changes: result.changes ?? 0 } }; + }, + }; + }, + }; + }, + } as unknown as D1Database; +} + +function runtimeEnv(db: D1Database, values: Partial = {}): RuntimeEnv { + return { DB: db, ...values } as RuntimeEnv; +} + +function user(values: Partial = {}): User { + return { + subject: "github:42", + login: "owner", + email: "owner@example.com", + name: "Owner", + role: "viewer", + allowed: false, + teams: ["@openclaw/core"], + ...values, + }; +} + +test("allowlist authorization selects the strongest matching identity role", async () => { + const env = runtimeEnv( + d1((sql) => { + assert.match(sql, /from "allow_entries"/i); + return { + results: [ + { value: "@owner", role: "viewer" }, + { value: "owner@example.com", role: "maintainer" }, + { value: "@openclaw/core", role: "owner" }, + { value: "@someone-else", role: "owner" }, + ], + }; + }), + ); + + assert.deepEqual(await authorize(env, user()), { ...user(), role: "owner", allowed: true }); +}); + +test("development sessions are invalidated when the local login gate is unavailable", async () => { + let deletedTokenHash: unknown; + const token = "dev-session-token"; + const env = runtimeEnv( + d1((sql, parameters, kind) => { + if (kind === "all" && /from "sessions" as "s"/i.test(sql)) { + return { + results: [ + { + subject: "dev:operator", + login: "operator", + email: null, + name: "Operator", + role: "owner", + allowed: 1, + teams: "[]", + }, + ], + }; + } + if (kind === "run" && /^delete from "sessions"/i.test(sql)) { + deletedTokenHash = parameters[0]; + return { changes: 1 }; + } + throw new Error(`unexpected query: ${sql}`); + }), + { CRABFLEET_DEV_LOGIN_ENABLED: "false" }, + ); + + await assert.rejects( + requireUser( + new Request("https://fleet.example/api/session", { + headers: { cookie: `crabbox_session=${token}` }, + }), + env, + { kind: "disabled" }, + ), + (error: unknown) => + error instanceof Error && + error.message === "unauthorized" && + "status" in error && + error.status === 401, + ); + assert.equal(deletedTokenHash, await sha256(token)); +}); + +test("session GitHub credentials are bound to the authenticated subject", async () => { + const token = "browser-session-token"; + const secretEnv = runtimeEnv({} as D1Database, { + CRABBOX_TOKEN_ENCRYPTION_KEY: "encryption-key", + }); + const ciphertext = await sealSecret(secretEnv, "github-token"); + assert.ok(ciphertext); + + const env = runtimeEnv( + d1((sql, parameters) => { + assert.match(sql, /from "sessions"/i); + assert.match(sql, /"subject" = \?/i); + return parameters.includes("github:42") + ? { results: [{ github_token_ciphertext: ciphertext }] } + : { results: [] }; + }), + { CRABBOX_TOKEN_ENCRYPTION_KEY: "encryption-key" }, + ); + const request = new Request("https://fleet.example/api/session", { + headers: { cookie: `crabbox_session=${token}` }, + }); + + assert.equal(await sessionGitHubToken(request, env, "github:42"), "github-token"); + assert.equal(await sessionGitHubToken(request, env, "proxy:owner@example.com"), undefined); +}); + +test("session creation and logout persist only hashed browser tokens", async () => { + const writes: Array<{ sql: string; parameters: unknown[] }> = []; + const env = runtimeEnv( + d1((sql, parameters, kind) => { + if (kind === "run") writes.push({ sql, parameters }); + return { changes: 1 }; + }), + { CRABBOX_TOKEN_ENCRYPTION_KEY: "encryption-key" }, + ); + const request = new Request("https://fleet.example/api/session"); + const session = await createSession(env, request, "github:42", 1_000, 900, "github-token"); + const cookieValue = session.match(/^crabbox_session=([^;]+)/)?.[1]; + assert.ok(cookieValue); + assert.equal(writes.length, 2); + assert.match(writes[0]?.sql ?? "", /^delete from "sessions"/i); + assert.match(writes[1]?.sql ?? "", /^insert into "sessions"/i); + assert.equal(writes[1]?.parameters.includes(cookieValue), false); + assert.ok(writes[1]?.parameters.includes(await sha256(decodeURIComponent(cookieValue)))); + + const response = await logout( + new Request("https://fleet.example/api/logout", { + headers: { cookie: `crabbox_session=${cookieValue}` }, + }), + env, + ); + assert.equal(response.status, 200); + assert.match(response.headers.get("set-cookie") ?? "", /Max-Age=0/); + assert.match(writes.at(-1)?.sql ?? "", /^delete from "sessions"/i); +}); + +test("auth policy helpers normalize identities, advertise configured methods, and enforce roles", async () => { + assert.equal(devIdentityId(" DEV:Jane Doe "), "jane-doe"); + assert.equal(devIdentityId("***"), "dev"); + assert.equal(parseRole("maintainer"), "maintainer"); + assert.equal(parseRole("invalid"), "owner"); + assert.equal(actor(user({ login: null })), "owner@example.com"); + + const configured = runtimeEnv({} as D1Database, { + CRABBOX_BOOTSTRAP_TOKEN: "bootstrap", + GITHUB_CLIENT_ID: "client", + GITHUB_CLIENT_SECRET: "secret", + CRABFLEET_DEV_LOGIN_ENABLED: "true", + CRABFLEET_TRUSTED_PROXY_ORIGIN: "https://backend.example", + CRABFLEET_TRUSTED_PROXY_SECRET: "proxy-secret", + }); + assert.deepEqual(authMethods(configured, new Request("http://127.0.0.1:8787/api/auth/config")), { + github: true, + token: true, + devIdentity: true, + trustedProxy: true, + }); + assert.match(await bootstrapSubject(configured), /^bootstrap:[a-f0-9]{24}$/); + + assert.doesNotThrow(() => requireRole(user({ role: "owner" }), "maintainer")); + assert.throws(() => requireRole(user({ role: "viewer" }), "maintainer"), { + message: "insufficient role", + }); + + const config = await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"); + assert.match(config, /"CRABFLEET_DEV_LOGIN_ENABLED": "false"/); +}); + +test("secret encryption round-trips with the configured key and fails closed", async () => { + const env = runtimeEnv({} as D1Database, { + CRABBOX_TOKEN_ENCRYPTION_KEY: "encryption-key", + }); + const sealed = await sealSecret(env, "credential"); + assert.match(sealed ?? "", /^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); + assert.equal(await openSecret(env, sealed ?? ""), "credential"); + assert.equal(await openSecret(env, `${sealed}corrupt`), null); + assert.equal(await sealSecret(runtimeEnv({} as D1Database), "credential"), null); +}); diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 9c7e31c..cf63588 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -867,32 +867,6 @@ test("summary and sharing events invalidate terminal cleanup snapshots", async ( assert.ok(metadataSource.indexOf("eventQuery") < metadataSource.indexOf("updateQuery")); }); -test("development identity login requires an explicit local gate", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const config = await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"); - const loginSource = source.slice( - source.indexOf("async function devIdentityLogin"), - source.indexOf("async function githubLogin"), - ); - const requireSource = source.slice( - source.indexOf("async function requireUser"), - source.indexOf("async function optionalUser"), - ); - const authStart = source.indexOf("function authMethods"); - const authSource = source.slice(authStart, source.indexOf("function actor", authStart)); - - assert.match(source, /function devIdentityEnabled\(env: RuntimeEnv, request: Request\)/); - assert.match(loginSource, /devIdentityEnabled\(env, request\)/); - assert.match(requireSource, /devIdentityEnabled\(env, request\)/); - assert.match(requireSource, /deleteFrom\("sessions"\)/); - assert.match(authSource, /devIdentityEnabled\(env, request\)/); - assert.match( - authSource, - /developmentIdentityEnabled\(env\.CRABFLEET_DEV_LOGIN_ENABLED, request\.url\)/, - ); - assert.match(config, /"CRABFLEET_DEV_LOGIN_ENABLED": "false"/); -}); - test("runtime adapter credentials are preflighted before session allocation", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const createStart = source.indexOf("async function createInteractiveSessionFromInput"); diff --git a/tests/trusted-proxy-integration.test.ts b/tests/trusted-proxy-integration.test.ts index 38221cc..89c68ac 100644 --- a/tests/trusted-proxy-integration.test.ts +++ b/tests/trusted-proxy-integration.test.ts @@ -2,17 +2,13 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { test } from "node:test"; -test("proxy users cannot consume a cookie session GitHub credential", async () => { +test("interactive session creation requests browser credentials only for GitHub users", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const createStart = source.indexOf("async function createInteractiveSession("); const createEnd = source.indexOf("async function createInteractiveSessionFromInput", createStart); const createSource = source.slice(createStart, createEnd); assert.match(createSource, /user\.subject\.startsWith\("github:"\)/); assert.match(createSource, /sessionGitHubToken\(request, env, user\.subject\)/); - - const tokenStart = source.indexOf("async function sessionGitHubToken("); - const tokenEnd = source.indexOf("async function sandboxSessionWithGitHubToken", tokenStart); - assert.match(source.slice(tokenStart, tokenEnd), /\.where\("subject", "=", expectedSubject\)/); }); test("trusted proxy requests stay sanitized on terminal forwarding paths", async () => { From bee69b5a42f1fafd56535274c659a5e23177ac3e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:11:10 +0100 Subject: [PATCH 007/109] refactor: extract GitHub authentication --- CHANGELOG.md | 2 +- src/index.ts | 201 +------------------------------------- src/worker/github-auth.ts | 125 ++++++++++++++++++++++++ src/worker/github.ts | 115 ++++++++++++++++++++++ tests/oauth.test.ts | 92 ++++++++++++++--- 5 files changed, 321 insertions(+), 214 deletions(-) create mode 100644 src/worker/github-auth.ts create mode 100644 src/worker/github.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 596a731..32aa6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Give Worker users, allowlist roles, cookie sessions, trusted-proxy identities, session-owned GitHub credentials, and secret encryption dedicated auth modules with behavioral coverage. +- Give Worker users, allowlist roles, cookie sessions, trusted-proxy identities, GitHub OAuth/API membership, session-owned credentials, and secret encryption dedicated auth modules with behavioral coverage. - Isolate Worker ingress authentication, trusted-proxy credential stripping, and independent service-route policy behind direct behavioral tests. - Centralize Worker HTTP responses, security headers, status errors, JSON parsing, bearer authentication, and cookie handling behind a directly tested module. - Give shared Worker models and the complete Kysely/D1 schema, dialect, factory, and batch execution dedicated foundation modules. diff --git a/src/index.ts b/src/index.ts index a55c0f6..e2409d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,12 +47,7 @@ import { type GitHubActionsWorkState, } from "./github-actions-runtime"; import { githubRequestCanUseRepoCredential, matchesAnyHost } from "./sandbox-security"; -import { - githubOAuthCallbackRequestMatches, - githubOAuthCanonicalLoginUrl, - githubOAuthCanonicalSshLinkUrl, - githubOAuthRedirectUri, -} from "./oauth"; +import { githubOAuthCanonicalSshLinkUrl, githubOAuthRedirectUri } from "./oauth"; import { APP_HTML, GHOSTTY_BROWSER_EXTERNAL_JS, @@ -182,7 +177,6 @@ import { bearerToken, conflict, cookie, - cookies, forbidden, json, notFound, @@ -204,7 +198,6 @@ import { createSession, devIdentityEnabled, devIdentityId, - githubSessionSeconds, logout, optionalUser, parseRole, @@ -214,6 +207,8 @@ import { upsertUser, } from "./worker/auth"; import { base64FromBytes, openSecret, sealSecret, sha256 } from "./worker/crypto"; +import { githubCallback, githubLogin, sshLinkCookie } from "./worker/github-auth"; +import { GitHubApiError, githubFetch, githubHeaders, refreshGitHubUser } from "./worker/github"; const defaultInteractiveCommand = "codex --yolo"; @@ -253,13 +248,6 @@ export class Sandbox extends CloudflareSandboxBase { export { ContainerProxy }; -type GitHubProfile = { - id: number; - login: string; - email: string | null; - name: string | null; -}; - type GitHubIssuePayload = { number: number; title: string; @@ -661,8 +649,6 @@ type CardChanges = { const encoder = new TextEncoder(); const terminalInputStates = new Map(); -const oauthStateCookie = "crabbox_oauth_state"; -const sshLinkCookie = "crabbox_ssh_link"; const sshLinkSeconds = 5 * 60; const terminalClipboardMaxBytes = 10 * 1024 * 1024; const lanes = ["Todo", "Running", "Human Review", "Done"]; @@ -2161,115 +2147,6 @@ async function devIdentityLogin(request: Request, env: RuntimeEnv): Promise { - if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) { - return text("GitHub OAuth is not configured.\n", "text/plain; charset=utf-8", {}, 503); - } - - const url = new URL(request.url); - const redirectUri = githubOAuthRedirectUri(url, env.GITHUB_REDIRECT_URI); - const canonicalLoginUrl = githubOAuthCanonicalLoginUrl(url, env.GITHUB_REDIRECT_URI); - if (canonicalLoginUrl) { - return redirect(canonicalLoginUrl, { "cache-control": "no-store" }); - } - const state = crypto.randomUUID(); - const target = new URL("https://github.com/login/oauth/authorize"); - target.searchParams.set("client_id", env.GITHUB_CLIENT_ID); - target.searchParams.set("redirect_uri", redirectUri); - target.searchParams.set("scope", "read:user read:org repo"); - target.searchParams.set("state", state); - - return redirect(target.toString(), { - "set-cookie": cookie(request, oauthStateCookie, state, 600), - }); -} - -async function githubCallback(request: Request, env: RuntimeEnv): Promise { - if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) { - return text("GitHub OAuth is not configured.\n", "text/plain; charset=utf-8", {}, 503); - } - - const url = new URL(request.url); - const redirectUri = githubOAuthRedirectUri(url, env.GITHUB_REDIRECT_URI); - if (!githubOAuthCallbackRequestMatches(url, env.GITHUB_REDIRECT_URI)) { - return text( - "OAuth callback host does not match configured redirect URI.\n", - "text/plain; charset=utf-8", - {}, - 400, - ); - } - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - if (!code || !state || state !== cookies(request).get(oauthStateCookie)) { - return text("Invalid OAuth state.\n", "text/plain; charset=utf-8", {}, 400); - } - - const tokenResponse = await fetch("https://github.com/login/oauth/access_token", { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - "user-agent": "crabbox-ai", - }, - body: JSON.stringify({ - client_id: env.GITHUB_CLIENT_ID, - client_secret: env.GITHUB_CLIENT_SECRET, - code, - redirect_uri: redirectUri, - state, - }), - }); - const tokenBody = await tokenResponse.json<{ access_token?: string; error?: string }>(); - if (!tokenBody.access_token) { - return text( - tokenBody.error ?? "OAuth token exchange failed.\n", - "text/plain; charset=utf-8", - {}, - 401, - ); - } - - const freshUser = await refreshGitHubUser(env, tokenBody.access_token).catch(() => { - throw serviceUnavailable("GitHub membership refresh failed; retry later"); - }); - if (!freshUser) { - return text( - "GitHub user is not an active OpenClaw org member.\n", - "text/plain; charset=utf-8", - {}, - 403, - ); - } - const authorized = await authorize(env, freshUser); - if (!authorized.allowed) { - return text( - "GitHub user is not in the Crabfleet allowlist.\n", - "text/plain; charset=utf-8", - {}, - 403, - ); - } - - const now = Date.now(); - await upsertUser(env, authorized, now); - const session = await createSession( - env, - request, - authorized.subject, - now, - githubSessionSeconds, - tokenBody.access_token, - ); - const pendingSshCode = cookies(request).get(sshLinkCookie); - return redirect( - pendingSshCode ? `/ssh/link/${encodeURIComponent(pendingSshCode)}` : "/app?login=github", - { - "set-cookie": session, - }, - ); -} - async function sshLink( request: Request, env: RuntimeEnv, @@ -14606,78 +14483,6 @@ function eventInsert( .values({ card_id: cardId, actor: actorName, message, created_at: now }); } -async function githubFetch(path: string, token: string, signal?: AbortSignal): Promise { - const response = await fetch(`https://api.github.com${path}`, { - headers: { - ...githubHeaders(), - authorization: `Bearer ${token}`, - }, - ...(signal ? { signal } : {}), - }); - if (!response.ok) throw new GitHubApiError(response.status); - return response.json(); -} - -function githubHeaders(env?: RuntimeEnv): HeadersInit { - return { - accept: "application/vnd.github+json", - ...(env?.GITHUB_TOKEN ? { authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}), - "user-agent": "crabbox-ai", - "x-github-api-version": "2022-11-28", - }; -} - -async function githubFetchPages(path: string, token: string): Promise { - const rows: T[] = []; - for (let page = 1; page <= 10; page += 1) { - const separator = path.includes("?") ? "&" : "?"; - const batch = await githubFetch(`${path}${separator}per_page=100&page=${page}`, token); - rows.push(...batch); - if (batch.length < 100) break; - } - return rows; -} - -async function refreshGitHubUser(env: RuntimeEnv, token: string): Promise { - const org = env.GITHUB_ORG ?? "openclaw"; - const [githubUser, emails, membership, teamRows] = await Promise.all([ - githubFetch("/user", token), - githubFetch>( - "/user/emails", - token, - ).catch(() => []), - githubFetch<{ state: string }>(`/user/memberships/orgs/${org}`, token).catch((error) => { - if (error instanceof GitHubApiError && error.status === 404) return null; - throw error; - }), - githubFetchPages<{ slug: string; organization?: { login?: string } }>("/user/teams", token), - ]); - if (membership?.state !== "active") return null; - const email = - githubUser.email ?? - emails.find((item) => item.primary && item.verified)?.email ?? - emails.find((item) => item.verified)?.email ?? - null; - const teams = teamRows - .filter((team) => (team.organization?.login ?? "").toLowerCase() === org.toLowerCase()) - .map((team) => `@${org}/${team.slug}`); - return { - subject: `github:${githubUser.id}`, - login: githubUser.login, - email, - name: githubUser.name, - role: "viewer", - allowed: false, - teams, - }; -} - -class GitHubApiError extends Error { - constructor(readonly status: number) { - super(`GitHub API failed: ${status}`); - } -} - function parseJson(value: string, fallback: T): T { try { return JSON.parse(value) as T; diff --git a/src/worker/github-auth.ts b/src/worker/github-auth.ts new file mode 100644 index 0000000..aba121f --- /dev/null +++ b/src/worker/github-auth.ts @@ -0,0 +1,125 @@ +import { + githubOAuthCallbackRequestMatches, + githubOAuthCanonicalLoginUrl, + githubOAuthRedirectUri, +} from "../oauth.ts"; +import { authorize, createSession, githubSessionSeconds, upsertUser } from "./auth.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { refreshGitHubUser, type Fetcher } from "./github.ts"; +import { cookie, cookies, redirect, serviceUnavailable, text } from "./http.ts"; + +const oauthStateCookie = "crabbox_oauth_state"; +export const sshLinkCookie = "crabbox_ssh_link"; + +export async function githubLogin(request: Request, env: RuntimeEnv): Promise { + if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) { + return text("GitHub OAuth is not configured.\n", "text/plain; charset=utf-8", {}, 503); + } + + const url = new URL(request.url); + const redirectUri = githubOAuthRedirectUri(url, env.GITHUB_REDIRECT_URI); + const canonicalLoginUrl = githubOAuthCanonicalLoginUrl(url, env.GITHUB_REDIRECT_URI); + if (canonicalLoginUrl) { + return redirect(canonicalLoginUrl, { "cache-control": "no-store" }); + } + const state = crypto.randomUUID(); + const target = new URL("https://github.com/login/oauth/authorize"); + target.searchParams.set("client_id", env.GITHUB_CLIENT_ID); + target.searchParams.set("redirect_uri", redirectUri); + target.searchParams.set("scope", "read:user read:org repo"); + target.searchParams.set("state", state); + + return redirect(target.toString(), { + "set-cookie": cookie(request, oauthStateCookie, state, 600), + }); +} + +export async function githubCallback( + request: Request, + env: RuntimeEnv, + fetcher: Fetcher = fetch, +): Promise { + if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) { + return text("GitHub OAuth is not configured.\n", "text/plain; charset=utf-8", {}, 503); + } + + const url = new URL(request.url); + const redirectUri = githubOAuthRedirectUri(url, env.GITHUB_REDIRECT_URI); + if (!githubOAuthCallbackRequestMatches(url, env.GITHUB_REDIRECT_URI)) { + return text( + "OAuth callback host does not match configured redirect URI.\n", + "text/plain; charset=utf-8", + {}, + 400, + ); + } + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state || state !== cookies(request).get(oauthStateCookie)) { + return text("Invalid OAuth state.\n", "text/plain; charset=utf-8", {}, 400); + } + + const tokenResponse = await fetcher("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + "user-agent": "crabbox-ai", + }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + redirect_uri: redirectUri, + state, + }), + }); + const tokenBody = await tokenResponse.json<{ access_token?: string; error?: string }>(); + if (!tokenBody.access_token) { + return text( + tokenBody.error ?? "OAuth token exchange failed.\n", + "text/plain; charset=utf-8", + {}, + 401, + ); + } + + const freshUser = await refreshGitHubUser(env, tokenBody.access_token, fetcher).catch(() => { + throw serviceUnavailable("GitHub membership refresh failed; retry later"); + }); + if (!freshUser) { + return text( + "GitHub user is not an active OpenClaw org member.\n", + "text/plain; charset=utf-8", + {}, + 403, + ); + } + const authorized = await authorize(env, freshUser); + if (!authorized.allowed) { + return text( + "GitHub user is not in the Crabfleet allowlist.\n", + "text/plain; charset=utf-8", + {}, + 403, + ); + } + + const now = Date.now(); + await upsertUser(env, authorized, now); + const session = await createSession( + env, + request, + authorized.subject, + now, + githubSessionSeconds, + tokenBody.access_token, + ); + const pendingSshCode = cookies(request).get(sshLinkCookie); + return redirect( + pendingSshCode ? `/ssh/link/${encodeURIComponent(pendingSshCode)}` : "/app?login=github", + { + "set-cookie": session, + }, + ); +} diff --git a/src/worker/github.ts b/src/worker/github.ts new file mode 100644 index 0000000..dd39f90 --- /dev/null +++ b/src/worker/github.ts @@ -0,0 +1,115 @@ +import type { RuntimeEnv } from "./env.ts"; +import type { User } from "./models.ts"; + +export type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +type GitHubProfile = { + id: number; + login: string; + email: string | null; + name: string | null; +}; + +export class GitHubApiError extends Error { + readonly status: number; + + constructor(status: number) { + super(`GitHub API failed: ${status}`); + this.status = status; + } +} + +export async function githubFetch( + path: string, + token: string, + signal?: AbortSignal, + fetcher: Fetcher = fetch, +): Promise { + const response = await fetcher(`https://api.github.com${path}`, { + headers: { + ...githubHeaders(), + authorization: `Bearer ${token}`, + }, + ...(signal ? { signal } : {}), + }); + if (!response.ok) throw new GitHubApiError(response.status); + return response.json(); +} + +export function githubHeaders(env?: Pick): HeadersInit { + return { + accept: "application/vnd.github+json", + ...(env?.GITHUB_TOKEN ? { authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}), + "user-agent": "crabbox-ai", + "x-github-api-version": "2022-11-28", + }; +} + +export async function githubFetchPages( + path: string, + token: string, + fetcher: Fetcher = fetch, +): Promise { + const rows: T[] = []; + for (let page = 1; page <= 10; page += 1) { + const separator = path.includes("?") ? "&" : "?"; + const batch = await githubFetch( + `${path}${separator}per_page=100&page=${page}`, + token, + undefined, + fetcher, + ); + rows.push(...batch); + if (batch.length < 100) break; + } + return rows; +} + +export async function refreshGitHubUser( + env: RuntimeEnv, + token: string, + fetcher: Fetcher = fetch, +): Promise { + const org = env.GITHUB_ORG ?? "openclaw"; + const [githubUser, emails, membership, teamRows] = await Promise.all([ + githubFetch("/user", token, undefined, fetcher), + githubFetch>( + "/user/emails", + token, + undefined, + fetcher, + ).catch(() => []), + githubFetch<{ state: string }>( + `/user/memberships/orgs/${org}`, + token, + undefined, + fetcher, + ).catch((error) => { + if (error instanceof GitHubApiError && error.status === 404) return null; + throw error; + }), + githubFetchPages<{ slug: string; organization?: { login?: string } }>( + "/user/teams", + token, + fetcher, + ), + ]); + if (membership?.state !== "active") return null; + const email = + githubUser.email ?? + emails.find((item) => item.primary && item.verified)?.email ?? + emails.find((item) => item.verified)?.email ?? + null; + const teams = teamRows + .filter((team) => (team.organization?.login ?? "").toLowerCase() === org.toLowerCase()) + .map((team) => `@${org}/${team.slug}`); + return { + subject: `github:${githubUser.id}`, + login: githubUser.login, + email, + name: githubUser.name, + role: "viewer", + allowed: false, + teams, + }; +} diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index e5c4018..c69bfb1 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -7,6 +7,9 @@ import { githubOAuthCanonicalSshLinkUrl, githubOAuthRedirectUri, } from "../src/oauth.ts"; +import { githubCallback, githubLogin } from "../src/worker/github-auth.ts"; +import { refreshGitHubUser, type Fetcher } from "../src/worker/github.ts"; +import type { RuntimeEnv } from "../src/worker/env.ts"; test("githubOAuthRedirectUri uses configured callback when present", () => { assert.equal( @@ -123,20 +126,79 @@ test("SSH link state canonicalizes before host-only OAuth cookies", async () => }); test("OAuth initiation and token exchange share the authoritative callback", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const loginStart = source.indexOf("async function githubLogin"); - const callbackStart = source.indexOf("async function githubCallback", loginStart); - const loginSource = source.slice(loginStart, callbackStart); - const callbackEnd = source.indexOf("async function sshLink", callbackStart); - const callbackSource = source.slice(callbackStart, callbackEnd); + const env = { + GITHUB_CLIENT_ID: "client-id", + GITHUB_CLIENT_SECRET: "client-secret", + GITHUB_REDIRECT_URI: "https://fleet.example/auth/github/callback", + } as RuntimeEnv; + + const canonical = await githubLogin(new Request("https://alias.example/login/github"), env); + assert.equal(canonical.headers.get("location"), "https://fleet.example/login/github"); - assert.match(loginSource, /githubOAuthRedirectUri\(url, env\.GITHUB_REDIRECT_URI\)/); - assert.match(loginSource, /githubOAuthCanonicalLoginUrl\(url, env\.GITHUB_REDIRECT_URI\)/); - assert.ok(loginSource.indexOf("canonicalLoginUrl") < loginSource.indexOf("crypto.randomUUID")); - assert.match(callbackSource, /githubOAuthCallbackRequestMatches/); - assert.ok( - callbackSource.indexOf("githubOAuthCallbackRequestMatches") < - callbackSource.indexOf('fetch("https://github.com/login/oauth/access_token"'), - ); - assert.match(callbackSource, /redirect_uri: redirectUri/); + const login = await githubLogin(new Request("https://fleet.example/login/github"), env); + const authorizeUrl = new URL(login.headers.get("location") ?? ""); + assert.equal(authorizeUrl.origin, "https://github.com"); + assert.equal( + authorizeUrl.searchParams.get("redirect_uri"), + "https://fleet.example/auth/github/callback", + ); + assert.match(login.headers.get("set-cookie") ?? "", /^crabbox_oauth_state=/); + + let exchangeCalls = 0; + let exchangeBody: Record | undefined; + const fetcher: Fetcher = async (_input, init) => { + exchangeCalls += 1; + exchangeBody = JSON.parse(String(init?.body)) as Record; + return Response.json({ error: "denied" }, { status: 401 }); + }; + const rejected = await githubCallback( + new Request("https://alias.example/auth/github/callback?code=code&state=state", { + headers: { cookie: "crabbox_oauth_state=state" }, + }), + env, + fetcher, + ); + assert.equal(rejected.status, 400); + assert.equal(exchangeCalls, 0); + + const callback = await githubCallback( + new Request("https://fleet.example/auth/github/callback?code=code&state=state", { + headers: { cookie: "crabbox_oauth_state=state" }, + }), + env, + fetcher, + ); + assert.equal(callback.status, 401); + assert.equal(exchangeCalls, 1); + assert.equal(exchangeBody?.redirect_uri, "https://fleet.example/auth/github/callback"); +}); + +test("GitHub membership refresh builds one normalized organization identity", async () => { + const fetcher: Fetcher = async (input) => { + const url = new URL(String(input)); + const payload = url.pathname.endsWith("/user/emails") + ? [{ email: "owner@example.com", primary: true, verified: true }] + : url.pathname.endsWith("/user/teams") + ? [ + { slug: "core", organization: { login: "OpenClaw" } }, + { slug: "other", organization: { login: "Elsewhere" } }, + ] + : url.pathname.includes("/memberships/orgs/") + ? { state: "active" } + : { id: 42, login: "Owner", email: null, name: "Owner Name" }; + return Response.json(payload); + }; + + assert.deepEqual( + await refreshGitHubUser({ GITHUB_ORG: "OpenClaw" } as RuntimeEnv, "token", fetcher), + { + subject: "github:42", + login: "Owner", + email: "owner@example.com", + name: "Owner Name", + role: "viewer", + allowed: false, + teams: ["@OpenClaw/core"], + }, + ); }); From af3a6fe1dec113ec73c280923e3457b431a5b08c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:16:42 +0100 Subject: [PATCH 008/109] refactor: extract interactive session model --- CHANGELOG.md | 1 + src/index.ts | 231 ++------------------------------- src/worker/session-model.ts | 236 ++++++++++++++++++++++++++++++++++ tests/runtime-adapter.test.ts | 3 - tests/session-model.test.ts | 168 ++++++++++++++++++++++++ 5 files changed, 419 insertions(+), 220 deletions(-) create mode 100644 src/worker/session-model.ts create mode 100644 tests/session-model.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32aa6be..451c485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Centralize interactive-session types, capability defaults, hidden adapter identity, and database row/event/archive mapping in a directly tested model module. - Give Worker users, allowlist roles, cookie sessions, trusted-proxy identities, GitHub OAuth/API membership, session-owned credentials, and secret encryption dedicated auth modules with behavioral coverage. - Isolate Worker ingress authentication, trusted-proxy credential stripping, and independent service-route policy behind direct behavioral tests. - Centralize Worker HTTP responses, security headers, status errors, JSON parsing, bearer authentication, and cookie handling behind a directly tested module. diff --git a/src/index.ts b/src/index.ts index e2409d8..5500bf9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,7 +44,6 @@ import { parseGitHubActionsWorkState, replaceGitHubActionsRunner, githubActionsCapabilities, - type GitHubActionsWorkState, } from "./github-actions-runtime"; import { githubRequestCanUseRepoCredential, matchesAnyHost } from "./sandbox-security"; import { githubOAuthCanonicalSshLinkUrl, githubOAuthRedirectUri } from "./oauth"; @@ -140,7 +139,6 @@ import { resolveRuntimeProfileCodexSsh, runtimeProfileByID, runtimeProfileCapabilities, - type ResolvedRuntimeProfileCodexSsh, } from "./runtime-profiles"; import { browserAppOrigin, @@ -209,6 +207,20 @@ import { import { base64FromBytes, openSecret, sealSecret, sha256 } from "./worker/crypto"; import { githubCallback, githubLogin, sshLinkCookie } from "./worker/github-auth"; import { GitHubApiError, githubFetch, githubHeaders, refreshGitHubUser } from "./worker/github"; +import { + containerCapabilities, + crabboxCapabilities, + interactiveSession, + interactiveSessionAdapterControlPlane, + interactiveSessionEvent, + interactiveSessionLogArchive, + runtimeCapabilities, + type InteractiveSession, + type InteractiveSessionEvent, + type InteractiveSessionEventRow, + type InteractiveSessionLogArchive, + type RuntimeCapabilities, +} from "./worker/session-model"; const defaultInteractiveCommand = "codex --yolo"; @@ -296,15 +308,6 @@ type WorkflowConfig = { promptPrefix?: string; }; -type RuntimeCapabilities = { - terminal: boolean; - takeover: boolean; - vnc: boolean; - desktop: boolean; - logs: boolean; - artifacts: boolean; -}; - type RuntimeDescriptor = { runtime: "container" | "crabbox"; reason: string; @@ -364,92 +367,6 @@ type RunAttempt = { error: string | null; }; -const interactiveSessionAdapterControlPlane = Symbol("interactiveSessionAdapterControlPlane"); - -type InteractiveSession = { - [interactiveSessionAdapterControlPlane]: string | null; - id: string; - parentSessionId: string | null; - rootSessionId: string | null; - repo: string; - branch: string; - runtime: InteractiveRuntime; - adapter: string | null; - profile: string; - adapterWorkspaceId: string | null; - providerResourceId: string | null; - capabilities: RuntimeCapabilities; - expiresAt: number | null; - lastReconciledAt: number | null; - reconcileError: string | null; - command: string; - prompt: string; - purpose: string; - summary: string; - owner: string; - createdBy: string; - status: InteractiveSessionStatus; - leaseId: string | null; - attachUrl: string | null; - vncUrl: string | null; - lastEvent: string; - createdAt: number; - updatedAt: number; - lastSeenAt: number; - stoppedAt: number | null; - shareMode: "private" | "link_read"; - shareTokenPreview: string | null; - controlRequestedBy: string | null; - controlRequestedAt: number | null; - controller: string | null; - controlGrantedAt: number | null; - controlExpiresAt: number | null; - multiplayerMode: boolean; - workKey: string | null; - workKind: string | null; - workState: GitHubActionsWorkState | null; - workPhase: string; - sourceUrl: string | null; - githubRunUrl: string | null; - codexThreadId: string | null; - codexTurnId: string | null; - lastHeartbeatAt: number | null; - completionReason: string | null; - canControl?: boolean; - canManage?: boolean; - canChangeMultiplayer?: boolean; - canRequestControl?: boolean; - sharedReadOnly?: boolean; - ptyAvailable?: boolean; - codexSsh?: ResolvedRuntimeProfileCodexSsh | null; - logs: string[]; - logArchive: InteractiveSessionLogArchive | null; -}; - -type InteractiveSessionLogArchive = { - sessionId: string; - eventCount: number; - eventsKey: string | null; - transcriptKey: string | null; - summaryKey: string | null; - archivedAt: number; - updatedAt: number; -}; - -type InteractiveSessionEvent = { - actor: string; - message: string; - createdAt: number; -}; - -type InteractiveSessionEventRow = { - id: number; - session_id: string; - actor: string; - message: string; - created_at: number; -}; - type SandboxRuntimeSession = (InteractiveProvisionRequest | InteractiveSession) & { githubToken?: string; }; @@ -679,22 +596,6 @@ const credentialPolicyProvisioningStaleMs = 15 * 60_000; const credentialPolicyLegacyGenerationPrefix = "legacy:"; const credentialPolicyLegacyRepairClaimPrefix = "legacy-repair:"; const standaloneSandboxDefaultTtlSeconds = 14_400; -const containerCapabilities: RuntimeCapabilities = { - terminal: true, - takeover: false, - vnc: false, - desktop: false, - logs: true, - artifacts: true, -}; -const crabboxCapabilities: RuntimeCapabilities = { - terminal: true, - takeover: true, - vnc: true, - desktop: true, - logs: true, - artifacts: true, -}; function runtimeAdapterCreateSettings( env: RuntimeEnv, capabilities: RuntimeCapabilities, @@ -14540,88 +14441,6 @@ function runAttempt(row: RunAttemptTable): RunAttempt { }; } -function interactiveSession( - row: InteractiveSessionRow, - logs: string[], - logArchive: InteractiveSessionLogArchive | null = null, -): InteractiveSession { - const capabilities = runtimeCapabilities(row.runtime, row.capabilities_json); - return { - [interactiveSessionAdapterControlPlane]: row.adapter_control_plane, - id: row.id, - parentSessionId: row.parent_session_id, - rootSessionId: row.root_session_id ?? row.id, - repo: row.repo, - branch: row.branch, - runtime: row.runtime, - adapter: row.adapter, - profile: row.profile, - adapterWorkspaceId: row.adapter_workspace_id, - providerResourceId: row.provider_resource_id, - capabilities, - expiresAt: row.expires_at, - lastReconciledAt: row.last_reconciled_at, - reconcileError: row.reconcile_error, - command: row.command, - prompt: row.prompt, - purpose: row.purpose, - summary: row.summary, - owner: row.owner, - createdBy: row.created_by, - status: row.status, - leaseId: row.lease_id, - attachUrl: capabilities.terminal ? row.attach_url : null, - vncUrl: row.vnc_url, - lastEvent: row.last_event, - createdAt: row.created_at, - updatedAt: row.updated_at, - lastSeenAt: row.last_seen_at, - stoppedAt: row.stopped_at, - shareMode: row.share_mode, - shareTokenPreview: row.share_token_preview, - controlRequestedBy: row.control_requested_by, - controlRequestedAt: row.control_requested_at, - controller: row.controller, - controlGrantedAt: row.control_granted_at, - controlExpiresAt: row.control_expires_at, - multiplayerMode: row.multiplayer_mode === 1, - workKey: row.work_key, - workKind: row.work_kind, - workState: parseGitHubActionsWorkState(row.work_state), - workPhase: row.work_phase, - sourceUrl: row.source_url, - githubRunUrl: row.github_run_url, - codexThreadId: row.codex_thread_id, - codexTurnId: row.codex_turn_id, - lastHeartbeatAt: row.last_heartbeat_at, - completionReason: row.completion_reason, - logs, - logArchive, - }; -} - -function interactiveSessionEvent(row: InteractiveSessionEventRow): InteractiveSessionEvent { - return { - actor: row.actor, - message: row.message, - createdAt: row.created_at, - }; -} - -function interactiveSessionLogArchive( - row: InteractiveSessionLogArchiveTable, -): InteractiveSessionLogArchive { - return { - sessionId: row.session_id, - eventCount: row.event_count, - eventsKey: row.events_key, - transcriptKey: row.transcript_key, - summaryKey: row.summary_key, - archivedAt: row.archived_at, - updatedAt: row.updated_at, - }; -} - function sessionLogArchiveBase(id: string): string { return `orgs/openclaw/interactive-sessions/${id.replace(/[^A-Za-z0-9_.-]/g, "_")}`; } @@ -15034,28 +14853,6 @@ function runtimeDescriptor( }; } -function runtimeCapabilities(runtime: string, value: string): RuntimeCapabilities { - const fallback = - runtime === githubActionsRuntime - ? githubActionsCapabilities - : runtime === "crabbox" - ? crabboxCapabilities - : containerCapabilities; - const parsed = parseJson>(value, fallback); - return { - terminal: booleanCapability(parsed.terminal, fallback.terminal), - takeover: booleanCapability(parsed.takeover, fallback.takeover), - vnc: booleanCapability(parsed.vnc, fallback.vnc), - desktop: booleanCapability(parsed.desktop, fallback.desktop), - logs: booleanCapability(parsed.logs, fallback.logs), - artifacts: booleanCapability(parsed.artifacts, fallback.artifacts), - }; -} - -function booleanCapability(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - function stallThresholdMs(settings: Record): number { const parsed = Number(settings.stall_ms); return Number.isFinite(parsed) && parsed >= 60_000 ? parsed : defaultStallMs; diff --git a/src/worker/session-model.ts b/src/worker/session-model.ts new file mode 100644 index 0000000..cd7e97d --- /dev/null +++ b/src/worker/session-model.ts @@ -0,0 +1,236 @@ +import { + githubActionsCapabilities, + githubActionsRuntime, + parseGitHubActionsWorkState, + type GitHubActionsWorkState, +} from "../github-actions-runtime.ts"; +import type { ResolvedRuntimeProfileCodexSsh } from "../runtime-profiles.ts"; +import type { InteractiveSessionLogArchiveTable, InteractiveSessionRow } from "./database.ts"; +import type { InteractiveRuntime, InteractiveSessionStatus } from "./models.ts"; + +export type RuntimeCapabilities = { + terminal: boolean; + takeover: boolean; + vnc: boolean; + desktop: boolean; + logs: boolean; + artifacts: boolean; +}; + +export const containerCapabilities: RuntimeCapabilities = { + terminal: true, + takeover: false, + vnc: false, + desktop: false, + logs: true, + artifacts: true, +}; + +export const crabboxCapabilities: RuntimeCapabilities = { + terminal: true, + takeover: true, + vnc: true, + desktop: true, + logs: true, + artifacts: true, +}; + +export const interactiveSessionAdapterControlPlane = Symbol( + "interactiveSessionAdapterControlPlane", +); + +export type InteractiveSession = { + [interactiveSessionAdapterControlPlane]: string | null; + id: string; + parentSessionId: string | null; + rootSessionId: string | null; + repo: string; + branch: string; + runtime: InteractiveRuntime; + adapter: string | null; + profile: string; + adapterWorkspaceId: string | null; + providerResourceId: string | null; + capabilities: RuntimeCapabilities; + expiresAt: number | null; + lastReconciledAt: number | null; + reconcileError: string | null; + command: string; + prompt: string; + purpose: string; + summary: string; + owner: string; + createdBy: string; + status: InteractiveSessionStatus; + leaseId: string | null; + attachUrl: string | null; + vncUrl: string | null; + lastEvent: string; + createdAt: number; + updatedAt: number; + lastSeenAt: number; + stoppedAt: number | null; + shareMode: "private" | "link_read"; + shareTokenPreview: string | null; + controlRequestedBy: string | null; + controlRequestedAt: number | null; + controller: string | null; + controlGrantedAt: number | null; + controlExpiresAt: number | null; + multiplayerMode: boolean; + workKey: string | null; + workKind: string | null; + workState: GitHubActionsWorkState | null; + workPhase: string; + sourceUrl: string | null; + githubRunUrl: string | null; + codexThreadId: string | null; + codexTurnId: string | null; + lastHeartbeatAt: number | null; + completionReason: string | null; + canControl?: boolean; + canManage?: boolean; + canChangeMultiplayer?: boolean; + canRequestControl?: boolean; + sharedReadOnly?: boolean; + ptyAvailable?: boolean; + codexSsh?: ResolvedRuntimeProfileCodexSsh | null; + logs: string[]; + logArchive: InteractiveSessionLogArchive | null; +}; + +export type InteractiveSessionLogArchive = { + sessionId: string; + eventCount: number; + eventsKey: string | null; + transcriptKey: string | null; + summaryKey: string | null; + archivedAt: number; + updatedAt: number; +}; + +export type InteractiveSessionEvent = { + actor: string; + message: string; + createdAt: number; +}; + +export type InteractiveSessionEventRow = { + id: number; + session_id: string; + actor: string; + message: string; + created_at: number; +}; + +export function interactiveSession( + row: InteractiveSessionRow, + logs: string[], + logArchive: InteractiveSessionLogArchive | null = null, +): InteractiveSession { + const capabilities = runtimeCapabilities(row.runtime, row.capabilities_json); + return { + [interactiveSessionAdapterControlPlane]: row.adapter_control_plane, + id: row.id, + parentSessionId: row.parent_session_id, + rootSessionId: row.root_session_id ?? row.id, + repo: row.repo, + branch: row.branch, + runtime: row.runtime, + adapter: row.adapter, + profile: row.profile, + adapterWorkspaceId: row.adapter_workspace_id, + providerResourceId: row.provider_resource_id, + capabilities, + expiresAt: row.expires_at, + lastReconciledAt: row.last_reconciled_at, + reconcileError: row.reconcile_error, + command: row.command, + prompt: row.prompt, + purpose: row.purpose, + summary: row.summary, + owner: row.owner, + createdBy: row.created_by, + status: row.status, + leaseId: row.lease_id, + attachUrl: capabilities.terminal ? row.attach_url : null, + vncUrl: row.vnc_url, + lastEvent: row.last_event, + createdAt: row.created_at, + updatedAt: row.updated_at, + lastSeenAt: row.last_seen_at, + stoppedAt: row.stopped_at, + shareMode: row.share_mode, + shareTokenPreview: row.share_token_preview, + controlRequestedBy: row.control_requested_by, + controlRequestedAt: row.control_requested_at, + controller: row.controller, + controlGrantedAt: row.control_granted_at, + controlExpiresAt: row.control_expires_at, + multiplayerMode: row.multiplayer_mode === 1, + workKey: row.work_key, + workKind: row.work_kind, + workState: parseGitHubActionsWorkState(row.work_state), + workPhase: row.work_phase, + sourceUrl: row.source_url, + githubRunUrl: row.github_run_url, + codexThreadId: row.codex_thread_id, + codexTurnId: row.codex_turn_id, + lastHeartbeatAt: row.last_heartbeat_at, + completionReason: row.completion_reason, + logs, + logArchive, + }; +} + +export function interactiveSessionEvent(row: InteractiveSessionEventRow): InteractiveSessionEvent { + return { + actor: row.actor, + message: row.message, + createdAt: row.created_at, + }; +} + +export function interactiveSessionLogArchive( + row: InteractiveSessionLogArchiveTable, +): InteractiveSessionLogArchive { + return { + sessionId: row.session_id, + eventCount: row.event_count, + eventsKey: row.events_key, + transcriptKey: row.transcript_key, + summaryKey: row.summary_key, + archivedAt: row.archived_at, + updatedAt: row.updated_at, + }; +} + +export function runtimeCapabilities(runtime: string, value: string): RuntimeCapabilities { + const fallback = + runtime === githubActionsRuntime + ? githubActionsCapabilities + : runtime === "crabbox" + ? crabboxCapabilities + : containerCapabilities; + const parsed = parseJson>(value, fallback); + return { + terminal: booleanCapability(parsed.terminal, fallback.terminal), + takeover: booleanCapability(parsed.takeover, fallback.takeover), + vnc: booleanCapability(parsed.vnc, fallback.vnc), + desktop: booleanCapability(parsed.desktop, fallback.desktop), + logs: booleanCapability(parsed.logs, fallback.logs), + artifacts: booleanCapability(parsed.artifacts, fallback.artifacts), + }; +} + +function booleanCapability(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function parseJson(value: string, fallback: T): T { + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index cf63588..e3112b1 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -1731,11 +1731,8 @@ test("terminal endpoints enforce current runtime capabilities", async () => { ); const decorateSource = source.slice(decorateStart, decorateEnd); - assert.match(source, /type InteractiveSession = \{[\s\S]*ptyAvailable\?: boolean;/); assert.match(source, /if \(!session\.capabilities\.terminal\)/); - assert.match(source, /runtimeCapabilities\(row\.runtime, row\.capabilities_json\)\.terminal/); assert.match(source, /runtimeAdapterTerminalFailureStatus\(existing\.adapter\) === "detached"/); - assert.match(source, /attachUrl: capabilities\.terminal \? row\.attach_url : null/); assert.match(decorateSource, /const routeKind = interactivePtyRouteKind\(env, session\)/); assert.match(decorateSource, /interactiveTerminalTarget\(env, session, routeKind\)/); assert.match(decorateSource, /routeAvailable/); diff --git a/tests/session-model.test.ts b/tests/session-model.test.ts new file mode 100644 index 0000000..8d5e9b6 --- /dev/null +++ b/tests/session-model.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { + InteractiveSessionLogArchiveTable, + InteractiveSessionRow, +} from "../src/worker/database.ts"; +import { + crabboxCapabilities, + interactiveSession, + interactiveSessionAdapterControlPlane, + interactiveSessionEvent, + interactiveSessionLogArchive, + runtimeCapabilities, +} from "../src/worker/session-model.ts"; + +function sessionRow(values: Partial = {}): InteractiveSessionRow { + return { + id: "IS-42", + parent_session_id: null, + root_session_id: null, + repo: "openclaw/crabfleet", + branch: "main", + runtime: "container", + adapter: null, + profile: "cloudflare-sandbox", + adapter_workspace_id: null, + adapter_control_plane: "https://adapter.example", + provider_resource_id: null, + capabilities_json: "{}", + expires_at: null, + last_reconciled_at: null, + reconcile_error: null, + terminal_status: null, + terminal_failure_reason: null, + adapter_ttl_seconds: null, + adapter_idle_timeout_seconds: null, + adapter_requested_capabilities_json: null, + adapter_create_payload_json: null, + adapter_create_pending: 0, + preparation_pending: 0, + openclaw_request_id: null, + openclaw_request_hash: null, + openclaw_admission_closed: 0, + terminal_finalize_pending: 0, + credential_cleanup_terminal_status: null, + sandbox_refresh_sandbox_id: null, + sandbox_refresh_claim: null, + sandbox_refresh_claim_expires_at: null, + command: "codex", + prompt: "Fix the issue", + purpose: "Fix the issue", + summary: "Working", + owner: "owner", + created_by: "github:42", + status: "ready", + lease_id: "lease-1", + attach_url: "wss://terminal.example", + vnc_url: null, + last_event: "ready", + created_at: 1, + updated_at: 2, + last_seen_at: 3, + stopped_at: null, + share_mode: "private", + share_token_hash: null, + share_token_preview: null, + control_requested_by: null, + control_requested_at: null, + controller: null, + control_granted_at: null, + control_expires_at: null, + multiplayer_mode: 1, + agent_token_hash: null, + work_key: null, + work_kind: null, + work_state: "", + work_phase: "", + source_url: null, + github_run_url: null, + codex_thread_id: null, + codex_turn_id: null, + last_heartbeat_at: null, + completion_reason: null, + ...values, + }; +} + +test("runtime capabilities use runtime defaults and honor explicit booleans only", () => { + assert.deepEqual(runtimeCapabilities("crabbox", "{"), crabboxCapabilities); + assert.deepEqual(runtimeCapabilities("container", '{"terminal":false,"vnc":true}'), { + terminal: false, + takeover: false, + vnc: true, + desktop: false, + logs: true, + artifacts: true, + }); + assert.deepEqual(runtimeCapabilities("container", '{"terminal":"yes","logs":null}'), { + terminal: true, + takeover: false, + vnc: false, + desktop: false, + logs: true, + artifacts: true, + }); +}); + +test("interactive session mapping centralizes row names, defaults, and hidden identity", () => { + const archive = { + sessionId: "IS-42", + eventCount: 3, + eventsKey: "events", + transcriptKey: "transcript", + summaryKey: "summary", + archivedAt: 4, + updatedAt: 5, + }; + const session = interactiveSession( + sessionRow({ + capabilities_json: '{"terminal":false}', + work_state: "running", + }), + ["ready"], + archive, + ); + + assert.equal(session.rootSessionId, "IS-42"); + assert.equal(session.attachUrl, null); + assert.equal(session.multiplayerMode, true); + assert.equal(session.workState, "running"); + assert.deepEqual(session.logs, ["ready"]); + assert.equal(session.logArchive, archive); + assert.equal(session[interactiveSessionAdapterControlPlane], "https://adapter.example"); +}); + +test("event and archive rows map to public session records", () => { + assert.deepEqual( + interactiveSessionEvent({ + id: 1, + session_id: "IS-42", + actor: "owner", + message: "ready", + created_at: 10, + }), + { actor: "owner", message: "ready", createdAt: 10 }, + ); + + const row: InteractiveSessionLogArchiveTable = { + session_id: "IS-42", + event_count: 3, + events_key: "events", + transcript_key: "transcript", + summary_key: "summary", + archived_at: 11, + updated_at: 12, + session_updated_at: 10, + }; + assert.deepEqual(interactiveSessionLogArchive(row), { + sessionId: "IS-42", + eventCount: 3, + eventsKey: "events", + transcriptKey: "transcript", + summaryKey: "summary", + archivedAt: 11, + updatedAt: 12, + }); +}); From e03c66e804e85dc34df79c2085d365c5529ab8f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:27:13 +0100 Subject: [PATCH 009/109] refactor: extract OpenClaw request replay --- CHANGELOG.md | 1 + src/index.ts | 103 ++------------------- src/worker/deployment.ts | 10 +- src/worker/openclaw-request.ts | 93 +++++++++++++++++++ src/worker/repositories.ts | 8 ++ tests/openclaw-request.test.ts | 163 +++++++++++++++++++++++++++++++++ tests/openclaw-service.test.ts | 19 +--- 7 files changed, 279 insertions(+), 118 deletions(-) create mode 100644 src/worker/openclaw-request.ts create mode 100644 src/worker/repositories.ts create mode 100644 tests/openclaw-request.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 451c485..2089d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Centralize repository normalization and OpenClaw request identity, semantic hashing, and durable replay lookup behind direct behavior tests. - Centralize interactive-session types, capability defaults, hidden adapter identity, and database row/event/archive mapping in a directly tested model module. - Give Worker users, allowlist roles, cookie sessions, trusted-proxy identities, GitHub OAuth/API membership, session-owned credentials, and secret encryption dedicated auth modules with behavioral coverage. - Isolate Worker ingress authentication, trusted-proxy credential stripping, and independent service-route policy behind direct behavioral tests. diff --git a/src/index.ts b/src/index.ts index 5500bf9..3f37dda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -221,6 +221,12 @@ import { type InteractiveSessionLogArchive, type RuntimeCapabilities, } from "./worker/session-model"; +import { normalizeRepo } from "./worker/repositories"; +import { + openClawCrabboxRequestHash, + openClawRequestId, + readOpenClawRequestSession, +} from "./worker/openclaw-request"; const defaultInteractiveCommand = "codex --yolo"; @@ -2391,14 +2397,10 @@ async function openClawCreateCrabbox( if (baseBranch) body.baseBranch = baseBranch; else delete body.baseBranch; const serviceUser = openClawServiceUser(); - if (body.requestId !== undefined && typeof body.requestId !== "string") { - throw badRequest("requestId must be a string"); - } - if (body.requestId && body.requestId.length > 200) { - throw badRequest("requestId must be at most 200 characters"); - } - const requestId = body.requestId || null; - const requestHash = requestId ? await openClawCrabboxRequestHash(env, body, owner) : null; + const requestId = openClawRequestId(body.requestId); + const requestHash = requestId + ? await openClawCrabboxRequestHash(body, owner, deploymentConfig(env).defaultRuntime) + : null; if (requestId && requestHash) { const existing = await readOpenClawRequestSession(env, requestId, requestHash); if (existing) { @@ -2453,82 +2455,6 @@ async function openClawCreateCrabbox( return openClawDecoratedCrabboxResponse(env, result.session); } -async function openClawCrabboxRequestHash( - env: RuntimeEnv, - body: { - repo?: string; - branch?: string; - runtime?: string; - profile?: string; - command?: string; - prompt?: string; - parentSessionId?: string; - rootSessionId?: string; - purpose?: string; - summary?: string; - baseBranch?: string; - githubToken?: string; - }, - owner: string, -): Promise { - const githubToken = clean(body.githubToken, 4000); - const runtime = oneOf( - body.runtime, - ["crabbox", "container"] as const, - deploymentConfig(env).defaultRuntime, - ); - return sha256( - JSON.stringify({ - repo: normalizeRepo(body.repo), - branch: clean(body.branch, 120), - runtime, - profile: clean(body.profile, 120), - command: clean(body.command, 4000), - prompt: clean(body.prompt, 4000), - parentSessionId: clean(body.parentSessionId, 120), - rootSessionId: clean(body.rootSessionId, 120), - purpose: clean(body.purpose, 500), - summary: clean(body.summary, 500), - baseBranch: clean(body.baseBranch, 120), - githubTokenHash: githubToken ? await sha256(githubToken) : null, - owner, - }), - ); -} - -async function readOpenClawRequestSession( - env: RuntimeEnv, - requestId: string, - requestHash: string, -): Promise { - const replay = await database(env) - .selectFrom("openclaw_request_replays as replay") - .leftJoin("interactive_sessions as session", "session.id", "replay.session_id") - .selectAll("session") - .select("replay.request_hash as replay_request_hash") - .where("replay.request_id", "=", requestId) - .executeTakeFirst(); - if (!replay) return null; - if (replay.replay_request_hash !== requestHash) { - throw conflict("OpenClaw crabbox request id already belongs to a different request"); - } - if (!replay.id) { - throw conflict("OpenClaw crabbox request already completed and is no longer available"); - } - const row = replay as InteractiveSessionRow; - if ( - row.created_by !== "service:openclaw" || - row.openclaw_request_id !== requestId || - row.openclaw_request_hash !== requestHash - ) { - throw serviceUnavailable("OpenClaw crabbox replay record is inconsistent"); - } - if (row.preparation_pending !== 0) { - throw serviceUnavailable("OpenClaw crabbox request is still preparing"); - } - return interactiveSession(row, []); -} - async function openClawReadSessionRoot( request: Request, env: RuntimeEnv, @@ -14870,15 +14796,6 @@ function systemUser(): User { }; } -function normalizeRepo(value: unknown): string { - return String(value ?? "") - .trim() - .toLowerCase() - .replace(/^https:\/\/github\.com\//, "") - .replace(/\.git$/, "") - .replace(/\/+$/, ""); -} - function normalizeAllow(value: unknown): string { const raw = String(value ?? "").trim(); if (!raw) return ""; diff --git a/src/worker/deployment.ts b/src/worker/deployment.ts index f2109f2..2ebbaa2 100644 --- a/src/worker/deployment.ts +++ b/src/worker/deployment.ts @@ -7,6 +7,7 @@ import { import { trustedProxyPublicOrigin, type TrustedProxyEnv } from "../trusted-proxy-auth.ts"; import { configuredHttpOrigin } from "../url-security.ts"; import { badRequest } from "./http.ts"; +import { normalizeRepo } from "./repositories.ts"; export const defaultPreferredRepo = "openclaw/crabfleet"; @@ -93,15 +94,6 @@ export function browserAppOrigin(env: DeploymentEnv): string { return trustedProxyPublicOrigin(env) ?? deploymentConfig(env).canonicalUrl; } -function normalizeRepo(value: unknown): string { - return String(value ?? "") - .trim() - .toLowerCase() - .replace(/^https:\/\/github\.com\//, "") - .replace(/\.git$/, "") - .replace(/\/+$/, ""); -} - function clean(value: unknown, maximum: number): string { return String(value ?? "") .trim() diff --git a/src/worker/openclaw-request.ts b/src/worker/openclaw-request.ts new file mode 100644 index 0000000..ad34dd7 --- /dev/null +++ b/src/worker/openclaw-request.ts @@ -0,0 +1,93 @@ +import { sha256 } from "./crypto.ts"; +import { database, type InteractiveSessionRow } from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { badRequest, conflict, serviceUnavailable } from "./http.ts"; +import { normalizeRepo } from "./repositories.ts"; +import { interactiveSession, type InteractiveSession } from "./session-model.ts"; + +export type OpenClawCrabboxRequest = { + repo?: string; + branch?: string; + runtime?: string; + profile?: string; + command?: string; + prompt?: string; + parentSessionId?: string; + rootSessionId?: string; + purpose?: string; + summary?: string; + baseBranch?: string; + githubToken?: string; +}; + +export function openClawRequestId(value: unknown): string | null { + if (value === undefined || value === "") return null; + if (typeof value !== "string") throw badRequest("requestId must be a string"); + if (value.length > 200) throw badRequest("requestId must be at most 200 characters"); + return value; +} + +export async function openClawCrabboxRequestHash( + body: OpenClawCrabboxRequest, + owner: string, + defaultRuntime: "crabbox" | "container", +): Promise { + const githubToken = clean(body.githubToken, 4000); + const runtime = + body.runtime === "crabbox" || body.runtime === "container" ? body.runtime : defaultRuntime; + return sha256( + JSON.stringify({ + repo: normalizeRepo(body.repo), + branch: clean(body.branch, 120), + runtime, + profile: clean(body.profile, 120), + command: clean(body.command, 4000), + prompt: clean(body.prompt, 4000), + parentSessionId: clean(body.parentSessionId, 120), + rootSessionId: clean(body.rootSessionId, 120), + purpose: clean(body.purpose, 500), + summary: clean(body.summary, 500), + baseBranch: clean(body.baseBranch, 120), + githubTokenHash: githubToken ? await sha256(githubToken) : null, + owner, + }), + ); +} + +export async function readOpenClawRequestSession( + env: RuntimeEnv, + requestId: string, + requestHash: string, +): Promise { + const replay = await database(env) + .selectFrom("openclaw_request_replays as replay") + .leftJoin("interactive_sessions as session", "session.id", "replay.session_id") + .selectAll("session") + .select("replay.request_hash as replay_request_hash") + .where("replay.request_id", "=", requestId) + .executeTakeFirst(); + if (!replay) return null; + if (replay.replay_request_hash !== requestHash) { + throw conflict("OpenClaw crabbox request id already belongs to a different request"); + } + if (!replay.id) { + throw conflict("OpenClaw crabbox request already completed and is no longer available"); + } + if ( + replay.created_by !== "service:openclaw" || + replay.openclaw_request_id !== requestId || + replay.openclaw_request_hash !== requestHash + ) { + throw serviceUnavailable("OpenClaw crabbox replay record is inconsistent"); + } + if (replay.preparation_pending !== 0) { + throw serviceUnavailable("OpenClaw crabbox request is still preparing"); + } + return interactiveSession(replay as InteractiveSessionRow, []); +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/src/worker/repositories.ts b/src/worker/repositories.ts new file mode 100644 index 0000000..c860fd6 --- /dev/null +++ b/src/worker/repositories.ts @@ -0,0 +1,8 @@ +export function normalizeRepo(value: unknown): string { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/^https:\/\/github\.com\//, "") + .replace(/\/+$/, "") + .replace(/\.git$/, ""); +} diff --git a/tests/openclaw-request.test.ts b/tests/openclaw-request.test.ts new file mode 100644 index 0000000..5c50b5c --- /dev/null +++ b/tests/openclaw-request.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + openClawCrabboxRequestHash, + openClawRequestId, + readOpenClawRequestSession, +} from "../src/worker/openclaw-request.ts"; +import { normalizeRepo } from "../src/worker/repositories.ts"; + +function d1(row: Record | null): D1Database { + return { + prepare(sql: string) { + assert.match(sql, /from "openclaw_request_replays" as "replay"/i); + assert.match(sql, /left join "interactive_sessions" as "session"/i); + return { + bind(...parameters: unknown[]) { + assert.equal(parameters[0], "request-1"); + return { + async all() { + return { results: row ? [row] : [], meta: {} }; + }, + async run() { + return { meta: {} }; + }, + }; + }, + }; + }, + } as unknown as D1Database; +} + +function env(row: Record | null): RuntimeEnv { + return { DB: d1(row) } as RuntimeEnv; +} + +function replayRow(values: Record = {}): Record { + return { + replay_request_hash: "hash-1", + id: "IS-42", + created_by: "service:openclaw", + openclaw_request_id: "request-1", + openclaw_request_hash: "hash-1", + preparation_pending: 0, + parent_session_id: null, + root_session_id: null, + repo: "openclaw/crabfleet", + branch: "main", + runtime: "container", + adapter: null, + profile: "default", + adapter_workspace_id: null, + adapter_control_plane: null, + provider_resource_id: null, + capabilities_json: "{}", + expires_at: null, + last_reconciled_at: null, + reconcile_error: null, + command: "codex", + prompt: "Fix issue", + purpose: "Fix issue", + summary: "Working", + owner: "owner", + status: "ready", + lease_id: null, + attach_url: null, + vnc_url: null, + last_event: "ready", + created_at: 1, + updated_at: 2, + last_seen_at: 3, + stopped_at: null, + share_mode: "private", + share_token_preview: null, + control_requested_by: null, + control_requested_at: null, + controller: null, + control_granted_at: null, + control_expires_at: null, + multiplayer_mode: 0, + work_key: null, + work_kind: null, + work_state: "", + work_phase: "", + source_url: null, + github_run_url: null, + codex_thread_id: null, + codex_turn_id: null, + last_heartbeat_at: null, + completion_reason: null, + ...values, + }; +} + +test("OpenClaw request IDs preserve exact caller identity within the size limit", () => { + assert.equal(openClawRequestId(undefined), null); + assert.equal(openClawRequestId(""), null); + assert.equal(openClawRequestId(" request-1 "), " request-1 "); + assert.throws(() => openClawRequestId(null), { message: "requestId must be a string" }); + assert.throws(() => openClawRequestId(42), { message: "requestId must be a string" }); + assert.throws(() => openClawRequestId("x".repeat(201)), { + message: "requestId must be at most 200 characters", + }); +}); + +test("OpenClaw request hashes normalize repositories and include profile and secret identity", async () => { + assert.equal(normalizeRepo(" HTTPS://github.com/OpenClaw/Crabfleet.git/ "), "openclaw/crabfleet"); + const base = { + repo: "https://github.com/OpenClaw/Crabfleet.git", + branch: "main", + runtime: "invalid", + profile: "large", + githubToken: "token-a", + }; + const first = await openClawCrabboxRequestHash(base, "owner", "container"); + assert.equal( + first, + await openClawCrabboxRequestHash( + { ...base, repo: "openclaw/crabfleet", runtime: "container" }, + "owner", + "container", + ), + ); + assert.notEqual( + first, + await openClawCrabboxRequestHash({ ...base, profile: "small" }, "owner", "container"), + ); + assert.notEqual( + first, + await openClawCrabboxRequestHash({ ...base, githubToken: "token-b" }, "owner", "container"), + ); +}); + +test("OpenClaw replay lookup distinguishes conflicts, completion, preparation, and success", async () => { + assert.equal(await readOpenClawRequestSession(env(null), "request-1", "hash-1"), null); + + await assert.rejects( + readOpenClawRequestSession( + env(replayRow({ replay_request_hash: "other" })), + "request-1", + "hash-1", + ), + { message: "OpenClaw crabbox request id already belongs to a different request" }, + ); + await assert.rejects( + readOpenClawRequestSession(env(replayRow({ id: null })), "request-1", "hash-1"), + { message: "OpenClaw crabbox request already completed and is no longer available" }, + ); + await assert.rejects( + readOpenClawRequestSession(env(replayRow({ created_by: "github:42" })), "request-1", "hash-1"), + { message: "OpenClaw crabbox replay record is inconsistent" }, + ); + await assert.rejects( + readOpenClawRequestSession(env(replayRow({ preparation_pending: 1 })), "request-1", "hash-1"), + { message: "OpenClaw crabbox request is still preparing" }, + ); + + const session = await readOpenClawRequestSession(env(replayRow()), "request-1", "hash-1"); + assert.equal(session?.id, "IS-42"); + assert.equal(session?.rootSessionId, "IS-42"); + assert.deepEqual(session?.logs, []); +}); diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index 7340015..b1d771e 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -15,6 +15,7 @@ import { openClawTranscriptMaxBytes, sessionBelongsToRoot, } from "../src/openclaw-service.ts"; +import { openClawRequestId } from "../src/worker/openclaw-request.ts"; test("OpenClaw service authorization accepts dedicated scoped consumers", () => { assert.equal(openClawServiceAuthorized("Bearer openclaw", ["openclaw", "multicodex"]), true); @@ -345,11 +346,8 @@ test("OpenClaw crabbox requests reserve durable idempotency before provisioning" "utf8", ); const endpointStart = source.indexOf("async function openClawCreateCrabbox"); - const endpointEnd = source.indexOf("async function openClawCrabboxRequestHash", endpointStart); + const endpointEnd = source.indexOf("async function openClawReadSessionRoot", endpointStart); const endpointSource = source.slice(endpointStart, endpointEnd); - const replayStart = source.indexOf("async function readOpenClawRequestSession"); - const replayEnd = source.indexOf("async function openClawReadSessionRoot", replayStart); - const replaySource = source.slice(replayStart, replayEnd); const createStart = source.indexOf("async function createInteractiveSessionFromInput"); const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); const createSource = source.slice(createStart, createEnd); @@ -364,22 +362,11 @@ test("OpenClaw crabbox requests reserve durable idempotency before provisioning" assert.match(migration, /UNIQUE INDEX IF NOT EXISTS idx_interactive_sessions_openclaw_request/); assert.match(migration, /CREATE TABLE IF NOT EXISTS openclaw_request_replays/); assert.match(endpointSource, /readOpenClawRequestSession/); - assert.match(endpointSource, /requestId must be at most 200 characters/); + assert.equal(openClawRequestId("request-1"), "request-1"); assert.ok( endpointSource.indexOf("readOpenClawRequestSession") < endpointSource.indexOf("createInteractiveSessionFromInput"), ); - assert.match(source, /profile: clean\(body\.profile, 120\)/); - assert.match(source, /runtime,\s+profile: clean\(body\.profile, 120\)/); - assert.match(source, /githubTokenHash: githubToken \? await sha256\(githubToken\) : null/); - assert.match(replaySource, /row\.preparation_pending !== 0/); - assert.match(replaySource, /OpenClaw crabbox request is still preparing/); - assert.match( - replaySource, - /OpenClaw crabbox request already completed and is no longer available/, - ); - assert.match(replaySource, /\.selectFrom\("openclaw_request_replays as replay"\)/); - assert.match(replaySource, /\.leftJoin\("interactive_sessions as session"/); assert.match(createSource, /openclaw_request_id: options\.openClawRequestId \?\? null/); assert.match(createSource, /openclaw_request_hash: options\.openClawRequestHash \?\? null/); assert.match(createSource, /\.insertInto\("openclaw_request_replays"\)/); From a16240f6cdc318b54307cb291e6f145166b538e1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:34:16 +0100 Subject: [PATCH 010/109] refactor: extract OpenClaw room repository --- CHANGELOG.md | 1 + src/index.ts | 140 ++++-------------------- src/worker/openclaw-repository.ts | 141 ++++++++++++++++++++++++ tests/helpers/session-row.ts | 73 +++++++++++++ tests/openclaw-repository.test.ts | 175 ++++++++++++++++++++++++++++++ tests/openclaw-request.test.ts | 60 ++-------- tests/openclaw-service.test.ts | 28 +---- tests/session-model.test.ts | 78 +------------ 8 files changed, 423 insertions(+), 273 deletions(-) create mode 100644 src/worker/openclaw-repository.ts create mode 100644 tests/helpers/session-row.ts create mode 100644 tests/openclaw-repository.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2089d91..584a548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Move OpenClaw room reads, cleanup polling, admission state, completion counts, and lineage reads into a directly tested repository boundary. - Centralize repository normalization and OpenClaw request identity, semantic hashing, and durable replay lookup behind direct behavior tests. - Centralize interactive-session types, capability defaults, hidden adapter identity, and database row/event/archive mapping in a directly tested model module. - Give Worker users, allowlist roles, cookie sessions, trusted-proxy identities, GitHub OAuth/API membership, session-owned credentials, and secret encryption dedicated auth modules with behavioral coverage. diff --git a/src/index.ts b/src/index.ts index 3f37dda..1d29327 100644 --- a/src/index.ts +++ b/src/index.ts @@ -227,6 +227,15 @@ import { openClawRequestId, readOpenClawRequestSession, } from "./worker/openclaw-request"; +import { + closeOpenClawRootAdmission, + openClawRootAdmissionOpen, + readOpenClawLineageSession, + readOpenClawRoomRoot, + readOpenClawRoomSessions, + readOpenClawRootCompletion, + readOpenClawRootRows, +} from "./worker/openclaw-repository"; const defaultInteractiveCommand = "codex --yolo"; @@ -2466,45 +2475,20 @@ async function openClawReadSessionRoot( requireOpenClawRoomService(request, env); const root = clean(rootSessionId, 120); if (!root) throw badRequest("root session id is required"); - const db = database(env); - const rootRow = await db - .selectFrom("interactive_sessions") - .selectAll() - .where("id", "=", root) - .where("preparation_pending", "=", 0) - .executeTakeFirst(); - const rootSession = rootRow ? interactiveSession(rootRow, []) : null; + const rootSession = await readOpenClawRoomRoot(env, root); if (!rootSession || !openClawRoomRootAllowed(rootSession)) { throw notFound("session root not found"); } - const rows = await db - .selectFrom("interactive_sessions") - .selectAll() - .where((expression) => - expression.or([expression("root_session_id", "=", root), expression("id", "=", root)]), - ) - .where((expression) => - expression.or([ - expression("created_by", "=", "service:openclaw"), - expression("created_by", "like", "session:%"), - ]), - ) - .where("runtime", "!=", "github_actions") - .where("work_key", "is", null) - .where("preparation_pending", "=", 0) - .orderBy("created_at", "asc") - .limit(openClawRoomMaxSessions + 1) - .execute(); - if (!rows.length) throw notFound("session root not found"); - if (rows.length > openClawRoomMaxSessions) { + const room = await readOpenClawRoomSessions(env, root, openClawRoomMaxSessions); + if (!room.sessions.length) throw notFound("session root not found"); + if (room.overflow) { throw serviceUnavailable("session root exceeds the supervision limit"); } const serviceUser = openClawServiceUser(); - const sessions = rows.map((row) => interactiveSession(row, [])); return { rootSessionId: root, - crabboxes: sessions - .filter((session) => openClawRoomSessionChainAllowed(sessions, session.id, root)) + crabboxes: room.sessions + .filter((session) => openClawRoomSessionChainAllowed(room.sessions, session.id, root)) .map((session) => openClawCrabboxSummaryResponse(env, serviceUser, session)), }; } @@ -2529,11 +2513,7 @@ async function openClawMutateSessionRoot( } const serviceUser = openClawServiceUser(); await audit(env, serviceUser, `openclaw session root stop requested ${root}`, Date.now()); - await database(env) - .updateTable("interactive_sessions") - .set({ openclaw_admission_closed: 1 }) - .where("id", "=", root) - .execute(); + await closeOpenClawRootAdmission(env, root); const deadline = Date.now() + 60_000; let terminalReads = 0; let pollDelayMs = 250; @@ -2541,7 +2521,7 @@ async function openClawMutateSessionRoot( const lifecycleAttempts = new Map(); const nextLifecycleAttemptAt = new Map(); while (Date.now() < deadline) { - let rows = await readOpenClawRootRows(env, root); + let rows = await readOpenClawRootRows(env, root, openClawRoomMaxSessions); const pending = rows.filter((row) => row.preparation_pending !== 0).slice(0, 4); await mapWithConcurrency(pending, 4, async (row) => { await runOpenClawRootOperationBeforeDeadline(deadline, () => @@ -2549,7 +2529,7 @@ async function openClawMutateSessionRoot( ); }); if (Date.now() >= deadline) break; - rows = await readOpenClawRootRows(env, root); + rows = await readOpenClawRootRows(env, root, openClawRoomMaxSessions); const sessions = rows.map((row) => interactiveSession(row, [])); const now = Date.now(); const actionable = sessions @@ -2577,7 +2557,7 @@ async function openClawMutateSessionRoot( ); }); if (Date.now() >= deadline) break; - rows = await readOpenClawRootRows(env, root); + rows = await readOpenClawRootRows(env, root, openClawRoomMaxSessions); const completion = await readOpenClawRootCompletion(env, root); const complete = completion.remaining === 0; terminalReads = complete ? terminalReads + 1 : 0; @@ -2621,65 +2601,6 @@ async function runOpenClawRootOperationBeforeDeadline( }); } -async function readOpenClawRootRows( - env: RuntimeEnv, - rootSessionId: string, -): Promise { - return database(env) - .selectFrom("interactive_sessions") - .selectAll() - .where((expression) => - expression.or([ - expression("root_session_id", "=", rootSessionId), - expression("id", "=", rootSessionId), - ]), - ) - .where((expression) => - expression.or([ - expression("created_by", "=", "service:openclaw"), - expression("created_by", "like", "session:%"), - ]), - ) - .where("runtime", "!=", "github_actions") - .where("work_key", "is", null) - .orderBy( - sql`CASE - WHEN preparation_pending != 0 THEN 0 - WHEN status NOT IN ('stopped', 'expired', 'failed') THEN 1 - ELSE 2 - END`, - "asc", - ) - .orderBy("created_at", "asc") - .limit(openClawRoomMaxSessions) - .execute(); -} - -async function readOpenClawRootCompletion( - env: RuntimeEnv, - rootSessionId: string, -): Promise<{ total: number; remaining: number }> { - const result = await sql<{ total: number; remaining: number }>` - SELECT - count(*) AS total, - sum( - CASE - WHEN preparation_pending != 0 OR status NOT IN ('stopped', 'expired', 'failed') THEN 1 - ELSE 0 - END - ) AS remaining - FROM interactive_sessions - WHERE (root_session_id = ${rootSessionId} OR id = ${rootSessionId}) - AND (created_by = 'service:openclaw' OR created_by LIKE 'session:%') - AND runtime != 'github_actions' - AND work_key IS NULL - `.execute(database(env)); - return { - total: Number(result.rows[0]?.total ?? 0), - remaining: Number(result.rows[0]?.remaining ?? 0), - }; -} - async function openClawReadCrabbox( request: Request, env: RuntimeEnv, @@ -2907,15 +2828,6 @@ async function enforceOpenClawRoomSessionLimitAfterInsert( throw tooManyRequests("session root reached the supervision limit"); } -async function openClawRootAdmissionOpen(env: RuntimeEnv, rootSessionId: string): Promise { - const row = await database(env) - .selectFrom("interactive_sessions") - .select("openclaw_admission_closed") - .where("id", "=", rootSessionId) - .executeTakeFirst(); - return row?.openclaw_admission_closed === 0; -} - async function openClawRoomReservationLineageAllowed( env: RuntimeEnv, insertedSessionId: string, @@ -2939,20 +2851,6 @@ async function openClawRoomReservationLineageAllowed( return openClawRoomSessionChainAllowed([...chain.values()], session.id, rootSessionId); } -async function readOpenClawLineageSession( - env: RuntimeEnv, - id: string, - preparationPending: 0 | 1, -): Promise { - const row = await database(env) - .selectFrom("interactive_sessions") - .selectAll() - .where("id", "=", id) - .where("preparation_pending", "=", preparationPending) - .executeTakeFirst(); - return row ? interactiveSession(row, []) : null; -} - async function rollbackInteractiveSessionReservation( env: RuntimeEnv, insertedSessionId: string, diff --git a/src/worker/openclaw-repository.ts b/src/worker/openclaw-repository.ts new file mode 100644 index 0000000..f6f9aae --- /dev/null +++ b/src/worker/openclaw-repository.ts @@ -0,0 +1,141 @@ +import { sql } from "kysely"; + +import { database, type InteractiveSessionRow } from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { interactiveSession, type InteractiveSession } from "./session-model.ts"; + +export type OpenClawRoomSessions = { + sessions: InteractiveSession[]; + overflow: boolean; +}; + +export async function readOpenClawRoomRoot( + env: RuntimeEnv, + rootSessionId: string, +): Promise { + const row = await database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where("id", "=", rootSessionId) + .where("preparation_pending", "=", 0) + .executeTakeFirst(); + return row ? interactiveSession(row, []) : null; +} + +export async function readOpenClawRoomSessions( + env: RuntimeEnv, + rootSessionId: string, + maximumSessions: number, +): Promise { + const limit = Math.max(1, Math.floor(maximumSessions)); + const rows = await openClawRoomRowsQuery(env, rootSessionId) + .where("preparation_pending", "=", 0) + .orderBy("created_at", "asc") + .limit(limit + 1) + .execute(); + return { + sessions: rows.slice(0, limit).map((row) => interactiveSession(row, [])), + overflow: rows.length > limit, + }; +} + +export async function readOpenClawRootRows( + env: RuntimeEnv, + rootSessionId: string, + maximumSessions: number, +): Promise { + return openClawRoomRowsQuery(env, rootSessionId) + .orderBy( + sql`CASE + WHEN preparation_pending != 0 THEN 0 + WHEN status NOT IN ('stopped', 'expired', 'failed') THEN 1 + ELSE 2 + END`, + "asc", + ) + .orderBy("created_at", "asc") + .limit(Math.max(1, Math.floor(maximumSessions))) + .execute(); +} + +export async function readOpenClawRootCompletion( + env: RuntimeEnv, + rootSessionId: string, +): Promise<{ total: number; remaining: number }> { + const result = await sql<{ total: number; remaining: number }>` + SELECT + count(*) AS total, + sum( + CASE + WHEN preparation_pending != 0 OR status NOT IN ('stopped', 'expired', 'failed') THEN 1 + ELSE 0 + END + ) AS remaining + FROM interactive_sessions + WHERE (root_session_id = ${rootSessionId} OR id = ${rootSessionId}) + AND (created_by = 'service:openclaw' OR created_by LIKE 'session:%') + AND runtime != 'github_actions' + AND work_key IS NULL + `.execute(database(env)); + return { + total: Number(result.rows[0]?.total ?? 0), + remaining: Number(result.rows[0]?.remaining ?? 0), + }; +} + +export async function closeOpenClawRootAdmission( + env: RuntimeEnv, + rootSessionId: string, +): Promise { + await database(env) + .updateTable("interactive_sessions") + .set({ openclaw_admission_closed: 1 }) + .where("id", "=", rootSessionId) + .execute(); +} + +export async function openClawRootAdmissionOpen( + env: RuntimeEnv, + rootSessionId: string, +): Promise { + const row = await database(env) + .selectFrom("interactive_sessions") + .select("openclaw_admission_closed") + .where("id", "=", rootSessionId) + .executeTakeFirst(); + return row?.openclaw_admission_closed === 0; +} + +export async function readOpenClawLineageSession( + env: RuntimeEnv, + id: string, + preparationPending: 0 | 1, +): Promise { + const row = await database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where("id", "=", id) + .where("preparation_pending", "=", preparationPending) + .executeTakeFirst(); + return row ? interactiveSession(row, []) : null; +} + +function openClawRoomRowsQuery(env: RuntimeEnv, rootSessionId: string) { + return database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where((expression) => + expression.or([ + expression("root_session_id", "=", rootSessionId), + expression("id", "=", rootSessionId), + ]), + ) + .where((expression) => + expression.or([ + expression("created_by", "=", "service:openclaw"), + expression("created_by", "like", "session:%"), + ]), + ) + .where("runtime", "!=", "github_actions") + .where("work_key", "is", null); +} diff --git a/tests/helpers/session-row.ts b/tests/helpers/session-row.ts new file mode 100644 index 0000000..70afae3 --- /dev/null +++ b/tests/helpers/session-row.ts @@ -0,0 +1,73 @@ +import type { InteractiveSessionRow } from "../../src/worker/database.ts"; + +export function sessionRow(values: Partial = {}): InteractiveSessionRow { + return { + id: "IS-42", + parent_session_id: null, + root_session_id: null, + repo: "openclaw/crabfleet", + branch: "main", + runtime: "container", + adapter: null, + profile: "cloudflare-sandbox", + adapter_workspace_id: null, + adapter_control_plane: "https://adapter.example", + provider_resource_id: null, + capabilities_json: "{}", + expires_at: null, + last_reconciled_at: null, + reconcile_error: null, + terminal_status: null, + terminal_failure_reason: null, + adapter_ttl_seconds: null, + adapter_idle_timeout_seconds: null, + adapter_requested_capabilities_json: null, + adapter_create_payload_json: null, + adapter_create_pending: 0, + preparation_pending: 0, + openclaw_request_id: null, + openclaw_request_hash: null, + openclaw_admission_closed: 0, + terminal_finalize_pending: 0, + credential_cleanup_terminal_status: null, + sandbox_refresh_sandbox_id: null, + sandbox_refresh_claim: null, + sandbox_refresh_claim_expires_at: null, + command: "codex", + prompt: "Fix the issue", + purpose: "Fix the issue", + summary: "Working", + owner: "owner", + created_by: "github:42", + status: "ready", + lease_id: "lease-1", + attach_url: "wss://terminal.example", + vnc_url: null, + last_event: "ready", + created_at: 1, + updated_at: 2, + last_seen_at: 3, + stopped_at: null, + share_mode: "private", + share_token_hash: null, + share_token_preview: null, + control_requested_by: null, + control_requested_at: null, + controller: null, + control_granted_at: null, + control_expires_at: null, + multiplayer_mode: 1, + agent_token_hash: null, + work_key: null, + work_kind: null, + work_state: "", + work_phase: "", + source_url: null, + github_run_url: null, + codex_thread_id: null, + codex_turn_id: null, + last_heartbeat_at: null, + completion_reason: null, + ...values, + }; +} diff --git a/tests/openclaw-repository.test.ts b/tests/openclaw-repository.test.ts new file mode 100644 index 0000000..1bf810f --- /dev/null +++ b/tests/openclaw-repository.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + closeOpenClawRootAdmission, + openClawRootAdmissionOpen, + readOpenClawLineageSession, + readOpenClawRoomRoot, + readOpenClawRoomSessions, + readOpenClawRootCompletion, + readOpenClawRootRows, +} from "../src/worker/openclaw-repository.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +type D1Result = { results?: unknown[]; changes?: number }; +type D1Handler = (sql: string, parameters: unknown[], kind: "all" | "run") => D1Result; + +function runtimeEnv(handler: D1Handler): RuntimeEnv { + return { + DB: { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + return { + async all() { + const result = handler(sql, parameters, "all"); + return { results: result.results ?? [], meta: { changes: result.changes ?? 0 } }; + }, + async run() { + const result = handler(sql, parameters, "run"); + return { meta: { changes: result.changes ?? 0 } }; + }, + }; + }, + }; + }, + } as unknown as D1Database, + } as RuntimeEnv; +} + +test("OpenClaw room reads are bounded, filtered, and do not load logs", async () => { + const queries: string[] = []; + const root = sessionRow({ + id: "IS-1", + root_session_id: "IS-1", + created_by: "service:openclaw", + }); + const child = sessionRow({ + id: "IS-2", + parent_session_id: "IS-1", + root_session_id: "IS-1", + created_by: "session:IS-1", + created_at: 2, + }); + const overflow = sessionRow({ + id: "IS-3", + parent_session_id: "IS-2", + root_session_id: "IS-1", + created_by: "session:IS-2", + created_at: 3, + }); + const env = runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + queries.push(sql); + if (/root_session_id/i.test(sql)) { + assert.match(sql, /created_by/i); + assert.match(sql, /runtime.*!=/is); + assert.match(sql, /work_key.*is null/is); + assert.match(sql, /preparation_pending.*=/is); + assert.match(sql, /order by.*created_at.*asc/is); + assert.equal(parameters.at(-1), 3); + return { results: [root, child, overflow] }; + } + assert.match(sql, /where "id" = .+ "preparation_pending" =/i); + return { results: [root] }; + }); + const roomRoot = await readOpenClawRoomRoot(env, "IS-1"); + const room = await readOpenClawRoomSessions(env, "IS-1", 2); + + assert.equal(queries.length, 2); + assert.equal(roomRoot?.id, "IS-1"); + assert.deepEqual( + room.sessions.map((session) => session.id), + ["IS-1", "IS-2"], + ); + assert.deepEqual( + room.sessions.map((session) => session.logs), + [[], []], + ); + assert.equal(room.overflow, true); +}); + +test("OpenClaw room descendants are not read until callers validate the root", async () => { + let queries = 0; + const root = await readOpenClawRoomRoot( + runtimeEnv((sql, _parameters, kind) => { + assert.equal(kind, "all"); + queries += 1; + assert.doesNotMatch(sql, /root_session_id/i); + assert.match(sql, /where "id" = .+ "preparation_pending" =/i); + return { results: [] }; + }), + "IS-1", + ); + assert.equal(root, null); + assert.equal(queries, 1); +}); + +test("OpenClaw cleanup reads prioritize reservations and report terminal completion", async () => { + const env = runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + assert.equal(parameters[0], "IS-1"); + if (/count\(\*\) AS total/i.test(sql)) { + assert.match(sql, /status NOT IN \('stopped', 'expired', 'failed'\)/); + return { results: [{ total: "3", remaining: "1" }] }; + } + assert.match(sql, /CASE\s+WHEN preparation_pending != 0 THEN 0/is); + assert.match(sql, /created_by/i); + assert.equal(parameters.at(-1), 4); + return { + results: [ + sessionRow({ + id: "IS-2", + root_session_id: "IS-1", + created_by: "session:IS-1", + preparation_pending: 1, + }), + ], + }; + }); + + const rows = await readOpenClawRootRows(env, "IS-1", 4); + assert.equal(rows[0]?.preparation_pending, 1); + assert.deepEqual(await readOpenClawRootCompletion(env, "IS-1"), { + total: 3, + remaining: 1, + }); +}); + +test("OpenClaw admission and lineage persistence preserve explicit preparation states", async () => { + let admissionClosed = 0; + const env = runtimeEnv((sql, parameters, kind) => { + if (/^update "interactive_sessions"/i.test(sql)) { + assert.equal(kind, "run"); + assert.deepEqual(parameters, [1, "IS-1"]); + admissionClosed = 1; + return { changes: 1 }; + } + assert.equal(kind, "all"); + if (/select "openclaw_admission_closed"/i.test(sql)) { + assert.deepEqual(parameters, ["IS-1"]); + return { results: [{ openclaw_admission_closed: admissionClosed }] }; + } + assert.match(sql, /select .* from "interactive_sessions"/i); + assert.deepEqual(parameters, ["IS-2", 1]); + return { + results: [ + sessionRow({ + id: "IS-2", + root_session_id: "IS-1", + preparation_pending: 1, + created_by: "session:IS-1", + }), + ], + }; + }); + + assert.equal(await openClawRootAdmissionOpen(env, "IS-1"), true); + await closeOpenClawRootAdmission(env, "IS-1"); + assert.equal(await openClawRootAdmissionOpen(env, "IS-1"), false); + const lineage = await readOpenClawLineageSession(env, "IS-2", 1); + assert.equal(lineage?.id, "IS-2"); + assert.deepEqual(lineage?.logs, []); +}); diff --git a/tests/openclaw-request.test.ts b/tests/openclaw-request.test.ts index 5c50b5c..c05509b 100644 --- a/tests/openclaw-request.test.ts +++ b/tests/openclaw-request.test.ts @@ -8,6 +8,7 @@ import { readOpenClawRequestSession, } from "../src/worker/openclaw-request.ts"; import { normalizeRepo } from "../src/worker/repositories.ts"; +import { sessionRow } from "./helpers/session-row.ts"; function d1(row: Record | null): D1Database { return { @@ -37,58 +38,15 @@ function env(row: Record | null): RuntimeEnv { function replayRow(values: Record = {}): Record { return { + ...sessionRow({ + created_by: "service:openclaw", + openclaw_request_id: "request-1", + openclaw_request_hash: "hash-1", + lease_id: null, + attach_url: null, + multiplayer_mode: 0, + }), replay_request_hash: "hash-1", - id: "IS-42", - created_by: "service:openclaw", - openclaw_request_id: "request-1", - openclaw_request_hash: "hash-1", - preparation_pending: 0, - parent_session_id: null, - root_session_id: null, - repo: "openclaw/crabfleet", - branch: "main", - runtime: "container", - adapter: null, - profile: "default", - adapter_workspace_id: null, - adapter_control_plane: null, - provider_resource_id: null, - capabilities_json: "{}", - expires_at: null, - last_reconciled_at: null, - reconcile_error: null, - command: "codex", - prompt: "Fix issue", - purpose: "Fix issue", - summary: "Working", - owner: "owner", - status: "ready", - lease_id: null, - attach_url: null, - vnc_url: null, - last_event: "ready", - created_at: 1, - updated_at: 2, - last_seen_at: 3, - stopped_at: null, - share_mode: "private", - share_token_preview: null, - control_requested_by: null, - control_requested_at: null, - controller: null, - control_granted_at: null, - control_expires_at: null, - multiplayer_mode: 0, - work_key: null, - work_kind: null, - work_state: "", - work_phase: "", - source_url: null, - github_run_url: null, - codex_thread_id: null, - codex_turn_id: null, - last_heartbeat_at: null, - completion_reason: null, ...values, }; } diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index b1d771e..e65751e 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -182,28 +182,6 @@ test("OpenClaw transcript reads a sentinel event before reporting completeness", ); }); -test("OpenClaw root reads are filtered, capped, D1-only, and log-free", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const readStart = source.indexOf("async function openClawReadSessionRoot"); - const readEnd = source.indexOf("async function openClawMutateSessionRoot", readStart); - const readSource = source.slice(readStart, readEnd); - const summaryStart = source.indexOf("function openClawCrabboxSummaryResponse"); - const summaryEnd = source.indexOf("function openClawDecoratedCrabboxResponse", summaryStart); - const summarySource = source.slice(summaryStart, summaryEnd); - - assert.match(readSource, /expression\("created_by", "=", "service:openclaw"\)/); - assert.match(readSource, /expression\("created_by", "like", "session:%"\)/); - assert.match(readSource, /\.where\("runtime", "!=", "github_actions"\)/); - assert.match(readSource, /\.where\("work_key", "is", null\)/); - assert.match(readSource, /\.where\("preparation_pending", "=", 0\)/); - assert.match(readSource, /\.limit\(openClawRoomMaxSessions \+ 1\)/); - assert.doesNotMatch(readSource, /readFreshInteractiveSession/); - assert.doesNotMatch(readSource, /mapWithConcurrency\(/); - assert.match(readSource, /openClawRoomSessionChainAllowed/); - assert.match(readSource, /openClawCrabboxSummaryResponse/); - assert.match(summarySource, /session: \{ \.\.\.response\.session, logs: \[\] \}/); -}); - test("OpenClaw target authorization precedes targeted reconciliation", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const scopedStart = source.indexOf("async function openClawRootScopedCrabbox"); @@ -388,13 +366,13 @@ test("OpenClaw root stop freezes admission and drives pending descendants termin const lineageSource = source.slice(lineageStart, lineageEnd); assert.match(routeSource, /openClawMutateSessionRoot/); - assert.match(stopSource, /openclaw_admission_closed: 1/); + assert.match(stopSource, /closeOpenClawRootAdmission/); assert.ok( stopSource.indexOf("openclaw session root stop requested") < - stopSource.indexOf("openclaw_admission_closed: 1"), + stopSource.indexOf("closeOpenClawRootAdmission"), ); assert.ok( - stopSource.indexOf("openclaw_admission_closed: 1") < + stopSource.indexOf("closeOpenClawRootAdmission") < stopSource.indexOf("rollbackInteractiveSessionReservation"), ); assert.ok( diff --git a/tests/session-model.test.ts b/tests/session-model.test.ts index 8d5e9b6..2d6c092 100644 --- a/tests/session-model.test.ts +++ b/tests/session-model.test.ts @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { - InteractiveSessionLogArchiveTable, - InteractiveSessionRow, -} from "../src/worker/database.ts"; +import type { InteractiveSessionLogArchiveTable } from "../src/worker/database.ts"; import { crabboxCapabilities, interactiveSession, @@ -13,78 +10,7 @@ import { interactiveSessionLogArchive, runtimeCapabilities, } from "../src/worker/session-model.ts"; - -function sessionRow(values: Partial = {}): InteractiveSessionRow { - return { - id: "IS-42", - parent_session_id: null, - root_session_id: null, - repo: "openclaw/crabfleet", - branch: "main", - runtime: "container", - adapter: null, - profile: "cloudflare-sandbox", - adapter_workspace_id: null, - adapter_control_plane: "https://adapter.example", - provider_resource_id: null, - capabilities_json: "{}", - expires_at: null, - last_reconciled_at: null, - reconcile_error: null, - terminal_status: null, - terminal_failure_reason: null, - adapter_ttl_seconds: null, - adapter_idle_timeout_seconds: null, - adapter_requested_capabilities_json: null, - adapter_create_payload_json: null, - adapter_create_pending: 0, - preparation_pending: 0, - openclaw_request_id: null, - openclaw_request_hash: null, - openclaw_admission_closed: 0, - terminal_finalize_pending: 0, - credential_cleanup_terminal_status: null, - sandbox_refresh_sandbox_id: null, - sandbox_refresh_claim: null, - sandbox_refresh_claim_expires_at: null, - command: "codex", - prompt: "Fix the issue", - purpose: "Fix the issue", - summary: "Working", - owner: "owner", - created_by: "github:42", - status: "ready", - lease_id: "lease-1", - attach_url: "wss://terminal.example", - vnc_url: null, - last_event: "ready", - created_at: 1, - updated_at: 2, - last_seen_at: 3, - stopped_at: null, - share_mode: "private", - share_token_hash: null, - share_token_preview: null, - control_requested_by: null, - control_requested_at: null, - controller: null, - control_granted_at: null, - control_expires_at: null, - multiplayer_mode: 1, - agent_token_hash: null, - work_key: null, - work_kind: null, - work_state: "", - work_phase: "", - source_url: null, - github_run_url: null, - codex_thread_id: null, - codex_turn_id: null, - last_heartbeat_at: null, - completion_reason: null, - ...values, - }; -} +import { sessionRow } from "./helpers/session-row.ts"; test("runtime capabilities use runtime defaults and honor explicit booleans only", () => { assert.deepEqual(runtimeCapabilities("crabbox", "{"), crabboxCapabilities); From a483268b153021308b8144791eb4539d3189741a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:38:25 +0100 Subject: [PATCH 011/109] refactor: extract OpenClaw reservations --- CHANGELOG.md | 2 +- src/index.ts | 144 +++++++++--------------------- src/worker/openclaw-repository.ts | 128 +++++++++++++++++++++++++- tests/openclaw-repository.test.ts | 120 ++++++++++++++++++++++++- tests/openclaw-service.test.ts | 42 ++------- 5 files changed, 294 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 584a548..4fd306e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Move OpenClaw room reads, cleanup polling, admission state, completion counts, and lineage reads into a directly tested repository boundary. +- Move OpenClaw room reads, reservation fencing, activation, rollback, cleanup polling, admission state, completion counts, and lineage reads into a directly tested repository boundary. - Centralize repository normalization and OpenClaw request identity, semantic hashing, and durable replay lookup behind direct behavior tests. - Centralize interactive-session types, capability defaults, hidden adapter identity, and database row/event/archive mapping in a directly tested model module. - Give Worker users, allowlist roles, cookie sessions, trusted-proxy identities, GitHub OAuth/API membership, session-owned credentials, and secret encryption dedicated auth modules with behavioral coverage. diff --git a/src/index.ts b/src/index.ts index 1d29327..52eca9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -228,13 +228,17 @@ import { readOpenClawRequestSession, } from "./worker/openclaw-request"; import { + activateInteractiveSessionReservation, closeOpenClawRootAdmission, + openClawRoomReservationPosition, openClawRootAdmissionOpen, + readAbandonedInteractiveSessionReservations, readOpenClawLineageSession, readOpenClawRoomRoot, readOpenClawRoomSessions, readOpenClawRootCompletion, readOpenClawRootRows, + removeInteractiveSessionReservation, } from "./worker/openclaw-repository"; const defaultInteractiveCommand = "codex --yolo"; @@ -2795,27 +2799,12 @@ async function enforceOpenClawRoomSessionLimitAfterInsert( await rollbackInteractiveSessionReservation(env, insertedSessionId, insertedAt); throw badRequest("invalid OpenClaw room lineage"); } - const db = database(env); - const admission = await sql<{ inserted_rowid: number; position: number }>` - SELECT inserted.rowid AS inserted_rowid, count(candidate.rowid) AS position - FROM interactive_sessions AS inserted - JOIN interactive_sessions AS candidate - ON candidate.rowid <= inserted.rowid - AND (candidate.root_session_id = ${rootSessionId} OR candidate.id = ${rootSessionId}) - AND (candidate.created_by = 'service:openclaw' OR candidate.created_by LIKE 'session:%') - AND candidate.runtime != 'github_actions' - AND candidate.work_key IS NULL - JOIN interactive_sessions AS room_root - ON room_root.id = ${rootSessionId} - AND room_root.openclaw_admission_closed = 0 - WHERE inserted.id = ${insertedSessionId} - AND inserted.status = 'provisioning' - AND inserted.preparation_pending = 1 - AND inserted.created_at = ${insertedAt} - AND inserted.updated_at = ${insertedAt} - GROUP BY inserted.rowid - `.execute(db); - const position = Number(admission.rows[0]?.position ?? 0); + const position = await openClawRoomReservationPosition( + env, + rootSessionId, + insertedSessionId, + insertedAt, + ); if (position > 0 && position <= openClawRoomMaxSessions) return; if (!position) { await rollbackInteractiveSessionReservation(env, insertedSessionId, insertedAt); @@ -2851,99 +2840,50 @@ async function openClawRoomReservationLineageAllowed( return openClawRoomSessionChainAllowed([...chain.values()], session.id, rootSessionId); } -async function rollbackInteractiveSessionReservation( - env: RuntimeEnv, - insertedSessionId: string, - insertedAt: number, -): Promise { - const db = database(env); - const ownsReservation = sql`EXISTS ( - SELECT 1 - FROM interactive_sessions - WHERE id = ${insertedSessionId} - AND status = 'provisioning' - AND preparation_pending = 1 - AND created_at = ${insertedAt} - AND updated_at = ${insertedAt} - )`; - await executeBatch(env, [ - db - .deleteFrom("openclaw_request_replays") - .where("session_id", "=", insertedSessionId) - .where(ownsReservation), - db - .deleteFrom("interactive_session_events") - .where("session_id", "=", insertedSessionId) - .where(ownsReservation), - db - .deleteFrom("interactive_session_log_archives") - .where("session_id", "=", insertedSessionId) - .where(ownsReservation), - db - .deleteFrom("interactive_sessions") - .where("id", "=", insertedSessionId) - .where("status", "=", "provisioning") - .where("preparation_pending", "=", 1) - .where("created_at", "=", insertedAt) - .where("updated_at", "=", insertedAt), - ]); - const current = await db - .selectFrom("interactive_sessions") - .select("id") - .where("id", "=", insertedSessionId) - .executeTakeFirst(); - if (current) throw serviceUnavailable("interactive session reservation rollback failed"); -} - async function cleanupAbandonedInteractiveSessionPreparations( env: RuntimeEnv, now: number, ): Promise { - const rows = await database(env) - .selectFrom("interactive_sessions") - .select(["id", "created_at"]) - .where("status", "=", "provisioning") - .where("preparation_pending", "=", 1) - .where("updated_at", "<=", now - interactiveSessionPreparationStaleMs) - .orderBy("updated_at", "asc") - .limit(runtimeAdapterReconcileLimit) - .execute(); + const rows = await readAbandonedInteractiveSessionReservations( + env, + now - interactiveSessionPreparationStaleMs, + runtimeAdapterReconcileLimit, + ); await mapWithConcurrency(rows, runtimeAdapterReconcileConcurrency, async (row) => { - await rollbackInteractiveSessionReservation(env, row.id, row.created_at).catch((error) => { - console.error(`interactive session preparation cleanup failed for ${row.id}`, error); - }); + await rollbackInteractiveSessionReservation(env, row.sessionId, row.createdAt).catch( + (error) => { + console.error(`interactive session preparation cleanup failed for ${row.sessionId}`, error); + }, + ); }); } -async function activateInteractiveSessionReservation( +async function rollbackInteractiveSessionReservation( + env: RuntimeEnv, + insertedSessionId: string, + insertedAt: number, +): Promise { + if (await removeInteractiveSessionReservation(env, insertedSessionId, insertedAt)) return; + throw serviceUnavailable("interactive session reservation rollback failed"); +} + +async function requireInteractiveSessionReservationActivation( env: RuntimeEnv, insertedSessionId: string, insertedAt: number, adapterWorkspaceId: string | null, ): Promise { - const activated = await database(env) - .updateTable("interactive_sessions") - .set({ - preparation_pending: 0, - ...(adapterWorkspaceId - ? { - adapter: runtimeAdapterName, - adapter_create_pending: 1, - last_reconciled_at: insertedAt, - reconcile_error: "runtime adapter create pending", - } - : {}), - }) - .where("id", "=", insertedSessionId) - .where("status", "=", "provisioning") - .where("preparation_pending", "=", 1) - .where("adapter", "is", null) - .where(sql`adapter_workspace_id IS ${adapterWorkspaceId}`) - .where("adapter_create_pending", "=", 0) - .where("created_at", "=", insertedAt) - .where("updated_at", "=", insertedAt) - .executeTakeFirst(); - if ((activated.numUpdatedRows ?? 0n) > 0n) return; + if ( + await activateInteractiveSessionReservation( + env, + insertedSessionId, + insertedAt, + adapterWorkspaceId, + runtimeAdapterName, + ) + ) { + return; + } await rollbackInteractiveSessionReservation(env, insertedSessionId, insertedAt); throw serviceUnavailable("interactive session reservation activation failed"); } @@ -4158,7 +4098,7 @@ async function createInteractiveSessionFromInput( throw error; } if (preparationReservation) { - await activateInteractiveSessionReservation(env, id, now, adapterWorkspaceId); + await requireInteractiveSessionReservationActivation(env, id, now, adapterWorkspaceId); } await appendInteractiveSessionEvent(env, id, user, "interactive workspace requested", now); const provisioned = await provisionInteractiveSession( diff --git a/src/worker/openclaw-repository.ts b/src/worker/openclaw-repository.ts index f6f9aae..d74f521 100644 --- a/src/worker/openclaw-repository.ts +++ b/src/worker/openclaw-repository.ts @@ -1,6 +1,6 @@ import { sql } from "kysely"; -import { database, type InteractiveSessionRow } from "./database.ts"; +import { database, executeBatch, type InteractiveSessionRow } from "./database.ts"; import type { RuntimeEnv } from "./env.ts"; import { interactiveSession, type InteractiveSession } from "./session-model.ts"; @@ -120,6 +120,132 @@ export async function readOpenClawLineageSession( return row ? interactiveSession(row, []) : null; } +export async function openClawRoomReservationPosition( + env: RuntimeEnv, + rootSessionId: string, + insertedSessionId: string, + insertedAt: number, +): Promise { + const admission = await sql<{ inserted_rowid: number; position: number }>` + SELECT inserted.rowid AS inserted_rowid, count(candidate.rowid) AS position + FROM interactive_sessions AS inserted + JOIN interactive_sessions AS candidate + ON candidate.rowid <= inserted.rowid + AND (candidate.root_session_id = ${rootSessionId} OR candidate.id = ${rootSessionId}) + AND (candidate.created_by = 'service:openclaw' OR candidate.created_by LIKE 'session:%') + AND candidate.runtime != 'github_actions' + AND candidate.work_key IS NULL + JOIN interactive_sessions AS room_root + ON room_root.id = ${rootSessionId} + AND room_root.openclaw_admission_closed = 0 + WHERE inserted.id = ${insertedSessionId} + AND inserted.status = 'provisioning' + AND inserted.preparation_pending = 1 + AND inserted.created_at = ${insertedAt} + AND inserted.updated_at = ${insertedAt} + GROUP BY inserted.rowid + `.execute(database(env)); + return Number(admission.rows[0]?.position ?? 0); +} + +export async function removeInteractiveSessionReservation( + env: RuntimeEnv, + insertedSessionId: string, + insertedAt: number, +): Promise { + const db = database(env); + const ownsReservation = sql`EXISTS ( + SELECT 1 + FROM interactive_sessions + WHERE id = ${insertedSessionId} + AND status = 'provisioning' + AND preparation_pending = 1 + AND created_at = ${insertedAt} + AND updated_at = ${insertedAt} + )`; + await executeBatch(env, [ + db + .deleteFrom("openclaw_request_replays") + .where("session_id", "=", insertedSessionId) + .where(ownsReservation), + db + .deleteFrom("interactive_session_events") + .where("session_id", "=", insertedSessionId) + .where(ownsReservation), + db + .deleteFrom("interactive_session_log_archives") + .where("session_id", "=", insertedSessionId) + .where(ownsReservation), + db + .deleteFrom("interactive_sessions") + .where("id", "=", insertedSessionId) + .where("status", "=", "provisioning") + .where("preparation_pending", "=", 1) + .where("created_at", "=", insertedAt) + .where("updated_at", "=", insertedAt), + ]); + const current = await db + .selectFrom("interactive_sessions") + .select("id") + .where("id", "=", insertedSessionId) + .executeTakeFirst(); + return !current; +} + +export type AbandonedInteractiveSessionReservation = { + sessionId: string; + createdAt: number; +}; + +export async function readAbandonedInteractiveSessionReservations( + env: RuntimeEnv, + staleBefore: number, + limit: number, +): Promise { + const rows = await database(env) + .selectFrom("interactive_sessions") + .select(["id", "created_at"]) + .where("status", "=", "provisioning") + .where("preparation_pending", "=", 1) + .where("updated_at", "<=", staleBefore) + .orderBy("updated_at", "asc") + .limit(Math.max(1, Math.floor(limit))) + .execute(); + return rows.map((row) => ({ sessionId: row.id, createdAt: row.created_at })); +} + +export async function activateInteractiveSessionReservation( + env: RuntimeEnv, + insertedSessionId: string, + insertedAt: number, + adapterWorkspaceId: string | null, + adapterName: string, +): Promise { + const activated = await database(env) + .updateTable("interactive_sessions") + .set({ + preparation_pending: 0, + ...(adapterWorkspaceId + ? { + adapter: adapterName, + adapter_create_pending: 1, + last_reconciled_at: insertedAt, + reconcile_error: "runtime adapter create pending", + } + : {}), + }) + .where("id", "=", insertedSessionId) + .where("status", "=", "provisioning") + .where("preparation_pending", "=", 1) + .where("adapter", "is", null) + .where(sql`adapter_workspace_id IS ${adapterWorkspaceId}`) + .where("adapter_create_pending", "=", 0) + .where("created_at", "=", insertedAt) + .where("updated_at", "=", insertedAt) + .executeTakeFirst(); + return (activated.numUpdatedRows ?? 0n) > 0n; +} + function openClawRoomRowsQuery(env: RuntimeEnv, rootSessionId: string) { return database(env) .selectFrom("interactive_sessions") diff --git a/tests/openclaw-repository.test.ts b/tests/openclaw-repository.test.ts index 1bf810f..87fe6c3 100644 --- a/tests/openclaw-repository.test.ts +++ b/tests/openclaw-repository.test.ts @@ -3,26 +3,41 @@ import test from "node:test"; import type { RuntimeEnv } from "../src/worker/env.ts"; import { + activateInteractiveSessionReservation, closeOpenClawRootAdmission, + openClawRoomReservationPosition, openClawRootAdmissionOpen, + readAbandonedInteractiveSessionReservations, readOpenClawLineageSession, readOpenClawRoomRoot, readOpenClawRoomSessions, readOpenClawRootCompletion, readOpenClawRootRows, + removeInteractiveSessionReservation, } from "../src/worker/openclaw-repository.ts"; import { sessionRow } from "./helpers/session-row.ts"; type D1Result = { results?: unknown[]; changes?: number }; type D1Handler = (sql: string, parameters: unknown[], kind: "all" | "run") => D1Result; +type PreparedStatement = { + sql: string; + parameters: unknown[]; + all(): Promise; + run(): Promise; +}; -function runtimeEnv(handler: D1Handler): RuntimeEnv { +function runtimeEnv( + handler: D1Handler, + batchHandler: (statements: PreparedStatement[]) => void = () => undefined, +): RuntimeEnv { return { DB: { prepare(sql: string) { return { bind(...parameters: unknown[]) { - return { + const statement: PreparedStatement = { + sql, + parameters, async all() { const result = handler(sql, parameters, "all"); return { results: result.results ?? [], meta: { changes: result.changes ?? 0 } }; @@ -32,9 +47,14 @@ function runtimeEnv(handler: D1Handler): RuntimeEnv { return { meta: { changes: result.changes ?? 0 } }; }, }; + return statement; }, }; }, + async batch(statements: unknown[]) { + batchHandler(statements as PreparedStatement[]); + return []; + }, } as unknown as D1Database, } as RuntimeEnv; } @@ -173,3 +193,99 @@ test("OpenClaw admission and lineage persistence preserve explicit preparation s assert.equal(lineage?.id, "IS-2"); assert.deepEqual(lineage?.logs, []); }); + +test("OpenClaw room reservation positions are fenced by insertion order and open admission", async () => { + const position = await openClawRoomReservationPosition( + runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + assert.match(sql, /inserted\.rowid AS inserted_rowid/i); + assert.match(sql, /candidate\.rowid <= inserted\.rowid/i); + assert.match(sql, /room_root\.openclaw_admission_closed = 0/i); + assert.match(sql, /inserted\.preparation_pending = 1/i); + assert.match(sql, /GROUP BY inserted\.rowid/i); + assert.deepEqual(parameters, ["IS-1", "IS-1", "IS-1", "IS-2", 10, 10]); + return { results: [{ inserted_rowid: 7, position: "3" }] }; + }), + "IS-1", + "IS-2", + 10, + ); + assert.equal(position, 3); +}); + +test("OpenClaw stale reservation reads are bounded and map persistence names", async () => { + const rows = await readAbandonedInteractiveSessionReservations( + runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + assert.match(sql, /status.*=.*preparation_pending.*=.*updated_at.*<=/is); + assert.match(sql, /order by "updated_at" asc/i); + assert.deepEqual(parameters, ["provisioning", 1, 500, 8]); + return { results: [{ id: "IS-2", created_at: 100 }] }; + }), + 500, + 8, + ); + assert.deepEqual(rows, [{ sessionId: "IS-2", createdAt: 100 }]); +}); + +test("OpenClaw reservation rollback deletes all owned records in one batch", async () => { + let batch: PreparedStatement[] = []; + const removed = await removeInteractiveSessionReservation( + runtimeEnv( + (sql, parameters, kind) => { + assert.equal(kind, "all"); + assert.match(sql, /^select "id" from "interactive_sessions"/i); + assert.deepEqual(parameters, ["IS-2"]); + return { results: [] }; + }, + (statements) => { + batch = statements; + }, + ), + "IS-2", + 100, + ); + + assert.equal(removed, true); + assert.equal(batch.length, 4); + assert.match(batch[0]?.sql ?? "", /^delete from "openclaw_request_replays"/i); + assert.match(batch[1]?.sql ?? "", /^delete from "interactive_session_events"/i); + assert.match(batch[2]?.sql ?? "", /^delete from "interactive_session_log_archives"/i); + assert.match(batch[3]?.sql ?? "", /^delete from "interactive_sessions"/i); + assert.ok(batch.every((statement) => statement.parameters.includes("IS-2"))); + assert.ok(batch.every((statement) => statement.parameters.includes(100))); +}); + +test("OpenClaw reservation activation reports the fenced compare-and-set result", async () => { + let updateSql = ""; + const activated = await activateInteractiveSessionReservation( + runtimeEnv((sql, _parameters, kind) => { + assert.equal(kind, "run"); + updateSql = sql; + return { changes: 1 }; + }), + "IS-2", + 100, + "workspace-2", + "runtime-adapter", + ); + assert.equal(activated, true); + assert.match(updateSql, /preparation_pending/); + assert.match(updateSql, /adapter_create_pending/); + assert.match(updateSql, /adapter_workspace_id IS/i); + assert.match(updateSql, /created_at.*=.*updated_at.*=/is); + + assert.equal( + await activateInteractiveSessionReservation( + runtimeEnv((_sql, _parameters, kind) => { + assert.equal(kind, "run"); + return { changes: 0 }; + }), + "IS-3", + 200, + null, + "runtime-adapter", + ), + false, + ); +}); diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index e65751e..054b6fc 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -219,14 +219,8 @@ test("OpenClaw room reservation precedes branch mutation, event recording, and p "utf8", ); const capacityStart = source.indexOf("async function enforceOpenClawRoomSessionLimitAfterInsert"); - const capacityEnd = source.indexOf("function openClawCrabboxResponse", capacityStart); + const capacityEnd = source.indexOf("async function openClawRoomReservationLineageAllowed"); const capacitySource = source.slice(capacityStart, capacityEnd); - const activationStart = source.indexOf("async function activateInteractiveSessionReservation"); - const activationEnd = source.indexOf("function openClawCrabboxResponse", activationStart); - const activationSource = source.slice(activationStart, activationEnd); - const rollbackStart = source.indexOf("async function rollbackInteractiveSessionReservation"); - const rollbackEnd = source.indexOf("async function activateInteractiveSessionReservation"); - const rollbackSource = source.slice(rollbackStart, rollbackEnd); const createStart = source.indexOf("async function createInteractiveSessionFromInput"); const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); const createSource = source.slice(createStart, createEnd); @@ -235,32 +229,16 @@ test("OpenClaw room reservation precedes branch mutation, event recording, and p const readSource = source.slice(readStart, readEnd); assert.match(migration, /ADD COLUMN preparation_pending INTEGER NOT NULL DEFAULT 0/); - assert.match(capacitySource, /inserted\.rowid AS inserted_rowid/); - assert.match(capacitySource, /candidate\.rowid <= inserted\.rowid/); - assert.match(capacitySource, /GROUP BY inserted\.rowid/); assert.match(capacitySource, /openClawRoomReservationLineageAllowed/); - assert.match(capacitySource, /readOpenClawLineageSession\(env, insertedSessionId, 1\)/); - assert.match(capacitySource, /readOpenClawLineageSession\(env, rootSessionId, 0\)/); assert.ok( capacitySource.indexOf("openClawRoomReservationLineageAllowed") < - capacitySource.indexOf("inserted.rowid AS inserted_rowid"), + capacitySource.indexOf("openClawRoomReservationPosition"), ); - assert.match(capacitySource, /deleteFrom\("interactive_sessions"\)/); + assert.match(capacitySource, /rollbackInteractiveSessionReservation/); assert.match( capacitySource, /throw tooManyRequests\("session root reached the supervision limit"\)/, ); - assert.match(activationSource, /adapter: runtimeAdapterName/); - assert.match(activationSource, /adapter_create_pending: 1/); - assert.match(activationSource, /preparation_pending: 0/); - assert.match(activationSource, /\.where\("preparation_pending", "=", 1\)/); - assert.match(activationSource, /\.where\("adapter", "is", null\)/); - assert.match(activationSource, /\.where\("adapter_create_pending", "=", 0\)/); - assert.match(rollbackSource, /executeBatch\(env,/); - assert.match(rollbackSource, /deleteFrom\("interactive_session_events"\)/); - assert.match(rollbackSource, /deleteFrom\("interactive_session_log_archives"\)/); - assert.match(capacitySource, /cleanupAbandonedInteractiveSessionPreparations/); - assert.match(capacitySource, /interactiveSessionPreparationStaleMs/); assert.match(readSource, /\.where\("preparation_pending", "=", 0\)/); assert.match( createSource, @@ -281,10 +259,10 @@ test("OpenClaw room reservation precedes branch mutation, event recording, and p ); assert.ok( createSource.indexOf("await options.afterReserve") < - createSource.indexOf("activateInteractiveSessionReservation"), + createSource.indexOf("requireInteractiveSessionReservationActivation"), ); assert.ok( - createSource.indexOf("activateInteractiveSessionReservation") < + createSource.indexOf("requireInteractiveSessionReservationActivation") < createSource.indexOf("appendInteractiveSessionEvent"), ); assert.ok( @@ -329,13 +307,6 @@ test("OpenClaw crabbox requests reserve durable idempotency before provisioning" const createStart = source.indexOf("async function createInteractiveSessionFromInput"); const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); const createSource = source.slice(createStart, createEnd); - const rollbackStart = source.indexOf("async function rollbackInteractiveSessionReservation"); - const rollbackEnd = source.indexOf( - "async function cleanupAbandonedInteractiveSessionPreparations", - rollbackStart, - ); - const rollbackSource = source.slice(rollbackStart, rollbackEnd); - assert.match(migration, /ADD COLUMN openclaw_request_id TEXT/); assert.match(migration, /UNIQUE INDEX IF NOT EXISTS idx_interactive_sessions_openclaw_request/); assert.match(migration, /CREATE TABLE IF NOT EXISTS openclaw_request_replays/); @@ -350,7 +321,6 @@ test("OpenClaw crabbox requests reserve durable idempotency before provisioning" assert.match(createSource, /\.insertInto\("openclaw_request_replays"\)/); assert.match(createSource, /!reservationInserted &&\s+isConstraintError\(error\)/); assert.match(createSource, /if \(reservationInserted \|\| !isConstraintError\(error\)/); - assert.match(rollbackSource, /\.deleteFrom\("openclaw_request_replays"\)/); }); test("OpenClaw root stop freezes admission and drives pending descendants terminal", async () => { @@ -393,7 +363,7 @@ test("OpenClaw root stop freezes admission and drives pending descendants termin assert.doesNotMatch(stopSource, /session root exceeds the supervision limit/); assert.doesNotMatch(stopSource, /openClawRoomSessionChainAllowed/); assert.match(lineageSource, /openClawRootAdmissionOpen/); - assert.match(lineageSource, /room_root\.openclaw_admission_closed = 0/); + assert.match(lineageSource, /openClawRoomReservationPosition/); assert.match( lineageSource, /if \(!position\) \{\s+await rollbackInteractiveSessionReservation[\s\S]+throw conflict\("OpenClaw room root is stopping"\)/, From 0072cdbb186223792015851f02e58c5c7f710084 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:42:20 +0100 Subject: [PATCH 012/109] refactor: extract OpenClaw supervision --- CHANGELOG.md | 1 + src/index.ts | 181 ++++++------------------- src/worker/openclaw-supervision.ts | 158 ++++++++++++++++++++++ tests/openclaw-service.test.ts | 73 +--------- tests/openclaw-supervision.test.ts | 206 +++++++++++++++++++++++++++++ 5 files changed, 408 insertions(+), 211 deletions(-) create mode 100644 src/worker/openclaw-supervision.ts create mode 100644 tests/openclaw-supervision.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd306e..6dfc6d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. - Move OpenClaw room reads, reservation fencing, activation, rollback, cleanup polling, admission state, completion counts, and lineage reads into a directly tested repository boundary. - Centralize repository normalization and OpenClaw request identity, semantic hashing, and durable replay lookup behind direct behavior tests. - Centralize interactive-session types, capability defaults, hidden adapter identity, and database row/event/archive mapping in a directly tested model module. diff --git a/src/index.ts b/src/index.ts index 52eca9c..704c40c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -240,6 +240,10 @@ import { readOpenClawRootRows, removeInteractiveSessionReservation, } from "./worker/openclaw-repository"; +import { + OpenClawSupervisionService, + type OpenClawSupervisionStore, +} from "./worker/openclaw-supervision"; const defaultInteractiveCommand = "codex --yolo"; @@ -2516,6 +2520,7 @@ async function openClawMutateSessionRoot( throw notFound("session root not found"); } const serviceUser = openClawServiceUser(); + const supervision = openClawSupervision(env); await audit(env, serviceUser, `openclaw session root stop requested ${root}`, Date.now()); await closeOpenClawRootAdmission(env, root); const deadline = Date.now() + 60_000; @@ -2529,7 +2534,7 @@ async function openClawMutateSessionRoot( const pending = rows.filter((row) => row.preparation_pending !== 0).slice(0, 4); await mapWithConcurrency(pending, 4, async (row) => { await runOpenClawRootOperationBeforeDeadline(deadline, () => - rollbackInteractiveSessionReservation(env, row.id, row.created_at), + supervision.rollbackReservation(row.id, row.created_at), ); }); if (Date.now() >= deadline) break; @@ -2732,112 +2737,7 @@ async function openClawRootScopedCrabbox( 120, ); if (!rootSessionId) throw badRequest("root session id is required"); - const session = await readInteractiveSession(env, id); - const root = await readInteractiveSession(env, rootSessionId); - const chain = - session && root ? await openClawReadSessionChain(env, session, root, rootSessionId) : []; - if (!session || !root || !openClawRoomSessionChainAllowed(chain, session.id, rootSessionId)) { - throw notFound("interactive session not found"); - } - const refreshed = await readFreshInteractiveSession(env, id); - if (!refreshed) throw notFound("interactive session not found"); - return refreshed; -} - -async function openClawReadSessionChain( - env: RuntimeEnv, - session: InteractiveSession, - root: InteractiveSession, - rootSessionId: string, -): Promise { - const chain = new Map([[root.id, root]]); - let current = session; - for (let depth = 0; depth < openClawRoomMaxSessions && !chain.has(current.id); depth += 1) { - chain.set(current.id, current); - if (current.id === rootSessionId || !current.parentSessionId) break; - if (chain.has(current.parentSessionId)) break; - const parent = await readInteractiveSession(env, current.parentSessionId); - if (!parent) break; - current = parent; - } - return [...chain.values()]; -} - -async function openClawSupervisedRootForCreate( - env: RuntimeEnv, - createdBy: string, - lineage: { parentSessionId: string | null; rootSessionId: string | null }, -): Promise { - if (!lineage.parentSessionId || !lineage.rootSessionId) return null; - const [parent, root] = await Promise.all([ - readInteractiveSession(env, lineage.parentSessionId), - readInteractiveSession(env, lineage.rootSessionId), - ]); - if (!parent || !root) throw badRequest("session lineage not found"); - if (createdBy === "service:openclaw" || createdBy === `session:${lineage.parentSessionId}`) { - const chain = await openClawReadSessionChain(env, parent, root, lineage.rootSessionId); - if (openClawRoomSessionChainAllowed(chain, parent.id, lineage.rootSessionId)) { - if (!(await openClawRootAdmissionOpen(env, lineage.rootSessionId))) { - throw conflict("OpenClaw room root is stopping"); - } - return lineage.rootSessionId; - } - } - if (createdBy === "service:openclaw" || openClawRoomRootAllowed(root)) { - throw badRequest("invalid OpenClaw room lineage"); - } - return null; -} - -async function enforceOpenClawRoomSessionLimitAfterInsert( - env: RuntimeEnv, - rootSessionId: string, - insertedSessionId: string, - insertedAt: number, -): Promise { - if (!(await openClawRoomReservationLineageAllowed(env, insertedSessionId, rootSessionId))) { - await rollbackInteractiveSessionReservation(env, insertedSessionId, insertedAt); - throw badRequest("invalid OpenClaw room lineage"); - } - const position = await openClawRoomReservationPosition( - env, - rootSessionId, - insertedSessionId, - insertedAt, - ); - if (position > 0 && position <= openClawRoomMaxSessions) return; - if (!position) { - await rollbackInteractiveSessionReservation(env, insertedSessionId, insertedAt); - if (!(await openClawRootAdmissionOpen(env, rootSessionId))) { - throw conflict("OpenClaw room root is stopping"); - } - throw serviceUnavailable("session root reservation disappeared"); - } - await rollbackInteractiveSessionReservation(env, insertedSessionId, insertedAt); - throw tooManyRequests("session root reached the supervision limit"); -} - -async function openClawRoomReservationLineageAllowed( - env: RuntimeEnv, - insertedSessionId: string, - rootSessionId: string, -): Promise { - const [session, root] = await Promise.all([ - readOpenClawLineageSession(env, insertedSessionId, 1), - readOpenClawLineageSession(env, rootSessionId, 0), - ]); - if (!session || !root) return false; - const chain = new Map([[root.id, root]]); - let current = session; - for (let depth = 0; depth < openClawRoomMaxSessions && !chain.has(current.id); depth += 1) { - chain.set(current.id, current); - if (current.id === rootSessionId || !current.parentSessionId) break; - if (chain.has(current.parentSessionId)) break; - const parent = await readOpenClawLineageSession(env, current.parentSessionId, 0); - if (!parent) break; - current = parent; - } - return openClawRoomSessionChainAllowed([...chain.values()], session.id, rootSessionId); + return openClawSupervision(env).requireRootScopedSession(id, rootSessionId); } async function cleanupAbandonedInteractiveSessionPreparations( @@ -2849,43 +2749,35 @@ async function cleanupAbandonedInteractiveSessionPreparations( now - interactiveSessionPreparationStaleMs, runtimeAdapterReconcileLimit, ); + const supervision = openClawSupervision(env); await mapWithConcurrency(rows, runtimeAdapterReconcileConcurrency, async (row) => { - await rollbackInteractiveSessionReservation(env, row.sessionId, row.createdAt).catch( - (error) => { - console.error(`interactive session preparation cleanup failed for ${row.sessionId}`, error); - }, - ); + await supervision.rollbackReservation(row.sessionId, row.createdAt).catch((error) => { + console.error(`interactive session preparation cleanup failed for ${row.sessionId}`, error); + }); }); } -async function rollbackInteractiveSessionReservation( - env: RuntimeEnv, - insertedSessionId: string, - insertedAt: number, -): Promise { - if (await removeInteractiveSessionReservation(env, insertedSessionId, insertedAt)) return; - throw serviceUnavailable("interactive session reservation rollback failed"); -} - -async function requireInteractiveSessionReservationActivation( - env: RuntimeEnv, - insertedSessionId: string, - insertedAt: number, - adapterWorkspaceId: string | null, -): Promise { - if ( - await activateInteractiveSessionReservation( - env, - insertedSessionId, - insertedAt, - adapterWorkspaceId, - runtimeAdapterName, - ) - ) { - return; - } - await rollbackInteractiveSessionReservation(env, insertedSessionId, insertedAt); - throw serviceUnavailable("interactive session reservation activation failed"); +function openClawSupervision(env: RuntimeEnv): OpenClawSupervisionService { + const store: OpenClawSupervisionStore = { + readSession: (id) => readInteractiveSession(env, id), + refreshSession: (id) => readFreshInteractiveSession(env, id), + readLineageSession: (id, preparationPending) => + readOpenClawLineageSession(env, id, preparationPending), + rootAdmissionOpen: (rootSessionId) => openClawRootAdmissionOpen(env, rootSessionId), + roomReservationPosition: (rootSessionId, insertedSessionId, insertedAt) => + openClawRoomReservationPosition(env, rootSessionId, insertedSessionId, insertedAt), + removeReservation: (insertedSessionId, insertedAt) => + removeInteractiveSessionReservation(env, insertedSessionId, insertedAt), + activateReservation: (insertedSessionId, insertedAt, adapterWorkspaceId) => + activateInteractiveSessionReservation( + env, + insertedSessionId, + insertedAt, + adapterWorkspaceId, + runtimeAdapterName, + ), + }; + return new OpenClawSupervisionService(store); } function openClawCrabboxResponse( @@ -3953,7 +3845,8 @@ async function createInteractiveSessionFromInput( options.parentSessionId ?? (clean(body.parentSessionId, 120) || null), options.rootSessionId ?? (clean(body.rootSessionId, 120) || null), ); - const supervisedRootSessionId = await openClawSupervisedRootForCreate(env, createdBy, lineage); + const supervision = openClawSupervision(env); + const supervisedRootSessionId = await supervision.supervisedRootForCreate(createdBy, lineage); const preparationReservation = Boolean(options.afterReserve || supervisedRootSessionId); const now = Date.now(); const db = database(env); @@ -4089,16 +3982,16 @@ async function createInteractiveSessionFromInput( } reservationInserted = true; if (supervisedRootSessionId) { - await enforceOpenClawRoomSessionLimitAfterInsert(env, supervisedRootSessionId, id, now); + await supervision.enforceRoomSessionLimitAfterInsert(supervisedRootSessionId, id, now); } try { await options.afterReserve?.(); } catch (error) { - await rollbackInteractiveSessionReservation(env, id, now); + await supervision.rollbackReservation(id, now); throw error; } if (preparationReservation) { - await requireInteractiveSessionReservationActivation(env, id, now, adapterWorkspaceId); + await supervision.requireReservationActivation(id, now, adapterWorkspaceId); } await appendInteractiveSessionEvent(env, id, user, "interactive workspace requested", now); const provisioned = await provisionInteractiveSession( diff --git a/src/worker/openclaw-supervision.ts b/src/worker/openclaw-supervision.ts new file mode 100644 index 0000000..7504afc --- /dev/null +++ b/src/worker/openclaw-supervision.ts @@ -0,0 +1,158 @@ +import { + openClawRoomMaxSessions, + openClawRoomRootAllowed, + openClawRoomSessionChainAllowed, +} from "../openclaw-service.ts"; +import { badRequest, conflict, notFound, serviceUnavailable, tooManyRequests } from "./http.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export type OpenClawSupervisionStore = { + readSession(id: string): Promise; + refreshSession(id: string): Promise; + readLineageSession(id: string, preparationPending: 0 | 1): Promise; + rootAdmissionOpen(rootSessionId: string): Promise; + roomReservationPosition( + rootSessionId: string, + insertedSessionId: string, + insertedAt: number, + ): Promise; + removeReservation(insertedSessionId: string, insertedAt: number): Promise; + activateReservation( + insertedSessionId: string, + insertedAt: number, + adapterWorkspaceId: string | null, + ): Promise; +}; + +export class OpenClawSupervisionService { + private readonly store: OpenClawSupervisionStore; + private readonly maximumSessions: number; + + constructor(store: OpenClawSupervisionStore, maximumSessions = openClawRoomMaxSessions) { + this.store = store; + this.maximumSessions = maximumSessions; + } + + async requireRootScopedSession( + sessionId: string, + rootSessionId: string, + ): Promise { + const session = await this.store.readSession(sessionId); + const root = await this.store.readSession(rootSessionId); + const chain = + session && root + ? await this.readSessionChain(session, root, rootSessionId, (id) => + this.store.readSession(id), + ) + : []; + if (!session || !root || !openClawRoomSessionChainAllowed(chain, session.id, rootSessionId)) { + throw notFound("interactive session not found"); + } + const refreshed = await this.store.refreshSession(sessionId); + if (!refreshed) throw notFound("interactive session not found"); + return refreshed; + } + + async supervisedRootForCreate( + createdBy: string, + lineage: { parentSessionId: string | null; rootSessionId: string | null }, + ): Promise { + if (!lineage.parentSessionId || !lineage.rootSessionId) return null; + const [parent, root] = await Promise.all([ + this.store.readSession(lineage.parentSessionId), + this.store.readSession(lineage.rootSessionId), + ]); + if (!parent || !root) throw badRequest("session lineage not found"); + if (createdBy === "service:openclaw" || createdBy === `session:${lineage.parentSessionId}`) { + const chain = await this.readSessionChain(parent, root, lineage.rootSessionId, (id) => + this.store.readSession(id), + ); + if (openClawRoomSessionChainAllowed(chain, parent.id, lineage.rootSessionId)) { + if (!(await this.store.rootAdmissionOpen(lineage.rootSessionId))) { + throw conflict("OpenClaw room root is stopping"); + } + return lineage.rootSessionId; + } + } + if (createdBy === "service:openclaw" || openClawRoomRootAllowed(root)) { + throw badRequest("invalid OpenClaw room lineage"); + } + return null; + } + + async enforceRoomSessionLimitAfterInsert( + rootSessionId: string, + insertedSessionId: string, + insertedAt: number, + ): Promise { + if (!(await this.roomReservationLineageAllowed(insertedSessionId, rootSessionId))) { + await this.rollbackReservation(insertedSessionId, insertedAt); + throw badRequest("invalid OpenClaw room lineage"); + } + const position = await this.store.roomReservationPosition( + rootSessionId, + insertedSessionId, + insertedAt, + ); + if (position > 0 && position <= this.maximumSessions) return; + await this.rollbackReservation(insertedSessionId, insertedAt); + if (!position) { + if (!(await this.store.rootAdmissionOpen(rootSessionId))) { + throw conflict("OpenClaw room root is stopping"); + } + throw serviceUnavailable("session root reservation disappeared"); + } + throw tooManyRequests("session root reached the supervision limit"); + } + + async rollbackReservation(insertedSessionId: string, insertedAt: number): Promise { + if (await this.store.removeReservation(insertedSessionId, insertedAt)) return; + throw serviceUnavailable("interactive session reservation rollback failed"); + } + + async requireReservationActivation( + insertedSessionId: string, + insertedAt: number, + adapterWorkspaceId: string | null, + ): Promise { + if (await this.store.activateReservation(insertedSessionId, insertedAt, adapterWorkspaceId)) { + return; + } + await this.rollbackReservation(insertedSessionId, insertedAt); + throw serviceUnavailable("interactive session reservation activation failed"); + } + + private async roomReservationLineageAllowed( + insertedSessionId: string, + rootSessionId: string, + ): Promise { + const [session, root] = await Promise.all([ + this.store.readLineageSession(insertedSessionId, 1), + this.store.readLineageSession(rootSessionId, 0), + ]); + if (!session || !root) return false; + const chain = await this.readSessionChain(session, root, rootSessionId, (id) => + this.store.readLineageSession(id, 0), + ); + return openClawRoomSessionChainAllowed(chain, session.id, rootSessionId); + } + + private async readSessionChain( + session: InteractiveSession, + root: InteractiveSession, + rootSessionId: string, + readParent: (id: string) => Promise, + ): Promise { + const chain = new Map([[root.id, root]]); + let current = session; + for (let depth = 0; depth < this.maximumSessions && !chain.has(current.id); depth += 1) { + chain.set(current.id, current); + if (current.id === rootSessionId || !current.parentSessionId) break; + if (chain.has(current.parentSessionId)) break; + const parent = await readParent(current.parentSessionId); + if (!parent) break; + current = parent; + } + return [...chain.values()]; + } +} diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index 054b6fc..70dfea7 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -182,24 +182,6 @@ test("OpenClaw transcript reads a sentinel event before reporting completeness", ); }); -test("OpenClaw target authorization precedes targeted reconciliation", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const scopedStart = source.indexOf("async function openClawRootScopedCrabbox"); - const scopedEnd = source.indexOf("async function openClawReadSessionChain", scopedStart); - const scopedSource = source.slice(scopedStart, scopedEnd); - const chainStart = scopedEnd; - const chainEnd = source.indexOf("async function openClawSupervisedRootForCreate", chainStart); - const chainSource = source.slice(chainStart, chainEnd); - - assert.ok( - scopedSource.indexOf("openClawRoomSessionChainAllowed") < - scopedSource.indexOf("readFreshInteractiveSession"), - ); - assert.match(scopedSource, /const session = await readInteractiveSession/); - assert.match(scopedSource, /const root = await readInteractiveSession/); - assert.doesNotMatch(chainSource, /readFreshInteractiveSession/); -}); - test("interactive lineage rejects caller-claimed roots without a parent", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const start = source.indexOf("async function resolveInteractiveSessionLineage"); @@ -218,9 +200,6 @@ test("OpenClaw room reservation precedes branch mutation, event recording, and p new URL("../migrations/0025_interactive_session_preparation.sql", import.meta.url), "utf8", ); - const capacityStart = source.indexOf("async function enforceOpenClawRoomSessionLimitAfterInsert"); - const capacityEnd = source.indexOf("async function openClawRoomReservationLineageAllowed"); - const capacitySource = source.slice(capacityStart, capacityEnd); const createStart = source.indexOf("async function createInteractiveSessionFromInput"); const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); const createSource = source.slice(createStart, createEnd); @@ -229,16 +208,6 @@ test("OpenClaw room reservation precedes branch mutation, event recording, and p const readSource = source.slice(readStart, readEnd); assert.match(migration, /ADD COLUMN preparation_pending INTEGER NOT NULL DEFAULT 0/); - assert.match(capacitySource, /openClawRoomReservationLineageAllowed/); - assert.ok( - capacitySource.indexOf("openClawRoomReservationLineageAllowed") < - capacitySource.indexOf("openClawRoomReservationPosition"), - ); - assert.match(capacitySource, /rollbackInteractiveSessionReservation/); - assert.match( - capacitySource, - /throw tooManyRequests\("session root reached the supervision limit"\)/, - ); assert.match(readSource, /\.where\("preparation_pending", "=", 0\)/); assert.match( createSource, @@ -254,15 +223,15 @@ test("OpenClaw room reservation precedes branch mutation, event recording, and p ); assert.match(createSource, /preparation_pending: preparationReservation \? 1 : 0/); assert.ok( - createSource.indexOf("enforceOpenClawRoomSessionLimitAfterInsert") < + createSource.indexOf("supervision.enforceRoomSessionLimitAfterInsert") < createSource.indexOf("await options.afterReserve"), ); assert.ok( createSource.indexOf("await options.afterReserve") < - createSource.indexOf("requireInteractiveSessionReservationActivation"), + createSource.indexOf("supervision.requireReservationActivation"), ); assert.ok( - createSource.indexOf("requireInteractiveSessionReservationActivation") < + createSource.indexOf("supervision.requireReservationActivation") < createSource.indexOf("appendInteractiveSessionEvent"), ); assert.ok( @@ -271,30 +240,10 @@ test("OpenClaw room reservation precedes branch mutation, event recording, and p ); assert.match( createSource, - /catch \(error\) \{\s+await rollbackInteractiveSessionReservation\(env, id, now\);\s+throw error;/, + /catch \(error\) \{\s+await supervision\.rollbackReservation\(id, now\);\s+throw error;/, ); }); -test("invalid descendants below an OpenClaw root fail closed before insertion", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const lineageStart = source.indexOf("async function openClawSupervisedRootForCreate"); - const lineageEnd = source.indexOf( - "async function enforceOpenClawRoomSessionLimitAfterInsert", - lineageStart, - ); - const lineageSource = source.slice(lineageStart, lineageEnd); - - assert.match( - lineageSource, - /if \(!parent \|\| !root\) throw badRequest\("session lineage not found"\)/, - ); - assert.match( - lineageSource, - /if \(createdBy === "service:openclaw" \|\| openClawRoomRootAllowed\(root\)\)/, - ); - assert.match(lineageSource, /throw badRequest\("invalid OpenClaw room lineage"\)/); -}); - test("OpenClaw crabbox requests reserve durable idempotency before provisioning", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const migration = await readFile( @@ -331,10 +280,6 @@ test("OpenClaw root stop freezes admission and drives pending descendants termin const stopStart = source.indexOf("async function openClawMutateSessionRoot"); const stopEnd = source.indexOf("async function openClawReadCrabbox", stopStart); const stopSource = source.slice(stopStart, stopEnd); - const lineageStart = source.indexOf("async function openClawSupervisedRootForCreate"); - const lineageEnd = source.indexOf("async function openClawRoomReservationLineageAllowed"); - const lineageSource = source.slice(lineageStart, lineageEnd); - assert.match(routeSource, /openClawMutateSessionRoot/); assert.match(stopSource, /closeOpenClawRootAdmission/); assert.ok( @@ -343,10 +288,10 @@ test("OpenClaw root stop freezes admission and drives pending descendants termin ); assert.ok( stopSource.indexOf("closeOpenClawRootAdmission") < - stopSource.indexOf("rollbackInteractiveSessionReservation"), + stopSource.indexOf("supervision.rollbackReservation"), ); assert.ok( - stopSource.indexOf("rollbackInteractiveSessionReservation") < + stopSource.indexOf("supervision.rollbackReservation") < stopSource.indexOf("mutateInteractiveSession"), ); assert.match(stopSource, /terminalReads >= 2/); @@ -362,12 +307,6 @@ test("OpenClaw root stop freezes admission and drives pending descendants termin assert.match(stopSource, /Math\.min\(2_000, pollDelayMs \* 2\)/); assert.doesNotMatch(stopSource, /session root exceeds the supervision limit/); assert.doesNotMatch(stopSource, /openClawRoomSessionChainAllowed/); - assert.match(lineageSource, /openClawRootAdmissionOpen/); - assert.match(lineageSource, /openClawRoomReservationPosition/); - assert.match( - lineageSource, - /if \(!position\) \{\s+await rollbackInteractiveSessionReservation[\s\S]+throw conflict\("OpenClaw room root is stopping"\)/, - ); }); test("OpenClaw lifecycle guarantees are documented", async () => { diff --git a/tests/openclaw-supervision.test.ts b/tests/openclaw-supervision.test.ts new file mode 100644 index 0000000..c31bfd7 --- /dev/null +++ b/tests/openclaw-supervision.test.ts @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { InteractiveSession } from "../src/worker/session-model.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { + OpenClawSupervisionService, + type OpenClawSupervisionStore, +} from "../src/worker/openclaw-supervision.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function session(values: Parameters[0] = {}): InteractiveSession { + return interactiveSession(sessionRow(values), []); +} + +function supervisionStore( + overrides: Partial = {}, +): OpenClawSupervisionStore { + return { + readSession: async () => null, + refreshSession: async () => null, + readLineageSession: async () => null, + rootAdmissionOpen: async () => true, + roomReservationPosition: async () => 1, + removeReservation: async () => true, + activateReservation: async () => true, + ...overrides, + }; +} + +function status(error: unknown): number | undefined { + return typeof error === "object" && error !== null && "status" in error + ? Number(error.status) + : undefined; +} + +test("OpenClaw root-scoped reads authorize the complete chain before refreshing the target", async () => { + const root = session({ + id: "IS-1", + root_session_id: "IS-1", + created_by: "service:openclaw", + }); + const child = session({ + id: "IS-2", + parent_session_id: "IS-1", + root_session_id: "IS-1", + created_by: "session:IS-1", + }); + const calls: string[] = []; + const sessions = new Map([ + [root.id, root], + [child.id, child], + ]); + const service = new OpenClawSupervisionService( + supervisionStore({ + readSession: async (id) => { + calls.push(`read:${id}`); + return sessions.get(id) ?? null; + }, + refreshSession: async (id) => { + calls.push(`refresh:${id}`); + return sessions.get(id) ?? null; + }, + }), + ); + + assert.equal((await service.requireRootScopedSession("IS-2", "IS-1")).id, "IS-2"); + assert.deepEqual(calls, ["read:IS-2", "read:IS-1", "refresh:IS-2"]); + + calls.length = 0; + sessions.set("IS-2", { ...child, createdBy: "github:42" }); + await assert.rejects(service.requireRootScopedSession("IS-2", "IS-1"), (error) => { + assert.equal(status(error), 404); + return true; + }); + assert.deepEqual(calls, ["read:IS-2", "read:IS-1"]); +}); + +test("OpenClaw supervised lineage accepts exact descendants and fences closed roots", async () => { + const root = session({ + id: "IS-1", + root_session_id: "IS-1", + created_by: "service:openclaw", + }); + const parent = session({ + id: "IS-2", + parent_session_id: "IS-1", + root_session_id: "IS-1", + created_by: "session:IS-1", + }); + const sessions = new Map([ + [root.id, root], + [parent.id, parent], + ]); + let admissionOpen = true; + const service = new OpenClawSupervisionService( + supervisionStore({ + readSession: async (id) => sessions.get(id) ?? null, + rootAdmissionOpen: async () => admissionOpen, + }), + ); + const lineage = { parentSessionId: "IS-2", rootSessionId: "IS-1" }; + + assert.equal(await service.supervisedRootForCreate("session:IS-2", lineage), "IS-1"); + admissionOpen = false; + await assert.rejects(service.supervisedRootForCreate("session:IS-2", lineage), (error) => { + assert.equal(status(error), 409); + return true; + }); + await assert.rejects( + service.supervisedRootForCreate("service:openclaw", { + parentSessionId: "missing", + rootSessionId: "IS-1", + }), + (error) => { + assert.equal(status(error), 400); + return true; + }, + ); +}); + +test("OpenClaw reservation supervision maps lineage, capacity, and admission outcomes", async () => { + const root = session({ + id: "IS-1", + root_session_id: "IS-1", + created_by: "service:openclaw", + }); + const inserted = session({ + id: "IS-2", + parent_session_id: "IS-1", + root_session_id: "IS-1", + created_by: "session:IS-1", + }); + let position = 2; + let admissionOpen = true; + const rollbacks: string[] = []; + const lineage = new Map([ + ["IS-1:0", root], + ["IS-2:1", inserted], + ]); + const service = new OpenClawSupervisionService( + supervisionStore({ + readLineageSession: async (id, pending) => lineage.get(`${id}:${pending}`) ?? null, + roomReservationPosition: async () => position, + rootAdmissionOpen: async () => admissionOpen, + removeReservation: async (id) => { + rollbacks.push(id); + return true; + }, + }), + ); + + await service.enforceRoomSessionLimitAfterInsert("IS-1", "IS-2", 100); + assert.deepEqual(rollbacks, []); + + position = 65; + await assert.rejects(service.enforceRoomSessionLimitAfterInsert("IS-1", "IS-2", 100), (error) => { + assert.equal(status(error), 429); + return true; + }); + assert.deepEqual(rollbacks, ["IS-2"]); + + position = 0; + admissionOpen = false; + await assert.rejects(service.enforceRoomSessionLimitAfterInsert("IS-1", "IS-2", 100), (error) => { + assert.equal(status(error), 409); + return true; + }); + assert.deepEqual(rollbacks, ["IS-2", "IS-2"]); + + lineage.delete("IS-2:1"); + await assert.rejects(service.enforceRoomSessionLimitAfterInsert("IS-1", "IS-2", 100), (error) => { + assert.equal(status(error), 400); + return true; + }); + assert.deepEqual(rollbacks, ["IS-2", "IS-2", "IS-2"]); +}); + +test("OpenClaw activation failures roll back before returning service errors", async () => { + const calls: string[] = []; + let removeSucceeded = true; + const service = new OpenClawSupervisionService( + supervisionStore({ + activateReservation: async () => { + calls.push("activate"); + return false; + }, + removeReservation: async () => { + calls.push("rollback"); + return removeSucceeded; + }, + }), + ); + + await assert.rejects(service.requireReservationActivation("IS-2", 100, "workspace"), { + message: "interactive session reservation activation failed", + }); + assert.deepEqual(calls, ["activate", "rollback"]); + + calls.length = 0; + removeSucceeded = false; + await assert.rejects(service.requireReservationActivation("IS-2", 100, "workspace"), { + message: "interactive session reservation rollback failed", + }); + assert.deepEqual(calls, ["activate", "rollback"]); +}); From 9868b87fe87160f5bb4a23b41e02bcf58b5aef73 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:47:22 +0100 Subject: [PATCH 013/109] refactor: extract OpenClaw root stop --- CHANGELOG.md | 1 + src/index.ts | 147 +++++++--------------- src/worker/models.ts | 6 + src/worker/openclaw-root-stop.ts | 145 ++++++++++++++++++++++ tests/openclaw-root-stop.test.ts | 207 +++++++++++++++++++++++++++++++ tests/openclaw-service.test.ts | 37 ------ 6 files changed, 402 insertions(+), 141 deletions(-) create mode 100644 src/worker/openclaw-root-stop.ts create mode 100644 tests/openclaw-root-stop.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dfc6d4..f2e83b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. - Move OpenClaw room reads, reservation fencing, activation, rollback, cleanup polling, admission state, completion counts, and lineage reads into a directly tested repository boundary. - Centralize repository normalization and OpenClaw request identity, semantic hashing, and durable replay lookup behind direct behavior tests. diff --git a/src/index.ts b/src/index.ts index 704c40c..d1e2a41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -161,13 +161,14 @@ import { type RunAttemptTable, type StandaloneSandboxProvisionTable, } from "./worker/database"; -import type { - InteractiveRuntime, - InteractiveSessionStatus, - Role, - RunStatus, - User, - WorkflowStatus, +import { + deadInteractiveSessionStatuses, + type InteractiveRuntime, + type InteractiveSessionStatus, + type Role, + type RunStatus, + type User, + type WorkflowStatus, } from "./worker/models"; import { badRequest, @@ -244,6 +245,7 @@ import { OpenClawSupervisionService, type OpenClawSupervisionStore, } from "./worker/openclaw-supervision"; +import { OpenClawRootStopService, type OpenClawRootStopStore } from "./worker/openclaw-root-stop"; const defaultInteractiveCommand = "codex --yolo"; @@ -595,11 +597,6 @@ const lanes = ["Todo", "Running", "Human Review", "Done"]; const sandboxLeasePrefix = "sandbox:"; const sandboxLeaseProfile = "autostart-v4"; const activeRunStatuses: readonly RunStatus[] = ["queued", "leasing", "running"]; -const deadInteractiveSessionStatuses: readonly InteractiveSessionStatus[] = [ - "stopped", - "expired", - "failed", -]; const runtimeOptions = ["auto", "container", "crabbox"] as const; const mergePolicyOptions = ["open_pr", "merge_when_green", "fix_until_green_and_merge"] as const; const defaultStallMs = 5 * 60 * 1000; @@ -2515,99 +2512,15 @@ async function openClawMutateSessionRoot( if (body.action !== "stop") throw badRequest("only stop is supported"); const root = clean(rootSessionId, 120); if (!root) throw badRequest("root session id is required"); - const rootSession = await readInteractiveSession(env, root); - if (!rootSession || !openClawRoomRootAllowed(rootSession)) { - throw notFound("session root not found"); - } const serviceUser = openClawServiceUser(); - const supervision = openClawSupervision(env); - await audit(env, serviceUser, `openclaw session root stop requested ${root}`, Date.now()); - await closeOpenClawRootAdmission(env, root); - const deadline = Date.now() + 60_000; - let terminalReads = 0; - let pollDelayMs = 250; - let previousState = ""; - const lifecycleAttempts = new Map(); - const nextLifecycleAttemptAt = new Map(); - while (Date.now() < deadline) { - let rows = await readOpenClawRootRows(env, root, openClawRoomMaxSessions); - const pending = rows.filter((row) => row.preparation_pending !== 0).slice(0, 4); - await mapWithConcurrency(pending, 4, async (row) => { - await runOpenClawRootOperationBeforeDeadline(deadline, () => - supervision.rollbackReservation(row.id, row.created_at), - ); - }); - if (Date.now() >= deadline) break; - rows = await readOpenClawRootRows(env, root, openClawRoomMaxSessions); - const sessions = rows.map((row) => interactiveSession(row, [])); - const now = Date.now(); - const actionable = sessions - .filter((session) => !deadInteractiveSessionStatuses.includes(session.status)) - .filter((session) => (nextLifecycleAttemptAt.get(session.id) ?? 0) <= now) - .reverse() - .slice(0, 4); - await mapWithConcurrency(actionable, 4, async (session) => { - const attempt = (lifecycleAttempts.get(session.id) ?? 0) + 1; - lifecycleAttempts.set(session.id, attempt); - nextLifecycleAttemptAt.set( - session.id, - now + Math.min(10_000, 500 * 2 ** Math.min(attempt - 1, 5)), - ); - if (session.status === "stopping" && session.adapter !== runtimeAdapterName) { - await runOpenClawRootOperationBeforeDeadline(deadline, () => - reconcileExternalInteractiveSessionById(env, session.id, now), - ); - return; - } - await runOpenClawRootOperationBeforeDeadline(deadline, () => - mutateInteractiveSession(request, env, serviceUser, session.id, "stop").then( - () => undefined, - ), - ); - }); - if (Date.now() >= deadline) break; - rows = await readOpenClawRootRows(env, root, openClawRoomMaxSessions); - const completion = await readOpenClawRootCompletion(env, root); - const complete = completion.remaining === 0; - terminalReads = complete ? terminalReads + 1 : 0; - if (terminalReads >= 2) { - const sessions = rows.map((row) => interactiveSession(row, [])); - await audit(env, serviceUser, `openclaw session root stopped ${root}`, Date.now()); - return { - rootSessionId: root, - admissionClosed: true, - crabboxes: sessions.map((session) => - openClawCrabboxSummaryResponse(env, serviceUser, session), - ), - }; - } - const currentState = `${completion.total}:${completion.remaining}:${rows - .map((row) => `${row.id}:${row.status}:${row.preparation_pending}`) - .join("|")}`; - pollDelayMs = currentState === previousState ? Math.min(2_000, pollDelayMs * 2) : 250; - previousState = currentState; - const remaining = deadline - Date.now(); - if (remaining <= 0) break; - await sleep(Math.min(pollDelayMs, remaining)); - } - throw serviceUnavailable("OpenClaw session root cleanup did not reach a terminal state"); -} - -async function runOpenClawRootOperationBeforeDeadline( - deadline: number, - operation: () => Promise, -): Promise { - const remaining = deadline - Date.now(); - if (remaining <= 0) return; - await new Promise((resolve) => { - const timer = setTimeout(resolve, remaining); - void operation() - .catch(() => undefined) - .finally(() => { - clearTimeout(timer); - resolve(); - }); - }); + const result = await openClawRootStopService(request, env, serviceUser).stop(root); + return { + rootSessionId: result.rootSessionId, + admissionClosed: true, + crabboxes: result.sessions.map((session) => + openClawCrabboxSummaryResponse(env, serviceUser, session), + ), + }; } async function openClawReadCrabbox( @@ -2780,6 +2693,32 @@ function openClawSupervision(env: RuntimeEnv): OpenClawSupervisionService { return new OpenClawSupervisionService(store); } +function openClawRootStopService( + request: Request, + env: RuntimeEnv, + serviceUser: User, +): OpenClawRootStopService { + const supervision = openClawSupervision(env); + const store: OpenClawRootStopStore = { + readRootSession: (rootSessionId) => readInteractiveSession(env, rootSessionId), + recordStopRequested: (rootSessionId, now) => + audit(env, serviceUser, `openclaw session root stop requested ${rootSessionId}`, now), + closeAdmission: (rootSessionId) => closeOpenClawRootAdmission(env, rootSessionId), + readRootRows: (rootSessionId, maximumSessions) => + readOpenClawRootRows(env, rootSessionId, maximumSessions), + rollbackReservation: (sessionId, createdAt) => + supervision.rollbackReservation(sessionId, createdAt), + stopSession: (session) => + mutateInteractiveSession(request, env, serviceUser, session.id, "stop").then(() => undefined), + reconcileSession: (session, now) => + reconcileExternalInteractiveSessionById(env, session.id, now), + readRootCompletion: (rootSessionId) => readOpenClawRootCompletion(env, rootSessionId), + recordStopped: (rootSessionId, now) => + audit(env, serviceUser, `openclaw session root stopped ${rootSessionId}`, now), + }; + return new OpenClawRootStopService(store, runtimeAdapterName); +} + function openClawCrabboxResponse( env: RuntimeEnv, serviceUser: User, diff --git a/src/worker/models.ts b/src/worker/models.ts index 506de1c..4ce8fbc 100644 --- a/src/worker/models.ts +++ b/src/worker/models.ts @@ -34,3 +34,9 @@ export type InteractiveSessionStatus = | "stopped" | "expired" | "failed"; + +export const deadInteractiveSessionStatuses: readonly InteractiveSessionStatus[] = [ + "stopped", + "expired", + "failed", +]; diff --git a/src/worker/openclaw-root-stop.ts b/src/worker/openclaw-root-stop.ts new file mode 100644 index 0000000..76e5d3d --- /dev/null +++ b/src/worker/openclaw-root-stop.ts @@ -0,0 +1,145 @@ +import { openClawRoomMaxSessions, openClawRoomRootAllowed } from "../openclaw-service.ts"; +import type { InteractiveSessionRow } from "./database.ts"; +import { notFound, serviceUnavailable } from "./http.ts"; +import { deadInteractiveSessionStatuses } from "./models.ts"; +import { interactiveSession, type InteractiveSession } from "./session-model.ts"; + +export type OpenClawRootStopStore = { + readRootSession(rootSessionId: string): Promise; + recordStopRequested(rootSessionId: string, now: number): Promise; + closeAdmission(rootSessionId: string): Promise; + readRootRows(rootSessionId: string, maximumSessions: number): Promise; + rollbackReservation(sessionId: string, createdAt: number): Promise; + stopSession(session: InteractiveSession): Promise; + reconcileSession(session: InteractiveSession, now: number): Promise; + readRootCompletion(rootSessionId: string): Promise<{ total: number; remaining: number }>; + recordStopped(rootSessionId: string, now: number): Promise; +}; + +export type OpenClawRootStopClock = { + now(): number; + sleep(milliseconds: number): Promise; +}; + +export type OpenClawRootStopResult = { + rootSessionId: string; + sessions: InteractiveSession[]; +}; + +const defaultClock: OpenClawRootStopClock = { + now: () => Date.now(), + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +}; + +export class OpenClawRootStopService { + private readonly store: OpenClawRootStopStore; + private readonly adapterName: string; + private readonly clock: OpenClawRootStopClock; + private readonly maximumSessions: number; + private readonly deadlineMilliseconds: number; + + constructor( + store: OpenClawRootStopStore, + adapterName: string, + options: { + clock?: OpenClawRootStopClock; + maximumSessions?: number; + deadlineMilliseconds?: number; + } = {}, + ) { + this.store = store; + this.adapterName = adapterName; + this.clock = options.clock ?? defaultClock; + this.maximumSessions = options.maximumSessions ?? openClawRoomMaxSessions; + this.deadlineMilliseconds = options.deadlineMilliseconds ?? 60_000; + } + + async stop(rootSessionId: string): Promise { + const root = await this.store.readRootSession(rootSessionId); + if (!root || !openClawRoomRootAllowed(root)) { + throw notFound("session root not found"); + } + await this.store.recordStopRequested(rootSessionId, this.clock.now()); + await this.store.closeAdmission(rootSessionId); + + const deadline = this.clock.now() + this.deadlineMilliseconds; + let terminalReads = 0; + let pollDelayMilliseconds = 250; + let previousState = ""; + const lifecycleAttempts = new Map(); + const nextLifecycleAttemptAt = new Map(); + + while (this.clock.now() < deadline) { + let rows = await this.store.readRootRows(rootSessionId, this.maximumSessions); + const pending = rows.filter((row) => row.preparation_pending !== 0).slice(0, 4); + await Promise.all( + pending.map((row) => + this.runBeforeDeadline(deadline, () => + this.store.rollbackReservation(row.id, row.created_at), + ), + ), + ); + if (this.clock.now() >= deadline) break; + + rows = await this.store.readRootRows(rootSessionId, this.maximumSessions); + const sessions = rows.map((row) => interactiveSession(row, [])); + const now = this.clock.now(); + const actionable = sessions + .filter((session) => !deadInteractiveSessionStatuses.includes(session.status)) + .filter((session) => (nextLifecycleAttemptAt.get(session.id) ?? 0) <= now) + .reverse() + .slice(0, 4); + await Promise.all( + actionable.map(async (session) => { + const attempt = (lifecycleAttempts.get(session.id) ?? 0) + 1; + lifecycleAttempts.set(session.id, attempt); + nextLifecycleAttemptAt.set( + session.id, + now + Math.min(10_000, 500 * 2 ** Math.min(attempt - 1, 5)), + ); + if (session.status === "stopping" && session.adapter !== this.adapterName) { + await this.runBeforeDeadline(deadline, () => this.store.reconcileSession(session, now)); + return; + } + await this.runBeforeDeadline(deadline, () => this.store.stopSession(session)); + }), + ); + if (this.clock.now() >= deadline) break; + + rows = await this.store.readRootRows(rootSessionId, this.maximumSessions); + const completion = await this.store.readRootCompletion(rootSessionId); + terminalReads = completion.remaining === 0 ? terminalReads + 1 : 0; + if (terminalReads >= 2) { + await this.store.recordStopped(rootSessionId, this.clock.now()); + return { + rootSessionId, + sessions: rows.map((row) => interactiveSession(row, [])), + }; + } + const currentState = `${completion.total}:${completion.remaining}:${rows + .map((row) => `${row.id}:${row.status}:${row.preparation_pending}`) + .join("|")}`; + pollDelayMilliseconds = + currentState === previousState ? Math.min(2_000, pollDelayMilliseconds * 2) : 250; + previousState = currentState; + const remaining = deadline - this.clock.now(); + if (remaining <= 0) break; + await this.clock.sleep(Math.min(pollDelayMilliseconds, remaining)); + } + throw serviceUnavailable("OpenClaw session root cleanup did not reach a terminal state"); + } + + private async runBeforeDeadline(deadline: number, operation: () => Promise): Promise { + const remaining = deadline - this.clock.now(); + if (remaining <= 0) return; + await new Promise((resolve) => { + const timer = setTimeout(resolve, remaining); + void operation() + .catch(() => undefined) + .finally(() => { + clearTimeout(timer); + resolve(); + }); + }); + } +} diff --git a/tests/openclaw-root-stop.test.ts b/tests/openclaw-root-stop.test.ts new file mode 100644 index 0000000..b52d587 --- /dev/null +++ b/tests/openclaw-root-stop.test.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { InteractiveSessionRow } from "../src/worker/database.ts"; +import { + OpenClawRootStopService, + type OpenClawRootStopClock, + type OpenClawRootStopStore, +} from "../src/worker/openclaw-root-stop.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function rootSession() { + return interactiveSession( + sessionRow({ + id: "IS-1", + root_session_id: "IS-1", + created_by: "service:openclaw", + status: "stopped", + }), + [], + ); +} + +function clock(): OpenClawRootStopClock & { current: number } { + return { + current: 0, + now() { + return this.current; + }, + async sleep(milliseconds) { + this.current += milliseconds; + }, + }; +} + +function rootStopStore(overrides: Partial = {}): OpenClawRootStopStore { + return { + readRootSession: async () => rootSession(), + recordStopRequested: async () => undefined, + closeAdmission: async () => undefined, + readRootRows: async () => [], + rollbackReservation: async () => undefined, + stopSession: async () => undefined, + reconcileSession: async () => undefined, + readRootCompletion: async () => ({ total: 0, remaining: 0 }), + recordStopped: async () => undefined, + ...overrides, + }; +} + +test("OpenClaw root stop records intent before closing admission and requires stable completion", async () => { + const calls: string[] = []; + let completionReads = 0; + const terminalRoot = sessionRow({ + id: "IS-1", + root_session_id: "IS-1", + created_by: "service:openclaw", + status: "stopped", + }); + const service = new OpenClawRootStopService( + rootStopStore({ + recordStopRequested: async () => { + calls.push("requested"); + }, + closeAdmission: async () => { + calls.push("closed"); + }, + readRootRows: async () => [terminalRoot], + readRootCompletion: async () => { + completionReads += 1; + return { total: 1, remaining: 0 }; + }, + recordStopped: async () => { + calls.push("stopped"); + }, + }), + "runtime-adapter", + { clock: clock() }, + ); + + const result = await service.stop("IS-1"); + assert.equal(completionReads, 2); + assert.deepEqual(calls, ["requested", "closed", "stopped"]); + assert.deepEqual( + result.sessions.map((session) => session.id), + ["IS-1"], + ); +}); + +test("OpenClaw root stop rolls back reservations before stopping active descendants", async () => { + const calls: string[] = []; + let pending = true; + let active = true; + const root = sessionRow({ + id: "IS-1", + root_session_id: "IS-1", + created_by: "service:openclaw", + status: "stopped", + }); + const pendingChild = sessionRow({ + id: "IS-2", + parent_session_id: "IS-1", + root_session_id: "IS-1", + created_by: "session:IS-1", + preparation_pending: 1, + }); + const activeChild = sessionRow({ + id: "IS-3", + parent_session_id: "IS-1", + root_session_id: "IS-1", + created_by: "session:IS-1", + status: "ready", + }); + const rows = (): InteractiveSessionRow[] => [ + ...(pending ? [pendingChild] : []), + { ...activeChild, status: active ? "ready" : "stopped" }, + root, + ]; + const service = new OpenClawRootStopService( + rootStopStore({ + readRootRows: async () => rows(), + rollbackReservation: async (id) => { + calls.push(`rollback:${id}`); + pending = false; + }, + stopSession: async (session) => { + calls.push(`stop:${session.id}`); + active = false; + }, + readRootCompletion: async () => ({ + total: rows().length, + remaining: pending || active ? 1 : 0, + }), + }), + "runtime-adapter", + { clock: clock() }, + ); + + await service.stop("IS-1"); + assert.deepEqual(calls.slice(0, 2), ["rollback:IS-2", "stop:IS-3"]); +}); + +test("OpenClaw root stop reconciles non-adapter stopping sessions", async () => { + let reconciled = 0; + let stopping = true; + const root = sessionRow({ + id: "IS-1", + root_session_id: "IS-1", + created_by: "service:openclaw", + status: "stopped", + }); + const child = sessionRow({ + id: "IS-2", + parent_session_id: "IS-1", + root_session_id: "IS-1", + created_by: "session:IS-1", + status: "stopping", + adapter: null, + }); + const rows = () => [{ ...child, status: stopping ? "stopping" : "stopped" } as const, root]; + const service = new OpenClawRootStopService( + rootStopStore({ + readRootRows: async () => rows(), + reconcileSession: async () => { + reconciled += 1; + stopping = false; + }, + stopSession: async () => { + throw new Error("stopping legacy sessions must reconcile"); + }, + readRootCompletion: async () => ({ + total: 2, + remaining: stopping ? 1 : 0, + }), + }), + "runtime-adapter", + { clock: clock() }, + ); + + await service.stop("IS-1"); + assert.equal(reconciled, 1); +}); + +test("OpenClaw root stop rejects non-room roots before recording mutations", async () => { + let mutated = false; + const service = new OpenClawRootStopService( + rootStopStore({ + readRootSession: async () => + interactiveSession(sessionRow({ id: "IS-1", created_by: "github:42" }), []), + recordStopRequested: async () => { + mutated = true; + }, + }), + "runtime-adapter", + { clock: clock() }, + ); + + await assert.rejects(service.stop("IS-1"), (error: unknown) => { + assert.equal( + typeof error === "object" && error !== null && "status" in error ? error.status : null, + 404, + ); + return true; + }); + assert.equal(mutated, false); +}); diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index 70dfea7..7ff8d23 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -272,43 +272,6 @@ test("OpenClaw crabbox requests reserve durable idempotency before provisioning" assert.match(createSource, /if \(reservationInserted \|\| !isConstraintError\(error\)/); }); -test("OpenClaw root stop freezes admission and drives pending descendants terminal", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const routeStart = source.indexOf("const openClawSessionRootActionMatch"); - const routeEnd = source.indexOf("const openClawCrabboxTranscriptMatch", routeStart); - const routeSource = source.slice(routeStart, routeEnd); - const stopStart = source.indexOf("async function openClawMutateSessionRoot"); - const stopEnd = source.indexOf("async function openClawReadCrabbox", stopStart); - const stopSource = source.slice(stopStart, stopEnd); - assert.match(routeSource, /openClawMutateSessionRoot/); - assert.match(stopSource, /closeOpenClawRootAdmission/); - assert.ok( - stopSource.indexOf("openclaw session root stop requested") < - stopSource.indexOf("closeOpenClawRootAdmission"), - ); - assert.ok( - stopSource.indexOf("closeOpenClawRootAdmission") < - stopSource.indexOf("supervision.rollbackReservation"), - ); - assert.ok( - stopSource.indexOf("supervision.rollbackReservation") < - stopSource.indexOf("mutateInteractiveSession"), - ); - assert.match(stopSource, /terminalReads >= 2/); - assert.match(stopSource, /completion\.remaining === 0/); - assert.match(stopSource, /nextLifecycleAttemptAt/); - assert.match( - stopSource, - /session\.status === "stopping" && session\.adapter !== runtimeAdapterName/, - ); - assert.match(stopSource, /reconcileExternalInteractiveSessionById/); - assert.match(stopSource, /runOpenClawRootOperationBeforeDeadline/); - assert.match(stopSource, /\.slice\(0, 4\)/); - assert.match(stopSource, /Math\.min\(2_000, pollDelayMs \* 2\)/); - assert.doesNotMatch(stopSource, /session root exceeds the supervision limit/); - assert.doesNotMatch(stopSource, /openClawRoomSessionChainAllowed/); -}); - test("OpenClaw lifecycle guarantees are documented", async () => { const docs = await readFile(new URL("../docs/api.md", import.meta.url), "utf8"); assert.match(docs, /requestId/); From a57c9b9506da136ca6bc75e67c50594944de0cac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:52:32 +0100 Subject: [PATCH 014/109] refactor: extract OpenClaw queries --- CHANGELOG.md | 1 + src/index.ts | 38 +++++++-------- src/worker/openclaw-queries.ts | 55 +++++++++++++++++++++ tests/openclaw-queries.test.ts | 89 ++++++++++++++++++++++++++++++++++ tests/openclaw-service.test.ts | 16 ------ 5 files changed, 162 insertions(+), 37 deletions(-) create mode 100644 src/worker/openclaw-queries.ts create mode 100644 tests/openclaw-queries.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e83b6..8f57253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. - Move OpenClaw room reads, reservation fencing, activation, rollback, cleanup polling, admission state, completion counts, and lineage reads into a directly tested repository boundary. diff --git a/src/index.ts b/src/index.ts index d1e2a41..c0ead2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,13 +111,10 @@ import { cachedBooleanGrant } from "./terminal-authorization"; import { obsoleteSessionArchiveObjectKeys, sessionArchiveAttemptKeys } from "./session-archive"; import { readBoundedResponseText } from "./bounded-response"; import { - boundedUtf8Tail, openClawBranchPreparationCanDefer, openClawGitBranchAllowed, openClawGitHubRepoParts, openClawRoomMaxSessions, - openClawRoomRootAllowed, - openClawRoomSessionChainAllowed, openClawServiceAuthorized, } from "./openclaw-service"; import { @@ -246,6 +243,12 @@ import { type OpenClawSupervisionStore, } from "./worker/openclaw-supervision"; import { OpenClawRootStopService, type OpenClawRootStopStore } from "./worker/openclaw-root-stop"; +import { + buildOpenClawTranscript, + openClawSessionSummary, + openClawTranscriptEventWindow, + openClawVisibleRoomSessions, +} from "./worker/openclaw-queries"; const defaultInteractiveCommand = "codex --yolo"; @@ -2481,20 +2484,12 @@ async function openClawReadSessionRoot( const root = clean(rootSessionId, 120); if (!root) throw badRequest("root session id is required"); const rootSession = await readOpenClawRoomRoot(env, root); - if (!rootSession || !openClawRoomRootAllowed(rootSession)) { - throw notFound("session root not found"); - } const room = await readOpenClawRoomSessions(env, root, openClawRoomMaxSessions); - if (!room.sessions.length) throw notFound("session root not found"); - if (room.overflow) { - throw serviceUnavailable("session root exceeds the supervision limit"); - } + const sessions = openClawVisibleRoomSessions(root, rootSession, room); const serviceUser = openClawServiceUser(); return { rootSessionId: root, - crabboxes: room.sessions - .filter((session) => openClawRoomSessionChainAllowed(room.sessions, session.id, root)) - .map((session) => openClawCrabboxSummaryResponse(env, serviceUser, session)), + crabboxes: sessions.map((session) => openClawCrabboxSummaryResponse(env, serviceUser, session)), }; } @@ -2547,18 +2542,19 @@ async function openClawReadCrabboxTranscript( requireOpenClawRoomService(request, env); const session = await openClawRootScopedCrabbox(request, env, id); const [eventWindow, eventCount] = await Promise.all([ - readInteractiveSessionEventRows(env, id, { limit: 241, newest: true }), + readInteractiveSessionEventRows(env, id, { + limit: openClawTranscriptEventWindow, + newest: true, + }), countInteractiveSessionEvents(env, id), ]); - const hasMoreEvents = eventWindow.length > 240; - const events = hasMoreEvents ? eventWindow.slice(1) : eventWindow; - const transcript = boundedUtf8Tail(sessionLogTranscript(session, events)); + const transcript = buildOpenClawTranscript(eventWindow, eventCount, (events) => + sessionLogTranscript(session, events), + ); const response = openClawCrabboxSummaryResponse(env, openClawServiceUser(), session); return { ...response, - transcript: transcript.text, - eventCount, - truncated: transcript.truncated || hasMoreEvents || eventCount > events.length, + ...transcript, }; } @@ -2736,7 +2732,7 @@ function openClawCrabboxSummaryResponse( session: InteractiveSession, ): { session: InteractiveSession; browserUrl: string } { const response = openClawCrabboxResponse(env, serviceUser, session); - return { ...response, session: { ...response.session, logs: [] } }; + return { ...response, session: openClawSessionSummary(response.session) }; } function openClawDecoratedCrabboxResponse( diff --git a/src/worker/openclaw-queries.ts b/src/worker/openclaw-queries.ts new file mode 100644 index 0000000..94a39d4 --- /dev/null +++ b/src/worker/openclaw-queries.ts @@ -0,0 +1,55 @@ +import { + boundedUtf8Tail, + openClawRoomRootAllowed, + openClawRoomSessionChainAllowed, +} from "../openclaw-service.ts"; +import { notFound, serviceUnavailable } from "./http.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export const openClawTranscriptEventLimit = 240; +export const openClawTranscriptEventWindow = openClawTranscriptEventLimit + 1; + +export type OpenClawRoomRead = { + sessions: InteractiveSession[]; + overflow: boolean; +}; + +export function openClawVisibleRoomSessions( + rootSessionId: string, + root: InteractiveSession | null, + room: OpenClawRoomRead, +): InteractiveSession[] { + if (!root || !openClawRoomRootAllowed(root)) { + throw notFound("session root not found"); + } + if (!room.sessions.length) throw notFound("session root not found"); + if (room.overflow) { + throw serviceUnavailable("session root exceeds the supervision limit"); + } + return room.sessions.filter((session) => + openClawRoomSessionChainAllowed(room.sessions, session.id, rootSessionId), + ); +} + +export function openClawSessionSummary(session: InteractiveSession): InteractiveSession { + return { ...session, logs: [] }; +} + +export function buildOpenClawTranscript( + eventWindow: Event[], + eventCount: number, + render: (events: Event[]) => string, +): { + transcript: string; + eventCount: number; + truncated: boolean; +} { + const hasMoreEvents = eventWindow.length > openClawTranscriptEventLimit; + const events = hasMoreEvents ? eventWindow.slice(1) : eventWindow; + const transcript = boundedUtf8Tail(render(events)); + return { + transcript: transcript.text, + eventCount, + truncated: transcript.truncated || hasMoreEvents || eventCount > events.length, + }; +} diff --git a/tests/openclaw-queries.test.ts b/tests/openclaw-queries.test.ts new file mode 100644 index 0000000..b645b62 --- /dev/null +++ b/tests/openclaw-queries.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { interactiveSession } from "../src/worker/session-model.ts"; +import { + buildOpenClawTranscript, + openClawSessionSummary, + openClawTranscriptEventLimit, + openClawTranscriptEventWindow, + openClawVisibleRoomSessions, +} from "../src/worker/openclaw-queries.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function status(error: unknown): number | undefined { + return typeof error === "object" && error !== null && "status" in error + ? Number(error.status) + : undefined; +} + +test("OpenClaw room queries reject invalid roots and filter invalid descendants", () => { + const root = interactiveSession( + sessionRow({ + id: "IS-1", + root_session_id: "IS-1", + created_by: "service:openclaw", + }), + [], + ); + const child = interactiveSession( + sessionRow({ + id: "IS-2", + parent_session_id: "IS-1", + root_session_id: "IS-1", + created_by: "session:IS-1", + }), + [], + ); + const invalid = { ...child, id: "IS-3", createdBy: "github:42" }; + + assert.deepEqual( + openClawVisibleRoomSessions("IS-1", root, { + sessions: [root, child, invalid], + overflow: false, + }).map((session) => session.id), + ["IS-1", "IS-2"], + ); + assert.throws( + () => openClawVisibleRoomSessions("IS-1", null, { sessions: [], overflow: false }), + (error) => { + assert.equal(status(error), 404); + return true; + }, + ); + assert.throws( + () => openClawVisibleRoomSessions("IS-1", root, { sessions: [root], overflow: true }), + (error) => { + assert.equal(status(error), 503); + return true; + }, + ); +}); + +test("OpenClaw transcript queries consume a sentinel and preserve truncation evidence", () => { + const events = Array.from({ length: openClawTranscriptEventWindow }, (_, index) => index + 1); + let rendered: number[] = []; + const result = buildOpenClawTranscript(events, 300, (selected) => { + rendered = selected; + return selected.join(","); + }); + + assert.equal(openClawTranscriptEventLimit, 240); + assert.equal(openClawTranscriptEventWindow, 241); + assert.equal(rendered.length, 240); + assert.equal(rendered[0], 2); + assert.equal(result.eventCount, 300); + assert.equal(result.truncated, true); + assert.equal( + buildOpenClawTranscript([1, 2], 2, (selected) => selected.join(",")).truncated, + false, + ); +}); + +test("OpenClaw summaries remove logs without mutating the session", () => { + const session = interactiveSession(sessionRow(), ["first", "second"]); + const summary = openClawSessionSummary(session); + assert.deepEqual(summary.logs, []); + assert.deepEqual(session.logs, ["first", "second"]); + assert.equal(summary.id, session.id); +}); diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index 7ff8d23..481cf54 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -166,22 +166,6 @@ test("OpenClaw mutations persist request evidence before consequential work", as ); }); -test("OpenClaw transcript reads a sentinel event before reporting completeness", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const transcriptStart = source.indexOf("async function openClawReadCrabboxTranscript"); - const transcriptEnd = source.indexOf("async function openClawMessageCrabbox", transcriptStart); - const transcriptSource = source.slice(transcriptStart, transcriptEnd); - - assert.match(transcriptSource, /limit: 241, newest: true/); - assert.match(transcriptSource, /const hasMoreEvents = eventWindow\.length > 240/); - assert.match(transcriptSource, /eventWindow\.slice\(1\)/); - assert.match(transcriptSource, /openClawCrabboxSummaryResponse/); - assert.match( - transcriptSource, - /transcript\.truncated \|\| hasMoreEvents \|\| eventCount > events\.length/, - ); -}); - test("interactive lineage rejects caller-claimed roots without a parent", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const start = source.indexOf("async function resolveInteractiveSessionLineage"); From 670d20ffed64ac892fd03cfd2ba7be4f23237a42 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 11:57:25 +0100 Subject: [PATCH 015/109] refactor: extract OpenClaw mutations --- CHANGELOG.md | 1 + src/index.ts | 83 +++++++---------- src/worker/openclaw-mutations.ts | 78 ++++++++++++++++ tests/openclaw-mutations.test.ts | 152 +++++++++++++++++++++++++++++++ tests/openclaw-service.test.ts | 20 ---- 5 files changed, 266 insertions(+), 68 deletions(-) create mode 100644 src/worker/openclaw-mutations.ts create mode 100644 tests/openclaw-mutations.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f57253..9492095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Extract OpenClaw nudge and stop validation, audit ordering, terminal delivery, and best-effort delivery records into a directly tested mutation service. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index c0ead2f..c381859 100644 --- a/src/index.ts +++ b/src/index.ts @@ -249,6 +249,7 @@ import { openClawTranscriptEventWindow, openClawVisibleRoomSessions, } from "./worker/openclaw-queries"; +import { OpenClawMutationService, type OpenClawMutationStore } from "./worker/openclaw-mutations"; const defaultInteractiveCommand = "codex --yolo"; @@ -2568,52 +2569,8 @@ async function openClawMessageCrabbox( request, ); const session = await openClawRootScopedCrabbox(request, env, id, body.rootSessionId); - if (["stopping", "stopped", "expired", "failed"].includes(session.status)) { - throw badRequest(`session is ${session.status}`); - } - if (!session.capabilities.terminal) { - throw badRequest("session does not advertise terminal access"); - } - const message = clean(body.message, 4000); - if (!message) throw badRequest("message is required"); const serviceUser = openClawServiceUser(); - const now = Date.now(); - await appendInteractiveSessionEvent( - env, - id, - serviceUser, - "OpenClaw service nudge requested", - now, - ); - await audit(env, serviceUser, `openclaw crabbox message requested ${id}`, now); - const terminalRequest = new Request(request.url, { headers: { upgrade: "websocket" } }); - const upstream = await openInteractiveTerminalUpstream( - terminalRequest, - env, - serviceUser, - session, - 120, - 34, - ); - upstream.socket.send(encoder.encode(`${message}${body.enter === false ? "" : "\r"}`)); - try { - upstream.socket.close(1000, "OpenClaw service nudge sent"); - } catch { - console.warn(JSON.stringify({ event: "openclaw_message_socket_close_failed", sessionId: id })); - } - const deliveredAt = Date.now(); - const deliveryRecords = await Promise.allSettled([ - appendInteractiveSessionEvent(env, id, serviceUser, "OpenClaw service nudge sent", deliveredAt), - audit(env, serviceUser, `openclaw crabbox message sent ${id}`, deliveredAt), - ]); - if (deliveryRecords.some((record) => record.status === "rejected")) { - console.warn( - JSON.stringify({ - event: "openclaw_message_delivery_record_failed", - sessionId: id, - }), - ); - } + await openClawMutationService(request, env, serviceUser).sendMessage(session, body); return { delivered: true, ...openClawCrabboxSummaryResponse(env, serviceUser, session), @@ -2630,9 +2587,8 @@ async function openClawMutateCrabbox( await openClawRootScopedCrabbox(request, env, id, body.rootSessionId); if (body.action !== "stop") throw badRequest("only stop is supported"); const serviceUser = openClawServiceUser(); - await audit(env, serviceUser, `openclaw crabbox stop requested ${id}`, Date.now()); - const result = await mutateInteractiveSession(request, env, serviceUser, id, body.action); - return openClawCrabboxSummaryResponse(env, serviceUser, result.session); + const session = await openClawMutationService(request, env, serviceUser).stopSession(id); + return openClawCrabboxSummaryResponse(env, serviceUser, session); } async function openClawRootScopedCrabbox( @@ -2715,6 +2671,37 @@ function openClawRootStopService( return new OpenClawRootStopService(store, runtimeAdapterName); } +function openClawMutationService( + request: Request, + env: RuntimeEnv, + serviceUser: User, +): OpenClawMutationService { + const store: OpenClawMutationStore = { + now: () => Date.now(), + recordEvent: (sessionId, message, now) => + appendInteractiveSessionEvent(env, sessionId, serviceUser, message, now), + audit: (message, now) => audit(env, serviceUser, message, now), + openTerminal: async (session) => { + const terminalRequest = new Request(request.url, { headers: { upgrade: "websocket" } }); + const upstream = await openInteractiveTerminalUpstream( + terminalRequest, + env, + serviceUser, + session, + 120, + 34, + ); + return upstream.socket; + }, + stopSession: (sessionId) => + mutateInteractiveSession(request, env, serviceUser, sessionId, "stop").then( + (result) => result.session, + ), + warn: (event) => console.warn(JSON.stringify(event)), + }; + return new OpenClawMutationService(store); +} + function openClawCrabboxResponse( env: RuntimeEnv, serviceUser: User, diff --git a/src/worker/openclaw-mutations.ts b/src/worker/openclaw-mutations.ts new file mode 100644 index 0000000..4319c69 --- /dev/null +++ b/src/worker/openclaw-mutations.ts @@ -0,0 +1,78 @@ +import { badRequest } from "./http.ts"; +import { deadInteractiveSessionStatuses } from "./models.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export type OpenClawTerminalSocket = { + send(data: Uint8Array): void; + close(code: number, reason: string): void; +}; + +export type OpenClawMutationStore = { + now(): number; + recordEvent(sessionId: string, message: string, now: number): Promise; + audit(message: string, now: number): Promise; + openTerminal(session: InteractiveSession): Promise; + stopSession(sessionId: string): Promise; + warn(event: Record): void; +}; + +const encoder = new TextEncoder(); + +export class OpenClawMutationService { + private readonly store: OpenClawMutationStore; + + constructor(store: OpenClawMutationStore) { + this.store = store; + } + + async sendMessage( + session: InteractiveSession, + input: { message?: unknown; enter?: unknown }, + ): Promise { + if (session.status === "stopping" || deadInteractiveSessionStatuses.includes(session.status)) { + throw badRequest(`session is ${session.status}`); + } + if (!session.capabilities.terminal) { + throw badRequest("session does not advertise terminal access"); + } + const message = clean(input.message, 4000); + if (!message) throw badRequest("message is required"); + + const now = this.store.now(); + await this.store.recordEvent(session.id, "OpenClaw service nudge requested", now); + await this.store.audit(`openclaw crabbox message requested ${session.id}`, now); + const socket = await this.store.openTerminal(session); + socket.send(encoder.encode(`${message}${input.enter === false ? "" : "\r"}`)); + try { + socket.close(1000, "OpenClaw service nudge sent"); + } catch { + this.store.warn({ + event: "openclaw_message_socket_close_failed", + sessionId: session.id, + }); + } + + const deliveredAt = this.store.now(); + const deliveryRecords = await Promise.allSettled([ + this.store.recordEvent(session.id, "OpenClaw service nudge sent", deliveredAt), + this.store.audit(`openclaw crabbox message sent ${session.id}`, deliveredAt), + ]); + if (deliveryRecords.some((record) => record.status === "rejected")) { + this.store.warn({ + event: "openclaw_message_delivery_record_failed", + sessionId: session.id, + }); + } + } + + async stopSession(sessionId: string): Promise { + await this.store.audit(`openclaw crabbox stop requested ${sessionId}`, this.store.now()); + return this.store.stopSession(sessionId); + } +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/openclaw-mutations.test.ts b/tests/openclaw-mutations.test.ts new file mode 100644 index 0000000..6ad48ae --- /dev/null +++ b/tests/openclaw-mutations.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + OpenClawMutationService, + type OpenClawMutationStore, + type OpenClawTerminalSocket, +} from "../src/worker/openclaw-mutations.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function mutationStore(overrides: Partial = {}): OpenClawMutationStore { + return { + now: () => 1, + recordEvent: async () => undefined, + audit: async () => undefined, + openTerminal: async () => ({ send() {}, close() {} }), + stopSession: async (id) => interactiveSession(sessionRow({ id, status: "stopped" }), []), + warn: () => undefined, + ...overrides, + }; +} + +function status(error: unknown): number | undefined { + return typeof error === "object" && error !== null && "status" in error + ? Number(error.status) + : undefined; +} + +test("OpenClaw nudges persist request evidence before terminal delivery", async () => { + const calls: string[] = []; + const times = [10, 20]; + let sent = ""; + const socket: OpenClawTerminalSocket = { + send(data) { + calls.push("send"); + sent = new TextDecoder().decode(data); + }, + close() { + calls.push("close"); + }, + }; + const service = new OpenClawMutationService( + mutationStore({ + now: () => times.shift() ?? 20, + recordEvent: async (_id, message, now) => { + calls.push(`event:${message}:${now}`); + }, + audit: async (message, now) => { + calls.push(`audit:${message}:${now}`); + }, + openTerminal: async () => { + calls.push("open"); + return socket; + }, + }), + ); + const session = interactiveSession(sessionRow({ id: "IS-2" }), []); + + await service.sendMessage(session, { message: " continue " }); + assert.equal(sent, "continue\r"); + assert.deepEqual(calls, [ + "event:OpenClaw service nudge requested:10", + "audit:openclaw crabbox message requested IS-2:10", + "open", + "send", + "close", + "event:OpenClaw service nudge sent:20", + "audit:openclaw crabbox message sent IS-2:20", + ]); +}); + +test("OpenClaw nudges reject unavailable terminals before recording evidence", async () => { + let recorded = false; + const service = new OpenClawMutationService( + mutationStore({ + recordEvent: async () => { + recorded = true; + }, + }), + ); + + await assert.rejects( + service.sendMessage(interactiveSession(sessionRow({ status: "stopping" }), []), { + message: "continue", + }), + (error) => { + assert.equal(status(error), 400); + return true; + }, + ); + await assert.rejects( + service.sendMessage( + interactiveSession(sessionRow({ capabilities_json: '{"terminal":false}' }), []), + { message: "continue" }, + ), + (error) => { + assert.equal(status(error), 400); + return true; + }, + ); + assert.equal(recorded, false); +}); + +test("OpenClaw nudge close and delivery-record failures are best effort", async () => { + const warnings: string[] = []; + let eventCalls = 0; + const service = new OpenClawMutationService( + mutationStore({ + recordEvent: async () => { + eventCalls += 1; + if (eventCalls === 2) throw new Error("D1 unavailable"); + }, + openTerminal: async () => ({ + send() {}, + close() { + throw new Error("already closed"); + }, + }), + warn: (event) => { + warnings.push(String(event.event)); + }, + }), + ); + + await service.sendMessage(interactiveSession(sessionRow(), []), { + message: "continue", + enter: false, + }); + assert.deepEqual(warnings, [ + "openclaw_message_socket_close_failed", + "openclaw_message_delivery_record_failed", + ]); +}); + +test("OpenClaw stop records audit evidence before lifecycle mutation", async () => { + const calls: string[] = []; + const service = new OpenClawMutationService( + mutationStore({ + audit: async () => { + calls.push("audit"); + }, + stopSession: async (id) => { + calls.push("stop"); + return interactiveSession(sessionRow({ id, status: "stopped" }), []); + }, + }), + ); + + assert.equal((await service.stopSession("IS-2")).status, "stopped"); + assert.deepEqual(calls, ["audit", "stop"]); +}); diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index 481cf54..21b31eb 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -146,26 +146,6 @@ test("OpenClaw create preserves the already-decorated interactive session", asyn assert.doesNotMatch(branchSource, /clean\(branch/); }); -test("OpenClaw mutations persist request evidence before consequential work", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const messageStart = source.indexOf("async function openClawMessageCrabbox"); - const messageEnd = source.indexOf("async function openClawMutateCrabbox", messageStart); - const messageSource = source.slice(messageStart, messageEnd); - const stopStart = messageEnd; - const stopEnd = source.indexOf("async function openClawRootScopedCrabbox", stopStart); - const stopSource = source.slice(stopStart, stopEnd); - - assert.ok( - messageSource.indexOf("OpenClaw service nudge requested") < - messageSource.indexOf("openInteractiveTerminalUpstream"), - ); - assert.match(messageSource, /Promise\.allSettled/); - assert.ok( - stopSource.indexOf("openclaw crabbox stop requested") < - stopSource.indexOf("mutateInteractiveSession"), - ); -}); - test("interactive lineage rejects caller-claimed roots without a parent", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const start = source.indexOf("async function resolveInteractiveSessionLineage"); From be5bc33a44033fee77b04c0ef8201c81fca846f1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:03:28 +0100 Subject: [PATCH 016/109] refactor: extract OpenClaw creation --- CHANGELOG.md | 1 + src/index.ts | 132 +++++--------------- src/worker/openclaw-create.ts | 134 +++++++++++++++++++++ tests/openclaw-create.test.ts | 212 +++++++++++++++++++++++++++++++++ tests/openclaw-service.test.ts | 35 +----- 5 files changed, 379 insertions(+), 135 deletions(-) create mode 100644 src/worker/openclaw-create.ts create mode 100644 tests/openclaw-create.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9492095..e382657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Extract OpenClaw nudge and stop validation, audit ordering, terminal delivery, and best-effort delivery records into a directly tested mutation service. +- Extract OpenClaw crabbox normalization, replay handling, branch preparation, timeout policy, and creation audit into a directly tested service. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index c381859..d65ffe2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,8 +111,6 @@ import { cachedBooleanGrant } from "./terminal-authorization"; import { obsoleteSessionArchiveObjectKeys, sessionArchiveAttemptKeys } from "./session-archive"; import { readBoundedResponseText } from "./bounded-response"; import { - openClawBranchPreparationCanDefer, - openClawGitBranchAllowed, openClawGitHubRepoParts, openClawRoomMaxSessions, openClawServiceAuthorized, @@ -220,11 +218,7 @@ import { type RuntimeCapabilities, } from "./worker/session-model"; import { normalizeRepo } from "./worker/repositories"; -import { - openClawCrabboxRequestHash, - openClawRequestId, - readOpenClawRequestSession, -} from "./worker/openclaw-request"; +import { readOpenClawRequestSession } from "./worker/openclaw-request"; import { activateInteractiveSessionReservation, closeOpenClawRootAdmission, @@ -250,6 +244,12 @@ import { openClawVisibleRoomSessions, } from "./worker/openclaw-queries"; import { OpenClawMutationService, type OpenClawMutationStore } from "./worker/openclaw-mutations"; +import { + OpenClawCreateService, + openClawServiceBranch, + type OpenClawCreateStore, + type OpenClawCreateInput, +} from "./worker/openclaw-create"; const defaultInteractiveCommand = "codex --yolo"; @@ -2393,84 +2393,10 @@ async function openClawCreateCrabbox( env: RuntimeEnv, ): Promise<{ session: InteractiveSession; browserUrl: string }> { requireOpenClawRoomService(request, env); - const body = await readJson<{ - repo?: string; - branch?: string; - runtime?: string; - profile?: string; - command?: string; - prompt?: string; - owner?: string; - parentSessionId?: string; - rootSessionId?: string; - purpose?: string; - summary?: string; - githubToken?: string; - baseBranch?: string; - requestId?: string; - }>(request); - const owner = openClawOwner(body.owner); - body.branch = openClawServiceBranch(body.branch, "branch", "main"); - const baseBranch = openClawServiceBranch(body.baseBranch, "baseBranch"); - if (baseBranch) body.baseBranch = baseBranch; - else delete body.baseBranch; + const body = await readJson(request); const serviceUser = openClawServiceUser(); - const requestId = openClawRequestId(body.requestId); - const requestHash = requestId - ? await openClawCrabboxRequestHash(body, owner, deploymentConfig(env).defaultRuntime) - : null; - if (requestId && requestHash) { - const existing = await readOpenClawRequestSession(env, requestId, requestHash); - if (existing) { - return openClawDecoratedCrabboxResponse( - env, - decorateInteractiveSession(existing, serviceUser, env), - ); - } - } - const result = await createInteractiveSessionFromInput( - env, - serviceUser, - body, - clean(body.githubToken, 4000) || undefined, - { - owner, - createdBy: "service:openclaw", - openClawRequestId: requestId, - openClawRequestHash: requestHash, - afterReserve: async () => { - const signal = AbortSignal.timeout(openClawPreparationTimeoutMs); - try { - await ensureOpenClawServiceBranch(env, body.repo, body.branch, body.baseBranch, signal); - } catch (error) { - if (signal.aborted) { - throw serviceUnavailable("OpenClaw branch preparation timed out"); - } - if ( - !(error instanceof GitHubApiError) || - !openClawBranchPreparationCanDefer(error.status) - ) { - throw error; - } - console.warn( - JSON.stringify({ - event: "openclaw_branch_preparation_deferred", - repo: normalizeRepo(body.repo), - branch: clean(body.branch, 120) || "main", - status: error.status, - }), - ); - } - }, - }, - ); - await audit( - env, - serviceUser, - `openclaw crabbox created ${result.session.id} owner=${owner}`, - Date.now(), - ); - return openClawDecoratedCrabboxResponse(env, result.session); + const session = await openClawCreateService(env, serviceUser).create(body); + return openClawDecoratedCrabboxResponse(env, session); } async function openClawReadSessionRoot( @@ -2702,6 +2628,27 @@ function openClawMutationService( return new OpenClawMutationService(store); } +function openClawCreateService(env: RuntimeEnv, serviceUser: User): OpenClawCreateService { + const store: OpenClawCreateStore = { + defaultRuntime: deploymentConfig(env).defaultRuntime, + now: () => Date.now(), + preparationSignal: () => AbortSignal.timeout(openClawPreparationTimeoutMs), + readRequestSession: async (requestId, requestHash) => { + const session = await readOpenClawRequestSession(env, requestId, requestHash); + return session ? decorateInteractiveSession(session, serviceUser, env) : null; + }, + prepareBranch: (repo, branch, baseBranch, signal) => + ensureOpenClawServiceBranch(env, repo, branch, baseBranch, signal), + createSession: (body, githubToken, options) => + createInteractiveSessionFromInput(env, serviceUser, body, githubToken, options).then( + (result) => result.session, + ), + audit: (message, now) => audit(env, serviceUser, message, now), + warn: (event) => console.warn(JSON.stringify(event)), + }; + return new OpenClawCreateService(store); +} + function openClawCrabboxResponse( env: RuntimeEnv, serviceUser: User, @@ -2944,14 +2891,6 @@ function requireOpenClawServiceToken( } } -function openClawServiceBranch(value: unknown, name: string, fallback = ""): string { - if (value === undefined || value === null || value === "") return fallback; - if (typeof value !== "string" || !openClawGitBranchAllowed(value)) { - throw badRequest(`${name} must be a valid Git branch of at most 120 characters`); - } - return value; -} - async function ensureOpenClawServiceBranch( env: RuntimeEnv, repoInput: unknown, @@ -3019,15 +2958,6 @@ function optionalHttpUrl(value: unknown, name: string): string | null { } } -function openClawOwner(value: unknown): string { - const owner = clean(value, 240); - if (!owner) throw badRequest("owner is required"); - if (/^[A-Za-z0-9_.-]+$/.test(owner)) return owner; - if (/^@[A-Za-z0-9_.-]+$/.test(owner)) return owner.slice(1); - if (/^github:[A-Za-z0-9_.-]+$/.test(owner)) return owner.replace(/^github:/, ""); - return owner; -} - async function requireSshGatewayUser(request: Request, env: RuntimeEnv): Promise { requireSshGateway(request, env); const fingerprint = sshFingerprint(request); diff --git a/src/worker/openclaw-create.ts b/src/worker/openclaw-create.ts new file mode 100644 index 0000000..38088dc --- /dev/null +++ b/src/worker/openclaw-create.ts @@ -0,0 +1,134 @@ +import { + openClawBranchPreparationCanDefer, + openClawGitBranchAllowed, +} from "../openclaw-service.ts"; +import { GitHubApiError } from "./github.ts"; +import { badRequest, serviceUnavailable } from "./http.ts"; +import type { InteractiveSession } from "./session-model.ts"; +import { + openClawCrabboxRequestHash, + openClawRequestId, + type OpenClawCrabboxRequest, +} from "./openclaw-request.ts"; +import { normalizeRepo } from "./repositories.ts"; + +export type OpenClawCreateInput = OpenClawCrabboxRequest & { + owner?: unknown; + requestId?: unknown; +}; + +export type OpenClawCreateSessionOptions = { + owner: string; + createdBy: "service:openclaw"; + openClawRequestId: string | null; + openClawRequestHash: string | null; + afterReserve(): Promise; +}; + +export type OpenClawCreateStore = { + defaultRuntime: "crabbox" | "container"; + now(): number; + preparationSignal(): AbortSignal; + readRequestSession(requestId: string, requestHash: string): Promise; + prepareBranch( + repo: unknown, + branch: unknown, + baseBranch: unknown, + signal: AbortSignal, + ): Promise; + createSession( + body: OpenClawCrabboxRequest, + githubToken: string | undefined, + options: OpenClawCreateSessionOptions, + ): Promise; + audit(message: string, now: number): Promise; + warn(event: Record): void; +}; + +export class OpenClawCreateService { + private readonly store: OpenClawCreateStore; + + constructor(store: OpenClawCreateStore) { + this.store = store; + } + + async create(input: OpenClawCreateInput): Promise { + const owner = openClawOwner(input.owner); + const body: OpenClawCrabboxRequest = { + ...input, + branch: openClawServiceBranch(input.branch, "branch", "main"), + }; + const baseBranch = openClawServiceBranch(input.baseBranch, "baseBranch"); + if (baseBranch) body.baseBranch = baseBranch; + else delete body.baseBranch; + + const requestId = openClawRequestId(input.requestId); + const requestHash = requestId + ? await openClawCrabboxRequestHash(body, owner, this.store.defaultRuntime) + : null; + if (requestId && requestHash) { + const existing = await this.store.readRequestSession(requestId, requestHash); + if (existing) return existing; + } + + const session = await this.store.createSession( + body, + clean(input.githubToken, 4000) || undefined, + { + owner, + createdBy: "service:openclaw", + openClawRequestId: requestId, + openClawRequestHash: requestHash, + afterReserve: () => this.prepareBranch(body), + }, + ); + await this.store.audit( + `openclaw crabbox created ${session.id} owner=${owner}`, + this.store.now(), + ); + return session; + } + + private async prepareBranch(body: OpenClawCrabboxRequest): Promise { + const signal = this.store.preparationSignal(); + try { + await this.store.prepareBranch(body.repo, body.branch, body.baseBranch, signal); + } catch (error) { + if (signal.aborted) { + throw serviceUnavailable("OpenClaw branch preparation timed out"); + } + if (!(error instanceof GitHubApiError) || !openClawBranchPreparationCanDefer(error.status)) { + throw error; + } + this.store.warn({ + event: "openclaw_branch_preparation_deferred", + repo: normalizeRepo(body.repo), + branch: clean(body.branch, 120) || "main", + status: error.status, + }); + } + } +} + +export function openClawServiceBranch(value: unknown, name: string, fallback = ""): string { + if (value === undefined || value === null || value === "") return fallback; + if (typeof value !== "string" || !openClawGitBranchAllowed(value)) { + throw badRequest(`${name} must be a valid Git branch of at most 120 characters`); + } + return value; +} + +export function openClawOwner(value: unknown): string { + const owner = clean(value, 240); + if (!owner) throw badRequest("owner is required"); + if (/^[A-Za-z0-9_.-]+$/.test(owner)) return owner; + if (/^@[A-Za-z0-9_.-]+$/.test(owner)) return owner.slice(1); + if (/^github:[A-Za-z0-9_.-]+$/.test(owner)) return owner.replace(/^github:/, ""); + return owner; +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/openclaw-create.test.ts b/tests/openclaw-create.test.ts new file mode 100644 index 0000000..1ca2c5a --- /dev/null +++ b/tests/openclaw-create.test.ts @@ -0,0 +1,212 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { GitHubApiError } from "../src/worker/github.ts"; +import { + OpenClawCreateService, + openClawOwner, + openClawServiceBranch, + type OpenClawCreateStore, +} from "../src/worker/openclaw-create.ts"; +import type { InteractiveSession } from "../src/worker/session-model.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function session(values: Parameters[0] = {}): InteractiveSession { + return interactiveSession(sessionRow(values), []); +} + +function activeSignal(): AbortSignal { + return new AbortController().signal; +} + +function createStore(overrides: Partial = {}): OpenClawCreateStore { + return { + defaultRuntime: "container", + now: () => 100, + preparationSignal: activeSignal, + readRequestSession: async () => null, + prepareBranch: async () => undefined, + createSession: async () => session(), + audit: async () => undefined, + warn: () => undefined, + ...overrides, + }; +} + +function status(error: unknown): number | undefined { + return typeof error === "object" && error !== null && "status" in error + ? Number(error.status) + : undefined; +} + +test("OpenClaw create normalizes owner and exact Git branch inputs", () => { + assert.equal(openClawOwner("@maintainer"), "maintainer"); + assert.equal(openClawOwner("github:maintainer"), "maintainer"); + assert.equal(openClawOwner("team owner"), "team owner"); + assert.throws(() => openClawOwner(""), { message: "owner is required" }); + + assert.equal(openClawServiceBranch(undefined, "branch", "main"), "main"); + assert.equal(openClawServiceBranch("feature/team-room", "branch"), "feature/team-room"); + assert.throws(() => openClawServiceBranch("feature//team-room", "branch"), { + message: "branch must be a valid Git branch of at most 120 characters", + }); +}); + +test("OpenClaw create replays an already-decorated session without creation or audit", async () => { + const replay = session({ id: "IS-7", attach_url: "/api/terminal/ws" }); + const calls: string[] = []; + const service = new OpenClawCreateService( + createStore({ + readRequestSession: async (requestId) => { + calls.push(`replay:${requestId}`); + return replay; + }, + createSession: async () => { + calls.push("create"); + return session(); + }, + audit: async () => { + calls.push("audit"); + }, + }), + ); + + const result = await service.create({ + owner: "@maintainer", + repo: "openclaw/crabfleet", + branch: "main", + requestId: "request-1", + }); + + assert.equal(result, replay); + assert.deepEqual(calls, ["replay:request-1"]); +}); + +test("OpenClaw create prepares the branch after reservation and before provisioning", async () => { + const created = session({ id: "IS-8", attach_url: "/api/terminal/ws" }); + const calls: string[] = []; + let receivedBranch = ""; + let receivedBaseBranch: string | undefined; + let receivedToken: string | undefined; + const service = new OpenClawCreateService( + createStore({ + prepareBranch: async (_repo, branch, baseBranch) => { + calls.push("prepare"); + receivedBranch = String(branch); + receivedBaseBranch = baseBranch as string | undefined; + }, + createSession: async (body, githubToken, options) => { + calls.push("reserve"); + receivedToken = githubToken; + assert.equal(options.owner, "maintainer"); + assert.equal(options.createdBy, "service:openclaw"); + await options.afterReserve(); + calls.push("provision"); + assert.equal(body.branch, "main"); + return created; + }, + audit: async (message, now) => { + calls.push(`audit:${now}`); + assert.equal(message, "openclaw crabbox created IS-8 owner=maintainer"); + }, + }), + ); + + const result = await service.create({ + owner: "github:maintainer", + repo: "openclaw/crabfleet", + githubToken: " token ", + }); + + assert.equal(result, created); + assert.equal(receivedBranch, "main"); + assert.equal(receivedBaseBranch, undefined); + assert.equal(receivedToken, "token"); + assert.deepEqual(calls, ["reserve", "prepare", "provision", "audit:100"]); +}); + +test("OpenClaw create defers masked branch permissions but reports timeouts", async () => { + const warnings: Array> = []; + const deferred = new OpenClawCreateService( + createStore({ + prepareBranch: async () => { + throw new GitHubApiError(403); + }, + createSession: async (_body, _token, options) => { + await options.afterReserve(); + return session({ id: "IS-9" }); + }, + warn: (event) => warnings.push(event), + }), + ); + + assert.equal( + ( + await deferred.create({ + owner: "maintainer", + repo: "OpenClaw/Crabfleet", + branch: "feature/room", + }) + ).id, + "IS-9", + ); + assert.deepEqual(warnings, [ + { + event: "openclaw_branch_preparation_deferred", + repo: "openclaw/crabfleet", + branch: "feature/room", + status: 403, + }, + ]); + + const controller = new AbortController(); + controller.abort(); + const timedOut = new OpenClawCreateService( + createStore({ + preparationSignal: () => controller.signal, + prepareBranch: async () => { + throw new GitHubApiError(403); + }, + createSession: async (_body, _token, options) => { + await options.afterReserve(); + return session(); + }, + }), + ); + await assert.rejects( + timedOut.create({ owner: "maintainer", repo: "openclaw/crabfleet" }), + (error) => { + assert.equal(status(error), 503); + assert.equal((error as Error).message, "OpenClaw branch preparation timed out"); + return true; + }, + ); +}); + +test("OpenClaw create propagates non-deferred branch failures without audit", async () => { + let audited = false; + const service = new OpenClawCreateService( + createStore({ + prepareBranch: async () => { + throw new GitHubApiError(401); + }, + createSession: async (_body, _token, options) => { + await options.afterReserve(); + return session(); + }, + audit: async () => { + audited = true; + }, + }), + ); + + await assert.rejects( + service.create({ owner: "maintainer", repo: "openclaw/crabfleet" }), + (error) => { + assert.equal((error as GitHubApiError).status, 401); + return true; + }, + ); + assert.equal(audited, false); +}); diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index 21b31eb..315c578 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -121,31 +121,6 @@ test("bounded transcript tails remain valid UTF-8 and report truncation", () => assert.equal(openClawRoomMaxSessions, 64); }); -test("OpenClaw create preserves the already-decorated interactive session", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const createStart = source.indexOf("async function openClawCreateCrabbox"); - const createEnd = source.indexOf("async function openClawReadSessionRoot", createStart); - const createSource = source.slice(createStart, createEnd); - const responseStart = source.indexOf("function openClawDecoratedCrabboxResponse"); - const responseEnd = source.indexOf("async function openClawRegisterActionSession", responseStart); - const responseSource = source.slice(responseStart, responseEnd); - - assert.match(createSource, /return openClawDecoratedCrabboxResponse\(env, result\.session\)/); - assert.doesNotMatch(createSource, /openClawCrabboxResponse\(env, serviceUser, result\.session\)/); - assert.doesNotMatch(responseSource, /decorateInteractiveSession/); - assert.match(createSource, /AbortSignal\.timeout\(openClawPreparationTimeoutMs\)/); - assert.match(createSource, /ensureOpenClawServiceBranch\([\s\S]*signal\)/); - assert.match(createSource, /if \(signal\.aborted\)/); - assert.match(createSource, /openClawServiceBranch\(body\.branch, "branch", "main"\)/); - - const branchStart = source.indexOf("async function ensureOpenClawServiceBranch"); - const branchEnd = source.indexOf("function actionWorkIdentifier", branchStart); - const branchSource = source.slice(branchStart, branchEnd); - assert.match(branchSource, /openClawServiceBranch\(branchInput, "branch", "main"\)/); - assert.match(branchSource, /openClawServiceBranch\(baseBranchInput, "baseBranch"\)/); - assert.doesNotMatch(branchSource, /clean\(branch/); -}); - test("interactive lineage rejects caller-claimed roots without a parent", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const start = source.indexOf("async function resolveInteractiveSessionLineage"); @@ -208,27 +183,19 @@ test("OpenClaw room reservation precedes branch mutation, event recording, and p ); }); -test("OpenClaw crabbox requests reserve durable idempotency before provisioning", async () => { +test("OpenClaw crabbox persistence reserves durable idempotency before provisioning", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const migration = await readFile( new URL("../migrations/0026_openclaw_lifecycle_guarantees.sql", import.meta.url), "utf8", ); - const endpointStart = source.indexOf("async function openClawCreateCrabbox"); - const endpointEnd = source.indexOf("async function openClawReadSessionRoot", endpointStart); - const endpointSource = source.slice(endpointStart, endpointEnd); const createStart = source.indexOf("async function createInteractiveSessionFromInput"); const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); const createSource = source.slice(createStart, createEnd); assert.match(migration, /ADD COLUMN openclaw_request_id TEXT/); assert.match(migration, /UNIQUE INDEX IF NOT EXISTS idx_interactive_sessions_openclaw_request/); assert.match(migration, /CREATE TABLE IF NOT EXISTS openclaw_request_replays/); - assert.match(endpointSource, /readOpenClawRequestSession/); assert.equal(openClawRequestId("request-1"), "request-1"); - assert.ok( - endpointSource.indexOf("readOpenClawRequestSession") < - endpointSource.indexOf("createInteractiveSessionFromInput"), - ); assert.match(createSource, /openclaw_request_id: options\.openClawRequestId \?\? null/); assert.match(createSource, /openclaw_request_hash: options\.openClawRequestHash \?\? null/); assert.match(createSource, /\.insertInto\("openclaw_request_replays"\)/); From eb87d1bf0954d201ec3fa55fce4dc10e582e8545 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:07:12 +0100 Subject: [PATCH 017/109] refactor: extract session lineage --- CHANGELOG.md | 1 + src/index.ts | 31 +++------- src/worker/session-lineage.ts | 50 +++++++++++++++ tests/openclaw-service.test.ts | 12 ---- tests/session-lineage.test.ts | 108 +++++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 33 deletions(-) create mode 100644 src/worker/session-lineage.ts create mode 100644 tests/session-lineage.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e382657..fd7c875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Extract OpenClaw nudge and stop validation, audit ordering, terminal delivery, and best-effort delivery records into a directly tested mutation service. - Extract OpenClaw crabbox normalization, replay handling, branch preparation, timeout policy, and creation audit into a directly tested service. +- Extract interactive-session lineage normalization, parent visibility, and canonical root derivation into a directly tested service. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index d65ffe2..95c84d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -250,6 +250,10 @@ import { type OpenClawCreateStore, type OpenClawCreateInput, } from "./worker/openclaw-create"; +import { + InteractiveSessionLineageService, + type InteractiveSessionLineageStore, +} from "./worker/session-lineage"; const defaultInteractiveCommand = "codex --yolo"; @@ -3691,8 +3695,7 @@ async function createInteractiveSessionFromInput( const summary = interactiveSessionSummary(body.summary, purpose, prompt); const owner = options.owner || actor(user); const createdBy = options.createdBy || actor(user); - const lineage = await resolveInteractiveSessionLineage( - env, + const lineage = await interactiveSessionLineageService(env).resolve( user, options.parentSessionId ?? (clean(body.parentSessionId, 120) || null), options.rootSessionId ?? (clean(body.rootSessionId, 120) || null), @@ -4294,26 +4297,12 @@ async function clearRuntimeAdapterCreatePending( .execute(); } -async function resolveInteractiveSessionLineage( - env: RuntimeEnv, - user: User, - parentSessionId: string | null, - rootSessionId: string | null, -): Promise<{ parentSessionId: string | null; rootSessionId: string | null }> { - const parentId = clean(parentSessionId, 120) || null; - const rootId = clean(rootSessionId, 120) || null; - if (!parentId) { - if (rootId) throw badRequest("root session id requires a parent session id"); - return { parentSessionId: null, rootSessionId: null }; - } - - const parent = await readInteractiveSession(env, parentId); - if (!parent) throw badRequest("parent session not found"); - if (!canManageInteractiveSession(user, parent)) throw forbidden("parent session is not visible"); - return { - parentSessionId: parent.id, - rootSessionId: parent.rootSessionId || parent.id, +function interactiveSessionLineageService(env: RuntimeEnv): InteractiveSessionLineageService { + const store: InteractiveSessionLineageStore = { + readSession: (id) => readInteractiveSession(env, id), + canManage: canManageInteractiveSession, }; + return new InteractiveSessionLineageService(store); } function interactiveSessionPurpose( diff --git a/src/worker/session-lineage.ts b/src/worker/session-lineage.ts new file mode 100644 index 0000000..cd8a42b --- /dev/null +++ b/src/worker/session-lineage.ts @@ -0,0 +1,50 @@ +import { badRequest, forbidden } from "./http.ts"; +import type { User } from "./models.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export type InteractiveSessionLineage = { + parentSessionId: string | null; + rootSessionId: string | null; +}; + +export type InteractiveSessionLineageStore = { + readSession(id: string): Promise; + canManage(user: User, session: InteractiveSession): boolean; +}; + +export class InteractiveSessionLineageService { + private readonly store: InteractiveSessionLineageStore; + + constructor(store: InteractiveSessionLineageStore) { + this.store = store; + } + + async resolve( + user: User, + parentSessionId: string | null, + rootSessionId: string | null, + ): Promise { + const parentId = clean(parentSessionId, 120) || null; + const rootId = clean(rootSessionId, 120) || null; + if (!parentId) { + if (rootId) throw badRequest("root session id requires a parent session id"); + return { parentSessionId: null, rootSessionId: null }; + } + + const parent = await this.store.readSession(parentId); + if (!parent) throw badRequest("parent session not found"); + if (!this.store.canManage(user, parent)) { + throw forbidden("parent session is not visible"); + } + return { + parentSessionId: parent.id, + rootSessionId: parent.rootSessionId || parent.id, + }; + } +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index 315c578..9d11c41 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -121,18 +121,6 @@ test("bounded transcript tails remain valid UTF-8 and report truncation", () => assert.equal(openClawRoomMaxSessions, 64); }); -test("interactive lineage rejects caller-claimed roots without a parent", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const start = source.indexOf("async function resolveInteractiveSessionLineage"); - const end = source.indexOf("function interactiveSessionPurpose", start); - const lineageSource = source.slice(start, end); - - assert.match( - lineageSource, - /if \(rootId\) throw badRequest\("root session id requires a parent session id"\)/, - ); -}); - test("OpenClaw room reservation precedes branch mutation, event recording, and provisioning", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const migration = await readFile( diff --git a/tests/session-lineage.test.ts b/tests/session-lineage.test.ts new file mode 100644 index 0000000..5c60680 --- /dev/null +++ b/tests/session-lineage.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { User } from "../src/worker/models.ts"; +import { + InteractiveSessionLineageService, + type InteractiveSessionLineageStore, +} from "../src/worker/session-lineage.ts"; +import type { InteractiveSession } from "../src/worker/session-model.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +const user: User = { + subject: "github:42", + login: "maintainer", + email: null, + name: null, + role: "maintainer", + allowed: true, + teams: [], +}; + +function session(values: Parameters[0] = {}): InteractiveSession { + return interactiveSession(sessionRow(values), []); +} + +function lineageStore( + overrides: Partial = {}, +): InteractiveSessionLineageStore { + return { + readSession: async () => null, + canManage: () => true, + ...overrides, + }; +} + +function status(error: unknown): number | undefined { + return typeof error === "object" && error !== null && "status" in error + ? Number(error.status) + : undefined; +} + +test("interactive lineage rejects caller-claimed roots without a parent", async () => { + let reads = 0; + const service = new InteractiveSessionLineageService( + lineageStore({ + readSession: async () => { + reads += 1; + return null; + }, + }), + ); + + assert.deepEqual(await service.resolve(user, null, null), { + parentSessionId: null, + rootSessionId: null, + }); + await assert.rejects(service.resolve(user, null, "IS-1"), (error) => { + assert.equal(status(error), 400); + assert.equal((error as Error).message, "root session id requires a parent session id"); + return true; + }); + assert.equal(reads, 0); +}); + +test("interactive lineage derives the canonical root from the visible parent", async () => { + const parent = session({ + id: "IS-2", + parent_session_id: "IS-1", + root_session_id: "IS-1", + }); + const reads: string[] = []; + const service = new InteractiveSessionLineageService( + lineageStore({ + readSession: async (id) => { + reads.push(id); + return parent; + }, + }), + ); + + assert.deepEqual(await service.resolve(user, " IS-2 ", "IS-attacker"), { + parentSessionId: "IS-2", + rootSessionId: "IS-1", + }); + assert.deepEqual(reads, ["IS-2"]); +}); + +test("interactive lineage rejects missing or invisible parents", async () => { + const service = new InteractiveSessionLineageService(lineageStore()); + await assert.rejects(service.resolve(user, "IS-missing", null), (error) => { + assert.equal(status(error), 400); + assert.equal((error as Error).message, "parent session not found"); + return true; + }); + + const hidden = new InteractiveSessionLineageService( + lineageStore({ + readSession: async () => session({ id: "IS-2" }), + canManage: () => false, + }), + ); + await assert.rejects(hidden.resolve(user, "IS-2", null), (error) => { + assert.equal(status(error), 403); + assert.equal((error as Error).message, "parent session is not visible"); + return true; + }); +}); From ffc5a1c11e4712cf3d79a46767c69fd76639c970 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:10:50 +0100 Subject: [PATCH 018/109] refactor: extract session creation coordination --- CHANGELOG.md | 1 + src/index.ts | 111 ++++++++++++++++----------- src/worker/session-creation.ts | 59 +++++++++++++++ tests/openclaw-service.test.ts | 22 +----- tests/session-creation.test.ts | 132 +++++++++++++++++++++++++++++++++ 5 files changed, 262 insertions(+), 63 deletions(-) create mode 100644 src/worker/session-creation.ts create mode 100644 tests/session-creation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fd7c875..3609d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Extract OpenClaw nudge and stop validation, audit ordering, terminal delivery, and best-effort delivery records into a directly tested mutation service. - Extract OpenClaw crabbox normalization, replay handling, branch preparation, timeout policy, and creation audit into a directly tested service. - Extract interactive-session lineage normalization, parent visibility, and canonical root derivation into a directly tested service. +- Extract interactive-session reservation supervision, preparation rollback, activation, request evidence, and provisioning order into a directly tested service. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 95c84d3..50343cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -254,6 +254,10 @@ import { InteractiveSessionLineageService, type InteractiveSessionLineageStore, } from "./worker/session-lineage"; +import { + InteractiveSessionCreationService, + type InteractiveSessionCreationStore, +} from "./worker/session-creation"; const defaultInteractiveCommand = "codex --yolo"; @@ -3701,6 +3705,7 @@ async function createInteractiveSessionFromInput( options.rootSessionId ?? (clean(body.rootSessionId, 120) || null), ); const supervision = openClawSupervision(env); + const creation = interactiveSessionCreationService(env, user, supervision); const supervisedRootSessionId = await supervision.supervisedRootForCreate(createdBy, lineage); const preparationReservation = Boolean(options.afterReserve || supervisedRootSessionId); const now = Date.now(); @@ -3836,51 +3841,49 @@ async function createInteractiveSessionFromInput( await insertSession.execute(); } reservationInserted = true; - if (supervisedRootSessionId) { - await supervision.enforceRoomSessionLimitAfterInsert(supervisedRootSessionId, id, now); - } - try { - await options.afterReserve?.(); - } catch (error) { - await supervision.rollbackReservation(id, now); - throw error; - } - if (preparationReservation) { - await supervision.requireReservationActivation(id, now, adapterWorkspaceId); - } - await appendInteractiveSessionEvent(env, id, user, "interactive workspace requested", now); - const provisioned = await provisionInteractiveSession( - env, + const provisioned = await creation.provision( { id, - ...(adapterWorkspaceId ? { adapterWorkspaceId } : {}), - ...(adapterControlPlane ? { adapterControlPlane } : {}), - ...(adapterSettings - ? { - adapterTtlSeconds: adapterSettings.ttlSeconds, - adapterIdleTimeoutSeconds: adapterSettings.idleTimeoutSeconds, - adapterRequestedCapabilities: adapterSettings.capabilities, - adapterCreatePayloadJson, - } - : {}), - parentSessionId: lineage.parentSessionId, - rootSessionId, - repo, - branch, - runtime, - profile, - command, - prompt, - purpose, - summary, - owner, - createdBy, - ...(githubToken ? { githubToken } : {}), + insertedAt: now, + supervisedRootSessionId, + requiresActivation: preparationReservation, + adapterWorkspaceId, }, - agentToken, - initialSandboxLease && initialSandboxOwnership - ? { lease: initialSandboxLease, ownership: initialSandboxOwnership } - : undefined, + options.afterReserve, + () => + provisionInteractiveSession( + env, + { + id, + ...(adapterWorkspaceId ? { adapterWorkspaceId } : {}), + ...(adapterControlPlane ? { adapterControlPlane } : {}), + ...(adapterSettings + ? { + adapterTtlSeconds: adapterSettings.ttlSeconds, + adapterIdleTimeoutSeconds: adapterSettings.idleTimeoutSeconds, + adapterRequestedCapabilities: adapterSettings.capabilities, + adapterCreatePayloadJson, + } + : {}), + parentSessionId: lineage.parentSessionId, + rootSessionId, + repo, + branch, + runtime, + profile, + command, + prompt, + purpose, + summary, + owner, + createdBy, + ...(githubToken ? { githubToken } : {}), + }, + agentToken, + initialSandboxLease && initialSandboxOwnership + ? { lease: initialSandboxLease, ownership: initialSandboxOwnership } + : undefined, + ), ); if (provisioned) { const initialTerminalStatus: "stopped" | "expired" | "failed" | null = @@ -4305,6 +4308,30 @@ function interactiveSessionLineageService(env: RuntimeEnv): InteractiveSessionLi return new InteractiveSessionLineageService(store); } +function interactiveSessionCreationService( + env: RuntimeEnv, + user: User, + supervision: OpenClawSupervisionService, +): InteractiveSessionCreationService { + const store: InteractiveSessionCreationStore = { + enforceSupervision: (rootSessionId, insertedSessionId, insertedAt) => + supervision.enforceRoomSessionLimitAfterInsert(rootSessionId, insertedSessionId, insertedAt), + rollbackReservation: (insertedSessionId, insertedAt) => + supervision.rollbackReservation(insertedSessionId, insertedAt), + activateReservation: (insertedSessionId, insertedAt, adapterWorkspaceId) => + supervision.requireReservationActivation(insertedSessionId, insertedAt, adapterWorkspaceId), + recordRequest: (insertedSessionId, insertedAt) => + appendInteractiveSessionEvent( + env, + insertedSessionId, + user, + "interactive workspace requested", + insertedAt, + ), + }; + return new InteractiveSessionCreationService(store); +} + function interactiveSessionPurpose( value: unknown, prompt: string, diff --git a/src/worker/session-creation.ts b/src/worker/session-creation.ts new file mode 100644 index 0000000..9e14454 --- /dev/null +++ b/src/worker/session-creation.ts @@ -0,0 +1,59 @@ +export type InteractiveSessionCreationReservation = { + id: string; + insertedAt: number; + supervisedRootSessionId: string | null; + requiresActivation: boolean; + adapterWorkspaceId: string | null; +}; + +export type InteractiveSessionCreationStore = { + enforceSupervision( + rootSessionId: string, + insertedSessionId: string, + insertedAt: number, + ): Promise; + rollbackReservation(insertedSessionId: string, insertedAt: number): Promise; + activateReservation( + insertedSessionId: string, + insertedAt: number, + adapterWorkspaceId: string | null, + ): Promise; + recordRequest(insertedSessionId: string, insertedAt: number): Promise; +}; + +export class InteractiveSessionCreationService { + private readonly store: InteractiveSessionCreationStore; + + constructor(store: InteractiveSessionCreationStore) { + this.store = store; + } + + async provision( + reservation: InteractiveSessionCreationReservation, + prepare: (() => Promise) | undefined, + provision: () => Promise, + ): Promise { + if (reservation.supervisedRootSessionId) { + await this.store.enforceSupervision( + reservation.supervisedRootSessionId, + reservation.id, + reservation.insertedAt, + ); + } + try { + await prepare?.(); + } catch (error) { + await this.store.rollbackReservation(reservation.id, reservation.insertedAt); + throw error; + } + if (reservation.requiresActivation) { + await this.store.activateReservation( + reservation.id, + reservation.insertedAt, + reservation.adapterWorkspaceId, + ); + } + await this.store.recordRequest(reservation.id, reservation.insertedAt); + return provision(); + } +} diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index 9d11c41..8ae161a 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -121,7 +121,7 @@ test("bounded transcript tails remain valid UTF-8 and report truncation", () => assert.equal(openClawRoomMaxSessions, 64); }); -test("OpenClaw room reservation precedes branch mutation, event recording, and provisioning", async () => { +test("OpenClaw room reservations stay hidden until activation", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const migration = await readFile( new URL("../migrations/0025_interactive_session_preparation.sql", import.meta.url), @@ -149,26 +149,6 @@ test("OpenClaw room reservation precedes branch mutation, event recording, and p /const preparationReservation = Boolean\(options\.afterReserve \|\| supervisedRootSessionId\)/, ); assert.match(createSource, /preparation_pending: preparationReservation \? 1 : 0/); - assert.ok( - createSource.indexOf("supervision.enforceRoomSessionLimitAfterInsert") < - createSource.indexOf("await options.afterReserve"), - ); - assert.ok( - createSource.indexOf("await options.afterReserve") < - createSource.indexOf("supervision.requireReservationActivation"), - ); - assert.ok( - createSource.indexOf("supervision.requireReservationActivation") < - createSource.indexOf("appendInteractiveSessionEvent"), - ); - assert.ok( - createSource.indexOf("await options.afterReserve") < - createSource.indexOf("provisionInteractiveSession"), - ); - assert.match( - createSource, - /catch \(error\) \{\s+await supervision\.rollbackReservation\(id, now\);\s+throw error;/, - ); }); test("OpenClaw crabbox persistence reserves durable idempotency before provisioning", async () => { diff --git a/tests/session-creation.test.ts b/tests/session-creation.test.ts new file mode 100644 index 0000000..208f346 --- /dev/null +++ b/tests/session-creation.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + InteractiveSessionCreationService, + type InteractiveSessionCreationReservation, + type InteractiveSessionCreationStore, +} from "../src/worker/session-creation.ts"; + +const reservation: InteractiveSessionCreationReservation = { + id: "IS-2", + insertedAt: 100, + supervisedRootSessionId: "IS-1", + requiresActivation: true, + adapterWorkspaceId: "workspace-2", +}; + +function creationStore( + overrides: Partial = {}, +): InteractiveSessionCreationStore { + return { + enforceSupervision: async () => undefined, + rollbackReservation: async () => undefined, + activateReservation: async () => undefined, + recordRequest: async () => undefined, + ...overrides, + }; +} + +test("session creation orders supervision, preparation, activation, evidence, and provisioning", async () => { + const calls: string[] = []; + const service = new InteractiveSessionCreationService( + creationStore({ + enforceSupervision: async (rootId, sessionId, insertedAt) => { + calls.push(`supervise:${rootId}:${sessionId}:${insertedAt}`); + }, + activateReservation: async (sessionId, insertedAt, workspaceId) => { + calls.push(`activate:${sessionId}:${insertedAt}:${workspaceId}`); + }, + recordRequest: async (sessionId, insertedAt) => { + calls.push(`record:${sessionId}:${insertedAt}`); + }, + }), + ); + + const result = await service.provision( + reservation, + async () => { + calls.push("prepare"); + }, + async () => { + calls.push("provision"); + return "ready"; + }, + ); + + assert.equal(result, "ready"); + assert.deepEqual(calls, [ + "supervise:IS-1:IS-2:100", + "prepare", + "activate:IS-2:100:workspace-2", + "record:IS-2:100", + "provision", + ]); +}); + +test("session creation rolls back failed preparation before returning the error", async () => { + const calls: string[] = []; + const failure = new Error("branch preparation failed"); + const service = new InteractiveSessionCreationService( + creationStore({ + enforceSupervision: async () => { + calls.push("supervise"); + }, + rollbackReservation: async (sessionId, insertedAt) => { + calls.push(`rollback:${sessionId}:${insertedAt}`); + }, + activateReservation: async () => { + calls.push("activate"); + }, + recordRequest: async () => { + calls.push("record"); + }, + }), + ); + + await assert.rejects( + service.provision( + reservation, + async () => { + calls.push("prepare"); + throw failure; + }, + async () => { + calls.push("provision"); + }, + ), + failure, + ); + assert.deepEqual(calls, ["supervise", "prepare", "rollback:IS-2:100"]); +}); + +test("session creation skips optional supervision, preparation, and activation", async () => { + const calls: string[] = []; + const service = new InteractiveSessionCreationService( + creationStore({ + enforceSupervision: async () => { + calls.push("supervise"); + }, + activateReservation: async () => { + calls.push("activate"); + }, + recordRequest: async () => { + calls.push("record"); + }, + }), + ); + + await service.provision( + { + ...reservation, + supervisedRootSessionId: null, + requiresActivation: false, + adapterWorkspaceId: null, + }, + undefined, + async () => { + calls.push("provision"); + }, + ); + assert.deepEqual(calls, ["record", "provision"]); +}); From dd1d30f291f8e2ee018d9a4f07c0bd64ce31d9e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:17:09 +0100 Subject: [PATCH 019/109] refactor: extract session reservation persistence --- CHANGELOG.md | 3 + src/index.ts | 109 +++++++------------- src/worker/runtime-adapter-preflight.ts | 40 ++++++++ src/worker/session-creation.ts | 34 ++++++ src/worker/session-repository.ts | 68 ++++++++++++ tests/openclaw-service.test.ts | 32 +----- tests/runtime-adapter.test.ts | 75 ++++++++------ tests/session-creation.test.ts | 70 +++++++++++++ tests/session-repository.test.ts | 131 ++++++++++++++++++++++++ 9 files changed, 425 insertions(+), 137 deletions(-) create mode 100644 src/worker/runtime-adapter-preflight.ts create mode 100644 src/worker/session-repository.ts create mode 100644 tests/session-repository.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3609d55..d74ce83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ - Extract OpenClaw crabbox normalization, replay handling, branch preparation, timeout policy, and creation audit into a directly tested service. - Extract interactive-session lineage normalization, parent visibility, and canonical root derivation into a directly tested service. - Extract interactive-session reservation supervision, preparation rollback, activation, request evidence, and provisioning order into a directly tested service. +- Extract visible interactive-session reads and atomic session/replay reservation inserts into a directly tested repository. +- Move interactive-session reservation retry and idempotent replay recovery into the creation service. +- Extract runtime-adapter configuration, control-plane, token, and create-preflight policy into a directly tested module. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 50343cc..a9086a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,7 +82,6 @@ import { redactedAdapterResponseMessage, runtimeAdapterCreatePayload, runtimeAdapterCollectionUrl, - runtimeAdapterControlPlaneForProfile, runtimeAdapterControlPlaneIdentity, runtimeAdapterBrowserVncUrl, runtimeAdapterDesktopUrl, @@ -258,6 +257,18 @@ import { InteractiveSessionCreationService, type InteractiveSessionCreationStore, } from "./worker/session-creation"; +import { + insertInteractiveSessionReservation, + readVisibleInteractiveSessionRow, + readVisibleInteractiveSessionRows, + type InteractiveSessionReservationValues, +} from "./worker/session-repository"; +import { + configuredRuntimeAdapterControlPlane, + requireRuntimeAdapterCreatePreflight, + runtimeAdapterConfigurationPresent, + runtimeAdapterToken, +} from "./worker/runtime-adapter-preflight"; const defaultInteractiveCommand = "codex --yolo"; @@ -3761,7 +3772,7 @@ async function createInteractiveSessionFromInput( ? JSON.stringify(adapterCreatePayload) : null; try { - const insertSession = db.insertInto("interactive_sessions").values({ + const reservationValues: InteractiveSessionReservationValues = { id, parent_session_id: lineage.parentSessionId, root_session_id: rootSessionId, @@ -3825,20 +3836,16 @@ async function createInteractiveSessionFromInput( codex_turn_id: null, last_heartbeat_at: null, completion_reason: null, - }); + }; if (options.openClawRequestId && options.openClawRequestHash) { - await executeBatch(env, [ - db.insertInto("openclaw_request_replays").values({ - request_id: options.openClawRequestId, - request_hash: options.openClawRequestHash, - session_id: id, - created_at: now, - updated_at: now, - }), - insertSession, - ]); + await insertInteractiveSessionReservation(env, reservationValues, { + requestId: options.openClawRequestId, + requestHash: options.openClawRequestHash, + sessionId: id, + createdAt: now, + }); } else { - await insertSession.execute(); + await insertInteractiveSessionReservation(env, reservationValues, null); } reservationInserted = true; const provisioned = await creation.provision( @@ -4017,20 +4024,14 @@ async function createInteractiveSessionFromInput( ), }; } catch (error) { - if ( - !reservationInserted && - isConstraintError(error) && - options.openClawRequestId && - options.openClawRequestHash - ) { - const existing = await readOpenClawRequestSession( - env, - options.openClawRequestId, - options.openClawRequestHash, - ); - if (existing) return { session: decorateInteractiveSession(existing, user, env) }; - } - if (reservationInserted || !isConstraintError(error) || attempt === 2) throw error; + const replay = await creation.recoverReservationFailure(error, { + reservationInserted, + attempt, + maximumAttempts: 3, + requestId: options.openClawRequestId ?? null, + requestHash: options.openClawRequestHash ?? null, + }); + if (replay) return { session: replay }; } } throw new Error("failed to allocate interactive session id"); @@ -4055,34 +4056,6 @@ function initialRuntimeAdapterWorkspaceId( return adapterWorkspaceId; } -function requireRuntimeAdapterCreatePreflight( - env: RuntimeEnv, - runtime: "crabbox" | "container", - profile: string, -): void { - if (!runtimeAdapterConfigurationPresent(env) || (runtime === "container" && env.SANDBOX)) return; - if (!configuredRuntimeAdapterControlPlane(env, profile)) { - throw serviceUnavailable( - "runtime adapter URL or profile route template must be valid and unambiguous", - ); - } - if (!runtimeAdapterToken(env)) { - throw serviceUnavailable("runtime adapter token is not configured"); - } -} - -function runtimeAdapterConfigurationPresent(env: RuntimeEnv): boolean { - return Boolean(env.CRABBOX_RUNTIME_ADAPTER_URL || env.CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE); -} - -function configuredRuntimeAdapterControlPlane(env: RuntimeEnv, profile: string): string | null { - return runtimeAdapterControlPlaneForProfile( - env.CRABBOX_RUNTIME_ADAPTER_URL, - env.CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE, - profile, - ); -} - function requireRegisteredRuntimeAdapterControlPlane( env: RuntimeEnv, profile: string, @@ -4328,6 +4301,11 @@ function interactiveSessionCreationService( "interactive workspace requested", insertedAt, ), + isConstraintError, + readRequestReplay: async (requestId, requestHash) => { + const session = await readOpenClawRequestSession(env, requestId, requestHash); + return session ? decorateInteractiveSession(session, user, env) : null; + }, }; return new InteractiveSessionCreationService(store); } @@ -12012,10 +11990,6 @@ async function readRuntimeAdapterResponseBody(response: Response): Promise { - const rows = await database(env) - .selectFrom("interactive_sessions") - .selectAll() - .where("preparation_pending", "=", 0) - .orderBy("updated_at", "desc") - .limit(80) - .execute(); + const rows = await readVisibleInteractiveSessionRows(env); if (!rows.length) return []; const logs = await readInteractiveSessionLogs( env, @@ -13010,12 +12978,7 @@ async function readInteractiveSession( env: RuntimeEnv, id: string, ): Promise { - const row = await database(env) - .selectFrom("interactive_sessions") - .selectAll() - .where("id", "=", id) - .where("preparation_pending", "=", 0) - .executeTakeFirst(); + const row = await readVisibleInteractiveSessionRow(env, id); if (!row) return null; const logs = await readInteractiveSessionLogs(env, [id]); const archives = await readInteractiveSessionLogArchives(env, [id]); diff --git a/src/worker/runtime-adapter-preflight.ts b/src/worker/runtime-adapter-preflight.ts new file mode 100644 index 0000000..e7d29a2 --- /dev/null +++ b/src/worker/runtime-adapter-preflight.ts @@ -0,0 +1,40 @@ +import { runtimeAdapterControlPlaneForProfile } from "../runtime-adapter.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { serviceUnavailable } from "./http.ts"; + +export function requireRuntimeAdapterCreatePreflight( + env: RuntimeEnv, + runtime: "crabbox" | "container", + profile: string, +): void { + if (!runtimeAdapterConfigurationPresent(env) || (runtime === "container" && env.SANDBOX)) return; + if (!configuredRuntimeAdapterControlPlane(env, profile)) { + throw serviceUnavailable( + "runtime adapter URL or profile route template must be valid and unambiguous", + ); + } + if (!runtimeAdapterToken(env)) { + throw serviceUnavailable("runtime adapter token is not configured"); + } +} + +export function runtimeAdapterConfigurationPresent(env: RuntimeEnv): boolean { + return Boolean(env.CRABBOX_RUNTIME_ADAPTER_URL || env.CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE); +} + +export function configuredRuntimeAdapterControlPlane( + env: RuntimeEnv, + profile: string, +): string | null { + return runtimeAdapterControlPlaneForProfile( + env.CRABBOX_RUNTIME_ADAPTER_URL, + env.CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE, + profile, + ); +} + +export function runtimeAdapterToken(env: RuntimeEnv): string { + return String(env.CRABBOX_RUNTIME_ADAPTER_TOKEN ?? "") + .trim() + .slice(0, 4000); +} diff --git a/src/worker/session-creation.ts b/src/worker/session-creation.ts index 9e14454..518ea77 100644 --- a/src/worker/session-creation.ts +++ b/src/worker/session-creation.ts @@ -1,3 +1,5 @@ +import type { InteractiveSession } from "./session-model.ts"; + export type InteractiveSessionCreationReservation = { id: string; insertedAt: number; @@ -19,6 +21,8 @@ export type InteractiveSessionCreationStore = { adapterWorkspaceId: string | null, ): Promise; recordRequest(insertedSessionId: string, insertedAt: number): Promise; + isConstraintError(error: unknown): boolean; + readRequestReplay(requestId: string, requestHash: string): Promise; }; export class InteractiveSessionCreationService { @@ -56,4 +60,34 @@ export class InteractiveSessionCreationService { await this.store.recordRequest(reservation.id, reservation.insertedAt); return provision(); } + + async recoverReservationFailure( + error: unknown, + context: { + reservationInserted: boolean; + attempt: number; + maximumAttempts: number; + requestId: string | null; + requestHash: string | null; + }, + ): Promise { + const constraintError = this.store.isConstraintError(error); + if ( + !context.reservationInserted && + constraintError && + context.requestId && + context.requestHash + ) { + const existing = await this.store.readRequestReplay(context.requestId, context.requestHash); + if (existing) return existing; + } + if ( + context.reservationInserted || + !constraintError || + context.attempt === context.maximumAttempts - 1 + ) { + throw error; + } + return null; + } } diff --git a/src/worker/session-repository.ts b/src/worker/session-repository.ts new file mode 100644 index 0000000..0cd16f8 --- /dev/null +++ b/src/worker/session-repository.ts @@ -0,0 +1,68 @@ +import type { Insertable } from "kysely"; + +import { + database, + executeBatch, + type InteractiveSessionRow, + type InteractiveSessionTable, +} from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; + +export type InteractiveSessionReplayReservation = { + requestId: string; + requestHash: string; + sessionId: string; + createdAt: number; +}; + +export type InteractiveSessionReservationValues = Insertable; + +export async function readVisibleInteractiveSessionRows( + env: RuntimeEnv, + limit = 80, +): Promise { + return database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where("preparation_pending", "=", 0) + .orderBy("updated_at", "desc") + .limit(Math.max(1, Math.floor(limit))) + .execute(); +} + +export async function readVisibleInteractiveSessionRow( + env: RuntimeEnv, + id: string, +): Promise { + return ( + (await database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where("id", "=", id) + .where("preparation_pending", "=", 0) + .executeTakeFirst()) ?? null + ); +} + +export async function insertInteractiveSessionReservation( + env: RuntimeEnv, + values: InteractiveSessionReservationValues, + replay: InteractiveSessionReplayReservation | null, +): Promise { + const db = database(env); + const insertSession = db.insertInto("interactive_sessions").values(values); + if (!replay) { + await insertSession.execute(); + return; + } + await executeBatch(env, [ + db.insertInto("openclaw_request_replays").values({ + request_id: replay.requestId, + request_hash: replay.requestHash, + session_id: replay.sessionId, + created_at: replay.createdAt, + updated_at: replay.createdAt, + }), + insertSession, + ]); +} diff --git a/tests/openclaw-service.test.ts b/tests/openclaw-service.test.ts index 8ae161a..66ae7fc 100644 --- a/tests/openclaw-service.test.ts +++ b/tests/openclaw-service.test.ts @@ -121,54 +121,24 @@ test("bounded transcript tails remain valid UTF-8 and report truncation", () => assert.equal(openClawRoomMaxSessions, 64); }); -test("OpenClaw room reservations stay hidden until activation", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); +test("OpenClaw room reservations use explicit preparation state", async () => { const migration = await readFile( new URL("../migrations/0025_interactive_session_preparation.sql", import.meta.url), "utf8", ); - const createStart = source.indexOf("async function createInteractiveSessionFromInput"); - const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); - const createSource = source.slice(createStart, createEnd); - const readStart = source.indexOf("async function readInteractiveSessions"); - const readEnd = source.indexOf("async function readSharedInteractiveSession", readStart); - const readSource = source.slice(readStart, readEnd); assert.match(migration, /ADD COLUMN preparation_pending INTEGER NOT NULL DEFAULT 0/); - assert.match(readSource, /\.where\("preparation_pending", "=", 0\)/); - assert.match( - createSource, - /adapter: adapterWorkspaceId && !preparationReservation \? runtimeAdapterName : null/, - ); - assert.match( - createSource, - /adapter_create_pending: adapterWorkspaceId && !preparationReservation \? 1 : 0/, - ); - assert.match( - createSource, - /const preparationReservation = Boolean\(options\.afterReserve \|\| supervisedRootSessionId\)/, - ); - assert.match(createSource, /preparation_pending: preparationReservation \? 1 : 0/); }); test("OpenClaw crabbox persistence reserves durable idempotency before provisioning", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const migration = await readFile( new URL("../migrations/0026_openclaw_lifecycle_guarantees.sql", import.meta.url), "utf8", ); - const createStart = source.indexOf("async function createInteractiveSessionFromInput"); - const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); - const createSource = source.slice(createStart, createEnd); assert.match(migration, /ADD COLUMN openclaw_request_id TEXT/); assert.match(migration, /UNIQUE INDEX IF NOT EXISTS idx_interactive_sessions_openclaw_request/); assert.match(migration, /CREATE TABLE IF NOT EXISTS openclaw_request_replays/); assert.equal(openClawRequestId("request-1"), "request-1"); - assert.match(createSource, /openclaw_request_id: options\.openClawRequestId \?\? null/); - assert.match(createSource, /openclaw_request_hash: options\.openClawRequestHash \?\? null/); - assert.match(createSource, /\.insertInto\("openclaw_request_replays"\)/); - assert.match(createSource, /!reservationInserted &&\s+isConstraintError\(error\)/); - assert.match(createSource, /if \(reservationInserted \|\| !isConstraintError\(error\)/); }); test("OpenClaw lifecycle guarantees are documented", async () => { diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index e3112b1..76caa03 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -41,6 +41,12 @@ import { publicDeploymentConfig, selectedRuntimeProfile, } from "../src/worker/deployment.ts"; +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + configuredRuntimeAdapterControlPlane, + requireRuntimeAdapterCreatePreflight, + runtimeAdapterToken, +} from "../src/worker/runtime-adapter-preflight.ts"; test("adapter create payload matches the strict controller contract", () => { const payload = runtimeAdapterCreatePayload({ @@ -764,19 +770,20 @@ test("public auth deployment metadata excludes runtime routing", () => { }); test("worker deployment installs the shared runtime adapter credential", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const workflow = await readFile( new URL("../.github/workflows/deploy-worker.yml", import.meta.url), "utf8", ); - const tokenStart = source.indexOf("function runtimeAdapterToken"); - const tokenEnd = source.indexOf("function runtimeAdapterProviderConfigured", tokenStart); - const tokenSource = source.slice(tokenStart, tokenEnd); assert.match(workflow, /CRABBOX_RUNTIME_ADAPTER_TOKEN="\$runtime_token"/); assert.match(workflow, /CRABBOX_RUNTIME_ADAPTER_TOKEN:\s*\n\s*process\.env/); - assert.match(tokenSource, /env\.CRABBOX_RUNTIME_ADAPTER_TOKEN/); - assert.doesNotMatch(tokenSource, /CRABBOX_OPENCLAW_TOKEN|createHmac|crypto\.subtle/); + assert.equal( + runtimeAdapterToken({ + CRABBOX_RUNTIME_ADAPTER_TOKEN: " shared-token ", + CRABBOX_OPENCLAW_TOKEN: "unrelated-token", + } as RuntimeEnv), + "shared-token", + ); }); test("production runtime adapter calls use the Crabbox service binding", async () => { @@ -867,31 +874,24 @@ test("summary and sharing events invalidate terminal cleanup snapshots", async ( assert.ok(metadataSource.indexOf("eventQuery") < metadataSource.indexOf("updateQuery")); }); -test("runtime adapter credentials are preflighted before session allocation", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const createStart = source.indexOf("async function createInteractiveSessionFromInput"); - const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); - const createSource = source.slice(createStart, createEnd); - const preflightStart = source.indexOf("function requireRuntimeAdapterCreatePreflight"); - const preflightEnd = source.indexOf( - "async function stopSupersededRuntimeAdapterProvision", - preflightStart, - ); - const preflightSource = source.slice(preflightStart, preflightEnd); +test("runtime adapter credentials are preflighted before session allocation", () => { + const env = { + CRABBOX_RUNTIME_ADAPTER_URL: "https://adapter.example.test", + } as RuntimeEnv; + assert.throws(() => requireRuntimeAdapterCreatePreflight(env, "crabbox", "default"), { + message: "runtime adapter token is not configured", + }); - assert.ok( - createSource.indexOf("requireRuntimeAdapterCreatePreflight(env, runtime, profile)") < - createSource.indexOf("nextInteractiveSessionId(env)"), - ); - assert.ok( - createSource.indexOf("requireRuntimeAdapterCreatePreflight(env, runtime, profile)") < - createSource.indexOf('.insertInto("interactive_sessions")'), - ); - assert.match(preflightSource, /runtime === "container" && env\.SANDBOX/); - assert.match(preflightSource, /runtimeAdapterToken\(env\)/); - assert.match(preflightSource, /configuredRuntimeAdapterControlPlane\(env, profile\)/); - assert.match(preflightSource, /runtimeAdapterControlPlaneForProfile/); - assert.match(preflightSource, /runtime adapter token is not configured/); + env.CRABBOX_RUNTIME_ADAPTER_TOKEN = " token "; + assert.doesNotThrow(() => requireRuntimeAdapterCreatePreflight(env, "crabbox", "default")); + + env.CRABBOX_RUNTIME_ADAPTER_URL = "not-a-url"; + assert.throws(() => requireRuntimeAdapterCreatePreflight(env, "crabbox", "default"), { + message: "runtime adapter URL or profile route template must be valid and unambiguous", + }); + + env.SANDBOX = {} as DurableObjectNamespace; + assert.doesNotThrow(() => requireRuntimeAdapterCreatePreflight(env, "container", "default")); }); test("runtime adapter operations stay bound to the registered control plane", async () => { @@ -900,7 +900,7 @@ test("runtime adapter operations stay bound to the registered control plane", as new URL("../migrations/0020_runtime_adapter_lifecycle.sql", import.meta.url), "utf8", ); - const bindingStart = source.indexOf("function configuredRuntimeAdapterControlPlane"); + const bindingStart = source.indexOf("function requireRegisteredRuntimeAdapterControlPlane"); const bindingEnd = source.indexOf( "async function stopSupersededRuntimeAdapterProvision", bindingStart, @@ -921,6 +921,15 @@ test("runtime adapter operations stay bound to the registered control plane", as assert.match(migration, /ADD COLUMN adapter_control_plane TEXT/); assert.match(source, /adapter_control_plane: adapterControlPlane/); + assert.equal( + configuredRuntimeAdapterControlPlane( + { + CRABBOX_RUNTIME_ADAPTER_URL: "https://adapter.example.test", + } as RuntimeEnv, + "default", + ), + "https://adapter.example.test/", + ); assert.match(bindingSource, /configuredControlPlane !== registeredControlPlane/); assert.match(bindingSource, /configuredRuntimeAdapterControlPlane\(env, profile\)/); assert.match(bindingSource, /control plane differs from workspace registration/); @@ -1850,7 +1859,7 @@ test("definitive adapter create errors retain a redacted provider reason before ); const releaseSource = source.slice(releaseStart, releaseEnd); const bodyStart = source.indexOf("async function readRuntimeAdapterResponseBody"); - const bodyEnd = source.indexOf("function runtimeAdapterToken", bodyStart); + const bodyEnd = source.indexOf("function runtimeAdapterProviderConfigured", bodyStart); const bodySource = source.slice(bodyStart, bodyEnd); const bodyReadIndex = createSource.indexOf( @@ -2083,7 +2092,7 @@ test("adapter bodies share the bounded stream reader", async () => { assert.doesNotMatch(operation, /response\.(?:json|text)\(/); } const readerStart = source.indexOf("async function readRuntimeAdapterResponseBody"); - const readerEnd = source.indexOf("function runtimeAdapterToken", readerStart); + const readerEnd = source.indexOf("function runtimeAdapterProviderConfigured", readerStart); const readerSource = source.slice(readerStart, readerEnd); assert.match(readerSource, /readBoundedResponseText\(response\)/); assert.doesNotMatch(readerSource, /response\.(?:json|text)\(/); diff --git a/tests/session-creation.test.ts b/tests/session-creation.test.ts index 208f346..b90f8e4 100644 --- a/tests/session-creation.test.ts +++ b/tests/session-creation.test.ts @@ -6,6 +6,9 @@ import { type InteractiveSessionCreationReservation, type InteractiveSessionCreationStore, } from "../src/worker/session-creation.ts"; +import type { InteractiveSession } from "../src/worker/session-model.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; const reservation: InteractiveSessionCreationReservation = { id: "IS-2", @@ -23,10 +26,16 @@ function creationStore( rollbackReservation: async () => undefined, activateReservation: async () => undefined, recordRequest: async () => undefined, + isConstraintError: () => false, + readRequestReplay: async () => null, ...overrides, }; } +function session(values: Parameters[0] = {}): InteractiveSession { + return interactiveSession(sessionRow(values), []); +} + test("session creation orders supervision, preparation, activation, evidence, and provisioning", async () => { const calls: string[] = []; const service = new InteractiveSessionCreationService( @@ -130,3 +139,64 @@ test("session creation skips optional supervision, preparation, and activation", ); assert.deepEqual(calls, ["record", "provision"]); }); + +test("session creation recovers an idempotent replay after a reservation race", async () => { + const replay = session({ id: "IS-9" }); + const service = new InteractiveSessionCreationService( + creationStore({ + isConstraintError: () => true, + readRequestReplay: async (requestId, requestHash) => { + assert.equal(requestId, "request-1"); + assert.equal(requestHash, "hash-1"); + return replay; + }, + }), + ); + + assert.equal( + await service.recoverReservationFailure(new Error("unique"), { + reservationInserted: false, + attempt: 0, + maximumAttempts: 3, + requestId: "request-1", + requestHash: "hash-1", + }), + replay, + ); +}); + +test("session creation retries only unowned constraint failures before the final attempt", async () => { + const constraint = new Error("unique"); + const service = new InteractiveSessionCreationService( + creationStore({ + isConstraintError: (error) => error === constraint, + }), + ); + const context = { + reservationInserted: false, + attempt: 0, + maximumAttempts: 3, + requestId: null, + requestHash: null, + }; + + assert.equal(await service.recoverReservationFailure(constraint, context), null); + await assert.rejects( + service.recoverReservationFailure(constraint, { + ...context, + reservationInserted: true, + }), + constraint, + ); + await assert.rejects( + service.recoverReservationFailure(constraint, { + ...context, + attempt: 2, + }), + constraint, + ); + await assert.rejects( + service.recoverReservationFailure(new Error("provider"), context), + /provider/, + ); +}); diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts new file mode 100644 index 0000000..5320398 --- /dev/null +++ b/tests/session-repository.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + insertInteractiveSessionReservation, + readVisibleInteractiveSessionRow, + readVisibleInteractiveSessionRows, +} from "../src/worker/session-repository.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +type D1Result = { results?: unknown[]; changes?: number }; +type D1Handler = (sql: string, parameters: unknown[], kind: "all" | "run") => D1Result; +type PreparedStatement = { + sql: string; + parameters: unknown[]; + all(): Promise; + run(): Promise; +}; + +function runtimeEnv( + handler: D1Handler, + batchHandler: (statements: PreparedStatement[]) => void = () => undefined, +): RuntimeEnv { + return { + DB: { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + const statement: PreparedStatement = { + sql, + parameters, + async all() { + const result = handler(sql, parameters, "all"); + return { results: result.results ?? [], meta: { changes: result.changes ?? 0 } }; + }, + async run() { + const result = handler(sql, parameters, "run"); + return { meta: { changes: result.changes ?? 0 } }; + }, + }; + return statement; + }, + }; + }, + async batch(statements: unknown[]) { + batchHandler(statements as PreparedStatement[]); + return []; + }, + } as unknown as D1Database, + } as RuntimeEnv; +} + +test("visible session reads exclude preparation reservations and stay bounded", async () => { + const row = sessionRow({ id: "IS-2", preparation_pending: 0 }); + let calls = 0; + const env = runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + calls += 1; + assert.match(sql, /from "interactive_sessions"/i); + assert.match(sql, /"preparation_pending" =/i); + if (/"id" =/i.test(sql)) { + assert.deepEqual(parameters, ["IS-2", 0]); + } else { + assert.match(sql, /order by "updated_at" desc/i); + assert.match(sql, /limit/i); + assert.deepEqual(parameters, [0, 12]); + } + return { results: [row] }; + }); + + assert.equal((await readVisibleInteractiveSessionRow(env, "IS-2"))?.id, "IS-2"); + assert.deepEqual( + (await readVisibleInteractiveSessionRows(env, 12)).map((session) => session.id), + ["IS-2"], + ); + assert.equal(calls, 2); +}); + +test("session reservation inserts preparation and request identity in one batch", async () => { + let batch: PreparedStatement[] = []; + const values = sessionRow({ + id: "IS-2", + preparation_pending: 1, + openclaw_request_id: "request-1", + openclaw_request_hash: "hash-1", + }); + await insertInteractiveSessionReservation( + runtimeEnv( + () => { + throw new Error("batched inserts must not execute individually"); + }, + (statements) => { + batch = statements; + }, + ), + values, + { + requestId: "request-1", + requestHash: "hash-1", + sessionId: "IS-2", + createdAt: 100, + }, + ); + + assert.equal(batch.length, 2); + assert.match(batch[0]?.sql ?? "", /^insert into "openclaw_request_replays"/i); + assert.deepEqual(batch[0]?.parameters, ["request-1", "hash-1", "IS-2", 100, 100]); + assert.match(batch[1]?.sql ?? "", /^insert into "interactive_sessions"/i); + assert.match(batch[1]?.sql ?? "", /"preparation_pending"/i); + assert.match(batch[1]?.sql ?? "", /"openclaw_request_id"/i); + assert.match(batch[1]?.sql ?? "", /"openclaw_request_hash"/i); + assert.ok(batch[1]?.parameters.includes(1)); + assert.ok(batch[1]?.parameters.includes("request-1")); + assert.ok(batch[1]?.parameters.includes("hash-1")); +}); + +test("session reservations without request identity use one insert", async () => { + let inserts = 0; + await insertInteractiveSessionReservation( + runtimeEnv((sql, _parameters, kind) => { + assert.equal(kind, "run"); + assert.match(sql, /^insert into "interactive_sessions"/i); + inserts += 1; + return { changes: 1 }; + }), + sessionRow({ id: "IS-2" }), + null, + ); + assert.equal(inserts, 1); +}); From ba680717c3bbb222bb10584bd6102e343efc774e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:23:49 +0100 Subject: [PATCH 020/109] refactor: extract provision completion --- CHANGELOG.md | 1 + src/index.ts | 188 +++++++++-------------------- src/worker/session-creation.ts | 50 ++++++++ src/worker/session-provisioning.ts | 39 ++++++ src/worker/session-repository.ts | 87 ++++++++++++- tests/runtime-adapter.test.ts | 29 ----- tests/session-creation.test.ts | 83 ++++++++++++- tests/session-repository.test.ts | 74 ++++++++++++ 8 files changed, 386 insertions(+), 165 deletions(-) create mode 100644 src/worker/session-provisioning.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d74ce83..3ef1478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Extract visible interactive-session reads and atomic session/replay reservation inserts into a directly tested repository. - Move interactive-session reservation retry and idempotent replay recovery into the creation service. - Extract runtime-adapter configuration, control-plane, token, and create-preflight policy into a directly tested module. +- Extract provisioning-result compare-and-set persistence, pending-adapter fallback, event recording, and terminal finalization ordering. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index a9086a4..6e00337 100644 --- a/src/index.ts +++ b/src/index.ts @@ -259,10 +259,13 @@ import { } from "./worker/session-creation"; import { insertInteractiveSessionReservation, + markInteractiveSessionPendingAdapter, + persistInteractiveSessionProvisionResult, readVisibleInteractiveSessionRow, readVisibleInteractiveSessionRows, type InteractiveSessionReservationValues, } from "./worker/session-repository"; +import type { InteractiveProvisionResult } from "./worker/session-provisioning"; import { configuredRuntimeAdapterControlPlane, requireRuntimeAdapterCreatePreflight, @@ -477,27 +480,6 @@ type InteractiveProvisionRequest = { githubToken?: string; }; -type InteractiveProvisionResult = { - status: InteractiveSessionStatus; - leaseId: string | null; - attachUrl: string | null; - attachUrlPresent?: boolean; - vncUrl: string | null; - message: string; - adapter?: string | null; - profile?: string; - adapterWorkspaceId?: string | null; - providerResourceId?: string | null; - capabilities?: RuntimeCapabilities | null; - capabilitiesPresent?: boolean; - expiresAt?: number | null; - expiresAtPresent?: boolean; - reconciledAt?: number | null; - reconcileError?: string | null; - terminalStatus?: "failed" | null; - createPending?: boolean; -}; - type SandboxCredentialPolicy = { allowedHosts: string[]; expiresAt?: number; @@ -3720,7 +3702,6 @@ async function createInteractiveSessionFromInput( const supervisedRootSessionId = await supervision.supervisedRootForCreate(createdBy, lineage); const preparationReservation = Boolean(options.afterReserve || supervisedRootSessionId); const now = Date.now(); - const db = database(env); for (let attempt = 0; attempt < 3; attempt += 1) { let reservationInserted = false; const id = await nextInteractiveSessionId(env); @@ -3892,123 +3873,55 @@ async function createInteractiveSessionFromInput( : undefined, ), ); - if (provisioned) { - const initialTerminalStatus: "stopped" | "expired" | "failed" | null = - provisioned.status === "stopped" || - provisioned.status === "expired" || - provisioned.status === "failed" - ? provisioned.status - : null; - const terminalAt = provisioned.reconciledAt ?? now + 1; - const completionVersionFloor = Math.max(terminalAt, now + 1); - const provisionUpdate = await db - .updateTable("interactive_sessions") - .set({ - status: provisioned.status, - lease_id: provisioned.adapter === runtimeAdapterName ? null : provisioned.leaseId, - attach_url: initialTerminalStatus ? null : provisioned.attachUrl, - // Versioned adapter desktop URLs are minted on demand and never persisted. - vnc_url: provisioned.adapter === runtimeAdapterName ? null : provisioned.vncUrl, - adapter: provisioned.adapter ?? null, - profile: provisioned.profile ?? profile, - adapter_workspace_id: provisioned.adapterWorkspaceId ?? null, - provider_resource_id: provisioned.providerResourceId ?? null, - capabilities_json: JSON.stringify(provisioned.capabilities ?? requestedCapabilities), - expires_at: provisioned.expiresAt ?? null, - last_reconciled_at: provisioned.reconciledAt ?? null, - reconcile_error: provisioned.reconcileError ?? null, - terminal_status: initialTerminalStatus ? null : (provisioned.terminalStatus ?? null), - adapter_create_pending: initialTerminalStatus ? 0 : provisioned.createPending ? 1 : 0, - terminal_finalize_pending: initialTerminalStatus ? 1 : 0, - ...(initialTerminalStatus - ? { - stopped_at: terminalAt, - agent_token_hash: null, - controller: null, - control_requested_by: null, - control_requested_at: null, - control_granted_at: null, - control_expires_at: null, - } - : {}), - last_event: provisioned.message, - updated_at: sql`MAX(updated_at + 1, ${completionVersionFloor})`, - }) - .where("id", "=", id) - .where("status", "in", ["provisioning", "pending_adapter"]) - .where(sql`lease_id IS ${initialSandboxOwnership?.leaseId ?? null}`) - .where("agent_token_hash", "=", initialAgentTokenHash) - .where("sandbox_refresh_sandbox_id", "is", null) - .where("sandbox_refresh_claim", "is", null) - .where("sandbox_refresh_claim_expires_at", "is", null) - .executeTakeFirst(); - if ((provisionUpdate.numUpdatedRows ?? 0n) === 0n) { - let current = await readInteractiveSession(env, id); - const currentAdapterProvision = Boolean( - current && - current.adapter === runtimeAdapterName && - current.adapterWorkspaceId === provisioned.adapterWorkspaceId && - ["provisioning", "pending_adapter", "ready", "attached", "detached"].includes( - current.status, - ), + const provisionPersistence = await creation.completeProvision( + { + sessionId: id, + insertedAt: now, + profile, + requestedCapabilities, + initialLeaseId: initialSandboxOwnership?.leaseId ?? null, + initialAgentTokenHash, + adapterName: runtimeAdapterName, + }, + provisioned, + ); + if (!provisionPersistence.updated && provisioned) { + let current = await readInteractiveSession(env, id); + const currentAdapterProvision = Boolean( + current && + current.adapter === runtimeAdapterName && + current.adapterWorkspaceId === provisioned.adapterWorkspaceId && + ["provisioning", "pending_adapter", "ready", "attached", "detached"].includes( + current.status, + ), + ); + if ( + !currentAdapterProvision && + provisioned.adapter === runtimeAdapterName && + provisioned.adapterWorkspaceId + ) { + await stopSupersededRuntimeAdapterProvision( + env, + id, + provisioned.adapterWorkspaceId, + provisioned.createPending === true, + Date.now(), ); - if ( - !currentAdapterProvision && - provisioned.adapter === runtimeAdapterName && - provisioned.adapterWorkspaceId - ) { - await stopSupersededRuntimeAdapterProvision( - env, - id, - provisioned.adapterWorkspaceId, - provisioned.createPending === true, - Date.now(), - ); - } - if ( - provisioned.adapter !== runtimeAdapterName && - provisioned.leaseId?.startsWith(sandboxLeasePrefix) - ) { - await queueSandboxCredentialPolicyCleanup( - env, - id, - sandboxLeaseInfo({ id, leaseId: provisioned.leaseId }).sandboxId, - ); - await reconcileCredentialPolicyCleanupBatch(env, Date.now(), id); - current = await readInteractiveSession(env, id); - } - if (!current) throw new Error("interactive session disappeared during provisioning"); - return { session: decorateInteractiveSession(current, user, env) }; } - await appendInteractiveSessionEvent(env, id, user, provisioned.message, now + 1); - if (initialTerminalStatus) { - await finalizeTerminalInteractiveSession( + if ( + provisioned.adapter !== runtimeAdapterName && + provisioned.leaseId?.startsWith(sandboxLeasePrefix) + ) { + await queueSandboxCredentialPolicyCleanup( env, id, - initialTerminalStatus, - terminalAt, - ).catch(() => undefined); + sandboxLeaseInfo({ id, leaseId: provisioned.leaseId }).sandboxId, + ); + await reconcileCredentialPolicyCleanupBatch(env, Date.now(), id); + current = await readInteractiveSession(env, id); } - } else { - await db - .updateTable("interactive_sessions") - .set({ - status: "pending_adapter", - last_event: "waiting for interactive runtime adapter", - updated_at: sql`MAX(updated_at + 1, ${now + 1})`, - }) - .where("id", "=", id) - .where("status", "=", "provisioning") - .where(sql`lease_id IS ${initialSandboxOwnership?.leaseId ?? null}`) - .where("agent_token_hash", "=", initialAgentTokenHash) - .execute(); - await appendInteractiveSessionEvent( - env, - id, - user, - "waiting for interactive runtime adapter", - now + 1, - ); + if (!current) throw new Error("interactive session disappeared during provisioning"); + return { session: decorateInteractiveSession(current, user, env) }; } await audit( env, @@ -4306,6 +4219,13 @@ function interactiveSessionCreationService( const session = await readOpenClawRequestSession(env, requestId, requestHash); return session ? decorateInteractiveSession(session, user, env) : null; }, + persistProvisionResult: (input, result) => + persistInteractiveSessionProvisionResult(env, input, result), + markPendingAdapter: (input) => markInteractiveSessionPendingAdapter(env, input), + recordProvisionEvent: (sessionId, message, now) => + appendInteractiveSessionEvent(env, sessionId, user, message, now), + finalizeTerminal: (sessionId, status, now) => + finalizeTerminalInteractiveSession(env, sessionId, status, now), }; return new InteractiveSessionCreationService(store); } diff --git a/src/worker/session-creation.ts b/src/worker/session-creation.ts index 518ea77..15a61b1 100644 --- a/src/worker/session-creation.ts +++ b/src/worker/session-creation.ts @@ -1,4 +1,9 @@ import type { InteractiveSession } from "./session-model.ts"; +import type { + InteractiveProvisionPersistence, + InteractiveProvisionPersistenceInput, + InteractiveProvisionResult, +} from "./session-provisioning.ts"; export type InteractiveSessionCreationReservation = { id: string; @@ -23,6 +28,22 @@ export type InteractiveSessionCreationStore = { recordRequest(insertedSessionId: string, insertedAt: number): Promise; isConstraintError(error: unknown): boolean; readRequestReplay(requestId: string, requestHash: string): Promise; + persistProvisionResult( + input: InteractiveProvisionPersistenceInput, + result: InteractiveProvisionResult, + ): Promise; + markPendingAdapter( + input: Pick< + InteractiveProvisionPersistenceInput, + "sessionId" | "insertedAt" | "initialLeaseId" | "initialAgentTokenHash" + >, + ): Promise; + recordProvisionEvent(sessionId: string, message: string, now: number): Promise; + finalizeTerminal( + sessionId: string, + status: "stopped" | "expired" | "failed", + now: number, + ): Promise; }; export class InteractiveSessionCreationService { @@ -90,4 +111,33 @@ export class InteractiveSessionCreationService { } return null; } + + async completeProvision( + input: InteractiveProvisionPersistenceInput, + result: InteractiveProvisionResult | null, + ): Promise { + if (!result) { + await this.store.markPendingAdapter(input); + await this.store.recordProvisionEvent( + input.sessionId, + "waiting for interactive runtime adapter", + input.insertedAt + 1, + ); + return { + updated: true, + terminalStatus: null, + terminalAt: input.insertedAt + 1, + }; + } + + const persisted = await this.store.persistProvisionResult(input, result); + if (!persisted.updated) return persisted; + await this.store.recordProvisionEvent(input.sessionId, result.message, input.insertedAt + 1); + if (persisted.terminalStatus) { + await this.store + .finalizeTerminal(input.sessionId, persisted.terminalStatus, persisted.terminalAt) + .catch(() => undefined); + } + return persisted; + } } diff --git a/src/worker/session-provisioning.ts b/src/worker/session-provisioning.ts new file mode 100644 index 0000000..872457f --- /dev/null +++ b/src/worker/session-provisioning.ts @@ -0,0 +1,39 @@ +import type { InteractiveSessionStatus } from "./models.ts"; +import type { RuntimeCapabilities } from "./session-model.ts"; + +export type InteractiveProvisionResult = { + status: InteractiveSessionStatus; + leaseId: string | null; + attachUrl: string | null; + attachUrlPresent?: boolean; + vncUrl: string | null; + message: string; + adapter?: string | null; + profile?: string; + adapterWorkspaceId?: string | null; + providerResourceId?: string | null; + capabilities?: RuntimeCapabilities | null; + capabilitiesPresent?: boolean; + expiresAt?: number | null; + expiresAtPresent?: boolean; + reconciledAt?: number | null; + reconcileError?: string | null; + terminalStatus?: "failed" | null; + createPending?: boolean; +}; + +export type InteractiveProvisionPersistence = { + updated: boolean; + terminalStatus: "stopped" | "expired" | "failed" | null; + terminalAt: number; +}; + +export type InteractiveProvisionPersistenceInput = { + sessionId: string; + insertedAt: number; + profile: string; + requestedCapabilities: RuntimeCapabilities; + initialLeaseId: string | null; + initialAgentTokenHash: string; + adapterName: string; +}; diff --git a/src/worker/session-repository.ts b/src/worker/session-repository.ts index 0cd16f8..afb9ac6 100644 --- a/src/worker/session-repository.ts +++ b/src/worker/session-repository.ts @@ -1,4 +1,4 @@ -import type { Insertable } from "kysely"; +import { sql, type Insertable } from "kysely"; import { database, @@ -7,6 +7,11 @@ import { type InteractiveSessionTable, } from "./database.ts"; import type { RuntimeEnv } from "./env.ts"; +import type { + InteractiveProvisionPersistence, + InteractiveProvisionPersistenceInput, + InteractiveProvisionResult, +} from "./session-provisioning.ts"; export type InteractiveSessionReplayReservation = { requestId: string; @@ -66,3 +71,83 @@ export async function insertInteractiveSessionReservation( insertSession, ]); } + +export async function persistInteractiveSessionProvisionResult( + env: RuntimeEnv, + input: InteractiveProvisionPersistenceInput, + result: InteractiveProvisionResult, +): Promise { + const terminalStatus = + result.status === "stopped" || result.status === "expired" || result.status === "failed" + ? result.status + : null; + const terminalAt = result.reconciledAt ?? input.insertedAt + 1; + const completionVersionFloor = Math.max(terminalAt, input.insertedAt + 1); + const update = await database(env) + .updateTable("interactive_sessions") + .set({ + status: result.status, + lease_id: result.adapter === input.adapterName ? null : result.leaseId, + attach_url: terminalStatus ? null : result.attachUrl, + // Versioned adapter desktop URLs are minted on demand and never persisted. + vnc_url: result.adapter === input.adapterName ? null : result.vncUrl, + adapter: result.adapter ?? null, + profile: result.profile ?? input.profile, + adapter_workspace_id: result.adapterWorkspaceId ?? null, + provider_resource_id: result.providerResourceId ?? null, + capabilities_json: JSON.stringify(result.capabilities ?? input.requestedCapabilities), + expires_at: result.expiresAt ?? null, + last_reconciled_at: result.reconciledAt ?? null, + reconcile_error: result.reconcileError ?? null, + terminal_status: terminalStatus ? null : (result.terminalStatus ?? null), + adapter_create_pending: terminalStatus ? 0 : result.createPending ? 1 : 0, + terminal_finalize_pending: terminalStatus ? 1 : 0, + ...(terminalStatus + ? { + stopped_at: terminalAt, + agent_token_hash: null, + controller: null, + control_requested_by: null, + control_requested_at: null, + control_granted_at: null, + control_expires_at: null, + } + : {}), + last_event: result.message, + updated_at: sql`MAX(updated_at + 1, ${completionVersionFloor})`, + }) + .where("id", "=", input.sessionId) + .where("status", "in", ["provisioning", "pending_adapter"]) + .where(sql`lease_id IS ${input.initialLeaseId}`) + .where("agent_token_hash", "=", input.initialAgentTokenHash) + .where("sandbox_refresh_sandbox_id", "is", null) + .where("sandbox_refresh_claim", "is", null) + .where("sandbox_refresh_claim_expires_at", "is", null) + .executeTakeFirst(); + return { + updated: (update.numUpdatedRows ?? 0n) > 0n, + terminalStatus, + terminalAt, + }; +} + +export async function markInteractiveSessionPendingAdapter( + env: RuntimeEnv, + input: Pick< + InteractiveProvisionPersistenceInput, + "sessionId" | "insertedAt" | "initialLeaseId" | "initialAgentTokenHash" + >, +): Promise { + await database(env) + .updateTable("interactive_sessions") + .set({ + status: "pending_adapter", + last_event: "waiting for interactive runtime adapter", + updated_at: sql`MAX(updated_at + 1, ${input.insertedAt + 1})`, + }) + .where("id", "=", input.sessionId) + .where("status", "=", "provisioning") + .where(sql`lease_id IS ${input.initialLeaseId}`) + .where("agent_token_hash", "=", input.initialAgentTokenHash) + .execute(); +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 76caa03..393f9ed 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -1345,35 +1345,6 @@ test("Sandbox credential registration always proves exact durable ownership", as ); }); -test("initial terminal adapter responses enter durable finalization", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const createStart = source.indexOf("async function createInteractiveSessionFromInput"); - const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); - const createSource = source.slice(createStart, createEnd); - const completionStart = createSource.indexOf("const provisionUpdate"); - const completionEnd = createSource.indexOf( - "if ((provisionUpdate.numUpdatedRows", - completionStart, - ); - const completionSource = createSource.slice(completionStart, completionEnd); - - assert.ok(createStart >= 0 && createEnd > createStart); - assert.match(createSource, /const initialTerminalStatus: "stopped" \| "expired" \| "failed"/); - assert.match(createSource, /terminal_finalize_pending: initialTerminalStatus \? 1 : 0/); - assert.match(createSource, /stopped_at: terminalAt/); - assert.match(createSource, /agent_token_hash: null/); - assert.match(createSource, /attach_url: initialTerminalStatus \? null/); - assert.match(createSource, /adapter_create_pending: initialTerminalStatus/); - assert.match(completionSource, /MAX\(updated_at \+ 1, \$\{completionVersionFloor\}\)/); - assert.doesNotMatch(completionSource, /where\("updated_at"/); - assert.match(createSource, /lease_id IS \$\{initialSandboxOwnership\?\.leaseId \?\? null\}/); - assert.match(createSource, /where\("agent_token_hash", "=", initialAgentTokenHash\)/); - assert.match(createSource, /where\("sandbox_refresh_sandbox_id", "is", null\)/); - assert.match(createSource, /where\("sandbox_refresh_claim", "is", null\)/); - assert.match(createSource, /where\("sandbox_refresh_claim_expires_at", "is", null\)/); - assert.match(createSource, /finalizeTerminalInteractiveSession/); -}); - test("create-only adapters reject stopping responses before persistence", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const externalStart = source.indexOf("async function provisionInteractiveSession"); diff --git a/tests/session-creation.test.ts b/tests/session-creation.test.ts index b90f8e4..cbce175 100644 --- a/tests/session-creation.test.ts +++ b/tests/session-creation.test.ts @@ -7,7 +7,7 @@ import { type InteractiveSessionCreationStore, } from "../src/worker/session-creation.ts"; import type { InteractiveSession } from "../src/worker/session-model.ts"; -import { interactiveSession } from "../src/worker/session-model.ts"; +import { containerCapabilities, interactiveSession } from "../src/worker/session-model.ts"; import { sessionRow } from "./helpers/session-row.ts"; const reservation: InteractiveSessionCreationReservation = { @@ -28,6 +28,14 @@ function creationStore( recordRequest: async () => undefined, isConstraintError: () => false, readRequestReplay: async () => null, + persistProvisionResult: async () => ({ + updated: true, + terminalStatus: null, + terminalAt: 101, + }), + markPendingAdapter: async () => undefined, + recordProvisionEvent: async () => undefined, + finalizeTerminal: async () => undefined, ...overrides, }; } @@ -200,3 +208,76 @@ test("session creation retries only unowned constraint failures before the final /provider/, ); }); + +test("session creation persists provision evidence before terminal finalization", async () => { + const calls: string[] = []; + const service = new InteractiveSessionCreationService( + creationStore({ + persistProvisionResult: async () => { + calls.push("persist"); + return { updated: true, terminalStatus: "failed", terminalAt: 150 }; + }, + recordProvisionEvent: async (_sessionId, message, now) => { + calls.push(`event:${message}:${now}`); + }, + finalizeTerminal: async (_sessionId, status, now) => { + calls.push(`finalize:${status}:${now}`); + }, + }), + ); + + assert.deepEqual( + await service.completeProvision( + { + sessionId: "IS-2", + insertedAt: 100, + profile: "default", + requestedCapabilities: containerCapabilities, + initialLeaseId: null, + initialAgentTokenHash: "agent-hash", + adapterName: "runtime-adapter", + }, + { + status: "failed", + leaseId: null, + attachUrl: null, + vncUrl: null, + message: "provider failed", + }, + ), + { updated: true, terminalStatus: "failed", terminalAt: 150 }, + ); + assert.deepEqual(calls, ["persist", "event:provider failed:101", "finalize:failed:150"]); +}); + +test("session creation records pending adapters without finalization", async () => { + const calls: string[] = []; + const service = new InteractiveSessionCreationService( + creationStore({ + markPendingAdapter: async () => { + calls.push("pending"); + }, + recordProvisionEvent: async (_sessionId, message, now) => { + calls.push(`event:${message}:${now}`); + }, + finalizeTerminal: async () => { + calls.push("finalize"); + }, + }), + ); + + const result = await service.completeProvision( + { + sessionId: "IS-2", + insertedAt: 100, + profile: "default", + requestedCapabilities: containerCapabilities, + initialLeaseId: null, + initialAgentTokenHash: "agent-hash", + adapterName: "runtime-adapter", + }, + null, + ); + assert.deepEqual(result, { updated: true, terminalStatus: null, terminalAt: 101 }); + assert.deepEqual(calls, ["pending", "event:waiting for interactive runtime adapter:101"]); +}); diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts index 5320398..f2a5af3 100644 --- a/tests/session-repository.test.ts +++ b/tests/session-repository.test.ts @@ -4,9 +4,12 @@ import test from "node:test"; import type { RuntimeEnv } from "../src/worker/env.ts"; import { insertInteractiveSessionReservation, + markInteractiveSessionPendingAdapter, + persistInteractiveSessionProvisionResult, readVisibleInteractiveSessionRow, readVisibleInteractiveSessionRows, } from "../src/worker/session-repository.ts"; +import { containerCapabilities } from "../src/worker/session-model.ts"; import { sessionRow } from "./helpers/session-row.ts"; type D1Result = { results?: unknown[]; changes?: number }; @@ -129,3 +132,74 @@ test("session reservations without request identity use one insert", async () => ); assert.equal(inserts, 1); }); + +test("terminal provision results atomically enter durable finalization", async () => { + let updateSql = ""; + const result = await persistInteractiveSessionProvisionResult( + runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "run"); + updateSql = sql; + assert.ok(parameters.includes("IS-2")); + assert.ok(parameters.includes("agent-hash")); + return { changes: 1 }; + }), + { + sessionId: "IS-2", + insertedAt: 100, + profile: "default", + requestedCapabilities: containerCapabilities, + initialLeaseId: "sandbox:lease", + initialAgentTokenHash: "agent-hash", + adapterName: "runtime-adapter", + }, + { + status: "failed", + leaseId: "provider-lease", + attachUrl: "wss://terminal.example.test", + vncUrl: "https://desktop.example.test", + message: "provider failed", + adapter: "runtime-adapter", + adapterWorkspaceId: "workspace-2", + reconciledAt: 150, + createPending: true, + }, + ); + + assert.deepEqual(result, { + updated: true, + terminalStatus: "failed", + terminalAt: 150, + }); + assert.match(updateSql, /"terminal_finalize_pending"/i); + assert.match(updateSql, /"stopped_at"/i); + assert.match(updateSql, /"agent_token_hash"/i); + assert.match(updateSql, /MAX\(updated_at \+ 1/i); + assert.match(updateSql, /lease_id IS/i); + assert.match(updateSql, /"sandbox_refresh_sandbox_id" is null/i); + assert.match(updateSql, /"sandbox_refresh_claim" is null/i); + assert.match(updateSql, /"sandbox_refresh_claim_expires_at" is null/i); +}); + +test("missing provision adapters persist the fenced pending state", async () => { + let updateSql = ""; + await markInteractiveSessionPendingAdapter( + runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "run"); + updateSql = sql; + assert.ok(parameters.includes("IS-2")); + assert.ok(parameters.includes("agent-hash")); + assert.ok(parameters.includes(null)); + return { changes: 1 }; + }), + { + sessionId: "IS-2", + insertedAt: 100, + initialLeaseId: null, + initialAgentTokenHash: "agent-hash", + }, + ); + assert.match(updateSql, /"status" =/i); + assert.match(updateSql, /"last_event" =/i); + assert.match(updateSql, /MAX\(updated_at \+ 1/i); + assert.match(updateSql, /lease_id IS/i); +}); From 4b68b8b2bfe5635dfc4a7285f81ab4e2f93f756c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:26:40 +0100 Subject: [PATCH 021/109] refactor: centralize session reservations --- CHANGELOG.md | 1 + src/index.ts | 74 ++++++---------------- src/worker/session-repository.ts | 103 +++++++++++++++++++++++++++++++ tests/runtime-adapter.test.ts | 2 - tests/session-repository.test.ts | 59 ++++++++++++++++++ 5 files changed, 181 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef1478..8142b84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Move interactive-session reservation retry and idempotent replay recovery into the creation service. - Extract runtime-adapter configuration, control-plane, token, and create-preflight policy into a directly tested module. - Extract provisioning-result compare-and-set persistence, pending-adapter fallback, event recording, and terminal finalization ordering. +- Centralize interactive-session reservation row defaults, adapter preparation state, replay identity, and sandbox lease ownership. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 6e00337..f99d3e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -258,12 +258,12 @@ import { type InteractiveSessionCreationStore, } from "./worker/session-creation"; import { + buildInteractiveSessionReservationValues, insertInteractiveSessionReservation, markInteractiveSessionPendingAdapter, persistInteractiveSessionProvisionResult, readVisibleInteractiveSessionRow, readVisibleInteractiveSessionRows, - type InteractiveSessionReservationValues, } from "./worker/session-repository"; import type { InteractiveProvisionResult } from "./worker/session-provisioning"; import { @@ -3753,71 +3753,33 @@ async function createInteractiveSessionFromInput( ? JSON.stringify(adapterCreatePayload) : null; try { - const reservationValues: InteractiveSessionReservationValues = { + const reservationValues = buildInteractiveSessionReservationValues({ id, - parent_session_id: lineage.parentSessionId, - root_session_id: rootSessionId, + parentSessionId: lineage.parentSessionId, + rootSessionId, repo, branch, runtime, - adapter: adapterWorkspaceId && !preparationReservation ? runtimeAdapterName : null, + adapterName: runtimeAdapterName, profile, - adapter_workspace_id: adapterWorkspaceId, - adapter_control_plane: adapterControlPlane, - provider_resource_id: null, - capabilities_json: JSON.stringify(requestedCapabilities), - expires_at: null, - last_reconciled_at: adapterWorkspaceId && !preparationReservation ? now : null, - reconcile_error: - adapterWorkspaceId && !preparationReservation ? "runtime adapter create pending" : null, - terminal_status: null, - adapter_ttl_seconds: adapterSettings?.ttlSeconds ?? null, - adapter_idle_timeout_seconds: adapterSettings?.idleTimeoutSeconds ?? null, - adapter_requested_capabilities_json: adapterSettings - ? JSON.stringify(adapterSettings.capabilities) - : null, - adapter_create_payload_json: adapterCreatePayloadJson, - adapter_create_pending: adapterWorkspaceId && !preparationReservation ? 1 : 0, - preparation_pending: preparationReservation ? 1 : 0, - openclaw_request_id: options.openClawRequestId ?? null, - openclaw_request_hash: options.openClawRequestHash ?? null, - openclaw_admission_closed: 0, + adapterWorkspaceId, + adapterControlPlane, + requestedCapabilities, + adapterSettings, + adapterCreatePayloadJson, + preparationReservation, + openClawRequestId: options.openClawRequestId ?? null, + openClawRequestHash: options.openClawRequestHash ?? null, command, prompt, purpose, summary, owner, - created_by: createdBy, - status: "provisioning", - lease_id: initialSandboxOwnership?.leaseId ?? null, - attach_url: null, - vnc_url: null, - last_event: "interactive workspace requested", - created_at: now, - updated_at: now, - last_seen_at: now, - stopped_at: null, - share_mode: "private", - share_token_hash: null, - share_token_preview: null, - control_requested_by: null, - control_requested_at: null, - controller: null, - control_granted_at: null, - control_expires_at: null, - multiplayer_mode: 0, - agent_token_hash: initialAgentTokenHash, - work_key: null, - work_kind: null, - work_state: "", - work_phase: "", - source_url: null, - github_run_url: null, - codex_thread_id: null, - codex_turn_id: null, - last_heartbeat_at: null, - completion_reason: null, - }; + createdBy, + initialLeaseId: initialSandboxOwnership?.leaseId ?? null, + initialAgentTokenHash, + now, + }); if (options.openClawRequestId && options.openClawRequestHash) { await insertInteractiveSessionReservation(env, reservationValues, { requestId: options.openClawRequestId, diff --git a/src/worker/session-repository.ts b/src/worker/session-repository.ts index afb9ac6..9f126bc 100644 --- a/src/worker/session-repository.ts +++ b/src/worker/session-repository.ts @@ -12,6 +12,7 @@ import type { InteractiveProvisionPersistenceInput, InteractiveProvisionResult, } from "./session-provisioning.ts"; +import type { RuntimeCapabilities } from "./session-model.ts"; export type InteractiveSessionReplayReservation = { requestId: string; @@ -22,6 +23,108 @@ export type InteractiveSessionReplayReservation = { export type InteractiveSessionReservationValues = Insertable; +export type InteractiveSessionReservationBuildInput = { + id: string; + parentSessionId: string | null; + rootSessionId: string; + repo: string; + branch: string; + runtime: "crabbox" | "container"; + adapterName: string; + profile: string; + adapterWorkspaceId: string | null; + adapterControlPlane: string | null; + requestedCapabilities: RuntimeCapabilities; + adapterSettings: { + ttlSeconds: number; + idleTimeoutSeconds: number; + capabilities: RuntimeCapabilities; + } | null; + adapterCreatePayloadJson: string | null; + preparationReservation: boolean; + openClawRequestId: string | null; + openClawRequestHash: string | null; + command: string; + prompt: string; + purpose: string; + summary: string; + owner: string; + createdBy: string; + initialLeaseId: string | null; + initialAgentTokenHash: string; + now: number; +}; + +export function buildInteractiveSessionReservationValues( + input: InteractiveSessionReservationBuildInput, +): InteractiveSessionReservationValues { + const immediateAdapter = Boolean(input.adapterWorkspaceId && !input.preparationReservation); + return { + id: input.id, + parent_session_id: input.parentSessionId, + root_session_id: input.rootSessionId, + repo: input.repo, + branch: input.branch, + runtime: input.runtime, + adapter: immediateAdapter ? input.adapterName : null, + profile: input.profile, + adapter_workspace_id: input.adapterWorkspaceId, + adapter_control_plane: input.adapterControlPlane, + provider_resource_id: null, + capabilities_json: JSON.stringify(input.requestedCapabilities), + expires_at: null, + last_reconciled_at: immediateAdapter ? input.now : null, + reconcile_error: immediateAdapter ? "runtime adapter create pending" : null, + terminal_status: null, + adapter_ttl_seconds: input.adapterSettings?.ttlSeconds ?? null, + adapter_idle_timeout_seconds: input.adapterSettings?.idleTimeoutSeconds ?? null, + adapter_requested_capabilities_json: input.adapterSettings + ? JSON.stringify(input.adapterSettings.capabilities) + : null, + adapter_create_payload_json: input.adapterCreatePayloadJson, + adapter_create_pending: immediateAdapter ? 1 : 0, + preparation_pending: input.preparationReservation ? 1 : 0, + openclaw_request_id: input.openClawRequestId, + openclaw_request_hash: input.openClawRequestHash, + openclaw_admission_closed: 0, + command: input.command, + prompt: input.prompt, + purpose: input.purpose, + summary: input.summary, + owner: input.owner, + created_by: input.createdBy, + status: "provisioning", + lease_id: input.initialLeaseId, + attach_url: null, + vnc_url: null, + last_event: "interactive workspace requested", + created_at: input.now, + updated_at: input.now, + last_seen_at: input.now, + stopped_at: null, + share_mode: "private", + share_token_hash: null, + share_token_preview: null, + control_requested_by: null, + control_requested_at: null, + controller: null, + control_granted_at: null, + control_expires_at: null, + multiplayer_mode: 0, + agent_token_hash: input.initialAgentTokenHash, + work_key: null, + work_kind: null, + work_state: "", + work_phase: "", + source_url: null, + github_run_url: null, + codex_thread_id: null, + codex_turn_id: null, + last_heartbeat_at: null, + completion_reason: null, + }; +} + export async function readVisibleInteractiveSessionRows( env: RuntimeEnv, limit = 80, diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 393f9ed..6204ccc 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -920,7 +920,6 @@ test("runtime adapter operations stay bound to the registered control plane", as const stopSource = source.slice(stopStart, stopEnd); assert.match(migration, /ADD COLUMN adapter_control_plane TEXT/); - assert.match(source, /adapter_control_plane: adapterControlPlane/); assert.equal( configuredRuntimeAdapterControlPlane( { @@ -1334,7 +1333,6 @@ test("Sandbox credential registration always proves exact durable ownership", as assert.match(ownerSource, /AND \$\{sandboxId\} = \$\{ownershipFence\.sandboxId\}/); assert.match(createSource, /const initialSandboxLease/); assert.match(createSource, /const initialAgentTokenHash = await sha256\(agentToken\)/); - assert.match(createSource, /lease_id: initialSandboxOwnership\?\.leaseId/); assert.match(createSource, /ownership: initialSandboxOwnership/); assert.match(ensureSource, /sandboxCurrentLeaseFence|SandboxCurrentLeaseFence/); assert.match(ensureSource, /credentialPolicyLegacyGenerationPrefix/); diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts index f2a5af3..b075e93 100644 --- a/tests/session-repository.test.ts +++ b/tests/session-repository.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import type { RuntimeEnv } from "../src/worker/env.ts"; import { + buildInteractiveSessionReservationValues, insertInteractiveSessionReservation, markInteractiveSessionPendingAdapter, persistInteractiveSessionProvisionResult, @@ -54,6 +55,64 @@ function runtimeEnv( } as RuntimeEnv; } +function reservationInput() { + return { + id: "IS-2", + parentSessionId: "IS-1", + rootSessionId: "IS-1", + repo: "openclaw/crabfleet", + branch: "main", + runtime: "crabbox" as const, + adapterName: "runtime-adapter", + profile: "default", + adapterWorkspaceId: "workspace-2", + adapterControlPlane: "https://adapter.example.test/", + requestedCapabilities: containerCapabilities, + adapterSettings: { + ttlSeconds: 14_400, + idleTimeoutSeconds: 1_800, + capabilities: containerCapabilities, + }, + adapterCreatePayloadJson: '{"id":"workspace-2"}', + preparationReservation: true, + openClawRequestId: "request-1", + openClawRequestHash: "hash-1", + command: "codex --yolo", + prompt: "fix the issue", + purpose: "fix the issue", + summary: "starting", + owner: "maintainer", + createdBy: "service:openclaw", + initialLeaseId: "sandbox:lease", + initialAgentTokenHash: "agent-hash", + now: 100, + }; +} + +test("session reservation rows centralize preparation, replay, and lease ownership", () => { + const prepared = buildInteractiveSessionReservationValues(reservationInput()); + assert.equal(prepared.adapter, null); + assert.equal(prepared.adapter_create_pending, 0); + assert.equal(prepared.preparation_pending, 1); + assert.equal(prepared.last_reconciled_at, null); + assert.equal(prepared.reconcile_error, null); + assert.equal(prepared.adapter_control_plane, "https://adapter.example.test/"); + assert.equal(prepared.openclaw_request_id, "request-1"); + assert.equal(prepared.openclaw_request_hash, "hash-1"); + assert.equal(prepared.lease_id, "sandbox:lease"); + assert.equal(prepared.agent_token_hash, "agent-hash"); + + const immediate = buildInteractiveSessionReservationValues({ + ...reservationInput(), + preparationReservation: false, + }); + assert.equal(immediate.adapter, "runtime-adapter"); + assert.equal(immediate.adapter_create_pending, 1); + assert.equal(immediate.preparation_pending, 0); + assert.equal(immediate.last_reconciled_at, 100); + assert.equal(immediate.reconcile_error, "runtime adapter create pending"); +}); + test("visible session reads exclude preparation reservations and stay bounded", async () => { const row = sessionRow({ id: "IS-2", preparation_pending: 0 }); let calls = 0; From 635fdc329821463add648bd65102e459ccec422c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:33:51 +0100 Subject: [PATCH 022/109] refactor: extract session create requests --- CHANGELOG.md | 1 + src/index.ts | 93 ++++++---------------- src/worker/session-create-request.ts | 113 +++++++++++++++++++++++++++ tests/runtime-adapter.test.ts | 16 +--- tests/session-create-request.test.ts | 91 +++++++++++++++++++++ 5 files changed, 232 insertions(+), 82 deletions(-) create mode 100644 src/worker/session-create-request.ts create mode 100644 tests/session-create-request.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8142b84..25a9174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Extract runtime-adapter configuration, control-plane, token, and create-preflight policy into a directly tested module. - Extract provisioning-result compare-and-set persistence, pending-adapter fallback, event recording, and terminal finalization ordering. - Centralize interactive-session reservation row defaults, adapter preparation state, replay identity, and sandbox lease ownership. +- Centralize interactive-session create request defaults, profile policy, capability selection, and descriptive fields. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index f99d3e6..6bc22cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -129,11 +129,7 @@ import { type CredentialPolicyGenerationTombstone, type CredentialPolicyLegacyMigration, } from "./credential-policy-fence"; -import { - resolveRuntimeProfileCodexSsh, - runtimeProfileByID, - runtimeProfileCapabilities, -} from "./runtime-profiles"; +import { resolveRuntimeProfileCodexSsh, runtimeProfileByID } from "./runtime-profiles"; import { browserAppOrigin, clientDeploymentConfig, @@ -266,15 +262,19 @@ import { readVisibleInteractiveSessionRows, } from "./worker/session-repository"; import type { InteractiveProvisionResult } from "./worker/session-provisioning"; +import { + interactiveCommand, + interactiveSessionPurpose, + interactiveSessionSummary, + resolveInteractiveSessionCreateRequest, + type InteractiveSessionCreateRequest, +} from "./worker/session-create-request"; import { configuredRuntimeAdapterControlPlane, - requireRuntimeAdapterCreatePreflight, runtimeAdapterConfigurationPresent, runtimeAdapterToken, } from "./worker/runtime-adapter-preflight"; -const defaultInteractiveCommand = "codex --yolo"; - const sandboxPlaceholderOpenAIKey = "crabfleet-worker-injected"; const sandboxPlaceholderGitHubToken = "crabfleet-worker-injected"; @@ -3649,18 +3649,7 @@ async function createInteractiveSession( async function createInteractiveSessionFromInput( env: RuntimeEnv, user: User, - body: { - repo?: string; - branch?: string; - runtime?: string; - profile?: string; - command?: string; - prompt?: string; - parentSessionId?: string; - rootSessionId?: string; - purpose?: string; - summary?: string; - }, + body: InteractiveSessionCreateRequest, githubToken?: string, options: { createdBy?: string; @@ -3672,26 +3661,24 @@ async function createInteractiveSessionFromInput( afterReserve?: () => Promise; } = {}, ): Promise<{ session: InteractiveSession }> { - const repo = normalizeRepo(body.repo); - if (!repo) throw badRequest("repo is required"); + const request = resolveInteractiveSessionCreateRequest(env, body, { + owner: options.owner || actor(user), + createdBy: options.createdBy || actor(user), + }); + const { + repo, + branch, + runtime, + profile, + requestedCapabilities, + command, + prompt, + purpose, + summary, + owner, + createdBy, + } = request; await requireRepo(env, repo); - const branch = clean(body.branch, 120) || "main"; - const deployment = deploymentConfig(env); - const runtime = oneOf(body.runtime, ["crabbox", "container"], deployment.defaultRuntime) as - | "crabbox" - | "container"; - const { profile, descriptor: runtimeProfile } = selectedRuntimeProfile(deployment, body.profile); - requireRuntimeAdapterCreatePreflight(env, runtime, profile); - const requestedCapabilities = runtimeProfileCapabilities( - runtime === "crabbox" ? runtimeProfile : undefined, - runtime === "crabbox" ? crabboxCapabilities : containerCapabilities, - ); - const command = interactiveCommand(body.command); - const prompt = clean(body.prompt, 4000); - const purpose = interactiveSessionPurpose(body.purpose, prompt, repo, branch, command); - const summary = interactiveSessionSummary(body.summary, purpose, prompt); - const owner = options.owner || actor(user); - const createdBy = options.createdBy || actor(user); const lineage = await interactiveSessionLineageService(env).resolve( user, options.parentSessionId ?? (clean(body.parentSessionId, 120) || null), @@ -4192,25 +4179,6 @@ function interactiveSessionCreationService( return new InteractiveSessionCreationService(store); } -function interactiveSessionPurpose( - value: unknown, - prompt: string, - repo: string, - branch: string, - command: string, -): string { - const explicit = clean(value, 500); - if (explicit) return explicit; - if (prompt) return clean(prompt, 500); - return clean(`${command} in ${repo}@${branch}`, 500); -} - -function interactiveSessionSummary(value: unknown, purpose: string, prompt: string): string { - const explicit = clean(value, 500); - if (explicit) return explicit; - return clean(purpose || prompt || "interactive Codex session", 500); -} - function newAgentToken(): string { return `${crypto.randomUUID()}${crypto.randomUUID()}`; } @@ -14555,15 +14523,6 @@ async function runSandboxSetupStep(step: string, operation: () => Promise(value: unknown, options: readonly T[], fallback: T): T { + return options.includes(value as T) ? (value as T) : fallback; +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 6204ccc..468ba74 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -36,11 +36,7 @@ import { shouldReplayRuntimeAdapterCreate, validatedRuntimeAdapterCreatePayloadJson, } from "../src/runtime-adapter.ts"; -import { - deploymentConfig, - publicDeploymentConfig, - selectedRuntimeProfile, -} from "../src/worker/deployment.ts"; +import { publicDeploymentConfig } from "../src/worker/deployment.ts"; import type { RuntimeEnv } from "../src/worker/env.ts"; import { configuredRuntimeAdapterControlPlane, @@ -119,9 +115,6 @@ test("adapter create payload matches the strict controller contract", () => { test("configured profiles fence every adapter runtime and preserve requested capabilities", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const createStart = source.indexOf("async function createInteractiveSessionFromInput"); - const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); - const createSource = source.slice(createStart, createEnd); const resultStart = source.indexOf("function runtimeAdapterProvisionResult"); const resultEnd = source.indexOf( "async function reconcileStoppingRuntimeAdapterWorkspace", @@ -129,13 +122,6 @@ test("configured profiles fence every adapter runtime and preserve requested cap ); const resultSource = source.slice(resultStart, resultEnd); - assert.match(createSource, /selectedRuntimeProfile\(deployment, body\.profile\)/); - const deployment = deploymentConfig({ - CRABFLEET_DEFAULT_PROFILE: "linux", - CRABFLEET_RUNTIME_PROFILES_JSON: JSON.stringify([{ id: "linux", label: "Linux" }]), - }); - assert.equal(selectedRuntimeProfile(deployment, "linux").descriptor?.id, "linux"); - assert.throws(() => selectedRuntimeProfile(deployment, "unknown"), /profile is not configured/); assert.match(resultSource, /session\.adapterRequestedCapabilities \?\?/); assert.match(resultSource, /profile: session\.profile/); assert.doesNotMatch(resultSource, /profile: result\.profile/); diff --git a/tests/session-create-request.test.ts b/tests/session-create-request.test.ts new file mode 100644 index 0000000..40d77e3 --- /dev/null +++ b/tests/session-create-request.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + interactiveCommand, + resolveInteractiveSessionCreateRequest, +} from "../src/worker/session-create-request.ts"; + +function runtimeEnv(values: Partial = {}): RuntimeEnv { + return values as RuntimeEnv; +} + +test("session create requests centralize defaults and descriptive fields", () => { + const request = resolveInteractiveSessionCreateRequest( + runtimeEnv(), + { + repo: " HTTPS://github.com/OpenClaw/Crabfleet.git/ ", + command: " codex --yolosandbox ", + }, + { owner: "maintainer", createdBy: "service:openclaw" }, + ); + + assert.equal(request.repo, "openclaw/crabfleet"); + assert.equal(request.branch, "main"); + assert.equal(request.runtime, "container"); + assert.equal(request.profile, "default"); + assert.equal(request.command, "codex --yolo"); + assert.equal(request.purpose, "codex --yolo in openclaw/crabfleet@main"); + assert.equal(request.summary, request.purpose); + assert.equal(request.owner, "maintainer"); + assert.equal(request.createdBy, "service:openclaw"); + assert.equal(request.requestedCapabilities.terminal, true); + assert.equal(request.requestedCapabilities.desktop, false); + assert.equal(interactiveCommand(undefined), "codex --yolo"); +}); + +test("session create requests enforce configured profiles and capability overlays", () => { + const env = runtimeEnv({ + CRABFLEET_DEFAULT_RUNTIME: "crabbox", + CRABFLEET_DEFAULT_PROFILE: "desktop", + CRABFLEET_RUNTIME_PROFILES_JSON: JSON.stringify([ + { + id: "desktop", + label: "Desktop", + capabilities: { terminal: true, takeover: false, desktop: false, vnc: false }, + }, + ]), + }); + const request = resolveInteractiveSessionCreateRequest( + env, + { + repo: "openclaw/crabfleet", + prompt: "investigate the failure", + }, + { owner: "maintainer", createdBy: "maintainer" }, + ); + + assert.equal(request.runtime, "crabbox"); + assert.equal(request.profile, "desktop"); + assert.equal(request.purpose, "investigate the failure"); + assert.equal(request.summary, "investigate the failure"); + assert.equal(request.requestedCapabilities.terminal, true); + assert.equal(request.requestedCapabilities.takeover, false); + assert.equal(request.requestedCapabilities.desktop, false); + assert.equal(request.requestedCapabilities.vnc, false); + assert.throws( + () => + resolveInteractiveSessionCreateRequest( + env, + { repo: "openclaw/crabfleet", profile: "missing" }, + { owner: "maintainer", createdBy: "maintainer" }, + ), + /profile is not configured/, + ); +}); + +test("session create requests fail before allocation when adapter routing is incomplete", () => { + assert.throws( + () => + resolveInteractiveSessionCreateRequest( + runtimeEnv({ + CRABBOX_RUNTIME_ADAPTER_URL: "https://adapter.example.test", + CRABBOX_RUNTIME_ADAPTER_NAMESPACE: "fleet", + }), + { repo: "openclaw/crabfleet" }, + { owner: "maintainer", createdBy: "maintainer" }, + ), + /runtime adapter token is not configured/, + ); +}); From 5dd8881fe70c88a761f1e1623dc0efafdc13c74c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:39:43 +0100 Subject: [PATCH 023/109] refactor: extract session reservation context --- CHANGELOG.md | 1 + src/index.ts | 197 ++++------------------ src/worker/duration.ts | 5 + src/worker/sandbox-lease.ts | 84 +++++++++ src/worker/session-reservation-context.ts | 136 +++++++++++++++ tests/runtime-adapter.test.ts | 6 - tests/sandbox-lease.test.ts | 37 ++++ tests/session-reservation-context.test.ts | 97 +++++++++++ 8 files changed, 392 insertions(+), 171 deletions(-) create mode 100644 src/worker/duration.ts create mode 100644 src/worker/sandbox-lease.ts create mode 100644 src/worker/session-reservation-context.ts create mode 100644 tests/sandbox-lease.test.ts create mode 100644 tests/session-reservation-context.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a9174..ea22702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Extract provisioning-result compare-and-set persistence, pending-adapter fallback, event recording, and terminal finalization ordering. - Centralize interactive-session reservation row defaults, adapter preparation state, replay identity, and sandbox lease ownership. - Centralize interactive-session create request defaults, profile policy, capability selection, and descriptive fields. +- Centralize interactive-session reservation tokens, sandbox lease ownership, and runtime-adapter create identity. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 6bc22cd..1ea2322 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,6 +138,7 @@ import { publicDeploymentConfig, selectedRuntimeProfile, } from "./worker/deployment"; +import { clampedSeconds } from "./worker/duration"; import type { RuntimeEnv } from "./worker/env"; import { database, @@ -213,6 +214,19 @@ import { type RuntimeCapabilities, } from "./worker/session-model"; import { normalizeRepo } from "./worker/repositories"; +import { + isCurrentSandboxLease, + newSandboxLease, + sandboxIdForSession, + sandboxLeaseId, + sandboxLeaseInfo, + sandboxLeasePrefix, + sandboxLeaseRefreshStartedAt, + sandboxLeaseWithoutRefresh, + type SandboxCurrentLeaseFence, + type SandboxLease, + type SandboxLeaseRefreshFence, +} from "./worker/sandbox-lease"; import { readOpenClawRequestSession } from "./worker/openclaw-request"; import { activateInteractiveSessionReservation, @@ -269,6 +283,10 @@ import { resolveInteractiveSessionCreateRequest, type InteractiveSessionCreateRequest, } from "./worker/session-create-request"; +import { + createInteractiveSessionReservationContext, + newAgentToken, +} from "./worker/session-reservation-context"; import { configuredRuntimeAdapterControlPlane, runtimeAdapterConfigurationPresent, @@ -422,23 +440,6 @@ type SandboxRuntimeSession = (InteractiveProvisionRequest | InteractiveSession) githubToken?: string; }; -type SandboxLease = { - sandboxId: string; - terminalSessionId: string; -}; - -type SandboxLeaseRefreshFence = { - claim: string; - expiresAt: number; - refreshLeaseId: string | null; - sandboxId: string; -}; - -type SandboxCurrentLeaseFence = { - leaseId: string; - sandboxId: string; -}; - type StandaloneSandboxProvisionFence = { claim: string; provisionId: string; @@ -599,8 +600,6 @@ const terminalInputStates = new Map(); const sshLinkSeconds = 5 * 60; const terminalClipboardMaxBytes = 10 * 1024 * 1024; const lanes = ["Todo", "Running", "Human Review", "Done"]; -const sandboxLeasePrefix = "sandbox:"; -const sandboxLeaseProfile = "autostart-v4"; const activeRunStatuses: readonly RunStatus[] = ["queued", "leasing", "running"]; const runtimeOptions = ["auto", "container", "crabbox"] as const; const mergePolicyOptions = ["open_pr", "merge_when_green", "fix_until_green_and_merge"] as const; @@ -621,20 +620,6 @@ const credentialPolicyProvisioningStaleMs = 15 * 60_000; const credentialPolicyLegacyGenerationPrefix = "legacy:"; const credentialPolicyLegacyRepairClaimPrefix = "legacy-repair:"; const standaloneSandboxDefaultTtlSeconds = 14_400; -function runtimeAdapterCreateSettings( - env: RuntimeEnv, - capabilities: RuntimeCapabilities, -): { - ttlSeconds: number; - idleTimeoutSeconds: number; - capabilities: RuntimeCapabilities; -} { - return { - ttlSeconds: clampedSeconds(env.CRABBOX_RUNTIME_ADAPTER_TTL_SECONDS, 14_400), - idleTimeoutSeconds: clampedSeconds(env.CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS, 1_800), - capabilities, - }; -} const defaultSandboxEgressHosts = [ "api.github.com", @@ -3693,52 +3678,20 @@ async function createInteractiveSessionFromInput( let reservationInserted = false; const id = await nextInteractiveSessionId(env); const rootSessionId = lineage.rootSessionId ?? id; - const agentToken = newAgentToken(); - const initialAgentTokenHash = await sha256(agentToken); - const initialSandboxLease = runtime === "container" && env.SANDBOX ? newSandboxLease(id) : null; - const initialSandboxOwnership: SandboxCurrentLeaseFence | null = initialSandboxLease - ? { - leaseId: sandboxLeaseId(initialSandboxLease), - sandboxId: initialSandboxLease.sandboxId, - } - : null; - const adapterWorkspaceId = initialRuntimeAdapterWorkspaceId(env, runtime, id); - const adapterControlPlane = adapterWorkspaceId - ? configuredRuntimeAdapterControlPlane(env, profile) - : null; - const adapterSettings = adapterWorkspaceId - ? runtimeAdapterCreateSettings(env, requestedCapabilities) - : null; - const adapterCreatePayload = - adapterWorkspaceId && adapterSettings - ? runtimeAdapterCreatePayload( - { - namespace: normalizeAdapterNamespace( - env.CRABBOX_RUNTIME_ADAPTER_NAMESPACE ?? "", - ) as string, - id, - parentSessionId: lineage.parentSessionId, - rootSessionId, - repo, - branch, - runtime, - profile, - command, - prompt, - purpose, - summary, - owner, - createdBy, - ttlSeconds: adapterSettings.ttlSeconds, - idleTimeoutSeconds: adapterSettings.idleTimeoutSeconds, - desktop: adapterSettings.capabilities.desktop, - }, - adapterWorkspaceId, - ) - : null; - const adapterCreatePayloadJson = adapterCreatePayload - ? JSON.stringify(adapterCreatePayload) - : null; + const { + agentToken, + initialAgentTokenHash, + initialSandboxLease, + initialSandboxOwnership, + adapterWorkspaceId, + adapterControlPlane, + adapterSettings, + adapterCreatePayloadJson, + } = await createInteractiveSessionReservationContext(env, request, { + id, + parentSessionId: lineage.parentSessionId, + rootSessionId, + }); try { const reservationValues = buildInteractiveSessionReservationValues({ id, @@ -3899,25 +3852,6 @@ async function createInteractiveSessionFromInput( throw new Error("failed to allocate interactive session id"); } -function initialRuntimeAdapterWorkspaceId( - env: RuntimeEnv, - runtime: "crabbox" | "container", - sessionId: string, -): string | null { - if (!runtimeAdapterConfigurationPresent(env) || (runtime === "container" && env.SANDBOX)) { - return null; - } - const namespace = normalizeAdapterNamespace(env.CRABBOX_RUNTIME_ADAPTER_NAMESPACE ?? ""); - if (!namespace) { - throw serviceUnavailable( - "runtime adapter namespace is required and must be a DNS-safe label of at most 32 characters", - ); - } - const adapterWorkspaceId = namespacedAdapterWorkspaceId(namespace, sessionId); - if (!adapterWorkspaceId) throw serviceUnavailable("runtime adapter workspace id is invalid"); - return adapterWorkspaceId; -} - function requireRegisteredRuntimeAdapterControlPlane( env: RuntimeEnv, profile: string, @@ -4179,10 +4113,6 @@ function interactiveSessionCreationService( return new InteractiveSessionCreationService(store); } -function newAgentToken(): string { - return `${crypto.randomUUID()}${crypto.randomUUID()}`; -} - async function cleanupInteractiveSessions( request: Request, env: RuntimeEnv, @@ -12049,12 +11979,6 @@ function cloudflareRunnerInstanceType(env: RuntimeEnv): string { ); } -function clampedSeconds(value: string | undefined, fallback: number): number { - const parsed = Number(value); - if (!Number.isFinite(parsed)) return fallback; - return Math.min(86_400, Math.max(300, Math.trunc(parsed))); -} - async function createCard(request: Request, env: RuntimeEnv, user: User): Promise<{ card: Card }> { const body = await readJson<{ title?: string; @@ -14299,63 +14223,6 @@ function httpToWebSocketUrl(rawUrl: string): string { } } -function sandboxIdForSession(id: string): string { - return clean(`crabbox-${id}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"), 63); -} - -function newSandboxLease(id: string): { sandboxId: string; terminalSessionId: string } { - const suffix = crypto.randomUUID().slice(0, 8).toLowerCase(); - const base = sandboxIdForSession(id); - const sandboxId = `${base.slice(0, 63 - suffix.length - 1)}-${suffix}`; - return { - sandboxId, - terminalSessionId: sandboxTerminalSessionId(id, suffix), - }; -} - -function sandboxLeaseId(lease: { sandboxId: string; terminalSessionId: string }): string { - return `${sandboxLeasePrefix}${lease.sandboxId}:${lease.terminalSessionId}:${sandboxLeaseProfile}`; -} - -function isCurrentSandboxLease(leaseId: string | null | undefined): boolean { - return ( - leaseId?.startsWith(sandboxLeasePrefix) === true && leaseId.endsWith(`:${sandboxLeaseProfile}`) - ); -} - -function sandboxLeaseRefreshStartedAt(leaseId: string): number | null { - const match = /:refreshing-(\d+)-[a-f0-9]+$/.exec(leaseId); - return match ? Number(match[1]) : null; -} - -function sandboxLeaseWithoutRefresh(leaseId: string): string { - return leaseId.replace(/:refreshing-\d+-[a-f0-9]+$/, ""); -} - -function sandboxLeaseInfo( - session: Pick & { - leaseId?: string | null; - adapter?: string | null; - }, -): { sandboxId: string; terminalSessionId: string } { - const rawLease = legacyLeaseIdForAdapter(session.adapter ?? null, session.leaseId ?? null); - const raw = rawLease?.startsWith(sandboxLeasePrefix) - ? rawLease.slice(sandboxLeasePrefix.length) - : ""; - const [sandboxId, terminalSessionId] = raw.split(":"); - const fallbackSandboxId = clean(sandboxId, 80) || sandboxIdForSession(session.id); - return { - sandboxId: fallbackSandboxId, - terminalSessionId: clean(terminalSessionId, 100) || sandboxTerminalSessionId(session.id), - }; -} - -function sandboxTerminalSessionId(id: string, suffix?: string): string { - const base = clean(`terminal-${id}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"), 80); - if (!suffix) return base; - return `${base.slice(0, 80 - suffix.length - 1)}-${suffix}`; -} - function sandboxSetupSessionId(id: string): string { return clean(`setup-${id}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"), 80); } diff --git a/src/worker/duration.ts b/src/worker/duration.ts new file mode 100644 index 0000000..81df258 --- /dev/null +++ b/src/worker/duration.ts @@ -0,0 +1,5 @@ +export function clampedSeconds(value: string | undefined, fallback: number): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(86_400, Math.max(300, Math.trunc(parsed))); +} diff --git a/src/worker/sandbox-lease.ts b/src/worker/sandbox-lease.ts new file mode 100644 index 0000000..0d5f316 --- /dev/null +++ b/src/worker/sandbox-lease.ts @@ -0,0 +1,84 @@ +import { legacyLeaseIdForAdapter } from "../runtime-adapter.ts"; + +export const sandboxLeasePrefix = "sandbox:"; +export const sandboxLeaseProfile = "autostart-v4"; + +export type SandboxLease = { + sandboxId: string; + terminalSessionId: string; +}; + +export type SandboxLeaseRefreshFence = { + claim: string; + expiresAt: number; + refreshLeaseId: string | null; + sandboxId: string; +}; + +export type SandboxCurrentLeaseFence = { + leaseId: string; + sandboxId: string; +}; + +export type SandboxLeaseSource = { + id: string; + leaseId?: string | null; + adapter?: string | null; +}; + +export function sandboxIdForSession(id: string): string { + return clean(`crabbox-${id}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"), 63); +} + +export function newSandboxLease(id: string): SandboxLease { + const suffix = crypto.randomUUID().slice(0, 8).toLowerCase(); + const base = sandboxIdForSession(id); + const sandboxId = `${base.slice(0, 63 - suffix.length - 1)}-${suffix}`; + return { + sandboxId, + terminalSessionId: sandboxTerminalSessionId(id, suffix), + }; +} + +export function sandboxLeaseId(lease: SandboxLease): string { + return `${sandboxLeasePrefix}${lease.sandboxId}:${lease.terminalSessionId}:${sandboxLeaseProfile}`; +} + +export function isCurrentSandboxLease(leaseId: string | null | undefined): boolean { + return ( + leaseId?.startsWith(sandboxLeasePrefix) === true && leaseId.endsWith(`:${sandboxLeaseProfile}`) + ); +} + +export function sandboxLeaseRefreshStartedAt(leaseId: string): number | null { + const match = /:refreshing-(\d+)-[a-f0-9]+$/.exec(leaseId); + return match ? Number(match[1]) : null; +} + +export function sandboxLeaseWithoutRefresh(leaseId: string): string { + return leaseId.replace(/:refreshing-\d+-[a-f0-9]+$/, ""); +} + +export function sandboxLeaseInfo(source: SandboxLeaseSource): SandboxLease { + const rawLease = legacyLeaseIdForAdapter(source.adapter ?? null, source.leaseId ?? null); + const raw = rawLease?.startsWith(sandboxLeasePrefix) + ? rawLease.slice(sandboxLeasePrefix.length) + : ""; + const [sandboxId, terminalSessionId] = raw.split(":"); + return { + sandboxId: clean(sandboxId, 80) || sandboxIdForSession(source.id), + terminalSessionId: clean(terminalSessionId, 100) || sandboxTerminalSessionId(source.id), + }; +} + +export function sandboxTerminalSessionId(id: string, suffix?: string): string { + const base = clean(`terminal-${id}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"), 80); + if (!suffix) return base; + return `${base.slice(0, 80 - suffix.length - 1)}-${suffix}`; +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/src/worker/session-reservation-context.ts b/src/worker/session-reservation-context.ts new file mode 100644 index 0000000..c2ad197 --- /dev/null +++ b/src/worker/session-reservation-context.ts @@ -0,0 +1,136 @@ +import { + namespacedAdapterWorkspaceId, + normalizeAdapterNamespace, + runtimeAdapterCreatePayload, +} from "../runtime-adapter.ts"; +import { sha256 } from "./crypto.ts"; +import { clampedSeconds } from "./duration.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { serviceUnavailable } from "./http.ts"; +import { + newSandboxLease, + sandboxLeaseId, + type SandboxCurrentLeaseFence, + type SandboxLease, +} from "./sandbox-lease.ts"; +import type { ResolvedInteractiveSessionCreateRequest } from "./session-create-request.ts"; +import { + configuredRuntimeAdapterControlPlane, + runtimeAdapterConfigurationPresent, +} from "./runtime-adapter-preflight.ts"; +import type { RuntimeCapabilities } from "./session-model.ts"; + +export type RuntimeAdapterReservationSettings = { + ttlSeconds: number; + idleTimeoutSeconds: number; + capabilities: RuntimeCapabilities; +}; + +export type InteractiveSessionReservationContext = { + agentToken: string; + initialAgentTokenHash: string; + initialSandboxLease: SandboxLease | null; + initialSandboxOwnership: SandboxCurrentLeaseFence | null; + adapterWorkspaceId: string | null; + adapterControlPlane: string | null; + adapterSettings: RuntimeAdapterReservationSettings | null; + adapterCreatePayloadJson: string | null; +}; + +export async function createInteractiveSessionReservationContext( + env: RuntimeEnv, + request: ResolvedInteractiveSessionCreateRequest, + session: { + id: string; + parentSessionId: string | null; + rootSessionId: string; + }, +): Promise { + const agentToken = newAgentToken(); + const initialAgentTokenHash = await sha256(agentToken); + const initialSandboxLease = + request.runtime === "container" && env.SANDBOX ? newSandboxLease(session.id) : null; + const initialSandboxOwnership: SandboxCurrentLeaseFence | null = initialSandboxLease + ? { + leaseId: sandboxLeaseId(initialSandboxLease), + sandboxId: initialSandboxLease.sandboxId, + } + : null; + const adapterIdentity = initialRuntimeAdapterIdentity(env, request.runtime, session.id); + const adapterWorkspaceId = adapterIdentity?.workspaceId ?? null; + const adapterControlPlane = adapterWorkspaceId + ? configuredRuntimeAdapterControlPlane(env, request.profile) + : null; + const adapterSettings = adapterWorkspaceId + ? runtimeAdapterCreateSettings(env, request.requestedCapabilities) + : null; + const adapterCreatePayload = + adapterIdentity && adapterSettings + ? runtimeAdapterCreatePayload( + { + namespace: adapterIdentity.namespace, + id: session.id, + parentSessionId: session.parentSessionId, + rootSessionId: session.rootSessionId, + repo: request.repo, + branch: request.branch, + runtime: request.runtime, + profile: request.profile, + command: request.command, + prompt: request.prompt, + purpose: request.purpose, + summary: request.summary, + owner: request.owner, + createdBy: request.createdBy, + ttlSeconds: adapterSettings.ttlSeconds, + idleTimeoutSeconds: adapterSettings.idleTimeoutSeconds, + desktop: adapterSettings.capabilities.desktop, + }, + adapterIdentity.workspaceId, + ) + : null; + return { + agentToken, + initialAgentTokenHash, + initialSandboxLease, + initialSandboxOwnership, + adapterWorkspaceId, + adapterControlPlane, + adapterSettings, + adapterCreatePayloadJson: adapterCreatePayload ? JSON.stringify(adapterCreatePayload) : null, + }; +} + +export function newAgentToken(): string { + return `${crypto.randomUUID()}${crypto.randomUUID()}`; +} + +function initialRuntimeAdapterIdentity( + env: RuntimeEnv, + runtime: "crabbox" | "container", + sessionId: string, +): { namespace: string; workspaceId: string } | null { + if (!runtimeAdapterConfigurationPresent(env) || (runtime === "container" && env.SANDBOX)) { + return null; + } + const namespace = normalizeAdapterNamespace(env.CRABBOX_RUNTIME_ADAPTER_NAMESPACE ?? ""); + if (!namespace) { + throw serviceUnavailable( + "runtime adapter namespace is required and must be a DNS-safe label of at most 32 characters", + ); + } + const workspaceId = namespacedAdapterWorkspaceId(namespace, sessionId); + if (!workspaceId) throw serviceUnavailable("runtime adapter workspace id is invalid"); + return { namespace, workspaceId }; +} + +function runtimeAdapterCreateSettings( + env: RuntimeEnv, + capabilities: RuntimeCapabilities, +): RuntimeAdapterReservationSettings { + return { + ttlSeconds: clampedSeconds(env.CRABBOX_RUNTIME_ADAPTER_TTL_SECONDS, 14_400), + idleTimeoutSeconds: clampedSeconds(env.CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS, 1_800), + capabilities, + }; +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 468ba74..dcfd3e0 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -1301,9 +1301,6 @@ test("Sandbox credential registration always proves exact durable ownership", as const ownerStart = source.indexOf("function sandboxManagedOwnershipCondition"); const ownerEnd = source.indexOf("async function abandonSandboxCredentialPolicyRegistration"); const ownerSource = source.slice(ownerStart, ownerEnd); - const createStart = source.indexOf("async function createInteractiveSessionFromInput"); - const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); - const createSource = source.slice(createStart, createEnd); const ensureStart = source.indexOf("async function ensureSandboxCredentialPolicy"); const ensureEnd = source.indexOf("async function recordSandboxCredentialPolicyRefs", ensureStart); const ensureSource = source.slice(ensureStart, ensureEnd); @@ -1317,9 +1314,6 @@ test("Sandbox credential registration always proves exact durable ownership", as assert.match(ownerSource, /lease_id = \$\{ownershipFence\.leaseId\}/); assert.match(ownerSource, /sandbox_refresh_claim = \$\{ownershipFence\.claim\}/); assert.match(ownerSource, /AND \$\{sandboxId\} = \$\{ownershipFence\.sandboxId\}/); - assert.match(createSource, /const initialSandboxLease/); - assert.match(createSource, /const initialAgentTokenHash = await sha256\(agentToken\)/); - assert.match(createSource, /ownership: initialSandboxOwnership/); assert.match(ensureSource, /sandboxCurrentLeaseFence|SandboxCurrentLeaseFence/); assert.match(ensureSource, /credentialPolicyLegacyGenerationPrefix/); assert.match(ensureSource, /repairLegacySandboxCredentialPolicy/); diff --git a/tests/sandbox-lease.test.ts b/tests/sandbox-lease.test.ts new file mode 100644 index 0000000..ef672ce --- /dev/null +++ b/tests/sandbox-lease.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isCurrentSandboxLease, + newSandboxLease, + sandboxIdForSession, + sandboxLeaseId, + sandboxLeaseInfo, + sandboxLeaseRefreshStartedAt, + sandboxLeaseWithoutRefresh, +} from "../src/worker/sandbox-lease.ts"; + +test("sandbox leases centralize managed identity and adapter filtering", () => { + const lease = newSandboxLease("IS-101"); + const leaseId = sandboxLeaseId(lease); + + assert.match(lease.sandboxId, /^crabbox-is-101-[a-f0-9]{8}$/); + assert.match(lease.terminalSessionId, /^terminal-is-101-[a-f0-9]{8}$/); + assert.equal(isCurrentSandboxLease(leaseId), true); + assert.deepEqual(sandboxLeaseInfo({ id: "IS-101", leaseId }), lease); + assert.deepEqual(sandboxLeaseInfo({ id: "IS-101", leaseId, adapter: "runtime-v1" }), { + sandboxId: "crabbox-is-101", + terminalSessionId: "terminal-is-101", + }); + assert.equal(sandboxIdForSession("IS/101"), "crabbox-is-101"); +}); + +test("sandbox lease refresh markers preserve the terminal lease identity", () => { + const leaseId = "sandbox:crabbox-is-101:terminal-is-101:autostart-v4:refreshing-1234-deadbeef"; + assert.equal(sandboxLeaseRefreshStartedAt(leaseId), 1234); + assert.equal( + sandboxLeaseWithoutRefresh(leaseId), + "sandbox:crabbox-is-101:terminal-is-101:autostart-v4", + ); + assert.equal(sandboxLeaseRefreshStartedAt("sandbox:plain"), null); +}); diff --git a/tests/session-reservation-context.test.ts b/tests/session-reservation-context.test.ts new file mode 100644 index 0000000..280680c --- /dev/null +++ b/tests/session-reservation-context.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sha256 } from "../src/worker/crypto.ts"; +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { sandboxLeaseId } from "../src/worker/sandbox-lease.ts"; +import type { ResolvedInteractiveSessionCreateRequest } from "../src/worker/session-create-request.ts"; +import { createInteractiveSessionReservationContext } from "../src/worker/session-reservation-context.ts"; +import { containerCapabilities, crabboxCapabilities } from "../src/worker/session-model.ts"; + +function request( + values: Partial = {}, +): ResolvedInteractiveSessionCreateRequest { + return { + repo: "openclaw/crabfleet", + branch: "main", + runtime: "container", + profile: "default", + requestedCapabilities: containerCapabilities, + command: "codex --yolo", + prompt: "fix the issue", + purpose: "fix the issue", + summary: "starting", + owner: "maintainer", + createdBy: "service:openclaw", + ...values, + }; +} + +test("reservation contexts bind agent tokens to initial sandbox ownership", async () => { + const context = await createInteractiveSessionReservationContext( + { SANDBOX: {} as DurableObjectNamespace } as RuntimeEnv, + request(), + { id: "IS-2", parentSessionId: "IS-1", rootSessionId: "IS-1" }, + ); + + assert.equal(context.initialAgentTokenHash, await sha256(context.agentToken)); + assert.ok(context.initialSandboxLease); + assert.deepEqual(context.initialSandboxOwnership, { + leaseId: sandboxLeaseId(context.initialSandboxLease), + sandboxId: context.initialSandboxLease.sandboxId, + }); + assert.equal(context.adapterWorkspaceId, null); + assert.equal(context.adapterControlPlane, null); + assert.equal(context.adapterSettings, null); + assert.equal(context.adapterCreatePayloadJson, null); +}); + +test("reservation contexts persist exact runtime-adapter creation identity", async () => { + const context = await createInteractiveSessionReservationContext( + { + CRABBOX_RUNTIME_ADAPTER_URL: "https://adapter.example.test", + CRABBOX_RUNTIME_ADAPTER_TOKEN: "server-secret", + CRABBOX_RUNTIME_ADAPTER_NAMESPACE: "fleet", + CRABBOX_RUNTIME_ADAPTER_TTL_SECONDS: "60", + CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS: "999999", + } as RuntimeEnv, + request({ + runtime: "crabbox", + requestedCapabilities: crabboxCapabilities, + }), + { id: "IS-2", parentSessionId: "IS-1", rootSessionId: "IS-1" }, + ); + + assert.equal(context.initialSandboxLease, null); + assert.equal(context.initialSandboxOwnership, null); + assert.equal(context.adapterWorkspaceId, "fleet-is-2"); + assert.equal(context.adapterControlPlane, "https://adapter.example.test/"); + assert.deepEqual(context.adapterSettings, { + ttlSeconds: 300, + idleTimeoutSeconds: 86_400, + capabilities: crabboxCapabilities, + }); + const payload = JSON.parse(context.adapterCreatePayloadJson ?? "{}") as Record; + assert.equal(payload.id, "fleet-is-2"); + assert.equal(payload.parentSessionId, "IS-1"); + assert.equal(payload.rootSessionId, "IS-1"); + assert.equal(payload.profile, "default"); + assert.equal(payload.owner, "maintainer"); + assert.equal(payload.createdBy, "service:openclaw"); + assert.equal("token" in payload, false); +}); + +test("reservation contexts reject invalid runtime-adapter namespaces", async () => { + await assert.rejects( + createInteractiveSessionReservationContext( + { + CRABBOX_RUNTIME_ADAPTER_URL: "https://adapter.example.test", + CRABBOX_RUNTIME_ADAPTER_TOKEN: "server-secret", + CRABBOX_RUNTIME_ADAPTER_NAMESPACE: "INVALID_NAMESPACE", + } as RuntimeEnv, + request({ runtime: "crabbox", requestedCapabilities: crabboxCapabilities }), + { id: "IS-2", parentSessionId: null, rootSessionId: "IS-2" }, + ), + /runtime adapter namespace is required/, + ); +}); From 0eb64264fd879adce9372c0b09a9c475246be480 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:43:13 +0100 Subject: [PATCH 024/109] refactor: recover superseded provisions --- CHANGELOG.md | 1 + src/index.ts | 53 +++++------- src/worker/session-creation.ts | 49 +++++++++++ tests/session-creation.test.ts | 145 +++++++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea22702..cbf329f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Centralize interactive-session reservation row defaults, adapter preparation state, replay identity, and sandbox lease ownership. - Centralize interactive-session create request defaults, profile policy, capability selection, and descriptive fields. - Centralize interactive-session reservation tokens, sandbox lease ownership, and runtime-adapter create identity. +- Extract superseded runtime-adapter and Sandbox provision recovery from session creation. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 1ea2322..244acfd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3788,41 +3788,15 @@ async function createInteractiveSessionFromInput( provisioned, ); if (!provisionPersistence.updated && provisioned) { - let current = await readInteractiveSession(env, id); - const currentAdapterProvision = Boolean( - current && - current.adapter === runtimeAdapterName && - current.adapterWorkspaceId === provisioned.adapterWorkspaceId && - ["provisioning", "pending_adapter", "ready", "attached", "detached"].includes( - current.status, - ), + const current = await creation.recoverSupersededProvision( + { + sessionId: id, + adapterName: runtimeAdapterName, + sandboxLeasePrefix, + now: Date.now(), + }, + provisioned, ); - if ( - !currentAdapterProvision && - provisioned.adapter === runtimeAdapterName && - provisioned.adapterWorkspaceId - ) { - await stopSupersededRuntimeAdapterProvision( - env, - id, - provisioned.adapterWorkspaceId, - provisioned.createPending === true, - Date.now(), - ); - } - if ( - provisioned.adapter !== runtimeAdapterName && - provisioned.leaseId?.startsWith(sandboxLeasePrefix) - ) { - await queueSandboxCredentialPolicyCleanup( - env, - id, - sandboxLeaseInfo({ id, leaseId: provisioned.leaseId }).sandboxId, - ); - await reconcileCredentialPolicyCleanupBatch(env, Date.now(), id); - current = await readInteractiveSession(env, id); - } - if (!current) throw new Error("interactive session disappeared during provisioning"); return { session: decorateInteractiveSession(current, user, env) }; } await audit( @@ -4109,6 +4083,17 @@ function interactiveSessionCreationService( appendInteractiveSessionEvent(env, sessionId, user, message, now), finalizeTerminal: (sessionId, status, now) => finalizeTerminalInteractiveSession(env, sessionId, status, now), + readSession: (sessionId) => readInteractiveSession(env, sessionId), + stopSupersededAdapter: (sessionId, adapterWorkspaceId, createPending, now) => + stopSupersededRuntimeAdapterProvision(env, sessionId, adapterWorkspaceId, createPending, now), + cleanupSupersededSandbox: async (sessionId, leaseId) => { + await queueSandboxCredentialPolicyCleanup( + env, + sessionId, + sandboxLeaseInfo({ id: sessionId, leaseId }).sandboxId, + ); + await reconcileCredentialPolicyCleanupBatch(env, Date.now(), sessionId); + }, }; return new InteractiveSessionCreationService(store); } diff --git a/src/worker/session-creation.ts b/src/worker/session-creation.ts index 15a61b1..71f7f27 100644 --- a/src/worker/session-creation.ts +++ b/src/worker/session-creation.ts @@ -13,6 +13,13 @@ export type InteractiveSessionCreationReservation = { adapterWorkspaceId: string | null; }; +export type InteractiveSessionProvisionRecoveryInput = { + sessionId: string; + adapterName: string; + sandboxLeasePrefix: string; + now: number; +}; + export type InteractiveSessionCreationStore = { enforceSupervision( rootSessionId: string, @@ -44,6 +51,14 @@ export type InteractiveSessionCreationStore = { status: "stopped" | "expired" | "failed", now: number, ): Promise; + readSession(sessionId: string): Promise; + stopSupersededAdapter( + sessionId: string, + adapterWorkspaceId: string, + createPending: boolean, + now: number, + ): Promise; + cleanupSupersededSandbox(sessionId: string, leaseId: string): Promise; }; export class InteractiveSessionCreationService { @@ -140,4 +155,38 @@ export class InteractiveSessionCreationService { } return persisted; } + + async recoverSupersededProvision( + input: InteractiveSessionProvisionRecoveryInput, + result: InteractiveProvisionResult, + ): Promise { + let current = await this.store.readSession(input.sessionId); + const currentAdapterProvision = Boolean( + current && + current.adapter === input.adapterName && + current.adapterWorkspaceId === result.adapterWorkspaceId && + ["provisioning", "pending_adapter", "ready", "attached", "detached"].includes(current.status), + ); + if ( + !currentAdapterProvision && + result.adapter === input.adapterName && + result.adapterWorkspaceId + ) { + await this.store.stopSupersededAdapter( + input.sessionId, + result.adapterWorkspaceId, + result.createPending === true, + input.now, + ); + } + if ( + result.adapter !== input.adapterName && + result.leaseId?.startsWith(input.sandboxLeasePrefix) + ) { + await this.store.cleanupSupersededSandbox(input.sessionId, result.leaseId); + current = await this.store.readSession(input.sessionId); + } + if (!current) throw new Error("interactive session disappeared during provisioning"); + return current; + } } diff --git a/tests/session-creation.test.ts b/tests/session-creation.test.ts index cbce175..48bb318 100644 --- a/tests/session-creation.test.ts +++ b/tests/session-creation.test.ts @@ -36,6 +36,9 @@ function creationStore( markPendingAdapter: async () => undefined, recordProvisionEvent: async () => undefined, finalizeTerminal: async () => undefined, + readSession: async () => null, + stopSupersededAdapter: async () => undefined, + cleanupSupersededSandbox: async () => undefined, ...overrides, }; } @@ -281,3 +284,145 @@ test("session creation records pending adapters without finalization", async () assert.deepEqual(result, { updated: true, terminalStatus: null, terminalAt: 101 }); assert.deepEqual(calls, ["pending", "event:waiting for interactive runtime adapter:101"]); }); + +test("session creation preserves the currently owned adapter provision", async () => { + const current = session({ + id: "IS-2", + status: "ready", + adapter: "runtime-v1", + adapter_workspace_id: "workspace-current", + }); + const calls: string[] = []; + const service = new InteractiveSessionCreationService( + creationStore({ + readSession: async () => current, + stopSupersededAdapter: async () => { + calls.push("stop"); + }, + }), + ); + + assert.equal( + await service.recoverSupersededProvision( + { + sessionId: "IS-2", + adapterName: "runtime-v1", + sandboxLeasePrefix: "sandbox:", + now: 150, + }, + { + status: "ready", + leaseId: null, + attachUrl: null, + vncUrl: null, + message: "ready", + adapter: "runtime-v1", + adapterWorkspaceId: "workspace-current", + }, + ), + current, + ); + assert.deepEqual(calls, []); +}); + +test("session creation stops superseded adapter workspaces", async () => { + const current = session({ + id: "IS-2", + status: "ready", + adapter: "runtime-v1", + adapter_workspace_id: "workspace-current", + }); + const calls: string[] = []; + const service = new InteractiveSessionCreationService( + creationStore({ + readSession: async () => current, + stopSupersededAdapter: async (sessionId, workspaceId, createPending, now) => { + calls.push(`stop:${sessionId}:${workspaceId}:${createPending}:${now}`); + }, + }), + ); + + assert.equal( + await service.recoverSupersededProvision( + { + sessionId: "IS-2", + adapterName: "runtime-v1", + sandboxLeasePrefix: "sandbox:", + now: 150, + }, + { + status: "ready", + leaseId: null, + attachUrl: null, + vncUrl: null, + message: "late create", + adapter: "runtime-v1", + adapterWorkspaceId: "workspace-late", + createPending: true, + }, + ), + current, + ); + assert.deepEqual(calls, ["stop:IS-2:workspace-late:true:150"]); +}); + +test("session creation cleans superseded sandbox ownership and rereads durability", async () => { + const before = session({ id: "IS-2", status: "stopping" }); + const after = session({ id: "IS-2", status: "failed" }); + let reads = 0; + const calls: string[] = []; + const service = new InteractiveSessionCreationService( + creationStore({ + readSession: async () => { + reads += 1; + return reads === 1 ? before : after; + }, + cleanupSupersededSandbox: async (sessionId, leaseId) => { + calls.push(`cleanup:${sessionId}:${leaseId}`); + }, + }), + ); + + assert.equal( + await service.recoverSupersededProvision( + { + sessionId: "IS-2", + adapterName: "runtime-v1", + sandboxLeasePrefix: "sandbox:", + now: 150, + }, + { + status: "ready", + leaseId: "sandbox:late", + attachUrl: null, + vncUrl: null, + message: "late sandbox", + }, + ), + after, + ); + assert.deepEqual(calls, ["cleanup:IS-2:sandbox:late"]); + assert.equal(reads, 2); +}); + +test("session creation fails explicitly when durable ownership disappears", async () => { + const service = new InteractiveSessionCreationService(creationStore()); + await assert.rejects( + service.recoverSupersededProvision( + { + sessionId: "IS-2", + adapterName: "runtime-v1", + sandboxLeasePrefix: "sandbox:", + now: 150, + }, + { + status: "ready", + leaseId: null, + attachUrl: null, + vncUrl: null, + message: "ready", + }, + ), + /interactive session disappeared during provisioning/, + ); +}); From 1c560fdaab718b12003ca965b1c76a6596601657 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:46:45 +0100 Subject: [PATCH 025/109] refactor: extract session event repository --- CHANGELOG.md | 1 + src/index.ts | 77 ++------------------ src/worker/session-repository.ts | 79 +++++++++++++++++++- tests/session-repository.test.ts | 119 +++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf329f..9bcbf36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Centralize interactive-session create request defaults, profile policy, capability selection, and descriptive fields. - Centralize interactive-session reservation tokens, sandbox lease ownership, and runtime-adapter create identity. - Extract superseded runtime-adapter and Sandbox provision recovery from session creation. +- Move bounded interactive-session logs, event pagination/counts, and archive reads into the session repository. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 244acfd..ea1b7c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -205,7 +205,6 @@ import { interactiveSession, interactiveSessionAdapterControlPlane, interactiveSessionEvent, - interactiveSessionLogArchive, runtimeCapabilities, type InteractiveSession, type InteractiveSessionEvent, @@ -269,9 +268,13 @@ import { } from "./worker/session-creation"; import { buildInteractiveSessionReservationValues, + countInteractiveSessionEvents, insertInteractiveSessionReservation, markInteractiveSessionPendingAdapter, persistInteractiveSessionProvisionResult, + readInteractiveSessionEventRows, + readInteractiveSessionLogArchives, + readInteractiveSessionLogs, readVisibleInteractiveSessionRow, readVisibleInteractiveSessionRows, } from "./worker/session-repository"; @@ -13007,78 +13010,6 @@ async function githubActionsRunnerPty( }); } -async function readInteractiveSessionLogs( - env: RuntimeEnv, - ids: string[], -): Promise> { - const uniqueIds = [...new Set(ids)].filter(Boolean); - if (!uniqueIds.length) return new Map(); - const eventRows = ( - await sql<{ session_id: string; message: string; created_at: number }>` - SELECT session_id, message, created_at - FROM ( - SELECT session_id, message, created_at, id, - row_number() OVER (PARTITION BY session_id ORDER BY created_at DESC, id DESC) AS rank - FROM interactive_session_events - WHERE session_id IN (${sql.join(uniqueIds)}) - ) - WHERE rank <= 80 - ORDER BY session_id ASC, created_at ASC, id ASC - `.execute(database(env)) - ).rows; - const logs = new Map(); - for (const row of eventRows) { - const line = `${new Date(row.created_at).toLocaleTimeString("en-GB")} ${row.message}`; - logs.set(row.session_id, [...(logs.get(row.session_id) ?? []), line]); - } - return logs; -} - -async function readInteractiveSessionLogArchives( - env: RuntimeEnv, - ids: string[], -): Promise> { - const uniqueIds = [...new Set(ids)].filter(Boolean); - if (!uniqueIds.length) return new Map(); - const rows = await database(env) - .selectFrom("interactive_session_log_archives") - .selectAll() - .where("session_id", "in", uniqueIds) - .execute(); - return new Map(rows.map((row) => [row.session_id, interactiveSessionLogArchive(row)])); -} - -async function readInteractiveSessionEventRows( - env: RuntimeEnv, - id: string, - options: { limit?: number; newest?: boolean } = {}, -): Promise { - const limit = options.limit ? Math.max(1, Math.min(10000, Math.floor(options.limit))) : 0; - const base = database(env) - .selectFrom("interactive_session_events") - .selectAll() - .where("session_id", "=", id); - if (limit && options.newest) { - const rows = await base - .orderBy("created_at", "desc") - .orderBy("id", "desc") - .limit(limit) - .execute(); - return rows.reverse(); - } - const ordered = base.orderBy("created_at", "asc").orderBy("id", "asc"); - return (limit ? ordered.limit(limit) : ordered).execute(); -} - -async function countInteractiveSessionEvents(env: RuntimeEnv, id: string): Promise { - const row = await database(env) - .selectFrom("interactive_session_events") - .select(({ fn }) => fn.countAll().as("count")) - .where("session_id", "=", id) - .executeTakeFirst(); - return Number(row?.count ?? 0); -} - async function appendInteractiveSessionEvent( env: RuntimeEnv, id: string, diff --git a/src/worker/session-repository.ts b/src/worker/session-repository.ts index 9f126bc..1c045a1 100644 --- a/src/worker/session-repository.ts +++ b/src/worker/session-repository.ts @@ -12,7 +12,12 @@ import type { InteractiveProvisionPersistenceInput, InteractiveProvisionResult, } from "./session-provisioning.ts"; -import type { RuntimeCapabilities } from "./session-model.ts"; +import { + interactiveSessionLogArchive, + type InteractiveSessionEventRow, + type InteractiveSessionLogArchive, + type RuntimeCapabilities, +} from "./session-model.ts"; export type InteractiveSessionReplayReservation = { requestId: string; @@ -152,6 +157,78 @@ export async function readVisibleInteractiveSessionRow( ); } +export async function readInteractiveSessionLogs( + env: RuntimeEnv, + ids: string[], +): Promise> { + const uniqueIds = [...new Set(ids)].filter(Boolean); + if (!uniqueIds.length) return new Map(); + const eventRows = ( + await sql<{ session_id: string; message: string; created_at: number }>` + SELECT session_id, message, created_at + FROM ( + SELECT session_id, message, created_at, id, + row_number() OVER (PARTITION BY session_id ORDER BY created_at DESC, id DESC) AS rank + FROM interactive_session_events + WHERE session_id IN (${sql.join(uniqueIds)}) + ) + WHERE rank <= 80 + ORDER BY session_id ASC, created_at ASC, id ASC + `.execute(database(env)) + ).rows; + const logs = new Map(); + for (const row of eventRows) { + const line = `${new Date(row.created_at).toLocaleTimeString("en-GB")} ${row.message}`; + logs.set(row.session_id, [...(logs.get(row.session_id) ?? []), line]); + } + return logs; +} + +export async function readInteractiveSessionLogArchives( + env: RuntimeEnv, + ids: string[], +): Promise> { + const uniqueIds = [...new Set(ids)].filter(Boolean); + if (!uniqueIds.length) return new Map(); + const rows = await database(env) + .selectFrom("interactive_session_log_archives") + .selectAll() + .where("session_id", "in", uniqueIds) + .execute(); + return new Map(rows.map((row) => [row.session_id, interactiveSessionLogArchive(row)])); +} + +export async function readInteractiveSessionEventRows( + env: RuntimeEnv, + id: string, + options: { limit?: number; newest?: boolean } = {}, +): Promise { + const limit = options.limit ? Math.max(1, Math.min(10000, Math.floor(options.limit))) : 0; + const base = database(env) + .selectFrom("interactive_session_events") + .selectAll() + .where("session_id", "=", id); + if (limit && options.newest) { + const rows = await base + .orderBy("created_at", "desc") + .orderBy("id", "desc") + .limit(limit) + .execute(); + return rows.reverse(); + } + const ordered = base.orderBy("created_at", "asc").orderBy("id", "asc"); + return (limit ? ordered.limit(limit) : ordered).execute(); +} + +export async function countInteractiveSessionEvents(env: RuntimeEnv, id: string): Promise { + const row = await database(env) + .selectFrom("interactive_session_events") + .select(({ fn }) => fn.countAll().as("count")) + .where("session_id", "=", id) + .executeTakeFirst(); + return Number(row?.count ?? 0); +} + export async function insertInteractiveSessionReservation( env: RuntimeEnv, values: InteractiveSessionReservationValues, diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts index b075e93..727d425 100644 --- a/tests/session-repository.test.ts +++ b/tests/session-repository.test.ts @@ -4,9 +4,13 @@ import test from "node:test"; import type { RuntimeEnv } from "../src/worker/env.ts"; import { buildInteractiveSessionReservationValues, + countInteractiveSessionEvents, insertInteractiveSessionReservation, markInteractiveSessionPendingAdapter, persistInteractiveSessionProvisionResult, + readInteractiveSessionEventRows, + readInteractiveSessionLogArchives, + readInteractiveSessionLogs, readVisibleInteractiveSessionRow, readVisibleInteractiveSessionRows, } from "../src/worker/session-repository.ts"; @@ -139,6 +143,121 @@ test("visible session reads exclude preparation reservations and stay bounded", assert.equal(calls, 2); }); +test("session log reads keep the newest bounded window in chronological order", async () => { + let queries = 0; + const logs = await readInteractiveSessionLogs( + runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + queries += 1; + assert.match(sql, /row_number\(\) over/i); + assert.match(sql, /rank <= 80/i); + assert.match(sql, /order by session_id asc, created_at asc, id asc/i); + assert.deepEqual(parameters, ["IS-2", "IS-3"]); + return { + results: [ + { session_id: "IS-2", message: "requested", created_at: 0 }, + { session_id: "IS-2", message: "ready", created_at: 1000 }, + { session_id: "IS-3", message: "requested", created_at: 2000 }, + ], + }; + }), + ["IS-2", "IS-2", "", "IS-3"], + ); + + assert.equal(queries, 1); + assert.equal(logs.get("IS-2")?.length, 2); + assert.match(logs.get("IS-2")?.[0] ?? "", /requested$/); + assert.match(logs.get("IS-2")?.[1] ?? "", /ready$/); + assert.match(logs.get("IS-3")?.[0] ?? "", /requested$/); + assert.deepEqual( + await readInteractiveSessionLogs( + runtimeEnv(() => ({})), + [], + ), + new Map(), + ); +}); + +test("session archive reads map persistence rows by session", async () => { + const archives = await readInteractiveSessionLogArchives( + runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + assert.match(sql, /from "interactive_session_log_archives"/i); + assert.deepEqual(parameters, ["IS-2"]); + return { + results: [ + { + session_id: "IS-2", + event_count: 3, + events_key: "events.json", + transcript_key: "transcript.md", + summary_key: "summary.json", + archived_at: 100, + updated_at: 110, + session_updated_at: 105, + }, + ], + }; + }), + ["IS-2", "IS-2"], + ); + + assert.deepEqual(archives.get("IS-2"), { + sessionId: "IS-2", + eventCount: 3, + eventsKey: "events.json", + transcriptKey: "transcript.md", + summaryKey: "summary.json", + archivedAt: 100, + updatedAt: 110, + }); +}); + +test("session event reads clamp newest windows and restore chronological order", async () => { + const rows = [ + { id: 3, session_id: "IS-2", actor: "agent", message: "third", created_at: 30 }, + { id: 2, session_id: "IS-2", actor: "agent", message: "second", created_at: 20 }, + ]; + const events = await readInteractiveSessionEventRows( + runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + assert.match(sql, /order by "created_at" desc, "id" desc/i); + assert.match(sql, /limit/i); + assert.deepEqual(parameters, ["IS-2", 10000]); + return { results: rows }; + }), + "IS-2", + { limit: 50_000, newest: true }, + ); + + assert.deepEqual( + events.map((event) => event.id), + [2, 3], + ); +}); + +test("session event counts normalize missing and persisted values", async () => { + assert.equal( + await countInteractiveSessionEvents( + runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + assert.match(sql, /count\(\*\)/i); + assert.deepEqual(parameters, ["IS-2"]); + return { results: [{ count: 7 }] }; + }), + "IS-2", + ), + 7, + ); + assert.equal( + await countInteractiveSessionEvents( + runtimeEnv(() => ({ results: [] })), + "IS-3", + ), + 0, + ); +}); + test("session reservation inserts preparation and request identity in one batch", async () => { let batch: PreparedStatement[] = []; const values = sessionRow({ From 770fcde0890913280a0306be681bf27af5c2aebc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:51:00 +0100 Subject: [PATCH 026/109] refactor: centralize shared session policy --- CHANGELOG.md | 1 + src/index.ts | 41 ++----------------- src/worker/session-repository.ts | 15 +++++++ src/worker/session-sharing.ts | 35 ++++++++++++++++ tests/session-repository.test.ts | 20 +++++++++ tests/session-sharing.test.ts | 69 ++++++++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 37 deletions(-) create mode 100644 src/worker/session-sharing.ts create mode 100644 tests/session-sharing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bcbf36..1db8277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Centralize interactive-session reservation tokens, sandbox lease ownership, and runtime-adapter create identity. - Extract superseded runtime-adapter and Sandbox provision recovery from session creation. - Move bounded interactive-session logs, event pagination/counts, and archive reads into the session repository. +- Centralize shared-session visibility and redaction of provider, terminal, lease, reconciliation, and control authority. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index ea1b7c2..92a1317 100644 --- a/src/index.ts +++ b/src/index.ts @@ -275,9 +275,11 @@ import { readInteractiveSessionEventRows, readInteractiveSessionLogArchives, readInteractiveSessionLogs, + readSharedInteractiveSessionRow, readVisibleInteractiveSessionRow, readVisibleInteractiveSessionRows, } from "./worker/session-repository"; +import { activeDelegatedController, sharedInteractiveSession } from "./worker/session-sharing"; import type { InteractiveProvisionResult } from "./worker/session-provisioning"; import { interactiveCommand, @@ -12762,42 +12764,13 @@ async function readSharedInteractiveSession( id: string, token: string, ): Promise<{ session: InteractiveSession }> { - const row = await database(env) - .selectFrom("interactive_sessions") - .selectAll() - .where("id", "=", id) - .where("preparation_pending", "=", 0) - .where("share_mode", "=", "link_read") - .executeTakeFirst(); + const row = await readSharedInteractiveSessionRow(env, id); if (!row || !row.share_token_hash || !token) throw notFound("shared session not found"); if ((await sha256(token)) !== row.share_token_hash) throw notFound("shared session not found"); const logs = await readInteractiveSessionLogs(env, [id]); const archives = await readInteractiveSessionLogArchives(env, [id]); const session = interactiveSession(row, logs.get(id) ?? [], archives.get(id) ?? null); - const activeController = activeDelegatedController(session, Date.now()); - return { - session: { - ...session, - adapter: null, - profile: "", - adapterWorkspaceId: null, - providerResourceId: null, - lastReconciledAt: null, - reconcileError: null, - leaseId: null, - attachUrl: null, - vncUrl: null, - ptyAvailable: false, - controller: activeController, - controlGrantedAt: activeController ? session.controlGrantedAt : null, - controlExpiresAt: activeController ? session.controlExpiresAt : null, - multiplayerMode: session.multiplayerMode, - canControl: false, - canManage: false, - canRequestControl: false, - sharedReadOnly: true, - }, - }; + return { session: sharedInteractiveSession(session, Date.now()) }; } async function readInteractiveSessionLogBundle( @@ -13824,12 +13797,6 @@ function canControlInteractiveSession( ); } -function activeDelegatedController(session: InteractiveSession, now: number): string | null { - if (!session.controller) return null; - if (typeof session.controlExpiresAt !== "number" || session.controlExpiresAt <= now) return null; - return session.controller; -} - async function canControlInteractiveSessionById( env: RuntimeEnv, user: User, diff --git a/src/worker/session-repository.ts b/src/worker/session-repository.ts index 1c045a1..8ea291f 100644 --- a/src/worker/session-repository.ts +++ b/src/worker/session-repository.ts @@ -157,6 +157,21 @@ export async function readVisibleInteractiveSessionRow( ); } +export async function readSharedInteractiveSessionRow( + env: RuntimeEnv, + id: string, +): Promise { + return ( + (await database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where("id", "=", id) + .where("preparation_pending", "=", 0) + .where("share_mode", "=", "link_read") + .executeTakeFirst()) ?? null + ); +} + export async function readInteractiveSessionLogs( env: RuntimeEnv, ids: string[], diff --git a/src/worker/session-sharing.ts b/src/worker/session-sharing.ts new file mode 100644 index 0000000..1cc11cd --- /dev/null +++ b/src/worker/session-sharing.ts @@ -0,0 +1,35 @@ +import type { InteractiveSession } from "./session-model.ts"; + +export function activeDelegatedController(session: InteractiveSession, now: number): string | null { + if (!session.controller) return null; + if (typeof session.controlExpiresAt !== "number" || session.controlExpiresAt <= now) return null; + return session.controller; +} + +export function sharedInteractiveSession( + session: InteractiveSession, + now: number, +): InteractiveSession { + const activeController = activeDelegatedController(session, now); + return { + ...session, + adapter: null, + profile: "", + adapterWorkspaceId: null, + providerResourceId: null, + lastReconciledAt: null, + reconcileError: null, + leaseId: null, + attachUrl: null, + vncUrl: null, + ptyAvailable: false, + controller: activeController, + controlGrantedAt: activeController ? session.controlGrantedAt : null, + controlExpiresAt: activeController ? session.controlExpiresAt : null, + multiplayerMode: session.multiplayerMode, + canControl: false, + canManage: false, + canRequestControl: false, + sharedReadOnly: true, + }; +} diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts index 727d425..e4d837b 100644 --- a/tests/session-repository.test.ts +++ b/tests/session-repository.test.ts @@ -11,6 +11,7 @@ import { readInteractiveSessionEventRows, readInteractiveSessionLogArchives, readInteractiveSessionLogs, + readSharedInteractiveSessionRow, readVisibleInteractiveSessionRow, readVisibleInteractiveSessionRows, } from "../src/worker/session-repository.ts"; @@ -143,6 +144,25 @@ test("visible session reads exclude preparation reservations and stay bounded", assert.equal(calls, 2); }); +test("shared session reads require visible link-read rows", async () => { + const row = sessionRow({ + id: "IS-2", + preparation_pending: 0, + share_mode: "link_read", + share_token_hash: "hash", + }); + const env = runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + assert.match(sql, /from "interactive_sessions"/i); + assert.match(sql, /"preparation_pending" =/i); + assert.match(sql, /"share_mode" =/i); + assert.deepEqual(parameters, ["IS-2", 0, "link_read"]); + return { results: [row] }; + }); + + assert.equal((await readSharedInteractiveSessionRow(env, "IS-2"))?.share_token_hash, "hash"); +}); + test("session log reads keep the newest bounded window in chronological order", async () => { let queries = 0; const logs = await readInteractiveSessionLogs( diff --git a/tests/session-sharing.test.ts b/tests/session-sharing.test.ts new file mode 100644 index 0000000..e3e99b6 --- /dev/null +++ b/tests/session-sharing.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { interactiveSession } from "../src/worker/session-model.ts"; +import { + activeDelegatedController, + sharedInteractiveSession, +} from "../src/worker/session-sharing.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +test("shared session policy removes provider and terminal authority", () => { + const session = interactiveSession( + sessionRow({ + id: "IS-2", + adapter: "runtime-v1", + profile: "desktop", + adapter_workspace_id: "workspace-2", + provider_resource_id: "provider-2", + last_reconciled_at: 100, + reconcile_error: "pending", + lease_id: "sandbox:secret", + attach_url: "wss://terminal.example.test", + vnc_url: "https://desktop.example.test", + controller: "operator", + control_granted_at: 90, + control_expires_at: 200, + multiplayer_mode: 1, + }), + ["ready"], + ); + const shared = sharedInteractiveSession(session, 150); + + assert.equal(shared.adapter, null); + assert.equal(shared.profile, ""); + assert.equal(shared.adapterWorkspaceId, null); + assert.equal(shared.providerResourceId, null); + assert.equal(shared.lastReconciledAt, null); + assert.equal(shared.reconcileError, null); + assert.equal(shared.leaseId, null); + assert.equal(shared.attachUrl, null); + assert.equal(shared.vncUrl, null); + assert.equal(shared.ptyAvailable, false); + assert.equal(shared.controller, "operator"); + assert.equal(shared.controlGrantedAt, 90); + assert.equal(shared.controlExpiresAt, 200); + assert.equal(shared.multiplayerMode, true); + assert.equal(shared.canControl, false); + assert.equal(shared.canManage, false); + assert.equal(shared.canRequestControl, false); + assert.equal(shared.sharedReadOnly, true); + assert.deepEqual(shared.logs, ["ready"]); +}); + +test("shared session policy removes expired delegated control", () => { + const session = interactiveSession( + sessionRow({ + controller: "operator", + control_granted_at: 90, + control_expires_at: 100, + }), + [], + ); + + assert.equal(activeDelegatedController(session, 100), null); + const shared = sharedInteractiveSession(session, 100); + assert.equal(shared.controller, null); + assert.equal(shared.controlGrantedAt, null); + assert.equal(shared.controlExpiresAt, null); +}); From 69b770a5ae2a380cb741ecaef8f80019105ec7b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:53:58 +0100 Subject: [PATCH 027/109] refactor: assemble session aggregates --- CHANGELOG.md | 1 + src/index.ts | 33 +++--------------------- src/worker/session-repository.ts | 31 ++++++++++++++++++++++ tests/session-repository.test.ts | 44 ++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db8277..1ddbcfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Extract superseded runtime-adapter and Sandbox provision recovery from session creation. - Move bounded interactive-session logs, event pagination/counts, and archive reads into the session repository. - Centralize shared-session visibility and redaction of provider, terminal, lease, reconciliation, and control authority. +- Assemble visible interactive-session rows, recent logs, and archive metadata in the session repository. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 92a1317..3a88654 100644 --- a/src/index.ts +++ b/src/index.ts @@ -275,9 +275,9 @@ import { readInteractiveSessionEventRows, readInteractiveSessionLogArchives, readInteractiveSessionLogs, + readInteractiveSessionRecord as readInteractiveSession, + readInteractiveSessionRecords, readSharedInteractiveSessionRow, - readVisibleInteractiveSessionRow, - readVisibleInteractiveSessionRows, } from "./worker/session-repository"; import { activeDelegatedController, sharedInteractiveSession } from "./worker/session-sharing"; import type { InteractiveProvisionResult } from "./worker/session-provisioning"; @@ -12719,36 +12719,11 @@ async function readRunsForCard(env: RuntimeEnv, cardId: string): Promise { - const rows = await readVisibleInteractiveSessionRows(env); - if (!rows.length) return []; - const logs = await readInteractiveSessionLogs( - env, - rows.map((row) => row.id), - ); - const archives = await readInteractiveSessionLogArchives( - env, - rows.map((row) => row.id), - ); - return rows.map((row) => - decorateInteractiveSession( - interactiveSession(row, logs.get(row.id) ?? [], archives.get(row.id) ?? null), - user, - env, - ), + return (await readInteractiveSessionRecords(env)).map((session) => + decorateInteractiveSession(session, user, env), ); } -async function readInteractiveSession( - env: RuntimeEnv, - id: string, -): Promise { - const row = await readVisibleInteractiveSessionRow(env, id); - if (!row) return null; - const logs = await readInteractiveSessionLogs(env, [id]); - const archives = await readInteractiveSessionLogArchives(env, [id]); - return interactiveSession(row, logs.get(id) ?? [], archives.get(id) ?? null); -} - async function readFreshInteractiveSession( env: RuntimeEnv, id: string, diff --git a/src/worker/session-repository.ts b/src/worker/session-repository.ts index 8ea291f..f620bdf 100644 --- a/src/worker/session-repository.ts +++ b/src/worker/session-repository.ts @@ -13,7 +13,9 @@ import type { InteractiveProvisionResult, } from "./session-provisioning.ts"; import { + interactiveSession, interactiveSessionLogArchive, + type InteractiveSession, type InteractiveSessionEventRow, type InteractiveSessionLogArchive, type RuntimeCapabilities, @@ -172,6 +174,35 @@ export async function readSharedInteractiveSessionRow( ); } +export async function readInteractiveSessionRecords( + env: RuntimeEnv, + limit = 80, +): Promise { + const rows = await readVisibleInteractiveSessionRows(env, limit); + if (!rows.length) return []; + const ids = rows.map((row) => row.id); + const [logs, archives] = await Promise.all([ + readInteractiveSessionLogs(env, ids), + readInteractiveSessionLogArchives(env, ids), + ]); + return rows.map((row) => + interactiveSession(row, logs.get(row.id) ?? [], archives.get(row.id) ?? null), + ); +} + +export async function readInteractiveSessionRecord( + env: RuntimeEnv, + id: string, +): Promise { + const row = await readVisibleInteractiveSessionRow(env, id); + if (!row) return null; + const [logs, archives] = await Promise.all([ + readInteractiveSessionLogs(env, [id]), + readInteractiveSessionLogArchives(env, [id]), + ]); + return interactiveSession(row, logs.get(id) ?? [], archives.get(id) ?? null); +} + export async function readInteractiveSessionLogs( env: RuntimeEnv, ids: string[], diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts index e4d837b..6aa73c9 100644 --- a/tests/session-repository.test.ts +++ b/tests/session-repository.test.ts @@ -11,6 +11,8 @@ import { readInteractiveSessionEventRows, readInteractiveSessionLogArchives, readInteractiveSessionLogs, + readInteractiveSessionRecord, + readInteractiveSessionRecords, readSharedInteractiveSessionRow, readVisibleInteractiveSessionRow, readVisibleInteractiveSessionRows, @@ -163,6 +165,48 @@ test("shared session reads require visible link-read rows", async () => { assert.equal((await readSharedInteractiveSessionRow(env, "IS-2"))?.share_token_hash, "hash"); }); +test("session aggregates combine rows, recent logs, and archive metadata", async () => { + const row = sessionRow({ id: "IS-2", preparation_pending: 0 }); + const archive = { + session_id: "IS-2", + event_count: 1, + events_key: "events.json", + transcript_key: "transcript.md", + summary_key: "summary.json", + archived_at: 100, + updated_at: 110, + session_updated_at: 105, + }; + const env = runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + if (/from "interactive_sessions"/i.test(sql)) { + return { results: [row] }; + } + if (/from interactive_session_events/i.test(sql)) { + assert.deepEqual(parameters, ["IS-2"]); + return { + results: [{ session_id: "IS-2", message: "ready", created_at: 1000 }], + }; + } + if (/from "interactive_session_log_archives"/i.test(sql)) { + assert.deepEqual(parameters, ["IS-2"]); + return { results: [archive] }; + } + throw new Error(`unexpected query: ${sql}`); + }); + + const record = await readInteractiveSessionRecord(env, "IS-2"); + assert.equal(record?.id, "IS-2"); + assert.match(record?.logs[0] ?? "", /ready$/); + assert.equal(record?.logArchive?.transcriptKey, "transcript.md"); + + const records = await readInteractiveSessionRecords(env, 1); + assert.equal(records.length, 1); + assert.equal(records[0]?.id, "IS-2"); + assert.match(records[0]?.logs[0] ?? "", /ready$/); + assert.equal(records[0]?.logArchive?.eventCount, 1); +}); + test("session log reads keep the newest bounded window in chronological order", async () => { let queries = 0; const logs = await readInteractiveSessionLogs( From 80e54833acffa3421fe2fcbc2f0a43222f30e954 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 12:59:41 +0100 Subject: [PATCH 028/109] refactor: extract session metadata persistence --- CHANGELOG.md | 1 + src/index.ts | 45 ++++++--------------- src/worker/session-repository.ts | 53 ++++++++++++++++++++++++- tests/runtime-adapter.test.ts | 11 ++---- tests/session-repository.test.ts | 67 ++++++++++++++++++++++++++++++-- 5 files changed, 131 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ddbcfd..051f7e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Move bounded interactive-session logs, event pagination/counts, and archive reads into the session repository. - Centralize shared-session visibility and redaction of provider, terminal, lease, reconciliation, and control authority. - Assemble visible interactive-session rows, recent logs, and archive metadata in the session repository. +- Move atomic interactive-session metadata/event persistence and terminal snapshot invalidation into the session repository. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 3a88654..22be666 100644 --- a/src/index.ts +++ b/src/index.ts @@ -271,6 +271,7 @@ import { countInteractiveSessionEvents, insertInteractiveSessionReservation, markInteractiveSessionPendingAdapter, + persistInteractiveSessionMetadataMutation, persistInteractiveSessionProvisionResult, readInteractiveSessionEventRows, readInteractiveSessionLogArchives, @@ -4320,40 +4321,16 @@ async function mutateInteractiveSessionMetadataAtomically( values: UpdateObject, now = Date.now(), ): Promise { - const db = database(env); - const eventMessage = clean(message, 1000); - const revision = Math.max(now, session.updatedAt + 1); - const expectedOwner = sql` - id = ${session.id} - AND status = ${session.status} - AND updated_at = ${session.updatedAt} - `; - const eventQuery = sql` - INSERT INTO interactive_session_events (session_id, actor, message, created_at) - SELECT ${session.id}, ${actor(user)}, ${eventMessage}, ${now} - FROM interactive_sessions - WHERE ${expectedOwner} - `; - const updateQuery = db - .updateTable("interactive_sessions") - .set({ - ...values, - terminal_finalize_pending: sql`CASE - WHEN status IN ('stopped', 'expired', 'failed') THEN 1 - ELSE terminal_finalize_pending - END`, - updated_at: revision, - last_event: eventMessage, - }) - .where(expectedOwner) - .returning("updated_at"); - const results = await env.DB.batch<{ updated_at: number }>( - [eventQuery, updateQuery].map((query) => { - const compiled = query.compile(db); - return env.DB.prepare(compiled.sql).bind(...compiled.parameters); - }), - ); - if (!results.at(-1)?.results.some((row) => row.updated_at === revision)) { + if ( + !(await persistInteractiveSessionMetadataMutation( + env, + session, + actor(user), + message, + values, + now, + )) + ) { throw conflict("interactive session lifecycle changed; retry metadata update"); } await archiveInteractiveSessionLogs(env, session.id, now).catch(() => undefined); diff --git a/src/worker/session-repository.ts b/src/worker/session-repository.ts index f620bdf..0e3b356 100644 --- a/src/worker/session-repository.ts +++ b/src/worker/session-repository.ts @@ -1,8 +1,9 @@ -import { sql, type Insertable } from "kysely"; +import { sql, type Insertable, type UpdateObject } from "kysely"; import { database, executeBatch, + type Database, type InteractiveSessionRow, type InteractiveSessionTable, } from "./database.ts"; @@ -275,6 +276,50 @@ export async function countInteractiveSessionEvents(env: RuntimeEnv, id: string) return Number(row?.count ?? 0); } +export async function persistInteractiveSessionMetadataMutation( + env: RuntimeEnv, + session: { id: string; status: string; updatedAt: number }, + actorName: string, + message: string, + values: UpdateObject, + now: number, +): Promise { + const db = database(env); + const eventMessage = clean(message, 1000); + const revision = Math.max(now, session.updatedAt + 1); + const expectedOwner = sql` + id = ${session.id} + AND status = ${session.status} + AND updated_at = ${session.updatedAt} + `; + const eventQuery = sql` + INSERT INTO interactive_session_events (session_id, actor, message, created_at) + SELECT ${session.id}, ${actorName}, ${eventMessage}, ${now} + FROM interactive_sessions + WHERE ${expectedOwner} + `; + const updateQuery = db + .updateTable("interactive_sessions") + .set({ + ...values, + terminal_finalize_pending: sql`CASE + WHEN status IN ('stopped', 'expired', 'failed') THEN 1 + ELSE terminal_finalize_pending + END`, + updated_at: revision, + last_event: eventMessage, + }) + .where(expectedOwner) + .returning("updated_at"); + const results = await env.DB.batch<{ updated_at: number }>( + [eventQuery, updateQuery].map((query) => { + const compiled = query.compile(db); + return env.DB.prepare(compiled.sql).bind(...compiled.parameters); + }), + ); + return results.at(-1)?.results.some((row) => row.updated_at === revision) ?? false; +} + export async function insertInteractiveSessionReservation( env: RuntimeEnv, values: InteractiveSessionReservationValues, @@ -377,3 +422,9 @@ export async function markInteractiveSessionPendingAdapter( .where("agent_token_hash", "=", input.initialAgentTokenHash) .execute(); } + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index dcfd3e0..f8d9f0c 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -838,7 +838,7 @@ test("summary and sharing events invalidate terminal cleanup snapshots", async ( const shareEnd = source.indexOf('if (action === "enable_multiplayer")', shareStart); const shareSource = source.slice(shareStart, shareEnd); const summaryStart = source.indexOf("async function updateInteractiveSessionSummary"); - const summaryEnd = source.indexOf("async function readInteractiveSessionLogs", summaryStart); + const summaryEnd = source.indexOf("async function updateGitHubActionsWorkState", summaryStart); const summarySource = source.slice(summaryStart, summaryEnd); const metadataStart = source.indexOf("async function mutateInteractiveSessionMetadataAtomically"); const metadataEnd = source.indexOf("async function mutateInteractiveSession(", metadataStart); @@ -851,13 +851,8 @@ test("summary and sharing events invalidate terminal cleanup snapshots", async ( assert.match(cleanupSource, /count\(\*\)/); assert.match(shareSource, /mutateInteractiveSessionMetadataAtomically/); assert.match(summarySource, /mutateInteractiveSessionMetadataAtomically/); - assert.match(metadataSource, /INSERT INTO interactive_session_events/); - assert.match(metadataSource, /terminal_finalize_pending: sql`CASE/); - assert.match(metadataSource, /WHEN status IN \('stopped', 'expired', 'failed'\) THEN 1/); - assert.match(metadataSource, /updated_at = \$\{session\.updatedAt\}/); - assert.match(metadataSource, /\.returning\("updated_at"\)/); - assert.match(metadataSource, /env\.DB\.batch/); - assert.ok(metadataSource.indexOf("eventQuery") < metadataSource.indexOf("updateQuery")); + assert.match(metadataSource, /persistInteractiveSessionMetadataMutation/); + assert.match(metadataSource, /archiveInteractiveSessionLogs/); }); test("runtime adapter credentials are preflighted before session allocation", () => { diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts index 6aa73c9..6f5ccd6 100644 --- a/tests/session-repository.test.ts +++ b/tests/session-repository.test.ts @@ -7,6 +7,7 @@ import { countInteractiveSessionEvents, insertInteractiveSessionReservation, markInteractiveSessionPendingAdapter, + persistInteractiveSessionMetadataMutation, persistInteractiveSessionProvisionResult, readInteractiveSessionEventRows, readInteractiveSessionLogArchives, @@ -31,7 +32,7 @@ type PreparedStatement = { function runtimeEnv( handler: D1Handler, - batchHandler: (statements: PreparedStatement[]) => void = () => undefined, + batchHandler: (statements: PreparedStatement[]) => unknown[] | void = () => undefined, ): RuntimeEnv { return { DB: { @@ -55,8 +56,7 @@ function runtimeEnv( }; }, async batch(statements: unknown[]) { - batchHandler(statements as PreparedStatement[]); - return []; + return batchHandler(statements as PreparedStatement[]) ?? []; }, } as unknown as D1Database, } as RuntimeEnv; @@ -322,6 +322,67 @@ test("session event counts normalize missing and persisted values", async () => ); }); +test("session metadata mutations persist events before fenced snapshot-invalidating updates", async () => { + let batch: PreparedStatement[] = []; + const updated = await persistInteractiveSessionMetadataMutation( + runtimeEnv( + () => { + throw new Error("metadata mutation must execute as one batch"); + }, + (statements) => { + batch = statements; + return [{ results: [] }, { results: [{ updated_at: 101 }] }]; + }, + ), + { id: "IS-2", status: "ready", updatedAt: 100 }, + "operator", + " summary updated ", + { summary: "done" }, + 90, + ); + + assert.equal(updated, true); + assert.equal(batch.length, 2); + assert.match(batch[0]?.sql ?? "", /^\s*insert into interactive_session_events/i); + assert.deepEqual(batch[0]?.parameters, [ + "IS-2", + "operator", + "summary updated", + 90, + "IS-2", + "ready", + 100, + ]); + assert.match(batch[1]?.sql ?? "", /^update "interactive_sessions"/i); + assert.match(batch[1]?.sql ?? "", /terminal_finalize_pending/i); + assert.match(batch[1]?.sql ?? "", /when status in \('stopped', 'expired', 'failed'\) then 1/i); + assert.match(batch[1]?.sql ?? "", /returning "updated_at"/i); + assert.ok(batch[1]?.parameters.includes("done")); + assert.ok(batch[1]?.parameters.includes(101)); + assert.ok(batch[1]?.parameters.includes("IS-2")); + assert.ok(batch[1]?.parameters.includes("ready")); + assert.ok(batch[1]?.parameters.includes(100)); +}); + +test("session metadata mutations report lost revision ownership", async () => { + assert.equal( + await persistInteractiveSessionMetadataMutation( + runtimeEnv( + () => { + throw new Error("metadata mutation must execute as one batch"); + }, + () => [{ results: [] }, { results: [] }], + ), + { id: "IS-2", status: "ready", updatedAt: 100 }, + "operator", + "summary updated", + { summary: "done" }, + 101, + ), + false, + ); +}); + test("session reservation inserts preparation and request identity in one batch", async () => { let batch: PreparedStatement[] = []; const values = sessionRow({ From 03315edc7ea6952684c944c98830820bebc5b155 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 13:05:11 +0100 Subject: [PATCH 029/109] refactor: extract session metadata service --- CHANGELOG.md | 1 + src/index.ts | 235 +++------------------ src/worker/session-metadata.ts | 264 ++++++++++++++++++++++++ tests/runtime-adapter.test.ts | 6 +- tests/session-metadata.test.ts | 246 ++++++++++++++++++++++ tests/trusted-proxy-integration.test.ts | 2 +- 6 files changed, 545 insertions(+), 209 deletions(-) create mode 100644 src/worker/session-metadata.ts create mode 100644 tests/session-metadata.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 051f7e4..7385c57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Centralize shared-session visibility and redaction of provider, terminal, lease, reconciliation, and control authority. - Assemble visible interactive-session rows, recent logs, and archive metadata in the session repository. - Move atomic interactive-session metadata/event persistence and terminal snapshot invalidation into the session repository. +- Extract sharing, multiplayer, and delegated-control mutations into a directly tested session metadata service. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 22be666..9f6c1b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -280,6 +280,11 @@ import { readInteractiveSessionRecords, readSharedInteractiveSessionRow, } from "./worker/session-repository"; +import { + InteractiveSessionMetadataService, + isInteractiveSessionMetadataAction, + type InteractiveSessionMetadataStore, +} from "./worker/session-metadata"; import { activeDelegatedController, sharedInteractiveSession } from "./worker/session-sharing"; import type { InteractiveProvisionResult } from "./worker/session-provisioning"; import { @@ -4336,6 +4341,20 @@ async function mutateInteractiveSessionMetadataAtomically( await archiveInteractiveSessionLogs(env, session.id, now).catch(() => undefined); } +function interactiveSessionMetadataService( + env: RuntimeEnv, + user: User, +): InteractiveSessionMetadataService { + const store: InteractiveSessionMetadataStore = { + persist: (session, actorName, message, values, now) => + persistInteractiveSessionMetadataMutation(env, session, actorName, message, values, now), + archive: (sessionId, now) => archiveInteractiveSessionLogs(env, sessionId, now), + audit: (message, now) => audit(env, user, message, now), + readSession: (sessionId) => readInteractiveSession(env, sessionId), + }; + return new InteractiveSessionMetadataService(store); +} + type LegacyInteractiveSessionStopOwner = { id: string; status: InteractiveSessionStatus; @@ -4531,207 +4550,23 @@ async function mutateInteractiveSession( }; } - if (action === "share_link") { - if (!canManage) throw forbidden("only the session owner or maintainer can share"); - const token = shareToken(); - const tokenHash = await sha256(token); - const preview = token.slice(0, 8); - await mutateInteractiveSessionMetadataAtomically( - env, - session, - user, - "read-only share link enabled", - { - share_mode: "link_read", - share_token_hash: tokenHash, - share_token_preview: preview, - }, - now, - ); - await audit(env, user, `interactive session share enabled ${id}`, now); - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), - shareUrl: shareUrl(request, env, id, token), - }; - } - - if (action === "disable_share") { - if (!canManage) throw forbidden("only the session owner or maintainer can disable sharing"); - await mutateInteractiveSessionMetadataAtomically( - env, + if (isInteractiveSessionMetadataAction(action)) { + const delegatedControlAvailable = canGrantDelegatedControl(env, session); + const result = await interactiveSessionMetadataService(env, user).mutate({ session, - user, - "session sharing disabled", - { - share_mode: "private", - share_token_hash: null, - share_token_preview: null, - control_requested_by: null, - control_requested_at: null, - controller: null, - control_granted_at: null, - control_expires_at: null, + action, + actor: userActor, + policy: { + canManage, + canChangeMultiplayer: canChangeInteractiveSessionMultiplayer(user, session), + canControl: canControlInteractiveSession(user, session, now, delegatedControlAvailable), + delegatedControlAvailable, }, now, - ); - await audit(env, user, `interactive session share disabled ${id}`, now); - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), - }; - } - - if (action === "enable_multiplayer" || action === "disable_multiplayer") { - if (!canChangeInteractiveSessionMultiplayer(user, session)) { - throw forbidden("only the session creator can change multiplayer"); - } - const enabled = action === "enable_multiplayer"; - const message = enabled ? "multiplayer mode enabled" : "multiplayer mode disabled"; - await mutateInteractiveSessionMetadataAtomically( - env, - session, - user, - message, - { multiplayer_mode: enabled ? 1 : 0 }, - now, - ); - await audit( - env, - user, - `interactive session multiplayer ${enabled ? "enabled" : "disabled"} ${id}`, - now, - ); - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), - }; - } - - if (action === "request_control") { - if (!canGrantDelegatedControl(env, session)) { - throw badRequest("delegated terminal control requires a revocable PTY bridge"); - } - if (canControlInteractiveSession(user, session, now, canGrantDelegatedControl(env, session))) { - return { session: decorateInteractiveSession(session, user, env) }; - } - if (["stopping", "expired", "failed", "stopped"].includes(session.status)) { - throw badRequest(`session is ${session.status}`); - } - const message = `${userActor} requested terminal control`; - await mutateInteractiveSessionMetadataAtomically( - env, - session, - user, - message, - { - control_requested_by: userActor, - control_requested_at: now, - }, - now, - ); - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), - }; - } - - if (action === "approve_control") { - if (!canManage) throw forbidden("only the session owner or maintainer can approve control"); - if (!session.controlRequestedBy) throw badRequest("no pending control request"); - if (!canGrantDelegatedControl(env, session)) { - throw badRequest("delegated terminal control requires a revocable PTY bridge"); - } - const expires = now + 30 * 60 * 1000; - const message = `control granted to ${session.controlRequestedBy}`; - await mutateInteractiveSessionMetadataAtomically( - env, - session, - user, - message, - { - controller: session.controlRequestedBy, - control_granted_at: now, - control_expires_at: expires, - control_requested_by: null, - control_requested_at: null, - }, - now, - ); - await audit( - env, - user, - `interactive session control granted ${id} to ${session.controlRequestedBy}`, - now, - ); - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), - }; - } - - if (action === "deny_control") { - if (!canManage) throw forbidden("only the session owner or maintainer can deny control"); - const requester = session.controlRequestedBy; - const message = requester - ? `control request denied for ${requester}` - : "control request denied"; - await mutateInteractiveSessionMetadataAtomically( - env, - session, - user, - message, - { - control_requested_by: null, - control_requested_at: null, - }, - now, - ); - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), - }; - } - - if (action === "revoke_control") { - if (!canManage) throw forbidden("only the session owner or maintainer can revoke control"); - await mutateInteractiveSessionMetadataAtomically( - env, - session, - user, - "terminal control revoked", - { - controller: null, - control_granted_at: null, - control_expires_at: null, - }, - now, - ); - await audit(env, user, `interactive session control revoked ${id}`, now); + }); return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), + session: decorateInteractiveSession(result.session, user, env), + ...(result.shareToken ? { shareUrl: shareUrl(request, env, id, result.shareToken) } : {}), }; } @@ -13776,12 +13611,6 @@ function canGrantDelegatedControl(env: RuntimeEnv, session: InteractiveSession): return true; } -function shareToken(): string { - const first = crypto.randomUUID().replaceAll("-", ""); - const second = crypto.randomUUID().replaceAll("-", ""); - return `${first}${second}`; -} - function shareUrl(request: Request, env: RuntimeEnv, id: string, token: string): string { return `${externalRequestOrigin(request, env)}/sessions/${encodeURIComponent(id)}?token=${encodeURIComponent(token)}`; } diff --git a/src/worker/session-metadata.ts b/src/worker/session-metadata.ts new file mode 100644 index 0000000..640605d --- /dev/null +++ b/src/worker/session-metadata.ts @@ -0,0 +1,264 @@ +import type { UpdateObject } from "kysely"; + +import { sha256 } from "./crypto.ts"; +import type { Database } from "./database.ts"; +import { badRequest, conflict, forbidden, notFound } from "./http.ts"; +import { deadInteractiveSessionStatuses } from "./models.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export const interactiveSessionMetadataActions = [ + "share_link", + "disable_share", + "enable_multiplayer", + "disable_multiplayer", + "request_control", + "approve_control", + "deny_control", + "revoke_control", +] as const; + +export type InteractiveSessionMetadataAction = (typeof interactiveSessionMetadataActions)[number]; + +export type InteractiveSessionMetadataPolicy = { + canManage: boolean; + canChangeMultiplayer: boolean; + canControl: boolean; + delegatedControlAvailable: boolean; +}; + +export type InteractiveSessionMetadataMutation = { + session: InteractiveSession; + action: InteractiveSessionMetadataAction; + actor: string; + policy: InteractiveSessionMetadataPolicy; + now: number; +}; + +export type InteractiveSessionMetadataResult = { + session: InteractiveSession; + shareToken?: string; +}; + +export type InteractiveSessionMetadataStore = { + persist( + session: Pick, + actor: string, + message: string, + values: UpdateObject, + now: number, + ): Promise; + archive(sessionId: string, now: number): Promise; + audit(message: string, now: number): Promise; + readSession(sessionId: string): Promise; +}; + +type ShareTokenFactory = () => string; +type ShareTokenHasher = (token: string) => Promise; + +const delegatedControlDurationMs = 30 * 60 * 1000; + +export class InteractiveSessionMetadataService { + private readonly store: InteractiveSessionMetadataStore; + private readonly createShareToken: ShareTokenFactory; + private readonly hashShareToken: ShareTokenHasher; + + constructor( + store: InteractiveSessionMetadataStore, + createShareToken: ShareTokenFactory = interactiveSessionShareToken, + hashShareToken: ShareTokenHasher = sha256, + ) { + this.store = store; + this.createShareToken = createShareToken; + this.hashShareToken = hashShareToken; + } + + async mutate( + input: InteractiveSessionMetadataMutation, + ): Promise { + const { action, actor, now, policy, session } = input; + + if (action === "share_link") { + if (!policy.canManage) { + throw forbidden("only the session owner or maintainer can share"); + } + const shareToken = this.createShareToken(); + await this.persist( + session, + actor, + "read-only share link enabled", + { + share_mode: "link_read", + share_token_hash: await this.hashShareToken(shareToken), + share_token_preview: shareToken.slice(0, 8), + }, + now, + ); + await this.store.audit(`interactive session share enabled ${session.id}`, now); + return { session: await this.readSession(session.id), shareToken }; + } + + if (action === "disable_share") { + if (!policy.canManage) { + throw forbidden("only the session owner or maintainer can disable sharing"); + } + await this.persist( + session, + actor, + "session sharing disabled", + { + share_mode: "private", + share_token_hash: null, + share_token_preview: null, + control_requested_by: null, + control_requested_at: null, + controller: null, + control_granted_at: null, + control_expires_at: null, + }, + now, + ); + await this.store.audit(`interactive session share disabled ${session.id}`, now); + return { session: await this.readSession(session.id) }; + } + + if (action === "enable_multiplayer" || action === "disable_multiplayer") { + if (!policy.canChangeMultiplayer) { + throw forbidden("only the session creator can change multiplayer"); + } + const enabled = action === "enable_multiplayer"; + await this.persist( + session, + actor, + enabled ? "multiplayer mode enabled" : "multiplayer mode disabled", + { multiplayer_mode: enabled ? 1 : 0 }, + now, + ); + await this.store.audit( + `interactive session multiplayer ${enabled ? "enabled" : "disabled"} ${session.id}`, + now, + ); + return { session: await this.readSession(session.id) }; + } + + if (action === "request_control") { + this.requireDelegatedControl(policy); + if (policy.canControl) return { session }; + if ( + session.status === "stopping" || + deadInteractiveSessionStatuses.includes(session.status) + ) { + throw badRequest(`session is ${session.status}`); + } + await this.persist( + session, + actor, + `${actor} requested terminal control`, + { + control_requested_by: actor, + control_requested_at: now, + }, + now, + ); + return { session: await this.readSession(session.id) }; + } + + if (action === "approve_control") { + if (!policy.canManage) { + throw forbidden("only the session owner or maintainer can approve control"); + } + if (!session.controlRequestedBy) throw badRequest("no pending control request"); + this.requireDelegatedControl(policy); + await this.persist( + session, + actor, + `control granted to ${session.controlRequestedBy}`, + { + controller: session.controlRequestedBy, + control_granted_at: now, + control_expires_at: now + delegatedControlDurationMs, + control_requested_by: null, + control_requested_at: null, + }, + now, + ); + await this.store.audit( + `interactive session control granted ${session.id} to ${session.controlRequestedBy}`, + now, + ); + return { session: await this.readSession(session.id) }; + } + + if (action === "deny_control") { + if (!policy.canManage) { + throw forbidden("only the session owner or maintainer can deny control"); + } + await this.persist( + session, + actor, + session.controlRequestedBy + ? `control request denied for ${session.controlRequestedBy}` + : "control request denied", + { + control_requested_by: null, + control_requested_at: null, + }, + now, + ); + return { session: await this.readSession(session.id) }; + } + + if (!policy.canManage) { + throw forbidden("only the session owner or maintainer can revoke control"); + } + await this.persist( + session, + actor, + "terminal control revoked", + { + controller: null, + control_granted_at: null, + control_expires_at: null, + }, + now, + ); + await this.store.audit(`interactive session control revoked ${session.id}`, now); + return { session: await this.readSession(session.id) }; + } + + private requireDelegatedControl(policy: InteractiveSessionMetadataPolicy): void { + if (!policy.delegatedControlAvailable) { + throw badRequest("delegated terminal control requires a revocable PTY bridge"); + } + } + + private async persist( + session: Pick, + actor: string, + message: string, + values: UpdateObject, + now: number, + ): Promise { + if (!(await this.store.persist(session, actor, message, values, now))) { + throw conflict("interactive session lifecycle changed; retry metadata update"); + } + await this.store.archive(session.id, now).catch(() => undefined); + } + + private async readSession(sessionId: string): Promise { + const session = await this.store.readSession(sessionId); + if (!session) throw notFound("interactive session not found"); + return session; + } +} + +export function isInteractiveSessionMetadataAction( + action: string, +): action is InteractiveSessionMetadataAction { + return interactiveSessionMetadataActions.includes(action as InteractiveSessionMetadataAction); +} + +export function interactiveSessionShareToken(): string { + const first = crypto.randomUUID().replaceAll("-", ""); + const second = crypto.randomUUID().replaceAll("-", ""); + return `${first}${second}`; +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index f8d9f0c..38e3b7a 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -829,14 +829,11 @@ test("strict session rows and cleanup preserve terminal finalization anchors", a assert.match(source, /events_key IS NOT NULL/); }); -test("summary and sharing events invalidate terminal cleanup snapshots", async () => { +test("summary events invalidate terminal cleanup snapshots", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const cleanupStart = source.indexOf("async function deleteFinalizedInteractiveSession"); const cleanupEnd = source.indexOf("async function mutateInteractiveSession", cleanupStart); const cleanupSource = source.slice(cleanupStart, cleanupEnd); - const shareStart = source.indexOf('if (action === "share_link")'); - const shareEnd = source.indexOf('if (action === "enable_multiplayer")', shareStart); - const shareSource = source.slice(shareStart, shareEnd); const summaryStart = source.indexOf("async function updateInteractiveSessionSummary"); const summaryEnd = source.indexOf("async function updateGitHubActionsWorkState", summaryStart); const summarySource = source.slice(summaryStart, summaryEnd); @@ -849,7 +846,6 @@ test("summary and sharing events invalidate terminal cleanup snapshots", async ( assert.match(cleanupSource, /event_count = \$\{archive\?\.event_count/); assert.match(cleanupSource, /archived_at = \$\{archive\?\.archived_at/); assert.match(cleanupSource, /count\(\*\)/); - assert.match(shareSource, /mutateInteractiveSessionMetadataAtomically/); assert.match(summarySource, /mutateInteractiveSessionMetadataAtomically/); assert.match(metadataSource, /persistInteractiveSessionMetadataMutation/); assert.match(metadataSource, /archiveInteractiveSessionLogs/); diff --git a/tests/session-metadata.test.ts b/tests/session-metadata.test.ts new file mode 100644 index 0000000..cb987ab --- /dev/null +++ b/tests/session-metadata.test.ts @@ -0,0 +1,246 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { UpdateObject } from "kysely"; + +import type { Database } from "../src/worker/database.ts"; +import type { HttpError } from "../src/worker/http.ts"; +import { + InteractiveSessionMetadataService, + isInteractiveSessionMetadataAction, + type InteractiveSessionMetadataAction, + type InteractiveSessionMetadataPolicy, + type InteractiveSessionMetadataStore, +} from "../src/worker/session-metadata.ts"; +import { interactiveSession, type InteractiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +type PersistedMutation = { + actor: string; + message: string; + values: UpdateObject; + now: number; +}; + +function fixture( + options: { + session?: InteractiveSession; + persist?: boolean; + archiveFailure?: boolean; + } = {}, +) { + const session = + options.session ?? + interactiveSession(sessionRow({ id: "IS-7", owner: "owner", updated_at: 20 }), []); + const mutations: PersistedMutation[] = []; + const audits: string[] = []; + const archives: string[] = []; + const store: InteractiveSessionMetadataStore = { + persist: async (_session, actor, message, values, now) => { + mutations.push({ actor, message, values, now }); + return options.persist ?? true; + }, + archive: async (sessionId) => { + archives.push(sessionId); + if (options.archiveFailure) throw new Error("archive unavailable"); + }, + audit: async (message) => { + audits.push(message); + }, + readSession: async () => session, + }; + const service = new InteractiveSessionMetadataService( + store, + () => "12345678share-token", + async (token) => `hash:${token}`, + ); + return { archives, audits, mutations, service, session }; +} + +const allowedPolicy: InteractiveSessionMetadataPolicy = { + canManage: true, + canChangeMultiplayer: true, + canControl: true, + delegatedControlAvailable: true, +}; + +function hasStatus(status: number): (error: unknown) => boolean { + return (error) => error instanceof Error && (error as HttpError).status === status; +} + +async function mutate( + action: InteractiveSessionMetadataAction, + options: { + session?: InteractiveSession; + policy?: Partial; + persist?: boolean; + } = {}, +) { + const context = fixture({ session: options.session, persist: options.persist }); + const result = await context.service.mutate({ + session: context.session, + action, + actor: "operator", + policy: { ...allowedPolicy, ...options.policy }, + now: 100, + }); + return { ...context, result }; +} + +test("metadata action detection accepts only service-owned actions", () => { + for (const action of [ + "share_link", + "disable_share", + "enable_multiplayer", + "disable_multiplayer", + "request_control", + "approve_control", + "deny_control", + "revoke_control", + ]) { + assert.equal(isInteractiveSessionMetadataAction(action), true); + } + assert.equal(isInteractiveSessionMetadataAction("attach"), false); + assert.equal(isInteractiveSessionMetadataAction("stop"), false); +}); + +test("share link rotates the credential and records durable evidence", async () => { + const { archives, audits, mutations, result } = await mutate("share_link"); + + assert.equal(result.shareToken, "12345678share-token"); + assert.deepEqual(mutations, [ + { + actor: "operator", + message: "read-only share link enabled", + values: { + share_mode: "link_read", + share_token_hash: "hash:12345678share-token", + share_token_preview: "12345678", + }, + now: 100, + }, + ]); + assert.deepEqual(archives, ["IS-7"]); + assert.deepEqual(audits, ["interactive session share enabled IS-7"]); +}); + +test("disabling sharing clears share and delegated-control state", async () => { + const { mutations } = await mutate("disable_share"); + + assert.deepEqual(mutations[0]?.values, { + share_mode: "private", + share_token_hash: null, + share_token_preview: null, + control_requested_by: null, + control_requested_at: null, + controller: null, + control_granted_at: null, + control_expires_at: null, + }); +}); + +test("multiplayer changes require the creator policy", async () => { + await assert.rejects( + () => mutate("enable_multiplayer", { policy: { canChangeMultiplayer: false } }), + hasStatus(403), + ); + const { mutations, audits } = await mutate("disable_multiplayer"); + assert.deepEqual(mutations[0]?.values, { multiplayer_mode: 0 }); + assert.deepEqual(audits, ["interactive session multiplayer disabled IS-7"]); +}); + +test("control requests require a live revocable session", async () => { + await assert.rejects( + () => mutate("request_control", { policy: { delegatedControlAvailable: false } }), + hasStatus(400), + ); + const stopped = interactiveSession(sessionRow({ status: "stopped" }), []); + await assert.rejects( + () => mutate("request_control", { session: stopped, policy: { canControl: false } }), + hasStatus(400), + ); + const stopping = interactiveSession(sessionRow({ status: "stopping" }), []); + await assert.rejects( + () => mutate("request_control", { session: stopping, policy: { canControl: false } }), + hasStatus(400), + ); + const { mutations } = await mutate("request_control", { policy: { canControl: false } }); + assert.deepEqual(mutations[0]?.values, { + control_requested_by: "operator", + control_requested_at: 100, + }); +}); + +test("existing controllers do not create duplicate requests", async () => { + const { mutations, result, session } = await mutate("request_control"); + assert.equal(result.session, session); + assert.deepEqual(mutations, []); +}); + +test("control approval grants a bounded lease and clears the request", async () => { + const session = interactiveSession( + sessionRow({ id: "IS-7", control_requested_by: "reviewer", control_requested_at: 90 }), + [], + ); + const { audits, mutations } = await mutate("approve_control", { session }); + + assert.deepEqual(mutations[0]?.values, { + controller: "reviewer", + control_granted_at: 100, + control_expires_at: 1_800_100, + control_requested_by: null, + control_requested_at: null, + }); + assert.deepEqual(audits, ["interactive session control granted IS-7 to reviewer"]); +}); + +test("deny and revoke clear only their owned control state", async () => { + const requested = interactiveSession( + sessionRow({ control_requested_by: "reviewer", control_requested_at: 90 }), + [], + ); + const denied = await mutate("deny_control", { session: requested }); + assert.deepEqual(denied.mutations[0]?.values, { + control_requested_by: null, + control_requested_at: null, + }); + + const revoked = await mutate("revoke_control"); + assert.deepEqual(revoked.mutations[0]?.values, { + controller: null, + control_granted_at: null, + control_expires_at: null, + }); +}); + +test("lost metadata ownership reports a conflict before audit or reread", async () => { + const context = fixture({ persist: false }); + await assert.rejects( + () => + context.service.mutate({ + session: context.session, + action: "disable_share", + actor: "operator", + policy: allowedPolicy, + now: 100, + }), + hasStatus(409), + ); + assert.deepEqual(context.archives, []); + assert.deepEqual(context.audits, []); +}); + +test("archive refresh failures do not roll back persisted metadata", async () => { + const context = fixture({ archiveFailure: true }); + const result = await context.service.mutate({ + session: context.session, + action: "disable_share", + actor: "operator", + policy: allowedPolicy, + now: 100, + }); + + assert.equal(result.session, context.session); + assert.deepEqual(context.archives, ["IS-7"]); + assert.deepEqual(context.audits, ["interactive session share disabled IS-7"]); +}); diff --git a/tests/trusted-proxy-integration.test.ts b/tests/trusted-proxy-integration.test.ts index 89c68ac..32d923c 100644 --- a/tests/trusted-proxy-integration.test.ts +++ b/tests/trusted-proxy-integration.test.ts @@ -35,7 +35,7 @@ test("trusted proxy sign-in cannot pretend that local logout will end the sessio test("split-origin links use the browser-visible proxy origin", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); assert.match(source, /trustedProxyPublicOrigin\(env\) \?\? new URL\(request\.url\)\.origin/); - assert.match(source, /shareUrl\(request, env, id, token\)/); + assert.match(source, /shareUrl\(request, env, id, result\.shareToken\)/); assert.match(source, /externalRequestOrigin\(request, env\)/); assert.match(source, /runtimeAdapterBrowserVncUrl\(browserAppOrigin\(env\), session\.id\)/); assert.match( From 38aa12522f20a3e3e671282e6225011a8060f870 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 13:09:34 +0100 Subject: [PATCH 030/109] refactor: extract session attach service --- CHANGELOG.md | 1 + src/index.ts | 91 +++++++-------- src/worker/session-attach.ts | 70 ++++++++++++ src/worker/session-repository.ts | 2 +- tests/runtime-adapter.test.ts | 8 +- tests/session-attach.test.ts | 185 +++++++++++++++++++++++++++++++ tests/session-repository.test.ts | 10 +- 7 files changed, 308 insertions(+), 59 deletions(-) create mode 100644 src/worker/session-attach.ts create mode 100644 tests/session-attach.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7385c57..f13e61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Assemble visible interactive-session rows, recent logs, and archive metadata in the session repository. - Move atomic interactive-session metadata/event persistence and terminal snapshot invalidation into the session repository. - Extract sharing, multiplayer, and delegated-control mutations into a directly tested session metadata service. +- Extract terminal attach policy into a directly tested service and persist attach state plus evidence atomically. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 9f6c1b1..0b4f22f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -271,7 +271,7 @@ import { countInteractiveSessionEvents, insertInteractiveSessionReservation, markInteractiveSessionPendingAdapter, - persistInteractiveSessionMetadataMutation, + persistInteractiveSessionEventMutation, persistInteractiveSessionProvisionResult, readInteractiveSessionEventRows, readInteractiveSessionLogArchives, @@ -280,6 +280,10 @@ import { readInteractiveSessionRecords, readSharedInteractiveSessionRow, } from "./worker/session-repository"; +import { + InteractiveSessionAttachService, + type InteractiveSessionAttachStore, +} from "./worker/session-attach"; import { InteractiveSessionMetadataService, isInteractiveSessionMetadataAction, @@ -4318,7 +4322,7 @@ async function deleteFinalizedInteractiveSession( return !current; } -async function mutateInteractiveSessionMetadataAtomically( +async function mutateInteractiveSessionWithEventAtomically( env: RuntimeEnv, session: Pick, user: User, @@ -4327,27 +4331,40 @@ async function mutateInteractiveSessionMetadataAtomically( now = Date.now(), ): Promise { if ( - !(await persistInteractiveSessionMetadataMutation( - env, - session, - actor(user), - message, - values, - now, - )) + !(await persistInteractiveSessionEventMutation(env, session, actor(user), message, values, now)) ) { throw conflict("interactive session lifecycle changed; retry metadata update"); } await archiveInteractiveSessionLogs(env, session.id, now).catch(() => undefined); } +function interactiveSessionAttachService(env: RuntimeEnv): InteractiveSessionAttachService { + const store: InteractiveSessionAttachStore = { + persist: (session, actorName, transition, now) => + persistInteractiveSessionEventMutation( + env, + session, + actorName, + transition.message, + { + status: transition.status, + last_seen_at: transition.lastSeenAt, + }, + now, + ), + archive: (sessionId, now) => archiveInteractiveSessionLogs(env, sessionId, now), + readSession: (sessionId) => readInteractiveSession(env, sessionId), + }; + return new InteractiveSessionAttachService(store); +} + function interactiveSessionMetadataService( env: RuntimeEnv, user: User, ): InteractiveSessionMetadataService { const store: InteractiveSessionMetadataStore = { persist: (session, actorName, message, values, now) => - persistInteractiveSessionMetadataMutation(env, session, actorName, message, values, now), + persistInteractiveSessionEventMutation(env, session, actorName, message, values, now), archive: (sessionId, now) => archiveInteractiveSessionLogs(env, sessionId, now), audit: (message, now) => audit(env, user, message, now), readSession: (sessionId) => readInteractiveSession(env, sessionId), @@ -4506,50 +4523,24 @@ async function mutateInteractiveSession( if (!session) throw notFound("interactive session not found"); const now = Date.now(); const userActor = actor(user); - const canManage = canManageInteractiveSession(user, session); if (action === "attach") { - if (!session.capabilities.terminal) { - throw badRequest("session does not advertise terminal access"); - } - if (!canControlInteractiveSession(user, session, now, canGrantDelegatedControl(env, session))) { - throw forbidden("terminal control has not been granted"); - } - if (["stopping", "expired", "failed", "stopped"].includes(session.status)) { - throw badRequest(`session is ${session.status}`); - } - const nextStatus = - session.status === "ready" || session.status === "detached" ? "attached" : session.status; - const message = - session.status === "pending_adapter" - ? "attach requested; runtime adapter pending" - : session.status === "provisioning" - ? "attach requested; workspace provisioning" - : "interactive terminal attached"; - const attached = await database(env) - .updateTable("interactive_sessions") - .set({ - status: nextStatus, - last_seen_at: now, - updated_at: sql`MAX(updated_at + 1, ${now})`, - last_event: message, - }) - .where("id", "=", id) - .where("status", "=", session.status) - .where("updated_at", "=", session.updatedAt) - .executeTakeFirst(); - if ((attached.numUpdatedRows ?? 0n) === 0n) { - throw conflict("interactive session lifecycle changed; retry attach"); - } - await appendInteractiveSessionEvent(env, id, user, message, now); - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, + const attached = await interactiveSessionAttachService(env).attach({ + session, + actor: userActor, + canControl: canControlInteractiveSession( user, - env, + session, + now, + canGrantDelegatedControl(env, session), ), + now, + }); + return { + session: decorateInteractiveSession(attached, user, env), }; } + const canManage = canManageInteractiveSession(user, session); if (isInteractiveSessionMetadataAction(action)) { const delegatedControlAvailable = canGrantDelegatedControl(env, session); const result = await interactiveSessionMetadataService(env, user).mutate({ @@ -12623,7 +12614,7 @@ async function updateInteractiveSessionSummary( const summary = clean(body.summary, 500); if (!purpose && !summary) throw badRequest("summary or purpose is required"); const now = Date.now(); - await mutateInteractiveSessionMetadataAtomically( + await mutateInteractiveSessionWithEventAtomically( env, session, user, diff --git a/src/worker/session-attach.ts b/src/worker/session-attach.ts new file mode 100644 index 0000000..af8633e --- /dev/null +++ b/src/worker/session-attach.ts @@ -0,0 +1,70 @@ +import { badRequest, conflict, forbidden, notFound } from "./http.ts"; +import { deadInteractiveSessionStatuses, type InteractiveSessionStatus } from "./models.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export type InteractiveSessionAttachTransition = { + status: InteractiveSessionStatus; + lastSeenAt: number; + message: string; +}; + +export type InteractiveSessionAttachStore = { + persist( + session: Pick, + actor: string, + transition: InteractiveSessionAttachTransition, + now: number, + ): Promise; + archive(sessionId: string, now: number): Promise; + readSession(sessionId: string): Promise; +}; + +export class InteractiveSessionAttachService { + private readonly store: InteractiveSessionAttachStore; + + constructor(store: InteractiveSessionAttachStore) { + this.store = store; + } + + async attach(input: { + session: InteractiveSession; + actor: string; + canControl: boolean; + now: number; + }): Promise { + const { actor, canControl, now, session } = input; + if (!session.capabilities.terminal) { + throw badRequest("session does not advertise terminal access"); + } + if (!canControl) throw forbidden("terminal control has not been granted"); + if (session.status === "stopping" || deadInteractiveSessionStatuses.includes(session.status)) { + throw badRequest(`session is ${session.status}`); + } + + const transition = interactiveSessionAttachTransition(session, now); + if (!(await this.store.persist(session, actor, transition, now))) { + throw conflict("interactive session lifecycle changed; retry attach"); + } + await this.store.archive(session.id, now).catch(() => undefined); + const current = await this.store.readSession(session.id); + if (!current) throw notFound("interactive session not found"); + return current; + } +} + +export function interactiveSessionAttachTransition( + session: Pick, + now: number, +): InteractiveSessionAttachTransition { + return { + status: + session.status === "ready" || session.status === "detached" ? "attached" : session.status, + lastSeenAt: now, + message: + session.status === "pending_adapter" + ? "attach requested; runtime adapter pending" + : session.status === "provisioning" + ? "attach requested; workspace provisioning" + : "interactive terminal attached", + }; +} diff --git a/src/worker/session-repository.ts b/src/worker/session-repository.ts index 0e3b356..b6665c1 100644 --- a/src/worker/session-repository.ts +++ b/src/worker/session-repository.ts @@ -276,7 +276,7 @@ export async function countInteractiveSessionEvents(env: RuntimeEnv, id: string) return Number(row?.count ?? 0); } -export async function persistInteractiveSessionMetadataMutation( +export async function persistInteractiveSessionEventMutation( env: RuntimeEnv, session: { id: string; status: string; updatedAt: number }, actorName: string, diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 38e3b7a..761fcba 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -837,7 +837,9 @@ test("summary events invalidate terminal cleanup snapshots", async () => { const summaryStart = source.indexOf("async function updateInteractiveSessionSummary"); const summaryEnd = source.indexOf("async function updateGitHubActionsWorkState", summaryStart); const summarySource = source.slice(summaryStart, summaryEnd); - const metadataStart = source.indexOf("async function mutateInteractiveSessionMetadataAtomically"); + const metadataStart = source.indexOf( + "async function mutateInteractiveSessionWithEventAtomically", + ); const metadataEnd = source.indexOf("async function mutateInteractiveSession(", metadataStart); const metadataSource = source.slice(metadataStart, metadataEnd); @@ -846,8 +848,8 @@ test("summary events invalidate terminal cleanup snapshots", async () => { assert.match(cleanupSource, /event_count = \$\{archive\?\.event_count/); assert.match(cleanupSource, /archived_at = \$\{archive\?\.archived_at/); assert.match(cleanupSource, /count\(\*\)/); - assert.match(summarySource, /mutateInteractiveSessionMetadataAtomically/); - assert.match(metadataSource, /persistInteractiveSessionMetadataMutation/); + assert.match(summarySource, /mutateInteractiveSessionWithEventAtomically/); + assert.match(metadataSource, /persistInteractiveSessionEventMutation/); assert.match(metadataSource, /archiveInteractiveSessionLogs/); }); diff --git a/tests/session-attach.test.ts b/tests/session-attach.test.ts new file mode 100644 index 0000000..c3833fc --- /dev/null +++ b/tests/session-attach.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { HttpError } from "../src/worker/http.ts"; +import { + InteractiveSessionAttachService, + interactiveSessionAttachTransition, + type InteractiveSessionAttachStore, + type InteractiveSessionAttachTransition, +} from "../src/worker/session-attach.ts"; +import { interactiveSession, type InteractiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function fixture( + options: { + session?: InteractiveSession; + persisted?: boolean; + archiveFailure?: boolean; + reread?: InteractiveSession | null; + } = {}, +) { + const session = options.session ?? interactiveSession(sessionRow({ id: "IS-8" }), []); + const transitions: InteractiveSessionAttachTransition[] = []; + const archives: string[] = []; + const store: InteractiveSessionAttachStore = { + persist: async (_session, actor, transition) => { + assert.equal(actor, "operator"); + transitions.push(transition); + return options.persisted ?? true; + }, + archive: async (sessionId) => { + archives.push(sessionId); + if (options.archiveFailure) throw new Error("archive unavailable"); + }, + readSession: async () => (options.reread === undefined ? session : options.reread), + }; + return { + archives, + service: new InteractiveSessionAttachService(store), + session, + transitions, + }; +} + +function hasStatus(status: number): (error: unknown) => boolean { + return (error) => error instanceof Error && (error as HttpError).status === status; +} + +test("attach transitions ready and detached sessions to attached", () => { + assert.deepEqual( + interactiveSessionAttachTransition( + interactiveSession(sessionRow({ status: "ready" }), []), + 100, + ), + { + status: "attached", + lastSeenAt: 100, + message: "interactive terminal attached", + }, + ); + assert.equal( + interactiveSessionAttachTransition( + interactiveSession(sessionRow({ status: "detached" }), []), + 100, + ).status, + "attached", + ); +}); + +test("attach preserves pending lifecycle states with descriptive evidence", () => { + assert.equal( + interactiveSessionAttachTransition( + interactiveSession(sessionRow({ status: "pending_adapter" }), []), + 100, + ).message, + "attach requested; runtime adapter pending", + ); + assert.equal( + interactiveSessionAttachTransition( + interactiveSession(sessionRow({ status: "provisioning" }), []), + 100, + ).message, + "attach requested; workspace provisioning", + ); +}); + +test("attach requires terminal capability and current control", async () => { + const noTerminal = interactiveSession( + sessionRow({ + capabilities_json: JSON.stringify({ + terminal: false, + takeover: false, + vnc: false, + desktop: false, + logs: true, + artifacts: true, + }), + }), + [], + ); + await assert.rejects( + () => + fixture({ session: noTerminal }).service.attach({ + session: noTerminal, + actor: "operator", + canControl: true, + now: 100, + }), + hasStatus(400), + ); + const context = fixture(); + await assert.rejects( + () => + context.service.attach({ + session: context.session, + actor: "operator", + canControl: false, + now: 100, + }), + hasStatus(403), + ); +}); + +test("attach rejects stopping and terminal sessions", async () => { + for (const status of ["stopping", "stopped", "expired", "failed"] as const) { + const session = interactiveSession(sessionRow({ status }), []); + await assert.rejects( + () => + fixture({ session }).service.attach({ + session, + actor: "operator", + canControl: true, + now: 100, + }), + hasStatus(400), + ); + } +}); + +test("attach persists one fenced transition and tolerates archive failure", async () => { + const context = fixture({ archiveFailure: true }); + const result = await context.service.attach({ + session: context.session, + actor: "operator", + canControl: true, + now: 100, + }); + + assert.equal(result, context.session); + assert.deepEqual(context.transitions, [ + { + status: "attached", + lastSeenAt: 100, + message: "interactive terminal attached", + }, + ]); + assert.deepEqual(context.archives, ["IS-8"]); +}); + +test("attach reports lost lifecycle ownership and missing rereads", async () => { + const conflict = fixture({ persisted: false }); + await assert.rejects( + () => + conflict.service.attach({ + session: conflict.session, + actor: "operator", + canControl: true, + now: 100, + }), + hasStatus(409), + ); + assert.deepEqual(conflict.archives, []); + + const missing = fixture({ reread: null }); + await assert.rejects( + () => + missing.service.attach({ + session: missing.session, + actor: "operator", + canControl: true, + now: 100, + }), + hasStatus(404), + ); +}); diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts index 6f5ccd6..43089e4 100644 --- a/tests/session-repository.test.ts +++ b/tests/session-repository.test.ts @@ -7,7 +7,7 @@ import { countInteractiveSessionEvents, insertInteractiveSessionReservation, markInteractiveSessionPendingAdapter, - persistInteractiveSessionMetadataMutation, + persistInteractiveSessionEventMutation, persistInteractiveSessionProvisionResult, readInteractiveSessionEventRows, readInteractiveSessionLogArchives, @@ -322,9 +322,9 @@ test("session event counts normalize missing and persisted values", async () => ); }); -test("session metadata mutations persist events before fenced snapshot-invalidating updates", async () => { +test("session event mutations persist events before fenced snapshot-invalidating updates", async () => { let batch: PreparedStatement[] = []; - const updated = await persistInteractiveSessionMetadataMutation( + const updated = await persistInteractiveSessionEventMutation( runtimeEnv( () => { throw new Error("metadata mutation must execute as one batch"); @@ -364,9 +364,9 @@ test("session metadata mutations persist events before fenced snapshot-invalidat assert.ok(batch[1]?.parameters.includes(100)); }); -test("session metadata mutations report lost revision ownership", async () => { +test("session event mutations report lost revision ownership", async () => { assert.equal( - await persistInteractiveSessionMetadataMutation( + await persistInteractiveSessionEventMutation( runtimeEnv( () => { throw new Error("metadata mutation must execute as one batch"); From f563ff78584f013a551625deb5c3b690bc5e0fd1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 13:14:46 +0100 Subject: [PATCH 031/109] refactor: extract session stop routing --- CHANGELOG.md | 1 + src/index.ts | 356 ++++++++++++++-------------------- src/worker/session-stop.ts | 143 ++++++++++++++ tests/runtime-adapter.test.ts | 55 ++---- tests/session-stop.test.ts | 226 +++++++++++++++++++++ 5 files changed, 532 insertions(+), 249 deletions(-) create mode 100644 src/worker/session-stop.ts create mode 100644 tests/session-stop.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f13e61b..6205335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Move atomic interactive-session metadata/event persistence and terminal snapshot invalidation into the session repository. - Extract sharing, multiplayer, and delegated-control mutations into a directly tested session metadata service. - Extract terminal attach policy into a directly tested service and persist attach state plus evidence atomically. +- Extract interactive-session stop authorization, runtime routing, idempotency, cleanup sequencing, conflicts, and audits into a directly tested service. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 0b4f22f..fd1835c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -289,6 +289,11 @@ import { isInteractiveSessionMetadataAction, type InteractiveSessionMetadataStore, } from "./worker/session-metadata"; +import { + InteractiveSessionStopService, + type InteractiveSessionStopStore, + type RuntimeAdapterStopServiceResult, +} from "./worker/session-stop"; import { activeDelegatedController, sharedInteractiveSession } from "./worker/session-sharing"; import type { InteractiveProvisionResult } from "./worker/session-provisioning"; import { @@ -4512,6 +4517,144 @@ async function stopGitHubActionsSession( return true; } +async function stopRuntimeAdapterInteractiveSession( + env: RuntimeEnv, + user: User, + session: InteractiveSession, + now: number, +): Promise { + if (!session.adapterWorkspaceId) { + throw serviceUnavailable("runtime adapter workspace reference is incomplete"); + } + const stopClaimRevision = Math.max(now, session.updatedAt + 1); + const stopClaim = await database(env) + .updateTable("interactive_sessions") + .set({ + status: "stopping", + lease_id: null, + updated_at: stopClaimRevision, + last_event: "runtime adapter stop requested", + reconcile_error: null, + agent_token_hash: null, + attach_url: null, + vnc_url: null, + controller: null, + control_requested_by: null, + control_requested_at: null, + control_granted_at: null, + control_expires_at: null, + }) + .where("id", "=", session.id) + .where("status", "=", session.status) + .where("updated_at", "=", session.updatedAt) + .executeTakeFirst(); + if ((stopClaim.numUpdatedRows ?? 0n) === 0n) { + const current = await readInteractiveSession(env, session.id); + if ( + !current || + current.adapter !== runtimeAdapterName || + current.adapterWorkspaceId !== session.adapterWorkspaceId || + !["stopping", "stopped", "expired", "failed"].includes(current.status) + ) { + throw conflict("interactive session lifecycle changed; retry stop"); + } + return { session: current, auditAt: null }; + } + await appendInteractiveSessionEvent(env, session.id, user, "runtime adapter stop requested", now); + let adapterStop: RuntimeAdapterStopResult; + try { + adapterStop = await stopRuntimeAdapterWorkspaceForSession( + env, + session.id, + session.adapterWorkspaceId, + ); + } catch (error) { + const message = safeProviderError(error, [session.adapterWorkspaceId]); + const pendingMessage = `runtime adapter stop pending: ${message}`; + await persistRuntimeAdapterStopEvidence( + env, + session.id, + session.adapterWorkspaceId, + pendingMessage, + now, + message, + actor(user), + ); + throw serviceUnavailable(`runtime adapter stop failed: ${message}`); + } + if (adapterStop.status === "stopping") { + const lifecycle = await database(env) + .selectFrom("interactive_sessions") + .select("adapter_create_pending") + .where("id", "=", session.id) + .where("adapter", "=", runtimeAdapterName) + .where("adapter_workspace_id", "=", session.adapterWorkspaceId) + .where("status", "=", "stopping") + .executeTakeFirst(); + const message = lifecycle?.adapter_create_pending + ? `${adapterStop.message}; runtime adapter stop waiting for create resolution` + : adapterStop.message; + await persistRuntimeAdapterStopEvidence( + env, + session.id, + session.adapterWorkspaceId, + message, + now, + null, + actor(user), + ); + const current = await readInteractiveSession(env, session.id); + if (!current) throw notFound("interactive session not found"); + return { session: current, auditAt: null }; + } + const resolvedAt = Date.now(); + const resolved = await recordConfirmedRuntimeAdapterRelease( + env, + session.id, + session.adapterWorkspaceId, + resolvedAt, + adapterStop.message, + ); + const current = await readInteractiveSession(env, session.id); + if (!current) throw notFound("interactive session not found"); + return { + session: current, + auditAt: resolved === "failed" || resolved === "stopped" ? Date.now() : null, + }; +} + +function interactiveSessionStopService(env: RuntimeEnv, user: User): InteractiveSessionStopService { + const store: InteractiveSessionStopStore = { + isSandbox: isSandboxInteractiveSession, + stageTerminalCleanup: (sessionId, status, message, now) => + stageTerminalCredentialPolicyCleanupById(env, sessionId, status, message, now), + reconcileCleanup: (sessionId, now) => + reconcileCredentialPolicyCleanupBatch(env, now, sessionId), + readTerminalCleanupIntent: async (sessionId) => { + const row = await database(env) + .selectFrom("interactive_sessions") + .select("credential_cleanup_terminal_status") + .where("id", "=", sessionId) + .where("status", "=", "stopping") + .executeTakeFirst(); + return Boolean(row?.credential_cleanup_terminal_status); + }, + recordEvent: (sessionId, message, now) => + appendInteractiveSessionEvent(env, sessionId, user, message, now), + readSession: (sessionId) => readInteractiveSession(env, sessionId), + finalizeTerminal: (sessionId, status, now) => + finalizeTerminalInteractiveSession(env, sessionId, status, now), + stopGitHubActions: (session, actorName, now) => + stopGitHubActionsSession(env, session, actorName, now), + stopRuntimeAdapter: (session, _actorName, now) => + stopRuntimeAdapterInteractiveSession(env, user, session, now), + stopLegacy: (session, actorName, now) => + completeLegacyInteractiveSessionStop(env, session, actorName, now), + audit: (message, now) => audit(env, user, message, now), + }; + return new InteractiveSessionStopService(store, runtimeAdapterName); +} + async function mutateInteractiveSession( request: Request, env: RuntimeEnv, @@ -4562,213 +4705,14 @@ async function mutateInteractiveSession( } if (action === "stop") { - if (!canManage) throw forbidden("only the session owner or maintainer can stop"); - if (["stopped", "expired", "failed"].includes(session.status)) { - if (isSandboxInteractiveSession(session)) { - const staged = await stageTerminalCredentialPolicyCleanupById( - env, - session.id, - session.status as "stopped" | "expired" | "failed", - "sandbox credential cleanup pending", - now, - ); - if (!staged) throw conflict("interactive session lifecycle changed; retry stop"); - await reconcileCredentialPolicyCleanupBatch(env, now, session.id); - const current = await readInteractiveSession(env, session.id); - if (current) return { session: decorateInteractiveSession(current, user, env) }; - } - await finalizeTerminalInteractiveSession( - env, - session.id, - session.status as "stopped" | "expired" | "failed", - session.stoppedAt ?? now, - ).catch(() => undefined); - return { session: decorateInteractiveSession(session, user, env) }; - } - if (session.runtime === githubActionsRuntime) { - if (!(await stopGitHubActionsSession(env, session, userActor, now))) { - const current = await readInteractiveSession(env, id); - if (!current) throw notFound("interactive session not found"); - if (!deadInteractiveSessionStatuses.includes(current.status)) { - throw conflict("interactive session lifecycle changed; retry stop"); - } - return { session: decorateInteractiveSession(current, user, env) }; - } - await audit(env, user, `GitHub Actions session stopped ${id}`, now); - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), - }; - } - if (session.adapter === runtimeAdapterName) { - if (!session.adapterWorkspaceId) { - throw serviceUnavailable("runtime adapter workspace reference is incomplete"); - } - const stopClaimRevision = Math.max(now, session.updatedAt + 1); - const stopClaim = await database(env) - .updateTable("interactive_sessions") - .set({ - status: "stopping", - lease_id: null, - updated_at: stopClaimRevision, - last_event: "runtime adapter stop requested", - reconcile_error: null, - agent_token_hash: null, - attach_url: null, - vnc_url: null, - controller: null, - control_requested_by: null, - control_requested_at: null, - control_granted_at: null, - control_expires_at: null, - }) - .where("id", "=", id) - .where("status", "=", session.status) - .where("updated_at", "=", session.updatedAt) - .executeTakeFirst(); - if ((stopClaim.numUpdatedRows ?? 0n) === 0n) { - const current = await readInteractiveSession(env, id); - if ( - !current || - current.adapter !== runtimeAdapterName || - current.adapterWorkspaceId !== session.adapterWorkspaceId || - !["stopping", "stopped", "expired", "failed"].includes(current.status) - ) { - throw conflict("interactive session lifecycle changed; retry stop"); - } - return { - session: decorateInteractiveSession(current, user, env), - }; - } - await appendInteractiveSessionEvent(env, id, user, "runtime adapter stop requested", now); - let adapterStop: RuntimeAdapterStopResult; - try { - adapterStop = await stopRuntimeAdapterWorkspaceForSession( - env, - session.id, - session.adapterWorkspaceId, - ); - } catch (error) { - const message = safeProviderError(error, [session.adapterWorkspaceId]); - const pendingMessage = `runtime adapter stop pending: ${message}`; - await persistRuntimeAdapterStopEvidence( - env, - id, - session.adapterWorkspaceId, - pendingMessage, - now, - message, - actor(user), - ); - throw serviceUnavailable(`runtime adapter stop failed: ${message}`); - } - if (adapterStop.status === "stopping") { - const lifecycle = await database(env) - .selectFrom("interactive_sessions") - .select("adapter_create_pending") - .where("id", "=", id) - .where("adapter", "=", runtimeAdapterName) - .where("adapter_workspace_id", "=", session.adapterWorkspaceId) - .where("status", "=", "stopping") - .executeTakeFirst(); - const message = lifecycle?.adapter_create_pending - ? `${adapterStop.message}; runtime adapter stop waiting for create resolution` - : adapterStop.message; - await persistRuntimeAdapterStopEvidence( - env, - id, - session.adapterWorkspaceId, - message, - now, - null, - actor(user), - ); - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), - }; - } - const resolved = await recordConfirmedRuntimeAdapterRelease( - env, - id, - session.adapterWorkspaceId, - Date.now(), - adapterStop.message, - ); - if (resolved === "failed" || resolved === "stopped") { - await audit(env, user, `interactive session stopped ${id}`, Date.now()); - } - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), - }; - } - if (isSandboxInteractiveSession(session)) { - const message = "interactive workspace stop waiting for credential cleanup"; - const staged = await stageTerminalCredentialPolicyCleanupById( - env, - session.id, - "stopped", - message, - now, - ); - if (!staged) { - const current = await readInteractiveSession(env, id); - if (!current) throw notFound("interactive session not found"); - const terminalIntent = await database(env) - .selectFrom("interactive_sessions") - .select("credential_cleanup_terminal_status") - .where("id", "=", id) - .where("status", "=", "stopping") - .executeTakeFirst(); - if (terminalIntent?.credential_cleanup_terminal_status) { - return { session: decorateInteractiveSession(current, user, env) }; - } - if (["stopped", "expired", "failed"].includes(current.status)) { - return { session: decorateInteractiveSession(current, user, env) }; - } - throw conflict("interactive session lifecycle changed; retry stop"); - } - await appendInteractiveSessionEvent( - env, - id, - user, - "interactive workspace stop requested", - now, - ); - await reconcileCredentialPolicyCleanupBatch(env, now, id); - return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), - }; - } - if (!(await completeLegacyInteractiveSessionStop(env, session, actor(user), now))) { - const current = await readInteractiveSession(env, id); - if (!current) throw notFound("interactive session not found"); - if (!["stopped", "expired", "failed"].includes(current.status)) { - throw conflict("interactive session lifecycle changed; retry stop"); - } - return { session: decorateInteractiveSession(current, user, env) }; - } - await audit(env, user, `interactive session stopped ${id}`, now); + const stopped = await interactiveSessionStopService(env, user).stop({ + session, + actor: userActor, + canManage, + now, + }); return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), + session: decorateInteractiveSession(stopped, user, env), }; } diff --git a/src/worker/session-stop.ts b/src/worker/session-stop.ts new file mode 100644 index 0000000..e5b05e5 --- /dev/null +++ b/src/worker/session-stop.ts @@ -0,0 +1,143 @@ +import { conflict, forbidden, notFound } from "./http.ts"; +import { deadInteractiveSessionStatuses } from "./models.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export type InteractiveSessionTerminalStatus = "stopped" | "expired" | "failed"; + +export type RuntimeAdapterStopServiceResult = { + session: InteractiveSession; + auditAt: number | null; +}; + +export type InteractiveSessionStopStore = { + isSandbox(session: InteractiveSession): boolean; + stageTerminalCleanup( + sessionId: string, + status: InteractiveSessionTerminalStatus, + message: string, + now: number, + ): Promise; + reconcileCleanup(sessionId: string, now: number): Promise; + readTerminalCleanupIntent(sessionId: string): Promise; + recordEvent(sessionId: string, message: string, now: number): Promise; + readSession(sessionId: string): Promise; + finalizeTerminal( + sessionId: string, + status: InteractiveSessionTerminalStatus, + now: number, + ): Promise; + stopGitHubActions(session: InteractiveSession, actor: string, now: number): Promise; + stopRuntimeAdapter( + session: InteractiveSession, + actor: string, + now: number, + ): Promise; + stopLegacy(session: InteractiveSession, actor: string, now: number): Promise; + audit(message: string, now: number): Promise; +}; + +export class InteractiveSessionStopService { + private readonly store: InteractiveSessionStopStore; + private readonly runtimeAdapterName: string; + + constructor(store: InteractiveSessionStopStore, runtimeAdapterName: string) { + this.store = store; + this.runtimeAdapterName = runtimeAdapterName; + } + + async stop(input: { + session: InteractiveSession; + actor: string; + canManage: boolean; + now: number; + }): Promise { + const { actor, canManage, now, session } = input; + if (!canManage) throw forbidden("only the session owner or maintainer can stop"); + + const terminalStatus = interactiveSessionTerminalStatus(session); + if (terminalStatus) { + if (this.store.isSandbox(session)) { + const staged = await this.store.stageTerminalCleanup( + session.id, + terminalStatus, + "sandbox credential cleanup pending", + now, + ); + if (!staged) throw conflict("interactive session lifecycle changed; retry stop"); + await this.store.reconcileCleanup(session.id, now); + const current = await this.store.readSession(session.id); + if (current) return current; + } + await this.store + .finalizeTerminal(session.id, terminalStatus, session.stoppedAt ?? now) + .catch(() => undefined); + return session; + } + + if (session.runtime === "github_actions") { + if (!(await this.store.stopGitHubActions(session, actor, now))) { + const current = await this.readSession(session.id); + if (!deadInteractiveSessionStatuses.includes(current.status)) { + throw conflict("interactive session lifecycle changed; retry stop"); + } + return current; + } + await this.store.audit(`GitHub Actions session stopped ${session.id}`, now); + return this.readSession(session.id); + } + + if (session.adapter === this.runtimeAdapterName) { + const result = await this.store.stopRuntimeAdapter(session, actor, now); + if (result.auditAt !== null) { + await this.store.audit(`interactive session stopped ${session.id}`, result.auditAt); + } + return result.session; + } + + if (this.store.isSandbox(session)) { + return this.stopSandbox(session, now); + } + + if (!(await this.store.stopLegacy(session, actor, now))) { + const current = await this.readSession(session.id); + if (!deadInteractiveSessionStatuses.includes(current.status)) { + throw conflict("interactive session lifecycle changed; retry stop"); + } + return current; + } + await this.store.audit(`interactive session stopped ${session.id}`, now); + return this.readSession(session.id); + } + + private async stopSandbox(session: InteractiveSession, now: number): Promise { + const staged = await this.store.stageTerminalCleanup( + session.id, + "stopped", + "interactive workspace stop waiting for credential cleanup", + now, + ); + if (!staged) { + const current = await this.readSession(session.id); + if (await this.store.readTerminalCleanupIntent(session.id)) return current; + if (deadInteractiveSessionStatuses.includes(current.status)) return current; + throw conflict("interactive session lifecycle changed; retry stop"); + } + await this.store.recordEvent(session.id, "interactive workspace stop requested", now); + await this.store.reconcileCleanup(session.id, now); + return this.readSession(session.id); + } + + private async readSession(sessionId: string): Promise { + const session = await this.store.readSession(sessionId); + if (!session) throw notFound("interactive session not found"); + return session; + } +} + +export function interactiveSessionTerminalStatus( + session: Pick, +): InteractiveSessionTerminalStatus | null { + return deadInteractiveSessionStatuses.includes(session.status) + ? (session.status as InteractiveSessionTerminalStatus) + : null; +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 761fcba..c4dc0f9 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -1330,20 +1330,17 @@ test("create-only adapters reject stopping responses before persistence", async assert.match(forwardedSource, /if \(!status\) return failedProvision/); }); -test("Sandbox cleanup and legacy stops use durable terminal transitions", async () => { +test("legacy and GitHub Actions stops use durable terminal transitions", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const actionStart = source.indexOf('if (action === "stop")'); - const actionEnd = source.indexOf('throw badRequest("unknown action")', actionStart); - const stopSource = source.slice(actionStart, actionEnd); - const legacyCompleteIndex = stopSource.indexOf("completeLegacyInteractiveSessionStop"); - const cleanupIndex = stopSource.lastIndexOf( - "stageTerminalCredentialPolicyCleanupById", - legacyCompleteIndex, - ); - const reconcileIndex = stopSource.indexOf("reconcileCredentialPolicyCleanupBatch", cleanupIndex); const completeStart = source.indexOf("async function completeLegacyInteractiveSessionStop"); - const completeEnd = source.indexOf("async function mutateInteractiveSession(", completeStart); + const completeEnd = source.indexOf("async function stopGitHubActionsSession", completeStart); const completeSource = source.slice(completeStart, completeEnd); + const githubActionsStart = source.indexOf("async function stopGitHubActionsSession"); + const githubActionsEnd = source.indexOf( + "async function stopRuntimeAdapterInteractiveSession", + githubActionsStart, + ); + const githubActionsSource = source.slice(githubActionsStart, githubActionsEnd); const scheduledStart = source.indexOf( "async function reconcileLegacyStoppingInteractiveSessionBatch", ); @@ -1353,17 +1350,6 @@ test("Sandbox cleanup and legacy stops use durable terminal transitions", async ); const scheduledSource = source.slice(scheduledStart, scheduledEnd); - assert.ok(actionStart >= 0 && actionEnd > actionStart); - assert.ok( - cleanupIndex >= 0 && reconcileIndex > cleanupIndex && legacyCompleteIndex > reconcileIndex, - ); - assert.match(stopSource, /const staged = await stageTerminalCredentialPolicyCleanupById/); - assert.match(stopSource, /if \(!staged\)/); - assert.match(stopSource, /credential_cleanup_terminal_status/); - assert.match( - stopSource, - /completeLegacyInteractiveSessionStop\(env, session, actor\(user\), now\)/, - ); assert.match(completeSource, /env\.DB\.batch/); assert.match(completeSource, /interactive workspace stop requested/); assert.match(completeSource, /interactive workspace stopped/); @@ -1377,17 +1363,9 @@ test("Sandbox cleanup and legacy stops use durable terminal transitions", async assert.match(scheduledSource, /\.where\("runtime", "!=", githubActionsRuntime\)/); assert.match(scheduledSource, /completeLegacyInteractiveSessionStop/); assert.match(completeSource, /if \(owner\.runtime === githubActionsRuntime\) return false/); - assert.match(completeSource, /async function stopGitHubActionsSession/); - assert.match(completeSource, /work_state: ""/); - assert.match(completeSource, /work_phase: "session_ended"/); - assert.match(completeSource, /workflow run not canceled/); - assert.match(stopSource, /session\.runtime === githubActionsRuntime/); - assert.match(stopSource, /stopGitHubActionsSession\(env, session, userActor, now\)/); - assert.match(stopSource, /interactive session lifecycle changed; retry stop/); - assert.match(stopSource, /const current = await readInteractiveSession\(env, id\)/); - assert.match(stopSource, /current\.adapter !== runtimeAdapterName/); - assert.match(stopSource, /current\.adapterWorkspaceId !== session\.adapterWorkspaceId/); - assert.match(stopSource, /\["stopping", "stopped", "expired", "failed"\]\.includes/); + assert.match(githubActionsSource, /work_state: ""/); + assert.match(githubActionsSource, /work_phase: "session_ended"/); + assert.match(githubActionsSource, /workflow run not canceled/); }); test("legacy expiry enters the shared retryable terminal finalizer", async () => { @@ -1413,14 +1391,8 @@ test("legacy expiry enters the shared retryable terminal finalizer", async () => ); }); -test("idempotent legacy terminal stop verifies credential cleanup", async () => { +test("sandbox credential cleanup failures remain explicit", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const actionStart = source.indexOf('if (action === "stop")'); - const legacyCompleteIndex = source.indexOf( - "completeLegacyInteractiveSessionStop(env, session", - actionStart, - ); - const fastPathSource = source.slice(actionStart, legacyCompleteIndex); const unregisterStart = source.indexOf("async function unregisterSandboxCredentialPolicyLookup"); const unregisterEnd = source.indexOf( "function sandboxCredentialPolicyRefQueries", @@ -1428,9 +1400,6 @@ test("idempotent legacy terminal stop verifies credential cleanup", async () => ); const unregisterSource = source.slice(unregisterStart, unregisterEnd); - assert.match(fastPathSource, /isSandboxInteractiveSession\(session\)/); - assert.match(fastPathSource, /stageTerminalCredentialPolicyCleanup/); - assert.match(fastPathSource, /reconcileCredentialPolicyCleanupBatch/); assert.match(unregisterSource, /if \(!stub\) throw serviceUnavailable/); assert.match(unregisterSource, /if \(!response\.ok\)/); assert.doesNotMatch(unregisterSource, /response\.status !== 404/); diff --git a/tests/session-stop.test.ts b/tests/session-stop.test.ts new file mode 100644 index 0000000..cef64be --- /dev/null +++ b/tests/session-stop.test.ts @@ -0,0 +1,226 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { HttpError } from "../src/worker/http.ts"; +import { + InteractiveSessionStopService, + interactiveSessionTerminalStatus, + type InteractiveSessionStopStore, + type InteractiveSessionTerminalStatus, + type RuntimeAdapterStopServiceResult, +} from "../src/worker/session-stop.ts"; +import { interactiveSession, type InteractiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +type StopCalls = { + staged: Array<{ status: InteractiveSessionTerminalStatus; message: string }>; + reconciled: string[]; + events: string[]; + finalized: InteractiveSessionTerminalStatus[]; + githubActions: number; + runtimeAdapter: number; + legacy: number; + audits: string[]; +}; + +function fixture( + options: { + session?: InteractiveSession; + sandbox?: boolean; + staged?: boolean; + cleanupIntent?: boolean; + reread?: InteractiveSession | null; + githubActionsStopped?: boolean; + runtimeAdapterResult?: RuntimeAdapterStopServiceResult; + legacyStopped?: boolean; + finalizeFailure?: boolean; + } = {}, +) { + const session = options.session ?? interactiveSession(sessionRow({ id: "IS-9" }), []); + const calls: StopCalls = { + staged: [], + reconciled: [], + events: [], + finalized: [], + githubActions: 0, + runtimeAdapter: 0, + legacy: 0, + audits: [], + }; + const store: InteractiveSessionStopStore = { + isSandbox: () => options.sandbox ?? false, + stageTerminalCleanup: async (_id, status, message) => { + calls.staged.push({ status, message }); + return options.staged ?? true; + }, + reconcileCleanup: async (id) => { + calls.reconciled.push(id); + }, + readTerminalCleanupIntent: async () => options.cleanupIntent ?? false, + recordEvent: async (_id, message) => { + calls.events.push(message); + }, + readSession: async () => (options.reread === undefined ? session : options.reread), + finalizeTerminal: async (_id, status) => { + calls.finalized.push(status); + if (options.finalizeFailure) throw new Error("finalizer unavailable"); + }, + stopGitHubActions: async () => { + calls.githubActions += 1; + return options.githubActionsStopped ?? true; + }, + stopRuntimeAdapter: async () => { + calls.runtimeAdapter += 1; + return options.runtimeAdapterResult ?? { session, auditAt: null }; + }, + stopLegacy: async () => { + calls.legacy += 1; + return options.legacyStopped ?? true; + }, + audit: async (message) => { + calls.audits.push(message); + }, + }; + return { + calls, + service: new InteractiveSessionStopService(store, "runtime-v1"), + session, + }; +} + +function hasStatus(status: number): (error: unknown) => boolean { + return (error) => error instanceof Error && (error as HttpError).status === status; +} + +async function stop( + context: ReturnType, + canManage = true, +): Promise { + return context.service.stop({ + session: context.session, + actor: "operator", + canManage, + now: 100, + }); +} + +test("terminal status detection owns only stable terminal states", () => { + for (const status of ["stopped", "expired", "failed"] as const) { + assert.equal( + interactiveSessionTerminalStatus(interactiveSession(sessionRow({ status }), [])), + status, + ); + } + assert.equal( + interactiveSessionTerminalStatus(interactiveSession(sessionRow({ status: "stopping" }), [])), + null, + ); +}); + +test("stop requires session management authority", async () => { + const context = fixture(); + await assert.rejects(() => stop(context, false), hasStatus(403)); + assert.equal(context.calls.legacy, 0); +}); + +test("terminal sandbox stops stage cleanup and return the reconciled session", async () => { + const terminal = interactiveSession( + sessionRow({ id: "IS-9", status: "failed", stopped_at: 80 }), + [], + ); + const current = interactiveSession(sessionRow({ id: "IS-9", status: "failed" }), []); + const context = fixture({ session: terminal, sandbox: true, reread: current }); + + assert.equal(await stop(context), current); + assert.deepEqual(context.calls.staged, [ + { status: "failed", message: "sandbox credential cleanup pending" }, + ]); + assert.deepEqual(context.calls.reconciled, ["IS-9"]); + assert.deepEqual(context.calls.finalized, []); +}); + +test("terminal non-sandbox stops retry finalization without changing the response", async () => { + const terminal = interactiveSession(sessionRow({ status: "expired", stopped_at: 80 }), []); + const context = fixture({ session: terminal, finalizeFailure: true }); + + assert.equal(await stop(context), terminal); + assert.deepEqual(context.calls.finalized, ["expired"]); +}); + +test("GitHub Actions stop audits success and accepts a concurrent terminal result", async () => { + const session = interactiveSession( + sessionRow({ id: "IS-9", runtime: "github_actions", capabilities_json: "{}" }), + [], + ); + const success = fixture({ session }); + assert.equal(await stop(success), session); + assert.deepEqual(success.calls.audits, ["GitHub Actions session stopped IS-9"]); + + const stopped = interactiveSession(sessionRow({ id: "IS-9", status: "stopped" }), []); + const raced = fixture({ session, githubActionsStopped: false, reread: stopped }); + assert.equal(await stop(raced), stopped); + assert.deepEqual(raced.calls.audits, []); +}); + +test("runtime adapter stops delegate mechanics and audit confirmed release", async () => { + const session = interactiveSession( + sessionRow({ id: "IS-9", adapter: "runtime-v1", adapter_workspace_id: "workspace-9" }), + [], + ); + const stopped = interactiveSession(sessionRow({ id: "IS-9", status: "stopped" }), []); + const context = fixture({ + session, + runtimeAdapterResult: { session: stopped, auditAt: 140 }, + }); + + assert.equal(await stop(context), stopped); + assert.equal(context.calls.runtimeAdapter, 1); + assert.deepEqual(context.calls.audits, ["interactive session stopped IS-9"]); +}); + +test("sandbox stops record intent before cleanup reconciliation", async () => { + const context = fixture({ sandbox: true }); + + assert.equal(await stop(context), context.session); + assert.deepEqual(context.calls.staged, [ + { + status: "stopped", + message: "interactive workspace stop waiting for credential cleanup", + }, + ]); + assert.deepEqual(context.calls.events, ["interactive workspace stop requested"]); + assert.deepEqual(context.calls.reconciled, ["IS-9"]); + assert.equal(context.calls.legacy, 0); +}); + +test("sandbox stop races accept durable intent or terminal state and reject live ownership loss", async () => { + const intent = fixture({ sandbox: true, staged: false, cleanupIntent: true }); + assert.equal(await stop(intent), intent.session); + + const terminal = interactiveSession(sessionRow({ status: "stopped" }), []); + const completed = fixture({ sandbox: true, staged: false, reread: terminal }); + assert.equal(await stop(completed), terminal); + + const lost = fixture({ sandbox: true, staged: false }); + await assert.rejects(() => stop(lost), hasStatus(409)); +}); + +test("legacy stops audit owned completion and accept concurrent terminal completion", async () => { + const success = fixture(); + assert.equal(await stop(success), success.session); + assert.deepEqual(success.calls.audits, ["interactive session stopped IS-9"]); + + const terminal = interactiveSession(sessionRow({ status: "stopped" }), []); + const raced = fixture({ legacyStopped: false, reread: terminal }); + assert.equal(await stop(raced), terminal); + assert.deepEqual(raced.calls.audits, []); +}); + +test("stop races reject live rereads and missing sessions", async () => { + const githubActions = interactiveSession(sessionRow({ runtime: "github_actions" }), []); + await assert.rejects( + () => stop(fixture({ session: githubActions, githubActionsStopped: false })), + hasStatus(409), + ); + await assert.rejects(() => stop(fixture({ legacyStopped: false, reread: null })), hasStatus(404)); +}); From 450d7e2383f43d69c1704472e0565f77678177c6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 13:22:42 +0100 Subject: [PATCH 032/109] refactor: extract runtime adapter stop service --- CHANGELOG.md | 1 + src/index.ts | 181 ++++++++------------ src/worker/session-runtime-adapter-stop.ts | 125 ++++++++++++++ src/worker/session-stop.ts | 6 +- tests/runtime-adapter.test.ts | 7 +- tests/session-runtime-adapter-stop.test.ts | 188 +++++++++++++++++++++ tests/session-stop.test.ts | 2 +- 7 files changed, 388 insertions(+), 122 deletions(-) create mode 100644 src/worker/session-runtime-adapter-stop.ts create mode 100644 tests/session-runtime-adapter-stop.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6205335..da51bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Extract sharing, multiplayer, and delegated-control mutations into a directly tested session metadata service. - Extract terminal attach policy into a directly tested service and persist attach state plus evidence atomically. - Extract interactive-session stop authorization, runtime routing, idempotency, cleanup sequencing, conflicts, and audits into a directly tested service. +- Extract runtime-adapter stop claim, provider outcome, retry evidence, create-resolution, and confirmed-release orchestration into a directly tested service. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index fd1835c..79b4813 100644 --- a/src/index.ts +++ b/src/index.ts @@ -292,8 +292,12 @@ import { import { InteractiveSessionStopService, type InteractiveSessionStopStore, - type RuntimeAdapterStopServiceResult, } from "./worker/session-stop"; +import { + RuntimeAdapterStopService, + type RuntimeAdapterStopStore, + type RuntimeAdapterWorkspaceStopResult, +} from "./worker/session-runtime-adapter-stop"; import { activeDelegatedController, sharedInteractiveSession } from "./worker/session-sharing"; import type { InteractiveProvisionResult } from "./worker/session-provisioning"; import { @@ -4517,110 +4521,60 @@ async function stopGitHubActionsSession( return true; } -async function stopRuntimeAdapterInteractiveSession( - env: RuntimeEnv, - user: User, - session: InteractiveSession, - now: number, -): Promise { - if (!session.adapterWorkspaceId) { - throw serviceUnavailable("runtime adapter workspace reference is incomplete"); - } - const stopClaimRevision = Math.max(now, session.updatedAt + 1); - const stopClaim = await database(env) - .updateTable("interactive_sessions") - .set({ - status: "stopping", - lease_id: null, - updated_at: stopClaimRevision, - last_event: "runtime adapter stop requested", - reconcile_error: null, - agent_token_hash: null, - attach_url: null, - vnc_url: null, - controller: null, - control_requested_by: null, - control_requested_at: null, - control_granted_at: null, - control_expires_at: null, - }) - .where("id", "=", session.id) - .where("status", "=", session.status) - .where("updated_at", "=", session.updatedAt) - .executeTakeFirst(); - if ((stopClaim.numUpdatedRows ?? 0n) === 0n) { - const current = await readInteractiveSession(env, session.id); - if ( - !current || - current.adapter !== runtimeAdapterName || - current.adapterWorkspaceId !== session.adapterWorkspaceId || - !["stopping", "stopped", "expired", "failed"].includes(current.status) - ) { - throw conflict("interactive session lifecycle changed; retry stop"); - } - return { session: current, auditAt: null }; - } - await appendInteractiveSessionEvent(env, session.id, user, "runtime adapter stop requested", now); - let adapterStop: RuntimeAdapterStopResult; - try { - adapterStop = await stopRuntimeAdapterWorkspaceForSession( - env, - session.id, - session.adapterWorkspaceId, - ); - } catch (error) { - const message = safeProviderError(error, [session.adapterWorkspaceId]); - const pendingMessage = `runtime adapter stop pending: ${message}`; - await persistRuntimeAdapterStopEvidence( - env, - session.id, - session.adapterWorkspaceId, - pendingMessage, - now, - message, - actor(user), - ); - throw serviceUnavailable(`runtime adapter stop failed: ${message}`); - } - if (adapterStop.status === "stopping") { - const lifecycle = await database(env) - .selectFrom("interactive_sessions") - .select("adapter_create_pending") - .where("id", "=", session.id) - .where("adapter", "=", runtimeAdapterName) - .where("adapter_workspace_id", "=", session.adapterWorkspaceId) - .where("status", "=", "stopping") - .executeTakeFirst(); - const message = lifecycle?.adapter_create_pending - ? `${adapterStop.message}; runtime adapter stop waiting for create resolution` - : adapterStop.message; - await persistRuntimeAdapterStopEvidence( - env, - session.id, - session.adapterWorkspaceId, - message, - now, - null, - actor(user), - ); - const current = await readInteractiveSession(env, session.id); - if (!current) throw notFound("interactive session not found"); - return { session: current, auditAt: null }; - } - const resolvedAt = Date.now(); - const resolved = await recordConfirmedRuntimeAdapterRelease( - env, - session.id, - session.adapterWorkspaceId, - resolvedAt, - adapterStop.message, - ); - const current = await readInteractiveSession(env, session.id); - if (!current) throw notFound("interactive session not found"); - return { - session: current, - auditAt: resolved === "failed" || resolved === "stopped" ? Date.now() : null, +function interactiveSessionRuntimeAdapterStopService(env: RuntimeEnv): RuntimeAdapterStopService { + const store: RuntimeAdapterStopStore = { + claimStop: (session, actorName, now) => + persistInteractiveSessionEventMutation( + env, + session, + actorName, + "runtime adapter stop requested", + { + status: "stopping", + lease_id: null, + reconcile_error: null, + agent_token_hash: null, + attach_url: null, + vnc_url: null, + controller: null, + control_requested_by: null, + control_requested_at: null, + control_granted_at: null, + control_expires_at: null, + }, + now, + ), + archive: (sessionId, now) => archiveInteractiveSessionLogs(env, sessionId, now), + readSession: (sessionId) => readInteractiveSession(env, sessionId), + stopWorkspace: (sessionId, adapterWorkspaceId) => + stopRuntimeAdapterWorkspaceForSession(env, sessionId, adapterWorkspaceId), + providerError: (error, adapterWorkspaceId) => safeProviderError(error, [adapterWorkspaceId]), + persistEvidence: (sessionId, adapterWorkspaceId, message, now, reconcileError, actorName) => + persistRuntimeAdapterStopEvidence( + env, + sessionId, + adapterWorkspaceId, + message, + now, + reconcileError, + actorName, + ), + readCreatePending: async (sessionId, adapterWorkspaceId) => { + const lifecycle = await database(env) + .selectFrom("interactive_sessions") + .select("adapter_create_pending") + .where("id", "=", sessionId) + .where("adapter", "=", runtimeAdapterName) + .where("adapter_workspace_id", "=", adapterWorkspaceId) + .where("status", "=", "stopping") + .executeTakeFirst(); + return lifecycle?.adapter_create_pending === 1; + }, + confirmRelease: (sessionId, adapterWorkspaceId, now, message) => + recordConfirmedRuntimeAdapterRelease(env, sessionId, adapterWorkspaceId, now, message), + now: Date.now, }; + return new RuntimeAdapterStopService(store, runtimeAdapterName); } function interactiveSessionStopService(env: RuntimeEnv, user: User): InteractiveSessionStopService { @@ -4646,8 +4600,12 @@ function interactiveSessionStopService(env: RuntimeEnv, user: User): Interactive finalizeTerminalInteractiveSession(env, sessionId, status, now), stopGitHubActions: (session, actorName, now) => stopGitHubActionsSession(env, session, actorName, now), - stopRuntimeAdapter: (session, _actorName, now) => - stopRuntimeAdapterInteractiveSession(env, user, session, now), + stopRuntimeAdapter: (session, actorName, now) => + interactiveSessionRuntimeAdapterStopService(env).stop({ + session, + actor: actorName, + now, + }), stopLegacy: (session, actorName, now) => completeLegacyInteractiveSessionStop(env, session, actorName, now), audit: (message, now) => audit(env, user, message, now), @@ -11093,7 +11051,7 @@ async function reconcileStoppingRuntimeAdapterWorkspace( } } - let release: RuntimeAdapterStopResult; + let release: RuntimeAdapterWorkspaceStopResult; try { release = await stopRuntimeAdapterWorkspace( env, @@ -11373,7 +11331,7 @@ async function stopRuntimeAdapterWorkspace( profile: string, registeredControlPlane: string, adapterWorkspaceId: string, -): Promise { +): Promise { const controlPlane = requireRegisteredRuntimeAdapterControlPlane( env, profile, @@ -11406,16 +11364,11 @@ async function stopRuntimeAdapterWorkspace( return { status: outcome, message }; } -type RuntimeAdapterStopResult = { - status: "stopping" | "stopped"; - message: string; -}; - async function stopRuntimeAdapterWorkspaceForSession( env: RuntimeEnv, sessionId: string, adapterWorkspaceId: string, -): Promise { +): Promise { const registration = await database(env) .selectFrom("interactive_sessions") .select(["adapter_control_plane", "adapter_create_pending", "profile"]) diff --git a/src/worker/session-runtime-adapter-stop.ts b/src/worker/session-runtime-adapter-stop.ts new file mode 100644 index 0000000..02ea4d4 --- /dev/null +++ b/src/worker/session-runtime-adapter-stop.ts @@ -0,0 +1,125 @@ +import { conflict, notFound, serviceUnavailable } from "./http.ts"; +import { deadInteractiveSessionStatuses } from "./models.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export type RuntimeAdapterWorkspaceStopResult = { + status: "stopping" | "stopped"; + message: string; +}; + +export type RuntimeAdapterStopServiceResult = { + session: InteractiveSession; + auditAt: number | null; +}; + +export type RuntimeAdapterStopStore = { + claimStop(session: InteractiveSession, actor: string, now: number): Promise; + archive(sessionId: string, now: number): Promise; + readSession(sessionId: string): Promise; + stopWorkspace( + sessionId: string, + adapterWorkspaceId: string, + ): Promise; + providerError(error: unknown, adapterWorkspaceId: string): string; + persistEvidence( + sessionId: string, + adapterWorkspaceId: string, + message: string, + now: number, + reconcileError: string | null, + actor: string, + ): Promise; + readCreatePending(sessionId: string, adapterWorkspaceId: string): Promise; + confirmRelease( + sessionId: string, + adapterWorkspaceId: string, + now: number, + message: string, + ): Promise<"stopping" | "stopped" | "failed" | null>; + now(): number; +}; + +export class RuntimeAdapterStopService { + private readonly store: RuntimeAdapterStopStore; + private readonly adapterName: string; + + constructor(store: RuntimeAdapterStopStore, adapterName: string) { + this.store = store; + this.adapterName = adapterName; + } + + async stop(input: { + session: InteractiveSession; + actor: string; + now: number; + }): Promise { + const { actor, now, session } = input; + const adapterWorkspaceId = session.adapterWorkspaceId; + if (!adapterWorkspaceId) { + throw serviceUnavailable("runtime adapter workspace reference is incomplete"); + } + + if (!(await this.store.claimStop(session, actor, now))) { + const current = await this.store.readSession(session.id); + if ( + !current || + current.adapter !== this.adapterName || + current.adapterWorkspaceId !== adapterWorkspaceId || + (current.status !== "stopping" && !deadInteractiveSessionStatuses.includes(current.status)) + ) { + throw conflict("interactive session lifecycle changed; retry stop"); + } + return { session: current, auditAt: null }; + } + await this.store.archive(session.id, now).catch(() => undefined); + + let stop: RuntimeAdapterWorkspaceStopResult; + try { + stop = await this.store.stopWorkspace(session.id, adapterWorkspaceId); + } catch (error) { + const message = this.store.providerError(error, adapterWorkspaceId); + await this.store.persistEvidence( + session.id, + adapterWorkspaceId, + `runtime adapter stop pending: ${message}`, + now, + message, + actor, + ); + throw serviceUnavailable(`runtime adapter stop failed: ${message}`); + } + + if (stop.status === "stopping") { + const createPending = await this.store.readCreatePending(session.id, adapterWorkspaceId); + await this.store.persistEvidence( + session.id, + adapterWorkspaceId, + createPending + ? `${stop.message}; runtime adapter stop waiting for create resolution` + : stop.message, + now, + null, + actor, + ); + return { session: await this.readSession(session.id), auditAt: null }; + } + + const resolvedAt = this.store.now(); + const resolved = await this.store.confirmRelease( + session.id, + adapterWorkspaceId, + resolvedAt, + stop.message, + ); + return { + session: await this.readSession(session.id), + auditAt: resolved === "failed" || resolved === "stopped" ? this.store.now() : null, + }; + } + + private async readSession(sessionId: string): Promise { + const session = await this.store.readSession(sessionId); + if (!session) throw notFound("interactive session not found"); + return session; + } +} diff --git a/src/worker/session-stop.ts b/src/worker/session-stop.ts index e5b05e5..212f49b 100644 --- a/src/worker/session-stop.ts +++ b/src/worker/session-stop.ts @@ -1,14 +1,10 @@ import { conflict, forbidden, notFound } from "./http.ts"; import { deadInteractiveSessionStatuses } from "./models.ts"; +import type { RuntimeAdapterStopServiceResult } from "./session-runtime-adapter-stop.ts"; import type { InteractiveSession } from "./session-model.ts"; export type InteractiveSessionTerminalStatus = "stopped" | "expired" | "failed"; -export type RuntimeAdapterStopServiceResult = { - session: InteractiveSession; - auditAt: number | null; -}; - export type InteractiveSessionStopStore = { isSandbox(session: InteractiveSession): boolean; stageTerminalCleanup( diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index c4dc0f9..38418bd 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -1337,7 +1337,7 @@ test("legacy and GitHub Actions stops use durable terminal transitions", async ( const completeSource = source.slice(completeStart, completeEnd); const githubActionsStart = source.indexOf("async function stopGitHubActionsSession"); const githubActionsEnd = source.indexOf( - "async function stopRuntimeAdapterInteractiveSession", + "function interactiveSessionRuntimeAdapterStopService", githubActionsStart, ); const githubActionsSource = source.slice(githubActionsStart, githubActionsEnd); @@ -1992,7 +1992,10 @@ test("adapter bodies share the bounded stream reader", async () => { "async function replayStoppingRuntimeAdapterCreate", "async function stopRuntimeAdapterWorkspace(", ], - ["async function stopRuntimeAdapterWorkspace(", "type RuntimeAdapterStopResult"], + [ + "async function stopRuntimeAdapterWorkspace(", + "async function stopRuntimeAdapterWorkspaceForSession", + ], ["async function interactiveSessionVnc", "function interactiveTerminalTarget"], ] as const; for (const [startMarker, endMarker] of ranges) { diff --git a/tests/session-runtime-adapter-stop.test.ts b/tests/session-runtime-adapter-stop.test.ts new file mode 100644 index 0000000..315ab5a --- /dev/null +++ b/tests/session-runtime-adapter-stop.test.ts @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { HttpError } from "../src/worker/http.ts"; +import { + RuntimeAdapterStopService, + type RuntimeAdapterStopStore, + type RuntimeAdapterWorkspaceStopResult, +} from "../src/worker/session-runtime-adapter-stop.ts"; +import { interactiveSession, type InteractiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function fixture( + options: { + session?: InteractiveSession; + claimed?: boolean; + reread?: InteractiveSession | null; + archiveFailure?: boolean; + stop?: RuntimeAdapterWorkspaceStopResult; + stopError?: unknown; + createPending?: boolean; + confirmed?: "stopping" | "stopped" | "failed" | null; + } = {}, +) { + const session = + options.session ?? + interactiveSession( + sessionRow({ + id: "IS-10", + adapter: "runtime-v1", + adapter_workspace_id: "workspace-10", + }), + [], + ); + const claims: Array<{ actor: string; now: number }> = []; + const archives: string[] = []; + const evidence: Array<{ + message: string; + reconcileError: string | null; + actor: string; + }> = []; + const releases: Array<{ now: number; message: string }> = []; + let clock = 200; + const store: RuntimeAdapterStopStore = { + claimStop: async (_session, actor, now) => { + claims.push({ actor, now }); + return options.claimed ?? true; + }, + archive: async (sessionId) => { + archives.push(sessionId); + if (options.archiveFailure) throw new Error("archive unavailable"); + }, + readSession: async () => (options.reread === undefined ? session : options.reread), + stopWorkspace: async () => { + if (options.stopError !== undefined) throw options.stopError; + return options.stop ?? { status: "stopped", message: "workspace released" }; + }, + providerError: () => "redacted provider failure", + persistEvidence: async (_id, _workspaceId, message, _now, reconcileError, actor) => { + evidence.push({ message, reconcileError, actor }); + }, + readCreatePending: async () => options.createPending ?? false, + confirmRelease: async (_id, _workspaceId, now, message) => { + releases.push({ now, message }); + return options.confirmed ?? "stopped"; + }, + now: () => { + clock += 1; + return clock; + }, + }; + return { + archives, + claims, + evidence, + releases, + service: new RuntimeAdapterStopService(store, "runtime-v1"), + session, + }; +} + +function hasStatus(status: number): (error: unknown) => boolean { + return (error) => error instanceof Error && (error as HttpError).status === status; +} + +async function stop(context: ReturnType) { + return context.service.stop({ + session: context.session, + actor: "operator", + now: 100, + }); +} + +test("runtime adapter stop requires a persisted workspace identity", async () => { + const session = interactiveSession( + sessionRow({ adapter: "runtime-v1", adapter_workspace_id: null }), + [], + ); + await assert.rejects(() => stop(fixture({ session })), hasStatus(503)); +}); + +test("lost stop claims accept only the same adapter workspace in stopping or terminal state", async () => { + const stopping = interactiveSession( + sessionRow({ + id: "IS-10", + adapter: "runtime-v1", + adapter_workspace_id: "workspace-10", + status: "stopping", + }), + [], + ); + const accepted = fixture({ claimed: false, reread: stopping }); + assert.equal((await stop(accepted)).session, stopping); + assert.deepEqual(accepted.archives, []); + + const different = interactiveSession( + sessionRow({ + id: "IS-10", + adapter: "runtime-v1", + adapter_workspace_id: "workspace-other", + status: "stopping", + }), + [], + ); + await assert.rejects(() => stop(fixture({ claimed: false, reread: different })), hasStatus(409)); +}); + +test("pending provider stops persist create-resolution evidence", async () => { + const context = fixture({ + archiveFailure: true, + stop: { status: "stopping", message: "provider stopping" }, + createPending: true, + }); + const result = await stop(context); + + assert.equal(result.session, context.session); + assert.equal(result.auditAt, null); + assert.deepEqual(context.claims, [{ actor: "operator", now: 100 }]); + assert.deepEqual(context.archives, ["IS-10"]); + assert.deepEqual(context.evidence, [ + { + message: "provider stopping; runtime adapter stop waiting for create resolution", + reconcileError: null, + actor: "operator", + }, + ]); +}); + +test("provider stop failures persist redacted retry evidence", async () => { + const context = fixture({ stopError: new Error("secret provider detail") }); + await assert.rejects(() => stop(context), hasStatus(503)); + assert.deepEqual(context.evidence, [ + { + message: "runtime adapter stop pending: redacted provider failure", + reconcileError: "redacted provider failure", + actor: "operator", + }, + ]); +}); + +test("confirmed releases return the durable session and a post-release audit clock", async () => { + const stopped = interactiveSession( + sessionRow({ + id: "IS-10", + adapter: "runtime-v1", + adapter_workspace_id: "workspace-10", + status: "stopped", + }), + [], + ); + const context = fixture({ reread: stopped, confirmed: "stopped" }); + const result = await stop(context); + + assert.equal(result.session, stopped); + assert.equal(result.auditAt, 202); + assert.deepEqual(context.releases, [{ now: 201, message: "workspace released" }]); +}); + +test("unresolved releases and missing rereads do not claim completion", async () => { + const unresolved = fixture({ confirmed: "stopping" }); + assert.equal((await stop(unresolved)).auditAt, null); + + const missing = fixture({ + stop: { status: "stopping", message: "provider stopping" }, + reread: null, + }); + await assert.rejects(() => stop(missing), hasStatus(404)); +}); diff --git a/tests/session-stop.test.ts b/tests/session-stop.test.ts index cef64be..aab3296 100644 --- a/tests/session-stop.test.ts +++ b/tests/session-stop.test.ts @@ -7,8 +7,8 @@ import { interactiveSessionTerminalStatus, type InteractiveSessionStopStore, type InteractiveSessionTerminalStatus, - type RuntimeAdapterStopServiceResult, } from "../src/worker/session-stop.ts"; +import type { RuntimeAdapterStopServiceResult } from "../src/worker/session-runtime-adapter-stop.ts"; import { interactiveSession, type InteractiveSession } from "../src/worker/session-model.ts"; import { sessionRow } from "./helpers/session-row.ts"; From 46e7150c6f823fa7743c779d2b5512a074acff0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 13:26:50 +0100 Subject: [PATCH 033/109] refactor: centralize session stop persistence --- CHANGELOG.md | 1 + src/index.ts | 154 +++++------------------------ src/worker/session-repository.ts | 163 +++++++++++++++++++++++++++++++ tests/runtime-adapter.test.ts | 19 ++-- tests/session-repository.test.ts | 132 +++++++++++++++++++++++++ 5 files changed, 325 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da51bbd..ccecb76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Extract terminal attach policy into a directly tested service and persist attach state plus evidence atomically. - Extract interactive-session stop authorization, runtime routing, idempotency, cleanup sequencing, conflicts, and audits into a directly tested service. - Extract runtime-adapter stop claim, provider outcome, retry evidence, create-resolution, and confirmed-release orchestration into a directly tested service. +- Move legacy and GitHub Actions stop transitions plus stop-state lookups into the session repository. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 79b4813..1b272f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -154,7 +154,6 @@ import { } from "./worker/database"; import { deadInteractiveSessionStatuses, - type InteractiveRuntime, type InteractiveSessionStatus, type Role, type RunStatus, @@ -271,14 +270,19 @@ import { countInteractiveSessionEvents, insertInteractiveSessionReservation, markInteractiveSessionPendingAdapter, + persistGitHubActionsSessionStop, persistInteractiveSessionEventMutation, persistInteractiveSessionProvisionResult, + persistLegacyInteractiveSessionStop, + readInteractiveSessionTerminalCleanupIntent, readInteractiveSessionEventRows, readInteractiveSessionLogArchives, readInteractiveSessionLogs, readInteractiveSessionRecord as readInteractiveSession, readInteractiveSessionRecords, readSharedInteractiveSessionRow, + readRuntimeAdapterCreatePending, + type LegacyInteractiveSessionStopOwner, } from "./worker/session-repository"; import { InteractiveSessionAttachService, @@ -4381,15 +4385,6 @@ function interactiveSessionMetadataService( return new InteractiveSessionMetadataService(store); } -type LegacyInteractiveSessionStopOwner = { - id: string; - status: InteractiveSessionStatus; - runtime: InteractiveRuntime; - adapter: string | null; - leaseId: string | null; - updatedAt: number; -}; - async function completeLegacyInteractiveSessionStop( env: RuntimeEnv, owner: LegacyInteractiveSessionStopOwner, @@ -4397,63 +4392,14 @@ async function completeLegacyInteractiveSessionStop( now: number, ): Promise { if (owner.runtime === githubActionsRuntime) return false; - const db = database(env); - const revision = Math.max(now, owner.updatedAt + 1); - const actorName = clean(eventActor, 120) || "system"; - const expectedOwner = sql` - id = ${owner.id} - AND status = ${owner.status} - AND runtime = ${owner.runtime} - AND updated_at = ${owner.updatedAt} - AND adapter IS ${owner.adapter} - AND lease_id IS ${owner.leaseId} - AND (adapter IS NULL OR adapter != ${runtimeAdapterName}) - AND (lease_id IS NULL OR lease_id NOT LIKE ${`${sandboxLeasePrefix}%`}) - AND credential_cleanup_terminal_status IS NULL - `; - const requestedMessage = "interactive workspace stop requested"; - const finalMessage = "interactive workspace stopped"; - const requestedEvent = sql` - INSERT INTO interactive_session_events (session_id, actor, message, created_at) - SELECT ${owner.id}, ${actorName}, ${requestedMessage}, ${now} - FROM interactive_sessions - WHERE ${expectedOwner} - `; - const stoppedEvent = sql` - INSERT INTO interactive_session_events (session_id, actor, message, created_at) - SELECT ${owner.id}, ${actorName}, ${finalMessage}, ${now} - FROM interactive_sessions - WHERE ${expectedOwner} - `; - const stop = db - .updateTable("interactive_sessions") - .set({ - status: "stopped", - stopped_at: sql`COALESCE(stopped_at, ${now})`, - reconcile_error: null, - terminal_status: null, - adapter_create_pending: 0, - terminal_finalize_pending: 1, - agent_token_hash: null, - attach_url: null, - vnc_url: null, - controller: null, - control_requested_by: null, - control_requested_at: null, - control_granted_at: null, - control_expires_at: null, - updated_at: revision, - last_event: finalMessage, - }) - .where(expectedOwner) - .returning("updated_at"); - const results = await env.DB.batch<{ updated_at: number }>( - [requestedEvent, stoppedEvent, stop].map((query) => { - const compiled = query.compile(db); - return env.DB.prepare(compiled.sql).bind(...compiled.parameters); - }), + const stopped = await persistLegacyInteractiveSessionStop( + env, + owner, + eventActor, + runtimeAdapterName, + sandboxLeasePrefix, + now, ); - const stopped = results.at(-1)?.results.some((row) => row.updated_at === revision) ?? false; if (stopped) { await archiveInteractiveSessionLogs(env, owner.id, now).catch(() => undefined); await finalizeTerminalInteractiveSession(env, owner.id, "stopped", now).catch(() => undefined); @@ -4467,53 +4413,13 @@ async function stopGitHubActionsSession( eventActor: string, now: number, ): Promise { - const revision = Math.max(now, session.updatedAt + 1); - const message = "GitHub Actions terminal session ended from Crabfleet; workflow run not canceled"; - const db = database(env); - const expectedOwner = sql` - id = ${session.id} - AND runtime = ${githubActionsRuntime} - AND status = ${session.status} - AND updated_at = ${session.updatedAt} - `; - const event = sql` - INSERT INTO interactive_session_events (session_id, actor, message, created_at) - SELECT ${session.id}, ${clean(eventActor, 120) || "system"}, ${message}, ${now} - FROM interactive_sessions - WHERE ${expectedOwner} - `; - const update = db - .updateTable("interactive_sessions") - .set({ - status: "stopped", - stopped_at: now, - reconcile_error: null, - terminal_status: null, - terminal_failure_reason: null, - terminal_finalize_pending: 1, - agent_token_hash: null, - attach_url: null, - vnc_url: null, - controller: null, - control_requested_by: null, - control_requested_at: null, - control_granted_at: null, - control_expires_at: null, - work_state: "", - work_phase: "session_ended", - completion_reason: "Crabfleet terminal session ended; workflow run not canceled", - last_event: message, - updated_at: revision, - }) - .where(expectedOwner) - .returning("updated_at"); - const results = await env.DB.batch<{ updated_at: number }>( - [event, update].map((query) => { - const compiled = query.compile(db); - return env.DB.prepare(compiled.sql).bind(...compiled.parameters); - }), + const stopped = await persistGitHubActionsSessionStop( + env, + session, + eventActor, + githubActionsRuntime, + now, ); - const stopped = results.at(-1)?.results.some((row) => row.updated_at === revision) ?? false; if (!stopped) return false; await disconnectGitHubActionsRunner(env, session.id).catch(() => undefined); await archiveInteractiveSessionLogs(env, session.id, now).catch(() => undefined); @@ -4559,17 +4465,8 @@ function interactiveSessionRuntimeAdapterStopService(env: RuntimeEnv): RuntimeAd reconcileError, actorName, ), - readCreatePending: async (sessionId, adapterWorkspaceId) => { - const lifecycle = await database(env) - .selectFrom("interactive_sessions") - .select("adapter_create_pending") - .where("id", "=", sessionId) - .where("adapter", "=", runtimeAdapterName) - .where("adapter_workspace_id", "=", adapterWorkspaceId) - .where("status", "=", "stopping") - .executeTakeFirst(); - return lifecycle?.adapter_create_pending === 1; - }, + readCreatePending: (sessionId, adapterWorkspaceId) => + readRuntimeAdapterCreatePending(env, sessionId, runtimeAdapterName, adapterWorkspaceId), confirmRelease: (sessionId, adapterWorkspaceId, now, message) => recordConfirmedRuntimeAdapterRelease(env, sessionId, adapterWorkspaceId, now, message), now: Date.now, @@ -4584,15 +4481,8 @@ function interactiveSessionStopService(env: RuntimeEnv, user: User): Interactive stageTerminalCredentialPolicyCleanupById(env, sessionId, status, message, now), reconcileCleanup: (sessionId, now) => reconcileCredentialPolicyCleanupBatch(env, now, sessionId), - readTerminalCleanupIntent: async (sessionId) => { - const row = await database(env) - .selectFrom("interactive_sessions") - .select("credential_cleanup_terminal_status") - .where("id", "=", sessionId) - .where("status", "=", "stopping") - .executeTakeFirst(); - return Boolean(row?.credential_cleanup_terminal_status); - }, + readTerminalCleanupIntent: (sessionId) => + readInteractiveSessionTerminalCleanupIntent(env, sessionId), recordEvent: (sessionId, message, now) => appendInteractiveSessionEvent(env, sessionId, user, message, now), readSession: (sessionId) => readInteractiveSession(env, sessionId), diff --git a/src/worker/session-repository.ts b/src/worker/session-repository.ts index b6665c1..eaf6d01 100644 --- a/src/worker/session-repository.ts +++ b/src/worker/session-repository.ts @@ -8,6 +8,7 @@ import { type InteractiveSessionTable, } from "./database.ts"; import type { RuntimeEnv } from "./env.ts"; +import type { InteractiveRuntime, InteractiveSessionStatus } from "./models.ts"; import type { InteractiveProvisionPersistence, InteractiveProvisionPersistenceInput, @@ -31,6 +32,15 @@ export type InteractiveSessionReplayReservation = { export type InteractiveSessionReservationValues = Insertable; +export type LegacyInteractiveSessionStopOwner = { + id: string; + status: InteractiveSessionStatus; + runtime: InteractiveRuntime; + adapter: string | null; + leaseId: string | null; + updatedAt: number; +}; + export type InteractiveSessionReservationBuildInput = { id: string; parentSessionId: string | null; @@ -320,6 +330,159 @@ export async function persistInteractiveSessionEventMutation( return results.at(-1)?.results.some((row) => row.updated_at === revision) ?? false; } +export async function persistLegacyInteractiveSessionStop( + env: RuntimeEnv, + owner: LegacyInteractiveSessionStopOwner, + actorName: string, + runtimeAdapterName: string, + sandboxLeasePrefix: string, + now: number, +): Promise { + const db = database(env); + const revision = Math.max(now, owner.updatedAt + 1); + const eventActor = clean(actorName, 120) || "system"; + const expectedOwner = sql` + id = ${owner.id} + AND status = ${owner.status} + AND runtime = ${owner.runtime} + AND updated_at = ${owner.updatedAt} + AND adapter IS ${owner.adapter} + AND lease_id IS ${owner.leaseId} + AND (adapter IS NULL OR adapter != ${runtimeAdapterName}) + AND (lease_id IS NULL OR lease_id NOT LIKE ${`${sandboxLeasePrefix}%`}) + AND credential_cleanup_terminal_status IS NULL + `; + const requestedMessage = "interactive workspace stop requested"; + const finalMessage = "interactive workspace stopped"; + const requestedEvent = sql` + INSERT INTO interactive_session_events (session_id, actor, message, created_at) + SELECT ${owner.id}, ${eventActor}, ${requestedMessage}, ${now} + FROM interactive_sessions + WHERE ${expectedOwner} + `; + const stoppedEvent = sql` + INSERT INTO interactive_session_events (session_id, actor, message, created_at) + SELECT ${owner.id}, ${eventActor}, ${finalMessage}, ${now} + FROM interactive_sessions + WHERE ${expectedOwner} + `; + const stop = db + .updateTable("interactive_sessions") + .set({ + status: "stopped", + stopped_at: sql`COALESCE(stopped_at, ${now})`, + reconcile_error: null, + terminal_status: null, + adapter_create_pending: 0, + terminal_finalize_pending: 1, + agent_token_hash: null, + attach_url: null, + vnc_url: null, + controller: null, + control_requested_by: null, + control_requested_at: null, + control_granted_at: null, + control_expires_at: null, + updated_at: revision, + last_event: finalMessage, + }) + .where(expectedOwner) + .returning("updated_at"); + const results = await env.DB.batch<{ updated_at: number }>( + [requestedEvent, stoppedEvent, stop].map((query) => { + const compiled = query.compile(db); + return env.DB.prepare(compiled.sql).bind(...compiled.parameters); + }), + ); + return results.at(-1)?.results.some((row) => row.updated_at === revision) ?? false; +} + +export async function persistGitHubActionsSessionStop( + env: RuntimeEnv, + session: Pick, + actorName: string, + githubActionsRuntime: string, + now: number, +): Promise { + const revision = Math.max(now, session.updatedAt + 1); + const message = "GitHub Actions terminal session ended from Crabfleet; workflow run not canceled"; + const db = database(env); + const expectedOwner = sql` + id = ${session.id} + AND runtime = ${githubActionsRuntime} + AND status = ${session.status} + AND updated_at = ${session.updatedAt} + `; + const event = sql` + INSERT INTO interactive_session_events (session_id, actor, message, created_at) + SELECT ${session.id}, ${clean(actorName, 120) || "system"}, ${message}, ${now} + FROM interactive_sessions + WHERE ${expectedOwner} + `; + const update = db + .updateTable("interactive_sessions") + .set({ + status: "stopped", + stopped_at: now, + reconcile_error: null, + terminal_status: null, + terminal_failure_reason: null, + terminal_finalize_pending: 1, + agent_token_hash: null, + attach_url: null, + vnc_url: null, + controller: null, + control_requested_by: null, + control_requested_at: null, + control_granted_at: null, + control_expires_at: null, + work_state: "", + work_phase: "session_ended", + completion_reason: "Crabfleet terminal session ended; workflow run not canceled", + last_event: message, + updated_at: revision, + }) + .where(expectedOwner) + .returning("updated_at"); + const results = await env.DB.batch<{ updated_at: number }>( + [event, update].map((query) => { + const compiled = query.compile(db); + return env.DB.prepare(compiled.sql).bind(...compiled.parameters); + }), + ); + return results.at(-1)?.results.some((row) => row.updated_at === revision) ?? false; +} + +export async function readInteractiveSessionTerminalCleanupIntent( + env: RuntimeEnv, + sessionId: string, +): Promise { + const row = await database(env) + .selectFrom("interactive_sessions") + .select("credential_cleanup_terminal_status") + .where("id", "=", sessionId) + .where("status", "=", "stopping") + .executeTakeFirst(); + return Boolean(row?.credential_cleanup_terminal_status); +} + +export async function readRuntimeAdapterCreatePending( + env: RuntimeEnv, + sessionId: string, + runtimeAdapterName: string, + adapterWorkspaceId: string, +): Promise { + const row = await database(env) + .selectFrom("interactive_sessions") + .select("adapter_create_pending") + .where("id", "=", sessionId) + .where("adapter", "=", runtimeAdapterName) + .where("adapter_workspace_id", "=", adapterWorkspaceId) + .where("status", "=", "stopping") + .executeTakeFirst(); + return row?.adapter_create_pending === 1; +} + export async function insertInteractiveSessionReservation( env: RuntimeEnv, values: InteractiveSessionReservationValues, diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 38418bd..a2fb875 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -1330,7 +1330,7 @@ test("create-only adapters reject stopping responses before persistence", async assert.match(forwardedSource, /if \(!status\) return failedProvision/); }); -test("legacy and GitHub Actions stops use durable terminal transitions", async () => { +test("legacy and GitHub Actions stop wrappers finalize persisted transitions", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const completeStart = source.indexOf("async function completeLegacyInteractiveSessionStop"); const completeEnd = source.indexOf("async function stopGitHubActionsSession", completeStart); @@ -1350,22 +1350,17 @@ test("legacy and GitHub Actions stops use durable terminal transitions", async ( ); const scheduledSource = source.slice(scheduledStart, scheduledEnd); - assert.match(completeSource, /env\.DB\.batch/); - assert.match(completeSource, /interactive workspace stop requested/); - assert.match(completeSource, /interactive workspace stopped/); - assert.match(completeSource, /status: "stopped"/); - assert.match(completeSource, /terminal_finalize_pending: 1/); - assert.match(completeSource, /AND status = \$\{owner\.status\}/); - assert.match(completeSource, /AND updated_at = \$\{owner\.updatedAt\}/); + assert.match(completeSource, /persistLegacyInteractiveSessionStop/); + assert.match(completeSource, /archiveInteractiveSessionLogs/); assert.match(completeSource, /finalizeTerminalInteractiveSession/); - assert.doesNotMatch(completeSource, /status: "stopping"/); assert.match(scheduledSource, /where\("status", "=", "stopping"\)/); assert.match(scheduledSource, /\.where\("runtime", "!=", githubActionsRuntime\)/); assert.match(scheduledSource, /completeLegacyInteractiveSessionStop/); assert.match(completeSource, /if \(owner\.runtime === githubActionsRuntime\) return false/); - assert.match(githubActionsSource, /work_state: ""/); - assert.match(githubActionsSource, /work_phase: "session_ended"/); - assert.match(githubActionsSource, /workflow run not canceled/); + assert.match(githubActionsSource, /persistGitHubActionsSessionStop/); + assert.match(githubActionsSource, /disconnectGitHubActionsRunner/); + assert.match(githubActionsSource, /archiveInteractiveSessionLogs/); + assert.match(githubActionsSource, /finalizeTerminalInteractiveSession/); }); test("legacy expiry enters the shared retryable terminal finalizer", async () => { diff --git a/tests/session-repository.test.ts b/tests/session-repository.test.ts index 43089e4..1bc3722 100644 --- a/tests/session-repository.test.ts +++ b/tests/session-repository.test.ts @@ -7,14 +7,18 @@ import { countInteractiveSessionEvents, insertInteractiveSessionReservation, markInteractiveSessionPendingAdapter, + persistGitHubActionsSessionStop, persistInteractiveSessionEventMutation, persistInteractiveSessionProvisionResult, + persistLegacyInteractiveSessionStop, readInteractiveSessionEventRows, readInteractiveSessionLogArchives, readInteractiveSessionLogs, readInteractiveSessionRecord, readInteractiveSessionRecords, readSharedInteractiveSessionRow, + readInteractiveSessionTerminalCleanupIntent, + readRuntimeAdapterCreatePending, readVisibleInteractiveSessionRow, readVisibleInteractiveSessionRows, } from "../src/worker/session-repository.ts"; @@ -383,6 +387,134 @@ test("session event mutations report lost revision ownership", async () => { ); }); +test("legacy stop persistence records request and completion in one fenced batch", async () => { + let batch: PreparedStatement[] = []; + const stopped = await persistLegacyInteractiveSessionStop( + runtimeEnv( + () => { + throw new Error("legacy stop must execute as one batch"); + }, + (statements) => { + batch = statements; + return [{ results: [] }, { results: [] }, { results: [{ updated_at: 101 }] }]; + }, + ), + { + id: "IS-2", + status: "ready", + runtime: "container", + adapter: null, + leaseId: null, + updatedAt: 100, + }, + "operator", + "runtime-v1", + "sandbox:v1:", + 90, + ); + + assert.equal(stopped, true); + assert.equal(batch.length, 3); + assert.match(batch[0]?.sql ?? "", /insert into interactive_session_events/i); + assert.ok(batch[0]?.parameters.includes("interactive workspace stop requested")); + assert.match(batch[1]?.sql ?? "", /insert into interactive_session_events/i); + assert.ok(batch[1]?.parameters.includes("interactive workspace stopped")); + assert.match(batch[2]?.sql ?? "", /^update "interactive_sessions"/i); + assert.match(batch[2]?.sql ?? "", /credential_cleanup_terminal_status is null/i); + assert.match(batch[2]?.sql ?? "", /terminal_finalize_pending/i); + assert.ok(batch[2]?.parameters.includes(101)); +}); + +test("GitHub Actions stop persistence clears terminal authority and workflow state", async () => { + let batch: PreparedStatement[] = []; + const stopped = await persistGitHubActionsSessionStop( + runtimeEnv( + () => { + throw new Error("GitHub Actions stop must execute as one batch"); + }, + (statements) => { + batch = statements; + return [{ results: [] }, { results: [{ updated_at: 101 }] }]; + }, + ), + { id: "IS-2", status: "ready", updatedAt: 100 }, + "operator", + "github_actions", + 90, + ); + + assert.equal(stopped, true); + assert.equal(batch.length, 2); + assert.match(batch[0]?.sql ?? "", /insert into interactive_session_events/i); + assert.ok( + batch[0]?.parameters.some( + (parameter) => + typeof parameter === "string" && parameter.includes("workflow run not canceled"), + ), + ); + assert.match(batch[1]?.sql ?? "", /^update "interactive_sessions"/i); + assert.match(batch[1]?.sql ?? "", /"work_state"/i); + assert.match(batch[1]?.sql ?? "", /"work_phase"/i); + assert.match(batch[1]?.sql ?? "", /"terminal_finalize_pending"/i); + assert.ok(batch[1]?.parameters.includes("session_ended")); +}); + +test("stop persistence reports lost ownership", async () => { + const env = runtimeEnv( + () => { + throw new Error("stop persistence must execute as one batch"); + }, + () => [{ results: [] }, { results: [] }, { results: [] }], + ); + assert.equal( + await persistLegacyInteractiveSessionStop( + env, + { + id: "IS-2", + status: "ready", + runtime: "container", + adapter: null, + leaseId: null, + updatedAt: 100, + }, + "operator", + "runtime-v1", + "sandbox:v1:", + 101, + ), + false, + ); + assert.equal( + await persistGitHubActionsSessionStop( + env, + { id: "IS-2", status: "ready", updatedAt: 100 }, + "operator", + "github_actions", + 101, + ), + false, + ); +}); + +test("stop-state lookups stay fenced to stopping sessions and adapter ownership", async () => { + const env = runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "all"); + if (/credential_cleanup_terminal_status/i.test(sql)) { + assert.deepEqual(parameters, ["IS-2", "stopping"]); + return { results: [{ credential_cleanup_terminal_status: "stopped" }] }; + } + assert.match(sql, /adapter_create_pending/i); + assert.deepEqual(parameters, ["IS-2", "runtime-v1", "workspace-2", "stopping"]); + return { results: [{ adapter_create_pending: 1 }] }; + }); + + assert.equal(await readInteractiveSessionTerminalCleanupIntent(env, "IS-2"), true); + assert.equal( + await readRuntimeAdapterCreatePending(env, "IS-2", "runtime-v1", "workspace-2"), + true, + ); +}); + test("session reservation inserts preparation and request identity in one batch", async () => { let batch: PreparedStatement[] = []; const values = sessionRow({ From 2597433cb61ebb9edd6da7e6cbbd1924604cd6d8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 13:31:12 +0100 Subject: [PATCH 034/109] refactor: centralize session access policy --- CHANGELOG.md | 1 + src/index.ts | 41 ++--------------- src/worker/session-access.ts | 41 +++++++++++++++++ tests/runtime-adapter.test.ts | 4 +- tests/runtime-profiles.test.ts | 2 +- tests/session-access.test.ts | 83 ++++++++++++++++++++++++++++++++++ 6 files changed, 133 insertions(+), 39 deletions(-) create mode 100644 src/worker/session-access.ts create mode 100644 tests/session-access.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ccecb76..442d3f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - Extract interactive-session stop authorization, runtime routing, idempotency, cleanup sequencing, conflicts, and audits into a directly tested service. - Extract runtime-adapter stop claim, provider outcome, retry evidence, create-resolution, and confirmed-release orchestration into a directly tested service. - Move legacy and GitHub Actions stop transitions plus stop-state lookups into the session repository. +- Extract interactive-session ownership, management, multiplayer, and delegated-control authorization into one directly tested policy module. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 1b272f3..867fcab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -288,6 +288,11 @@ import { InteractiveSessionAttachService, type InteractiveSessionAttachStore, } from "./worker/session-attach"; +import { + canChangeInteractiveSessionMultiplayer, + canControlInteractiveSession, + canManageInteractiveSession, +} from "./worker/session-access"; import { InteractiveSessionMetadataService, isInteractiveSessionMetadataAction, @@ -13326,42 +13331,6 @@ function decorateInteractiveSession( }; } -function canChangeInteractiveSessionMultiplayer(user: User, session: InteractiveSession): boolean { - return userActorCandidates(user).has(session.owner); -} - -function canManageInteractiveSession(user: User, session: InteractiveSession): boolean { - return ( - userActorCandidates(user).has(session.owner) || - user.role === "maintainer" || - user.role === "owner" - ); -} - -function userActorCandidates(user: User): Set { - return new Set( - [actor(user), user.subject, user.login, user.email].filter((value): value is string => - Boolean(value), - ), - ); -} - -function canControlInteractiveSession( - user: User, - session: InteractiveSession, - now: number, - delegatedControl = true, -): boolean { - if (canManageInteractiveSession(user, session)) return true; - if (!delegatedControl) return false; - const userActor = actor(user); - return ( - session.controller === userActor && - typeof session.controlExpiresAt === "number" && - session.controlExpiresAt > now - ); -} - async function canControlInteractiveSessionById( env: RuntimeEnv, user: User, diff --git a/src/worker/session-access.ts b/src/worker/session-access.ts new file mode 100644 index 0000000..9760055 --- /dev/null +++ b/src/worker/session-access.ts @@ -0,0 +1,41 @@ +import { actor } from "./auth.ts"; +import type { User } from "./models.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export function canChangeInteractiveSessionMultiplayer( + user: User, + session: InteractiveSession, +): boolean { + return interactiveSessionActorCandidates(user).has(session.owner); +} + +export function canManageInteractiveSession(user: User, session: InteractiveSession): boolean { + return ( + interactiveSessionActorCandidates(user).has(session.owner) || + user.role === "maintainer" || + user.role === "owner" + ); +} + +export function canControlInteractiveSession( + user: User, + session: InteractiveSession, + now: number, + delegatedControl = true, +): boolean { + if (canManageInteractiveSession(user, session)) return true; + if (!delegatedControl) return false; + return ( + session.controller === actor(user) && + typeof session.controlExpiresAt === "number" && + session.controlExpiresAt > now + ); +} + +export function interactiveSessionActorCandidates(user: User): Set { + return new Set( + [actor(user), user.subject, user.login, user.email].filter((value): value is string => + Boolean(value), + ), + ); +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index a2fb875..8236fc6 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -1641,7 +1641,7 @@ test("terminal endpoints enforce current runtime capabilities", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const decorateStart = source.indexOf("function decorateInteractiveSession"); const decorateEnd = source.indexOf( - "function canChangeInteractiveSessionMultiplayer", + "async function canControlInteractiveSessionById", decorateStart, ); const decorateSource = source.slice(decorateStart, decorateEnd); @@ -2049,7 +2049,7 @@ test("runtime adapter terminals use the server-side adapter bearer", async () => assert.doesNotMatch(targetSource, /searchParams\.set\([^)]*(?:token|ticket)/u); const decorateStart = source.indexOf("function decorateInteractiveSession"); const decorateEnd = source.indexOf( - "function canChangeInteractiveSessionMultiplayer", + "async function canControlInteractiveSessionById", decorateStart, ); const decorateSource = source.slice(decorateStart, decorateEnd); diff --git a/tests/runtime-profiles.test.ts b/tests/runtime-profiles.test.ts index a7ee9b5..0a595e2 100644 --- a/tests/runtime-profiles.test.ts +++ b/tests/runtime-profiles.test.ts @@ -171,7 +171,7 @@ test("runtime profiles resolve bounded manager-only Codex SSH handoff data", asy const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const start = source.indexOf("function decorateInteractiveSession"); - const end = source.indexOf("function canChangeInteractiveSessionMultiplayer", start); + const end = source.indexOf("async function canControlInteractiveSessionById", start); const decoration = source.slice(start, end); assert.match(decoration, /canManage && codexSshReady/); assert.match(decoration, /codexSsh,/); diff --git a/tests/session-access.test.ts b/tests/session-access.test.ts new file mode 100644 index 0000000..89c9e24 --- /dev/null +++ b/tests/session-access.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { User } from "../src/worker/models.ts"; +import { + canChangeInteractiveSessionMultiplayer, + canControlInteractiveSession, + canManageInteractiveSession, + interactiveSessionActorCandidates, +} from "../src/worker/session-access.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function user(values: Partial = {}): User { + return { + subject: "github:42", + login: "operator", + email: "operator@example.test", + name: "Operator", + role: "viewer", + allowed: true, + teams: [], + ...values, + }; +} + +test("session actor candidates include canonical and fallback identities", () => { + assert.deepEqual( + [...interactiveSessionActorCandidates(user())], + ["operator", "github:42", "operator@example.test"], + ); + assert.deepEqual( + [...interactiveSessionActorCandidates(user({ login: null }))], + ["operator@example.test", "github:42"], + ); +}); + +test("owners and elevated roles can manage sessions", () => { + const owned = interactiveSession(sessionRow({ owner: "operator@example.test" }), []); + assert.equal(canManageInteractiveSession(user(), owned), true); + + const foreign = interactiveSession(sessionRow({ owner: "someone-else" }), []); + assert.equal(canManageInteractiveSession(user(), foreign), false); + assert.equal(canManageInteractiveSession(user({ role: "maintainer" }), foreign), true); + assert.equal(canManageInteractiveSession(user({ role: "owner" }), foreign), true); +}); + +test("only the recorded creator identity can change multiplayer", () => { + const owned = interactiveSession(sessionRow({ owner: "github:42" }), []); + assert.equal(canChangeInteractiveSessionMultiplayer(user(), owned), true); + + const foreign = interactiveSession(sessionRow({ owner: "someone-else" }), []); + assert.equal(canChangeInteractiveSessionMultiplayer(user({ role: "owner" }), foreign), false); +}); + +test("session managers always retain terminal control", () => { + const session = interactiveSession(sessionRow({ owner: "operator" }), []); + assert.equal(canControlInteractiveSession(user(), session, 100, false), true); +}); + +test("delegated terminal control requires the canonical actor and a live lease", () => { + const session = interactiveSession( + sessionRow({ + owner: "someone-else", + controller: "operator", + control_expires_at: 200, + }), + [], + ); + assert.equal(canControlInteractiveSession(user(), session, 100, true), true); + assert.equal(canControlInteractiveSession(user(), session, 200, true), false); + assert.equal(canControlInteractiveSession(user(), session, 100, false), false); + + const emailController = interactiveSession( + sessionRow({ + owner: "someone-else", + controller: "operator@example.test", + control_expires_at: 200, + }), + [], + ); + assert.equal(canControlInteractiveSession(user(), emailController, 100, true), false); +}); From 65be3a6badef465f66354359dade3ac12532b9fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 13:38:22 +0100 Subject: [PATCH 035/109] refactor: extract session log archive --- CHANGELOG.md | 1 + src/index.ts | 242 +--------------------------- src/worker/session-log-archive.ts | 254 ++++++++++++++++++++++++++++++ tests/runtime-adapter.test.ts | 20 ++- tests/session-log-archive.test.ts | 89 +++++++++++ 5 files changed, 361 insertions(+), 245 deletions(-) create mode 100644 src/worker/session-log-archive.ts create mode 100644 tests/session-log-archive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 442d3f0..b16d5f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Extract runtime-adapter stop claim, provider outcome, retry evidence, create-resolution, and confirmed-release orchestration into a directly tested service. - Move legacy and GitHub Actions stop transitions plus stop-state lookups into the session repository. - Extract interactive-session ownership, management, multiplayer, and delegated-control authorization into one directly tested policy module. +- Move interactive-session archive cadence, D1 snapshots, R2 objects, cleanup, transcripts, and summaries into one archive module. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 867fcab..4dcb6eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,7 +107,6 @@ import { sandboxGitAuthorEmail } from "./git-identity"; import { completeTerminalFinalization } from "./terminal-finalization"; import { sizedTerminalTargetUrl } from "./terminal-target"; import { cachedBooleanGrant } from "./terminal-authorization"; -import { obsoleteSessionArchiveObjectKeys, sessionArchiveAttemptKeys } from "./session-archive"; import { readBoundedResponseText } from "./bounded-response"; import { openClawGitHubRepoParts, @@ -207,7 +206,6 @@ import { runtimeCapabilities, type InteractiveSession, type InteractiveSessionEvent, - type InteractiveSessionEventRow, type InteractiveSessionLogArchive, type RuntimeCapabilities, } from "./worker/session-model"; @@ -293,6 +291,11 @@ import { canControlInteractiveSession, canManageInteractiveSession, } from "./worker/session-access"; +import { + archiveInteractiveSessionLogs, + cleanupSessionLogArchiveObjects, + sessionLogTranscript, +} from "./worker/session-log-archive"; import { InteractiveSessionMetadataService, isInteractiveSessionMetadataAction, @@ -12730,146 +12733,6 @@ async function finalizeTerminalInteractiveSession( }); } -async function archiveInteractiveSessionLogs( - env: RuntimeEnv, - id: string, - now = Date.now(), - options: { force?: boolean } = {}, -): Promise { - const db = database(env); - const [sessionRow, currentArchive, eventCount] = await Promise.all([ - db.selectFrom("interactive_sessions").selectAll().where("id", "=", id).executeTakeFirst(), - db - .selectFrom("interactive_session_log_archives") - .selectAll() - .where("session_id", "=", id) - .executeTakeFirst(), - countInteractiveSessionEvents(env, id), - ]); - if (!sessionRow) return; - if (!shouldArchiveInteractiveSessionLogs(currentArchive, eventCount, now, options.force)) { - return; - } - const events = await readInteractiveSessionEventRows(env, id); - const latestEventAt = events.at(-1)?.created_at ?? now; - const attemptedArchive = sessionArchiveAttemptKeys( - sessionLogArchiveBase(id), - events.length, - latestEventAt, - now, - crypto.randomUUID(), - ); - const eventsKey = attemptedArchive.events_key; - const transcriptKey = attemptedArchive.transcript_key; - const summaryKey = attemptedArchive.summary_key; - if (env.SESSION_LOGS) { - await Promise.all([ - env.SESSION_LOGS.put( - eventsKey, - events.map((row) => JSON.stringify(interactiveSessionEvent(row))).join("\n") + "\n", - { httpMetadata: { contentType: "application/x-ndjson; charset=utf-8" } }, - ), - env.SESSION_LOGS.put(transcriptKey, sessionLogTranscript(sessionRow, events), { - httpMetadata: { contentType: "text/markdown; charset=utf-8" }, - }), - env.SESSION_LOGS.put( - summaryKey, - JSON.stringify(sessionLogSummary(sessionRow, events), null, 2), - { httpMetadata: { contentType: "application/json; charset=utf-8" } }, - ), - ]); - } - await sql` - INSERT INTO interactive_session_log_archives ( - session_id, - event_count, - session_updated_at, - events_key, - transcript_key, - summary_key, - archived_at, - updated_at - ) - VALUES ( - ${id}, - ${events.length}, - ${sessionRow.updated_at}, - ${env.SESSION_LOGS ? eventsKey : null}, - ${env.SESSION_LOGS ? transcriptKey : null}, - ${env.SESSION_LOGS ? summaryKey : null}, - ${now}, - ${now} - ) - ON CONFLICT(session_id) DO UPDATE SET - event_count = excluded.event_count, - session_updated_at = excluded.session_updated_at, - events_key = excluded.events_key, - transcript_key = excluded.transcript_key, - summary_key = excluded.summary_key, - updated_at = excluded.updated_at - WHERE excluded.event_count > interactive_session_log_archives.event_count - OR ( - excluded.event_count = interactive_session_log_archives.event_count - AND ( - ( - excluded.session_updated_at IS NOT NULL - AND interactive_session_log_archives.session_updated_at IS NULL - ) - OR ( - excluded.session_updated_at > interactive_session_log_archives.session_updated_at - ) - OR ( - excluded.session_updated_at IS interactive_session_log_archives.session_updated_at - AND ( - interactive_session_log_archives.events_key IS NULL - OR interactive_session_log_archives.transcript_key IS NULL - OR interactive_session_log_archives.summary_key IS NULL - OR excluded.updated_at >= interactive_session_log_archives.updated_at - ) - ) - ) - ) - `.execute(db); - if (!env.SESSION_LOGS) return; - const latestArchive = await db - .selectFrom("interactive_session_log_archives") - .selectAll() - .where("session_id", "=", id) - .executeTakeFirst(); - await cleanupSessionLogArchiveObjects( - env, - obsoleteSessionArchiveObjectKeys(latestArchive, currentArchive, attemptedArchive), - ); -} - -function shouldArchiveInteractiveSessionLogs( - current: InteractiveSessionLogArchiveTable | undefined, - eventCount: number, - now: number, - force = false, -): boolean { - if (force) return true; - if (!current) return true; - if (eventCount < current.event_count) return false; - if (eventCount <= 2 && eventCount > current.event_count) return true; - if (eventCount >= current.event_count + 20) return true; - return now >= current.updated_at + 60_000; -} - -async function cleanupSessionLogArchiveObjects( - env: RuntimeEnv, - archive: - | Pick - | undefined, -): Promise { - if (!env.SESSION_LOGS || !archive) return; - const keys = [archive.events_key, archive.transcript_key, archive.summary_key].filter( - (key): key is string => Boolean(key), - ); - if (!keys.length) return; - await Promise.all(keys.map((key) => env.SESSION_LOGS?.delete(key))); -} - async function readSettings(env: RuntimeEnv): Promise> { const rows = await database(env).selectFrom("settings").select(["key", "value"]).execute(); return Object.fromEntries(rows.map((row) => [row.key, row.value])); @@ -13162,101 +13025,6 @@ function runAttempt(row: RunAttemptTable): RunAttempt { }; } -function sessionLogArchiveBase(id: string): string { - return `orgs/openclaw/interactive-sessions/${id.replace(/[^A-Za-z0-9_.-]/g, "_")}`; -} - -function sessionLogTranscript( - session: InteractiveSession | InteractiveSessionRow, - events: InteractiveSessionEventRow[], -): string { - const parentSessionId = - "parentSessionId" in session ? session.parentSessionId : session.parent_session_id; - const rootSessionId = - "rootSessionId" in session ? session.rootSessionId : session.root_session_id; - const createdBy = "createdBy" in session ? session.createdBy : session.created_by; - const lines = [ - `# ${session.id}`, - "", - `repo: ${session.repo}`, - `branch: ${session.branch}`, - `runtime: ${session.runtime}`, - `owner: ${session.owner}`, - `created_by: ${createdBy}`, - `parent: ${parentSessionId ?? "none"}`, - `root: ${rootSessionId ?? session.id}`, - `status: ${session.status}`, - ...("workKey" in session - ? [ - `work_key: ${session.workKey ?? "none"}`, - `work_kind: ${session.workKind ?? "none"}`, - `work_state: ${session.workState ?? "none"}`, - `work_phase: ${session.workPhase || "none"}`, - `source_url: ${session.sourceUrl ?? "none"}`, - `github_run_url: ${session.githubRunUrl ?? "none"}`, - `codex_thread_id: ${session.codexThreadId ?? "none"}`, - `codex_turn_id: ${session.codexTurnId ?? "none"}`, - `last_heartbeat_at: ${session.lastHeartbeatAt ?? "none"}`, - `completion_reason: ${session.completionReason ?? "none"}`, - ] - : [ - `work_key: ${session.work_key ?? "none"}`, - `work_kind: ${session.work_kind ?? "none"}`, - `work_state: ${session.work_state || "none"}`, - `work_phase: ${session.work_phase || "none"}`, - `source_url: ${session.source_url ?? "none"}`, - `github_run_url: ${session.github_run_url ?? "none"}`, - `codex_thread_id: ${session.codex_thread_id ?? "none"}`, - `codex_turn_id: ${session.codex_turn_id ?? "none"}`, - `last_heartbeat_at: ${session.last_heartbeat_at ?? "none"}`, - `completion_reason: ${session.completion_reason ?? "none"}`, - ]), - `purpose: ${session.purpose}`, - `summary: ${session.summary}`, - "", - "## Events", - "", - ]; - for (const event of events) { - lines.push(`- ${new Date(event.created_at).toISOString()} ${event.actor}: ${event.message}`); - } - return `${lines.join("\n")}\n`; -} - -function sessionLogSummary( - session: InteractiveSessionRow, - events: InteractiveSessionEventRow[], -): Record { - return { - id: session.id, - parentSessionId: session.parent_session_id, - rootSessionId: session.root_session_id ?? session.id, - repo: session.repo, - branch: session.branch, - runtime: session.runtime, - owner: session.owner, - createdBy: session.created_by, - purpose: session.purpose, - summary: session.summary, - status: session.status, - workKey: session.work_key, - workKind: session.work_kind, - workState: parseGitHubActionsWorkState(session.work_state), - workPhase: session.work_phase, - sourceUrl: session.source_url, - githubRunUrl: session.github_run_url, - codexThreadId: session.codex_thread_id, - codexTurnId: session.codex_turn_id, - lastHeartbeatAt: session.last_heartbeat_at, - completionReason: session.completion_reason, - eventCount: events.length, - firstEventAt: events[0]?.created_at ?? null, - lastEventAt: events.at(-1)?.created_at ?? null, - lastEvent: events.at(-1)?.message ?? session.last_event, - updatedAt: session.updated_at, - }; -} - function decorateInteractiveSession( session: InteractiveSession, user: User, diff --git a/src/worker/session-log-archive.ts b/src/worker/session-log-archive.ts new file mode 100644 index 0000000..23008e8 --- /dev/null +++ b/src/worker/session-log-archive.ts @@ -0,0 +1,254 @@ +import { sql } from "kysely"; + +import { parseGitHubActionsWorkState } from "../github-actions-runtime.ts"; +import { obsoleteSessionArchiveObjectKeys, sessionArchiveAttemptKeys } from "../session-archive.ts"; +import { + database, + type InteractiveSessionLogArchiveTable, + type InteractiveSessionRow, +} from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { + interactiveSessionEvent, + type InteractiveSession, + type InteractiveSessionEventRow, +} from "./session-model.ts"; +import { + countInteractiveSessionEvents, + readInteractiveSessionEventRows, +} from "./session-repository.ts"; + +export async function archiveInteractiveSessionLogs( + env: RuntimeEnv, + id: string, + now = Date.now(), + options: { force?: boolean } = {}, +): Promise { + const db = database(env); + const [sessionRow, currentArchive, eventCount] = await Promise.all([ + db.selectFrom("interactive_sessions").selectAll().where("id", "=", id).executeTakeFirst(), + db + .selectFrom("interactive_session_log_archives") + .selectAll() + .where("session_id", "=", id) + .executeTakeFirst(), + countInteractiveSessionEvents(env, id), + ]); + if (!sessionRow) return; + if (!shouldArchiveInteractiveSessionLogs(currentArchive, eventCount, now, options.force)) { + return; + } + const events = await readInteractiveSessionEventRows(env, id); + const latestEventAt = events.at(-1)?.created_at ?? now; + const attemptedArchive = sessionArchiveAttemptKeys( + sessionLogArchiveBase(id), + events.length, + latestEventAt, + now, + crypto.randomUUID(), + ); + const eventsKey = attemptedArchive.events_key; + const transcriptKey = attemptedArchive.transcript_key; + const summaryKey = attemptedArchive.summary_key; + if (env.SESSION_LOGS) { + await Promise.all([ + env.SESSION_LOGS.put( + eventsKey, + events.map((row) => JSON.stringify(interactiveSessionEvent(row))).join("\n") + "\n", + { httpMetadata: { contentType: "application/x-ndjson; charset=utf-8" } }, + ), + env.SESSION_LOGS.put(transcriptKey, sessionLogTranscript(sessionRow, events), { + httpMetadata: { contentType: "text/markdown; charset=utf-8" }, + }), + env.SESSION_LOGS.put( + summaryKey, + JSON.stringify(sessionLogSummary(sessionRow, events), null, 2), + { httpMetadata: { contentType: "application/json; charset=utf-8" } }, + ), + ]); + } + await sql` + INSERT INTO interactive_session_log_archives ( + session_id, + event_count, + session_updated_at, + events_key, + transcript_key, + summary_key, + archived_at, + updated_at + ) + VALUES ( + ${id}, + ${events.length}, + ${sessionRow.updated_at}, + ${env.SESSION_LOGS ? eventsKey : null}, + ${env.SESSION_LOGS ? transcriptKey : null}, + ${env.SESSION_LOGS ? summaryKey : null}, + ${now}, + ${now} + ) + ON CONFLICT(session_id) DO UPDATE SET + event_count = excluded.event_count, + session_updated_at = excluded.session_updated_at, + events_key = excluded.events_key, + transcript_key = excluded.transcript_key, + summary_key = excluded.summary_key, + updated_at = excluded.updated_at + WHERE excluded.event_count > interactive_session_log_archives.event_count + OR ( + excluded.event_count = interactive_session_log_archives.event_count + AND ( + ( + excluded.session_updated_at IS NOT NULL + AND interactive_session_log_archives.session_updated_at IS NULL + ) + OR ( + excluded.session_updated_at > interactive_session_log_archives.session_updated_at + ) + OR ( + excluded.session_updated_at IS interactive_session_log_archives.session_updated_at + AND ( + interactive_session_log_archives.events_key IS NULL + OR interactive_session_log_archives.transcript_key IS NULL + OR interactive_session_log_archives.summary_key IS NULL + OR excluded.updated_at >= interactive_session_log_archives.updated_at + ) + ) + ) + ) + `.execute(db); + if (!env.SESSION_LOGS) return; + const latestArchive = await db + .selectFrom("interactive_session_log_archives") + .selectAll() + .where("session_id", "=", id) + .executeTakeFirst(); + await cleanupSessionLogArchiveObjects( + env, + obsoleteSessionArchiveObjectKeys(latestArchive, currentArchive, attemptedArchive), + ); +} + +export function shouldArchiveInteractiveSessionLogs( + current: InteractiveSessionLogArchiveTable | undefined, + eventCount: number, + now: number, + force = false, +): boolean { + if (force) return true; + if (!current) return true; + if (eventCount < current.event_count) return false; + if (eventCount <= 2 && eventCount > current.event_count) return true; + if (eventCount >= current.event_count + 20) return true; + return now >= current.updated_at + 60_000; +} + +export async function cleanupSessionLogArchiveObjects( + env: RuntimeEnv, + archive: + | Pick + | undefined, +): Promise { + if (!env.SESSION_LOGS || !archive) return; + const keys = [archive.events_key, archive.transcript_key, archive.summary_key].filter( + (key): key is string => Boolean(key), + ); + if (!keys.length) return; + await Promise.all(keys.map((key) => env.SESSION_LOGS?.delete(key))); +} + +export function sessionLogArchiveBase(id: string): string { + return `orgs/openclaw/interactive-sessions/${id.replace(/[^A-Za-z0-9_.-]/g, "_")}`; +} + +export function sessionLogTranscript( + session: InteractiveSession | InteractiveSessionRow, + events: InteractiveSessionEventRow[], +): string { + const parentSessionId = + "parentSessionId" in session ? session.parentSessionId : session.parent_session_id; + const rootSessionId = + "rootSessionId" in session ? session.rootSessionId : session.root_session_id; + const createdBy = "createdBy" in session ? session.createdBy : session.created_by; + const lines = [ + `# ${session.id}`, + "", + `repo: ${session.repo}`, + `branch: ${session.branch}`, + `runtime: ${session.runtime}`, + `owner: ${session.owner}`, + `created_by: ${createdBy}`, + `parent: ${parentSessionId ?? "none"}`, + `root: ${rootSessionId ?? session.id}`, + `status: ${session.status}`, + ...("workKey" in session + ? [ + `work_key: ${session.workKey ?? "none"}`, + `work_kind: ${session.workKind ?? "none"}`, + `work_state: ${session.workState ?? "none"}`, + `work_phase: ${session.workPhase || "none"}`, + `source_url: ${session.sourceUrl ?? "none"}`, + `github_run_url: ${session.githubRunUrl ?? "none"}`, + `codex_thread_id: ${session.codexThreadId ?? "none"}`, + `codex_turn_id: ${session.codexTurnId ?? "none"}`, + `last_heartbeat_at: ${session.lastHeartbeatAt ?? "none"}`, + `completion_reason: ${session.completionReason ?? "none"}`, + ] + : [ + `work_key: ${session.work_key ?? "none"}`, + `work_kind: ${session.work_kind ?? "none"}`, + `work_state: ${session.work_state || "none"}`, + `work_phase: ${session.work_phase || "none"}`, + `source_url: ${session.source_url ?? "none"}`, + `github_run_url: ${session.github_run_url ?? "none"}`, + `codex_thread_id: ${session.codex_thread_id ?? "none"}`, + `codex_turn_id: ${session.codex_turn_id ?? "none"}`, + `last_heartbeat_at: ${session.last_heartbeat_at ?? "none"}`, + `completion_reason: ${session.completion_reason ?? "none"}`, + ]), + `purpose: ${session.purpose}`, + `summary: ${session.summary}`, + "", + "## Events", + "", + ]; + for (const event of events) { + lines.push(`- ${new Date(event.created_at).toISOString()} ${event.actor}: ${event.message}`); + } + return `${lines.join("\n")}\n`; +} + +export function sessionLogSummary( + session: InteractiveSessionRow, + events: InteractiveSessionEventRow[], +): Record { + return { + id: session.id, + parentSessionId: session.parent_session_id, + rootSessionId: session.root_session_id ?? session.id, + repo: session.repo, + branch: session.branch, + runtime: session.runtime, + owner: session.owner, + createdBy: session.created_by, + purpose: session.purpose, + summary: session.summary, + status: session.status, + workKey: session.work_key, + workKind: session.work_kind, + workState: parseGitHubActionsWorkState(session.work_state), + workPhase: session.work_phase, + sourceUrl: session.source_url, + githubRunUrl: session.github_run_url, + codexThreadId: session.codex_thread_id, + codexTurnId: session.codex_turn_id, + lastHeartbeatAt: session.last_heartbeat_at, + completionReason: session.completion_reason, + eventCount: events.length, + firstEventAt: events[0]?.created_at ?? null, + lastEventAt: events.at(-1)?.created_at ?? null, + lastEvent: events.at(-1)?.message ?? session.last_event, + updatedAt: session.updated_at, + }; +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 8236fc6..fb7ef6e 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -557,7 +557,7 @@ test("confirmed adapter failure release keeps the original failure evidence", as ); const releaseSource = source.slice(releaseStart, releaseEnd); const finalizeStart = source.indexOf("async function finalizeTerminalInteractiveSession"); - const finalizeEnd = source.indexOf("async function archiveInteractiveSessionLogs", finalizeStart); + const finalizeEnd = source.indexOf("async function readSettings", finalizeStart); const finalizeSource = source.slice(finalizeStart, finalizeEnd); assert.match(releaseSource, /"terminal_failure_reason"/); @@ -576,6 +576,10 @@ test("confirmed adapter failure release keeps the original failure evidence", as test("terminal archive finalization remains durably retryable", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); + const archiveSource = await readFile( + new URL("../src/worker/session-log-archive.ts", import.meta.url), + "utf8", + ); const migration = await readFile( new URL("../migrations/0021_runtime_adapter_hardening.sql", import.meta.url), "utf8", @@ -587,7 +591,7 @@ test("terminal archive finalization remains durably retryable", async () => { ); const appendSource = source.slice(appendStart, appendEnd); const finalizeStart = source.indexOf("async function finalizeTerminalInteractiveSession"); - const finalizeEnd = source.indexOf("async function archiveInteractiveSessionLogs", finalizeStart); + const finalizeEnd = source.indexOf("async function readSettings", finalizeStart); const finalizeSource = source.slice(finalizeStart, finalizeEnd); assert.match(source, /expression\("terminal_finalize_pending", "=", 1\)/); @@ -595,20 +599,20 @@ test("terminal archive finalization remains durably retryable", async () => { assert.match(source, /const terminalCleanupDeletePending = 2/); assert.match(source, /completeTerminalFinalization/); assert.match(source, /SET terminal_finalize_pending = 0/); - assert.match(source, /interactive_session_log_archives\.events_key IS NULL/); - assert.match(source, /interactive_session_log_archives\.transcript_key IS NULL/); - assert.match(source, /interactive_session_log_archives\.summary_key IS NULL/); + assert.match(archiveSource, /interactive_session_log_archives\.events_key IS NULL/); + assert.match(archiveSource, /interactive_session_log_archives\.transcript_key IS NULL/); + assert.match(archiveSource, /interactive_session_log_archives\.summary_key IS NULL/); assert.match(source, /archive\.session_updated_at = interactive_sessions\.updated_at/); assert.match( - source, + archiveSource, /excluded\.session_updated_at > interactive_session_log_archives\.session_updated_at/, ); assert.match( - source, + archiveSource, /excluded\.session_updated_at IS interactive_session_log_archives\.session_updated_at/, ); assert.doesNotMatch( - source, + archiveSource, /session_updated_at IS NOT excluded\.session_updated_at[\s\S]*excluded\.updated_at >=/, ); assert.match(appendSource, /executeBatch\(env, \[/); diff --git a/tests/session-log-archive.test.ts b/tests/session-log-archive.test.ts new file mode 100644 index 0000000..1915d62 --- /dev/null +++ b/tests/session-log-archive.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + sessionLogArchiveBase, + sessionLogSummary, + sessionLogTranscript, + shouldArchiveInteractiveSessionLogs, +} from "../src/worker/session-log-archive.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +const archive = { + session_id: "IS-1", + event_count: 10, + session_updated_at: 100, + events_key: "events", + transcript_key: "transcript", + summary_key: "summary", + archived_at: 100, + updated_at: 100, +}; + +test("archive cadence handles force, small sessions, batches, and cooldowns", () => { + assert.equal(shouldArchiveInteractiveSessionLogs(undefined, 0, 100), true); + assert.equal(shouldArchiveInteractiveSessionLogs(archive, 9, 1000), false); + assert.equal(shouldArchiveInteractiveSessionLogs({ ...archive, event_count: 1 }, 2, 101), true); + assert.equal(shouldArchiveInteractiveSessionLogs(archive, 30, 101), true); + assert.equal(shouldArchiveInteractiveSessionLogs(archive, 10, 60_099), false); + assert.equal(shouldArchiveInteractiveSessionLogs(archive, 10, 60_100), true); + assert.equal(shouldArchiveInteractiveSessionLogs(archive, 9, 1, true), true); +}); + +test("archive object bases sanitize only the session path segment", () => { + assert.equal( + sessionLogArchiveBase("IS/1 unsafe"), + "orgs/openclaw/interactive-sessions/IS_1_unsafe", + ); +}); + +test("transcripts render row metadata and ordered events", () => { + const transcript = sessionLogTranscript( + sessionRow({ + id: "IS-1", + parent_session_id: "IS-0", + root_session_id: "IS-root", + work_state: "running", + work_phase: "review", + }), + [ + { + id: 1, + session_id: "IS-1", + actor: "operator", + message: "started", + created_at: 0, + }, + ], + ); + + assert.match(transcript, /^# IS-1/m); + assert.match(transcript, /parent: IS-0/); + assert.match(transcript, /root: IS-root/); + assert.match(transcript, /work_state: running/); + assert.match(transcript, /1970-01-01T00:00:00.000Z operator: started/); +}); + +test("transcripts accept domain sessions and summaries keep event anchors", () => { + const row = sessionRow({ + id: "IS-1", + root_session_id: null, + work_state: "completed", + last_event: "fallback", + updated_at: 120, + }); + const session = interactiveSession(row, []); + assert.match(sessionLogTranscript(session, []), /root: IS-1/); + + const summary = sessionLogSummary(row, [ + { id: 1, session_id: "IS-1", actor: "a", message: "first", created_at: 10 }, + { id: 2, session_id: "IS-1", actor: "b", message: "last", created_at: 20 }, + ]); + assert.equal(summary.workState, "completed"); + assert.equal(summary.eventCount, 2); + assert.equal(summary.firstEventAt, 10); + assert.equal(summary.lastEventAt, 20); + assert.equal(summary.lastEvent, "last"); + assert.equal(summary.updatedAt, 120); +}); From e099451dabcbb7c14e8c9737892e23e5416b8382 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 13:45:03 +0100 Subject: [PATCH 036/109] refactor: extract session presentation policy --- CHANGELOG.md | 1 + src/index.ts | 74 ++------- src/worker/session-presentation.ts | 92 +++++++++++ tests/runtime-adapter.test.ts | 23 --- tests/runtime-profiles.test.ts | 12 +- tests/session-presentation.test.ts | 205 ++++++++++++++++++++++++ tests/trusted-proxy-integration.test.ts | 5 +- 7 files changed, 314 insertions(+), 98 deletions(-) create mode 100644 src/worker/session-presentation.ts create mode 100644 tests/session-presentation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b16d5f9..6240fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Move legacy and GitHub Actions stop transitions plus stop-state lookups into the session repository. - Extract interactive-session ownership, management, multiplayer, and delegated-control authorization into one directly tested policy module. - Move interactive-session archive cadence, D1 snapshots, R2 objects, cleanup, transcripts, and summaries into one archive module. +- Extract interactive-session capability, control, provider redaction, desktop, and Codex SSH presentation policy behind direct tests. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 4dcb6eb..f2aa4fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -128,7 +128,6 @@ import { type CredentialPolicyGenerationTombstone, type CredentialPolicyLegacyMigration, } from "./credential-policy-fence"; -import { resolveRuntimeProfileCodexSsh, runtimeProfileByID } from "./runtime-profiles"; import { browserAppOrigin, clientDeploymentConfig, @@ -291,6 +290,7 @@ import { canControlInteractiveSession, canManageInteractiveSession, } from "./worker/session-access"; +import { presentInteractiveSession } from "./worker/session-presentation"; import { archiveInteractiveSessionLogs, cleanupSessionLogArchiveObjects, @@ -310,7 +310,7 @@ import { type RuntimeAdapterStopStore, type RuntimeAdapterWorkspaceStopResult, } from "./worker/session-runtime-adapter-stop"; -import { activeDelegatedController, sharedInteractiveSession } from "./worker/session-sharing"; +import { sharedInteractiveSession } from "./worker/session-sharing"; import type { InteractiveProvisionResult } from "./worker/session-provisioning"; import { interactiveCommand, @@ -13030,73 +13030,21 @@ function decorateInteractiveSession( user: User, env: RuntimeEnv, ): InteractiveSession { - const now = Date.now(); - const delegatedControl = canGrantDelegatedControl(env, session); - const canManage = canManageInteractiveSession(user, session); - const canChangeMultiplayer = canChangeInteractiveSessionMultiplayer(user, session); - const canControl = canControlInteractiveSession(user, session, now, delegatedControl); - const activeController = activeDelegatedController(session, now); - const desktopActive = !["stopping", "stopped", "expired", "failed"].includes(session.status); - const versionedDesktopReady = ["ready", "attached", "detached"].includes(session.status); - const versionedDesktopAvailable = - versionedDesktopReady && - session.adapter === runtimeAdapterName && - (session.capabilities.vnc || session.capabilities.desktop); - const legacyDesktopUrl = desktopActive ? safeDesktopUrl(session.vncUrl) : null; const routeKind = interactivePtyRouteKind(env, session); const routeAvailable = session.runtime === githubActionsRuntime || (routeKind === "sandbox" ? Boolean(env.SANDBOX) : Boolean(interactiveTerminalTarget(env, session, routeKind))); - const ptyAvailable = - canControl && - session.capabilities.terminal && - ["ready", "attached", "detached"].includes(session.status) && - routeAvailable; - const attachUrl = ptyAvailable ? "/api/terminal/ws" : null; - const codexSshReady = - session.adapter === runtimeAdapterName && - session.capabilities.terminal && - ["ready", "attached", "detached"].includes(session.status) && - Boolean(session[interactiveSessionAdapterControlPlane]) && - configuredRuntimeAdapterControlPlane(env, session.profile) === - session[interactiveSessionAdapterControlPlane]; - const runtimeProfile = runtimeProfileByID(deploymentConfig(env).runtimeProfiles, session.profile); - const codexSsh = - canManage && codexSshReady - ? resolveRuntimeProfileCodexSsh(runtimeProfile, { - providerResourceId: session.providerResourceId, - workspaceId: session.adapterWorkspaceId, - sessionId: session.id, - profile: session.profile, - }) - : null; - return { - ...session, - adapter: canControl ? session.adapter : null, - profile: canControl ? session.profile : "", - adapterWorkspaceId: canControl ? session.adapterWorkspaceId : null, - providerResourceId: canControl ? session.providerResourceId : null, - lastReconciledAt: canControl ? session.lastReconciledAt : null, - reconcileError: canControl ? session.reconcileError : null, - leaseId: canControl ? legacyInteractiveSessionLeaseId(session) : null, - attachUrl, - ptyAvailable, - codexSsh, - vncUrl: canControl - ? versionedDesktopAvailable - ? runtimeAdapterBrowserVncUrl(browserAppOrigin(env), session.id) - : legacyDesktopUrl - : null, - controller: activeController, - controlGrantedAt: activeController ? session.controlGrantedAt : null, - controlExpiresAt: activeController ? session.controlExpiresAt : null, - canManage, - canChangeMultiplayer, - canControl, - canRequestControl: delegatedControl && !canControl, - }; + return presentInteractiveSession(session, user, { + now: Date.now(), + delegatedControlAvailable: canGrantDelegatedControl(env, session), + terminalRouteAvailable: routeAvailable, + runtimeProfiles: deploymentConfig(env).runtimeProfiles, + configuredRuntimeAdapterControlPlane: (profile) => + configuredRuntimeAdapterControlPlane(env, profile), + browserVncUrl: (sessionId) => runtimeAdapterBrowserVncUrl(browserAppOrigin(env), sessionId), + }); } async function canControlInteractiveSessionById( diff --git a/src/worker/session-presentation.ts b/src/worker/session-presentation.ts new file mode 100644 index 0000000..fa5a4ee --- /dev/null +++ b/src/worker/session-presentation.ts @@ -0,0 +1,92 @@ +import { legacyLeaseIdForAdapter, runtimeAdapterName, safeDesktopUrl } from "../runtime-adapter.ts"; +import { + resolveRuntimeProfileCodexSsh, + runtimeProfileByID, + type RuntimeProfileDescriptor, +} from "../runtime-profiles.ts"; +import type { User } from "./models.ts"; +import { + canChangeInteractiveSessionMultiplayer, + canControlInteractiveSession, + canManageInteractiveSession, +} from "./session-access.ts"; +import { interactiveSessionAdapterControlPlane, type InteractiveSession } from "./session-model.ts"; +import { activeDelegatedController } from "./session-sharing.ts"; + +export type InteractiveSessionPresentationContext = { + now: number; + delegatedControlAvailable: boolean; + terminalRouteAvailable: boolean; + runtimeProfiles: RuntimeProfileDescriptor[]; + configuredRuntimeAdapterControlPlane: (profile: string) => string | null; + browserVncUrl: (sessionId: string) => string; +}; + +export function presentInteractiveSession( + session: InteractiveSession, + user: User, + context: InteractiveSessionPresentationContext, +): InteractiveSession { + const canManage = canManageInteractiveSession(user, session); + const canChangeMultiplayer = canChangeInteractiveSessionMultiplayer(user, session); + const canControl = canControlInteractiveSession( + user, + session, + context.now, + context.delegatedControlAvailable, + ); + const activeController = activeDelegatedController(session, context.now); + const ready = ["ready", "attached", "detached"].includes(session.status); + const desktopActive = !["stopping", "stopped", "expired", "failed"].includes(session.status); + const versionedDesktopAvailable = + ready && + session.adapter === runtimeAdapterName && + (session.capabilities.vnc || session.capabilities.desktop); + const ptyAvailable = + canControl && session.capabilities.terminal && ready && context.terminalRouteAvailable; + const codexSshReady = + session.adapter === runtimeAdapterName && + session.capabilities.terminal && + ready && + Boolean(session[interactiveSessionAdapterControlPlane]) && + context.configuredRuntimeAdapterControlPlane(session.profile) === + session[interactiveSessionAdapterControlPlane]; + const runtimeProfile = runtimeProfileByID(context.runtimeProfiles, session.profile); + const codexSsh = + canManage && codexSshReady + ? resolveRuntimeProfileCodexSsh(runtimeProfile, { + providerResourceId: session.providerResourceId, + workspaceId: session.adapterWorkspaceId, + sessionId: session.id, + profile: session.profile, + }) + : null; + + return { + ...session, + adapter: canControl ? session.adapter : null, + profile: canControl ? session.profile : "", + adapterWorkspaceId: canControl ? session.adapterWorkspaceId : null, + providerResourceId: canControl ? session.providerResourceId : null, + lastReconciledAt: canControl ? session.lastReconciledAt : null, + reconcileError: canControl ? session.reconcileError : null, + leaseId: canControl ? legacyLeaseIdForAdapter(session.adapter, session.leaseId) : null, + attachUrl: ptyAvailable ? "/api/terminal/ws" : null, + ptyAvailable, + codexSsh, + vncUrl: canControl + ? versionedDesktopAvailable + ? context.browserVncUrl(session.id) + : desktopActive + ? safeDesktopUrl(session.vncUrl) + : null + : null, + controller: activeController, + controlGrantedAt: activeController ? session.controlGrantedAt : null, + controlExpiresAt: activeController ? session.controlExpiresAt : null, + canManage, + canChangeMultiplayer, + canControl, + canRequestControl: context.delegatedControlAvailable && !canControl, + }; +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index fb7ef6e..6136223 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -1643,24 +1643,9 @@ test("sandbox credential cleanup is durably staged and retried", async () => { test("terminal endpoints enforce current runtime capabilities", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const decorateStart = source.indexOf("function decorateInteractiveSession"); - const decorateEnd = source.indexOf( - "async function canControlInteractiveSessionById", - decorateStart, - ); - const decorateSource = source.slice(decorateStart, decorateEnd); assert.match(source, /if \(!session\.capabilities\.terminal\)/); assert.match(source, /runtimeAdapterTerminalFailureStatus\(existing\.adapter\) === "detached"/); - assert.match(decorateSource, /const routeKind = interactivePtyRouteKind\(env, session\)/); - assert.match(decorateSource, /interactiveTerminalTarget\(env, session, routeKind\)/); - assert.match(decorateSource, /routeAvailable/); - assert.match(decorateSource, /const attachUrl = ptyAvailable \? "\/api\/terminal\/ws" : null/); - assert.match(decorateSource, /attachUrl,/); - assert.doesNotMatch( - decorateSource, - /attachUrl: canControl && session\.capabilities\.terminal \? session\.attachUrl : null/, - ); assert.doesNotMatch(source, /async function interactiveSessionPty/); assert.doesNotMatch(source, /\/api\/(?:ssh\/|agent\/)?interactive-sessions\/\(\[\^\/\]\+\)\/pty/); assert.match( @@ -2051,14 +2036,6 @@ test("runtime adapter terminals use the server-side adapter bearer", async () => assert.match(targetSource, /runtimeAdapterTerminalOriginMatches\(controlPlane, attachUrl\)/); assert.match(targetSource, /bearer\(runtimeAdapterToken\(env\)\)/); assert.doesNotMatch(targetSource, /searchParams\.set\([^)]*(?:token|ticket)/u); - const decorateStart = source.indexOf("function decorateInteractiveSession"); - const decorateEnd = source.indexOf( - "async function canControlInteractiveSessionById", - decorateStart, - ); - const decorateSource = source.slice(decorateStart, decorateEnd); - assert.match(decorateSource, /interactiveTerminalTarget\(env, session, routeKind\)/); - assert.match(decorateSource, /routeAvailable/); }); test("runtime adapter terminal upgrades use the coordinator service binding", async () => { diff --git a/tests/runtime-profiles.test.ts b/tests/runtime-profiles.test.ts index 0a595e2..6fb7abb 100644 --- a/tests/runtime-profiles.test.ts +++ b/tests/runtime-profiles.test.ts @@ -85,7 +85,7 @@ test("runtime profile catalog fails closed on malformed or ambiguous input", () assert.deepEqual(parseRuntimeProfiles(""), []); }); -test("runtime profiles resolve bounded manager-only Codex SSH handoff data", async () => { +test("runtime profiles resolve bounded Codex SSH handoff data", () => { const [profile] = parseRuntimeProfiles( JSON.stringify([ { @@ -169,16 +169,6 @@ test("runtime profiles resolve bounded manager-only Codex SSH handoff data", asy }, ); - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const start = source.indexOf("function decorateInteractiveSession"); - const end = source.indexOf("async function canControlInteractiveSessionById", start); - const decoration = source.slice(start, end); - assert.match(decoration, /canManage && codexSshReady/); - assert.match(decoration, /codexSsh,/); - assert.match( - decoration, - /configuredRuntimeAdapterControlPlane\(env, session\.profile\) ===\s+session\[interactiveSessionAdapterControlPlane\]/, - ); const client = clientDeploymentConfig({ CRABFLEET_DEFAULT_PROFILE: "linux", CRABFLEET_RUNTIME_PROFILES_JSON: JSON.stringify([ diff --git a/tests/session-presentation.test.ts b/tests/session-presentation.test.ts new file mode 100644 index 0000000..97e0afc --- /dev/null +++ b/tests/session-presentation.test.ts @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runtimeAdapterBrowserVncUrl, runtimeAdapterName } from "../src/runtime-adapter.ts"; +import { parseRuntimeProfiles } from "../src/runtime-profiles.ts"; +import type { User } from "../src/worker/models.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { + presentInteractiveSession, + type InteractiveSessionPresentationContext, +} from "../src/worker/session-presentation.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function user(values: Partial = {}): User { + return { + subject: "github:42", + login: "operator", + email: "operator@example.test", + name: "Operator", + role: "viewer", + allowed: true, + teams: [], + ...values, + }; +} + +function context( + values: Partial = {}, +): InteractiveSessionPresentationContext { + return { + now: 100, + delegatedControlAvailable: true, + terminalRouteAvailable: true, + runtimeProfiles: [], + configuredRuntimeAdapterControlPlane: () => "https://adapter.example", + browserVncUrl: (sessionId) => + runtimeAdapterBrowserVncUrl("https://crabfleet.example", sessionId), + ...values, + }; +} + +test("session presentation exposes managed adapter routes and Codex SSH only to managers", () => { + const [profile] = parseRuntimeProfiles( + JSON.stringify([ + { + id: "linux", + label: "Linux", + codexSsh: { + aliasTemplate: "codex-{providerResourceId}", + setupCommand: ["fleet-connect", "{workspaceId}"], + }, + }, + ]), + ); + const session = interactiveSession( + sessionRow({ + id: "IS-1", + owner: "operator", + runtime: "crabbox", + adapter: runtimeAdapterName, + profile: "linux", + adapter_workspace_id: "workspace-1", + adapter_control_plane: "https://adapter.example", + provider_resource_id: "box-1", + lease_id: "runtime-v1:legacy", + status: "ready", + }), + [], + ); + + const presented = presentInteractiveSession( + session, + user(), + context({ runtimeProfiles: profile ? [profile] : [] }), + ); + + assert.equal(presented.canManage, true); + assert.equal(presented.canControl, true); + assert.equal(presented.ptyAvailable, true); + assert.equal(presented.attachUrl, "/api/terminal/ws"); + assert.equal(presented.vncUrl, "https://crabfleet.example/api/interactive-sessions/IS-1/vnc"); + assert.equal(presented.leaseId, null); + assert.deepEqual(presented.codexSsh, { + alias: "codex-box-1", + setupCommand: "fleet-connect 'workspace-1'", + }); +}); + +test("session presentation hides provider authority from viewers without control", () => { + const session = interactiveSession( + sessionRow({ + owner: "someone-else", + runtime: "crabbox", + adapter: runtimeAdapterName, + profile: "linux", + adapter_workspace_id: "workspace-1", + adapter_control_plane: "https://adapter.example", + provider_resource_id: "box-1", + last_reconciled_at: 90, + reconcile_error: "private provider state", + status: "ready", + }), + [], + ); + + const presented = presentInteractiveSession(session, user(), context()); + + assert.equal(presented.adapter, null); + assert.equal(presented.profile, ""); + assert.equal(presented.adapterWorkspaceId, null); + assert.equal(presented.providerResourceId, null); + assert.equal(presented.lastReconciledAt, null); + assert.equal(presented.reconcileError, null); + assert.equal(presented.attachUrl, null); + assert.equal(presented.vncUrl, null); + assert.equal(presented.codexSsh, null); + assert.equal(presented.canRequestControl, true); +}); + +test("delegated control expires atomically in presented state", () => { + const session = interactiveSession( + sessionRow({ + owner: "someone-else", + controller: "operator", + control_granted_at: 50, + control_expires_at: 200, + status: "ready", + }), + [], + ); + + const active = presentInteractiveSession(session, user(), context({ now: 100 })); + assert.equal(active.canManage, false); + assert.equal(active.canControl, true); + assert.equal(active.controller, "operator"); + assert.equal(active.controlGrantedAt, 50); + assert.equal(active.canRequestControl, false); + assert.equal(active.attachUrl, "/api/terminal/ws"); + + const expired = presentInteractiveSession(session, user(), context({ now: 200 })); + assert.equal(expired.canControl, false); + assert.equal(expired.controller, null); + assert.equal(expired.controlGrantedAt, null); + assert.equal(expired.controlExpiresAt, null); + assert.equal(expired.canRequestControl, true); + assert.equal(expired.attachUrl, null); +}); + +test("terminal and Codex SSH projections fail closed when routing or adapter identity changes", () => { + const [profile] = parseRuntimeProfiles( + JSON.stringify([ + { + id: "linux", + label: "Linux", + codexSsh: { aliasTemplate: "codex-{workspaceId}" }, + }, + ]), + ); + const session = interactiveSession( + sessionRow({ + owner: "operator", + runtime: "crabbox", + adapter: runtimeAdapterName, + profile: "linux", + adapter_workspace_id: "workspace-1", + adapter_control_plane: "https://adapter.example", + status: "ready", + }), + [], + ); + const presented = presentInteractiveSession( + session, + user(), + context({ + terminalRouteAvailable: false, + runtimeProfiles: profile ? [profile] : [], + configuredRuntimeAdapterControlPlane: () => "https://other.example", + }), + ); + + assert.equal(presented.ptyAvailable, false); + assert.equal(presented.attachUrl, null); + assert.equal(presented.codexSsh, null); + + const withoutTerminal = presentInteractiveSession( + interactiveSession( + sessionRow({ + owner: "operator", + runtime: "crabbox", + adapter: runtimeAdapterName, + profile: "linux", + adapter_workspace_id: "workspace-1", + adapter_control_plane: "https://adapter.example", + capabilities_json: JSON.stringify({ terminal: false, vnc: true }), + status: "ready", + }), + [], + ), + user(), + context({ runtimeProfiles: profile ? [profile] : [] }), + ); + assert.equal(withoutTerminal.ptyAvailable, false); + assert.equal(withoutTerminal.attachUrl, null); + assert.equal(withoutTerminal.codexSsh, null); +}); diff --git a/tests/trusted-proxy-integration.test.ts b/tests/trusted-proxy-integration.test.ts index 32d923c..a0ff42d 100644 --- a/tests/trusted-proxy-integration.test.ts +++ b/tests/trusted-proxy-integration.test.ts @@ -37,7 +37,10 @@ test("split-origin links use the browser-visible proxy origin", async () => { assert.match(source, /trustedProxyPublicOrigin\(env\) \?\? new URL\(request\.url\)\.origin/); assert.match(source, /shareUrl\(request, env, id, result\.shareToken\)/); assert.match(source, /externalRequestOrigin\(request, env\)/); - assert.match(source, /runtimeAdapterBrowserVncUrl\(browserAppOrigin\(env\), session\.id\)/); + assert.match( + source, + /browserVncUrl: \(sessionId\) => runtimeAdapterBrowserVncUrl\(browserAppOrigin\(env\), sessionId\)/, + ); assert.match( source, /browserUrl: `\$\{browserAppOrigin\(env\)\}\/app\/sessions\/\$\{encodeURIComponent\(existing\.id\)\}`/, From 3d3ab8fe4138513c6da3186e40fe922974db059a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 13:49:51 +0100 Subject: [PATCH 037/109] refactor: extract terminal finalization --- CHANGELOG.md | 1 + src/index.ts | 138 +---------------- src/worker/session-terminal-finalization.ts | 158 ++++++++++++++++++++ tests/runtime-adapter.test.ts | 57 ++++--- tests/session-terminal-finalization.test.ts | 31 ++++ 5 files changed, 229 insertions(+), 156 deletions(-) create mode 100644 src/worker/session-terminal-finalization.ts create mode 100644 tests/session-terminal-finalization.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6240fb1..de4d45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Extract interactive-session ownership, management, multiplayer, and delegated-control authorization into one directly tested policy module. - Move interactive-session archive cadence, D1 snapshots, R2 objects, cleanup, transcripts, and summaries into one archive module. - Extract interactive-session capability, control, provider redaction, desktop, and Codex SSH presentation policy behind direct tests. +- Move terminal completion evidence, archive freshness checks, and finalization-marker persistence into one lifecycle module. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index f2aa4fd..af60bfd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -104,7 +104,6 @@ import { import { allocateInteractiveSessionIdSql, formatInteractiveSessionId } from "./session-id"; import { preferredEnabledRepo } from "./repo-selection"; import { sandboxGitAuthorEmail } from "./git-identity"; -import { completeTerminalFinalization } from "./terminal-finalization"; import { sizedTerminalTargetUrl } from "./terminal-target"; import { cachedBooleanGrant } from "./terminal-authorization"; import { readBoundedResponseText } from "./bounded-response"; @@ -296,6 +295,10 @@ import { cleanupSessionLogArchiveObjects, sessionLogTranscript, } from "./worker/session-log-archive"; +import { + finalizeTerminalInteractiveSession, + terminalFinalizationPendingQuery, +} from "./worker/session-terminal-finalization"; import { InteractiveSessionMetadataService, isInteractiveSessionMetadataAction, @@ -12600,139 +12603,6 @@ async function appendInteractiveSessionLog( await archiveInteractiveSessionLogs(env, id, now).catch(() => undefined); } -function terminalFinalizationPendingQuery(db: Kysely, id: string): CompilableQuery { - return db - .updateTable("interactive_sessions") - .set({ terminal_finalize_pending: 1 }) - .where("id", "=", id) - .where("status", "in", deadInteractiveSessionStatuses); -} - -async function finalizeTerminalInteractiveSession( - env: RuntimeEnv, - id: string, - status: "stopped" | "expired" | "failed", - now: number, -): Promise { - const db = database(env); - const terminal = await db - .selectFrom("interactive_sessions") - .select(["terminal_failure_reason", "reconcile_error", "last_event"]) - .where("id", "=", id) - .where("status", "=", status) - .executeTakeFirst(); - const message = - status === "failed" - ? retainedRuntimeAdapterFailureMessage( - terminal?.terminal_failure_reason ?? null, - terminal?.reconcile_error ?? null, - terminal?.last_event ?? null, - ) - : status === "expired" - ? "interactive workspace expired" - : "interactive workspace stopped"; - await completeTerminalFinalization({ - ensureEvent: async () => { - await executeBatch(env, [ - sql` - INSERT INTO interactive_session_events (session_id, actor, message, created_at) - SELECT ${id}, 'system', ${message}, COALESCE(stopped_at, ${now}) - FROM interactive_sessions AS session - WHERE session.id = ${id} - AND session.status = ${status} - AND NOT EXISTS ( - SELECT 1 - FROM interactive_session_events AS event - WHERE event.session_id = session.id - AND event.actor = 'system' - AND event.message = ${message} - ) - `, - terminalFinalizationPendingQuery(db, id), - ]); - return true; - }, - readArchiveState: async () => { - const [currentArchive, eventCount, currentSession] = await Promise.all([ - db - .selectFrom("interactive_session_log_archives") - .selectAll() - .where("session_id", "=", id) - .executeTakeFirst(), - countInteractiveSessionEvents(env, id), - db - .selectFrom("interactive_sessions") - .select("updated_at") - .where("id", "=", id) - .executeTakeFirst(), - ]); - return { - eventCount, - archiveEventCount: currentArchive?.event_count ?? null, - archiveSessionVersionMatches: - currentArchive?.session_updated_at === currentSession?.updated_at, - archiveObjectsReady: Boolean( - !env.SESSION_LOGS || - (currentArchive?.events_key && - currentArchive.transcript_key && - currentArchive.summary_key), - ), - }; - }, - archive: () => archiveInteractiveSessionLogs(env, id, now, { force: true }), - clearPending: async () => { - const cleared = await sql` - UPDATE interactive_sessions - SET terminal_finalize_pending = 0 - WHERE id = ${id} - AND status = ${status} - AND terminal_finalize_pending > 0 - AND EXISTS ( - SELECT 1 - FROM interactive_session_log_archives AS archive - WHERE archive.session_id = interactive_sessions.id - AND archive.session_updated_at = interactive_sessions.updated_at - ) - AND NOT EXISTS ( - SELECT 1 - FROM interactive_session_credential_policies - WHERE session_id = ${id} - ) - AND COALESCE( - ( - SELECT event_count - FROM interactive_session_log_archives - WHERE session_id = ${id} - ), - -1 - ) >= ( - SELECT count(*) - FROM interactive_session_events - WHERE session_id = ${id} - ) - AND ( - ${env.SESSION_LOGS ? 1 : 0} = 0 - OR EXISTS ( - SELECT 1 - FROM interactive_session_log_archives - WHERE session_id = ${id} - AND events_key IS NOT NULL - AND transcript_key IS NOT NULL - AND summary_key IS NOT NULL - ) - ) - `.execute(db); - if ((cleared.numAffectedRows ?? 0n) > 0n) return true; - const current = await db - .selectFrom("interactive_sessions") - .select("terminal_finalize_pending") - .where("id", "=", id) - .executeTakeFirst(); - return !current || current.terminal_finalize_pending === 0; - }, - }); -} - async function readSettings(env: RuntimeEnv): Promise> { const rows = await database(env).selectFrom("settings").select(["key", "value"]).execute(); return Object.fromEntries(rows.map((row) => [row.key, row.value])); diff --git a/src/worker/session-terminal-finalization.ts b/src/worker/session-terminal-finalization.ts new file mode 100644 index 0000000..3887ce8 --- /dev/null +++ b/src/worker/session-terminal-finalization.ts @@ -0,0 +1,158 @@ +import { sql, type Kysely } from "kysely"; + +import { retainedRuntimeAdapterFailureMessage } from "../runtime-adapter.ts"; +import { completeTerminalFinalization } from "../terminal-finalization.ts"; +import { database, executeBatch, type CompilableQuery, type Database } from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { deadInteractiveSessionStatuses } from "./models.ts"; +import { archiveInteractiveSessionLogs } from "./session-log-archive.ts"; +import { countInteractiveSessionEvents } from "./session-repository.ts"; + +export type TerminalInteractiveSessionStatus = "stopped" | "expired" | "failed"; + +export type TerminalInteractiveSessionFailureEvidence = { + terminal_failure_reason: string | null; + reconcile_error: string | null; + last_event: string | null; +}; + +export function terminalFinalizationPendingQuery( + db: Kysely, + id: string, +): CompilableQuery { + return db + .updateTable("interactive_sessions") + .set({ terminal_finalize_pending: 1 }) + .where("id", "=", id) + .where("status", "in", deadInteractiveSessionStatuses); +} + +export function terminalInteractiveSessionFinalizationMessage( + status: TerminalInteractiveSessionStatus, + terminal: TerminalInteractiveSessionFailureEvidence | undefined, +): string { + if (status === "failed") { + return retainedRuntimeAdapterFailureMessage( + terminal?.terminal_failure_reason ?? null, + terminal?.reconcile_error ?? null, + terminal?.last_event ?? null, + ); + } + return status === "expired" ? "interactive workspace expired" : "interactive workspace stopped"; +} + +export async function finalizeTerminalInteractiveSession( + env: RuntimeEnv, + id: string, + status: TerminalInteractiveSessionStatus, + now: number, +): Promise { + const db = database(env); + const terminal = await db + .selectFrom("interactive_sessions") + .select(["terminal_failure_reason", "reconcile_error", "last_event"]) + .where("id", "=", id) + .where("status", "=", status) + .executeTakeFirst(); + const message = terminalInteractiveSessionFinalizationMessage(status, terminal); + await completeTerminalFinalization({ + ensureEvent: async () => { + await executeBatch(env, [ + sql` + INSERT INTO interactive_session_events (session_id, actor, message, created_at) + SELECT ${id}, 'system', ${message}, COALESCE(stopped_at, ${now}) + FROM interactive_sessions AS session + WHERE session.id = ${id} + AND session.status = ${status} + AND NOT EXISTS ( + SELECT 1 + FROM interactive_session_events AS event + WHERE event.session_id = session.id + AND event.actor = 'system' + AND event.message = ${message} + ) + `, + terminalFinalizationPendingQuery(db, id), + ]); + return true; + }, + readArchiveState: async () => { + const [currentArchive, eventCount, currentSession] = await Promise.all([ + db + .selectFrom("interactive_session_log_archives") + .selectAll() + .where("session_id", "=", id) + .executeTakeFirst(), + countInteractiveSessionEvents(env, id), + db + .selectFrom("interactive_sessions") + .select("updated_at") + .where("id", "=", id) + .executeTakeFirst(), + ]); + return { + eventCount, + archiveEventCount: currentArchive?.event_count ?? null, + archiveSessionVersionMatches: + currentArchive?.session_updated_at === currentSession?.updated_at, + archiveObjectsReady: Boolean( + !env.SESSION_LOGS || + (currentArchive?.events_key && + currentArchive.transcript_key && + currentArchive.summary_key), + ), + }; + }, + archive: () => archiveInteractiveSessionLogs(env, id, now, { force: true }), + clearPending: async () => { + const cleared = await sql` + UPDATE interactive_sessions + SET terminal_finalize_pending = 0 + WHERE id = ${id} + AND status = ${status} + AND terminal_finalize_pending > 0 + AND EXISTS ( + SELECT 1 + FROM interactive_session_log_archives AS archive + WHERE archive.session_id = interactive_sessions.id + AND archive.session_updated_at = interactive_sessions.updated_at + ) + AND NOT EXISTS ( + SELECT 1 + FROM interactive_session_credential_policies + WHERE session_id = ${id} + ) + AND COALESCE( + ( + SELECT event_count + FROM interactive_session_log_archives + WHERE session_id = ${id} + ), + -1 + ) >= ( + SELECT count(*) + FROM interactive_session_events + WHERE session_id = ${id} + ) + AND ( + ${env.SESSION_LOGS ? 1 : 0} = 0 + OR EXISTS ( + SELECT 1 + FROM interactive_session_log_archives + WHERE session_id = ${id} + AND events_key IS NOT NULL + AND transcript_key IS NOT NULL + AND summary_key IS NOT NULL + ) + ) + `.execute(db); + if ((cleared.numAffectedRows ?? 0n) > 0n) return true; + const current = await db + .selectFrom("interactive_sessions") + .select("terminal_finalize_pending") + .where("id", "=", id) + .executeTakeFirst(); + return !current || current.terminal_finalize_pending === 0; + }, + }); +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 6136223..c28671a 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -501,6 +501,10 @@ test("confirmed stop races terminalize only after create ambiguity clears", () = test("runtime adapter lifecycle cannot escape durable session ownership", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); + const finalizationSource = await readFile( + new URL("../src/worker/session-terminal-finalization.ts", import.meta.url), + "utf8", + ); const stopStart = source.indexOf("async function stopSupersededRuntimeAdapterProvision"); const stopEnd = source.indexOf("async function resolveInteractiveSessionLineage", stopStart); const stopSource = source.slice(stopStart, stopEnd); @@ -541,11 +545,18 @@ test("runtime adapter lifecycle cannot escape durable session ownership", async assert.match(reconcileSource, /current\.stoppedAt \?\? now/); assert.match(reconcileSource, /finalizeTerminalInteractiveSession/); assert.match(source, /AND NOT EXISTS \(/); - assert.match(source, /archiveInteractiveSessionLogs\(env, id, now, \{ force: true \}\)/); + assert.match( + finalizationSource, + /archiveInteractiveSessionLogs\(env, id, now, \{ force: true \}\)/, + ); }); test("confirmed adapter failure release keeps the original failure evidence", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); + const finalizationSource = await readFile( + new URL("../src/worker/session-terminal-finalization.ts", import.meta.url), + "utf8", + ); const migration = await readFile( new URL("../migrations/0021_runtime_adapter_hardening.sql", import.meta.url), "utf8", @@ -556,9 +567,6 @@ test("confirmed adapter failure release keeps the original failure evidence", as releaseStart, ); const releaseSource = source.slice(releaseStart, releaseEnd); - const finalizeStart = source.indexOf("async function finalizeTerminalInteractiveSession"); - const finalizeEnd = source.indexOf("async function readSettings", finalizeStart); - const finalizeSource = source.slice(finalizeStart, finalizeEnd); assert.match(releaseSource, /"terminal_failure_reason"/); assert.match(releaseSource, /retainedRuntimeAdapterFailureMessage/); @@ -568,14 +576,18 @@ test("confirmed adapter failure release keeps the original failure evidence", as ); assert.match(releaseSource, /reconcile_error: resolved\.status === "failed" \? failureMessage/); assert.match(releaseSource, /\? failureMessage/); - assert.match(finalizeSource, /retainedRuntimeAdapterFailureMessage/); - assert.match(finalizeSource, /INSERT INTO interactive_session_events/); - assert.match(finalizeSource, /SELECT \$\{id\}, 'system', \$\{message\}/); + assert.match(finalizationSource, /retainedRuntimeAdapterFailureMessage/); + assert.match(finalizationSource, /INSERT INTO interactive_session_events/); + assert.match(finalizationSource, /SELECT \$\{id\}, 'system', \$\{message\}/); assert.match(migration, /ADD COLUMN terminal_failure_reason TEXT/); }); test("terminal archive finalization remains durably retryable", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); + const finalizationSource = await readFile( + new URL("../src/worker/session-terminal-finalization.ts", import.meta.url), + "utf8", + ); const archiveSource = await readFile( new URL("../src/worker/session-log-archive.ts", import.meta.url), "utf8", @@ -585,24 +597,21 @@ test("terminal archive finalization remains durably retryable", async () => { "utf8", ); const appendStart = source.indexOf("async function appendInteractiveSessionEvent"); - const appendEnd = source.indexOf( - "async function finalizeTerminalInteractiveSession", - appendStart, - ); + const appendEnd = source.indexOf("async function readSettings", appendStart); const appendSource = source.slice(appendStart, appendEnd); - const finalizeStart = source.indexOf("async function finalizeTerminalInteractiveSession"); - const finalizeEnd = source.indexOf("async function readSettings", finalizeStart); - const finalizeSource = source.slice(finalizeStart, finalizeEnd); assert.match(source, /expression\("terminal_finalize_pending", "=", 1\)/); assert.match(source, /row\.terminal_finalize_pending === 1/); assert.match(source, /const terminalCleanupDeletePending = 2/); - assert.match(source, /completeTerminalFinalization/); - assert.match(source, /SET terminal_finalize_pending = 0/); + assert.match(finalizationSource, /completeTerminalFinalization/); + assert.match(finalizationSource, /SET terminal_finalize_pending = 0/); assert.match(archiveSource, /interactive_session_log_archives\.events_key IS NULL/); assert.match(archiveSource, /interactive_session_log_archives\.transcript_key IS NULL/); assert.match(archiveSource, /interactive_session_log_archives\.summary_key IS NULL/); - assert.match(source, /archive\.session_updated_at = interactive_sessions\.updated_at/); + assert.match( + finalizationSource, + /archive\.session_updated_at = interactive_sessions\.updated_at/, + ); assert.match( archiveSource, /excluded\.session_updated_at > interactive_session_log_archives\.session_updated_at/, @@ -618,9 +627,9 @@ test("terminal archive finalization remains durably retryable", async () => { assert.match(appendSource, /executeBatch\(env, \[/); assert.match(appendSource, /insertInto\("interactive_session_events"\)/); assert.match(appendSource, /terminalFinalizationPendingQuery\(db, id\)/); - assert.match(finalizeSource, /executeBatch\(env, \[/); - assert.match(finalizeSource, /INSERT INTO interactive_session_events/); - assert.match(finalizeSource, /terminalFinalizationPendingQuery\(db, id\)/); + assert.match(finalizationSource, /executeBatch\(env, \[/); + assert.match(finalizationSource, /INSERT INTO interactive_session_events/); + assert.match(finalizationSource, /terminalFinalizationPendingQuery\(db, id\)/); assert.match(migration, /ADD COLUMN terminal_finalize_pending INTEGER NOT NULL DEFAULT 0/); assert.match(migration, /ADD COLUMN session_updated_at INTEGER/); assert.match(migration, /status IN \('stopped', 'expired', 'failed'\)/); @@ -801,6 +810,10 @@ test("production runtime adapter calls use the Crabbox service binding", async ( test("strict session rows and cleanup preserve terminal finalization anchors", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); + const finalizationSource = await readFile( + new URL("../src/worker/session-terminal-finalization.ts", import.meta.url), + "utf8", + ); const cleanupStart = source.indexOf("async function cleanupInteractiveSessions"); const cleanupEnd = source.indexOf("async function mutateInteractiveSession", cleanupStart); const cleanupSource = source.slice(cleanupStart, cleanupEnd); @@ -829,8 +842,8 @@ test("strict session rows and cleanup preserve terminal finalization anchors", a assert.match(cleanupSource, /WHERE id = \$\{row\.id\}/); assert.match(source, /terminalFinalizationPendingQuery/); assert.match(source, /executeBatch\(env, \[[\s\S]*interactive_session_events/); - assert.match(source, /COALESCE\([\s\S]*event_count[\s\S]*count\(\*\)/); - assert.match(source, /events_key IS NOT NULL/); + assert.match(finalizationSource, /COALESCE\([\s\S]*event_count[\s\S]*count\(\*\)/); + assert.match(finalizationSource, /events_key IS NOT NULL/); }); test("summary events invalidate terminal cleanup snapshots", async () => { diff --git a/tests/session-terminal-finalization.test.ts b/tests/session-terminal-finalization.test.ts new file mode 100644 index 0000000..9a76588 --- /dev/null +++ b/tests/session-terminal-finalization.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { terminalInteractiveSessionFinalizationMessage } from "../src/worker/session-terminal-finalization.ts"; + +test("terminal finalization messages preserve lifecycle and failure evidence", () => { + assert.equal( + terminalInteractiveSessionFinalizationMessage("stopped", undefined), + "interactive workspace stopped", + ); + assert.equal( + terminalInteractiveSessionFinalizationMessage("expired", undefined), + "interactive workspace expired", + ); + assert.equal( + terminalInteractiveSessionFinalizationMessage("failed", { + terminal_failure_reason: "provider create failed", + reconcile_error: "release retry failed", + last_event: "generic failure", + }), + "provider create failed", + ); + assert.equal( + terminalInteractiveSessionFinalizationMessage("failed", { + terminal_failure_reason: null, + reconcile_error: null, + last_event: null, + }), + "interactive workspace failed after release", + ); +}); From cd6e8b58f6a8a695e55c671bb76b1688bba8f089 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 13:53:43 +0100 Subject: [PATCH 038/109] refactor: unify session event append --- CHANGELOG.md | 1 + src/index.ts | 44 ++++++------------- src/worker/session-events.ts | 38 ++++++++++++++++ tests/runtime-adapter.test.ts | 14 +++--- tests/session-events.test.ts | 82 +++++++++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+), 38 deletions(-) create mode 100644 src/worker/session-events.ts create mode 100644 tests/session-events.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index de4d45b..4b1fefa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Move interactive-session archive cadence, D1 snapshots, R2 objects, cleanup, transcripts, and summaries into one archive module. - Extract interactive-session capability, control, provider redaction, desktop, and Codex SSH presentation policy behind direct tests. - Move terminal completion evidence, archive freshness checks, and finalization-marker persistence into one lifecycle module. +- Unify session event batching, message bounds, finalization invalidation, and best-effort archive refresh behind one service. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index af60bfd..02d4191 100644 --- a/src/index.ts +++ b/src/index.ts @@ -295,10 +295,8 @@ import { cleanupSessionLogArchiveObjects, sessionLogTranscript, } from "./worker/session-log-archive"; -import { - finalizeTerminalInteractiveSession, - terminalFinalizationPendingQuery, -} from "./worker/session-terminal-finalization"; +import { appendInteractiveSessionEventRecord } from "./worker/session-events"; +import { finalizeTerminalInteractiveSession } from "./worker/session-terminal-finalization"; import { InteractiveSessionMetadataService, isInteractiveSessionMetadataAction, @@ -12566,17 +12564,12 @@ async function appendInteractiveSessionEvent( message: string, now = Date.now(), ): Promise { - const db = database(env); - await executeBatch(env, [ - db.insertInto("interactive_session_events").values({ - session_id: id, - actor: actor(user), - message: clean(message, 1000), - created_at: now, - }), - terminalFinalizationPendingQuery(db, id), - ]); - await archiveInteractiveSessionLogs(env, id, now).catch(() => undefined); + await appendInteractiveSessionEventRecord(env, { + sessionId: id, + actor: actor(user), + message, + now, + }); } async function appendInteractiveSessionLog( @@ -12586,21 +12579,12 @@ async function appendInteractiveSessionLog( message: string, now = Date.now(), ): Promise { - if (user) { - await appendInteractiveSessionEvent(env, id, user, message, now); - return; - } - const db = database(env); - await executeBatch(env, [ - db.insertInto("interactive_session_events").values({ - session_id: id, - actor: "system", - message: clean(message, 1000), - created_at: now, - }), - terminalFinalizationPendingQuery(db, id), - ]); - await archiveInteractiveSessionLogs(env, id, now).catch(() => undefined); + await appendInteractiveSessionEventRecord(env, { + sessionId: id, + actor: user ? actor(user) : "system", + message, + now, + }); } async function readSettings(env: RuntimeEnv): Promise> { diff --git a/src/worker/session-events.ts b/src/worker/session-events.ts new file mode 100644 index 0000000..9f3b5d7 --- /dev/null +++ b/src/worker/session-events.ts @@ -0,0 +1,38 @@ +import { database, executeBatch } from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { archiveInteractiveSessionLogs } from "./session-log-archive.ts"; +import { terminalFinalizationPendingQuery } from "./session-terminal-finalization.ts"; + +export type AppendInteractiveSessionEventInput = { + sessionId: string; + actor: string; + message: string; + now: number; +}; + +export type InteractiveSessionEventArchive = (sessionId: string, now: number) => Promise; + +export async function appendInteractiveSessionEventRecord( + env: RuntimeEnv, + input: AppendInteractiveSessionEventInput, + archive: InteractiveSessionEventArchive = (sessionId, now) => + archiveInteractiveSessionLogs(env, sessionId, now), +): Promise { + const db = database(env); + await executeBatch(env, [ + db.insertInto("interactive_session_events").values({ + session_id: input.sessionId, + actor: input.actor, + message: clean(input.message, 1000), + created_at: input.now, + }), + terminalFinalizationPendingQuery(db, input.sessionId), + ]); + await archive(input.sessionId, input.now).catch(() => undefined); +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index c28671a..213f763 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -596,9 +596,6 @@ test("terminal archive finalization remains durably retryable", async () => { new URL("../migrations/0021_runtime_adapter_hardening.sql", import.meta.url), "utf8", ); - const appendStart = source.indexOf("async function appendInteractiveSessionEvent"); - const appendEnd = source.indexOf("async function readSettings", appendStart); - const appendSource = source.slice(appendStart, appendEnd); assert.match(source, /expression\("terminal_finalize_pending", "=", 1\)/); assert.match(source, /row\.terminal_finalize_pending === 1/); @@ -624,9 +621,6 @@ test("terminal archive finalization remains durably retryable", async () => { archiveSource, /session_updated_at IS NOT excluded\.session_updated_at[\s\S]*excluded\.updated_at >=/, ); - assert.match(appendSource, /executeBatch\(env, \[/); - assert.match(appendSource, /insertInto\("interactive_session_events"\)/); - assert.match(appendSource, /terminalFinalizationPendingQuery\(db, id\)/); assert.match(finalizationSource, /executeBatch\(env, \[/); assert.match(finalizationSource, /INSERT INTO interactive_session_events/); assert.match(finalizationSource, /terminalFinalizationPendingQuery\(db, id\)/); @@ -810,6 +804,10 @@ test("production runtime adapter calls use the Crabbox service binding", async ( test("strict session rows and cleanup preserve terminal finalization anchors", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); + const eventSource = await readFile( + new URL("../src/worker/session-events.ts", import.meta.url), + "utf8", + ); const finalizationSource = await readFile( new URL("../src/worker/session-terminal-finalization.ts", import.meta.url), "utf8", @@ -840,8 +838,8 @@ test("strict session rows and cleanup preserve terminal finalization anchors", a assert.match(cleanupSource, /JOIN active_ancestor ON session\.id = active_ancestor\.id/); assert.match(cleanupSource, /WHERE id = interactive_sessions\.id/); assert.match(cleanupSource, /WHERE id = \$\{row\.id\}/); - assert.match(source, /terminalFinalizationPendingQuery/); - assert.match(source, /executeBatch\(env, \[[\s\S]*interactive_session_events/); + assert.match(eventSource, /terminalFinalizationPendingQuery/); + assert.match(eventSource, /executeBatch\(env, \[[\s\S]*interactive_session_events/); assert.match(finalizationSource, /COALESCE\([\s\S]*event_count[\s\S]*count\(\*\)/); assert.match(finalizationSource, /events_key IS NOT NULL/); }); diff --git a/tests/session-events.test.ts b/tests/session-events.test.ts new file mode 100644 index 0000000..caa7616 --- /dev/null +++ b/tests/session-events.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { appendInteractiveSessionEventRecord } from "../src/worker/session-events.ts"; + +type PreparedStatement = { + sql: string; + parameters: unknown[]; +}; + +function runtimeEnv(onBatch: (statements: PreparedStatement[]) => void): RuntimeEnv { + return { + DB: { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + return { sql, parameters }; + }, + }; + }, + async batch(statements: unknown[]) { + onBatch(statements as PreparedStatement[]); + return []; + }, + } as unknown as D1Database, + } as RuntimeEnv; +} + +test("session events persist before archive refresh and invalidate terminal finalization", async () => { + const order: string[] = []; + let statements: PreparedStatement[] = []; + const env = runtimeEnv((batch) => { + order.push("persist"); + statements = batch; + }); + + await appendInteractiveSessionEventRecord( + env, + { + sessionId: "IS-1", + actor: "operator", + message: ` ${"x".repeat(1100)} `, + now: 123, + }, + async (sessionId, now) => { + order.push("archive"); + assert.equal(sessionId, "IS-1"); + assert.equal(now, 123); + }, + ); + + assert.deepEqual(order, ["persist", "archive"]); + assert.equal(statements.length, 2); + assert.match(statements[0]?.sql ?? "", /insert into "interactive_session_events"/i); + assert.deepEqual(statements[0]?.parameters, ["IS-1", "operator", "x".repeat(1000), 123]); + assert.match(statements[1]?.sql ?? "", /update "interactive_sessions"/i); + assert.match(statements[1]?.sql ?? "", /"terminal_finalize_pending" = \?/i); + assert.deepEqual(statements[1]?.parameters, [1, "IS-1", "stopped", "expired", "failed"]); +}); + +test("session event archive refresh remains best effort after durable persistence", async () => { + let persisted = false; + const env = runtimeEnv(() => { + persisted = true; + }); + + await appendInteractiveSessionEventRecord( + env, + { + sessionId: "IS-1", + actor: "system", + message: "completed", + now: 123, + }, + async () => { + throw new Error("archive unavailable"); + }, + ); + + assert.equal(persisted, true); +}); From 242056f800fbf940b8d560289b05765d096693ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:00:45 +0100 Subject: [PATCH 039/109] refactor: extract session cleanup service --- CHANGELOG.md | 1 + src/index.ts | 203 +--------------------------- src/worker/session-cleanup.ts | 242 ++++++++++++++++++++++++++++++++++ tests/runtime-adapter.test.ts | 39 +----- tests/session-cleanup.test.ts | 182 +++++++++++++++++++++++++ 5 files changed, 431 insertions(+), 236 deletions(-) create mode 100644 src/worker/session-cleanup.ts create mode 100644 tests/session-cleanup.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1fefa..effd995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Extract interactive-session capability, control, provider redaction, desktop, and Codex SSH presentation policy behind direct tests. - Move terminal completion evidence, archive freshness checks, and finalization-marker persistence into one lifecycle module. - Unify session event batching, message bounds, finalization invalidation, and best-effort archive refresh behind one service. +- Extract finalized-session admission, fenced transactional deletion, authorization filtering, and archive-object cleanup behind one service. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 02d4191..2b68c31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -143,7 +143,6 @@ import { type CompilableQuery, type Database, type InteractiveSessionCredentialPolicyTable, - type InteractiveSessionLogArchiveTable, type InteractiveSessionRow, type RepoWorkflowTable, type RunAttemptTable, @@ -290,12 +289,9 @@ import { canManageInteractiveSession, } from "./worker/session-access"; import { presentInteractiveSession } from "./worker/session-presentation"; -import { - archiveInteractiveSessionLogs, - cleanupSessionLogArchiveObjects, - sessionLogTranscript, -} from "./worker/session-log-archive"; +import { archiveInteractiveSessionLogs, sessionLogTranscript } from "./worker/session-log-archive"; import { appendInteractiveSessionEventRecord } from "./worker/session-events"; +import { createInteractiveSessionCleanupService } from "./worker/session-cleanup"; import { finalizeTerminalInteractiveSession } from "./worker/session-terminal-finalization"; import { InteractiveSessionMetadataService, @@ -648,7 +644,6 @@ const runtimeAdapterReconcileConcurrency = 3; const runtimeAdapterReconcileForegroundBudgetMs = 750; const openClawPreparationTimeoutMs = 60_000; const interactiveSessionPreparationStaleMs = 5 * 60_000; -const terminalCleanupDeletePending = 2; const credentialPolicyCleanupLimit = 8; const credentialPolicyScanLimit = 32; const credentialPolicyCleanupClaimMs = 30_000; @@ -4144,206 +4139,16 @@ async function cleanupInteractiveSessions( const ids = Array.isArray(body.ids) ? [...new Set(body.ids.map((id) => clean(String(id), 80)).filter(Boolean))] : []; - const db = database(env); - let query = db - .selectFrom("interactive_sessions") - .selectAll() - .where("status", "in", deadInteractiveSessionStatuses) - .where("terminal_finalize_pending", "=", 0).where(sql` - NOT EXISTS ( - SELECT 1 - FROM interactive_session_credential_policies AS policy - WHERE policy.session_id = interactive_sessions.id - ) - `).where(sql` - COALESCE( - ( - SELECT event_count - FROM interactive_session_log_archives AS archive - WHERE archive.session_id = interactive_sessions.id - ), - -1 - ) >= ( - SELECT count(*) - FROM interactive_session_events AS event - WHERE event.session_id = interactive_sessions.id - ) - `).where(sql` - EXISTS ( - SELECT 1 - FROM interactive_session_log_archives AS archive - WHERE archive.session_id = interactive_sessions.id - AND archive.session_updated_at = interactive_sessions.updated_at - ) - `).where(sql` - NOT EXISTS ( - WITH RECURSIVE active_ancestor(id) AS ( - SELECT parent_session_id - FROM interactive_sessions - WHERE status NOT IN ('stopped', 'expired', 'failed') - AND parent_session_id IS NOT NULL - UNION - SELECT session.parent_session_id - FROM interactive_sessions AS session - JOIN active_ancestor ON session.id = active_ancestor.id - WHERE session.parent_session_id IS NOT NULL - ) - SELECT 1 - FROM active_ancestor - WHERE id = interactive_sessions.id - ) - `).where(sql` - ${env.SESSION_LOGS ? 1 : 0} = 0 - OR EXISTS ( - SELECT 1 - FROM interactive_session_log_archives AS archive - WHERE archive.session_id = interactive_sessions.id - AND archive.events_key IS NOT NULL - AND archive.transcript_key IS NOT NULL - AND archive.summary_key IS NOT NULL - ) - `); - if (ids.length) query = query.where("id", "in", ids); - const candidates = (await query.execute()).filter((row) => + const cleanup = createInteractiveSessionCleanupService(env); + const removedIds = await cleanup.cleanup(ids, (row) => canManageInteractiveSession(user, interactiveSession(row, [])), ); - const removedIds = ( - await Promise.all( - candidates.map(async (row) => { - const archive = await db - .selectFrom("interactive_session_log_archives") - .selectAll() - .where("session_id", "=", row.id) - .executeTakeFirst(); - const removed = await deleteFinalizedInteractiveSession(env, row, archive); - if (!removed) return null; - await cleanupSessionLogArchiveObjects(env, archive).catch((error) => { - console.error(`session archive object cleanup leaked for ${row.id}`, error); - }); - return row.id; - }), - ) - ).filter((id): id is string => Boolean(id)); if (removedIds.length) { await audit(env, user, `interactive sessions cleaned ${removedIds.join(",")}`, Date.now()); } return { state: await readState(request, env, user), removedIds }; } -async function deleteFinalizedInteractiveSession( - env: RuntimeEnv, - row: InteractiveSessionRow, - archive: Selectable | undefined, -): Promise { - const db = database(env); - const claimToken = `cleanup:${crypto.randomUUID()}`; - const finalClaim = db - .updateTable("interactive_sessions") - .set({ - terminal_finalize_pending: terminalCleanupDeletePending, - reconcile_error: claimToken, - }) - .where("id", "=", row.id) - .where("status", "=", row.status) - .where("updated_at", "=", row.updated_at) - .where("terminal_finalize_pending", "=", 0).where(sql` - NOT EXISTS ( - SELECT 1 - FROM interactive_session_credential_policies - WHERE session_id = ${row.id} - ) - `).where(sql` - NOT EXISTS ( - WITH RECURSIVE active_ancestor(id) AS ( - SELECT parent_session_id - FROM interactive_sessions - WHERE status NOT IN ('stopped', 'expired', 'failed') - AND parent_session_id IS NOT NULL - UNION - SELECT session.parent_session_id - FROM interactive_sessions AS session - JOIN active_ancestor ON session.id = active_ancestor.id - WHERE session.parent_session_id IS NOT NULL - ) - SELECT 1 - FROM active_ancestor - WHERE id = ${row.id} - ) - `).where(sql` - ${archive ? 1 : 0} = 1 - AND EXISTS ( - SELECT 1 - FROM interactive_session_log_archives - WHERE session_id = ${row.id} - AND event_count = ${archive?.event_count ?? -1} - AND session_updated_at IS ${archive?.session_updated_at ?? null} - AND session_updated_at = ${row.updated_at} - AND events_key IS ${archive?.events_key ?? null} - AND transcript_key IS ${archive?.transcript_key ?? null} - AND summary_key IS ${archive?.summary_key ?? null} - AND archived_at = ${archive?.archived_at ?? -1} - AND updated_at = ${archive?.updated_at ?? -1} - ) - `).where(sql` - COALESCE( - ( - SELECT event_count - FROM interactive_session_log_archives - WHERE session_id = ${row.id} - ), - -1 - ) >= ( - SELECT count(*) - FROM interactive_session_events - WHERE session_id = ${row.id} - ) - `).where(sql` - ${env.SESSION_LOGS ? 1 : 0} = 0 - OR EXISTS ( - SELECT 1 - FROM interactive_session_log_archives - WHERE session_id = ${row.id} - AND events_key IS NOT NULL - AND transcript_key IS NOT NULL - AND summary_key IS NOT NULL - ) - `); - const ownsFinalClaim = sql`EXISTS ( - SELECT 1 - FROM interactive_sessions - WHERE id = ${row.id} - AND status = ${row.status} - AND updated_at = ${row.updated_at} - AND terminal_finalize_pending = ${terminalCleanupDeletePending} - AND reconcile_error = ${claimToken} - )`; - // D1 batches are transactional, so no event can interleave between the claim and row deletes. - await executeBatch(env, [ - finalClaim, - db - .deleteFrom("interactive_session_events") - .where("session_id", "=", row.id) - .where(ownsFinalClaim), - db - .deleteFrom("interactive_session_log_archives") - .where("session_id", "=", row.id) - .where(ownsFinalClaim), - db - .deleteFrom("interactive_sessions") - .where("id", "=", row.id) - .where("status", "=", row.status) - .where("updated_at", "=", row.updated_at) - .where("terminal_finalize_pending", "=", terminalCleanupDeletePending) - .where("reconcile_error", "=", claimToken), - ]); - const current = await db - .selectFrom("interactive_sessions") - .select("id") - .where("id", "=", row.id) - .executeTakeFirst(); - return !current; -} - async function mutateInteractiveSessionWithEventAtomically( env: RuntimeEnv, session: Pick, diff --git a/src/worker/session-cleanup.ts b/src/worker/session-cleanup.ts new file mode 100644 index 0000000..30801e3 --- /dev/null +++ b/src/worker/session-cleanup.ts @@ -0,0 +1,242 @@ +import { sql, type RawBuilder, type Selectable } from "kysely"; + +import { + database, + executeBatch, + type InteractiveSessionLogArchiveTable, + type InteractiveSessionRow, +} from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { deadInteractiveSessionStatuses } from "./models.ts"; +import { cleanupSessionLogArchiveObjects } from "./session-log-archive.ts"; + +const terminalCleanupDeletePending = 2; +type SessionReference = string | RawBuilder; + +function hasNoCredentialPolicy(sessionId: SessionReference): RawBuilder { + return sql` + NOT EXISTS ( + SELECT 1 + FROM interactive_session_credential_policies + WHERE session_id = ${sessionId} + ) + `; +} + +function hasNoActiveDescendants(sessionId: SessionReference): RawBuilder { + return sql` + NOT EXISTS ( + WITH RECURSIVE active_ancestor(id) AS ( + SELECT parent_session_id + FROM interactive_sessions + WHERE status NOT IN ('stopped', 'expired', 'failed') + AND parent_session_id IS NOT NULL + UNION + SELECT session.parent_session_id + FROM interactive_sessions AS session + JOIN active_ancestor ON session.id = active_ancestor.id + WHERE session.parent_session_id IS NOT NULL + ) + SELECT 1 + FROM active_ancestor + WHERE id = ${sessionId} + ) + `; +} + +function archiveCoversAllEvents(sessionId: SessionReference): RawBuilder { + return sql` + COALESCE( + ( + SELECT event_count + FROM interactive_session_log_archives + WHERE session_id = ${sessionId} + ), + -1 + ) >= ( + SELECT count(*) + FROM interactive_session_events + WHERE session_id = ${sessionId} + ) + `; +} + +function archiveObjectsAreComplete( + sessionId: SessionReference, + sessionLogsEnabled: boolean, +): RawBuilder { + return sql` + ${sessionLogsEnabled ? 1 : 0} = 0 + OR EXISTS ( + SELECT 1 + FROM interactive_session_log_archives + WHERE session_id = ${sessionId} + AND events_key IS NOT NULL + AND transcript_key IS NOT NULL + AND summary_key IS NOT NULL + ) + `; +} + +export type InteractiveSessionCleanupCandidate = { + row: InteractiveSessionRow; + archive: Selectable | undefined; +}; + +export type InteractiveSessionCleanupStore = { + readCandidates(ids: readonly string[]): Promise; + deleteCandidate(candidate: InteractiveSessionCleanupCandidate): Promise; + cleanupArchive(archive: Selectable | undefined): Promise; + reportArchiveCleanupFailure(sessionId: string, error: unknown): void; +}; + +export class InteractiveSessionCleanupService { + private readonly store: InteractiveSessionCleanupStore; + + constructor(store: InteractiveSessionCleanupStore) { + this.store = store; + } + + async cleanup( + ids: readonly string[], + canManage: (row: InteractiveSessionRow) => boolean, + ): Promise { + const candidates = (await this.store.readCandidates(ids)).filter(({ row }) => canManage(row)); + return ( + await Promise.all( + candidates.map(async (candidate) => { + if (!(await this.store.deleteCandidate(candidate))) return null; + await this.store.cleanupArchive(candidate.archive).catch((error) => { + this.store.reportArchiveCleanupFailure(candidate.row.id, error); + }); + return candidate.row.id; + }), + ) + ).filter((id): id is string => Boolean(id)); + } +} + +export function createInteractiveSessionCleanupService( + env: RuntimeEnv, + reportArchiveCleanupFailure: (sessionId: string, error: unknown) => void = (sessionId, error) => { + console.error(`session archive object cleanup leaked for ${sessionId}`, error); + }, +): InteractiveSessionCleanupService { + return new InteractiveSessionCleanupService({ + readCandidates: (ids) => readInteractiveSessionCleanupCandidates(env, ids), + deleteCandidate: ({ row, archive }) => deleteFinalizedInteractiveSession(env, row, archive), + cleanupArchive: (archive) => cleanupSessionLogArchiveObjects(env, archive), + reportArchiveCleanupFailure, + }); +} + +export async function readInteractiveSessionCleanupCandidates( + env: RuntimeEnv, + ids: readonly string[], +): Promise { + const db = database(env); + let query = db + .selectFrom("interactive_sessions") + .selectAll() + .where("status", "in", deadInteractiveSessionStatuses) + .where("terminal_finalize_pending", "=", 0) + .where(hasNoCredentialPolicy(sql.ref("interactive_sessions.id"))) + .where(archiveCoversAllEvents(sql.ref("interactive_sessions.id"))) + .where(sql` + EXISTS ( + SELECT 1 + FROM interactive_session_log_archives AS archive + WHERE archive.session_id = interactive_sessions.id + AND archive.session_updated_at = interactive_sessions.updated_at + ) + `) + .where(hasNoActiveDescendants(sql.ref("interactive_sessions.id"))) + .where( + archiveObjectsAreComplete(sql.ref("interactive_sessions.id"), Boolean(env.SESSION_LOGS)), + ); + if (ids.length) query = query.where("id", "in", ids); + const rows = await query.execute(); + return Promise.all( + rows.map(async (row) => ({ + row, + archive: await db + .selectFrom("interactive_session_log_archives") + .selectAll() + .where("session_id", "=", row.id) + .executeTakeFirst(), + })), + ); +} + +export async function deleteFinalizedInteractiveSession( + env: RuntimeEnv, + row: InteractiveSessionRow, + archive: Selectable | undefined, +): Promise { + const db = database(env); + const claimToken = `cleanup:${crypto.randomUUID()}`; + const finalClaim = db + .updateTable("interactive_sessions") + .set({ + terminal_finalize_pending: terminalCleanupDeletePending, + reconcile_error: claimToken, + }) + .where("id", "=", row.id) + .where("status", "=", row.status) + .where("updated_at", "=", row.updated_at) + .where("terminal_finalize_pending", "=", 0) + .where(hasNoCredentialPolicy(row.id)) + .where(hasNoActiveDescendants(row.id)) + .where(sql` + ${archive ? 1 : 0} = 1 + AND EXISTS ( + SELECT 1 + FROM interactive_session_log_archives + WHERE session_id = ${row.id} + AND event_count = ${archive?.event_count ?? -1} + AND session_updated_at IS ${archive?.session_updated_at ?? null} + AND session_updated_at = ${row.updated_at} + AND events_key IS ${archive?.events_key ?? null} + AND transcript_key IS ${archive?.transcript_key ?? null} + AND summary_key IS ${archive?.summary_key ?? null} + AND archived_at = ${archive?.archived_at ?? -1} + AND updated_at = ${archive?.updated_at ?? -1} + ) + `) + .where(archiveCoversAllEvents(row.id)) + .where(archiveObjectsAreComplete(row.id, Boolean(env.SESSION_LOGS))); + const ownsFinalClaim = sql`EXISTS ( + SELECT 1 + FROM interactive_sessions + WHERE id = ${row.id} + AND status = ${row.status} + AND updated_at = ${row.updated_at} + AND terminal_finalize_pending = ${terminalCleanupDeletePending} + AND reconcile_error = ${claimToken} + )`; + // D1 batches are transactional, so no event can interleave between the claim and row deletes. + await executeBatch(env, [ + finalClaim, + db + .deleteFrom("interactive_session_events") + .where("session_id", "=", row.id) + .where(ownsFinalClaim), + db + .deleteFrom("interactive_session_log_archives") + .where("session_id", "=", row.id) + .where(ownsFinalClaim), + db + .deleteFrom("interactive_sessions") + .where("id", "=", row.id) + .where("status", "=", row.status) + .where("updated_at", "=", row.updated_at) + .where("terminal_finalize_pending", "=", terminalCleanupDeletePending) + .where("reconcile_error", "=", claimToken), + ]); + const current = await db + .selectFrom("interactive_sessions") + .select("id") + .where("id", "=", row.id) + .executeTakeFirst(); + return !current; +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 213f763..16236e9 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -599,7 +599,6 @@ test("terminal archive finalization remains durably retryable", async () => { assert.match(source, /expression\("terminal_finalize_pending", "=", 1\)/); assert.match(source, /row\.terminal_finalize_pending === 1/); - assert.match(source, /const terminalCleanupDeletePending = 2/); assert.match(finalizationSource, /completeTerminalFinalization/); assert.match(finalizationSource, /SET terminal_finalize_pending = 0/); assert.match(archiveSource, /interactive_session_log_archives\.events_key IS NULL/); @@ -802,8 +801,7 @@ test("production runtime adapter calls use the Crabbox service binding", async ( assert.match(lifecycleSource, /fetcher\.fetch\(target/); }); -test("strict session rows and cleanup preserve terminal finalization anchors", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); +test("session events and terminal finalization preserve archive anchors", async () => { const eventSource = await readFile( new URL("../src/worker/session-events.ts", import.meta.url), "utf8", @@ -812,43 +810,15 @@ test("strict session rows and cleanup preserve terminal finalization anchors", a new URL("../src/worker/session-terminal-finalization.ts", import.meta.url), "utf8", ); - const cleanupStart = source.indexOf("async function cleanupInteractiveSessions"); - const cleanupEnd = source.indexOf("async function mutateInteractiveSession", cleanupStart); - const cleanupSource = source.slice(cleanupStart, cleanupEnd); - assert.match(cleanupSource, /where\("terminal_finalize_pending", "=", 0\)/); - assert.match(cleanupSource, /deleteFinalizedInteractiveSession\(env, row, archive\)/); - assert.match(cleanupSource, /terminalCleanupDeletePending/); - assert.match(cleanupSource, /updated_at", "=", row\.updated_at/); - assert.match(cleanupSource, /executeBatch\(env, \[/); - const archiveIndex = cleanupSource.indexOf('.selectFrom("interactive_session_log_archives")'); - const deleteIndex = cleanupSource.indexOf("deleteFinalizedInteractiveSession(env, row, archive)"); - const objectCleanupIndex = cleanupSource.indexOf("cleanupSessionLogArchiveObjects(env, archive)"); - assert.ok(archiveIndex >= 0 && deleteIndex > archiveIndex && objectCleanupIndex > deleteIndex); - assert.match(cleanupSource, /session archive object cleanup leaked/); - assert.match(cleanupSource, /events_key IS \$\{archive\?\.events_key/); - assert.match(cleanupSource, /transcript_key IS \$\{archive\?\.transcript_key/); - assert.match(cleanupSource, /summary_key IS \$\{archive\?\.summary_key/); - assert.match(cleanupSource, /deleteFrom\("interactive_session_events"\)/); - assert.match(cleanupSource, /deleteFrom\("interactive_session_log_archives"\)/); - assert.match(cleanupSource, /deleteFrom\("interactive_sessions"\)/); - assert.match(cleanupSource, /FROM interactive_session_credential_policies/); - assert.equal(cleanupSource.match(/WITH RECURSIVE active_ancestor\(id\)/g)?.length, 2); - assert.match(cleanupSource, /SELECT parent_session_id[\s\S]*FROM interactive_sessions/); - assert.match(cleanupSource, /JOIN active_ancestor ON session\.id = active_ancestor\.id/); - assert.match(cleanupSource, /WHERE id = interactive_sessions\.id/); - assert.match(cleanupSource, /WHERE id = \$\{row\.id\}/); assert.match(eventSource, /terminalFinalizationPendingQuery/); assert.match(eventSource, /executeBatch\(env, \[[\s\S]*interactive_session_events/); assert.match(finalizationSource, /COALESCE\([\s\S]*event_count[\s\S]*count\(\*\)/); assert.match(finalizationSource, /events_key IS NOT NULL/); }); -test("summary events invalidate terminal cleanup snapshots", async () => { +test("summary events update metadata and refresh archive snapshots", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const cleanupStart = source.indexOf("async function deleteFinalizedInteractiveSession"); - const cleanupEnd = source.indexOf("async function mutateInteractiveSession", cleanupStart); - const cleanupSource = source.slice(cleanupStart, cleanupEnd); const summaryStart = source.indexOf("async function updateInteractiveSessionSummary"); const summaryEnd = source.indexOf("async function updateGitHubActionsWorkState", summaryStart); const summarySource = source.slice(summaryStart, summaryEnd); @@ -858,11 +828,6 @@ test("summary events invalidate terminal cleanup snapshots", async () => { const metadataEnd = source.indexOf("async function mutateInteractiveSession(", metadataStart); const metadataSource = source.slice(metadataStart, metadataEnd); - assert.match(cleanupSource, /where\("updated_at", "=", row\.updated_at\)/); - assert.match(cleanupSource, /terminal_finalize_pending: terminalCleanupDeletePending/); - assert.match(cleanupSource, /event_count = \$\{archive\?\.event_count/); - assert.match(cleanupSource, /archived_at = \$\{archive\?\.archived_at/); - assert.match(cleanupSource, /count\(\*\)/); assert.match(summarySource, /mutateInteractiveSessionWithEventAtomically/); assert.match(metadataSource, /persistInteractiveSessionEventMutation/); assert.match(metadataSource, /archiveInteractiveSessionLogs/); diff --git a/tests/session-cleanup.test.ts b/tests/session-cleanup.test.ts new file mode 100644 index 0000000..b71f5f9 --- /dev/null +++ b/tests/session-cleanup.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { InteractiveSessionLogArchiveTable } from "../src/worker/database.ts"; +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + deleteFinalizedInteractiveSession, + InteractiveSessionCleanupService, + readInteractiveSessionCleanupCandidates, + type InteractiveSessionCleanupCandidate, +} from "../src/worker/session-cleanup.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +type D1Result = { results?: unknown[]; changes?: number }; +type PreparedStatement = { + sql: string; + parameters: unknown[]; + all(): Promise; + run(): Promise; +}; + +function runtimeEnv( + handler: (sql: string, parameters: unknown[], kind: "all" | "run") => D1Result, + batchHandler: (statements: PreparedStatement[]) => void = () => undefined, + sessionLogs = false, +): RuntimeEnv { + return { + DB: { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + return { + sql, + parameters, + async all() { + const result = handler(sql, parameters, "all"); + return { results: result.results ?? [], meta: { changes: result.changes ?? 0 } }; + }, + async run() { + const result = handler(sql, parameters, "run"); + return { meta: { changes: result.changes ?? 0 } }; + }, + }; + }, + }; + }, + async batch(statements: unknown[]) { + batchHandler(statements as PreparedStatement[]); + return []; + }, + } as unknown as D1Database, + ...(sessionLogs ? { SESSION_LOGS: {} as R2Bucket } : {}), + } as RuntimeEnv; +} + +function archive( + values: Partial = {}, +): InteractiveSessionLogArchiveTable { + return { + session_id: "IS-1", + event_count: 3, + session_updated_at: 100, + events_key: "events.ndjson", + transcript_key: "transcript.md", + summary_key: "summary.json", + archived_at: 110, + updated_at: 110, + ...values, + }; +} + +test("cleanup service authorizes before deletion and removes archive objects afterward", async () => { + const candidates: InteractiveSessionCleanupCandidate[] = [ + { row: sessionRow({ id: "IS-1", status: "stopped" }), archive: archive() }, + { row: sessionRow({ id: "IS-2", status: "failed" }), archive: archive({ session_id: "IS-2" }) }, + { + row: sessionRow({ id: "IS-3", status: "expired" }), + archive: archive({ session_id: "IS-3" }), + }, + ]; + const deleted: string[] = []; + const cleaned: string[] = []; + const failures: string[] = []; + const service = new InteractiveSessionCleanupService({ + async readCandidates(ids) { + assert.deepEqual(ids, ["IS-1", "IS-2", "IS-3"]); + return candidates; + }, + async deleteCandidate(candidate) { + deleted.push(candidate.row.id); + return candidate.row.id !== "IS-3"; + }, + async cleanupArchive(candidateArchive) { + cleaned.push(candidateArchive?.session_id ?? "none"); + throw new Error("R2 unavailable"); + }, + reportArchiveCleanupFailure(sessionId) { + failures.push(sessionId); + }, + }); + + assert.deepEqual(await service.cleanup(["IS-1", "IS-2", "IS-3"], (row) => row.id !== "IS-2"), [ + "IS-1", + ]); + assert.deepEqual(deleted, ["IS-1", "IS-3"]); + assert.deepEqual(cleaned, ["IS-1"]); + assert.deepEqual(failures, ["IS-1"]); +}); + +test("cleanup candidates require finalized archives, no credentials, and no active descendants", async () => { + const row = sessionRow({ id: "IS-1", status: "stopped", updated_at: 100 }); + const logArchive = archive(); + let sessionReads = 0; + const env = runtimeEnv( + (sql, parameters, kind) => { + assert.equal(kind, "all"); + if (/from "interactive_sessions"/i.test(sql)) { + sessionReads += 1; + assert.match(sql, /"terminal_finalize_pending" =/i); + assert.match(sql, /interactive_session_credential_policies/i); + assert.match(sql, /WITH RECURSIVE active_ancestor\(id\)/i); + assert.match(sql, /archive\.session_updated_at = interactive_sessions\.updated_at/i); + assert.match(sql, /events_key IS NOT NULL/i); + assert.match(sql, /count\(\*\)/i); + assert.match(sql, /"id" in/i); + assert.ok(parameters.includes("IS-1")); + return { results: [row] }; + } + if (/from "interactive_session_log_archives"/i.test(sql)) { + assert.deepEqual(parameters, ["IS-1"]); + return { results: [logArchive] }; + } + throw new Error(`unexpected query: ${sql}`); + }, + undefined, + true, + ); + + assert.deepEqual(await readInteractiveSessionCleanupCandidates(env, ["IS-1"]), [ + { row, archive: logArchive }, + ]); + assert.equal(sessionReads, 1); +}); + +test("finalized deletion claims and removes events, archive metadata, and session atomically", async () => { + const row = sessionRow({ id: "IS-1", status: "stopped", updated_at: 100 }); + const logArchive = archive(); + let batch: PreparedStatement[] = []; + const env = runtimeEnv( + (sql, parameters, kind) => { + assert.equal(kind, "all"); + assert.match(sql, /from "interactive_sessions"/i); + assert.deepEqual(parameters, ["IS-1"]); + return { results: [] }; + }, + (statements) => { + batch = statements; + }, + true, + ); + + assert.equal(await deleteFinalizedInteractiveSession(env, row, logArchive), true); + assert.equal(batch.length, 4); + assert.match(batch[0]?.sql ?? "", /update "interactive_sessions"/i); + assert.match(batch[0]?.sql ?? "", /interactive_session_credential_policies/i); + assert.match(batch[0]?.sql ?? "", /WITH RECURSIVE active_ancestor\(id\)/i); + assert.match(batch[0]?.sql ?? "", /event_count =/i); + assert.match(batch[0]?.sql ?? "", /events_key IS/i); + assert.match(batch[0]?.sql ?? "", /count\(\*\)/i); + assert.match(batch[1]?.sql ?? "", /delete from "interactive_session_events"/i); + assert.match(batch[2]?.sql ?? "", /delete from "interactive_session_log_archives"/i); + assert.match(batch[3]?.sql ?? "", /delete from "interactive_sessions"/i); + const claimToken = batch[0]?.parameters.find( + (parameter): parameter is string => + typeof parameter === "string" && parameter.startsWith("cleanup:"), + ); + assert.ok(claimToken); + assert.ok(batch[1]?.parameters.includes(claimToken)); + assert.ok(batch[2]?.parameters.includes(claimToken)); + assert.ok(batch[3]?.parameters.includes(claimToken)); + assert.ok(batch[3]?.parameters.includes(2)); +}); From 162aaa6581dadb36da1293b889ba584b22dd88ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:08:05 +0100 Subject: [PATCH 040/109] refactor: extract session reconciliation --- CHANGELOG.md | 1 + src/index.ts | 246 +++--------------- src/worker/session-reconciliation.ts | 328 +++++++++++++++++++++++ tests/runtime-adapter.test.ts | 26 +- tests/session-reconciliation.test.ts | 373 +++++++++++++++++++++++++++ 5 files changed, 742 insertions(+), 232 deletions(-) create mode 100644 src/worker/session-reconciliation.ts create mode 100644 tests/session-reconciliation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index effd995..fa37477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Move terminal completion evidence, archive freshness checks, and finalization-marker persistence into one lifecycle module. - Unify session event batching, message bounds, finalization invalidation, and best-effort archive refresh behind one service. - Extract finalized-session admission, fenced transactional deletion, authorization filtering, and archive-object cleanup behind one service. +- Extract runtime-adapter reconciliation claims, transition projection, atomic evidence persistence, race recovery, and terminal finalization behind one service. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 2b68c31..6124671 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,7 +68,6 @@ import { import { adapterFailureReleaseState, adapterWorkspaceIdMatches, - clearedAdapterCapabilities, createOnlyAdapterStatus, definitiveRuntimeAdapterCreateFailure, effectiveAdapterCapabilities, @@ -292,6 +291,13 @@ import { presentInteractiveSession } from "./worker/session-presentation"; import { archiveInteractiveSessionLogs, sessionLogTranscript } from "./worker/session-log-archive"; import { appendInteractiveSessionEventRecord } from "./worker/session-events"; import { createInteractiveSessionCleanupService } from "./worker/session-cleanup"; +import { + claimInteractiveSessionReconciliation, + InteractiveSessionReconciliationService, + persistInteractiveSessionReconciliation, + recordInteractiveSessionReconciliationFailure, + type InteractiveSessionReconciliationStore, +} from "./worker/session-reconciliation"; import { finalizeTerminalInteractiveSession } from "./worker/session-terminal-finalization"; import { InteractiveSessionMetadataService, @@ -3339,219 +3345,45 @@ async function reconcileExternalInteractiveSession( row: InteractiveSessionRow, now: number, ): Promise { - const terminalFinalizationStatus: "stopped" | "expired" | "failed" | null = - row.terminal_finalize_pending === 1 && - (row.status === "stopped" || row.status === "expired" || row.status === "failed") - ? row.status - : null; - if ( - !terminalFinalizationStatus && - (row.adapter !== runtimeAdapterName || !row.adapter_workspace_id) - ) { - return; - } - const claimAt = Math.max(now, Date.now(), (row.last_reconciled_at ?? 0) + 1); - let claim = database(env) - .updateTable("interactive_sessions") - .set({ last_reconciled_at: claimAt }) - .where("id", "=", row.id) - .where("status", "=", row.status) - .where("updated_at", "=", row.updated_at); - claim = row.last_reconciled_at - ? claim.where("last_reconciled_at", "=", row.last_reconciled_at) - : claim.where("last_reconciled_at", "is", null); - const claimed = await claim.executeTakeFirst(); - if ((claimed.numUpdatedRows ?? 0n) === 0n) return; + await interactiveSessionReconciliationService(env).reconcile(row, now); +} - try { - if (terminalFinalizationStatus) { - await finalizeTerminalInteractiveSession( +function interactiveSessionReconciliationService( + env: RuntimeEnv, +): InteractiveSessionReconciliationService { + const store: InteractiveSessionReconciliationStore = { + now: Date.now, + claim: (row, claimAt) => claimInteractiveSessionReconciliation(env, row, claimAt), + inspect: (row, claimAt) => inspectRuntimeAdapterWorkspace(env, row, claimAt), + persist: (row, inspection, transition, claimAt) => + persistInteractiveSessionReconciliation( env, - row.id, - terminalFinalizationStatus, - row.stopped_at ?? now, - ); - return; - } - if (row.adapter !== runtimeAdapterName || !row.adapter_workspace_id) return; - const inspected = await inspectRuntimeAdapterWorkspace(env, row, claimAt); - const completedAt = Math.max(Date.now(), claimAt); - const completionVersion = Math.max(completedAt, row.updated_at + 1); - const requestedTerminalStatus = - inspected.terminalStatus === undefined ? row.terminal_status : inspected.terminalStatus; - const status = reconciledInteractiveStatus( - row.status, - inspected.status, - requestedTerminalStatus, - ); - const inactive = ["stopping", "stopped", "expired", "failed"].includes(status); - const terminalStatus = ["stopped", "expired", "failed"].includes(status) - ? null - : requestedTerminalStatus; - const terminal = inactive - ? null - : inspected.attachUrlPresent - ? inspected.attachUrl - : row.attach_url; - const capabilities = inspected.capabilities - ? JSON.stringify(inspected.capabilities) - : inspected.capabilitiesPresent - ? JSON.stringify(clearedAdapterCapabilities) - : row.capabilities_json; - const expiresAt = inspected.expiresAtPresent ? (inspected.expiresAt ?? null) : row.expires_at; - const createPending = - inspected.createPending === undefined - ? row.adapter_create_pending - : inspected.createPending - ? 1 - : 0; - const stateChanged = - status !== row.status || - terminal !== row.attach_url || - capabilities !== row.capabilities_json || - (inspected.providerResourceId ?? row.provider_resource_id) !== row.provider_resource_id || - expiresAt !== row.expires_at || - terminalStatus !== row.terminal_status || - createPending !== row.adapter_create_pending || - (inspected.reconcileError ?? null) !== row.reconcile_error; - const messageChanged = inspected.message !== row.last_event; - const expectedOwner = sql` - id = ${row.id} - AND adapter = ${runtimeAdapterName} - AND status = ${row.status} - AND updated_at = ${row.updated_at} - AND last_reconciled_at = ${claimAt} - `; - const db = database(env); - const update = db - .updateTable("interactive_sessions") - .set({ - status, - lease_id: null, - provider_resource_id: inspected.providerResourceId ?? row.provider_resource_id, - attach_url: terminal, - // Connection-bearing desktop URLs are never persisted. - vnc_url: null, - capabilities_json: capabilities, - expires_at: expiresAt, - last_reconciled_at: completedAt, - reconcile_error: inspected.reconcileError ?? null, - terminal_status: terminalStatus, - adapter_create_pending: createPending, - terminal_finalize_pending: ["stopped", "expired", "failed"].includes(status) - ? 1 - : row.terminal_finalize_pending, - ...(inactive - ? { - agent_token_hash: null, - controller: null, - control_requested_by: null, - control_requested_at: null, - control_granted_at: null, - control_expires_at: null, - } - : {}), - stopped_at: ["stopped", "expired", "failed"].includes(status) - ? (row.stopped_at ?? completedAt) - : row.stopped_at, - ...(stateChanged || messageChanged - ? { updated_at: completionVersion, last_event: inspected.message } - : {}), - }) - .where(expectedOwner) - .returning("updated_at"); - const queries: CompilableQuery[] = []; - if (stateChanged || messageChanged) { - queries.push(sql` - INSERT INTO interactive_session_events (session_id, actor, message, created_at) - SELECT ${row.id}, 'system', ${clean(inspected.message, 1000)}, ${completedAt} - FROM interactive_sessions - WHERE ${expectedOwner} - `); - } - queries.push(update); - const results = await env.DB.batch<{ updated_at: number }>( - queries.map((query) => { - const compiled = query.compile(db); - return env.DB.prepare(compiled.sql).bind(...compiled.parameters); - }), - ); - if (!results.at(-1)?.results.length) { - const current = await readInteractiveSession(env, row.id); - if (current && ["stopped", "expired", "failed"].includes(current.status)) { - await finalizeTerminalInteractiveSession( - env, - current.id, - current.status as "stopped" | "expired" | "failed", - current.stoppedAt ?? now, - ).catch(() => undefined); - return; - } - const currentAdapterProvision = Boolean( - current && - current.adapter === runtimeAdapterName && - current.adapterWorkspaceId === inspected.adapterWorkspaceId && - ["provisioning", "pending_adapter", "ready", "attached", "detached"].includes( - current.status, - ), - ); - if (!currentAdapterProvision && inspected.adapterWorkspaceId) { - await stopSupersededRuntimeAdapterProvision( - env, - row.id, - inspected.adapterWorkspaceId, - inspected.createPending === true, - Date.now(), - ); - } - return; - } - if (stateChanged || messageChanged) { - await archiveInteractiveSessionLogs(env, row.id, completedAt).catch(() => undefined); - } - if ( - status !== row.status && - (status === "stopped" || status === "expired" || status === "failed") - ) { - await finalizeTerminalInteractiveSession( + row, + inspection, + transition, + claimAt, + runtimeAdapterName, + ), + readSession: (sessionId) => readInteractiveSession(env, sessionId), + stopSuperseded: (sessionId, adapterWorkspaceId, createPending, now) => + stopSupersededRuntimeAdapterProvision(env, sessionId, adapterWorkspaceId, createPending, now), + archive: (sessionId, now) => archiveInteractiveSessionLogs(env, sessionId, now), + finalize: (sessionId, status, now) => + finalizeTerminalInteractiveSession(env, sessionId, status, now), + recordFailure: (row, claimAt, failedAt, error) => + recordInteractiveSessionReconciliationFailure( env, - row.id, - status, - row.stopped_at ?? completedAt, - ).catch(() => undefined); - } - } catch (error) { - const failedAt = Math.max(Date.now(), claimAt); - await database(env) - .updateTable("interactive_sessions") - .set({ - last_reconciled_at: failedAt, - reconcile_error: safeProviderError( + row, + claimAt, + failedAt, + safeProviderError( error, [row.adapter_workspace_id, row.provider_resource_id], [row.attach_url], ), - updated_at: Math.max(failedAt, row.updated_at + 1), - }) - .where("id", "=", row.id) - .where("status", "=", row.status) - .where("updated_at", "=", row.updated_at) - .where("last_reconciled_at", "=", claimAt) - .execute(); - } -} - -function reconciledInteractiveStatus( - current: InteractiveSessionStatus, - next: InteractiveSessionStatus, - terminalStatus: "failed" | null, -): InteractiveSessionStatus { - if (current === "stopping") { - if (["stopped", "expired", "failed"].includes(next)) return terminalStatus ?? next; - return "stopping"; - } - if ((current === "attached" || current === "detached") && next === "ready") return current; - return next; + ), + }; + return new InteractiveSessionReconciliationService(store, runtimeAdapterName); } async function mapWithConcurrency( diff --git a/src/worker/session-reconciliation.ts b/src/worker/session-reconciliation.ts new file mode 100644 index 0000000..645f647 --- /dev/null +++ b/src/worker/session-reconciliation.ts @@ -0,0 +1,328 @@ +import { sql } from "kysely"; + +import { clearedAdapterCapabilities } from "../runtime-adapter.ts"; +import { database, type CompilableQuery, type InteractiveSessionRow } from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; +import type { InteractiveSessionStatus } from "./models.ts"; +import type { InteractiveProvisionResult } from "./session-provisioning.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export type RuntimeAdapterReconciliationTransition = { + status: InteractiveSessionStatus; + providerResourceId: string | null; + attachUrl: string | null; + capabilitiesJson: string; + expiresAt: number | null; + reconcileError: string | null; + terminalStatus: "failed" | null; + createPending: number; + inactive: boolean; + stoppedAt: number | null; + evidenceChanged: boolean; + completedAt: number; + completionVersion: number; +}; + +export type InteractiveSessionReconciliationStore = { + now(): number; + claim(row: InteractiveSessionRow, claimAt: number): Promise; + inspect(row: InteractiveSessionRow, claimAt: number): Promise; + persist( + row: InteractiveSessionRow, + inspection: InteractiveProvisionResult, + transition: RuntimeAdapterReconciliationTransition, + claimAt: number, + ): Promise; + readSession(sessionId: string): Promise; + stopSuperseded( + sessionId: string, + adapterWorkspaceId: string, + createPending: boolean, + now: number, + ): Promise; + archive(sessionId: string, now: number): Promise; + finalize(sessionId: string, status: "stopped" | "expired" | "failed", now: number): Promise; + recordFailure( + row: InteractiveSessionRow, + claimAt: number, + failedAt: number, + error: unknown, + ): Promise; +}; + +export class InteractiveSessionReconciliationService { + private readonly store: InteractiveSessionReconciliationStore; + private readonly adapterName: string; + + constructor(store: InteractiveSessionReconciliationStore, adapterName: string) { + this.store = store; + this.adapterName = adapterName; + } + + async reconcile(row: InteractiveSessionRow, now: number): Promise { + const terminalStatus = terminalFinalizationStatus(row); + if (!terminalStatus && (row.adapter !== this.adapterName || !row.adapter_workspace_id)) return; + + const claimAt = Math.max(now, this.store.now(), (row.last_reconciled_at ?? 0) + 1); + if (!(await this.store.claim(row, claimAt))) return; + + try { + if (terminalStatus) { + await this.store.finalize(row.id, terminalStatus, row.stopped_at ?? now); + return; + } + if (row.adapter !== this.adapterName || !row.adapter_workspace_id) return; + + const inspection = await this.store.inspect(row, claimAt); + const transition = runtimeAdapterReconciliationTransition( + row, + inspection, + Math.max(this.store.now(), claimAt), + ); + if (!(await this.store.persist(row, inspection, transition, claimAt))) { + await this.recoverLostOwnership(row, inspection, now); + return; + } + if (transition.evidenceChanged) { + await this.store.archive(row.id, transition.completedAt).catch(() => undefined); + } + if (row.status !== transition.status && isTerminalStatus(transition.status)) { + await this.store + .finalize(row.id, transition.status, row.stopped_at ?? transition.completedAt) + .catch(() => undefined); + } + } catch (error) { + await this.store.recordFailure(row, claimAt, Math.max(this.store.now(), claimAt), error); + } + } + + private async recoverLostOwnership( + row: InteractiveSessionRow, + inspection: InteractiveProvisionResult, + now: number, + ): Promise { + const current = await this.store.readSession(row.id); + if (current && isTerminalStatus(current.status)) { + await this.store + .finalize(current.id, current.status, current.stoppedAt ?? now) + .catch(() => undefined); + return; + } + if ( + current && + current.adapter === this.adapterName && + current.adapterWorkspaceId === inspection.adapterWorkspaceId && + ["provisioning", "pending_adapter", "ready", "attached", "detached"].includes(current.status) + ) { + return; + } + if (inspection.adapterWorkspaceId) { + await this.store.stopSuperseded( + row.id, + inspection.adapterWorkspaceId, + inspection.createPending === true, + this.store.now(), + ); + } + } +} + +export async function claimInteractiveSessionReconciliation( + env: RuntimeEnv, + row: InteractiveSessionRow, + claimAt: number, +): Promise { + let claim = database(env) + .updateTable("interactive_sessions") + .set({ last_reconciled_at: claimAt }) + .where("id", "=", row.id) + .where("status", "=", row.status) + .where("updated_at", "=", row.updated_at); + claim = row.last_reconciled_at + ? claim.where("last_reconciled_at", "=", row.last_reconciled_at) + : claim.where("last_reconciled_at", "is", null); + const claimed = await claim.executeTakeFirst(); + return (claimed.numUpdatedRows ?? 0n) > 0n; +} + +export async function persistInteractiveSessionReconciliation( + env: RuntimeEnv, + row: InteractiveSessionRow, + inspection: InteractiveProvisionResult, + transition: RuntimeAdapterReconciliationTransition, + claimAt: number, + adapterName: string, +): Promise { + const expectedOwner = sql` + id = ${row.id} + AND adapter = ${adapterName} + AND status = ${row.status} + AND updated_at = ${row.updated_at} + AND last_reconciled_at = ${claimAt} + `; + const db = database(env); + const update = db + .updateTable("interactive_sessions") + .set({ + status: transition.status, + lease_id: null, + provider_resource_id: transition.providerResourceId, + attach_url: transition.attachUrl, + // Connection-bearing desktop URLs are never persisted. + vnc_url: null, + capabilities_json: transition.capabilitiesJson, + expires_at: transition.expiresAt, + last_reconciled_at: transition.completedAt, + reconcile_error: transition.reconcileError, + terminal_status: transition.terminalStatus, + adapter_create_pending: transition.createPending, + terminal_finalize_pending: isTerminalStatus(transition.status) + ? 1 + : row.terminal_finalize_pending, + ...(transition.inactive + ? { + agent_token_hash: null, + controller: null, + control_requested_by: null, + control_requested_at: null, + control_granted_at: null, + control_expires_at: null, + } + : {}), + stopped_at: transition.stoppedAt, + ...(transition.evidenceChanged + ? { updated_at: transition.completionVersion, last_event: inspection.message } + : {}), + }) + .where(expectedOwner) + .returning("updated_at"); + const queries: CompilableQuery[] = []; + if (transition.evidenceChanged) { + queries.push(sql` + INSERT INTO interactive_session_events (session_id, actor, message, created_at) + SELECT ${row.id}, 'system', ${clean(inspection.message, 1000)}, ${transition.completedAt} + FROM interactive_sessions + WHERE ${expectedOwner} + `); + } + queries.push(update); + const results = await env.DB.batch<{ updated_at: number }>( + queries.map((query) => { + const compiled = query.compile(db); + return env.DB.prepare(compiled.sql).bind(...compiled.parameters); + }), + ); + return Boolean(results.at(-1)?.results.length); +} + +export async function recordInteractiveSessionReconciliationFailure( + env: RuntimeEnv, + row: InteractiveSessionRow, + claimAt: number, + failedAt: number, + reconcileError: string, +): Promise { + await database(env) + .updateTable("interactive_sessions") + .set({ + last_reconciled_at: failedAt, + reconcile_error: reconcileError, + updated_at: Math.max(failedAt, row.updated_at + 1), + }) + .where("id", "=", row.id) + .where("status", "=", row.status) + .where("updated_at", "=", row.updated_at) + .where("last_reconciled_at", "=", claimAt) + .execute(); +} + +export function runtimeAdapterReconciliationTransition( + row: InteractiveSessionRow, + inspection: InteractiveProvisionResult, + completedAt: number, +): RuntimeAdapterReconciliationTransition { + const requestedTerminalStatus = + inspection.terminalStatus === undefined ? row.terminal_status : inspection.terminalStatus; + const status = reconciledInteractiveStatus( + row.status, + inspection.status, + requestedTerminalStatus, + ); + const inactive = ["stopping", "stopped", "expired", "failed"].includes(status); + const terminalStatus = isTerminalStatus(status) ? null : requestedTerminalStatus; + const attachUrl = inactive + ? null + : inspection.attachUrlPresent + ? inspection.attachUrl + : row.attach_url; + const capabilitiesJson = inspection.capabilities + ? JSON.stringify(inspection.capabilities) + : inspection.capabilitiesPresent + ? JSON.stringify(clearedAdapterCapabilities) + : row.capabilities_json; + const expiresAt = inspection.expiresAtPresent ? (inspection.expiresAt ?? null) : row.expires_at; + const createPending = + inspection.createPending === undefined + ? row.adapter_create_pending + : inspection.createPending + ? 1 + : 0; + const providerResourceId = inspection.providerResourceId ?? row.provider_resource_id; + const reconcileError = inspection.reconcileError ?? null; + const evidenceChanged = + status !== row.status || + attachUrl !== row.attach_url || + capabilitiesJson !== row.capabilities_json || + providerResourceId !== row.provider_resource_id || + expiresAt !== row.expires_at || + terminalStatus !== row.terminal_status || + createPending !== row.adapter_create_pending || + reconcileError !== row.reconcile_error || + inspection.message !== row.last_event; + return { + status, + providerResourceId, + attachUrl, + capabilitiesJson, + expiresAt, + reconcileError, + terminalStatus, + createPending, + inactive, + stoppedAt: isTerminalStatus(status) ? (row.stopped_at ?? completedAt) : row.stopped_at, + evidenceChanged, + completedAt, + completionVersion: Math.max(completedAt, row.updated_at + 1), + }; +} + +export function reconciledInteractiveStatus( + current: InteractiveSessionStatus, + next: InteractiveSessionStatus, + terminalStatus: "failed" | null, +): InteractiveSessionStatus { + if (current === "stopping") { + if (isTerminalStatus(next)) return terminalStatus ?? next; + return "stopping"; + } + if ((current === "attached" || current === "detached") && next === "ready") return current; + return next; +} + +function terminalFinalizationStatus( + row: Pick, +): "stopped" | "expired" | "failed" | null { + return row.terminal_finalize_pending === 1 && isTerminalStatus(row.status) ? row.status : null; +} + +function isTerminalStatus( + status: InteractiveSessionStatus, +): status is "stopped" | "expired" | "failed" { + return status === "stopped" || status === "expired" || status === "failed"; +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 16236e9..034c42e 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -508,9 +508,6 @@ test("runtime adapter lifecycle cannot escape durable session ownership", async const stopStart = source.indexOf("async function stopSupersededRuntimeAdapterProvision"); const stopEnd = source.indexOf("async function resolveInteractiveSessionLineage", stopStart); const stopSource = source.slice(stopStart, stopEnd); - const reconcileStart = source.indexOf("async function reconcileExternalInteractiveSession("); - const reconcileEnd = source.indexOf("function reconciledInteractiveStatus", reconcileStart); - const reconcileSource = source.slice(reconcileStart, reconcileEnd); const releaseStart = source.indexOf("async function releaseFailedRuntimeAdapterProvision"); const releaseEnd = source.indexOf("function runtimeAdapterProvisionResult", releaseStart); const releaseSource = source.slice(releaseStart, releaseEnd); @@ -542,8 +539,6 @@ test("runtime adapter lifecycle cannot escape durable session ownership", async assert.match(releaseSource, /pendingMessage/); assert.match(releaseSource, /terminal_status: "failed"/); assert.match(releaseSource, /adapter_create_pending: 0/); - assert.match(reconcileSource, /current\.stoppedAt \?\? now/); - assert.match(reconcileSource, /finalizeTerminalInteractiveSession/); assert.match(source, /AND NOT EXISTS \(/); assert.match( finalizationSource, @@ -670,9 +665,6 @@ test("runtime reconciliation has scheduled and targeted lifecycle clocks", async const batchStart = source.indexOf("async function reconcileExternalInteractiveSessionBatch"); const batchEnd = source.indexOf("async function reconcileExternalInteractiveSessionById"); const batchSource = source.slice(batchStart, batchEnd); - const reconcileStart = source.indexOf("async function reconcileExternalInteractiveSession("); - const reconcileEnd = source.indexOf("function reconciledInteractiveStatus", reconcileStart); - const reconcileSource = source.slice(reconcileStart, reconcileEnd); assert.match(source, /async scheduled\(/); assert.match(source, /context\.waitUntil\(\s*reconcileInteractiveSessionLifecycleBatch/); @@ -684,10 +676,6 @@ test("runtime reconciliation has scheduled and targeted lifecycle clocks", async assert.match(targetedSource, /row\.adapter !== runtimeAdapterName/); assert.match(targetedSource, /runtimeAdapterReconcileIntervalMs/); assert.match(targetedSource, /reconcileExternalInteractiveSession\(env, row, now\)/); - assert.ok( - reconcileSource.indexOf("if (terminalFinalizationStatus)") < - reconcileSource.indexOf("inspectRuntimeAdapterWorkspace"), - ); assert.match(source, /async function readFreshInteractiveSession/); assert.match( source, @@ -699,19 +687,7 @@ test("runtime reconciliation has scheduled and targeted lifecycle clocks", async source, /async function reconcileInteractiveSessionLifecycleBatch[\s\S]*reconcileCredentialPolicyCleanupBatch[\s\S]*reconcileExternalInteractiveSessionBatch/, ); - assert.match(reconcileSource, /const claimAt = Math\.max/); - assert.match(reconcileSource, /where\("updated_at", "=", row\.updated_at\)/); - assert.match(reconcileSource, /const completedAt = Math\.max\(Date\.now\(\), claimAt\)/); - assert.match( - reconcileSource, - /const completionVersion = Math\.max\(completedAt, row\.updated_at \+ 1\)/, - ); - assert.match(reconcileSource, /last_reconciled_at: completedAt/); - assert.match(reconcileSource, /updated_at: completionVersion/); - assert.match(reconcileSource, /INSERT INTO interactive_session_events/); - assert.match(reconcileSource, /env\.DB\.batch/); - assert.match(reconcileSource, /reconcile_error: safeProviderError/); - assert.doesNotMatch(reconcileSource, /updated_at: now/); + assert.match(source, /interactiveSessionReconciliationService\(env\)\.reconcile\(row, now\)/); }); test("recurring terminal authorization never awaits provider reconciliation", async () => { diff --git a/tests/session-reconciliation.test.ts b/tests/session-reconciliation.test.ts new file mode 100644 index 0000000..24f56ca --- /dev/null +++ b/tests/session-reconciliation.test.ts @@ -0,0 +1,373 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { clearedAdapterCapabilities } from "../src/runtime-adapter.ts"; +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { containerCapabilities } from "../src/worker/session-model.ts"; +import { + claimInteractiveSessionReconciliation, + InteractiveSessionReconciliationService, + persistInteractiveSessionReconciliation, + reconciledInteractiveStatus, + recordInteractiveSessionReconciliationFailure, + runtimeAdapterReconciliationTransition, + type InteractiveSessionReconciliationStore, +} from "../src/worker/session-reconciliation.ts"; +import type { InteractiveProvisionResult } from "../src/worker/session-provisioning.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +type D1Result = { results?: unknown[]; changes?: number }; +type PreparedStatement = { + sql: string; + parameters: unknown[]; + all(): Promise; + run(): Promise; +}; + +function runtimeEnv( + handler: (sql: string, parameters: unknown[], kind: "all" | "run") => D1Result, + batchHandler: (statements: PreparedStatement[]) => D1Result[] = () => [], +): RuntimeEnv { + return { + DB: { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + return { + sql, + parameters, + async all() { + const result = handler(sql, parameters, "all"); + return { results: result.results ?? [], meta: { changes: result.changes ?? 0 } }; + }, + async run() { + const result = handler(sql, parameters, "run"); + return { meta: { changes: result.changes ?? 0 } }; + }, + }; + }, + }; + }, + async batch(statements: unknown[]) { + return batchHandler(statements as PreparedStatement[]); + }, + } as unknown as D1Database, + } as RuntimeEnv; +} + +function inspection(values: Partial = {}): InteractiveProvisionResult { + return { + status: "ready", + leaseId: null, + attachUrl: null, + vncUrl: null, + message: "runtime adapter workspace ready", + adapter: "runtime-adapter", + profile: "default", + adapterWorkspaceId: "workspace-1", + reconciledAt: 200, + reconcileError: null, + ...values, + }; +} + +function reconciliationStore( + overrides: Partial = {}, +): InteractiveSessionReconciliationStore { + return { + now: () => 200, + async claim() { + return true; + }, + async inspect() { + return inspection(); + }, + async persist() { + return true; + }, + async readSession() { + return null; + }, + async stopSuperseded() {}, + async archive() {}, + async finalize() {}, + async recordFailure() {}, + ...overrides, + }; +} + +test("reconciliation status policy preserves stopping and attached lifecycle ownership", () => { + assert.equal(reconciledInteractiveStatus("stopping", "ready", null), "stopping"); + assert.equal(reconciledInteractiveStatus("stopping", "stopped", "failed"), "failed"); + assert.equal(reconciledInteractiveStatus("attached", "ready", null), "attached"); + assert.equal(reconciledInteractiveStatus("detached", "ready", null), "detached"); + assert.equal(reconciledInteractiveStatus("provisioning", "ready", null), "ready"); +}); + +test("reconciliation transition preserves omitted fields and clears inactive authority", () => { + const row = sessionRow({ + status: "ready", + adapter: "runtime-adapter", + adapter_workspace_id: "workspace-1", + provider_resource_id: "provider-1", + attach_url: "wss://terminal.example.test", + capabilities_json: JSON.stringify(containerCapabilities), + expires_at: 500, + reconcile_error: null, + adapter_create_pending: 0, + last_event: "runtime adapter workspace ready", + updated_at: 100, + }); + const unchanged = runtimeAdapterReconciliationTransition(row, inspection(), 200); + assert.equal(unchanged.attachUrl, row.attach_url); + assert.equal(unchanged.capabilitiesJson, row.capabilities_json); + assert.equal(unchanged.expiresAt, 500); + assert.equal(unchanged.providerResourceId, "provider-1"); + assert.equal(unchanged.evidenceChanged, false); + + const terminal = runtimeAdapterReconciliationTransition( + row, + inspection({ + status: "expired", + attachUrlPresent: true, + capabilitiesPresent: true, + expiresAtPresent: true, + createPending: false, + message: "runtime adapter workspace is gone", + }), + 220, + ); + assert.equal(terminal.status, "expired"); + assert.equal(terminal.attachUrl, null); + assert.equal(terminal.capabilitiesJson, JSON.stringify(clearedAdapterCapabilities)); + assert.equal(terminal.expiresAt, null); + assert.equal(terminal.terminalStatus, null); + assert.equal(terminal.inactive, true); + assert.equal(terminal.stoppedAt, 220); + assert.equal(terminal.completionVersion, 220); + assert.equal(terminal.evidenceChanged, true); +}); + +test("reconciliation persistence claims exact revisions and atomically records changed evidence", async () => { + const row = sessionRow({ + status: "ready", + adapter: "runtime-adapter", + adapter_workspace_id: "workspace-1", + last_reconciled_at: 150, + updated_at: 100, + }); + const claimEnv = runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "run"); + assert.match(sql, /update "interactive_sessions"/i); + assert.match(sql, /"last_reconciled_at" =/i); + assert.deepEqual(parameters, [200, row.id, "ready", 100, 150]); + return { changes: 1 }; + }); + assert.equal(await claimInteractiveSessionReconciliation(claimEnv, row, 200), true); + + const inspected = inspection({ + status: "expired", + message: ` ${"x".repeat(1100)} `, + attachUrlPresent: true, + capabilitiesPresent: true, + expiresAtPresent: true, + }); + const transition = runtimeAdapterReconciliationTransition(row, inspected, 220); + let batch: PreparedStatement[] = []; + const persistEnv = runtimeEnv( + () => { + throw new Error("batch statements should not execute individually"); + }, + (statements) => { + batch = statements; + return [{ results: [] }, { results: [{ updated_at: 220 }] }]; + }, + ); + assert.equal( + await persistInteractiveSessionReconciliation( + persistEnv, + row, + inspected, + transition, + 200, + "runtime-adapter", + ), + true, + ); + assert.equal(batch.length, 2); + assert.match(batch[0]?.sql ?? "", /insert into interactive_session_events/i); + assert.match(batch[1]?.sql ?? "", /update "interactive_sessions"/i); + assert.ok(batch[0]?.parameters.includes("x".repeat(1000))); + assert.ok(batch[1]?.parameters.includes(1)); + assert.ok(batch[1]?.parameters.includes(220)); + assert.ok(batch[1]?.parameters.includes("runtime-adapter")); +}); + +test("reconciliation failure persistence retains status, revision, and claim fences", async () => { + const row = sessionRow({ status: "attached", updated_at: 100 }); + const env = runtimeEnv((sql, parameters, kind) => { + assert.equal(kind, "run"); + assert.match(sql, /update "interactive_sessions"/i); + assert.match(sql, /"reconcile_error" =/i); + assert.deepEqual(parameters, [220, "provider unavailable", 220, row.id, "attached", 100, 200]); + return { changes: 1 }; + }); + + await recordInteractiveSessionReconciliationFailure(env, row, 200, 220, "provider unavailable"); +}); + +test("reconciliation claims before inspection, persists evidence, archives, and finalizes", async () => { + const row = sessionRow({ + status: "ready", + adapter: "runtime-adapter", + adapter_workspace_id: "workspace-1", + updated_at: 100, + }); + const order: string[] = []; + const service = new InteractiveSessionReconciliationService( + reconciliationStore({ + async claim(_row, claimAt) { + order.push(`claim:${claimAt}`); + return true; + }, + async inspect(_row, claimAt) { + order.push(`inspect:${claimAt}`); + return inspection({ status: "expired", message: "workspace expired" }); + }, + async persist(_row, _inspection, transition, claimAt) { + order.push(`persist:${claimAt}:${transition.status}`); + return true; + }, + async archive(_sessionId, now) { + order.push(`archive:${now}`); + }, + async finalize(_sessionId, status, now) { + order.push(`finalize:${status}:${now}`); + }, + }), + "runtime-adapter", + ); + + await service.reconcile(row, 150); + assert.deepEqual(order, [ + "claim:200", + "inspect:200", + "persist:200:expired", + "archive:200", + "finalize:expired:200", + ]); +}); + +test("terminal reconciliation finalizes before provider inspection", async () => { + const row = sessionRow({ + status: "failed", + terminal_finalize_pending: 1, + stopped_at: 175, + adapter: "runtime-adapter", + adapter_workspace_id: "workspace-1", + }); + const order: string[] = []; + const service = new InteractiveSessionReconciliationService( + reconciliationStore({ + async claim() { + order.push("claim"); + return true; + }, + async inspect() { + order.push("inspect"); + return inspection(); + }, + async finalize(_sessionId, status, now) { + order.push(`finalize:${status}:${now}`); + }, + }), + "runtime-adapter", + ); + + await service.reconcile(row, 150); + assert.deepEqual(order, ["claim", "finalize:failed:175"]); +}); + +test("lost reconciliation ownership finalizes terminal rereads or releases superseded workspaces", async () => { + const row = sessionRow({ + status: "ready", + adapter: "runtime-adapter", + adapter_workspace_id: "workspace-1", + }); + const finalized: string[] = []; + const terminalService = new InteractiveSessionReconciliationService( + reconciliationStore({ + async persist() { + return false; + }, + async readSession() { + return interactiveSession( + sessionRow({ id: row.id, status: "stopped", stopped_at: 180 }), + [], + ); + }, + async finalize(_sessionId, status, now) { + finalized.push(`${status}:${now}`); + }, + }), + "runtime-adapter", + ); + await terminalService.reconcile(row, 150); + assert.deepEqual(finalized, ["stopped:180"]); + + const released: string[] = []; + const supersededService = new InteractiveSessionReconciliationService( + reconciliationStore({ + async persist() { + return false; + }, + async readSession() { + return interactiveSession( + sessionRow({ + id: row.id, + status: "ready", + adapter: "runtime-adapter", + adapter_workspace_id: "workspace-2", + }), + [], + ); + }, + async stopSuperseded(sessionId, workspaceId, createPending, now) { + released.push(`${sessionId}:${workspaceId}:${createPending}:${now}`); + }, + }), + "runtime-adapter", + ); + await supersededService.reconcile(row, 150); + assert.deepEqual(released, [`${row.id}:workspace-1:false:200`]); +}); + +test("reconciliation failures retain the claimed lifecycle fence", async () => { + const row = sessionRow({ + status: "ready", + adapter: "runtime-adapter", + adapter_workspace_id: "workspace-1", + last_reconciled_at: 190, + }); + const failures: Array<{ claimAt: number; failedAt: number; message: string }> = []; + const service = new InteractiveSessionReconciliationService( + reconciliationStore({ + now: () => 200, + async inspect() { + throw new Error("provider unavailable"); + }, + async recordFailure(_row, claimAt, failedAt, error) { + failures.push({ + claimAt, + failedAt, + message: error instanceof Error ? error.message : String(error), + }); + }, + }), + "runtime-adapter", + ); + + await service.reconcile(row, 150); + assert.deepEqual(failures, [{ claimAt: 200, failedAt: 200, message: "provider unavailable" }]); +}); From cc82117ce7aa008ff29648ffc58fad4d99b1815c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:13:42 +0100 Subject: [PATCH 041/109] refactor: extract reconciliation scheduler --- CHANGELOG.md | 1 + src/index.ts | 209 ++++--------- .../session-reconciliation-scheduler.ts | 240 +++++++++++++++ tests/runtime-adapter.test.ts | 66 +--- .../session-reconciliation-scheduler.test.ts | 281 ++++++++++++++++++ 5 files changed, 584 insertions(+), 213 deletions(-) create mode 100644 src/worker/session-reconciliation-scheduler.ts create mode 100644 tests/session-reconciliation-scheduler.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa37477..4911c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Unify session event batching, message bounds, finalization invalidation, and best-effort archive refresh behind one service. - Extract finalized-session admission, fenced transactional deletion, authorization filtering, and archive-object cleanup behind one service. - Extract runtime-adapter reconciliation claims, transition projection, atomic evidence persistence, race recovery, and terminal finalization behind one service. +- Extract scheduled and targeted reconciliation admission, legacy-stop recovery, cadence limits, and terminal archive backfill behind one scheduler. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 6124671..9fa3d56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -298,6 +298,14 @@ import { recordInteractiveSessionReconciliationFailure, type InteractiveSessionReconciliationStore, } from "./worker/session-reconciliation"; +import { + InteractiveSessionReconciliationScheduler, + readInteractiveSessionReconciliationCandidates, + readInteractiveSessionReconciliationRow, + readLegacyStoppingInteractiveSessionCandidates, + requeueTerminalArchiveObjectBackfill, + type InteractiveSessionReconciliationSchedulerStore, +} from "./worker/session-reconciliation-scheduler"; import { finalizeTerminalInteractiveSession } from "./worker/session-terminal-finalization"; import { InteractiveSessionMetadataService, @@ -3175,130 +3183,57 @@ async function reconcileInteractiveSessionLifecycleBatch( env: RuntimeEnv, now: number, ): Promise { - await cleanupAbandonedInteractiveSessionPreparations(env, now); - await reconcileCredentialPolicyCleanupBatch(env, now); - await reconcileLegacyStoppingInteractiveSessionBatch(env, now); - await reconcileExternalInteractiveSessionBatch(env, now); -} - -async function reconcileLegacyStoppingInteractiveSessionBatch( - env: RuntimeEnv, - now: number, - sessionId?: string, -): Promise { - let query = database(env) - .selectFrom("interactive_sessions") - .selectAll() - .where("status", "=", "stopping") - .where((expression) => - expression.or([ - expression("adapter", "is", null), - expression("adapter", "!=", runtimeAdapterName), - ]), - ) - .where("runtime", "!=", githubActionsRuntime) - .where("credential_cleanup_terminal_status", "is", null) - .where(sql`lease_id IS NULL OR lease_id NOT LIKE ${`${sandboxLeasePrefix}%`}`) - .orderBy("updated_at", "asc") - .limit(runtimeAdapterReconcileLimit); - if (sessionId) query = query.where("id", "=", sessionId); - const candidates = await query.execute(); - await mapWithConcurrency(candidates, runtimeAdapterReconcileConcurrency, async (session) => { - await completeLegacyInteractiveSessionStop( - env, - { - id: session.id, - status: session.status, - runtime: session.runtime, - adapter: session.adapter, - leaseId: session.lease_id, - updatedAt: session.updated_at, - }, - "system", - now, - ).catch((error) => { - console.error(`legacy interactive session stop recovery failed for ${session.id}`, error); - }); - }); -} - -async function requeueTerminalArchiveObjectBackfill( - env: RuntimeEnv, - sessionId?: string, -): Promise { - if (!env.SESSION_LOGS) return; - const sessionFilter = sessionId ? sql`AND session.id = ${sessionId}` : sql``; - const limit = sessionId ? 1 : runtimeAdapterReconcileLimit * 2; - await sql` - UPDATE interactive_sessions - SET terminal_finalize_pending = 1, - last_reconciled_at = NULL - WHERE id IN ( - SELECT session.id - FROM interactive_sessions AS session - JOIN interactive_session_log_archives AS archive - ON archive.session_id = session.id - WHERE session.status IN ('stopped', 'expired', 'failed') - AND session.terminal_finalize_pending = 0 - AND ( - archive.events_key IS NULL - OR archive.transcript_key IS NULL - OR archive.summary_key IS NULL - ) - ${sessionFilter} - ORDER BY session.updated_at ASC, session.id ASC - LIMIT ${limit} - ) - `.execute(database(env)); + await interactiveSessionReconciliationScheduler(env).runBatch(now); } -async function reconcileExternalInteractiveSessionBatch( +function interactiveSessionReconciliationScheduler( env: RuntimeEnv, - now: number, -): Promise { - await requeueTerminalArchiveObjectBackfill(env); - const providerConfigured = runtimeAdapterProviderConfigured(env); - const activeStatuses: InteractiveSessionStatus[] = [ - "provisioning", - "pending_adapter", - "ready", - "attached", - "detached", - "stopping", - ]; - const terminalStatuses: InteractiveSessionStatus[] = ["stopped", "expired", "failed"]; - const rows = await database(env) - .selectFrom("interactive_sessions") - .selectAll() - .where((expression) => - providerConfigured - ? expression.or([ - expression.and([ - expression("status", "in", terminalStatuses), - expression("terminal_finalize_pending", "=", 1), - ]), - expression.and([ - expression("adapter", "=", runtimeAdapterName), - expression("status", "in", activeStatuses), - ]), - ]) - : expression.and([ - expression("status", "in", terminalStatuses), - expression("terminal_finalize_pending", "=", 1), - ]), - ) - .orderBy("last_reconciled_at", "asc") - .limit(runtimeAdapterReconcileLimit * 2) - .execute(); - const due = rows - .filter( - (row) => - !row.last_reconciled_at || - now - row.last_reconciled_at >= runtimeAdapterReconcileIntervalMs, - ) - .slice(0, runtimeAdapterReconcileLimit); - await mapWithConcurrency(due, runtimeAdapterReconcileConcurrency, async (row) => { - await reconcileExternalInteractiveSession(env, row, now); +): InteractiveSessionReconciliationScheduler { + const store: InteractiveSessionReconciliationSchedulerStore = { + cleanupAbandonedPreparations: (now) => cleanupAbandonedInteractiveSessionPreparations(env, now), + cleanupCredentialPolicies: (now, sessionId) => + reconcileCredentialPolicyCleanupBatch(env, now, sessionId), + providerConfigured: () => runtimeAdapterProviderConfigured(env), + readLegacyStoppingCandidates: (sessionId) => + readLegacyStoppingInteractiveSessionCandidates(env, { + adapterName: runtimeAdapterName, + sandboxLeasePrefix, + limit: runtimeAdapterReconcileLimit, + ...(sessionId ? { sessionId } : {}), + }), + completeLegacyStop: (session, now) => + completeLegacyInteractiveSessionStop( + env, + { + id: session.id, + status: session.status, + runtime: session.runtime, + adapter: session.adapter, + leaseId: session.lease_id, + updatedAt: session.updated_at, + }, + "system", + now, + ).then(() => undefined), + requeueTerminalArchiveBackfill: (sessionId) => + requeueTerminalArchiveObjectBackfill(env, sessionId, runtimeAdapterReconcileLimit), + readBatchCandidates: (providerConfigured) => + readInteractiveSessionReconciliationCandidates( + env, + runtimeAdapterName, + providerConfigured, + runtimeAdapterReconcileLimit, + ), + readSession: (sessionId) => readInteractiveSessionReconciliationRow(env, sessionId), + reconcile: (row, now) => reconcileExternalInteractiveSession(env, row, now), + report: (message, error) => console.error(message, error), + }; + return new InteractiveSessionReconciliationScheduler(store, { + adapterName: runtimeAdapterName, + sandboxLeasePrefix, + intervalMs: runtimeAdapterReconcileIntervalMs, + limit: runtimeAdapterReconcileLimit, + concurrency: runtimeAdapterReconcileConcurrency, }); } @@ -3307,37 +3242,7 @@ async function reconcileExternalInteractiveSessionById( id: string, now = Date.now(), ): Promise { - await reconcileCredentialPolicyCleanupBatch(env, now, id); - await reconcileLegacyStoppingInteractiveSessionBatch(env, now, id); - await requeueTerminalArchiveObjectBackfill(env, id); - const row = await database(env) - .selectFrom("interactive_sessions") - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); - if (!row) return; - const terminalFinalizationPending = - row.terminal_finalize_pending === 1 && - (row.status === "stopped" || row.status === "expired" || row.status === "failed"); - const providerConfigured = runtimeAdapterProviderConfigured(env); - const active = [ - "provisioning", - "pending_adapter", - "ready", - "attached", - "detached", - "stopping", - ].includes(row.status); - if ( - !terminalFinalizationPending && - (row.adapter !== runtimeAdapterName || !providerConfigured || !active) - ) { - return; - } - if (row.last_reconciled_at && now - row.last_reconciled_at < runtimeAdapterReconcileIntervalMs) { - return; - } - await reconcileExternalInteractiveSession(env, row, now); + await interactiveSessionReconciliationScheduler(env).reconcileById(id, now); } async function reconcileExternalInteractiveSession( diff --git a/src/worker/session-reconciliation-scheduler.ts b/src/worker/session-reconciliation-scheduler.ts new file mode 100644 index 0000000..e396b54 --- /dev/null +++ b/src/worker/session-reconciliation-scheduler.ts @@ -0,0 +1,240 @@ +import { sql } from "kysely"; + +import { githubActionsRuntime } from "../github-actions-runtime.ts"; +import { database, type InteractiveSessionRow } from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; +import type { InteractiveSessionStatus } from "./models.ts"; + +const activeReconciliationStatuses: readonly InteractiveSessionStatus[] = [ + "provisioning", + "pending_adapter", + "ready", + "attached", + "detached", + "stopping", +]; +const terminalReconciliationStatuses: readonly InteractiveSessionStatus[] = [ + "stopped", + "expired", + "failed", +]; + +export type InteractiveSessionReconciliationSchedulerConfig = { + adapterName: string; + sandboxLeasePrefix: string; + intervalMs: number; + limit: number; + concurrency: number; +}; + +export type InteractiveSessionReconciliationSchedulerStore = { + cleanupAbandonedPreparations(now: number): Promise; + cleanupCredentialPolicies(now: number, sessionId?: string): Promise; + providerConfigured(): boolean; + readLegacyStoppingCandidates(sessionId?: string): Promise; + completeLegacyStop(row: InteractiveSessionRow, now: number): Promise; + requeueTerminalArchiveBackfill(sessionId?: string): Promise; + readBatchCandidates(providerConfigured: boolean): Promise; + readSession(sessionId: string): Promise; + reconcile(row: InteractiveSessionRow, now: number): Promise; + report(message: string, error: unknown): void; +}; + +export class InteractiveSessionReconciliationScheduler { + private readonly store: InteractiveSessionReconciliationSchedulerStore; + private readonly config: InteractiveSessionReconciliationSchedulerConfig; + + constructor( + store: InteractiveSessionReconciliationSchedulerStore, + config: InteractiveSessionReconciliationSchedulerConfig, + ) { + this.store = store; + this.config = config; + } + + async runBatch(now: number): Promise { + await this.store.cleanupAbandonedPreparations(now); + await this.store.cleanupCredentialPolicies(now); + await this.recoverLegacyStops(now); + await this.reconcileExternalBatch(now); + } + + async reconcileById(sessionId: string, now: number): Promise { + await this.store.cleanupCredentialPolicies(now, sessionId); + await this.recoverLegacyStops(now, sessionId); + await this.store.requeueTerminalArchiveBackfill(sessionId); + const row = await this.store.readSession(sessionId); + if ( + row && + interactiveSessionReconciliationDue(row, now, { + adapterName: this.config.adapterName, + providerConfigured: this.store.providerConfigured(), + intervalMs: this.config.intervalMs, + }) + ) { + await this.store.reconcile(row, now); + } + } + + private async recoverLegacyStops(now: number, sessionId?: string): Promise { + const candidates = await this.store.readLegacyStoppingCandidates(sessionId); + await mapWithConcurrency(candidates, this.config.concurrency, async (row) => { + await this.store.completeLegacyStop(row, now).catch((error) => { + this.store.report(`legacy interactive session stop recovery failed for ${row.id}`, error); + }); + }); + } + + private async reconcileExternalBatch(now: number): Promise { + await this.store.requeueTerminalArchiveBackfill(); + const providerConfigured = this.store.providerConfigured(); + const candidates = await this.store.readBatchCandidates(providerConfigured); + const due = candidates + .filter((row) => + interactiveSessionReconciliationDue(row, now, { + adapterName: this.config.adapterName, + providerConfigured, + intervalMs: this.config.intervalMs, + }), + ) + .slice(0, this.config.limit); + await mapWithConcurrency(due, this.config.concurrency, (row) => this.store.reconcile(row, now)); + } +} + +export function interactiveSessionReconciliationDue( + row: InteractiveSessionRow, + now: number, + options: { + adapterName: string; + providerConfigured: boolean; + intervalMs: number; + }, +): boolean { + const terminalFinalizationPending = + row.terminal_finalize_pending === 1 && terminalReconciliationStatuses.includes(row.status); + const activeAdapter = + options.providerConfigured && + row.adapter === options.adapterName && + activeReconciliationStatuses.includes(row.status); + if (!terminalFinalizationPending && !activeAdapter) return false; + return !row.last_reconciled_at || now - row.last_reconciled_at >= options.intervalMs; +} + +export async function readLegacyStoppingInteractiveSessionCandidates( + env: RuntimeEnv, + options: { + adapterName: string; + sandboxLeasePrefix: string; + limit: number; + sessionId?: string; + }, +): Promise { + let query = database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where("status", "=", "stopping") + .where((expression) => + expression.or([ + expression("adapter", "is", null), + expression("adapter", "!=", options.adapterName), + ]), + ) + .where("runtime", "!=", githubActionsRuntime) + .where("credential_cleanup_terminal_status", "is", null) + .where(sql`lease_id IS NULL OR lease_id NOT LIKE ${`${options.sandboxLeasePrefix}%`}`) + .orderBy("updated_at", "asc") + .limit(options.limit); + if (options.sessionId) query = query.where("id", "=", options.sessionId); + return query.execute(); +} + +export async function requeueTerminalArchiveObjectBackfill( + env: RuntimeEnv, + sessionId: string | undefined, + limit: number, +): Promise { + if (!env.SESSION_LOGS) return; + const sessionFilter = sessionId ? sql`AND session.id = ${sessionId}` : sql``; + await sql` + UPDATE interactive_sessions + SET terminal_finalize_pending = 1, + last_reconciled_at = NULL + WHERE id IN ( + SELECT session.id + FROM interactive_sessions AS session + JOIN interactive_session_log_archives AS archive + ON archive.session_id = session.id + WHERE session.status IN ('stopped', 'expired', 'failed') + AND session.terminal_finalize_pending = 0 + AND ( + archive.events_key IS NULL + OR archive.transcript_key IS NULL + OR archive.summary_key IS NULL + ) + ${sessionFilter} + ORDER BY session.updated_at ASC, session.id ASC + LIMIT ${sessionId ? 1 : limit * 2} + ) + `.execute(database(env)); +} + +export async function readInteractiveSessionReconciliationCandidates( + env: RuntimeEnv, + adapterName: string, + providerConfigured: boolean, + limit: number, +): Promise { + return database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where((expression) => + providerConfigured + ? expression.or([ + expression.and([ + expression("status", "in", terminalReconciliationStatuses), + expression("terminal_finalize_pending", "=", 1), + ]), + expression.and([ + expression("adapter", "=", adapterName), + expression("status", "in", activeReconciliationStatuses), + ]), + ]) + : expression.and([ + expression("status", "in", terminalReconciliationStatuses), + expression("terminal_finalize_pending", "=", 1), + ]), + ) + .orderBy("last_reconciled_at", "asc") + .limit(limit * 2) + .execute(); +} + +export async function readInteractiveSessionReconciliationRow( + env: RuntimeEnv, + sessionId: string, +): Promise { + return database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where("id", "=", sessionId) + .executeTakeFirst(); +} + +async function mapWithConcurrency( + values: readonly T[], + concurrency: number, + operation: (value: T) => Promise, +): Promise { + let cursor = 0; + const worker = async () => { + while (cursor < values.length) { + const index = cursor; + cursor += 1; + await operation(values[index] as T); + } + }; + await Promise.all( + Array.from({ length: Math.min(Math.max(1, concurrency), values.length) }, () => worker()), + ); +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 034c42e..f4825ad 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -578,7 +578,6 @@ test("confirmed adapter failure release keeps the original failure evidence", as }); test("terminal archive finalization remains durably retryable", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const finalizationSource = await readFile( new URL("../src/worker/session-terminal-finalization.ts", import.meta.url), "utf8", @@ -592,8 +591,6 @@ test("terminal archive finalization remains durably retryable", async () => { "utf8", ); - assert.match(source, /expression\("terminal_finalize_pending", "=", 1\)/); - assert.match(source, /row\.terminal_finalize_pending === 1/); assert.match(finalizationSource, /completeTerminalFinalization/); assert.match(finalizationSource, /SET terminal_finalize_pending = 0/); assert.match(archiveSource, /interactive_session_log_archives\.events_key IS NULL/); @@ -623,59 +620,13 @@ test("terminal archive finalization remains durably retryable", async () => { assert.match(migration, /status IN \('stopped', 'expired', 'failed'\)/); }); -test("enabling R2 requeues D1-only terminal archives for object backfill", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const backfillStart = source.indexOf("async function requeueTerminalArchiveObjectBackfill"); - const batchStart = source.indexOf( - "async function reconcileExternalInteractiveSessionBatch", - backfillStart, - ); - const targetedStart = source.indexOf( - "async function reconcileExternalInteractiveSessionById", - batchStart, - ); - const reconcileStart = source.indexOf( - "async function reconcileExternalInteractiveSession(", - targetedStart, - ); - const backfillSource = source.slice(backfillStart, batchStart); - const batchSource = source.slice(batchStart, targetedStart); - const targetedSource = source.slice(targetedStart, reconcileStart); - - assert.match(backfillSource, /if \(!env\.SESSION_LOGS\) return/); - assert.match(backfillSource, /session\.terminal_finalize_pending = 0/); - assert.match(backfillSource, /archive\.events_key IS NULL/); - assert.match(backfillSource, /archive\.transcript_key IS NULL/); - assert.match(backfillSource, /archive\.summary_key IS NULL/); - assert.match(backfillSource, /SET terminal_finalize_pending = 1/); - assert.match(backfillSource, /last_reconciled_at = NULL/); - assert.match(batchSource, /await requeueTerminalArchiveObjectBackfill\(env\)/); - assert.match(targetedSource, /await requeueTerminalArchiveObjectBackfill\(env, id\)/); -}); - test("runtime reconciliation has scheduled and targeted lifecycle clocks", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const config = await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"); - const targetedStart = source.indexOf("async function reconcileExternalInteractiveSessionById"); - const targetedEnd = source.indexOf( - "async function reconcileExternalInteractiveSession(", - targetedStart, - ); - const targetedSource = source.slice(targetedStart, targetedEnd); - const batchStart = source.indexOf("async function reconcileExternalInteractiveSessionBatch"); - const batchEnd = source.indexOf("async function reconcileExternalInteractiveSessionById"); - const batchSource = source.slice(batchStart, batchEnd); assert.match(source, /async scheduled\(/); assert.match(source, /context\.waitUntil\(\s*reconcileInteractiveSessionLifecycleBatch/); assert.match(config, /"crons": \["\* \* \* \* \*"\]/); - assert.match(batchSource, /expression\("terminal_finalize_pending", "=", 1\)/); - assert.match(batchSource, /expression\("adapter", "=", runtimeAdapterName\)/); - assert.match(targetedSource, /reconcileCredentialPolicyCleanupBatch\(env, now, id\)/); - assert.match(targetedSource, /row\.terminal_finalize_pending === 1/); - assert.match(targetedSource, /row\.adapter !== runtimeAdapterName/); - assert.match(targetedSource, /runtimeAdapterReconcileIntervalMs/); - assert.match(targetedSource, /reconcileExternalInteractiveSession\(env, row, now\)/); assert.match(source, /async function readFreshInteractiveSession/); assert.match( source, @@ -685,7 +636,11 @@ test("runtime reconciliation has scheduled and targeted lifecycle clocks", async assert.match(source, /scheduled interactive session reconciliation failed/); assert.match( source, - /async function reconcileInteractiveSessionLifecycleBatch[\s\S]*reconcileCredentialPolicyCleanupBatch[\s\S]*reconcileExternalInteractiveSessionBatch/, + /async function reconcileInteractiveSessionLifecycleBatch[\s\S]*interactiveSessionReconciliationScheduler\(env\)\.runBatch\(now\)/, + ); + assert.match( + source, + /async function reconcileExternalInteractiveSessionById[\s\S]*interactiveSessionReconciliationScheduler\(env\)\.reconcileById\(id, now\)/, ); assert.match(source, /interactiveSessionReconciliationService\(env\)\.reconcile\(row, now\)/); }); @@ -1297,21 +1252,10 @@ test("legacy and GitHub Actions stop wrappers finalize persisted transitions", a githubActionsStart, ); const githubActionsSource = source.slice(githubActionsStart, githubActionsEnd); - const scheduledStart = source.indexOf( - "async function reconcileLegacyStoppingInteractiveSessionBatch", - ); - const scheduledEnd = source.indexOf( - "async function requeueTerminalArchiveObjectBackfill", - scheduledStart, - ); - const scheduledSource = source.slice(scheduledStart, scheduledEnd); assert.match(completeSource, /persistLegacyInteractiveSessionStop/); assert.match(completeSource, /archiveInteractiveSessionLogs/); assert.match(completeSource, /finalizeTerminalInteractiveSession/); - assert.match(scheduledSource, /where\("status", "=", "stopping"\)/); - assert.match(scheduledSource, /\.where\("runtime", "!=", githubActionsRuntime\)/); - assert.match(scheduledSource, /completeLegacyInteractiveSessionStop/); assert.match(completeSource, /if \(owner\.runtime === githubActionsRuntime\) return false/); assert.match(githubActionsSource, /persistGitHubActionsSessionStop/); assert.match(githubActionsSource, /disconnectGitHubActionsRunner/); diff --git a/tests/session-reconciliation-scheduler.test.ts b/tests/session-reconciliation-scheduler.test.ts new file mode 100644 index 0000000..3b2f17d --- /dev/null +++ b/tests/session-reconciliation-scheduler.test.ts @@ -0,0 +1,281 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + interactiveSessionReconciliationDue, + InteractiveSessionReconciliationScheduler, + readInteractiveSessionReconciliationCandidates, + readInteractiveSessionReconciliationRow, + readLegacyStoppingInteractiveSessionCandidates, + requeueTerminalArchiveObjectBackfill, + type InteractiveSessionReconciliationSchedulerStore, +} from "../src/worker/session-reconciliation-scheduler.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +type D1Result = { results?: unknown[]; changes?: number }; + +function runtimeEnv( + handler: (sql: string, parameters: unknown[], kind: "all" | "run") => D1Result, + sessionLogs = false, +): RuntimeEnv { + return { + DB: { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + return { + async all() { + const result = handler(sql, parameters, "all"); + return { results: result.results ?? [], meta: { changes: result.changes ?? 0 } }; + }, + async run() { + const result = handler(sql, parameters, "run"); + return { meta: { changes: result.changes ?? 0 } }; + }, + }; + }, + }; + }, + } as unknown as D1Database, + ...(sessionLogs ? { SESSION_LOGS: {} as R2Bucket } : {}), + } as RuntimeEnv; +} + +function schedulerStore( + overrides: Partial = {}, +): InteractiveSessionReconciliationSchedulerStore { + return { + async cleanupAbandonedPreparations() {}, + async cleanupCredentialPolicies() {}, + providerConfigured: () => true, + async readLegacyStoppingCandidates() { + return []; + }, + async completeLegacyStop() {}, + async requeueTerminalArchiveBackfill() {}, + async readBatchCandidates() { + return []; + }, + async readSession() { + return undefined; + }, + async reconcile() {}, + report() {}, + ...overrides, + }; +} + +function scheduler( + store: InteractiveSessionReconciliationSchedulerStore, +): InteractiveSessionReconciliationScheduler { + return new InteractiveSessionReconciliationScheduler(store, { + adapterName: "runtime-adapter", + sandboxLeasePrefix: "sandbox:", + intervalMs: 15_000, + limit: 2, + concurrency: 2, + }); +} + +test("batch scheduling orders cleanup, recovers legacy stops, and reconciles only bounded due rows", async () => { + const order: string[] = []; + const reconciled: string[] = []; + const reports: string[] = []; + const legacyOne = sessionRow({ id: "IS-L1", status: "stopping", adapter: null }); + const legacyTwo = sessionRow({ id: "IS-L2", status: "stopping", adapter: null }); + const active = sessionRow({ + id: "IS-A", + status: "ready", + adapter: "runtime-adapter", + last_reconciled_at: null, + }); + const terminal = sessionRow({ + id: "IS-T", + status: "failed", + terminal_finalize_pending: 1, + last_reconciled_at: 1, + }); + const recent = sessionRow({ + id: "IS-R", + status: "ready", + adapter: "runtime-adapter", + last_reconciled_at: 99_000, + }); + await scheduler( + schedulerStore({ + async cleanupAbandonedPreparations() { + order.push("abandoned"); + }, + async cleanupCredentialPolicies() { + order.push("credentials"); + }, + async readLegacyStoppingCandidates() { + order.push("legacy-read"); + return [legacyOne, legacyTwo]; + }, + async completeLegacyStop(row) { + order.push(`legacy:${row.id}`); + if (row.id === legacyTwo.id) throw new Error("stop unavailable"); + }, + async requeueTerminalArchiveBackfill() { + order.push("backfill"); + }, + async readBatchCandidates(providerConfigured) { + assert.equal(providerConfigured, true); + order.push("batch-read"); + return [active, recent, terminal]; + }, + async reconcile(row) { + reconciled.push(row.id); + }, + report(message) { + reports.push(message); + }, + }), + ).runBatch(100_000); + + assert.deepEqual(order.slice(0, 3), ["abandoned", "credentials", "legacy-read"]); + assert.ok(order.indexOf("backfill") > order.indexOf(`legacy:${legacyTwo.id}`)); + assert.ok(order.indexOf("batch-read") > order.indexOf("backfill")); + assert.deepEqual(new Set(reconciled), new Set([active.id, terminal.id])); + assert.deepEqual(reports, [ + `legacy interactive session stop recovery failed for ${legacyTwo.id}`, + ]); +}); + +test("targeted scheduling completes cleanup and admits terminal finalization without a provider", async () => { + const order: string[] = []; + const row = sessionRow({ + id: "IS-T", + status: "stopped", + terminal_finalize_pending: 1, + last_reconciled_at: null, + }); + await scheduler( + schedulerStore({ + async cleanupCredentialPolicies(_now, sessionId) { + order.push(`credentials:${sessionId}`); + }, + providerConfigured: () => false, + async readLegacyStoppingCandidates(sessionId) { + order.push(`legacy:${sessionId}`); + return []; + }, + async requeueTerminalArchiveBackfill(sessionId) { + order.push(`backfill:${sessionId}`); + }, + async readSession(sessionId) { + order.push(`read:${sessionId}`); + return row; + }, + async reconcile(current) { + order.push(`reconcile:${current.id}`); + }, + }), + ).reconcileById(row.id, 100_000); + + assert.deepEqual(order, [ + `credentials:${row.id}`, + `legacy:${row.id}`, + `backfill:${row.id}`, + `read:${row.id}`, + `reconcile:${row.id}`, + ]); +}); + +test("reconciliation cadence requires terminal work or a configured active adapter", () => { + const active = sessionRow({ + status: "ready", + adapter: "runtime-adapter", + last_reconciled_at: 80_000, + }); + assert.equal( + interactiveSessionReconciliationDue(active, 100_000, { + adapterName: "runtime-adapter", + providerConfigured: true, + intervalMs: 15_000, + }), + true, + ); + assert.equal( + interactiveSessionReconciliationDue(active, 90_000, { + adapterName: "runtime-adapter", + providerConfigured: true, + intervalMs: 15_000, + }), + false, + ); + assert.equal( + interactiveSessionReconciliationDue(active, 100_000, { + adapterName: "runtime-adapter", + providerConfigured: false, + intervalMs: 15_000, + }), + false, + ); + assert.equal( + interactiveSessionReconciliationDue( + sessionRow({ status: "failed", terminal_finalize_pending: 1 }), + 100_000, + { + adapterName: "runtime-adapter", + providerConfigured: false, + intervalMs: 15_000, + }, + ), + true, + ); +}); + +test("scheduler queries preserve legacy, provider, terminal, and archive-backfill admission", async () => { + const row = sessionRow({ id: "IS-1", status: "stopping" }); + const queries: string[] = []; + const env = runtimeEnv((sql, parameters, kind) => { + queries.push(sql); + if (/update interactive_sessions/i.test(sql)) { + assert.equal(kind, "run"); + assert.match(sql, /archive\.events_key IS NULL/i); + assert.match(sql, /last_reconciled_at = NULL/i); + assert.ok(parameters.includes("IS-1")); + return { changes: 1 }; + } + assert.equal(kind, "all"); + if (/lease_id IS NULL OR lease_id NOT LIKE/i.test(sql)) { + assert.match(sql, /"status" =/i); + assert.match(sql, /"runtime" !=/i); + assert.ok(parameters.includes("runtime-adapter")); + assert.ok(parameters.includes("sandbox:%")); + return { results: [row] }; + } + if (/"terminal_finalize_pending" =/i.test(sql) && /"adapter" =/i.test(sql)) { + assert.match(sql, /order by "last_reconciled_at" asc/i); + assert.ok(parameters.includes("runtime-adapter")); + return { results: [row] }; + } + if (/from "interactive_sessions"/i.test(sql) && /"id" =/i.test(sql)) { + assert.deepEqual(parameters, ["IS-1"]); + return { results: [row] }; + } + throw new Error(`unexpected query: ${sql}`); + }, true); + + assert.equal( + ( + await readLegacyStoppingInteractiveSessionCandidates(env, { + adapterName: "runtime-adapter", + sandboxLeasePrefix: "sandbox:", + limit: 3, + sessionId: "IS-1", + }) + )[0]?.id, + "IS-1", + ); + assert.equal( + (await readInteractiveSessionReconciliationCandidates(env, "runtime-adapter", true, 3))[0]?.id, + "IS-1", + ); + assert.equal((await readInteractiveSessionReconciliationRow(env, "IS-1"))?.id, "IS-1"); + await requeueTerminalArchiveObjectBackfill(env, "IS-1", 3); + assert.equal(queries.length, 4); +}); From 23778acf8ff7f7c2ecad23708443a103e7858d7e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:17:08 +0100 Subject: [PATCH 042/109] refactor: share bounded concurrency --- CHANGELOG.md | 1 + src/index.ts | 19 +--------- src/worker/concurrency.ts | 17 +++++++++ .../session-reconciliation-scheduler.ts | 19 +--------- tests/concurrency.test.ts | 36 +++++++++++++++++++ 5 files changed, 56 insertions(+), 36 deletions(-) create mode 100644 src/worker/concurrency.ts create mode 100644 tests/concurrency.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4911c0d..8ded864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - Extract finalized-session admission, fenced transactional deletion, authorization filtering, and archive-object cleanup behind one service. - Extract runtime-adapter reconciliation claims, transition projection, atomic evidence persistence, race recovery, and terminal finalization behind one service. - Extract scheduled and targeted reconciliation admission, legacy-stop recovery, cadence limits, and terminal archive backfill behind one scheduler. +- Replace duplicate bounded-concurrency loops with one directly tested worker utility. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 9fa3d56..1722e26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -135,6 +135,7 @@ import { selectedRuntimeProfile, } from "./worker/deployment"; import { clampedSeconds } from "./worker/duration"; +import { mapWithConcurrency } from "./worker/concurrency"; import type { RuntimeEnv } from "./worker/env"; import { database, @@ -3291,24 +3292,6 @@ function interactiveSessionReconciliationService( return new InteractiveSessionReconciliationService(store, runtimeAdapterName); } -async function mapWithConcurrency( - values: T[], - concurrency: number, - operation: (value: T) => Promise, -): Promise { - let cursor = 0; - const worker = async () => { - while (cursor < values.length) { - const index = cursor; - cursor += 1; - await operation(values[index] as T); - } - }; - await Promise.all( - Array.from({ length: Math.min(Math.max(1, concurrency), values.length) }, () => worker()), - ); -} - async function readState( request: Request, env: RuntimeEnv, diff --git a/src/worker/concurrency.ts b/src/worker/concurrency.ts new file mode 100644 index 0000000..837a3cc --- /dev/null +++ b/src/worker/concurrency.ts @@ -0,0 +1,17 @@ +export async function mapWithConcurrency( + values: readonly T[], + concurrency: number, + operation: (value: T) => Promise, +): Promise { + let cursor = 0; + const worker = async () => { + while (cursor < values.length) { + const index = cursor; + cursor += 1; + await operation(values[index] as T); + } + }; + await Promise.all( + Array.from({ length: Math.min(Math.max(1, concurrency), values.length) }, () => worker()), + ); +} diff --git a/src/worker/session-reconciliation-scheduler.ts b/src/worker/session-reconciliation-scheduler.ts index e396b54..e5b27ba 100644 --- a/src/worker/session-reconciliation-scheduler.ts +++ b/src/worker/session-reconciliation-scheduler.ts @@ -1,6 +1,7 @@ import { sql } from "kysely"; import { githubActionsRuntime } from "../github-actions-runtime.ts"; +import { mapWithConcurrency } from "./concurrency.ts"; import { database, type InteractiveSessionRow } from "./database.ts"; import type { RuntimeEnv } from "./env.ts"; import type { InteractiveSessionStatus } from "./models.ts"; @@ -220,21 +221,3 @@ export async function readInteractiveSessionReconciliationRow( .where("id", "=", sessionId) .executeTakeFirst(); } - -async function mapWithConcurrency( - values: readonly T[], - concurrency: number, - operation: (value: T) => Promise, -): Promise { - let cursor = 0; - const worker = async () => { - while (cursor < values.length) { - const index = cursor; - cursor += 1; - await operation(values[index] as T); - } - }; - await Promise.all( - Array.from({ length: Math.min(Math.max(1, concurrency), values.length) }, () => worker()), - ); -} diff --git a/tests/concurrency.test.ts b/tests/concurrency.test.ts new file mode 100644 index 0000000..c36349b --- /dev/null +++ b/tests/concurrency.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mapWithConcurrency } from "../src/worker/concurrency.ts"; + +test("bounded concurrency processes each value without exceeding the worker limit", async () => { + const completed: number[] = []; + let active = 0; + let maximumActive = 0; + + await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (value) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + await new Promise((resolve) => setTimeout(resolve, value % 2)); + completed.push(value); + active -= 1; + }); + + assert.equal(maximumActive, 2); + assert.deepEqual(new Set(completed), new Set([1, 2, 3, 4, 5])); +}); + +test("bounded concurrency handles empty input and propagates operation failures", async () => { + let calls = 0; + await mapWithConcurrency([], 0, async () => { + calls += 1; + }); + assert.equal(calls, 0); + + await assert.rejects( + mapWithConcurrency([1], 0, async () => { + throw new Error("operation failed"); + }), + /operation failed/, + ); +}); From f76c26761b9bd86294762e70b4fdb1c62b551a99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:23:09 +0100 Subject: [PATCH 043/109] refactor: extract agent session authentication --- CHANGELOG.md | 1 + src/index.ts | 89 +++++++-------------- src/worker/ingress.ts | 4 +- src/worker/session-agent-auth.ts | 94 ++++++++++++++++++++++ tests/ingress.test.ts | 12 +++ tests/runtime-adapter.test.ts | 4 - tests/session-agent-auth.test.ts | 133 +++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+), 65 deletions(-) create mode 100644 src/worker/session-agent-auth.ts create mode 100644 tests/session-agent-auth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ded864..a024e74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Extract runtime-adapter reconciliation claims, transition projection, atomic evidence persistence, race recovery, and terminal finalization behind one service. - Extract scheduled and targeted reconciliation admission, legacy-stop recovery, cadence limits, and terminal archive backfill behind one scheduler. - Replace duplicate bounded-concurrency loops with one directly tested worker utility. +- Extract agent-session authentication and remove the legacy `X-Crabbox-Session-ID` alias. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 1722e26..6931562 100644 --- a/src/index.ts +++ b/src/index.ts @@ -149,7 +149,6 @@ import { type StandaloneSandboxProvisionTable, } from "./worker/database"; import { - deadInteractiveSessionStatuses, type InteractiveSessionStatus, type Role, type RunStatus, @@ -159,7 +158,6 @@ import { import { badRequest, bearer, - bearerToken, conflict, cookie, forbidden, @@ -192,6 +190,11 @@ import { upsertUser, } from "./worker/auth"; import { base64FromBytes, openSecret, sealSecret, sha256 } from "./worker/crypto"; +import { + AgentSessionAuthenticator, + agentSessionId, + type AgentSessionAuthenticationStore, +} from "./worker/session-agent-auth"; import { githubCallback, githubLogin, sshLinkCookie } from "./worker/github-auth"; import { GitHubApiError, githubFetch, githubHeaders, refreshGitHubUser } from "./worker/github"; import { @@ -1589,7 +1592,7 @@ async function api( /^\/api\/agent\/interactive-sessions\/([^/]+)$/, ); if (request.method === "GET" && agentInteractiveReadMatch) { - const { user } = await requireAgentSession(request, env); + const { user } = await agentSessionAuthentication(env).require(request); const session = await readFreshInteractiveSession( env, decodeURIComponent(agentInteractiveReadMatch[1] ?? ""), @@ -1665,7 +1668,7 @@ async function api( /^\/api\/agent\/interactive-sessions\/([^/]+)\/logs$/, ); if (request.method === "GET" && agentInteractiveLogsMatch) { - const { user } = await requireAgentSession(request, env); + const { user } = await agentSessionAuthentication(env).require(request); return json( await readInteractiveSessionLogBundle( env, @@ -1692,7 +1695,7 @@ async function api( /^\/api\/agent\/interactive-sessions\/([^/]+)\/transcript$/, ); if (request.method === "GET" && agentInteractiveTranscriptMatch) { - const { user } = await requireAgentSession(request, env); + const { user } = await agentSessionAuthentication(env).require(request); return interactiveSessionTranscriptResponse( env, user, @@ -1720,7 +1723,7 @@ async function api( /^\/api\/agent\/interactive-sessions\/([^/]+)\/summary$/, ); if (request.method === "POST" && agentInteractiveSummaryMatch) { - const { user } = await requireAgentSession(request, env); + const { user } = await agentSessionAuthentication(env).require(request); return json( await updateInteractiveSessionSummary( request, @@ -2256,7 +2259,7 @@ async function terminalHubUser( return requireSshGatewayUser(request, env); } if (agentSessionId(request)) { - return (await requireAgentSession(request, env)).user; + return (await agentSessionAuthentication(env).require(request)).user; } return optionalUser(request, env, requestAuth); } @@ -2342,7 +2345,7 @@ async function sshState(request: Request, env: RuntimeEnv): Promise> { - const { session, user } = await requireAgentSession(request, env); + const { session, user } = await agentSessionAuthentication(env).require(request); const state = await readState(request, env, user); return { ...state, agent: { sessionId: session.id, rootSessionId: session.rootSessionId } }; } @@ -2392,7 +2395,7 @@ async function agentCreateInteractiveSession( request: Request, env: RuntimeEnv, ): Promise<{ session: InteractiveSession }> { - const { session: parent, user } = await requireAgentSession(request, env); + const { session: parent, user } = await agentSessionAuthentication(env).require(request); const body = await readJson<{ repo?: string; branch?: string; @@ -3000,46 +3003,25 @@ async function requireSshGatewayUser(request: Request, env: RuntimeEnv): Promise return user; } -async function requireAgentSession( - request: Request, - env: RuntimeEnv, - expectedId?: string, - options: { allowQueryToken?: boolean } = {}, -): Promise<{ session: InteractiveSession; user: User }> { - const presentedId = agentSessionId(request); - const id = clean(expectedId, 120) || presentedId; - if (expectedId && presentedId && presentedId !== expectedId) throw unauthorized(); - const url = new URL(request.url); - const token = - bearerToken(request) || - clean(request.headers.get("x-crabfleet-agent-token"), 200) || - (options.allowQueryToken ? clean(url.searchParams.get("agentToken"), 200) : ""); - if (!id || !token) throw unauthorized(); - const row = await database(env) - .selectFrom("interactive_sessions") - .selectAll() - .where("id", "=", id) - .where("preparation_pending", "=", 0) - .executeTakeFirst(); - if (!row?.agent_token_hash || row.agent_token_hash !== (await sha256(token))) { - throw unauthorized(); - } - const session = interactiveSession(row, []); - if (session.status === "stopping" || deadInteractiveSessionStatuses.includes(session.status)) { - throw forbidden("agent session is not active"); - } - return { - session, - user: { - subject: `agent:${session.id}`, - login: session.owner, - email: null, - name: `Codex ${session.id}`, - role: "viewer", - allowed: true, - teams: [], +function agentSessionAuthentication(env: RuntimeEnv): AgentSessionAuthenticator { + const store: AgentSessionAuthenticationStore = { + readCredential: async (id) => { + const row = await database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where("id", "=", id) + .where("preparation_pending", "=", 0) + .executeTakeFirst(); + return row + ? { + session: interactiveSession(row, []), + tokenHash: row.agent_token_hash, + } + : null; }, + hashToken: sha256, }; + return new AgentSessionAuthenticator(store); } function requireSshGateway(request: Request, env: RuntimeEnv): void { @@ -3146,15 +3128,6 @@ function sshFingerprint(request: Request): string { ); } -function agentSessionId(request: Request): string { - const url = new URL(request.url); - return ( - clean(request.headers.get("x-crabfleet-session-id"), 120) || - clean(request.headers.get("x-crabbox-session-id"), 120) || - clean(url.searchParams.get("sessionId"), 120) - ); -} - function sshGatewayTokens(env: RuntimeEnv): string[] { return [env.CRABFLEET_SSH_GATEWAY_TOKEN, env.CRABBOX_SSH_GATEWAY_TOKEN].filter( (token): token is string => Boolean(token), @@ -11960,7 +11933,7 @@ async function updateGitHubActionsWorkState( env: RuntimeEnv, id: string, ): Promise<{ session: InteractiveSession }> { - const { session, user } = await requireAgentSession(request, env, id); + const { session, user } = await agentSessionAuthentication(env).require(request, id); if (session.runtime !== githubActionsRuntime || !session.workKey) { throw badRequest("session is not a GitHub Actions work session"); } @@ -12048,7 +12021,7 @@ async function githubActionsRunnerPty( if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { throw badRequest("websocket upgrade required"); } - const { session, user } = await requireAgentSession(request, env, id, { + const { session, user } = await agentSessionAuthentication(env).require(request, id, { allowQueryToken: true, }); if (session.runtime !== githubActionsRuntime || !session.workKey) { diff --git a/src/worker/ingress.ts b/src/worker/ingress.ts index 343043f..925410d 100644 --- a/src/worker/ingress.ts +++ b/src/worker/ingress.ts @@ -42,9 +42,7 @@ export function usesIndependentServiceAuth(request: Request): boolean { const hasSshIdentity = Boolean( headers.get("x-crabfleet-ssh-fingerprint") || headers.get("x-crabbox-ssh-fingerprint"), ); - const hasAgentIdentity = Boolean( - headers.get("x-crabfleet-session-id") || headers.get("x-crabbox-session-id"), - ); + const hasAgentIdentity = Boolean(headers.get("x-crabfleet-session-id")); return hasAuthorization && (hasSshIdentity || hasAgentIdentity); } return ["/api/ssh/", "/api/agent/", "/api/openclaw/", "/api/provision/"].some((prefix) => diff --git a/src/worker/session-agent-auth.ts b/src/worker/session-agent-auth.ts new file mode 100644 index 0000000..e893c6e --- /dev/null +++ b/src/worker/session-agent-auth.ts @@ -0,0 +1,94 @@ +import { deadInteractiveSessionStatuses, type User } from "./models.ts"; +import { bearerToken, forbidden, unauthorized } from "./http.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export type AgentSessionCredential = { + session: InteractiveSession; + tokenHash: string | null; +}; + +export type AgentSessionAuthenticationStore = { + readCredential(id: string): Promise; + hashToken(token: string): Promise; +}; + +export type AgentSessionAuthenticationOptions = { + allowQueryToken?: boolean; +}; + +export class AgentSessionAuthenticator { + private readonly store: AgentSessionAuthenticationStore; + + constructor(store: AgentSessionAuthenticationStore) { + this.store = store; + } + + async require( + request: Request, + expectedId?: string, + options: AgentSessionAuthenticationOptions = {}, + ): Promise<{ session: InteractiveSession; user: User }> { + const presentedId = agentSessionId(request); + const normalizedExpectedId = boundedValue(expectedId, 120); + const id = normalizedExpectedId || presentedId; + if (normalizedExpectedId && presentedId && presentedId !== normalizedExpectedId) { + throw unauthorized(); + } + + const token = agentSessionToken(request, options); + if (!id || !token) throw unauthorized(); + + const credential = await this.store.readCredential(id); + if (!credential?.tokenHash || credential.tokenHash !== (await this.store.hashToken(token))) { + throw unauthorized(); + } + if ( + credential.session.status === "stopping" || + deadInteractiveSessionStatuses.includes(credential.session.status) + ) { + throw forbidden("agent session is not active"); + } + return { + session: credential.session, + user: agentSessionUser(credential.session), + }; + } +} + +export function agentSessionId(request: Request): string { + const url = new URL(request.url); + return ( + boundedValue(request.headers.get("x-crabfleet-session-id"), 120) || + boundedValue(url.searchParams.get("sessionId"), 120) + ); +} + +export function agentSessionToken( + request: Request, + options: AgentSessionAuthenticationOptions = {}, +): string { + const url = new URL(request.url); + return ( + bearerToken(request) || + boundedValue(request.headers.get("x-crabfleet-agent-token"), 200) || + (options.allowQueryToken ? boundedValue(url.searchParams.get("agentToken"), 200) : "") + ); +} + +export function agentSessionUser(session: InteractiveSession): User { + return { + subject: `agent:${session.id}`, + login: session.owner, + email: null, + name: `Codex ${session.id}`, + role: "viewer", + allowed: true, + teams: [], + }; +} + +function boundedValue(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/ingress.test.ts b/tests/ingress.test.ts index f4088db..7297d1e 100644 --- a/tests/ingress.test.ts +++ b/tests/ingress.test.ts @@ -131,6 +131,18 @@ test("terminal ingress requires authorization plus an SSH or agent identity", () true, ); } + + assert.equal( + usesIndependentServiceAuth( + new Request("https://backend.example/api/terminal/ws", { + headers: { + authorization: "Bearer terminal-token", + "x-crabbox-session-id": "legacy-session", + }, + }), + ), + false, + ); }); test("disabled proxy mode leaves ordinary requests routable but rejects assertion headers", () => { diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index f4825ad..024876f 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -1544,10 +1544,6 @@ test("terminal endpoints enforce current runtime capabilities", async () => { assert.match(source, /runtimeAdapterTerminalFailureStatus\(existing\.adapter\) === "detached"/); assert.doesNotMatch(source, /async function interactiveSessionPty/); assert.doesNotMatch(source, /\/api\/(?:ssh\/|agent\/)?interactive-sessions\/\(\[\^\/\]\+\)\/pty/); - assert.match( - source, - /async function terminalHubUser[\s\S]*isSshGatewayRequest[\s\S]*requireSshGatewayUser[\s\S]*agentSessionId[\s\S]*requireAgentSession/, - ); }); test("non-retryable adapter client errors do not enter ambiguous replay", () => { diff --git a/tests/session-agent-auth.test.ts b/tests/session-agent-auth.test.ts new file mode 100644 index 0000000..302845a --- /dev/null +++ b/tests/session-agent-auth.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sha256 } from "../src/worker/crypto.ts"; +import { + AgentSessionAuthenticator, + agentSessionId, + agentSessionToken, +} from "../src/worker/session-agent-auth.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function authenticator(values: Parameters[0] = {}): AgentSessionAuthenticator { + const session = interactiveSession( + sessionRow({ + id: "IS-agent", + owner: "session-owner", + agent_token_hash: "agent-token-hash", + ...values, + }), + [], + ); + return new AgentSessionAuthenticator({ + readCredential: async (id) => + id === session.id ? { session, tokenHash: "agent-token-hash" } : null, + hashToken: async (token) => (token === "agent-token" ? "agent-token-hash" : sha256(token)), + }); +} + +test("agent session credentials use the canonical Crabfleet protocol", () => { + const request = new Request( + "https://fleet.example/api/terminal/ws?sessionId=query-session&agentToken=query-token", + { + headers: { + authorization: "Bearer bearer-token", + "x-crabfleet-session-id": "header-session", + "x-crabfleet-agent-token": "header-token", + "x-crabbox-session-id": "legacy-session", + }, + }, + ); + + assert.equal(agentSessionId(request), "header-session"); + assert.equal(agentSessionToken(request, { allowQueryToken: true }), "bearer-token"); + assert.equal( + agentSessionId( + new Request("https://fleet.example/api/terminal/ws", { + headers: { "x-crabbox-session-id": "legacy-session" }, + }), + ), + "", + ); +}); + +test("query agent tokens require an explicit endpoint exception", () => { + const request = new Request( + "https://fleet.example/api/agent/interactive-sessions/IS-agent/runner-pty?agentToken=query-token", + ); + assert.equal(agentSessionToken(request), ""); + assert.equal(agentSessionToken(request, { allowQueryToken: true }), "query-token"); +}); + +test("path-bound sessions can opt into query credentials without a session header", async () => { + const request = new Request( + "https://fleet.example/api/agent/interactive-sessions/IS-agent/runner-pty?agentToken=agent-token", + ); + await assert.doesNotReject(() => + authenticator().require(request, "IS-agent", { allowQueryToken: true }), + ); + await assert.rejects(() => authenticator().require(request, "IS-agent"), { + message: "unauthorized", + }); +}); + +test("agent authentication binds request identity and token to one active session", async () => { + const auth = authenticator(); + const request = new Request("https://fleet.example/api/agent/state", { + headers: { + authorization: "Bearer agent-token", + "x-crabfleet-session-id": "IS-agent", + }, + }); + + const result = await auth.require(request); + assert.equal(result.session.id, "IS-agent"); + assert.deepEqual(result.user, { + subject: "agent:IS-agent", + login: "session-owner", + email: null, + name: "Codex IS-agent", + role: "viewer", + allowed: true, + teams: [], + }); + await assert.rejects(() => auth.require(request, "IS-other"), { message: "unauthorized" }); +}); + +test("agent authentication rejects missing, invalid, and inactive credentials", async () => { + const request = new Request("https://fleet.example/api/agent/state", { + headers: { + authorization: "Bearer agent-token", + "x-crabfleet-session-id": "IS-agent", + }, + }); + + await assert.rejects( + () => + authenticator().require( + new Request("https://fleet.example/api/agent/state", { + headers: { "x-crabfleet-session-id": "IS-agent" }, + }), + ), + { message: "unauthorized" }, + ); + await assert.rejects( + () => + authenticator().require( + new Request(request, { + headers: { + ...Object.fromEntries(request.headers), + authorization: "Bearer wrong", + }, + }), + ), + { message: "unauthorized" }, + ); + await assert.rejects(() => authenticator({ status: "stopping" }).require(request), { + message: "agent session is not active", + }); + await assert.rejects(() => authenticator({ status: "stopped" }).require(request), { + message: "agent session is not active", + }); +}); From fb986ed51af3014a264017689f7415cf3a6179ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:33:22 +0100 Subject: [PATCH 044/109] refactor: extract GitHub Actions registration --- CHANGELOG.md | 1 + src/index.ts | 237 +++------------ src/worker/deployment.ts | 4 + .../github-actions-session-registration.ts | 286 ++++++++++++++++++ tests/deployment.test.ts | 5 + ...ithub-actions-session-registration.test.ts | 253 ++++++++++++++++ tests/trusted-proxy-integration.test.ts | 4 - 7 files changed, 597 insertions(+), 193 deletions(-) create mode 100644 src/worker/github-actions-session-registration.ts create mode 100644 tests/github-actions-session-registration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a024e74..335adb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Extract scheduled and targeted reconciliation admission, legacy-stop recovery, cadence limits, and terminal archive backfill behind one scheduler. - Replace duplicate bounded-concurrency loops with one directly tested worker utility. - Extract agent-session authentication and remove the legacy `X-Crabbox-Session-ID` alias. +- Extract GitHub Actions session validation, idempotent registration, token rotation, resume reset, runner replacement, and evidence ordering. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 6931562..541dda7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,7 +43,6 @@ import { notifyGitHubActionsViewers, parseGitHubActionsWorkState, replaceGitHubActionsRunner, - githubActionsCapabilities, } from "./github-actions-runtime"; import { githubRequestCanUseRepoCredential, matchesAnyHost } from "./sandbox-security"; import { githubOAuthCanonicalSshLinkUrl, githubOAuthRedirectUri } from "./oauth"; @@ -128,6 +127,7 @@ import { } from "./credential-policy-fence"; import { browserAppOrigin, + browserSessionUrl, clientDeploymentConfig, defaultPreferredRepo, deploymentConfig, @@ -197,6 +197,11 @@ import { } from "./worker/session-agent-auth"; import { githubCallback, githubLogin, sshLinkCookie } from "./worker/github-auth"; import { GitHubApiError, githubFetch, githubHeaders, refreshGitHubUser } from "./worker/github"; +import { + GitHubActionsSessionRegistrationService, + type GitHubActionsSessionRegistrationInput, + type GitHubActionsSessionRegistrationStore, +} from "./worker/github-actions-session-registration"; import { containerCapabilities, crabboxCapabilities, @@ -2711,7 +2716,7 @@ function openClawDecoratedCrabboxResponse( ): { session: InteractiveSession; browserUrl: string } { return { session, - browserUrl: `${browserAppOrigin(env)}/app/sessions/${encodeURIComponent(session.id)}`, + browserUrl: browserSessionUrl(env, session.id), }; } @@ -2725,173 +2730,50 @@ async function openClawRegisterActionSession( browserUrl: string; }> { requireOpenClawAutomationService(request, env); - const body = await readJson<{ - workKey?: string; - workKind?: string; - repo?: string; - branch?: string; - sourceUrl?: string; - runUrl?: string; - purpose?: string; - summary?: string; - }>(request); - const workKey = actionWorkIdentifier(body.workKey, "workKey", 300); - const workKind = actionWorkIdentifier(body.workKind, "workKind", 80); - const repo = normalizeRepo(body.repo); - if (!repo) throw badRequest("repo is required"); - await requireRepo(env, repo); - const branch = clean(body.branch, 120) || "main"; - const sourceUrl = optionalHttpUrl(body.sourceUrl, "sourceUrl"); - const runUrl = optionalHttpUrl(body.runUrl, "runUrl"); + const body = await readJson(request); const serviceUser = openClawServiceUser(); - const agentToken = newAgentToken(); - const agentTokenHash = await sha256(agentToken); - const now = Date.now(); const db = database(env); - let existing = await db - .selectFrom("interactive_sessions") - .selectAll() - .where("work_key", "=", workKey) - .executeTakeFirst(); - const purpose = - clean(body.purpose, 500) || - existing?.purpose || - `${workKind.replaceAll("_", " ")} in ${repo}@${branch}`; - const summary = clean(body.summary, 500) || existing?.summary || purpose; - - if (!existing) { - for (let attempt = 0; attempt < 3; attempt += 1) { - const id = await nextInteractiveSessionId(env); - try { - await db - .insertInto("interactive_sessions") - .values({ - id, - parent_session_id: null, - root_session_id: id, - repo, - branch, - runtime: githubActionsRuntime, - adapter: null, - profile: "github-actions", - adapter_workspace_id: null, - adapter_control_plane: null, - provider_resource_id: null, - capabilities_json: JSON.stringify(githubActionsCapabilities), - expires_at: null, - last_reconciled_at: null, - reconcile_error: null, - terminal_status: null, - adapter_ttl_seconds: null, - adapter_idle_timeout_seconds: null, - adapter_requested_capabilities_json: null, - adapter_create_payload_json: null, - adapter_create_pending: 0, - command: "codex", - prompt: purpose, - purpose, - summary, - owner: `github-actions:${id}`, - created_by: "service:openclaw", - status: "ready", - lease_id: null, - attach_url: null, - vnc_url: null, - last_event: "GitHub Actions work registered", - created_at: now, - updated_at: now, - last_seen_at: now, - stopped_at: null, - share_mode: "private", - share_token_hash: null, - share_token_preview: null, - control_requested_by: null, - control_requested_at: null, - controller: null, - control_granted_at: null, - control_expires_at: null, - multiplayer_mode: 0, - agent_token_hash: agentTokenHash, - work_key: workKey, - work_kind: workKind, - work_state: "registered", - work_phase: "waiting_for_runner", - source_url: sourceUrl, - github_run_url: runUrl, - codex_thread_id: null, - codex_turn_id: null, - last_heartbeat_at: null, - completion_reason: null, - }) - .execute(); - existing = await db - .selectFrom("interactive_sessions") - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); - break; - } catch (error) { - if (!isConstraintError(error)) throw error; - existing = await db - .selectFrom("interactive_sessions") - .selectAll() - .where("work_key", "=", workKey) - .executeTakeFirst(); - if (existing) break; - if (attempt === 2) throw error; - } - } - } - - if (!existing) throw new Error("failed to register GitHub Actions session"); - if (existing.runtime !== githubActionsRuntime) { - throw badRequest("workKey is already registered to a different runtime"); - } - const resumed = existing.work_state !== "registered" || existing.status !== "ready"; - await db - .updateTable("interactive_sessions") - .set({ - repo, - branch, - purpose, - summary, - prompt: purpose, - status: "ready", - lease_id: null, - stopped_at: null, - terminal_status: null, - terminal_failure_reason: null, - terminal_finalize_pending: 0, - credential_cleanup_terminal_status: null, - updated_at: now, - last_seen_at: now, - last_event: resumed ? "GitHub Actions work resumed" : "GitHub Actions work registered", - agent_token_hash: agentTokenHash, - work_kind: workKind, - work_state: "registered", - work_phase: "waiting_for_runner", - source_url: body.sourceUrl === undefined ? existing.source_url : sourceUrl, - github_run_url: body.runUrl === undefined ? existing.github_run_url : runUrl, - last_heartbeat_at: null, - completion_reason: null, - }) - .where("id", "=", existing.id) - .execute(); - await disconnectGitHubActionsRunner(env, existing.id).catch(() => undefined); - const message = resumed ? "GitHub Actions work resumed" : "GitHub Actions work registered"; - await appendInteractiveSessionEvent(env, existing.id, serviceUser, message, now); - await audit( - env, - serviceUser, - `openclaw action session ${resumed ? "resumed" : "registered"} ${existing.id} work=${workKey}`, - now, - ); - const session = (await readInteractiveSession(env, existing.id)) as InteractiveSession; + const store: GitHubActionsSessionRegistrationStore = { + now: () => Date.now(), + newAgentToken, + hashToken: sha256, + requireRepo: (repo) => requireRepo(env, repo), + readByWorkKey: async (workKey) => + (await db + .selectFrom("interactive_sessions") + .selectAll() + .where("work_key", "=", workKey) + .executeTakeFirst()) ?? null, + nextSessionId: () => nextInteractiveSessionId(env), + insertSession: async (values) => { + await db.insertInto("interactive_sessions").values(values).execute(); + }, + readById: async (id) => + (await db + .selectFrom("interactive_sessions") + .selectAll() + .where("id", "=", id) + .executeTakeFirst()) ?? null, + updateSession: async (id, values) => { + await db.updateTable("interactive_sessions").set(values).where("id", "=", id).execute(); + }, + isConstraintError, + disconnectRunner: (id) => disconnectGitHubActionsRunner(env, id), + appendEvent: (id, message, now) => + appendInteractiveSessionEvent(env, id, serviceUser, message, now), + audit: (message, now) => audit(env, serviceUser, message, now), + readSession: (id) => readInteractiveSession(env, id), + }; + const result = await new GitHubActionsSessionRegistrationService(store).register(body); return { - session: decorateInteractiveSession(session, serviceUser, env), - agentToken, - runnerPtyUrl: buildGitHubActionsRunnerPtyUrl(appCanonicalOrigin, existing.id, agentToken), - browserUrl: `${browserAppOrigin(env)}/app/sessions/${encodeURIComponent(existing.id)}`, + session: decorateInteractiveSession(result.session, serviceUser, env), + agentToken: result.agentToken, + runnerPtyUrl: buildGitHubActionsRunnerPtyUrl( + appCanonicalOrigin, + result.session.id, + result.agentToken, + ), + browserUrl: browserSessionUrl(env, result.session.id), }; } @@ -2971,29 +2853,6 @@ async function ensureOpenClawServiceBranch( throw new GitHubApiError(response.status); } -function actionWorkIdentifier(value: unknown, name: string, max: number): string { - const identifier = String(value ?? "").trim(); - if (!identifier) throw badRequest(`${name} is required`); - if (identifier.length > max) throw badRequest(`${name} exceeds ${max} characters`); - if (!/^[A-Za-z0-9][A-Za-z0-9_.:/@+#=-]*$/.test(identifier)) { - throw badRequest(`${name} contains unsupported characters`); - } - return identifier; -} - -function optionalHttpUrl(value: unknown, name: string): string | null { - const raw = String(value ?? "").trim(); - if (!raw) return null; - if (raw.length > 1000) throw badRequest(`${name} exceeds 1000 characters`); - try { - const url = new URL(raw); - if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(); - return url.toString(); - } catch { - throw badRequest(`${name} must be an http(s) URL`); - } -} - async function requireSshGatewayUser(request: Request, env: RuntimeEnv): Promise { requireSshGateway(request, env); const fingerprint = sshFingerprint(request); diff --git a/src/worker/deployment.ts b/src/worker/deployment.ts index 2ebbaa2..5fb78eb 100644 --- a/src/worker/deployment.ts +++ b/src/worker/deployment.ts @@ -94,6 +94,10 @@ export function browserAppOrigin(env: DeploymentEnv): string { return trustedProxyPublicOrigin(env) ?? deploymentConfig(env).canonicalUrl; } +export function browserSessionUrl(env: DeploymentEnv, sessionId: string): string { + return `${browserAppOrigin(env)}/app/sessions/${encodeURIComponent(sessionId)}`; +} + function clean(value: unknown, maximum: number): string { return String(value ?? "") .trim() diff --git a/src/worker/github-actions-session-registration.ts b/src/worker/github-actions-session-registration.ts new file mode 100644 index 0000000..eafd623 --- /dev/null +++ b/src/worker/github-actions-session-registration.ts @@ -0,0 +1,286 @@ +import { type Insertable } from "kysely"; + +import { githubActionsCapabilities, githubActionsRuntime } from "../github-actions-runtime.ts"; +import type { InteractiveSessionRow, InteractiveSessionTable } from "./database.ts"; +import { badRequest } from "./http.ts"; +import { normalizeRepo } from "./repositories.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export type GitHubActionsSessionRegistrationInput = { + workKey?: string; + workKind?: string; + repo?: string; + branch?: string; + sourceUrl?: string; + runUrl?: string; + purpose?: string; + summary?: string; +}; + +export type GitHubActionsSessionRegistrationUpdate = { + repo: string; + branch: string; + purpose: string; + summary: string; + prompt: string; + status: "ready"; + lease_id: null; + stopped_at: null; + terminal_status: null; + terminal_failure_reason: null; + terminal_finalize_pending: 0; + credential_cleanup_terminal_status: null; + updated_at: number; + last_seen_at: number; + last_event: string; + agent_token_hash: string; + work_kind: string; + work_state: "registered"; + work_phase: "waiting_for_runner"; + source_url: string | null; + github_run_url: string | null; + last_heartbeat_at: null; + completion_reason: null; +}; + +export type GitHubActionsSessionRegistrationStore = { + now(): number; + newAgentToken(): string; + hashToken(token: string): Promise; + requireRepo(repo: string): Promise; + readByWorkKey(workKey: string): Promise; + nextSessionId(): Promise; + insertSession(values: Insertable): Promise; + readById(id: string): Promise; + updateSession(id: string, values: GitHubActionsSessionRegistrationUpdate): Promise; + isConstraintError(error: unknown): boolean; + disconnectRunner(id: string): Promise; + appendEvent(id: string, message: string, now: number): Promise; + audit(message: string, now: number): Promise; + readSession(id: string): Promise; +}; + +export type GitHubActionsSessionRegistration = { + session: InteractiveSession; + agentToken: string; + resumed: boolean; + workKey: string; +}; + +export class GitHubActionsSessionRegistrationService { + private readonly store: GitHubActionsSessionRegistrationStore; + + constructor(store: GitHubActionsSessionRegistrationStore) { + this.store = store; + } + + async register( + input: GitHubActionsSessionRegistrationInput, + ): Promise { + const workKey = actionWorkIdentifier(input.workKey, "workKey", 300); + const workKind = actionWorkIdentifier(input.workKind, "workKind", 80); + const repo = normalizeRepo(input.repo); + if (!repo) throw badRequest("repo is required"); + await this.store.requireRepo(repo); + + const branch = boundedValue(input.branch, 120) || "main"; + const sourceUrl = optionalHttpUrl(input.sourceUrl, "sourceUrl"); + const runUrl = optionalHttpUrl(input.runUrl, "runUrl"); + const agentToken = this.store.newAgentToken(); + const agentTokenHash = await this.store.hashToken(agentToken); + const now = this.store.now(); + let existing = await this.store.readByWorkKey(workKey); + const purpose = + boundedValue(input.purpose, 500) || + existing?.purpose || + `${workKind.replaceAll("_", " ")} in ${repo}@${branch}`; + const summary = boundedValue(input.summary, 500) || existing?.summary || purpose; + + if (!existing) { + existing = await this.insertOrReadConcurrent({ + workKey, + workKind, + repo, + branch, + sourceUrl, + runUrl, + purpose, + summary, + agentTokenHash, + now, + }); + } + if (!existing) throw new Error("failed to register GitHub Actions session"); + if (existing.runtime !== githubActionsRuntime) { + throw badRequest("workKey is already registered to a different runtime"); + } + + const resumed = existing.work_state !== "registered" || existing.status !== "ready"; + const message = resumed ? "GitHub Actions work resumed" : "GitHub Actions work registered"; + await this.store.updateSession(existing.id, { + repo, + branch, + purpose, + summary, + prompt: purpose, + status: "ready", + lease_id: null, + stopped_at: null, + terminal_status: null, + terminal_failure_reason: null, + terminal_finalize_pending: 0, + credential_cleanup_terminal_status: null, + updated_at: now, + last_seen_at: now, + last_event: message, + agent_token_hash: agentTokenHash, + work_kind: workKind, + work_state: "registered", + work_phase: "waiting_for_runner", + source_url: input.sourceUrl === undefined ? existing.source_url : sourceUrl, + github_run_url: input.runUrl === undefined ? existing.github_run_url : runUrl, + last_heartbeat_at: null, + completion_reason: null, + }); + await this.store.disconnectRunner(existing.id).catch(() => undefined); + await this.store.appendEvent(existing.id, message, now); + await this.store.audit( + `openclaw action session ${resumed ? "resumed" : "registered"} ${existing.id} work=${workKey}`, + now, + ); + const session = await this.store.readSession(existing.id); + if (!session) throw new Error("registered GitHub Actions session is unavailable"); + return { session, agentToken, resumed, workKey }; + } + + private async insertOrReadConcurrent(input: { + workKey: string; + workKind: string; + repo: string; + branch: string; + sourceUrl: string | null; + runUrl: string | null; + purpose: string; + summary: string; + agentTokenHash: string; + now: number; + }): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + const id = await this.store.nextSessionId(); + try { + await this.store.insertSession(buildGitHubActionsSessionValues({ id, ...input })); + return await this.store.readById(id); + } catch (error) { + if (!this.store.isConstraintError(error)) throw error; + const concurrent = await this.store.readByWorkKey(input.workKey); + if (concurrent) return concurrent; + if (attempt === 2) throw error; + } + } + return null; + } +} + +export function buildGitHubActionsSessionValues(input: { + id: string; + workKey: string; + workKind: string; + repo: string; + branch: string; + sourceUrl: string | null; + runUrl: string | null; + purpose: string; + summary: string; + agentTokenHash: string; + now: number; +}): Insertable { + return { + id: input.id, + parent_session_id: null, + root_session_id: input.id, + repo: input.repo, + branch: input.branch, + runtime: githubActionsRuntime, + adapter: null, + profile: "github-actions", + adapter_workspace_id: null, + adapter_control_plane: null, + provider_resource_id: null, + capabilities_json: JSON.stringify(githubActionsCapabilities), + expires_at: null, + last_reconciled_at: null, + reconcile_error: null, + terminal_status: null, + adapter_ttl_seconds: null, + adapter_idle_timeout_seconds: null, + adapter_requested_capabilities_json: null, + adapter_create_payload_json: null, + adapter_create_pending: 0, + command: "codex", + prompt: input.purpose, + purpose: input.purpose, + summary: input.summary, + owner: `github-actions:${input.id}`, + created_by: "service:openclaw", + status: "ready", + lease_id: null, + attach_url: null, + vnc_url: null, + last_event: "GitHub Actions work registered", + created_at: input.now, + updated_at: input.now, + last_seen_at: input.now, + stopped_at: null, + share_mode: "private", + share_token_hash: null, + share_token_preview: null, + control_requested_by: null, + control_requested_at: null, + controller: null, + control_granted_at: null, + control_expires_at: null, + multiplayer_mode: 0, + agent_token_hash: input.agentTokenHash, + work_key: input.workKey, + work_kind: input.workKind, + work_state: "registered", + work_phase: "waiting_for_runner", + source_url: input.sourceUrl, + github_run_url: input.runUrl, + codex_thread_id: null, + codex_turn_id: null, + last_heartbeat_at: null, + completion_reason: null, + }; +} + +export function actionWorkIdentifier(value: unknown, name: string, maximum: number): string { + const identifier = String(value ?? "").trim(); + if (!identifier) throw badRequest(`${name} is required`); + if (identifier.length > maximum) { + throw badRequest(`${name} exceeds ${maximum} characters`); + } + if (!/^[A-Za-z0-9][A-Za-z0-9_.:/@+#=-]*$/.test(identifier)) { + throw badRequest(`${name} contains unsupported characters`); + } + return identifier; +} + +export function optionalHttpUrl(value: unknown, name: string): string | null { + const raw = String(value ?? "").trim(); + if (!raw) return null; + if (raw.length > 1000) throw badRequest(`${name} exceeds 1000 characters`); + try { + const url = new URL(raw); + if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(); + return url.toString(); + } catch { + throw badRequest(`${name} must be an http(s) URL`); + } +} + +function boundedValue(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/deployment.test.ts b/tests/deployment.test.ts index de008dd..4b88c50 100644 --- a/tests/deployment.test.ts +++ b/tests/deployment.test.ts @@ -3,6 +3,7 @@ import { test } from "node:test"; import { browserAppOrigin, + browserSessionUrl, clientDeploymentConfig, deploymentConfig, publicDeploymentConfig, @@ -88,6 +89,10 @@ test("public and client deployment views exclude server-only routing data", () = sshHost: "crabd.sh", }); assert.equal(browserAppOrigin(env), "https://fleet.example"); + assert.equal( + browserSessionUrl(env, "IS/with spaces"), + "https://fleet.example/app/sessions/IS%2Fwith%20spaces", + ); const client = clientDeploymentConfig(env); assert.equal(client.preferredRepo, "openclaw/crabfleet"); diff --git a/tests/github-actions-session-registration.test.ts b/tests/github-actions-session-registration.test.ts new file mode 100644 index 0000000..a6ce0ef --- /dev/null +++ b/tests/github-actions-session-registration.test.ts @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { githubActionsCapabilities } from "../src/github-actions-runtime.ts"; +import type { InteractiveSessionRow, InteractiveSessionTable } from "../src/worker/database.ts"; +import { + GitHubActionsSessionRegistrationService, + actionWorkIdentifier, + buildGitHubActionsSessionValues, + optionalHttpUrl, + type GitHubActionsSessionRegistrationStore, + type GitHubActionsSessionRegistrationUpdate, +} from "../src/worker/github-actions-session-registration.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +type StoreState = { + rows: Map; + workKeyReads: number; + inserted: InteractiveSessionTable[]; + updates: Array<{ id: string; values: GitHubActionsSessionRegistrationUpdate }>; + events: string[]; + audits: string[]; + operations: string[]; + insertError: unknown; + disconnectError: unknown; + concurrentRow: InteractiveSessionRow | null; +}; + +function registrationStore(initialRows: InteractiveSessionRow[] = []): { + store: GitHubActionsSessionRegistrationStore; + state: StoreState; +} { + const state: StoreState = { + rows: new Map(initialRows.map((row) => [row.id, row])), + workKeyReads: 0, + inserted: [], + updates: [], + events: [], + audits: [], + operations: [], + insertError: null, + disconnectError: null, + concurrentRow: null, + }; + const store: GitHubActionsSessionRegistrationStore = { + now: () => 100, + newAgentToken: () => "agent-token", + hashToken: async () => "agent-token-hash", + requireRepo: async (repo) => { + state.operations.push(`repo:${repo}`); + }, + readByWorkKey: async (workKey) => { + state.workKeyReads += 1; + if (state.concurrentRow && state.workKeyReads > 1) { + state.rows.set(state.concurrentRow.id, state.concurrentRow); + return state.concurrentRow; + } + return [...state.rows.values()].find((row) => row.work_key === workKey) ?? null; + }, + nextSessionId: async () => `IS-${state.inserted.length + 101}`, + insertSession: async (values) => { + state.operations.push("insert"); + if (state.insertError) throw state.insertError; + state.inserted.push(values as InteractiveSessionTable); + const row = sessionRow(values as Partial); + state.rows.set(row.id, row); + }, + readById: async (id) => state.rows.get(id) ?? null, + updateSession: async (id, values) => { + state.operations.push("update"); + state.updates.push({ id, values }); + const row = state.rows.get(id); + if (row) state.rows.set(id, { ...row, ...values }); + }, + isConstraintError: (error) => error === state.insertError, + disconnectRunner: async () => { + state.operations.push("disconnect"); + if (state.disconnectError) throw state.disconnectError; + }, + appendEvent: async (_id, message) => { + state.operations.push("event"); + state.events.push(message); + }, + audit: async (message) => { + state.operations.push("audit"); + state.audits.push(message); + }, + readSession: async (id) => { + state.operations.push("read"); + const row = state.rows.get(id); + return row ? interactiveSession(row, []) : null; + }, + }; + return { store, state }; +} + +test("GitHub Actions registration validators accept only bounded canonical input", () => { + assert.equal(actionWorkIdentifier(" issue:123 ", "workKey", 20), "issue:123"); + assert.equal(optionalHttpUrl("https://example.test/run", "runUrl"), "https://example.test/run"); + assert.equal(optionalHttpUrl("", "runUrl"), null); + assert.throws(() => actionWorkIdentifier("", "workKey", 20), { + message: "workKey is required", + }); + assert.throws(() => actionWorkIdentifier("invalid value", "workKey", 20), { + message: "workKey contains unsupported characters", + }); + assert.throws(() => optionalHttpUrl("file:///tmp/run", "runUrl"), { + message: "runUrl must be an http(s) URL", + }); +}); + +test("GitHub Actions rows centralize session defaults and scoped ownership", () => { + const values = buildGitHubActionsSessionValues({ + id: "IS-123", + workKey: "issue:123", + workKind: "issue", + repo: "openclaw/crabfleet", + branch: "main", + sourceUrl: "https://example.test/issues/123", + runUrl: null, + purpose: "fix issue", + summary: "starting", + agentTokenHash: "agent-hash", + now: 100, + }); + assert.equal(values.runtime, "github_actions"); + assert.equal(values.owner, "github-actions:IS-123"); + assert.equal(values.root_session_id, "IS-123"); + assert.equal(values.agent_token_hash, "agent-hash"); + assert.equal(values.work_state, "registered"); + assert.equal(values.work_phase, "waiting_for_runner"); + assert.deepEqual(JSON.parse(values.capabilities_json), githubActionsCapabilities); +}); + +test("new GitHub Actions work registers, rotates credentials, and records evidence", async () => { + const { store, state } = registrationStore(); + const result = await new GitHubActionsSessionRegistrationService(store).register({ + workKey: "issue:123", + workKind: "issue_fix", + repo: "OpenClaw/CrabFleet", + sourceUrl: "https://example.test/issues/123", + }); + + assert.equal(result.session.id, "IS-101"); + assert.equal(result.agentToken, "agent-token"); + assert.equal(result.resumed, false); + assert.equal(state.inserted[0]?.repo, "openclaw/crabfleet"); + assert.equal(state.inserted[0]?.purpose, "issue fix in openclaw/crabfleet@main"); + assert.equal(state.updates[0]?.values.agent_token_hash, "agent-token-hash"); + assert.deepEqual(state.events, ["GitHub Actions work registered"]); + assert.deepEqual(state.audits, ["openclaw action session registered IS-101 work=issue:123"]); + assert.deepEqual(state.operations, [ + "repo:openclaw/crabfleet", + "insert", + "update", + "disconnect", + "event", + "audit", + "read", + ]); +}); + +test("resumed work preserves omitted links and resets terminal state", async () => { + const existing = sessionRow({ + id: "IS-200", + runtime: "github_actions", + work_key: "pull:200", + work_kind: "pull_request", + work_state: "completed", + status: "stopped", + source_url: "https://example.test/pull/200", + github_run_url: "https://example.test/run/1", + purpose: "existing purpose", + summary: "existing summary", + }); + const { store, state } = registrationStore([existing]); + const result = await new GitHubActionsSessionRegistrationService(store).register({ + workKey: "pull:200", + workKind: "pull_request", + repo: "openclaw/crabfleet", + }); + + assert.equal(result.resumed, true); + assert.equal(state.inserted.length, 0); + assert.equal(state.updates[0]?.values.source_url, existing.source_url); + assert.equal(state.updates[0]?.values.github_run_url, existing.github_run_url); + assert.equal(state.updates[0]?.values.status, "ready"); + assert.equal(state.updates[0]?.values.work_state, "registered"); + assert.equal(state.updates[0]?.values.terminal_finalize_pending, 0); + assert.deepEqual(state.events, ["GitHub Actions work resumed"]); +}); + +test("stale runner disconnect failures do not suppress registration evidence", async () => { + const { store, state } = registrationStore(); + state.disconnectError = new Error("runner already gone"); + + await new GitHubActionsSessionRegistrationService(store).register({ + workKey: "issue:disconnect-race", + workKind: "issue", + repo: "openclaw/crabfleet", + }); + + assert.deepEqual(state.events, ["GitHub Actions work registered"]); + assert.equal(state.audits.length, 1); + assert.deepEqual(state.operations.slice(-4), ["disconnect", "event", "audit", "read"]); +}); + +test("registration adopts a concurrently inserted work key", async () => { + const { store, state } = registrationStore(); + const constraint = new Error("unique constraint"); + state.insertError = constraint; + state.concurrentRow = sessionRow({ + id: "IS-concurrent", + runtime: "github_actions", + work_key: "issue:race", + }); + + const result = await new GitHubActionsSessionRegistrationService(store).register({ + workKey: "issue:race", + workKind: "issue", + repo: "openclaw/crabfleet", + }); + + assert.equal(result.session.id, "IS-concurrent"); + assert.equal(state.workKeyReads, 2); + assert.equal(state.updates[0]?.id, "IS-concurrent"); +}); + +test("registration rejects invalid input and work keys owned by another runtime", async () => { + const { store } = registrationStore([ + sessionRow({ + id: "IS-container", + runtime: "container", + work_key: "issue:container", + }), + ]); + const service = new GitHubActionsSessionRegistrationService(store); + + await assert.rejects( + () => + service.register({ + workKey: "issue:container", + workKind: "issue", + repo: "openclaw/crabfleet", + }), + { message: "workKey is already registered to a different runtime" }, + ); + await assert.rejects( + () => service.register({ workKey: "bad key", workKind: "issue", repo: "openclaw/crabfleet" }), + { message: "workKey contains unsupported characters" }, + ); +}); diff --git a/tests/trusted-proxy-integration.test.ts b/tests/trusted-proxy-integration.test.ts index a0ff42d..2bd6039 100644 --- a/tests/trusted-proxy-integration.test.ts +++ b/tests/trusted-proxy-integration.test.ts @@ -41,10 +41,6 @@ test("split-origin links use the browser-visible proxy origin", async () => { source, /browserVncUrl: \(sessionId\) => runtimeAdapterBrowserVncUrl\(browserAppOrigin\(env\), sessionId\)/, ); - assert.match( - source, - /browserUrl: `\$\{browserAppOrigin\(env\)\}\/app\/sessions\/\$\{encodeURIComponent\(existing\.id\)\}`/, - ); assert.match( source, /new URL\(githubOAuthRedirectUri\(request\.url, env\.GITHUB_REDIRECT_URI\)\)\.origin/, From 2bfaea1f2279e52a5953898e00514da55b5fae87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:37:28 +0100 Subject: [PATCH 045/109] refactor: extract GitHub Actions work state --- CHANGELOG.md | 1 + src/index.ts | 107 +++-------- .../github-actions-session-work-state.ts | 128 +++++++++++++ .../github-actions-session-work-state.test.ts | 173 ++++++++++++++++++ tests/runtime-adapter.test.ts | 2 +- 5 files changed, 331 insertions(+), 80 deletions(-) create mode 100644 src/worker/github-actions-session-work-state.ts create mode 100644 tests/github-actions-session-work-state.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 335adb9..5bd886c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Replace duplicate bounded-concurrency loops with one directly tested worker utility. - Extract agent-session authentication and remove the legacy `X-Crabbox-Session-ID` alias. - Extract GitHub Actions session validation, idempotent registration, token rotation, resume reset, runner replacement, and evidence ordering. +- Extract GitHub Actions work-state projection, heartbeat persistence, event suppression, terminal mapping, runner disconnect, and reread. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 541dda7..36b40b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,13 +35,9 @@ import { import { buildGitHubActionsRunnerPtyUrl, forwardGitHubActionsRelayMessage, - gitHubActionsSessionStatus, - gitHubActionsWorkEvent, githubActionsRelayRole, githubActionsRuntime, - isTerminalGitHubActionsWorkState, notifyGitHubActionsViewers, - parseGitHubActionsWorkState, replaceGitHubActionsRunner, } from "./github-actions-runtime"; import { githubRequestCanUseRepoCredential, matchesAnyHost } from "./sandbox-security"; @@ -202,6 +198,11 @@ import { type GitHubActionsSessionRegistrationInput, type GitHubActionsSessionRegistrationStore, } from "./worker/github-actions-session-registration"; +import { + GitHubActionsWorkStateService, + type GitHubActionsWorkStateInput, + type GitHubActionsWorkStateStore, +} from "./worker/github-actions-session-work-state"; import { containerCapabilities, crabboxCapabilities, @@ -11793,82 +11794,30 @@ async function updateGitHubActionsWorkState( id: string, ): Promise<{ session: InteractiveSession }> { const { session, user } = await agentSessionAuthentication(env).require(request, id); - if (session.runtime !== githubActionsRuntime || !session.workKey) { - throw badRequest("session is not a GitHub Actions work session"); - } - const body = await readJson<{ - state?: string; - phase?: string; - summary?: string; - codexThreadId?: string | null; - codexTurnId?: string | null; - completionReason?: string | null; - }>(request); - const state = parseGitHubActionsWorkState(body.state); - if (!state) throw badRequest("invalid work state"); - const row = await database(env) - .selectFrom("interactive_sessions") - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); - if (!row) throw notFound("interactive session not found"); - const phase = body.phase === undefined ? row.work_phase : clean(body.phase, 160); - const summary = body.summary === undefined ? row.summary : clean(body.summary, 500); - const codexThreadId = - body.codexThreadId === undefined ? row.codex_thread_id : clean(body.codexThreadId, 240) || null; - const codexTurnId = - body.codexTurnId === undefined ? row.codex_turn_id : clean(body.codexTurnId, 240) || null; - const completionReason = - body.completionReason === undefined - ? isTerminalGitHubActionsWorkState(state) - ? row.completion_reason - : null - : clean(body.completionReason, 500) || null; - const terminal = isTerminalGitHubActionsWorkState(state); - const status = terminal - ? gitHubActionsSessionStatus(state) - : ["ready", "attached", "detached"].includes(row.status) - ? row.status - : "ready"; - const lastEvent = gitHubActionsWorkEvent(state, phase); - const changed = - row.work_state !== state || - row.work_phase !== phase || - row.summary !== summary || - row.codex_thread_id !== codexThreadId || - row.codex_turn_id !== codexTurnId || - row.completion_reason !== completionReason; - const now = Date.now(); - await database(env) - .updateTable("interactive_sessions") - .set({ - status, - summary, - work_state: state, - work_phase: phase, - codex_thread_id: codexThreadId, - codex_turn_id: codexTurnId, - last_heartbeat_at: now, - completion_reason: completionReason, - last_event: lastEvent, - last_seen_at: now, - updated_at: now, - stopped_at: terminal ? now : null, - }) - .where("id", "=", id) - .execute(); - if (changed) { - await appendInteractiveSessionEvent(env, id, user, lastEvent, now); - } - if (terminal) { - await disconnectGitHubActionsRunner(env, id).catch(() => undefined); - } + const body = await readJson(request); + const store: GitHubActionsWorkStateStore = { + now: () => Date.now(), + readRow: async (sessionId) => + (await database(env) + .selectFrom("interactive_sessions") + .selectAll() + .where("id", "=", sessionId) + .executeTakeFirst()) ?? null, + persist: async (sessionId, values) => { + await database(env) + .updateTable("interactive_sessions") + .set(values) + .where("id", "=", sessionId) + .execute(); + }, + appendEvent: (sessionId, message, now) => + appendInteractiveSessionEvent(env, sessionId, user, message, now), + disconnectRunner: (sessionId) => disconnectGitHubActionsRunner(env, sessionId), + readSession: (sessionId) => readInteractiveSession(env, sessionId), + }; + const current = await new GitHubActionsWorkStateService(store).update(session, body); return { - session: decorateInteractiveSession( - (await readInteractiveSession(env, id)) as InteractiveSession, - user, - env, - ), + session: decorateInteractiveSession(current, user, env), }; } diff --git a/src/worker/github-actions-session-work-state.ts b/src/worker/github-actions-session-work-state.ts new file mode 100644 index 0000000..a19f0a1 --- /dev/null +++ b/src/worker/github-actions-session-work-state.ts @@ -0,0 +1,128 @@ +import { + gitHubActionsSessionStatus, + gitHubActionsWorkEvent, + githubActionsRuntime, + isTerminalGitHubActionsWorkState, + parseGitHubActionsWorkState, + type GitHubActionsWorkState, +} from "../github-actions-runtime.ts"; +import type { InteractiveSessionRow } from "./database.ts"; +import { badRequest, notFound } from "./http.ts"; +import type { InteractiveSessionStatus } from "./models.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export type GitHubActionsWorkStateInput = { + state?: string; + phase?: string; + summary?: string; + codexThreadId?: string | null; + codexTurnId?: string | null; + completionReason?: string | null; +}; + +export type GitHubActionsWorkStateUpdate = { + status: InteractiveSessionStatus; + summary: string; + work_state: GitHubActionsWorkState; + work_phase: string; + codex_thread_id: string | null; + codex_turn_id: string | null; + last_heartbeat_at: number; + completion_reason: string | null; + last_event: string; + last_seen_at: number; + updated_at: number; + stopped_at: number | null; +}; + +export type GitHubActionsWorkStateStore = { + now(): number; + readRow(id: string): Promise; + persist(id: string, values: GitHubActionsWorkStateUpdate): Promise; + appendEvent(id: string, message: string, now: number): Promise; + disconnectRunner(id: string): Promise; + readSession(id: string): Promise; +}; + +export class GitHubActionsWorkStateService { + private readonly store: GitHubActionsWorkStateStore; + + constructor(store: GitHubActionsWorkStateStore) { + this.store = store; + } + + async update( + session: InteractiveSession, + input: GitHubActionsWorkStateInput, + ): Promise { + if (session.runtime !== githubActionsRuntime || !session.workKey) { + throw badRequest("session is not a GitHub Actions work session"); + } + const state = parseGitHubActionsWorkState(input.state); + if (!state) throw badRequest("invalid work state"); + + const row = await this.store.readRow(session.id); + if (!row) throw notFound("interactive session not found"); + const phase = input.phase === undefined ? row.work_phase : boundedValue(input.phase, 160); + const summary = input.summary === undefined ? row.summary : boundedValue(input.summary, 500); + const codexThreadId = + input.codexThreadId === undefined + ? row.codex_thread_id + : boundedValue(input.codexThreadId, 240) || null; + const codexTurnId = + input.codexTurnId === undefined + ? row.codex_turn_id + : boundedValue(input.codexTurnId, 240) || null; + const terminal = isTerminalGitHubActionsWorkState(state); + const completionReason = + input.completionReason === undefined + ? terminal + ? row.completion_reason + : null + : boundedValue(input.completionReason, 500) || null; + const status = terminal ? gitHubActionsSessionStatus(state) : activeSessionStatus(row.status); + const lastEvent = gitHubActionsWorkEvent(state, phase); + const changed = + row.work_state !== state || + row.work_phase !== phase || + row.summary !== summary || + row.codex_thread_id !== codexThreadId || + row.codex_turn_id !== codexTurnId || + row.completion_reason !== completionReason; + const now = this.store.now(); + + await this.store.persist(session.id, { + status, + summary, + work_state: state, + work_phase: phase, + codex_thread_id: codexThreadId, + codex_turn_id: codexTurnId, + last_heartbeat_at: now, + completion_reason: completionReason, + last_event: lastEvent, + last_seen_at: now, + updated_at: now, + stopped_at: terminal ? now : null, + }); + if (changed) { + await this.store.appendEvent(session.id, lastEvent, now); + } + if (terminal) { + await this.store.disconnectRunner(session.id).catch(() => undefined); + } + const current = await this.store.readSession(session.id); + if (!current) throw notFound("interactive session not found"); + return current; + } +} + +function activeSessionStatus(status: InteractiveSessionStatus): InteractiveSessionStatus { + return status === "ready" || status === "attached" || status === "detached" ? status : "ready"; +} + +function boundedValue(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/github-actions-session-work-state.test.ts b/tests/github-actions-session-work-state.test.ts new file mode 100644 index 0000000..11a69d8 --- /dev/null +++ b/tests/github-actions-session-work-state.test.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { InteractiveSessionRow } from "../src/worker/database.ts"; +import { + GitHubActionsWorkStateService, + type GitHubActionsWorkStateStore, + type GitHubActionsWorkStateUpdate, +} from "../src/worker/github-actions-session-work-state.ts"; +import { interactiveSession, type InteractiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +type WorkStateStoreState = { + row: InteractiveSessionRow | null; + update: GitHubActionsWorkStateUpdate | null; + events: string[]; + operations: string[]; + disconnectError: unknown; +}; + +function workSession(values: Partial = {}): InteractiveSession { + return interactiveSession( + sessionRow({ + id: "IS-work", + runtime: "github_actions", + work_key: "issue:123", + work_kind: "issue", + work_state: "registered", + work_phase: "waiting_for_runner", + ...values, + }), + [], + ); +} + +function workStateStore(values: Partial = {}): { + store: GitHubActionsWorkStateStore; + state: WorkStateStoreState; +} { + const state: WorkStateStoreState = { + row: sessionRow({ + id: "IS-work", + runtime: "github_actions", + work_key: "issue:123", + work_kind: "issue", + work_state: "registered", + work_phase: "waiting_for_runner", + ...values, + }), + update: null, + events: [], + operations: [], + disconnectError: null, + }; + const store: GitHubActionsWorkStateStore = { + now: () => 500, + readRow: async () => state.row, + persist: async (_id, update) => { + state.operations.push("persist"); + state.update = update; + if (state.row) state.row = { ...state.row, ...update }; + }, + appendEvent: async (_id, message) => { + state.operations.push("event"); + state.events.push(message); + }, + disconnectRunner: async () => { + state.operations.push("disconnect"); + if (state.disconnectError) throw state.disconnectError; + }, + readSession: async () => { + state.operations.push("read"); + return state.row ? interactiveSession(state.row, []) : null; + }, + }; + return { store, state }; +} + +test("active work-state updates project fields and clear stale completion", async () => { + const { store, state } = workStateStore({ + status: "provisioning", + completion_reason: "stale terminal reason", + }); + const result = await new GitHubActionsWorkStateService(store).update(workSession(), { + state: "running", + phase: "codex_turn", + summary: " working ", + codexThreadId: " thread-1 ", + codexTurnId: "", + }); + + assert.equal(result.status, "ready"); + assert.deepEqual(state.update, { + status: "ready", + summary: "working", + work_state: "running", + work_phase: "codex_turn", + codex_thread_id: "thread-1", + codex_turn_id: null, + last_heartbeat_at: 500, + completion_reason: null, + last_event: "running: codex_turn", + last_seen_at: 500, + updated_at: 500, + stopped_at: null, + }); + assert.deepEqual(state.events, ["running: codex_turn"]); + assert.deepEqual(state.operations, ["persist", "event", "read"]); +}); + +test("unchanged work-state heartbeats persist without duplicate events", async () => { + const { store, state } = workStateStore({ + status: "attached", + work_state: "running", + work_phase: "codex_turn", + summary: "working", + }); + await new GitHubActionsWorkStateService(store).update(workSession(), { + state: "running", + phase: "codex_turn", + summary: "working", + }); + + assert.equal(state.update?.status, "attached"); + assert.deepEqual(state.events, []); + assert.deepEqual(state.operations, ["persist", "read"]); +}); + +test("terminal work-state updates stop the session and disconnect the runner", async () => { + const { store, state } = workStateStore({ + work_state: "running", + work_phase: "tests", + completion_reason: "existing reason", + }); + const result = await new GitHubActionsWorkStateService(store).update(workSession(), { + state: "failed", + }); + + assert.equal(result.status, "failed"); + assert.equal(state.update?.completion_reason, "existing reason"); + assert.equal(state.update?.stopped_at, 500); + assert.deepEqual(state.events, ["failed: tests"]); + assert.deepEqual(state.operations, ["persist", "event", "disconnect", "read"]); +}); + +test("terminal runner disconnect races remain best effort", async () => { + const { store, state } = workStateStore(); + state.disconnectError = new Error("runner already disconnected"); + await assert.doesNotReject(() => + new GitHubActionsWorkStateService(store).update(workSession(), { + state: "completed", + completionReason: "done", + }), + ); + assert.equal(state.update?.completion_reason, "done"); + assert.deepEqual(state.operations, ["persist", "event", "disconnect", "read"]); +}); + +test("work-state updates reject invalid sessions, states, and missing rows", async () => { + const { store, state } = workStateStore(); + const service = new GitHubActionsWorkStateService(store); + await assert.rejects( + () => service.update(workSession({ runtime: "container" }), { state: "running" }), + { message: "session is not a GitHub Actions work session" }, + ); + await assert.rejects(() => service.update(workSession(), { state: "unknown" }), { + message: "invalid work state", + }); + state.row = null; + await assert.rejects(() => service.update(workSession(), { state: "running" }), { + message: "interactive session not found", + }); +}); diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 024876f..9644226 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -751,7 +751,7 @@ test("session events and terminal finalization preserve archive anchors", async test("summary events update metadata and refresh archive snapshots", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const summaryStart = source.indexOf("async function updateInteractiveSessionSummary"); - const summaryEnd = source.indexOf("async function updateGitHubActionsWorkState", summaryStart); + const summaryEnd = source.indexOf("async function githubActionsRunnerPty", summaryStart); const summarySource = source.slice(summaryStart, summaryEnd); const metadataStart = source.indexOf( "async function mutateInteractiveSessionWithEventAtomically", From a0b0964aa1bbccd715cc2c227700dcc23702b030 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:41:05 +0100 Subject: [PATCH 046/109] refactor: extract GitHub Actions runner connection --- CHANGELOG.md | 1 + src/index.ts | 41 ++++---- .../github-actions-runner-connection.ts | 55 +++++++++++ .../github-actions-runner-connection.test.ts | 99 +++++++++++++++++++ 4 files changed, 172 insertions(+), 24 deletions(-) create mode 100644 src/worker/github-actions-runner-connection.ts create mode 100644 tests/github-actions-runner-connection.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd886c..a8eddec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ - Extract agent-session authentication and remove the legacy `X-Crabbox-Session-ID` alias. - Extract GitHub Actions session validation, idempotent registration, token rotation, resume reset, runner replacement, and evidence ordering. - Extract GitHub Actions work-state projection, heartbeat persistence, event suppression, terminal mapping, runner disconnect, and reread. +- Extract GitHub Actions runner-connect validation, lifecycle projection, heartbeat persistence, and durable connection evidence. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 36b40b3..4dd7020 100644 --- a/src/index.ts +++ b/src/index.ts @@ -198,6 +198,10 @@ import { type GitHubActionsSessionRegistrationInput, type GitHubActionsSessionRegistrationStore, } from "./worker/github-actions-session-registration"; +import { + GitHubActionsRunnerConnectionService, + type GitHubActionsRunnerConnectionStore, +} from "./worker/github-actions-runner-connection"; import { GitHubActionsWorkStateService, type GitHubActionsWorkStateInput, @@ -11832,32 +11836,21 @@ async function githubActionsRunnerPty( const { session, user } = await agentSessionAuthentication(env).require(request, id, { allowQueryToken: true, }); - if (session.runtime !== githubActionsRuntime || !session.workKey) { - throw badRequest("session is not a GitHub Actions work session"); - } const stub = githubActionsRelayStub(env, id); if (!stub) throw serviceUnavailable("SESSION_CONTROL Durable Object is not configured"); - const now = Date.now(); - const state = - session.workState === "registered" || !session.workState ? "running" : session.workState; - const phase = - !session.workPhase || session.workPhase === "waiting_for_runner" - ? "runner_connected" - : session.workPhase; - await database(env) - .updateTable("interactive_sessions") - .set({ - status: ["attached", "detached"].includes(session.status) ? session.status : "ready", - work_state: state, - work_phase: phase, - last_heartbeat_at: now, - last_seen_at: now, - updated_at: now, - last_event: "GitHub Actions runner connected", - }) - .where("id", "=", id) - .execute(); - await appendInteractiveSessionEvent(env, id, user, "GitHub Actions runner connected", now); + const store: GitHubActionsRunnerConnectionStore = { + now: () => Date.now(), + persist: async (sessionId, values) => { + await database(env) + .updateTable("interactive_sessions") + .set(values) + .where("id", "=", sessionId) + .execute(); + }, + appendEvent: (sessionId, message, now) => + appendInteractiveSessionEvent(env, sessionId, user, message, now), + }; + await new GitHubActionsRunnerConnectionService(store).connect(session); return stub.fetch("https://crabfleet.internal/api/session-control/github-actions/runner", { headers: { upgrade: "websocket" }, }); diff --git a/src/worker/github-actions-runner-connection.ts b/src/worker/github-actions-runner-connection.ts new file mode 100644 index 0000000..ca9cdf5 --- /dev/null +++ b/src/worker/github-actions-runner-connection.ts @@ -0,0 +1,55 @@ +import { githubActionsRuntime, type GitHubActionsWorkState } from "../github-actions-runtime.ts"; +import { badRequest } from "./http.ts"; +import type { InteractiveSessionStatus } from "./models.ts"; +import type { InteractiveSession } from "./session-model.ts"; + +export const githubActionsRunnerConnectedEvent = "GitHub Actions runner connected"; + +export type GitHubActionsRunnerConnectionUpdate = { + status: InteractiveSessionStatus; + work_state: GitHubActionsWorkState; + work_phase: string; + last_heartbeat_at: number; + last_seen_at: number; + updated_at: number; + last_event: typeof githubActionsRunnerConnectedEvent; +}; + +export type GitHubActionsRunnerConnectionStore = { + now(): number; + persist(id: string, values: GitHubActionsRunnerConnectionUpdate): Promise; + appendEvent(id: string, message: string, now: number): Promise; +}; + +export class GitHubActionsRunnerConnectionService { + private readonly store: GitHubActionsRunnerConnectionStore; + + constructor(store: GitHubActionsRunnerConnectionStore) { + this.store = store; + } + + async connect(session: InteractiveSession): Promise { + if (session.runtime !== githubActionsRuntime || !session.workKey) { + throw badRequest("session is not a GitHub Actions work session"); + } + const now = this.store.now(); + const state = + session.workState === "registered" || !session.workState ? "running" : session.workState; + const phase = + !session.workPhase || session.workPhase === "waiting_for_runner" + ? "runner_connected" + : session.workPhase; + const status = + session.status === "attached" || session.status === "detached" ? session.status : "ready"; + await this.store.persist(session.id, { + status, + work_state: state, + work_phase: phase, + last_heartbeat_at: now, + last_seen_at: now, + updated_at: now, + last_event: githubActionsRunnerConnectedEvent, + }); + await this.store.appendEvent(session.id, githubActionsRunnerConnectedEvent, now); + } +} diff --git a/tests/github-actions-runner-connection.test.ts b/tests/github-actions-runner-connection.test.ts new file mode 100644 index 0000000..b207e9e --- /dev/null +++ b/tests/github-actions-runner-connection.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + GitHubActionsRunnerConnectionService, + githubActionsRunnerConnectedEvent, + type GitHubActionsRunnerConnectionStore, + type GitHubActionsRunnerConnectionUpdate, +} from "../src/worker/github-actions-runner-connection.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function session(values: Parameters[0] = {}) { + return interactiveSession( + sessionRow({ + id: "IS-runner", + runtime: "github_actions", + work_key: "issue:123", + work_state: "registered", + work_phase: "waiting_for_runner", + ...values, + }), + [], + ); +} + +function connectionStore(): { + store: GitHubActionsRunnerConnectionStore; + updates: GitHubActionsRunnerConnectionUpdate[]; + events: string[]; + operations: string[]; +} { + const updates: GitHubActionsRunnerConnectionUpdate[] = []; + const events: string[] = []; + const operations: string[] = []; + return { + updates, + events, + operations, + store: { + now: () => 700, + persist: async (_id, values) => { + operations.push("persist"); + updates.push(values); + }, + appendEvent: async (_id, message) => { + operations.push("event"); + events.push(message); + }, + }, + }; +} + +test("waiting runners become active with durable connection evidence", async () => { + const { store, updates, events, operations } = connectionStore(); + await new GitHubActionsRunnerConnectionService(store).connect( + session({ status: "provisioning" }), + ); + + assert.deepEqual(updates, [ + { + status: "ready", + work_state: "running", + work_phase: "runner_connected", + last_heartbeat_at: 700, + last_seen_at: 700, + updated_at: 700, + last_event: githubActionsRunnerConnectedEvent, + }, + ]); + assert.deepEqual(events, [githubActionsRunnerConnectedEvent]); + assert.deepEqual(operations, ["persist", "event"]); +}); + +test("reconnecting runners preserve active status, state, and phase", async () => { + const { store, updates } = connectionStore(); + await new GitHubActionsRunnerConnectionService(store).connect( + session({ + status: "attached", + work_state: "running", + work_phase: "codex_turn", + }), + ); + + assert.equal(updates[0]?.status, "attached"); + assert.equal(updates[0]?.work_state, "running"); + assert.equal(updates[0]?.work_phase, "codex_turn"); +}); + +test("runner connections reject non-work sessions", async () => { + const { store } = connectionStore(); + const service = new GitHubActionsRunnerConnectionService(store); + await assert.rejects(() => service.connect(session({ runtime: "container" })), { + message: "session is not a GitHub Actions work session", + }); + await assert.rejects(() => service.connect(session({ work_key: null })), { + message: "session is not a GitHub Actions work session", + }); +}); From cb9a5dbbb4c720184edd8a2fa94979c8f834679b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:50:15 +0100 Subject: [PATCH 047/109] refactor: extract terminal route policy --- CHANGELOG.md | 1 + src/index.ts | 191 +----------------------- src/worker/runtime-adapter-preflight.ts | 19 +++ src/worker/session-terminal-route.ts | 177 ++++++++++++++++++++++ tests/runtime-adapter.test.ts | 51 ++++--- tests/session-terminal-route.test.ts | 148 ++++++++++++++++++ 6 files changed, 377 insertions(+), 210 deletions(-) create mode 100644 src/worker/session-terminal-route.ts create mode 100644 tests/session-terminal-route.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a8eddec..da937f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - Extract GitHub Actions session validation, idempotent registration, token rotation, resume reset, runner replacement, and evidence ordering. - Extract GitHub Actions work-state projection, heartbeat persistence, event suppression, terminal mapping, runner disconnect, and reread. - Extract GitHub Actions runner-connect validation, lifecycle projection, heartbeat persistence, and durable connection evidence. +- Extract interactive terminal route selection, bridge expansion, signed attach preservation, adapter authorization, runner targets, query projection, and headers. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 4dd7020..251b957 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,13 +25,7 @@ import { terminalSubmittedLine, type TerminalInputState, } from "./terminal-multiplayer"; -import { - buildFleetState, - ptyRouteKind, - type FleetSandboxPolicySummary, - type FleetState, - type PtyRouteKind, -} from "./fleet-state"; +import { buildFleetState, type FleetSandboxPolicySummary, type FleetState } from "./fleet-state"; import { buildGitHubActionsRunnerPtyUrl, forwardGitHubActionsRelayMessage, @@ -84,12 +78,10 @@ import { retainedRuntimeAdapterFailureMessage, runtimeAdapterStopOutcome, runtimeAdapterTerminalFailureStatus, - runtimeAdapterTerminalOriginMatches, runtimeAdapterWorkspaceIdConflict, runtimeAdapterWorkspaceUrl, resolveCreateAfterStopRace, safeDesktopUrl, - safeWebSocketUrl, shouldReplayRuntimeAdapterCreate, validatedRuntimeAdapterCreatePayloadJson, type AdapterProvisionRecord, @@ -153,7 +145,6 @@ import { } from "./worker/models"; import { badRequest, - bearer, conflict, cookie, forbidden, @@ -211,7 +202,6 @@ import { containerCapabilities, crabboxCapabilities, interactiveSession, - interactiveSessionAdapterControlPlane, interactiveSessionEvent, runtimeCapabilities, type InteractiveSession, @@ -350,9 +340,15 @@ import { } from "./worker/session-reservation-context"; import { configuredRuntimeAdapterControlPlane, + requireRegisteredRuntimeAdapterControlPlane, runtimeAdapterConfigurationPresent, runtimeAdapterToken, } from "./worker/runtime-adapter-preflight"; +import { + interactivePtyRouteKind, + interactiveTerminalHeaders, + interactiveTerminalTarget, +} from "./worker/session-terminal-route"; const sandboxPlaceholderOpenAIKey = "crabfleet-worker-injected"; const sandboxPlaceholderGitHubToken = "crabfleet-worker-injected"; @@ -582,11 +578,6 @@ type SandboxCheckpoint = { workdir: string; }; -type InteractiveTerminalTarget = { - url: string; - authorization: string | null; -}; - type TerminalHubSubscription = { session: InteractiveSession; upstream: WebSocket; @@ -3415,25 +3406,6 @@ async function createInteractiveSessionFromInput( throw new Error("failed to allocate interactive session id"); } -function requireRegisteredRuntimeAdapterControlPlane( - env: RuntimeEnv, - profile: string, - registeredControlPlane: string | null | undefined, -): string { - if (!registeredControlPlane) { - throw new Error("runtime adapter control-plane registration is missing"); - } - const configuredControlPlane = configuredRuntimeAdapterControlPlane(env, profile); - if (!configuredControlPlane) { - throw new Error("runtime adapter control plane is unavailable"); - } - if (configuredControlPlane !== registeredControlPlane) { - throw new Error("runtime adapter control plane differs from workspace registration"); - } - if (!runtimeAdapterToken(env)) throw new Error("runtime adapter token is not configured"); - return registeredControlPlane; -} - async function registeredRuntimeAdapterControlPlaneForSession( env: RuntimeEnv, sessionId: string, @@ -6547,132 +6519,6 @@ async function currentRuntimeAdapterDesktopAccess( ); } -function interactiveTerminalTarget( - env: RuntimeEnv, - session: InteractiveSession, - routeKind = interactivePtyRouteKind(env, session), -): InteractiveTerminalTarget | null { - if (routeKind === "bridge" && env.CRABBOX_PTY_BRIDGE_URL) { - const url = interactiveBridgeUrl(env.CRABBOX_PTY_BRIDGE_URL, session); - if (!url) return null; - return { - url, - authorization: bearer(env.CRABBOX_PTY_BRIDGE_TOKEN), - }; - } - - const attachUrl = routeKind === "attach" ? safeWebSocketUrl(session.attachUrl) : null; - if (attachUrl) { - if (session.adapter === runtimeAdapterName) { - const authorization = runtimeAdapterTerminalAuthorization( - env, - session.profile, - session[interactiveSessionAdapterControlPlane], - attachUrl, - ); - return authorization ? { url: attachUrl, authorization } : null; - } - return { - url: attachUrl, - authorization: null, - }; - } - - const leaseId = legacyInteractiveSessionLeaseId(session); - if ( - routeKind === "cloudflare" && - leaseId?.startsWith("cloudflare:") && - env.CRABBOX_CLOUDFLARE_RUNNER_URL - ) { - const sandboxId = leaseId.slice("cloudflare:".length); - const runnerUrl = safeDesktopUrl(env.CRABBOX_CLOUDFLARE_RUNNER_URL); - if (!runnerUrl) return null; - const url = addQuery( - joinUrl(runnerUrl, `/v1/sandboxes/${encodeURIComponent(sandboxId)}/pty`), - terminalQuery(session), - ); - if (!url) return null; - return { - url, - authorization: bearer(env.CRABBOX_CLOUDFLARE_RUNNER_TOKEN), - }; - } - - return null; -} - -function runtimeAdapterTerminalAuthorization( - env: RuntimeEnv, - profile: string, - registeredControlPlane: string | null, - attachUrl: string, -): string | null { - try { - const controlPlane = requireRegisteredRuntimeAdapterControlPlane( - env, - profile, - registeredControlPlane, - ); - if (!runtimeAdapterTerminalOriginMatches(controlPlane, attachUrl)) return null; - return bearer(runtimeAdapterToken(env)); - } catch { - return null; - } -} - -function interactivePtyRouteKind( - env: RuntimeEnv, - session: Pick, -): PtyRouteKind | null { - return ptyRouteKind(session, { - sandboxAvailable: Boolean(env.SANDBOX), - bridgeUrl: env.CRABBOX_PTY_BRIDGE_URL, - cloudflareRunnerUrl: env.CRABBOX_CLOUDFLARE_RUNNER_URL, - }); -} - -function interactiveBridgeUrl(base: string, session: InteractiveSession): string { - const leaseId = legacyInteractiveSessionLeaseId(session) ?? ""; - const replacements: Record = { - id: session.id, - leaseId, - repo: session.repo, - branch: session.branch, - runtime: session.runtime, - }; - let url = base; - for (const [key, value] of Object.entries(replacements)) { - url = url.replaceAll(`{${key}}`, encodeURIComponent(value)); - } - return safeWebSocketUrl(addQuery(httpToWebSocketUrl(url), terminalQuery(session))) ?? ""; -} - -function terminalQuery(session: InteractiveSession): Record { - return { - sessionId: session.id, - leaseId: legacyInteractiveSessionLeaseId(session) ?? "", - repo: session.repo, - branch: session.branch, - runtime: session.runtime, - profile: session.profile, - command: session.command, - }; -} - -function interactiveTerminalHeaders( - session: InteractiveSession, - authorization: string | null, -): Headers { - const headers = new Headers({ - upgrade: "websocket", - "x-crabbox-session": session.id, - "x-crabbox-repo": session.repo, - "x-crabbox-runtime": session.runtime, - }); - if (authorization) headers.set("authorization", authorization); - return headers; -} - async function multiplayerTerminalInputPayloads( env: RuntimeEnv, subscription: TerminalHubSubscription, @@ -12480,29 +12326,6 @@ function joinUrl(base: string, path: string): string { } } -function addQuery(rawUrl: string, params: Record): string { - try { - const url = new URL(rawUrl); - for (const [key, value] of Object.entries(params)) { - if (value) url.searchParams.set(key, value); - } - return url.toString(); - } catch { - return ""; - } -} - -function httpToWebSocketUrl(rawUrl: string): string { - try { - const url = new URL(rawUrl); - if (url.protocol === "http:") url.protocol = "ws:"; - if (url.protocol === "https:") url.protocol = "wss:"; - return url.toString(); - } catch { - return ""; - } -} - function sandboxSetupSessionId(id: string): string { return clean(`setup-${id}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"), 80); } diff --git a/src/worker/runtime-adapter-preflight.ts b/src/worker/runtime-adapter-preflight.ts index e7d29a2..fb669c8 100644 --- a/src/worker/runtime-adapter-preflight.ts +++ b/src/worker/runtime-adapter-preflight.ts @@ -38,3 +38,22 @@ export function runtimeAdapterToken(env: RuntimeEnv): string { .trim() .slice(0, 4000); } + +export function requireRegisteredRuntimeAdapterControlPlane( + env: RuntimeEnv, + profile: string, + registeredControlPlane: string | null | undefined, +): string { + if (!registeredControlPlane) { + throw new Error("runtime adapter control-plane registration is missing"); + } + const configuredControlPlane = configuredRuntimeAdapterControlPlane(env, profile); + if (!configuredControlPlane) { + throw new Error("runtime adapter control plane is unavailable"); + } + if (configuredControlPlane !== registeredControlPlane) { + throw new Error("runtime adapter control plane differs from workspace registration"); + } + if (!runtimeAdapterToken(env)) throw new Error("runtime adapter token is not configured"); + return registeredControlPlane; +} diff --git a/src/worker/session-terminal-route.ts b/src/worker/session-terminal-route.ts new file mode 100644 index 0000000..2cc53ab --- /dev/null +++ b/src/worker/session-terminal-route.ts @@ -0,0 +1,177 @@ +import { ptyRouteKind, type PtyRouteKind } from "../fleet-state.ts"; +import { + legacyLeaseIdForAdapter, + runtimeAdapterName, + runtimeAdapterTerminalOriginMatches, + safeDesktopUrl, + safeWebSocketUrl, +} from "../runtime-adapter.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { bearer } from "./http.ts"; +import { + requireRegisteredRuntimeAdapterControlPlane, + runtimeAdapterToken, +} from "./runtime-adapter-preflight.ts"; +import { interactiveSessionAdapterControlPlane, type InteractiveSession } from "./session-model.ts"; + +export type InteractiveTerminalTarget = { + url: string; + authorization: string | null; +}; + +export function interactiveTerminalTarget( + env: RuntimeEnv, + session: InteractiveSession, + routeKind = interactivePtyRouteKind(env, session), +): InteractiveTerminalTarget | null { + if (routeKind === "bridge" && env.CRABBOX_PTY_BRIDGE_URL) { + const url = interactiveBridgeUrl(env.CRABBOX_PTY_BRIDGE_URL, session); + if (!url) return null; + return { + url, + authorization: bearer(env.CRABBOX_PTY_BRIDGE_TOKEN), + }; + } + + const attachUrl = routeKind === "attach" ? safeWebSocketUrl(session.attachUrl) : null; + if (attachUrl) { + if (session.adapter === runtimeAdapterName) { + const authorization = runtimeAdapterTerminalAuthorization( + env, + session.profile, + session[interactiveSessionAdapterControlPlane], + attachUrl, + ); + return authorization ? { url: attachUrl, authorization } : null; + } + return { + url: attachUrl, + authorization: null, + }; + } + + const leaseId = legacyLeaseIdForAdapter(session.adapter, session.leaseId); + if ( + routeKind === "cloudflare" && + leaseId?.startsWith("cloudflare:") && + env.CRABBOX_CLOUDFLARE_RUNNER_URL + ) { + const sandboxId = leaseId.slice("cloudflare:".length); + const runnerUrl = safeDesktopUrl(env.CRABBOX_CLOUDFLARE_RUNNER_URL); + if (!runnerUrl) return null; + const url = addQuery( + joinUrl(runnerUrl, `/v1/sandboxes/${encodeURIComponent(sandboxId)}/pty`), + terminalQuery(session), + ); + if (!url) return null; + return { + url, + authorization: bearer(env.CRABBOX_CLOUDFLARE_RUNNER_TOKEN), + }; + } + + return null; +} + +export function runtimeAdapterTerminalAuthorization( + env: RuntimeEnv, + profile: string, + registeredControlPlane: string | null, + attachUrl: string, +): string | null { + try { + const controlPlane = requireRegisteredRuntimeAdapterControlPlane( + env, + profile, + registeredControlPlane, + ); + if (!runtimeAdapterTerminalOriginMatches(controlPlane, attachUrl)) return null; + return bearer(runtimeAdapterToken(env)); + } catch { + return null; + } +} + +export function interactivePtyRouteKind( + env: RuntimeEnv, + session: Pick, +): PtyRouteKind | null { + return ptyRouteKind(session, { + sandboxAvailable: Boolean(env.SANDBOX), + bridgeUrl: env.CRABBOX_PTY_BRIDGE_URL, + cloudflareRunnerUrl: env.CRABBOX_CLOUDFLARE_RUNNER_URL, + }); +} + +export function interactiveBridgeUrl(base: string, session: InteractiveSession): string { + const leaseId = legacyLeaseIdForAdapter(session.adapter, session.leaseId) ?? ""; + const replacements: Record = { + id: session.id, + leaseId, + repo: session.repo, + branch: session.branch, + runtime: session.runtime, + }; + let url = base; + for (const [key, value] of Object.entries(replacements)) { + url = url.replaceAll(`{${key}}`, encodeURIComponent(value)); + } + return safeWebSocketUrl(addQuery(httpToWebSocketUrl(url), terminalQuery(session))) ?? ""; +} + +export function terminalQuery(session: InteractiveSession): Record { + return { + sessionId: session.id, + leaseId: legacyLeaseIdForAdapter(session.adapter, session.leaseId) ?? "", + repo: session.repo, + branch: session.branch, + runtime: session.runtime, + profile: session.profile, + command: session.command, + }; +} + +export function interactiveTerminalHeaders( + session: InteractiveSession, + authorization: string | null, +): Headers { + const headers = new Headers({ + upgrade: "websocket", + "x-crabbox-session": session.id, + "x-crabbox-repo": session.repo, + "x-crabbox-runtime": session.runtime, + }); + if (authorization) headers.set("authorization", authorization); + return headers; +} + +function joinUrl(base: string, path: string): string { + try { + return new URL(path, base.endsWith("/") ? base : `${base}/`).toString(); + } catch { + return ""; + } +} + +function addQuery(rawUrl: string, params: Record): string { + try { + const url = new URL(rawUrl); + for (const [key, value] of Object.entries(params)) { + if (value) url.searchParams.set(key, value); + } + return url.toString(); + } catch { + return ""; + } +} + +function httpToWebSocketUrl(rawUrl: string): string { + try { + const url = new URL(rawUrl); + if (url.protocol === "http:") url.protocol = "ws:"; + if (url.protocol === "https:") url.protocol = "wss:"; + return url.toString(); + } catch { + return ""; + } +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 9644226..2362d6c 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -40,6 +40,7 @@ import { publicDeploymentConfig } from "../src/worker/deployment.ts"; import type { RuntimeEnv } from "../src/worker/env.ts"; import { configuredRuntimeAdapterControlPlane, + requireRegisteredRuntimeAdapterControlPlane, requireRuntimeAdapterCreatePreflight, runtimeAdapterToken, } from "../src/worker/runtime-adapter-preflight.ts"; @@ -790,12 +791,6 @@ test("runtime adapter operations stay bound to the registered control plane", as new URL("../migrations/0020_runtime_adapter_lifecycle.sql", import.meta.url), "utf8", ); - const bindingStart = source.indexOf("function requireRegisteredRuntimeAdapterControlPlane"); - const bindingEnd = source.indexOf( - "async function stopSupersededRuntimeAdapterProvision", - bindingStart, - ); - const bindingSource = source.slice(bindingStart, bindingEnd); const provisionStart = source.indexOf("async function provisionWithRuntimeAdapter"); const provisionEnd = source.indexOf("function persistedRuntimeAdapterSeconds", provisionStart); const provisionSource = source.slice(provisionStart, provisionEnd); @@ -819,9 +814,28 @@ test("runtime adapter operations stay bound to the registered control plane", as ), "https://adapter.example.test/", ); - assert.match(bindingSource, /configuredControlPlane !== registeredControlPlane/); - assert.match(bindingSource, /configuredRuntimeAdapterControlPlane\(env, profile\)/); - assert.match(bindingSource, /control plane differs from workspace registration/); + const env = { + CRABBOX_RUNTIME_ADAPTER_URL: "https://adapter.example.test", + CRABBOX_RUNTIME_ADAPTER_TOKEN: "adapter-token", + } as RuntimeEnv; + assert.equal( + requireRegisteredRuntimeAdapterControlPlane(env, "default", "https://adapter.example.test/"), + "https://adapter.example.test/", + ); + assert.throws(() => requireRegisteredRuntimeAdapterControlPlane(env, "default", null), { + message: "runtime adapter control-plane registration is missing", + }); + assert.throws( + () => + requireRegisteredRuntimeAdapterControlPlane( + env, + "default", + "https://different.example.test/", + ), + { + message: "runtime adapter control plane differs from workspace registration", + }, + ); assert.match(provisionSource, /requireRegisteredRuntimeAdapterControlPlane/); assert.match(provisionSource, /runtimeAdapterCollectionUrl\(baseUrl\)/); assert.match(inspectSource, /session\.adapter_control_plane/); @@ -1872,7 +1886,7 @@ test("adapter bodies share the bounded stream reader", async () => { "async function stopRuntimeAdapterWorkspace(", "async function stopRuntimeAdapterWorkspaceForSession", ], - ["async function interactiveSessionVnc", "function interactiveTerminalTarget"], + ["async function interactiveSessionVnc", "async function multiplayerTerminalInputPayloads"], ] as const; for (const [startMarker, endMarker] of ranges) { const start = source.indexOf(startMarker); @@ -1891,7 +1905,7 @@ test("adapter bodies share the bounded stream reader", async () => { test("desktop mint revalidates current ownership before redirect", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const vncStart = source.indexOf("async function interactiveSessionVnc"); - const vncEnd = source.indexOf("function interactiveTerminalTarget", vncStart); + const vncEnd = source.indexOf("async function multiplayerTerminalInputPayloads", vncStart); const vncSource = source.slice(vncStart, vncEnd); assert.ok( @@ -1915,21 +1929,6 @@ test("desktop mint revalidates current ownership before redirect", async () => { assert.doesNotMatch(vncSource, /body:\s*JSON\.stringify\(\{\}\)/); }); -test("runtime adapter terminals use the server-side adapter bearer", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const targetStart = source.indexOf("function interactiveTerminalTarget"); - const targetEnd = source.indexOf("function interactivePtyRouteKind", targetStart); - const targetSource = source.slice(targetStart, targetEnd); - - assert.match(targetSource, /session\.adapter === runtimeAdapterName/); - assert.match(targetSource, /session\[interactiveSessionAdapterControlPlane\]/); - assert.match(targetSource, /runtimeAdapterTerminalAuthorization/); - assert.match(targetSource, /requireRegisteredRuntimeAdapterControlPlane/); - assert.match(targetSource, /runtimeAdapterTerminalOriginMatches\(controlPlane, attachUrl\)/); - assert.match(targetSource, /bearer\(runtimeAdapterToken\(env\)\)/); - assert.doesNotMatch(targetSource, /searchParams\.set\([^)]*(?:token|ticket)/u); -}); - test("runtime adapter terminal upgrades use the coordinator service binding", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const openStart = source.indexOf("async function openInteractiveTerminalUpstream"); diff --git a/tests/session-terminal-route.test.ts b/tests/session-terminal-route.test.ts new file mode 100644 index 0000000..4edbd67 --- /dev/null +++ b/tests/session-terminal-route.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + interactiveBridgeUrl, + interactivePtyRouteKind, + interactiveTerminalHeaders, + interactiveTerminalTarget, + runtimeAdapterTerminalAuthorization, + terminalQuery, +} from "../src/worker/session-terminal-route.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function session(values: Parameters[0] = {}) { + return interactiveSession( + sessionRow({ + id: "IS-route", + repo: "openclaw/crabfleet", + branch: "feature/terminal route", + runtime: "crabbox", + profile: "default", + command: "codex --yolo", + ...values, + }), + [], + ); +} + +test("terminal route selection follows managed backend priority", () => { + const routed = session({ + lease_id: "sandbox:owned", + attach_url: "wss://attach.example/pty", + }); + assert.equal(interactivePtyRouteKind({ SANDBOX: {} } as RuntimeEnv, routed), "sandbox"); + assert.equal( + interactivePtyRouteKind( + { CRABBOX_PTY_BRIDGE_URL: "https://bridge.example/{id}" } as RuntimeEnv, + routed, + ), + "bridge", + ); + assert.equal(interactivePtyRouteKind({} as RuntimeEnv, routed), "attach"); + assert.equal( + interactivePtyRouteKind( + { CRABBOX_CLOUDFLARE_RUNNER_URL: "https://runner.example" } as RuntimeEnv, + session({ lease_id: "cloudflare:sandbox-1", attach_url: null }), + ), + "cloudflare", + ); +}); + +test("bridge targets expand templates, append session context, and use bridge auth", () => { + const current = session({ lease_id: "bridge:lease/1" }); + const env = { + CRABBOX_PTY_BRIDGE_URL: "https://bridge.example/pty/{id}/{leaseId}?existing=opaque&repo={repo}", + CRABBOX_PTY_BRIDGE_TOKEN: "bridge-token", + } as RuntimeEnv; + const target = interactiveTerminalTarget(env, current, "bridge"); + + assert.equal( + target?.url, + "wss://bridge.example/pty/IS-route/bridge%3Alease%2F1?existing=opaque&repo=openclaw%2Fcrabfleet&sessionId=IS-route&leaseId=bridge%3Alease%2F1&branch=feature%2Fterminal+route&runtime=crabbox&profile=default&command=codex+--yolo", + ); + assert.equal(target?.authorization, "Bearer bridge-token"); + assert.equal(interactiveBridgeUrl("not a url", current), ""); +}); + +test("signed attach targets remain opaque and adapter auth is origin-bound", () => { + const signed = "wss://adapter.example/v1/pty?signature=a%2Bb%2Fc%3D&cols=provider-owned&opaque=1"; + const env = { + CRABBOX_RUNTIME_ADAPTER_URL: "https://adapter.example", + CRABBOX_RUNTIME_ADAPTER_TOKEN: "adapter-token", + } as RuntimeEnv; + const current = session({ + adapter: "runtime-v1", + adapter_control_plane: "https://adapter.example/", + attach_url: signed, + }); + + assert.deepEqual(interactiveTerminalTarget(env, current, "attach"), { + url: signed, + authorization: "Bearer adapter-token", + }); + assert.equal( + runtimeAdapterTerminalAuthorization( + env, + "default", + "https://adapter.example", + "wss://other.example/pty", + ), + null, + ); + assert.equal( + interactiveTerminalTarget( + env, + session({ + adapter: "runtime-v1", + adapter_control_plane: "https://different.example", + attach_url: signed, + }), + "attach", + ), + null, + ); + assert.deepEqual( + interactiveTerminalTarget( + {} as RuntimeEnv, + session({ adapter: null, attach_url: signed }), + "attach", + ), + { url: signed, authorization: null }, + ); +}); + +test("Cloudflare runner targets and terminal headers carry canonical session context", () => { + const current = session({ lease_id: "cloudflare:sandbox/1", attach_url: null }); + const target = interactiveTerminalTarget( + { + CRABBOX_CLOUDFLARE_RUNNER_URL: "https://runner.example/base", + CRABBOX_CLOUDFLARE_RUNNER_TOKEN: "runner-token", + } as RuntimeEnv, + current, + "cloudflare", + ); + assert.equal( + target?.url, + "https://runner.example/v1/sandboxes/sandbox%2F1/pty?sessionId=IS-route&leaseId=cloudflare%3Asandbox%2F1&repo=openclaw%2Fcrabfleet&branch=feature%2Fterminal+route&runtime=crabbox&profile=default&command=codex+--yolo", + ); + assert.equal(target?.authorization, "Bearer runner-token"); + assert.deepEqual(terminalQuery(current), { + sessionId: "IS-route", + leaseId: "cloudflare:sandbox/1", + repo: "openclaw/crabfleet", + branch: "feature/terminal route", + runtime: "crabbox", + profile: "default", + command: "codex --yolo", + }); + + const headers = interactiveTerminalHeaders(current, "Bearer upstream"); + assert.equal(headers.get("upgrade"), "websocket"); + assert.equal(headers.get("x-crabbox-session"), "IS-route"); + assert.equal(headers.get("x-crabbox-repo"), "openclaw/crabfleet"); + assert.equal(headers.get("x-crabbox-runtime"), "crabbox"); + assert.equal(headers.get("authorization"), "Bearer upstream"); +}); From 882cd983a593e56326de32a580bcb7e16c2cb4cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:54:33 +0100 Subject: [PATCH 048/109] refactor: extract terminal websocket bridge --- CHANGELOG.md | 1 + src/index.ts | 187 ++--------------------- src/worker/terminal-websocket-bridge.ts | 190 ++++++++++++++++++++++++ tests/runtime-adapter.test.ts | 10 -- tests/terminal-websocket-bridge.test.ts | 145 ++++++++++++++++++ 5 files changed, 346 insertions(+), 187 deletions(-) create mode 100644 src/worker/terminal-websocket-bridge.ts create mode 100644 tests/terminal-websocket-bridge.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index da937f4..56f20ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Extract GitHub Actions work-state projection, heartbeat persistence, event suppression, terminal mapping, runner disconnect, and reread. - Extract GitHub Actions runner-connect validation, lifecycle projection, heartbeat persistence, and durable connection evidence. - Extract interactive terminal route selection, bridge expansion, signed attach preservation, adapter authorization, runner targets, query projection, and headers. +- Extract terminal WebSocket relay queues, output acknowledgements, message normalization, authorization polling, and peer close handling. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index 251b957..f0b26fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -349,6 +349,12 @@ import { interactiveTerminalHeaders, interactiveTerminalTarget, } from "./worker/session-terminal-route"; +import { + bridgeWebSockets, + sendTerminalOutputAcknowledgement, + terminalOutputAcknowledgements, + webSocketMessageData, +} from "./worker/terminal-websocket-bridge"; const sandboxPlaceholderOpenAIKey = "crabfleet-worker-injected"; const sandboxPlaceholderGitHubToken = "crabfleet-worker-injected"; @@ -6567,176 +6573,6 @@ async function readInteractiveSessionMultiplayerMode( } } -function bridgeWebSockets( - left: WebSocket, - right: WebSocket, - canSendLeft?: () => Promise, - reconcileSubscription?: () => void, - deniedReason = "terminal control revoked", - forwardRightOutputAcknowledgements = false, - acknowledgeRightOutputImmediately = false, -): void { - let leftInputQueue = Promise.resolve(); - let rightOutputQueue = Promise.resolve(); - let controlCheckTimer: ReturnType | undefined; - let controlCheckInFlight: Promise | undefined; - let leftCanSend = true; - let rightOutputAcknowledgementBytes = 0; - const stopControlCheck = () => { - if (controlCheckTimer !== undefined) clearInterval(controlCheckTimer); - controlCheckTimer = undefined; - }; - const verifyControl = async () => { - const canSend = canSendLeft ? await canSendLeft().catch(() => false) : true; - leftCanSend = canSend; - if (!canSend) { - stopControlCheck(); - closePair(left, right, 1008, deniedReason); - return false; - } - return true; - }; - const scheduleControlCheck = () => { - reconcileSubscription?.(); - if (controlCheckInFlight) return; - controlCheckInFlight = verifyControl() - .then(() => undefined) - .finally(() => { - controlCheckInFlight = undefined; - }); - }; - if (canSendLeft) { - controlCheckTimer = setInterval(() => { - scheduleControlCheck(); - }, 5000); - scheduleControlCheck(); - } - left.addEventListener("message", (event) => { - const data = event.data; - leftInputQueue = leftInputQueue - .catch(() => undefined) - .then(async () => { - if (left.readyState !== WebSocket.OPEN || right.readyState !== WebSocket.OPEN) return; - if (!leftCanSend || !(await verifyControl())) { - closePair(left, right, 1008, deniedReason); - return; - } - const forwarded = await webSocketMessageData(data); - const acknowledgedBytes = forwardRightOutputAcknowledgements - ? terminalOutputAcknowledgement(forwarded) - : null; - if (acknowledgedBytes !== null) { - if (acknowledgedBytes <= rightOutputAcknowledgementBytes) { - rightOutputAcknowledgementBytes -= acknowledgedBytes; - sendTerminalOutputAcknowledgement(right, acknowledgedBytes); - } - return; - } - right.send(forwarded); - }); - }); - right.addEventListener("message", (event) => { - const data = event.data; - rightOutputQueue = rightOutputQueue - .catch(() => undefined) - .then(async () => { - if (left.readyState !== WebSocket.OPEN || right.readyState !== WebSocket.OPEN) return; - const forwarded = await webSocketMessageData(data); - left.send(forwarded); - if (forwardRightOutputAcknowledgements) { - rightOutputAcknowledgementBytes += terminalMessageByteLength(forwarded); - } else if (acknowledgeRightOutputImmediately) { - sendTerminalOutputAcknowledgement(right, terminalMessageByteLength(forwarded)); - } - }); - }); - left.addEventListener("close", (event) => { - stopControlCheck(); - closePeer(event, right); - }); - right.addEventListener("close", (event) => { - stopControlCheck(); - closePeer(event, left); - }); - left.addEventListener("error", () => { - stopControlCheck(); - closePair(left, right, 1011, "peer error"); - }); - right.addEventListener("error", () => { - stopControlCheck(); - closePair(right, left, 1011, "peer error"); - }); -} - -function terminalOutputAcknowledgements(value: string): boolean { - try { - return new URL(value).searchParams.get("flow") === "ack-v1"; - } catch { - return false; - } -} - -function terminalOutputAcknowledgement(value: string | ArrayBuffer): number | null { - if (typeof value !== "string" || !value.startsWith("{") || value.length > 100) return null; - try { - const parsed = JSON.parse(value) as Record; - const bytes = parsed.bytes; - return parsed.type === "ack" && - Number.isInteger(bytes) && - Number(bytes) > 0 && - Number(bytes) <= 1024 * 1024 - ? Number(bytes) - : null; - } catch { - return null; - } -} - -function terminalMessageByteLength(value: string | ArrayBuffer): number { - return typeof value === "string" ? encoder.encode(value).byteLength : value.byteLength; -} - -function sendTerminalOutputAcknowledgement(socket: WebSocket, bytes: number): void { - if (bytes > 0 && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify({ type: "ack", bytes })); - } -} - -async function webSocketMessageData(data: unknown): Promise { - if (typeof data === "string" || data instanceof ArrayBuffer) return data; - if (data instanceof Uint8Array) { - return new Uint8Array(data).buffer; - } - if (data instanceof Blob) return await data.arrayBuffer(); - if ( - data && - typeof data === "object" && - "arrayBuffer" in data && - typeof data.arrayBuffer === "function" - ) { - return await data.arrayBuffer(); - } - return String(data); -} - -function closePeer(event: CloseEvent, to: WebSocket): void { - if (to.readyState === WebSocket.OPEN || to.readyState === WebSocket.CONNECTING) { - to.close( - event.code || 1000, - clean(event.reason ? redactedAdapterMessage(event.reason, "detached") : "peer closed", 120), - ); - } -} - -function closePair(left: WebSocket, right: WebSocket, code: number, reason: string): void { - if (left.readyState === WebSocket.OPEN || left.readyState === WebSocket.CONNECTING) { - left.close(code, reason); - } - if (right.readyState === WebSocket.OPEN || right.readyState === WebSocket.CONNECTING) { - right.close(code, reason); - } -} - async function provisionInteractiveSession( env: RuntimeEnv, session: InteractiveProvisionRequest, @@ -7611,13 +7447,10 @@ async function standaloneSandboxPty( const server = pair[1]; server.accept(); response.webSocket.accept(); - bridgeWebSockets( - server, - response.webSocket, - terminalGrant, - undefined, - "standalone Sandbox authorization revoked or expired", - ); + bridgeWebSockets(server, response.webSocket, { + canSendLeft: terminalGrant, + deniedReason: "standalone Sandbox authorization revoked or expired", + }); return new Response(null, { status: 101, webSocket: client }); } diff --git a/src/worker/terminal-websocket-bridge.ts b/src/worker/terminal-websocket-bridge.ts new file mode 100644 index 0000000..3283fd9 --- /dev/null +++ b/src/worker/terminal-websocket-bridge.ts @@ -0,0 +1,190 @@ +import { redactedAdapterMessage } from "../runtime-adapter.ts"; + +const encoder = new TextEncoder(); + +export type TerminalWebSocketBridgeOptions = { + canSendLeft?: () => Promise; + reconcileSubscription?: () => void; + deniedReason?: string; + forwardRightOutputAcknowledgements?: boolean; + acknowledgeRightOutputImmediately?: boolean; +}; + +export function bridgeWebSockets( + left: WebSocket, + right: WebSocket, + options: TerminalWebSocketBridgeOptions = {}, +): void { + const { + canSendLeft, + reconcileSubscription, + deniedReason = "terminal control revoked", + forwardRightOutputAcknowledgements = false, + acknowledgeRightOutputImmediately = false, + } = options; + let leftInputQueue = Promise.resolve(); + let rightOutputQueue = Promise.resolve(); + let controlCheckTimer: ReturnType | undefined; + let controlCheckInFlight: Promise | undefined; + let leftCanSend = true; + let rightOutputAcknowledgementBytes = 0; + const stopControlCheck = () => { + if (controlCheckTimer !== undefined) clearInterval(controlCheckTimer); + controlCheckTimer = undefined; + }; + const verifyControl = async () => { + const canSend = canSendLeft ? await canSendLeft().catch(() => false) : true; + leftCanSend = canSend; + if (!canSend) { + stopControlCheck(); + closePair(left, right, 1008, deniedReason); + return false; + } + return true; + }; + const scheduleControlCheck = () => { + reconcileSubscription?.(); + if (controlCheckInFlight) return; + controlCheckInFlight = verifyControl() + .then(() => undefined) + .finally(() => { + controlCheckInFlight = undefined; + }); + }; + if (canSendLeft) { + controlCheckTimer = setInterval(() => { + scheduleControlCheck(); + }, 5000); + scheduleControlCheck(); + } + left.addEventListener("message", (event) => { + const data = event.data; + leftInputQueue = leftInputQueue + .catch(() => undefined) + .then(async () => { + if (left.readyState !== WebSocket.OPEN || right.readyState !== WebSocket.OPEN) return; + if (!leftCanSend || !(await verifyControl())) { + closePair(left, right, 1008, deniedReason); + return; + } + const forwarded = await webSocketMessageData(data); + const acknowledgedBytes = forwardRightOutputAcknowledgements + ? terminalOutputAcknowledgement(forwarded) + : null; + if (acknowledgedBytes !== null) { + if (acknowledgedBytes <= rightOutputAcknowledgementBytes) { + rightOutputAcknowledgementBytes -= acknowledgedBytes; + sendTerminalOutputAcknowledgement(right, acknowledgedBytes); + } + return; + } + right.send(forwarded); + }); + }); + right.addEventListener("message", (event) => { + const data = event.data; + rightOutputQueue = rightOutputQueue + .catch(() => undefined) + .then(async () => { + if (left.readyState !== WebSocket.OPEN || right.readyState !== WebSocket.OPEN) return; + const forwarded = await webSocketMessageData(data); + left.send(forwarded); + if (forwardRightOutputAcknowledgements) { + rightOutputAcknowledgementBytes += terminalMessageByteLength(forwarded); + } else if (acknowledgeRightOutputImmediately) { + sendTerminalOutputAcknowledgement(right, terminalMessageByteLength(forwarded)); + } + }); + }); + left.addEventListener("close", (event) => { + stopControlCheck(); + closePeer(event, right); + }); + right.addEventListener("close", (event) => { + stopControlCheck(); + closePeer(event, left); + }); + left.addEventListener("error", () => { + stopControlCheck(); + closePair(left, right, 1011, "peer error"); + }); + right.addEventListener("error", () => { + stopControlCheck(); + closePair(right, left, 1011, "peer error"); + }); +} + +export function terminalOutputAcknowledgements(value: string): boolean { + try { + return new URL(value).searchParams.get("flow") === "ack-v1"; + } catch { + return false; + } +} + +export function terminalOutputAcknowledgement(value: string | ArrayBuffer): number | null { + if (typeof value !== "string" || !value.startsWith("{") || value.length > 100) return null; + try { + const parsed = JSON.parse(value) as Record; + const bytes = parsed.bytes; + return parsed.type === "ack" && + Number.isInteger(bytes) && + Number(bytes) > 0 && + Number(bytes) <= 1024 * 1024 + ? Number(bytes) + : null; + } catch { + return null; + } +} + +export function terminalMessageByteLength(value: string | ArrayBuffer): number { + return typeof value === "string" ? encoder.encode(value).byteLength : value.byteLength; +} + +export function sendTerminalOutputAcknowledgement(socket: WebSocket, bytes: number): void { + if (bytes > 0 && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: "ack", bytes })); + } +} + +export async function webSocketMessageData(data: unknown): Promise { + if (typeof data === "string" || data instanceof ArrayBuffer) return data; + if (data instanceof Uint8Array) { + return new Uint8Array(data).buffer; + } + if (data instanceof Blob) return await data.arrayBuffer(); + if ( + data && + typeof data === "object" && + "arrayBuffer" in data && + typeof data.arrayBuffer === "function" + ) { + return await data.arrayBuffer(); + } + return String(data); +} + +function closePeer(event: CloseEvent, to: WebSocket): void { + if (to.readyState === WebSocket.OPEN || to.readyState === WebSocket.CONNECTING) { + to.close( + event.code || 1000, + clean(event.reason ? redactedAdapterMessage(event.reason, "detached") : "peer closed", 120), + ); + } +} + +function closePair(left: WebSocket, right: WebSocket, code: number, reason: string): void { + if (left.readyState === WebSocket.OPEN || left.readyState === WebSocket.CONNECTING) { + left.close(code, reason); + } + if (right.readyState === WebSocket.OPEN || right.readyState === WebSocket.CONNECTING) { + right.close(code, reason); + } +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 2362d6c..984a08a 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -657,10 +657,6 @@ test("recurring terminal authorization never awaits provider reconciliation", as const shareStart = source.indexOf("async function isSharedSessionToken"); const shareEnd = source.indexOf("function sendTerminalFrame", shareStart); const shareSource = source.slice(shareStart, shareEnd); - const bridgeStart = source.indexOf("function bridgeWebSockets"); - const bridgeEnd = source.indexOf("async function webSocketMessageData", bridgeStart); - const bridgeSource = source.slice(bridgeStart, bridgeEnd); - assert.match(grantSource, /cachedBooleanGrant/); assert.match(grantSource, /terminalSubscriptionReconciler/); assert.match(grantSource, /void reconcileExternalInteractiveSessionById/); @@ -668,8 +664,6 @@ test("recurring terminal authorization never awaits provider reconciliation", as assert.doesNotMatch(controlSource, /reconcileCredentialPolicyCleanupBatch|runtimeAdapterFetch/); assert.doesNotMatch(shareSource, /reconcileExternalInteractiveSessionById/); assert.doesNotMatch(shareSource, /reconcileCredentialPolicyCleanupBatch|runtimeAdapterFetch/); - assert.match(bridgeSource, /reconcileSubscription\?\.\(\)/); - assert.doesNotMatch(bridgeSource, /await reconcileSubscription/); }); test("public auth deployment metadata excludes runtime routing", () => { @@ -1951,11 +1945,7 @@ test("runtime adapter terminal flow control stays explicit and end-to-end", asyn assert.match(source, /frame\.type === TerminalMessageType\.Ack/); assert.match(source, /subscription\.outputAcknowledgements/); assert.match(source, /bytes <= subscription\.outputAcknowledgementBytes/); - assert.match(source, /acknowledgedBytes <= rightOutputAcknowledgementBytes/); assert.match(source, /TerminalSubscribeFlags\.OutputAcknowledgements/); - assert.match(source, /searchParams\.get\("flow"\) === "ack-v1"/); - assert.match(source, /JSON\.stringify\(\{ type: "ack", bytes \}\)/); - assert.match(source, /terminalOutputAcknowledgement\(forwarded\)/); assert.match(source, /else if \(upstreamConnection\.outputAcknowledgements\)/); }); diff --git a/tests/terminal-websocket-bridge.test.ts b/tests/terminal-websocket-bridge.test.ts new file mode 100644 index 0000000..60a9aa2 --- /dev/null +++ b/tests/terminal-websocket-bridge.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bridgeWebSockets, + sendTerminalOutputAcknowledgement, + terminalMessageByteLength, + terminalOutputAcknowledgement, + terminalOutputAcknowledgements, + webSocketMessageData, +} from "../src/worker/terminal-websocket-bridge.ts"; + +type Listener = (event: Event & { data?: unknown; code?: number; reason?: string }) => void; + +class TestSocket { + readyState = WebSocket.OPEN; + readonly sent: Array = []; + readonly closed: Array<{ code?: number; reason?: string }> = []; + private readonly listeners = new Map(); + + addEventListener(type: string, listener: Listener): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + send(data: string | ArrayBuffer | ArrayBufferView | Blob): void { + this.sent.push(data); + } + + close(code?: number, reason?: string): void { + this.closed.push({ code, reason }); + this.readyState = WebSocket.CLOSED; + } + + emit(type: string, values: Record = {}): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(Object.assign(new Event(type), values)); + } + } +} + +function socket(): WebSocket & TestSocket { + return new TestSocket() as WebSocket & TestSocket; +} + +async function flushQueues(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +test("terminal acknowledgement protocol is explicit and bounded", () => { + assert.equal(terminalOutputAcknowledgements("wss://terminal.example?flow=ack-v1"), true); + assert.equal(terminalOutputAcknowledgements("wss://terminal.example?flow=other"), false); + assert.equal(terminalOutputAcknowledgements("not a url"), false); + + assert.equal(terminalOutputAcknowledgement('{"type":"ack","bytes":12}'), 12); + assert.equal(terminalOutputAcknowledgement('{"type":"ack","bytes":0}'), null); + assert.equal(terminalOutputAcknowledgement('{"type":"ack","bytes":1048577}'), null); + assert.equal(terminalOutputAcknowledgement('{"type":"resize","bytes":12}'), null); + assert.equal(terminalOutputAcknowledgement(new ArrayBuffer(4)), null); + assert.equal(terminalMessageByteLength("€"), 3); + assert.equal(terminalMessageByteLength(new ArrayBuffer(7)), 7); + + const open = socket(); + sendTerminalOutputAcknowledgement(open, 9); + assert.deepEqual(open.sent, ['{"type":"ack","bytes":9}']); + open.readyState = WebSocket.CLOSED; + sendTerminalOutputAcknowledgement(open, 10); + assert.equal(open.sent.length, 1); +}); + +test("WebSocket message data preserves text and normalizes binary views", async () => { + assert.equal(await webSocketMessageData("text"), "text"); + const buffer = new Uint8Array([1, 2, 3]).buffer; + assert.equal(await webSocketMessageData(buffer), buffer); + assert.deepEqual( + new Uint8Array((await webSocketMessageData(new Uint8Array([4, 5]))) as ArrayBuffer), + new Uint8Array([4, 5]), + ); + assert.deepEqual( + new Uint8Array((await webSocketMessageData(new Blob([new Uint8Array([6, 7])]))) as ArrayBuffer), + new Uint8Array([6, 7]), + ); + assert.equal(await webSocketMessageData(42), "42"); +}); + +test("terminal bridge queues both directions and forwards earned acknowledgements", async () => { + const left = socket(); + const right = socket(); + bridgeWebSockets(left, right, { forwardRightOutputAcknowledgements: true }); + + right.emit("message", { data: "output" }); + await flushQueues(); + assert.deepEqual(left.sent, ["output"]); + + left.emit("message", { data: '{"type":"ack","bytes":6}' }); + await flushQueues(); + assert.deepEqual(right.sent, ['{"type":"ack","bytes":6}']); + + left.emit("message", { data: "input" }); + await flushQueues(); + assert.deepEqual(right.sent, ['{"type":"ack","bytes":6}', "input"]); +}); + +test("terminal bridge handles immediate acknowledgements and authorization revocation", async () => { + const immediateLeft = socket(); + const immediateRight = socket(); + bridgeWebSockets(immediateLeft, immediateRight, { acknowledgeRightOutputImmediately: true }); + immediateRight.emit("message", { data: new Uint8Array([1, 2, 3]) }); + await flushQueues(); + assert.deepEqual(immediateRight.sent, ['{"type":"ack","bytes":3}']); + + const deniedLeft = socket(); + const deniedRight = socket(); + let reconciliations = 0; + bridgeWebSockets(deniedLeft, deniedRight, { + canSendLeft: async () => false, + reconcileSubscription: () => { + reconciliations += 1; + }, + deniedReason: "authorization revoked", + }); + await flushQueues(); + assert.equal(reconciliations, 1); + assert.deepEqual(deniedLeft.closed, [{ code: 1008, reason: "authorization revoked" }]); + assert.deepEqual(deniedRight.closed, [{ code: 1008, reason: "authorization revoked" }]); +}); + +test("terminal bridge propagates sanitized close reasons and peer errors", () => { + const left = socket(); + const right = socket(); + bridgeWebSockets(left, right); + left.emit("close", { + code: 1001, + reason: "failed at wss://terminal.example/session?token=secret", + }); + assert.deepEqual(right.closed, [{ code: 1001, reason: "failed at [connection]" }]); + + const errorLeft = socket(); + const errorRight = socket(); + bridgeWebSockets(errorLeft, errorRight); + errorRight.emit("error"); + assert.deepEqual(errorLeft.closed, [{ code: 1011, reason: "peer error" }]); + assert.deepEqual(errorRight.closed, [{ code: 1011, reason: "peer error" }]); +}); From 33deea75680f14661887b9c654d31b1c346daf2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 14:58:17 +0100 Subject: [PATCH 049/109] refactor: extract runtime adapter transport --- CHANGELOG.md | 1 + src/index.ts | 72 +---------- src/worker/runtime-adapter-transport.ts | 83 ++++++++++++ tests/runtime-adapter-transport.test.ts | 160 ++++++++++++++++++++++++ tests/runtime-adapter.test.ts | 58 +-------- 5 files changed, 255 insertions(+), 119 deletions(-) create mode 100644 src/worker/runtime-adapter-transport.ts create mode 100644 tests/runtime-adapter-transport.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f20ef..e7f5c66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - Extract GitHub Actions runner-connect validation, lifecycle projection, heartbeat persistence, and durable connection evidence. - Extract interactive terminal route selection, bridge expansion, signed attach preservation, adapter authorization, runner targets, query projection, and headers. - Extract terminal WebSocket relay queues, output acknowledgements, message normalization, authorization polling, and peer close handling. +- Extract runtime-adapter lifecycle and terminal transport, coordinator binding selection, redirect refusal, and bounded response parsing. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. - Isolate OpenClaw recursive root-stop admission, reservation cleanup, lifecycle retries, reconciliation, and stable-completion polling behind a directly tested service. - Extract OpenClaw root-scoped authorization, lineage supervision, reservation outcomes, rollback, and activation orchestration into a directly tested service boundary. diff --git a/src/index.ts b/src/index.ts index f0b26fd..487bf78 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,7 +70,6 @@ import { redactedAdapterResponseMessage, runtimeAdapterCreatePayload, runtimeAdapterCollectionUrl, - runtimeAdapterControlPlaneIdentity, runtimeAdapterBrowserVncUrl, runtimeAdapterDesktopUrl, runtimeAdapterName, @@ -92,7 +91,6 @@ import { preferredEnabledRepo } from "./repo-selection"; import { sandboxGitAuthorEmail } from "./git-identity"; import { sizedTerminalTargetUrl } from "./terminal-target"; import { cachedBooleanGrant } from "./terminal-authorization"; -import { readBoundedResponseText } from "./bounded-response"; import { openClawGitHubRepoParts, openClawRoomMaxSessions, @@ -344,6 +342,11 @@ import { runtimeAdapterConfigurationPresent, runtimeAdapterToken, } from "./worker/runtime-adapter-preflight"; +import { + interactiveTerminalFetch, + readRuntimeAdapterResponseBody, + runtimeAdapterFetch, +} from "./worker/runtime-adapter-transport"; import { interactivePtyRouteKind, interactiveTerminalHeaders, @@ -10335,71 +10338,6 @@ async function stopRuntimeAdapterWorkspaceForSession( ); } -async function runtimeAdapterFetch( - env: RuntimeEnv, - url: string, - init: RequestInit, -): Promise { - const token = runtimeAdapterToken(env); - if (!token) throw new Error("runtime adapter token is not configured"); - const safeTarget = safeDesktopUrl(url); - if (!safeTarget) throw new Error("runtime adapter URL must use HTTPS or loopback HTTP"); - const target = new URL(safeTarget); - const headers = new Headers(init.headers); - headers.set("authorization", `Bearer ${token}`); - headers.set("accept", "application/json"); - if (init.body) headers.set("content-type", "application/json"); - const fetcher = runtimeAdapterFetcher(env, target); - const response = await fetcher.fetch(target, { - ...init, - headers, - redirect: "manual", - signal: AbortSignal.timeout(10_000), - }); - if (response.status >= 300 && response.status < 400) { - throw new Error("runtime adapter redirect refused"); - } - return response; -} - -function runtimeAdapterFetcher(env: RuntimeEnv, target: URL): Fetcher | typeof globalThis { - const coordinator = - runtimeAdapterControlPlaneIdentity(env.CRABBOX_COORDINATOR_ORIGIN) ?? - runtimeAdapterControlPlaneIdentity(env.CRABBOX_RUNTIME_ADAPTER_URL); - if (!env.CRABBOX_COORDINATOR || !coordinator) return globalThis; - const normalizedTarget = new URL(target); - if (normalizedTarget.protocol === "wss:") normalizedTarget.protocol = "https:"; - if (normalizedTarget.protocol === "ws:") normalizedTarget.protocol = "http:"; - return new URL(coordinator).origin === normalizedTarget.origin - ? env.CRABBOX_COORDINATOR - : globalThis; -} - -async function interactiveTerminalFetch( - env: RuntimeEnv, - session: Pick, - url: string, - headers: Headers, -): Promise { - const target = new URL(url); - const fetchTarget = new URL(target); - if (fetchTarget.protocol === "wss:") fetchTarget.protocol = "https:"; - if (fetchTarget.protocol === "ws:") fetchTarget.protocol = "http:"; - const fetcher = - session.adapter === runtimeAdapterName ? runtimeAdapterFetcher(env, target) : globalThis; - return fetcher.fetch(fetchTarget, { headers }); -} - -async function readRuntimeAdapterResponseBody(response: Response): Promise { - const body = await readBoundedResponseText(response); - if (!body) return null; - try { - return JSON.parse(body); - } catch { - return { message: body }; - } -} - function runtimeAdapterProviderConfigured(env: RuntimeEnv): boolean { return Boolean( configuredRuntimeAdapterControlPlane(env, "profile-route") && runtimeAdapterToken(env), diff --git a/src/worker/runtime-adapter-transport.ts b/src/worker/runtime-adapter-transport.ts new file mode 100644 index 0000000..132df3e --- /dev/null +++ b/src/worker/runtime-adapter-transport.ts @@ -0,0 +1,83 @@ +import { readBoundedResponseText } from "../bounded-response.ts"; +import { + runtimeAdapterControlPlaneIdentity, + runtimeAdapterName, + safeDesktopUrl, +} from "../runtime-adapter.ts"; +import type { RuntimeEnv } from "./env.ts"; +import { runtimeAdapterToken } from "./runtime-adapter-preflight.ts"; + +type FetchTarget = Fetcher | typeof globalThis; + +export async function runtimeAdapterFetch( + env: RuntimeEnv, + url: string, + init: RequestInit, + fallbackFetcher: FetchTarget = globalThis, +): Promise { + const token = runtimeAdapterToken(env); + if (!token) throw new Error("runtime adapter token is not configured"); + const safeTarget = safeDesktopUrl(url); + if (!safeTarget) throw new Error("runtime adapter URL must use HTTPS or loopback HTTP"); + const target = new URL(safeTarget); + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${token}`); + headers.set("accept", "application/json"); + if (init.body) headers.set("content-type", "application/json"); + const fetcher = runtimeAdapterFetcher(env, target, fallbackFetcher); + const response = await fetcher.fetch(target, { + ...init, + headers, + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + if (response.status >= 300 && response.status < 400) { + throw new Error("runtime adapter redirect refused"); + } + return response; +} + +export function runtimeAdapterFetcher( + env: RuntimeEnv, + target: URL, + fallbackFetcher: FetchTarget = globalThis, +): FetchTarget { + const coordinator = + runtimeAdapterControlPlaneIdentity(env.CRABBOX_COORDINATOR_ORIGIN) ?? + runtimeAdapterControlPlaneIdentity(env.CRABBOX_RUNTIME_ADAPTER_URL); + if (!env.CRABBOX_COORDINATOR || !coordinator) return fallbackFetcher; + const normalizedTarget = new URL(target); + if (normalizedTarget.protocol === "wss:") normalizedTarget.protocol = "https:"; + if (normalizedTarget.protocol === "ws:") normalizedTarget.protocol = "http:"; + return new URL(coordinator).origin === normalizedTarget.origin + ? env.CRABBOX_COORDINATOR + : fallbackFetcher; +} + +export async function interactiveTerminalFetch( + env: RuntimeEnv, + session: { adapter: string | null }, + url: string, + headers: Headers, + fallbackFetcher: FetchTarget = globalThis, +): Promise { + const target = new URL(url); + const fetchTarget = new URL(target); + if (fetchTarget.protocol === "wss:") fetchTarget.protocol = "https:"; + if (fetchTarget.protocol === "ws:") fetchTarget.protocol = "http:"; + const fetcher = + session.adapter === runtimeAdapterName + ? runtimeAdapterFetcher(env, target, fallbackFetcher) + : fallbackFetcher; + return fetcher.fetch(fetchTarget, { headers }); +} + +export async function readRuntimeAdapterResponseBody(response: Response): Promise { + const body = await readBoundedResponseText(response); + if (!body) return null; + try { + return JSON.parse(body); + } catch { + return { message: body }; + } +} diff --git a/tests/runtime-adapter-transport.test.ts b/tests/runtime-adapter-transport.test.ts new file mode 100644 index 0000000..1e22435 --- /dev/null +++ b/tests/runtime-adapter-transport.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ResponseBodyLimitError } from "../src/bounded-response.ts"; +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + interactiveTerminalFetch, + readRuntimeAdapterResponseBody, + runtimeAdapterFetch, + runtimeAdapterFetcher, +} from "../src/worker/runtime-adapter-transport.ts"; + +type FetchCall = { + input: RequestInfo | URL; + init?: RequestInit; +}; + +function recordingFetcher(response: Response): { + fetcher: Fetcher; + calls: FetchCall[]; +} { + const calls: FetchCall[] = []; + return { + calls, + fetcher: { + async fetch(input, init) { + calls.push({ input, init }); + return response; + }, + } as Fetcher, + }; +} + +test("runtime adapter fetcher selects the coordinator only for its exact origin", () => { + const coordinator = recordingFetcher(new Response()).fetcher; + const fallback = recordingFetcher(new Response()).fetcher; + const env = { + CRABBOX_COORDINATOR: coordinator, + CRABBOX_COORDINATOR_ORIGIN: "https://adapter.example/base", + } as RuntimeEnv; + + assert.equal( + runtimeAdapterFetcher(env, new URL("https://adapter.example/v1"), fallback), + coordinator, + ); + assert.equal( + runtimeAdapterFetcher(env, new URL("wss://adapter.example/v1"), fallback), + coordinator, + ); + assert.equal(runtimeAdapterFetcher(env, new URL("https://other.example/v1"), fallback), fallback); + assert.equal( + runtimeAdapterFetcher({} as RuntimeEnv, new URL("https://adapter.example"), fallback), + fallback, + ); +}); + +test("runtime adapter lifecycle fetches authenticate, bound redirects, and reject unsafe targets", async () => { + const ok = recordingFetcher(new Response("{}", { status: 200 })); + const env = { + CRABBOX_COORDINATOR: ok.fetcher, + CRABBOX_COORDINATOR_ORIGIN: "https://adapter.example", + CRABBOX_RUNTIME_ADAPTER_TOKEN: " adapter-token ", + } as RuntimeEnv; + const response = await runtimeAdapterFetch( + env, + "https://adapter.example/v1/workspaces", + { method: "POST", body: "{}" }, + recordingFetcher(new Response("fallback")).fetcher, + ); + assert.equal(response.status, 200); + assert.equal(ok.calls.length, 1); + assert.equal(String(ok.calls[0]?.input), "https://adapter.example/v1/workspaces"); + const request = ok.calls[0]?.init; + assert.equal(request?.method, "POST"); + assert.equal(request?.redirect, "manual"); + assert.ok(request?.signal instanceof AbortSignal); + const headers = new Headers(request?.headers); + assert.equal(headers.get("authorization"), "Bearer adapter-token"); + assert.equal(headers.get("accept"), "application/json"); + assert.equal(headers.get("content-type"), "application/json"); + + const redirect = recordingFetcher( + new Response(null, { status: 302, headers: { location: "https://other.example" } }), + ); + await assert.rejects( + runtimeAdapterFetch( + { + ...env, + CRABBOX_COORDINATOR: redirect.fetcher, + }, + "https://adapter.example/v1/workspaces", + { method: "GET" }, + ), + { message: "runtime adapter redirect refused" }, + ); + await assert.rejects( + runtimeAdapterFetch( + { CRABBOX_RUNTIME_ADAPTER_TOKEN: "token" } as RuntimeEnv, + "http://adapter.example", + {}, + ok.fetcher, + ), + { message: "runtime adapter URL must use HTTPS or loopback HTTP" }, + ); + await assert.rejects( + runtimeAdapterFetch({} as RuntimeEnv, "https://adapter.example", {}, ok.fetcher), + { message: "runtime adapter token is not configured" }, + ); +}); + +test("terminal upgrades use the coordinator for adapter sessions and normalize WebSocket schemes", async () => { + const coordinator = recordingFetcher(new Response(null, { status: 200 })); + const fallback = recordingFetcher(new Response(null, { status: 200 })); + const env = { + CRABBOX_COORDINATOR: coordinator.fetcher, + CRABBOX_COORDINATOR_ORIGIN: "https://adapter.example", + } as RuntimeEnv; + const headers = new Headers({ upgrade: "websocket" }); + + await interactiveTerminalFetch( + env, + { adapter: "runtime-v1" }, + "wss://adapter.example/v1/terminal?signature=opaque", + headers, + fallback.fetcher, + ); + assert.equal( + String(coordinator.calls[0]?.input), + "https://adapter.example/v1/terminal?signature=opaque", + ); + assert.equal(new Headers(coordinator.calls[0]?.init?.headers).get("upgrade"), "websocket"); + + await interactiveTerminalFetch( + env, + { adapter: null }, + "ws://127.0.0.1:8787/terminal", + headers, + fallback.fetcher, + ); + assert.equal(String(fallback.calls[0]?.input), "http://127.0.0.1:8787/terminal"); +}); + +test("runtime adapter response parsing is bounded and preserves non-JSON error text", async () => { + assert.deepEqual( + await readRuntimeAdapterResponseBody( + new Response('{"status":"ready","workspaceId":"workspace-1"}'), + ), + { status: "ready", workspaceId: "workspace-1" }, + ); + assert.deepEqual(await readRuntimeAdapterResponseBody(new Response("provider unavailable")), { + message: "provider unavailable", + }); + assert.equal(await readRuntimeAdapterResponseBody(new Response(null)), null); + await assert.rejects( + readRuntimeAdapterResponseBody( + new Response("small", { headers: { "content-length": String(64 * 1024 + 1) } }), + ), + ResponseBodyLimitError, + ); +}); diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 984a08a..8cd52c3 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -704,27 +704,12 @@ test("worker deployment installs the shared runtime adapter credential", async ( ); }); -test("production runtime adapter calls use the Crabbox service binding", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); +test("production config installs the Crabbox coordinator service binding", async () => { const config = await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"); - const lifecycleStart = source.indexOf("async function runtimeAdapterFetch"); - const lifecycleEnd = source.indexOf("function runtimeAdapterFetcher", lifecycleStart); - const lifecycleSource = source.slice(lifecycleStart, lifecycleEnd); - const fetchStart = source.indexOf("function runtimeAdapterFetcher"); - const fetchEnd = source.indexOf("async function readRuntimeAdapterResponseBody", fetchStart); - const fetchSource = source.slice(fetchStart, fetchEnd); assert.match(config, /"binding": "CRABBOX_COORDINATOR"/); assert.match(config, /"service": "crabbox-coordinator"/); assert.match(config, /"CRABBOX_COORDINATOR_ORIGIN": "https:\/\/crabbox\.openclaw\.ai"/); - assert.match(fetchSource, /env\.CRABBOX_COORDINATOR/); - assert.match(fetchSource, /env\.CRABBOX_COORDINATOR_ORIGIN/); - assert.match(fetchSource, /normalizedTarget\.protocol === "wss:"/); - assert.match(fetchSource, /new URL\(coordinator\)\.origin === normalizedTarget\.origin/); - assert.match(fetchSource, /session\.adapter === runtimeAdapterName/); - assert.match(fetchSource, /interactiveTerminalFetch/); - assert.match(lifecycleSource, /runtimeAdapterFetcher\(env, target\)/); - assert.match(lifecycleSource, /fetcher\.fetch\(target/); }); test("session events and terminal finalization preserve archive anchors", async () => { @@ -795,7 +780,7 @@ test("runtime adapter operations stay bound to the registered control plane", as ); const inspectSource = source.slice(inspectStart, inspectEnd); const stopStart = source.indexOf("async function stopRuntimeAdapterWorkspace("); - const stopEnd = source.indexOf("async function runtimeAdapterFetch", stopStart); + const stopEnd = source.indexOf("function runtimeAdapterProviderConfigured", stopStart); const stopSource = source.slice(stopStart, stopEnd); assert.match(migration, /ADD COLUMN adapter_control_plane TEXT/); @@ -843,18 +828,6 @@ test("runtime adapter operations stay bound to the registered control plane", as assert.doesNotMatch(stopSource, /response\.status === 404[\s\S]*CRABBOX_RUNTIME_ADAPTER_URL/); }); -test("runtime adapter requests reject redirects with edge-supported fetch semantics", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const fetchStart = source.indexOf("async function runtimeAdapterFetch"); - const fetchEnd = source.indexOf("async function readRuntimeAdapterResponseBody", fetchStart); - const fetchSource = source.slice(fetchStart, fetchEnd); - - assert.match(fetchSource, /redirect: "manual"/); - assert.match(fetchSource, /response\.status >= 300 && response\.status < 400/); - assert.match(fetchSource, /runtime adapter redirect refused/); - assert.doesNotMatch(fetchSource, /redirect: "error"/); -}); - test("pending runtime adapter creates replay before any inspect", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const inspectStart = source.indexOf("async function inspectRuntimeAdapterWorkspace"); @@ -921,7 +894,7 @@ test("stopping create replay owns the exact persisted lifecycle", async () => { test("every session-bound adapter delete waits for create ambiguity to clear", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const releaseStart = source.indexOf("async function stopRuntimeAdapterWorkspaceForSession"); - const releaseEnd = source.indexOf("async function runtimeAdapterFetch", releaseStart); + const releaseEnd = source.indexOf("function runtimeAdapterProviderConfigured", releaseStart); const releaseSource = source.slice(releaseStart, releaseEnd); const pendingGateIndex = releaseSource.indexOf("if (registration?.adapter_create_pending !== 0)"); const providerDeleteIndex = releaseSource.indexOf("return stopRuntimeAdapterWorkspace("); @@ -1653,10 +1626,6 @@ test("definitive adapter create errors retain a redacted provider reason before releaseStart, ); const releaseSource = source.slice(releaseStart, releaseEnd); - const bodyStart = source.indexOf("async function readRuntimeAdapterResponseBody"); - const bodyEnd = source.indexOf("function runtimeAdapterProviderConfigured", bodyStart); - const bodySource = source.slice(bodyStart, bodyEnd); - const bodyReadIndex = createSource.indexOf( "responseBody = await readRuntimeAdapterResponseBody(response)", ); @@ -1674,9 +1643,6 @@ test("definitive adapter create errors retain a redacted provider reason before releaseSource.indexOf("stageFailedRuntimeAdapterRelease") < releaseSource.indexOf("stopRuntimeAdapterWorkspaceForSession"), ); - assert.match(bodySource, /await readBoundedResponseText\(response\)/); - assert.doesNotMatch(bodySource, /response\.(?:json|text)\(/); - assert.match(bodySource, /JSON\.parse\(body\)/); assert.equal( redactedAdapterResponseMessage( { detail: "capacity unavailable; token=private-value" }, @@ -1717,7 +1683,7 @@ test("successful DELETE requires an implicit or parsed release confirmation", () test("adapter DELETE evidence survives pending and confirmed release", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const deleteStart = source.indexOf("async function stopRuntimeAdapterWorkspace("); - const deleteEnd = source.indexOf("async function runtimeAdapterFetch", deleteStart); + const deleteEnd = source.indexOf("function runtimeAdapterProviderConfigured", deleteStart); const deleteSource = source.slice(deleteStart, deleteEnd); const reconcileStart = source.indexOf("async function reconcileStoppingRuntimeAdapterWorkspace"); const reconcileEnd = source.indexOf("type StoppingRuntimeAdapterReplay", reconcileStart); @@ -1864,7 +1830,7 @@ test("runtime adapter profile routes expand one allowlisted path segment", () => ); }); -test("adapter bodies share the bounded stream reader", async () => { +test("adapter operations share the bounded response parser", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const ranges = [ ["async function provisionWithRuntimeAdapter", "function persistedRuntimeAdapterSeconds"], @@ -1889,11 +1855,6 @@ test("adapter bodies share the bounded stream reader", async () => { assert.match(operation, /readRuntimeAdapterResponseBody\(response\)/); assert.doesNotMatch(operation, /response\.(?:json|text)\(/); } - const readerStart = source.indexOf("async function readRuntimeAdapterResponseBody"); - const readerEnd = source.indexOf("function runtimeAdapterProviderConfigured", readerStart); - const readerSource = source.slice(readerStart, readerEnd); - assert.match(readerSource, /readBoundedResponseText\(response\)/); - assert.doesNotMatch(readerSource, /response\.(?:json|text)\(/); }); test("desktop mint revalidates current ownership before redirect", async () => { @@ -1923,20 +1884,13 @@ test("desktop mint revalidates current ownership before redirect", async () => { assert.doesNotMatch(vncSource, /body:\s*JSON\.stringify\(\{\}\)/); }); -test("runtime adapter terminal upgrades use the coordinator service binding", async () => { +test("runtime adapter terminal upgrades use the shared transport", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const openStart = source.indexOf("async function openInteractiveTerminalUpstream"); const openEnd = source.indexOf("async function markInteractiveTerminalConnected", openStart); const openSource = source.slice(openStart, openEnd); - const fetchStart = source.indexOf("async function interactiveTerminalFetch"); - const fetchEnd = source.indexOf("async function runtimeAdapterFetch", fetchStart); - const fetchSource = source.slice(fetchStart, fetchEnd); assert.match(openSource, /interactiveTerminalFetch\(/); - assert.match(fetchSource, /session\.adapter === runtimeAdapterName/); - assert.match(fetchSource, /runtimeAdapterFetcher\(env, target\)/); - assert.match(fetchSource, /fetchTarget\.protocol === "wss:"/); - assert.match(fetchSource, /fetcher\.fetch\(fetchTarget, \{ headers \}\)/); }); test("runtime adapter terminal flow control stays explicit and end-to-end", async () => { From 1ab0c8ea2285927246b76305612e22e77cd837a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 15:14:05 +0100 Subject: [PATCH 050/109] refactor: remove legacy runtime providers --- CHANGELOG.md | 10 +- README.md | 22 +- docs/admin.md | 3 +- docs/api.md | 24 +- docs/architecture.md | 6 +- docs/index.md | 4 +- docs/quickstart.md | 2 +- docs/runs.md | 18 +- docs/spec-v2.md | 2 +- docs/spec.md | 16 +- src/fleet-state.ts | 19 +- src/index.ts | 384 ++------------------------- src/runtime-adapter.ts | 17 -- src/terminal-target.ts | 4 +- src/worker/env.ts | 12 - src/worker/session-provisioning.ts | 23 ++ src/worker/session-terminal-route.ts | 30 --- tests/fleet-state.test.ts | 7 - tests/runtime-adapter.test.ts | 32 +-- tests/session-provisioning.test.ts | 61 +++++ tests/session-terminal-route.test.ts | 26 +- tests/terminal-target.test.ts | 6 +- worker-configuration.d.ts | 12 - wrangler.jsonc | 1 - 24 files changed, 154 insertions(+), 587 deletions(-) create mode 100644 tests/session-provisioning.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f5c66..343b0a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Remove generic create-only provisioning, the external Cloudflare runner, and the ClawFleet compatibility provider so managed workspaces use only built-in Sandbox or the versioned runtime adapter. - Extract OpenClaw nudge and stop validation, audit ordering, terminal delivery, and best-effort delivery records into a directly tested mutation service. - Extract OpenClaw crabbox normalization, replay handling, branch preparation, timeout policy, and creation audit into a directly tested service. - Extract interactive-session lineage normalization, parent visibility, and canonical root derivation into a directly tested service. @@ -36,7 +37,7 @@ - Extract GitHub Actions session validation, idempotent registration, token rotation, resume reset, runner replacement, and evidence ordering. - Extract GitHub Actions work-state projection, heartbeat persistence, event suppression, terminal mapping, runner disconnect, and reread. - Extract GitHub Actions runner-connect validation, lifecycle projection, heartbeat persistence, and durable connection evidence. -- Extract interactive terminal route selection, bridge expansion, signed attach preservation, adapter authorization, runner targets, query projection, and headers. +- Extract interactive terminal route selection, bridge expansion, signed attach preservation, adapter authorization, query projection, and headers. - Extract terminal WebSocket relay queues, output acknowledgements, message normalization, authorization polling, and peer close handling. - Extract runtime-adapter lifecycle and terminal transport, coordinator binding selection, redirect refusal, and bounded response parsing. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. @@ -77,7 +78,7 @@ - Add durable steerable GitHub Actions sessions with service registration, scoped runner URLs, work-state heartbeats, Fleet metadata, and a SessionControlDO PTY relay. - Add a tenant-namespaced versioned runtime lifecycle adapter with replayable idempotent create, monotonic workspace identities, CAS reconciliation, durable terminal finalization, confirmed provider release before failure/stop, presence-aware capability/expiry tracking, authenticated transient VNC redirects, and deployment-neutral configuration. - Reconcile runtime lifecycles and every adapter's terminal archives on cron and direct access, preserve partial capability-object defaults while honoring authoritative lists and explicit terminal withdrawal, make PTY availability server-authoritative, preserve opaque signed terminal and desktop URLs byte-for-byte, retain adapter failure evidence through confirmed release and exact session-version archive finalization, preflight adapter credentials before session allocation, bind every external lifecycle to its immutable registered control plane, generation-fence managed and standalone Sandbox credential ownership across crashes and late requests, repair incomplete equal-count archives, run teardown only after an exact cleanup CAS, use unique concurrent archive attempts, and transactionally remove D1 archive pointers before best-effort R2 object cleanup. -- Harden adapter and terminal boundaries by redacting connection credentials from durable messages, requiring byte-exact grammar-valid workspace identity echoes, rejecting malformed non-null expiries and create-only `stopping` results, keeping recurring WebSocket authorization provider-free, and paging credential cleanup with durable fair-progress cursors while retaining Sandbox failure evidence. +- Harden adapter and terminal boundaries by redacting connection credentials from durable messages, requiring byte-exact grammar-valid workspace identity echoes, rejecting malformed non-null expiries, keeping recurring WebSocket authorization provider-free, and paging credential cleanup with durable fair-progress cursors while retaining Sandbox failure evidence. - Fence ambiguous create replay during stop to the exact registered lifecycle, require immutable-request ownership claims before the stateless hook can provision a managed session ID, expose standalone Sandbox terminals through their own bearer-authenticated WebSocket route, atomically pair terminal events with archive-finalization markers, and prevent older equal-count session snapshots from replacing newer archive pointers. - Require an exact durable lease or provision/refresh claim for every Sandbox credential-policy transition, atomically activate standalone owners with their matching policy generation, redact structured and header-form provider credentials, fence slow reconciliation by the original session revision and completion time, and reject adapter base URLs containing raw query or fragment delimiters. - Atomically fence credential-policy cleanup against its durable owner and revalidate ownership before unregistering, keep versioned-adapter terminal credentials behind Worker-owned PTY routes, and rotate a fresh agent token into every managed Sandbox provision claim. @@ -86,7 +87,7 @@ - Reject stale same-generation credential-policy registrations, preflight and atomically stage failed managed Sandbox claims, require the provision bearer for standalone stop after backend removal, and backfill D1-only terminal archives when R2 is enabled later. - Proactively generation-wrap migrated legacy Sandbox credential policies under a live durable lease before cleanup, preserve live pre-token sessions, and use crash-safe cron retries that retain unattended session credentials. - Bound every runtime-adapter response stream, revalidate desktop authorization after minting, make legacy local stops atomic with scheduled crash recovery, and redact credentials before opaque provider identifiers. -- Recover active credential policies after a post-registration crash, redact provider identities from structured adapter errors, and propagate terminal dimensions through configured bridge and runner PTY routes without rewriting opaque adapter URLs. +- Recover active credential policies after a post-registration crash, redact provider identities from structured adapter errors, and propagate terminal dimensions through configured bridge PTY routes without rewriting opaque adapter URLs. - Support an optional authoritative `GITHUB_REDIRECT_URI` deployment binding with strict HTTPS callback validation, canonical-origin login handoff, and callback host/path enforcement while retaining safe request-origin defaults. - Replace native browser confirms and prompts with accessible Crabfleet dialogs for session cleanup, shutdown, and share-link fallback. - Sharpen the app visual system with flatter controls, tighter surfaces, and restrained overlay elevation. @@ -167,8 +168,7 @@ - Keep Escape routed to focused Codex terminals instead of closing the session drawer. - Enable the experimental Codex goals feature in provisioned interactive sessions. - Fix interactive Codex session provisioning to show the terminal immediately and stream live PTY bytes into Ghostty. -- Add a Cloudflare container runner backend for standalone interactive session provisioning. -- Add a built-in interactive provision endpoint with generic runtime and ClawFleet adapter backends. +- Add a built-in interactive provision endpoint with durable standalone Sandbox ownership. - Add standalone interactive Codex CLI sessions with Ghostty grid attach and an external runtime provision hook. - Document the real deployed control-plane status, runtime adapter boundary, workflow config, and test stack. - Close open side drawers with Escape. diff --git a/README.md b/README.md index 215f363..c7bb7ca 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Crabfleet gives OpenClaw maintainers a fleet dashboard where every Codex crabbox - **Cloudflare Sandbox containers** for standalone interactive Codex CLI workspaces with live PTY attach. - **Runtime descriptors** for card scheduling evidence and capability display. - **Versioned lifecycle adapter** for idempotent external workspace creation, bounded status reconciliation, provider-backed deletion, terminal attachment, and authenticated transient desktop connections. -- **Provision endpoint** at `/api/provision/interactive` that can use the built-in Sandbox backend or retain a legacy create-only adapter or ClawFleet integration, with durable ownership and a bearer-authenticated standalone PTY route. +- **Provision endpoint** at `/api/provision/interactive` for durable built-in Sandbox ownership and a bearer-authenticated standalone PTY route. - **SessionControlDO relay** for one outbound GitHub Actions runner and multiple authenticated Ghostty viewers per action session. - **R2 session archives** for periodically refreshed interactive-session event NDJSON, transcripts, and summaries, finalized at terminal completion. - **GitHub API** for OAuth, org/team membership, and issue/PR previews across enabled repos. @@ -204,29 +204,17 @@ The Crabbox namespace cutover intentionally has no old-name compatibility. Exist - `GITHUB_ORG` – GitHub org for membership check (default: `openclaw`) - `GITHUB_TOKEN` – GitHub token for all enabled repo issue/PR previews and private repo `CRABBOX.md` refreshes (optional; public/default repo paths work without it) - `CRABBOX_TOKEN_ENCRYPTION_KEY` – Optional encryption key for per-session GitHub OAuth tokens; defaults to `GITHUB_CLIENT_SECRET` -- `CRABBOX_INTERACTIVE_PROVISION_URL` – Optional adapter endpoint for standalone Codex CLI workspaces -- `CRABBOX_INTERACTIVE_PROVISION_TOKEN` – Optional bearer token sent to the interactive provision endpoint; required when backend URLs below are configured and always required to stop an existing standalone Sandbox +- `CRABBOX_INTERACTIVE_PROVISION_TOKEN` – Required bearer token for the built-in Sandbox provision, PTY, and stop endpoints - `CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS` – Optional built-in standalone Sandbox lifetime, default `14400`, bounded to 300–86400 seconds -- `CRABBOX_RUNTIME_ADAPTER_URL` – Optional fixed base URL for the versioned workspace lifecycle adapter; mutually exclusive with `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE`, takes precedence over the legacy create-only runtime provision URL, and becomes immutable registration identity for each created lifecycle. Nested base paths are preserved; raw query or fragment delimiters are rejected. +- `CRABBOX_RUNTIME_ADAPTER_URL` – Optional fixed base URL for the versioned workspace lifecycle adapter; mutually exclusive with `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` and becomes immutable registration identity for each created lifecycle. Nested base paths are preserved; raw query or fragment delimiters are rejected. - `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` – Optional profile-routed alternative containing exactly one `{profile}` full path segment. Selected profile IDs must be lowercase DNS labels; the resolved URL is validated and persisted with the same immutable lifecycle fence as a fixed adapter URL. - `CRABBOX_COORDINATOR_ORIGIN` – Optional public origin corresponding to the `CRABBOX_COORDINATOR` service binding. Matching fixed or profile-routed lifecycle and terminal requests use the binding; other adapter origins use normal outbound fetch. - `CRABBOX_RUNTIME_ADAPTER_TOKEN` – Required bearer token for the versioned lifecycle adapter; sent only over HTTPS or literal loopback HTTP - `CRABBOX_RUNTIME_ADAPTER_NAMESPACE` – Required stable tenant namespace when the versioned adapter is enabled; a DNS-safe label of at most 32 characters used in every workspace ID and idempotency key - `CRABBOX_RUNTIME_ADAPTER_TTL_SECONDS` – Optional requested workspace TTL, default `14400` - `CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS` – Optional requested workspace idle timeout, default `1800` -- `CRABBOX_RUNTIME_PROVISION_URL` – Optional generic backend URL used by `/api/provision/interactive` -- `CRABBOX_RUNTIME_PROVISION_TOKEN` – Optional bearer token sent to the generic runtime backend -- `CRABBOX_CLOUDFLARE_RUNNER_URL` – Optional Crabbox Cloudflare container runner URL used by `/api/provision/interactive` -- `CRABBOX_CLOUDFLARE_RUNNER_TOKEN` – Optional bearer token sent to the Cloudflare runner -- `CRABBOX_CLOUDFLARE_RUNNER_INSTANCE_TYPE` – Optional runner instance type, default `standard-4` -- `CRABBOX_CLOUDFLARE_RUNNER_WORKDIR` – Optional base workdir for provisioned sandboxes, default `/workspace/crabbox` -- `CRABBOX_CLOUDFLARE_RUNNER_TTL_SECONDS` – Optional sandbox TTL, default `14400` -- `CRABBOX_CLOUDFLARE_RUNNER_IDLE_SECONDS` – Optional idle timeout, default `1800` - `CRABBOX_PTY_BRIDGE_URL` – Optional WebSocket PTY bridge URL/template for live Ghostty attach; requires WSS except literal loopback WS and supports `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}` - `CRABBOX_PTY_BRIDGE_TOKEN` – Optional bearer token sent from Crabfleet to the PTY bridge -- `CRABBOX_CLAWFLEET_URL` – Optional ClawFleet dashboard/API URL used by `/api/provision/interactive` for `crabbox` sessions -- `CRABBOX_CLAWFLEET_TOKEN` – Optional bearer token sent to ClawFleet -- `CRABBOX_CLAWFLEET_PUBLIC_URL` – Optional public ClawFleet URL used when building attach/VNC links - `CRABBOX_OPENCLAW_TOKEN` – Internal bearer token for OpenClaw service crabbox and GitHub Actions session registration - `CRABBOX_MULTICODEX_TOKEN` – Optional dedicated bearer token for MultiCodex room supervision - `CRABFLEET_SSH_GATEWAY_TOKEN` / `CRABBOX_SSH_GATEWAY_TOKEN` – Shared bearer token for the Go SSH gateway internal API @@ -327,7 +315,7 @@ go run ./cmd/crabbox-ssh-gateway Unknown public keys get a short GitHub OAuth link through `ssh link@host`. Linked keys can run `whoami`, `list`, `new`, `attach SESSION_ID`, and `delete SESSION_ID`; `new` creates an interactive Codex session and attaches. Delete confirms runtime release for versioned lifecycle -adapters; legacy create-only and ClawFleet sessions stop locally and may need provider cleanup. +adapters and cleans up built-in Sandbox sessions through their durable ownership records. Production should expose the gateway at `crabd.sh` as a DNS-only `A` record. Use `ssh link@crabd.sh` once to connect a GitHub-backed SSH key, then run @@ -382,7 +370,7 @@ curl -fsS https://crabfleet.openclaw.ai/api/openclaw/crabboxes \ -d '{"owner":"@steipete","repo":"openclaw/crabfleet","prompt":"prep the meeting follow-up"}' ``` -The created crabbox appears in the fleet grid under the requested owner. Provisioning follows normal interactive-session routing: built-in Sandbox for Container, the versioned adapter for Crabbox, or an intentionally configured legacy path. +The created crabbox appears in the fleet grid under the requested owner. Provisioning follows normal interactive-session routing: built-in Sandbox for Container or the versioned adapter for Crabbox. ### Project Structure diff --git a/docs/admin.md b/docs/admin.md index 98e8128..b5299a9 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -212,7 +212,7 @@ Worker secrets live in Cloudflare bindings, not D1/R2: - GitHub OAuth/client and optional deployment token. - Bootstrap token. -- Runtime adapter/provision/runner/ClawFleet tokens. +- Runtime adapter and standalone Sandbox provision tokens. - OpenClaw and SSH gateway service tokens. - OpenAI API key. - token-encryption key. @@ -347,7 +347,6 @@ Check the selected runtime and deployment backend: - built-in `SANDBOX` binding for Container; - `CRABBOX_RUNTIME_ADAPTER_URL` or `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE`, plus token and namespace, for versioned Crabbox; -- legacy provision/runner/ClawFleet settings only when intentionally used. ### Delete Remains `stopping` diff --git a/docs/api.md b/docs/api.md index 2c6f15a..313216a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -249,23 +249,20 @@ Public read-only endpoint for a generated session share link. Returns the shared ### POST /api/provision/interactive -Provision hook used by `CRABBOX_INTERACTIVE_PROVISION_URL`. It accepts the same session request payload as the external adapter contract and returns normalized provision status. +Provision hook for built-in Cloudflare Sandbox workspaces. It accepts the managed session request shape and returns normalized provision status. Auth: -- If `CRABBOX_INTERACTIVE_PROVISION_TOKEN` is set, callers must send `Authorization: Bearer `. -- The token is required when `CRABBOX_RUNTIME_ADAPTER_URL`, `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE`, `CRABBOX_RUNTIME_PROVISION_URL`, `CRABBOX_CLOUDFLARE_RUNNER_URL`, or `CRABBOX_CLAWFLEET_URL` is configured; backend-enabled deployments fail closed without it. +- Callers must send `Authorization: Bearer `. +- Provision, PTY, and standalone stop fail closed when the token is absent, including after the Sandbox binding is removed. Backends: - Versioned lifecycle adapters are deliberately excluded from this stateless hook. Create those workspaces through `POST /api/interactive-sessions`, which durably records ownership before calling the adapter. - Direct built-in Sandbox calls without a managed interactive-session row acquire a durable standalone ownership fence before credential-policy registration. Standalone IDs cannot use the case-insensitive `IS-` managed-session namespace. Retries with the same ID must match the original immutable request; abandoned claims and failed provisions enter the same generation-fenced cleanup path as managed sessions. - A request whose ID already belongs to a managed interactive session is rejected unless every immutable request field matches that row and the call wins an exact session-version ownership claim before allocating a Sandbox. Completion commits through the immutable lease, claim, agent-token, and status ownership fence while monotonically advancing the session version, so an intervening metadata edit does not discard the non-replayable result. -- `CRABBOX_RUNTIME_PROVISION_URL`: forwards the session payload to a legacy create-only runtime adapter. -- `CRABBOX_CLOUDFLARE_RUNNER_URL`: creates a Crabbox Cloudflare container sandbox and returns its lease reference. -- `CRABBOX_CLAWFLEET_URL`: creates a ClawFleet OpenClaw instance and returns console/noVNC links. -- ClawFleet handles `crabbox` sessions only; use `CRABBOX_RUNTIME_PROVISION_URL` or `CRABBOX_CLOUDFLARE_RUNNER_URL` for `container` sessions. -- If neither backend is configured, returns `pending_adapter` with a message that the route is live. +- Only `runtime=container` is accepted. Crabbox workspaces must use the versioned managed lifecycle. +- Without the `SANDBOX` binding, the request fails instead of selecting another provider. For a successful direct built-in Sandbox provision, `attachUrl` is an absolute `wss://` URL under `/api/provision/interactive/:id/pty`, and `expiresAt` is bounded by `CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS` (default four hours, maximum one day). Connect with the same `Authorization: Bearer ` header used for provisioning. The Worker validates the unexpired standalone owner and exact active credential-policy generation, strips the bearer before opening the Sandbox terminal, proxies the WebSocket while periodically revalidating that ownership, and closes both peers after stop, expiry, or policy revocation. It never routes the connection through `interactive_sessions`. `POST /api/provision/interactive/:id/stop` always requires that configured bearer, even if runtime backend bindings were removed after creation, and atomically moves the exact owner plus every matching policy into durable cleanup; expiry follows the same path from cron and PTY access, and cleanup terminates the Sandbox terminal execution session before deleting its owner row. @@ -279,7 +276,7 @@ Crabfleet authenticates every adapter request with `Authorization: Bearer CRABBO - `DELETE /v1/workspaces/:id`: stop/release. Crabfleet enters `stopping` before calling the adapter and marks the session stopped only after `204`, `404`, or a valid exact-ID terminal response confirms release; malformed successful bodies remain `stopping`. Plain-text and malformed-JSON responses are read once and sanitized before their evidence is retained. An explicit stop whose ownership claim loses returns success only when the exact workspace is already stopping or terminal; otherwise it returns a lifecycle conflict. - `POST /v1/workspaces/:id/connections/desktop`: mint a current transient desktop URL. The request has no body. `expiresAt` is optional; when present it must be in the future and no more than 15 minutes away. Accepted HTTPS URLs are treated as opaque signed connection material and redirected byte-for-byte without URL normalization. After minting, Crabfleet re-reads the exact current session status, control grant, capabilities, and registered adapter identity before redirecting; a concurrent stop, revocation, capability withdrawal, or lifecycle replacement discards the URL and denies access. -`CRABBOX_RUNTIME_ADAPTER_NAMESPACE` is required and must remain stable for the deployment. It prevents workspace and idempotency collisions when an adapter serves more than one Crabfleet tenant. The adapter workspace `id` is an immutable lifecycle route key and remains separate from an opaque `providerResourceId`; the provider identity is never interpreted as a legacy lease or sandbox ID. Create, inspect, and stop responses must echo the byte-exact requested DNS-safe `id`; whitespace normalization is not accepted. Responses use `status`, `id`, optional `providerResourceId`, `attachUrl`, `capabilities`, `expiresAt`, and `message`. Only a literal `null` clears a previously stored expiry; a malformed non-null timestamp invalidates the response. A terminal URL implies terminal capability only when the response omits a terminal capability; an explicit `terminal: false` wins. Supported status values include `provisioning`, `ready`, `stopping`, `stopped`, `expired`, and `failed`. Create-only legacy adapters cannot return `stopping`, because they do not own a later reconciliation lifecycle. Every session-bound provider DELETE is gated on the persisted create ambiguity marker being clear. +`CRABBOX_RUNTIME_ADAPTER_NAMESPACE` is required and must remain stable for the deployment. It prevents workspace and idempotency collisions when an adapter serves more than one Crabfleet tenant. The adapter workspace `id` is an immutable lifecycle route key and remains separate from an opaque `providerResourceId`; the provider identity is never interpreted as a Sandbox lease ID. Create, inspect, and stop responses must echo the byte-exact requested DNS-safe `id`; whitespace normalization is not accepted. Responses use `status`, `id`, optional `providerResourceId`, `attachUrl`, `capabilities`, `expiresAt`, and `message`. Only a literal `null` clears a previously stored expiry; a malformed non-null timestamp invalidates the response. A terminal URL implies terminal capability only when the response omits a terminal capability; an explicit `terminal: false` wins. Supported status values include `provisioning`, `ready`, `stopping`, `stopped`, `expired`, and `failed`. Every session-bound provider DELETE is gated on the persisted create ambiguity marker being clear. Every create, inspect, delete, and desktop response body is consumed through one 64 KiB bounded stream reader before JSON or text parsing. Declared or chunked oversized bodies are cancelled and fail safely: ambiguous create remains reconcilable, delete remains pending, inspect retries later, and desktop access is denied. @@ -319,9 +316,8 @@ Target resolution: - `CRABBOX_PTY_BRIDGE_URL`: explicit bridge WebSocket URL/template. Templates support `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}`. Crabfleet appends `sessionId`, `leaseId`, `repo`, `branch`, `runtime`, and `command` query parameters. - Provider terminal connection: if the provision adapter returned a `wss://` URL, or literal loopback `ws://` URL, Crabfleet retains it server-side and proxies to it unchanged, including its path and signed query string. -- `CRABBOX_CLOUDFLARE_RUNNER_URL`: for `cloudflare:` leases, Crabfleet proxies to `/v1/sandboxes/:sandbox/pty` on the runner. -The hub appends terminal `cols` and `rows` only to configured bridge and Cloudflare runner endpoints, never to an adapter `attachUrl`. Crabfleet authenticates versioned-adapter terminal upgrades with `CRABBOX_RUNTIME_ADAPTER_TOKEN` only when the terminal shares the persisted and currently configured adapter origin; adapter URLs never carry reusable shell credentials. If `CRABBOX_PTY_BRIDGE_TOKEN` or `CRABBOX_CLOUDFLARE_RUNNER_TOKEN` is set, Crabfleet sends it as a bearer token only to the upstream bridge/runner. Clients never receive upstream credentials. +The hub appends terminal `cols` and `rows` only to configured bridge endpoints, never to an adapter `attachUrl`. Crabfleet authenticates versioned-adapter terminal upgrades with `CRABBOX_RUNTIME_ADAPTER_TOKEN` only when the terminal shares the persisted and currently configured adapter origin; adapter URLs never carry reusable shell credentials. If `CRABBOX_PTY_BRIDGE_TOKEN` is set, Crabfleet sends it only to the upstream bridge. Clients never receive upstream credentials. ### POST /api/interactive-sessions/:id/clipboard @@ -420,9 +416,9 @@ Fields: - `purpose`: optional short mission label. - `summary`: optional list/closeout summary. -If `CRABBOX_RUNTIME_ADAPTER_URL` or `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` is configured, the Worker creates and reconciles the versioned adapter workspace and records its resolved lifecycle identity, status, capabilities, expiry, and terminal connection. Otherwise `CRABBOX_INTERACTIVE_PROVISION_URL` retains the legacy create-only behavior. Without an adapter the session is stored as `pending_adapter`. +Container sessions use the built-in Sandbox when its binding is available. Otherwise, and for Crabbox sessions, `CRABBOX_RUNTIME_ADAPTER_URL` or `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` creates and reconciles the versioned adapter workspace and records its resolved lifecycle identity, status, capabilities, expiry, and terminal connection. Without either supported backend the session is stored as `pending_adapter`. -Session responses include `ptyAvailable`, the authenticated Worker's authoritative answer for whether the current terminal capability, lifecycle state, and configured Sandbox/bridge/runner route can resolve a PTY connection. Every controllable session exposes only the Worker-owned `/api/terminal/ws` route in `attachUrl`; signed provider connections remain server-side even for owners and controllers. +Session responses include `ptyAvailable`, the authenticated Worker's authoritative answer for whether the current terminal capability, lifecycle state, and configured Sandbox, bridge, or adapter route can resolve a PTY connection. Every controllable session exposes only the Worker-owned `/api/terminal/ws` route in `attachUrl`; signed provider connections remain server-side even for owners and controllers. When the selected runtime profile configures `codexSsh`, a ready `runtime-v1` session response may include `codexSsh: { alias, setupCommand }` for session managers. The alias and optional command are resolved from bounded `{providerResourceId}`, `{workspaceId}`, `{sessionId}`, and `{profile}` placeholders. Alias components use a strict OpenSSH-safe character set. `codexSsh.setupCommand` is an argv-like array whose first and static items use a shell-safe character set and whose dynamic items must each be one complete placeholder; Crabfleet POSIX-shell-quotes every substituted argument so opaque provider identifiers remain data. Missing values, an unsafe resolved alias, or a current profile route that differs from the workspace's immutable registered adapter control plane suppresses the handoff. Shared links and delegated terminal-only controllers never receive it. The command is display/copy data only; Crabfleet never executes it. @@ -492,7 +488,7 @@ Actions: - `revoke_control`: owner/maintainer, revoke active delegated control. - `enable_multiplayer`: session creator, prefix submitted terminal prompts with the actor. - `disable_multiplayer`: session creator, stop prefixing submitted terminal prompts with the actor. -- `stop`: owner/maintainer, internal wire action behind user-facing Delete, Stop, or End. Versioned adapters release the provider workspace before marking stopped, and asynchronous releases remain `stopping` until reconciliation confirms completion. Legacy create-only and ClawFleet sessions stop only in Crabfleet because those integrations expose no release lifecycle. For GitHub Actions, End disconnects and finalizes only the Crabfleet terminal session; it does not call GitHub's workflow-cancellation API, so the workflow run may continue. +- `stop`: owner/maintainer, internal wire action behind user-facing Delete or End. Versioned adapters release the provider workspace before marking stopped, and asynchronous releases remain `stopping` until reconciliation confirms completion. Built-in Sandbox sessions clean up their durable lease and credential policy. For GitHub Actions, End disconnects and finalizes only the Crabfleet terminal session; it does not call GitHub's workflow-cancellation API, so the workflow run may continue. Response: diff --git a/docs/architecture.md b/docs/architecture.md index 3c72510..79a487b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,8 +115,6 @@ Interactive sessions are the live execution plane. Supported paths: - **Built-in Sandbox:** Worker provisions a Cloudflare Sandbox, prepares the repo, starts a Codex-capable shell, and proxies PTY traffic. - **Versioned runtime adapter:** Worker durably registers a tenant-namespaced workspace ID, creates and reconciles the provider workspace, proxies PTY access, mints transient desktop links, and confirms provider release before terminal state. -- **Legacy provision hook:** create-only compatibility path. It can return terminal/VNC metadata but has no provider release lifecycle. -- **ClawFleet compatibility:** create-only Crabbox integration retained for deployments still using it. - **GitHub Actions:** OpenClaw automation registers a logical work key; an Actions runner connects outbound to `SessionControlDO`, reports work state, and receives browser steering. Sessions can carry parent/root lineage, purpose, summary, share state, delegated control, multiplayer mode, archive metadata, and runtime-specific capability state. @@ -154,10 +152,10 @@ Browser, CLI, agent, and SSH gateway clients use the multiplex `/api/terminal/ws - rechecks D1 authorization without waiting on provider I/O; - forwards input only for the current controller; - closes subscriptions after control or terminal capability is revoked; -- appends dimensions only to known bridge/runner routes, never opaque signed adapter URLs; +- appends dimensions only to configured bridge routes, never opaque signed adapter URLs; - keeps runtime bearer credentials out of browser responses. -Versioned adapter VNC uses the authenticated Worker route `/api/interactive-sessions/:id/vnc`, which mints a fresh provider desktop connection after authorization. Legacy sessions may expose a validated stored VNC URL. +Versioned adapter VNC uses the authenticated Worker route `/api/interactive-sessions/:id/vnc`, which mints a fresh provider desktop connection after authorization. ## Sandbox Credentials diff --git a/docs/index.md b/docs/index.md index b99cdb1..4afdcd8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,7 +32,7 @@ The web app at [crabfleet.openclaw.ai/app](https://crabfleet.openclaw.ai/app/) e ## What Crabfleet Does - **SSH-first onboarding.** Connect through `ssh link@crabd.sh`, complete GitHub sign-in, then use linked-key auth. -- **Crabbox control.** Create, attach, share, open WebVNC, delete provider-backed runtime workspaces, stop legacy sessions locally, and clean up retained Codex session history. +- **Crabbox control.** Create, attach, share, open WebVNC, delete provider-backed runtime workspaces, and clean up retained Codex session history. - **Fleet visibility.** The app groups all org Codex instances by person so OpenClaw can supervise live work. - **Repo-gated cards.** Prompt cards and GitHub issue/PR previews stay scoped to enabled OpenClaw repos. - **Runtime policy.** Crabfleet records runtime selection, capabilities, heartbeat, stall state, and operator intent. @@ -93,7 +93,7 @@ Cards represent task intent and policy: ### Runs -When a card enters Running, Crabfleet creates a `run_attempts` row, selects a runtime descriptor, records the selection reason and capabilities, and starts heartbeat/stall tracking. This is durable scheduling/control evidence, not an autonomous process launch. Live work is represented by interactive sessions, including built-in Sandbox, versioned Crabbox, legacy adapter, ClawFleet, and GitHub Actions-backed sessions. +When a card enters Running, Crabfleet creates a `run_attempts` row, selects a runtime descriptor, records the selection reason and capabilities, and starts heartbeat/stall tracking. This is durable scheduling/control evidence, not an autonomous process launch. Live work is represented by interactive sessions, including built-in Sandbox, versioned Crabbox, and GitHub Actions-backed sessions. ### Repo Workflows diff --git a/docs/quickstart.md b/docs/quickstart.md index 9e43021..7c67f10 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -82,7 +82,7 @@ crabfleet new --repo openclaw/crabfleet "fix the failing check" The CLI omits `runtime` unless `--runtime` is passed, so the deployment chooses via `CRABFLEET_DEFAULT_RUNTIME` (`container` when unset). The OpenClaw deployment supports built-in Cloudflare Sandbox sessions and versioned Crabbox workspaces. -End a session with `crabfleet delete `. Versioned lifecycle adapters confirm runtime release; legacy create-only and ClawFleet sessions stop only in Crabfleet and may require separate provider cleanup. Crabfleet retains the final status and logs until you clean up the dead session record. +End a session with `crabfleet delete `. Versioned lifecycle adapters confirm runtime release, while built-in Sandbox sessions clean up their durable lease and credential policy. Crabfleet retains the final status and logs until you clean up the dead session record. Useful follow-up commands: diff --git a/docs/runs.md b/docs/runs.md index a1e81ee..bf6d399 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -82,7 +82,7 @@ Attach opens a fullscreen Ghostty WASM grid. Current behavior: - Shows one or more Codex session tiles. - Includes standalone interactive Codex CLI sessions created from New session. - Uses the local `ghostty-web` bundle served by the Worker. -- Streams live PTY bytes through the multiplex `/api/terminal/ws` hub when a sandbox or bridge is configured. +- Streams live PTY bytes through the multiplex `/api/terminal/ws` hub when a Sandbox, bridge, or versioned adapter terminal is available. - Replays D1 event logs into the terminal surface while a live PTY is unavailable. - Falls back to a text terminal if Ghostty cannot initialize. - Copies terminal selection, pastes clipboard text when the viewer has writable control, and uploads clipboard images/files for Cloudflare Sandbox sessions. @@ -100,28 +100,22 @@ Deployments can expose an allowlisted set of generic Crabbox profiles. The creat Interactive sessions also store `parentSessionId`, `rootSessionId`, `createdBy`, `purpose`, and `summary`. Built-in Sandbox sessions export `CRABFLEET_SESSION_ID`, `CRABFLEET_PARENT_SESSION_ID`, `CRABFLEET_ROOT_SESSION_ID`, `CRABFLEET_AGENT_TOKEN`, and `CRABFLEET_API_URL`; the Go CLI uses those values to list sibling/child sessions, create children, send PTY messages, fetch transcripts, and update summaries without an SSH key. -Adapter capability arrays are authoritative: omitting `terminal`, `pty`, or `ssh` withdraws terminal access. A valid WSS (or literal-loopback WS) terminal URL implies terminal access only when capabilities are omitted entirely or an object omits all terminal-related keys. `ptyAvailable` additionally requires a ready lifecycle state and a resolvable configured Sandbox, bridge, direct WebSocket, or Cloudflare runner route. +Adapter capability arrays are authoritative: omitting `terminal`, `pty`, or `ssh` withdraws terminal access. A valid WSS (or literal-loopback WS) terminal URL implies terminal access only when capabilities are omitted entirely or an object omits all terminal-related keys. `ptyAvailable` additionally requires a ready lifecycle state and a resolvable configured Sandbox, bridge, or direct WebSocket route. Session events are mirrored into the `SESSION_LOGS` R2 binding when configured. Crabfleet writes NDJSON, Markdown transcript, and summary objects under `orgs/openclaw/interactive-sessions//`, while D1 keeps the compact event list and archive keys for the app, CLI, and SSH gateway. If the binding is enabled after D1-only terminal archives were finalized, cron and targeted reconciliation requeue their null-key snapshots and backfill the objects before cleanup. Cleanup transactionally removes the finalized D1 session, events, and archive pointers before best-effort R2 deletion, so a partial object-delete failure is an unreferenced leak rather than a dangling archive reference. Stops for local legacy sessions atomically commit the request event, stopped event, terminal state, and finalization marker; cron and targeted access repair older `stopping` rows left by interrupted deployments. Sandbox credential policies have a separate durable cleanup lifecycle. Registration commits a generation and expiring claim in D1 before any external POST. If the Durable Object accepted every alias before the Worker crashed, reconciliation verifies that matching generation and the exact live owner, clears the expired D1 claim, and promotes the group to active before cleanup scanning; transient lookup or ownership failures defer cleanup. The upgrade migration seeds active legacy policies for proactive repair: cron claims each exact live lease, atomically generation-wraps every retained raw Durable Object policy, and activates all lookup aliases. A raw lookup also runs this fenced repair synchronously and retries once, avoiding a credential gap before the first cron pass. A crash before D1 completion leaves an expiring repair claim; the next pass resumes the same generation idempotently, while stop can still stage cleanup. Raw records remain unserved but retained until this repair or authorized cleanup. Credential injection rechecks that complete active generation and its exact D1 owner, so raw legacy Durable Object records, expired standalone policies, and orphaned generations fail closed. A registration error for an expected live current lease clears into a retryable registration state; an owner transition instead stages that generation for cleanup. Stop, expiry, provisioning failure, and superseded-resource cleanup atomically pair the durable owner transition with policy staging, revoke the session agent token and terminal control, terminate standalone terminal execution sessions, wait out live registration claims, and revalidate that no live owner still expects the Sandbox before persisting a matching generation tombstone; this makes both lost owner CAS operations and late POSTs harmless across Worker termination. Bounded persisted scan/group cursors keep large cleanup backlogs fair. Failed or partial deletes remain `stopping` and retry from cron until every recorded policy lookup is gone, then enter normal terminal archive finalization with the original failure reason intact. A standalone terminal-destruction failure is recorded on that owner and retried without blocking other cleanup owners, runtime-adapter reconciliation, or terminal archives. -Managed session creation first uses the built-in Sandbox when `runtime=container` and the `SANDBOX` binding is available. Otherwise a configured versioned adapter owns the durable lifecycle; a legacy create-only provision URL remains available for compatibility. If no usable path exists, the session stays `pending_adapter` and remains visible in the Ghostty grid. +Managed session creation first uses the built-in Sandbox when `runtime=container` and the `SANDBOX` binding is available. Otherwise a configured versioned adapter owns the durable lifecycle. If neither supported path exists, the session stays `pending_adapter` and remains visible in the Ghostty grid. -Crabfleet also ships a stateless provision hook at `/api/provision/interactive`. The OpenClaw deployment points `CRABBOX_INTERACTIVE_PROVISION_URL` at this in-process route. `CRABBOX_INTERACTIVE_PROVISION_TOKEN` is required when a backend is configured. Direct standalone Sandboxes reject the reserved `IS-` namespace, expire after the bounded `CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS`, and stop through `/api/provision/interactive/:id/stop`. The hook can delegate to a legacy generic runtime backend, a Cloudflare runner, or a ClawFleet compatibility backend; versioned lifecycle workspaces are deliberately created through the managed session API instead. +Crabfleet also ships a built-in Sandbox provision hook at `/api/provision/interactive`. Every provision, PTY, and stop request requires `CRABBOX_INTERACTIVE_PROVISION_TOKEN`. Direct standalone Sandboxes reject the reserved `IS-` namespace, expire after the bounded `CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS`, and stop through `/api/provision/interactive/:id/stop`. The hook accepts only `runtime=container`; external workspaces are deliberately created through the managed versioned lifecycle instead. -Cloudflare runner configuration: +Optional bridge configuration: -- `CRABBOX_CLOUDFLARE_RUNNER_URL`: Crabbox Cloudflare container runner base URL. -- `CRABBOX_CLOUDFLARE_RUNNER_TOKEN`: runner bearer token. -- `CRABBOX_CLOUDFLARE_RUNNER_INSTANCE_TYPE`: `lite`, `basic`, `standard-1`, `standard-2`, `standard-3`, or `standard-4`; default `standard-4`. -- `CRABBOX_CLOUDFLARE_RUNNER_WORKDIR`: base workspace path; default `/workspace/crabbox`. -- `CRABBOX_CLOUDFLARE_RUNNER_TTL_SECONDS`: default `14400`. -- `CRABBOX_CLOUDFLARE_RUNNER_IDLE_SECONDS`: default `1800`. - `CRABBOX_PTY_BRIDGE_URL`: optional explicit PTY bridge WebSocket URL/template. Templates support `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}`. - `CRABBOX_PTY_BRIDGE_TOKEN`: optional bearer token sent only from Crabfleet to the bridge. -Runner PTY contract: +Bridge PTY contract: - Crabfleet accepts browser, CLI, agent, and SSH gateway WebSockets on `/api/terminal/ws` and multiplexes one or more subscribed sessions. - Crabfleet connects upstream to the configured bridge with `Upgrade: websocket`. diff --git a/docs/spec-v2.md b/docs/spec-v2.md index 8d34367..9a3c251 100644 --- a/docs/spec-v2.md +++ b/docs/spec-v2.md @@ -119,7 +119,7 @@ Per-session summary: - `policy.hasGithubToken` - `policy.openAIBaseUrlHost` -`attachable` is true only for terminal-capable `ready`, `attached`, or `detached` sessions with current control and a resolvable Sandbox, configured bridge, valid WSS/literal-loopback WS URL, or configured Cloudflare runner route. +`attachable` is true only for terminal-capable `ready`, `attached`, or `detached` sessions with current control and a resolvable Sandbox, configured bridge, or valid WSS/literal-loopback WS URL. ## Security diff --git a/docs/spec.md b/docs/spec.md index 4fb18bd..9a10c72 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -159,17 +159,9 @@ Required properties: Ambiguous creates are replayed only with the original payload and key. A `workspace_id_conflict` proves non-ownership and never causes Crabfleet to adopt or delete the pre-existing workspace. -### Legacy Backends +### Provision Hook -Compatibility paths remain for: - -- create-only `CRABBOX_INTERACTIVE_PROVISION_URL`; -- `CRABBOX_RUNTIME_PROVISION_URL`; -- `CRABBOX_CLOUDFLARE_RUNNER_URL`; -- `CRABBOX_CLAWFLEET_URL`; -- explicit `CRABBOX_PTY_BRIDGE_URL`. - -These paths do not gain lifecycle guarantees they do not implement. Legacy sessions may stop only inside Crabfleet when the backend has no delete/release contract. +`POST /api/provision/interactive` provisions only built-in Cloudflare Sandbox workspaces. External workspaces use the versioned runtime-adapter lifecycle through managed interactive sessions. ### GitHub Actions @@ -206,7 +198,7 @@ Terminal attachability requires: - active lifecycle status; - current `terminal` capability; -- a resolvable built-in Sandbox, bridge, runner, or provider terminal; +- a resolvable built-in Sandbox, bridge, or provider terminal; - current viewer authorization and control state. `ptyAvailable` is the Worker-authoritative result. Raw provider terminal credentials are never returned to clients. @@ -283,7 +275,7 @@ D1 is the source of truth for current app state. - Sandbox credential-policy registry; - checkpoint registry. -The Worker owns the general multiplex terminal hub and connects each subscription to its Sandbox, bridge, runner, or adapter backend. There is no `BoardDO` or `RunDO`. Board and Fleet state use D1 plus REST polling. The browser refreshes general state every 15 seconds; terminal bytes use WebSockets. +The Worker owns the general multiplex terminal hub and connects each subscription to its Sandbox, bridge, or adapter backend. There is no `BoardDO` or `RunDO`. Board and Fleet state use D1 plus REST polling. The browser refreshes general state every 15 seconds; terminal bytes use WebSockets. ### R2 diff --git a/src/fleet-state.ts b/src/fleet-state.ts index aadb977..36764c9 100644 --- a/src/fleet-state.ts +++ b/src/fleet-state.ts @@ -1,4 +1,4 @@ -import { normalizedSecureHttpUrl, normalizedSecureWebSocketUrl } from "./url-security.ts"; +import { normalizedSecureWebSocketUrl } from "./url-security.ts"; export type FleetStatus = | "provisioning" @@ -73,7 +73,6 @@ export type FleetStateOptions = { registryAvailable?: boolean; sandboxAvailable?: boolean | undefined; ptyBridgeUrl?: string | null | undefined; - cloudflareRunnerUrl?: string | null | undefined; }; export type FleetSessionSummary = { @@ -146,7 +145,7 @@ export type FleetState = { sessions: FleetSessionSummary[]; }; -export type PtyRouteKind = "sandbox" | "bridge" | "attach" | "cloudflare"; +export type PtyRouteKind = "sandbox" | "bridge" | "attach"; export type PtyRouteSession = { adapter?: string | null; @@ -157,7 +156,6 @@ export type PtyRouteSession = { export type PtyRouteConfig = { sandboxAvailable?: boolean | undefined; bridgeUrl?: string | null | undefined; - cloudflareRunnerUrl?: string | null | undefined; }; const allStatuses: FleetStatus[] = [ @@ -234,10 +232,7 @@ export function buildFleetState( export function fleetSessionSummary( session: FleetSessionInput, policy: FleetSandboxPolicySummary | null, - options: Pick< - FleetStateOptions, - "sandboxAvailable" | "ptyBridgeUrl" | "cloudflareRunnerUrl" - > = {}, + options: Pick = {}, ): FleetSessionSummary { const sandboxId = sandboxIdFromLeaseId(session.leaseId); const archived = Boolean(session.logArchive?.eventCount); @@ -276,7 +271,6 @@ export function fleetSessionSummary( ptyRouteKind(session, { sandboxAvailable: options.sandboxAvailable, bridgeUrl: options.ptyBridgeUrl, - cloudflareRunnerUrl: options.cloudflareRunnerUrl, }), ))) && ptyReadyStatuses.has(session.status), @@ -321,9 +315,6 @@ export function ptyRouteKind( if (config.sandboxAvailable && leaseId?.startsWith("sandbox:")) return "sandbox"; if (configuredBridgeWebSocketUrl(config.bridgeUrl)) return "bridge"; if (safePtyWebSocketUrl(session.attachUrl)) return "attach"; - if (leaseId?.startsWith("cloudflare:") && safePtyHttpUrl(config.cloudflareRunnerUrl ?? null)) { - return "cloudflare"; - } return null; } @@ -345,7 +336,3 @@ function configuredBridgeWebSocketUrl(value: string | null | undefined): string function safePtyWebSocketUrl(value: string | null | undefined): string | null { return normalizedSecureWebSocketUrl(value); } - -function safePtyHttpUrl(value: string | null | undefined): string | null { - return normalizedSecureHttpUrl(value); -} diff --git a/src/index.ts b/src/index.ts index 487bf78..a4b5e5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,17 +47,10 @@ import { SPEC_V2_HTML, SPEC_V2_MARKDOWN, } from "./generated"; -import { - appCanonicalHost, - appCanonicalOrigin, - appRedirectHosts, - canonicalAppRedirect, - productHostResponse, -} from "./canonical-host"; +import { appCanonicalOrigin, canonicalAppRedirect, productHostResponse } from "./canonical-host"; import { adapterFailureReleaseState, adapterWorkspaceIdMatches, - createOnlyAdapterStatus, definitiveRuntimeAdapterCreateFailure, effectiveAdapterCapabilities, currentAdapterDesktopConnection, @@ -324,7 +317,11 @@ import { type RuntimeAdapterWorkspaceStopResult, } from "./worker/session-runtime-adapter-stop"; import { sharedInteractiveSession } from "./worker/session-sharing"; -import type { InteractiveProvisionResult } from "./worker/session-provisioning"; +import { + managedInteractiveProvisionBackend, + standaloneInteractiveProvisionSupported, + type InteractiveProvisionResult, +} from "./worker/session-provisioning"; import { interactiveCommand, interactiveSessionPurpose, @@ -623,21 +620,6 @@ type StandaloneSandboxTerminalOwnership = { type SandboxExecutionSession = Awaited>; type SandboxSessionTarget = Pick; -type ClawFleetInstancePayload = { - name?: string; - status?: string; - novnc_port?: number; - gateway_port?: number; -}; - -type CloudflareSandboxPayload = { - id?: string; - state?: string; - workdir?: string; - instanceType?: string; - labels?: Record; -}; - type ChangedFile = { path: string; oldPath?: string; @@ -3191,7 +3173,6 @@ async function readFleetState( registryAvailable: policyResult.available, sandboxAvailable: Boolean(env.SANDBOX), ptyBridgeUrl: env.CRABBOX_PTY_BRIDGE_URL, - cloudflareRunnerUrl: env.CRABBOX_CLOUDFLARE_RUNNER_URL, }); } @@ -6585,7 +6566,11 @@ async function provisionInteractiveSession( ownership: SandboxCurrentLeaseFence; }, ): Promise { - if (session.runtime === "container" && env.SANDBOX) { + const backend = managedInteractiveProvisionBackend(session.runtime, { + sandbox: Boolean(env.SANDBOX), + runtimeAdapter: runtimeAdapterConfigurationPresent(env), + }); + if (backend === "sandbox") { if (!sandboxProvision) { return failedProvision("Cloudflare Sandbox durable ownership is missing"); } @@ -6597,75 +6582,17 @@ async function provisionInteractiveSession( sandboxProvision.ownership, ); } - if (runtimeAdapterConfigurationPresent(env)) { + if (backend === "runtime-adapter") { return provisionWithRuntimeAdapter(env, session, agentToken); } - if (!env.CRABBOX_INTERACTIVE_PROVISION_URL) return null; - if (isBuiltInInteractiveProvisionUrl(env, env.CRABBOX_INTERACTIVE_PROVISION_URL)) { - return provisionInteractivePayload(env, session, agentToken); - } - let response: Response; - try { - const headers = new Headers({ "content-type": "application/json" }); - if (env.CRABBOX_INTERACTIVE_PROVISION_TOKEN) { - headers.set("authorization", `Bearer ${env.CRABBOX_INTERACTIVE_PROVISION_TOKEN}`); - } - response = await fetch(env.CRABBOX_INTERACTIVE_PROVISION_URL, { - method: "POST", - headers, - body: JSON.stringify(session), - }); - } catch (error) { - return { - status: "failed", - leaseId: null, - attachUrl: null, - vncUrl: null, - message: `interactive provision failed: ${clean(String(error), 240)}`, - }; - } - if (!response.ok) { - return { - status: "failed", - leaseId: null, - attachUrl: null, - vncUrl: null, - message: `interactive provision failed: HTTP ${response.status}`, - }; - } - const body = (await response.json().catch(() => ({}))) as Record; - const status = createOnlyAdapterStatus(body.status); - if (!status) { - return { - status: "failed", - leaseId: null, - attachUrl: null, - vncUrl: null, - message: "interactive provision failed: invalid adapter response", - }; - } - const leaseId = clean(body.leaseId ?? body.lease_id, 240) || null; - const attachUrl = clean(body.attachUrl ?? body.attach_url, 1000) || null; - const vncUrl = clean(body.vncUrl ?? body.vnc_url, 1000) || null; - return { - status, - leaseId, - attachUrl, - vncUrl, - message: redactedAdapterMessage( - clean(body.message, 500) || null, - status, - [leaseId], - [attachUrl, vncUrl], - ), - }; + return null; } async function provisionInteractiveEndpoint( request: Request, env: RuntimeEnv, ): Promise { - authorizeProvisionEndpoint(request, env); + authorizeProvisionBearerToken(request, env); const session = await readJson>(request); const id = clean(session.id, 120); const repo = normalizeRepo(session.repo); @@ -6715,7 +6642,7 @@ async function provisionInteractiveEndpoint( } return provisionManagedSandboxEndpoint(env, payload, managed); } - if (payload.runtime === "container" && env.SANDBOX) { + if (standaloneInteractiveProvisionSupported(payload.runtime, Boolean(env.SANDBOX))) { if (managedInteractiveSessionId(payload.id)) { return failedProvision( "interactive provision failed: standalone provision id uses the managed session namespace", @@ -6723,7 +6650,11 @@ async function provisionInteractiveEndpoint( } return provisionStandaloneSandbox(env, payload); } - return provisionInteractivePayload(env, payload); + return failedProvision( + payload.runtime === "container" + ? "interactive provision failed: Cloudflare Sandbox binding is not configured" + : "interactive provision failed: standalone provision supports container runtime only", + ); } function managedSandboxProvisionPayloadMatches( @@ -7370,7 +7301,7 @@ async function standaloneSandboxPty( env: RuntimeEnv, provisionId: string, ): Promise { - authorizeProvisionEndpoint(request, env); + authorizeProvisionBearerToken(request, env); if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { throw badRequest("websocket upgrade required"); } @@ -7488,70 +7419,6 @@ function standaloneSandboxTerminalGrant( }); } -function isBuiltInInteractiveProvisionUrl(env: RuntimeEnv, value: string): boolean { - if (value === "/api/provision/interactive") return true; - try { - const url = new URL(value); - return ( - url.pathname === "/api/provision/interactive" && - (url.hostname === new URL(deploymentConfig(env).canonicalUrl).hostname || - url.hostname === appCanonicalHost || - appRedirectHosts.has(url.hostname)) - ); - } catch { - return false; - } -} - -async function provisionInteractivePayload( - env: RuntimeEnv, - payload: InteractiveProvisionRequest, - _agentToken?: string, -): Promise { - if (payload.runtime === "container" && env.SANDBOX) { - return failedProvision("Cloudflare Sandbox provision requires durable ownership"); - } - if (runtimeAdapterConfigurationPresent(env)) { - return failedProvision( - "versioned runtime adapter requires a durable interactive session lifecycle", - ); - } - if (env.CRABBOX_RUNTIME_PROVISION_URL) { - return forwardRuntimeProvision(env, payload); - } - if (payload.runtime === "container" && env.CRABBOX_CLOUDFLARE_RUNNER_URL) { - return provisionWithCloudflareRunner(env, payload); - } - if (payload.runtime === "crabbox" && env.CRABBOX_CLAWFLEET_URL) { - return provisionWithClawFleet(env, payload); - } - return { - status: "pending_adapter", - leaseId: null, - attachUrl: null, - attachUrlPresent: true, - vncUrl: null, - message: "provision route live; runtime backend not configured", - }; -} - -function authorizeProvisionEndpoint(request: Request, env: RuntimeEnv): void { - const hasBackend = Boolean( - env.SANDBOX || - runtimeAdapterConfigurationPresent(env) || - env.CRABBOX_RUNTIME_PROVISION_URL || - env.CRABBOX_CLOUDFLARE_RUNNER_URL || - env.CRABBOX_CLAWFLEET_URL, - ); - if (!env.CRABBOX_INTERACTIVE_PROVISION_TOKEN) { - if (hasBackend) { - throw serviceUnavailable("interactive provision token is not configured"); - } - return; - } - authorizeProvisionBearerToken(request, env); -} - function authorizeProvisionBearerToken(request: Request, env: RuntimeEnv): void { if (!env.CRABBOX_INTERACTIVE_PROVISION_TOKEN) { throw serviceUnavailable("interactive provision token is not configured"); @@ -9178,15 +9045,6 @@ function sandboxHasGitHubCredential(env: RuntimeEnv, session: SandboxRuntimeSess return Boolean(("githubToken" in session && session.githubToken) || env.GITHUB_TOKEN); } -function githubTokenEnv(session: Pick): { - GITHUB_TOKEN?: string; - GH_TOKEN?: string; -} { - return session.githubToken - ? { GITHUB_TOKEN: session.githubToken, GH_TOKEN: session.githubToken } - : {}; -} - async function provisionWithRuntimeAdapter( env: RuntimeEnv, session: InteractiveProvisionRequest, @@ -10344,167 +10202,6 @@ function runtimeAdapterProviderConfigured(env: RuntimeEnv): boolean { ); } -async function forwardRuntimeProvision( - env: RuntimeEnv, - session: InteractiveProvisionRequest, -): Promise { - let response: Response; - try { - const headers = new Headers({ "content-type": "application/json" }); - if (env.CRABBOX_RUNTIME_PROVISION_TOKEN) { - headers.set("authorization", `Bearer ${env.CRABBOX_RUNTIME_PROVISION_TOKEN}`); - } - response = await fetch(env.CRABBOX_RUNTIME_PROVISION_URL as string, { - method: "POST", - headers, - body: JSON.stringify(session), - }); - } catch (error) { - return failedProvision(`interactive provision failed: ${safeProviderError(error)}`); - } - if (!response.ok) { - return failedProvision(`interactive provision failed: runtime HTTP ${response.status}`); - } - return provisionResultFromBody( - (await response.json().catch(() => ({}))) as Record, - "interactive provision failed: invalid runtime response", - ); -} - -async function provisionWithCloudflareRunner( - env: RuntimeEnv, - session: InteractiveProvisionRequest, -): Promise { - if (!env.CRABBOX_CLOUDFLARE_RUNNER_TOKEN) { - return failedProvision("cloudflare runner token is not configured"); - } - - const runnerUrl = env.CRABBOX_CLOUDFLARE_RUNNER_URL as string; - const sandboxId = clean(`crabbox-${session.id}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"), 64); - const workdir = cloudflareRunnerWorkdir(env, session); - const instanceType = cloudflareRunnerInstanceType(env); - let response: Response; - try { - response = await fetch(joinUrl(runnerUrl, "/v1/sandboxes"), { - method: "POST", - headers: { - authorization: `Bearer ${env.CRABBOX_CLOUDFLARE_RUNNER_TOKEN}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - id: sandboxId, - leaseId: sandboxId, - repo: session.repo, - branch: session.branch, - workdir, - instanceType, - ttlSeconds: clampedSeconds(env.CRABBOX_CLOUDFLARE_RUNNER_TTL_SECONDS, 14_400), - idleTimeoutSeconds: clampedSeconds(env.CRABBOX_CLOUDFLARE_RUNNER_IDLE_SECONDS, 1_800), - env: githubTokenEnv(session), - labels: { - app: "crabbox", - session: session.id, - repo: session.repo, - branch: session.branch, - owner: session.owner, - runtime: session.runtime, - command: session.command, - }, - }), - }); - } catch (error) { - return failedProvision(`cloudflare runner provision failed: ${safeProviderError(error)}`); - } - if (!response.ok) { - return failedProvision(`cloudflare runner provision failed: HTTP ${response.status}`); - } - - const body = (await response.json().catch(() => ({}))) as CloudflareSandboxPayload; - const state = clean(body.state, 80); - const ready = state === "running" || state === "healthy"; - return { - status: ready ? "ready" : "provisioning", - leaseId: `cloudflare:${clean(body.id, 120) || sandboxId}`, - attachUrl: null, - vncUrl: null, - message: ready - ? `cloudflare sandbox ready (${clean(body.instanceType, 80) || instanceType}); PTY bridge pending` - : `cloudflare sandbox ${state || "provisioning"}`, - }; -} - -async function provisionWithClawFleet( - env: RuntimeEnv, - session: InteractiveProvisionRequest, -): Promise { - if (session.runtime !== "crabbox") { - return { - status: "pending_adapter", - leaseId: null, - attachUrl: null, - vncUrl: null, - message: "container runtime requires CRABBOX_RUNTIME_PROVISION_URL", - }; - } - - let response: Response; - try { - const headers = new Headers({ "content-type": "application/json" }); - if (env.CRABBOX_CLAWFLEET_TOKEN) { - headers.set("authorization", `Bearer ${env.CRABBOX_CLAWFLEET_TOKEN}`); - } - response = await fetch(joinUrl(env.CRABBOX_CLAWFLEET_URL as string, "/api/v1/instances"), { - method: "POST", - headers, - body: JSON.stringify({ count: 1, runtime_type: "openclaw" }), - }); - } catch (error) { - return failedProvision(`clawfleet provision failed: ${safeProviderError(error)}`); - } - if (!response.ok) { - return failedProvision(`clawfleet provision failed: HTTP ${response.status}`); - } - - const body = (await response.json().catch(() => ({}))) as Record; - const instances = Array.isArray(body.data) ? body.data : []; - const instance = (instances[0] ?? {}) as ClawFleetInstancePayload; - const name = clean(instance.name, 120); - if (!name) return failedProvision("clawfleet provision failed: missing instance name"); - - const publicUrl = env.CRABBOX_CLAWFLEET_PUBLIC_URL || env.CRABBOX_CLAWFLEET_URL || ""; - const status = instance.status === "running" ? "ready" : "provisioning"; - return { - status, - leaseId: `clawfleet:${name}`, - attachUrl: joinUrl(publicUrl, `/console/${encodeURIComponent(name)}/`), - vncUrl: directPortUrl(publicUrl, instance.novnc_port, "/vnc.html?autoconnect=1&resize=remote"), - message: `clawfleet instance ${name} ${status}`, - }; -} - -function provisionResultFromBody( - body: Record, - invalidMessage: string, -): InteractiveProvisionResult { - const status = createOnlyAdapterStatus(body.status); - if (!status) return failedProvision(invalidMessage); - const leaseId = clean(body.leaseId ?? body.lease_id, 240) || null; - const attachUrl = clean(body.attachUrl ?? body.attach_url, 1000) || null; - const vncUrl = clean(body.vncUrl ?? body.vnc_url, 1000) || null; - return { - status, - leaseId, - attachUrl, - vncUrl, - message: redactedAdapterMessage( - clean(body.message, 500) || null, - status, - [leaseId], - [attachUrl, vncUrl], - ), - }; -} - function failedProvision(message: string): InteractiveProvisionResult { return { status: "failed", @@ -10528,25 +10225,6 @@ function safeProviderError( ); } -function cloudflareRunnerWorkdir(env: RuntimeEnv, session: InteractiveProvisionRequest): string { - const base = clean(env.CRABBOX_CLOUDFLARE_RUNNER_WORKDIR, 160) || "/workspace/crabbox"; - const suffix = session.id.toLowerCase().replace(/[^a-z0-9_-]/g, "-"); - return `${base.replace(/\/+$/, "")}/${suffix}`; -} - -function cloudflareRunnerInstanceType(env: RuntimeEnv): string { - return ( - optionalOneOf(env.CRABBOX_CLOUDFLARE_RUNNER_INSTANCE_TYPE, [ - "lite", - "basic", - "standard-1", - "standard-2", - "standard-3", - "standard-4", - ] as const) ?? "standard-4" - ); -} - async function createCard(request: Request, env: RuntimeEnv, user: User): Promise<{ card: Card }> { const body = await readJson<{ title?: string; @@ -12089,14 +11767,6 @@ function clipboardExtension(mediaType: string): string { ); } -function joinUrl(base: string, path: string): string { - try { - return new URL(path, base.endsWith("/") ? base : `${base}/`).toString(); - } catch { - return ""; - } -} - function sandboxSetupSessionId(id: string): string { return clean(`setup-${id}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"), 80); } @@ -12264,18 +11934,6 @@ async function runSandboxSetupStep(step: string, operation: () => Promise = { error: "failed", }; -const createOnlyAdapterStatuses = new Set([ - "provisioning", - "pending_adapter", - "ready", - "attached", - "detached", - "stopped", - "expired", - "failed", -]); - export function runtimeAdapterCollectionUrl(base: string): string { return joinAdapterUrl(base, "/v1/workspaces"); } @@ -298,12 +287,6 @@ export function shouldReplayRuntimeAdapterCreate( return createPending && (status === "provisioning" || status === "pending_adapter"); } -export function createOnlyAdapterStatus(value: unknown): AdapterSessionStatus | null { - return typeof value === "string" && createOnlyAdapterStatuses.has(value as AdapterSessionStatus) - ? (value as AdapterSessionStatus) - : null; -} - export function runtimeAdapterTerminalFailureStatus( adapter: string | null, ): "detached" | "expired" { diff --git a/src/terminal-target.ts b/src/terminal-target.ts index 573fd30..a160191 100644 --- a/src/terminal-target.ts +++ b/src/terminal-target.ts @@ -1,4 +1,4 @@ -export type TerminalRouteKind = "sandbox" | "bridge" | "attach" | "cloudflare"; +export type TerminalRouteKind = "sandbox" | "bridge" | "attach"; export function sizedTerminalTargetUrl( rawUrl: string, @@ -6,7 +6,7 @@ export function sizedTerminalTargetUrl( cols: number, rows: number, ): string { - if (routeKind !== "bridge" && routeKind !== "cloudflare") return rawUrl; + if (routeKind !== "bridge") return rawUrl; try { const url = new URL(rawUrl); url.searchParams.set("cols", String(cols)); diff --git a/src/worker/env.ts b/src/worker/env.ts index c00d295..bcdcc24 100644 --- a/src/worker/env.ts +++ b/src/worker/env.ts @@ -12,11 +12,8 @@ export type RuntimeEnv = Env & { GITHUB_REDIRECT_URI?: string; GITHUB_TOKEN?: string; GITHUB_ORG?: string; - CRABBOX_INTERACTIVE_PROVISION_URL?: string; CRABBOX_INTERACTIVE_PROVISION_TOKEN?: string; CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS?: string; - CRABBOX_RUNTIME_PROVISION_URL?: string; - CRABBOX_RUNTIME_PROVISION_TOKEN?: string; CRABBOX_RUNTIME_ADAPTER_URL?: string; CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE?: string; CRABBOX_RUNTIME_ADAPTER_TOKEN?: string; @@ -25,17 +22,8 @@ export type RuntimeEnv = Env & { CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS?: string; CRABBOX_COORDINATOR?: Fetcher; CRABBOX_COORDINATOR_ORIGIN?: string; - CRABBOX_CLOUDFLARE_RUNNER_URL?: string; - CRABBOX_CLOUDFLARE_RUNNER_TOKEN?: string; - CRABBOX_CLOUDFLARE_RUNNER_INSTANCE_TYPE?: string; - CRABBOX_CLOUDFLARE_RUNNER_WORKDIR?: string; - CRABBOX_CLOUDFLARE_RUNNER_TTL_SECONDS?: string; - CRABBOX_CLOUDFLARE_RUNNER_IDLE_SECONDS?: string; CRABBOX_PTY_BRIDGE_URL?: string; CRABBOX_PTY_BRIDGE_TOKEN?: string; - CRABBOX_CLAWFLEET_URL?: string; - CRABBOX_CLAWFLEET_TOKEN?: string; - CRABBOX_CLAWFLEET_PUBLIC_URL?: string; CRABBOX_SSH_GATEWAY_TOKEN?: string; CRABFLEET_SSH_GATEWAY_TOKEN?: string; CRABBOX_OPENCLAW_TOKEN?: string; diff --git a/src/worker/session-provisioning.ts b/src/worker/session-provisioning.ts index 872457f..158ec46 100644 --- a/src/worker/session-provisioning.ts +++ b/src/worker/session-provisioning.ts @@ -37,3 +37,26 @@ export type InteractiveProvisionPersistenceInput = { initialAgentTokenHash: string; adapterName: string; }; + +export type InteractiveProvisionRuntime = "container" | "crabbox"; + +export type ManagedInteractiveProvisionBackend = "sandbox" | "runtime-adapter"; + +export function managedInteractiveProvisionBackend( + runtime: InteractiveProvisionRuntime, + availability: { + sandbox: boolean; + runtimeAdapter: boolean; + }, +): ManagedInteractiveProvisionBackend | null { + if (runtime === "container" && availability.sandbox) return "sandbox"; + if (availability.runtimeAdapter) return "runtime-adapter"; + return null; +} + +export function standaloneInteractiveProvisionSupported( + runtime: InteractiveProvisionRuntime, + sandboxAvailable: boolean, +): boolean { + return runtime === "container" && sandboxAvailable; +} diff --git a/src/worker/session-terminal-route.ts b/src/worker/session-terminal-route.ts index 2cc53ab..d0df318 100644 --- a/src/worker/session-terminal-route.ts +++ b/src/worker/session-terminal-route.ts @@ -3,7 +3,6 @@ import { legacyLeaseIdForAdapter, runtimeAdapterName, runtimeAdapterTerminalOriginMatches, - safeDesktopUrl, safeWebSocketUrl, } from "../runtime-adapter.ts"; import type { RuntimeEnv } from "./env.ts"; @@ -50,26 +49,6 @@ export function interactiveTerminalTarget( }; } - const leaseId = legacyLeaseIdForAdapter(session.adapter, session.leaseId); - if ( - routeKind === "cloudflare" && - leaseId?.startsWith("cloudflare:") && - env.CRABBOX_CLOUDFLARE_RUNNER_URL - ) { - const sandboxId = leaseId.slice("cloudflare:".length); - const runnerUrl = safeDesktopUrl(env.CRABBOX_CLOUDFLARE_RUNNER_URL); - if (!runnerUrl) return null; - const url = addQuery( - joinUrl(runnerUrl, `/v1/sandboxes/${encodeURIComponent(sandboxId)}/pty`), - terminalQuery(session), - ); - if (!url) return null; - return { - url, - authorization: bearer(env.CRABBOX_CLOUDFLARE_RUNNER_TOKEN), - }; - } - return null; } @@ -99,7 +78,6 @@ export function interactivePtyRouteKind( return ptyRouteKind(session, { sandboxAvailable: Boolean(env.SANDBOX), bridgeUrl: env.CRABBOX_PTY_BRIDGE_URL, - cloudflareRunnerUrl: env.CRABBOX_CLOUDFLARE_RUNNER_URL, }); } @@ -145,14 +123,6 @@ export function interactiveTerminalHeaders( return headers; } -function joinUrl(base: string, path: string): string { - try { - return new URL(path, base.endsWith("/") ? base : `${base}/`).toString(); - } catch { - return ""; - } -} - function addQuery(rawUrl: string, params: Record): string { try { const url = new URL(rawUrl); diff --git a/tests/fleet-state.test.ts b/tests/fleet-state.test.ts index 16b8c99..2c8c824 100644 --- a/tests/fleet-state.test.ts +++ b/tests/fleet-state.test.ts @@ -244,13 +244,6 @@ test("fleet attachability follows resolvable PTY routes", () => { summary({ ...baseSession, leaseId: null, attachUrl: "ws://127.1:9000/session" }), false, ); - assert.equal( - summary( - { ...baseSession, leaseId: "cloudflare:workspace-1", attachUrl: null }, - { cloudflareRunnerUrl: "https://runner.example" }, - ), - true, - ); assert.equal( summary( { ...baseSession, leaseId: null, attachUrl: null }, diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 8cd52c3..5e89705 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -6,7 +6,6 @@ import { adapterFailureReleaseState, adapterWorkspaceIdMatches, clearedAdapterCapabilities, - createOnlyAdapterStatus, definitiveRuntimeAdapterCreateFailure, effectiveAdapterCapabilities, currentAdapterDesktopConnection, @@ -300,13 +299,6 @@ test("adapter workspace identity is namespaced, bounded, and exact", () => { ); }); -test("create-only adapters cannot return an unowned stopping lifecycle", () => { - assert.equal(createOnlyAdapterStatus("ready"), "ready"); - assert.equal(createOnlyAdapterStatus("failed"), "failed"); - assert.equal(createOnlyAdapterStatus("stopping"), null); - assert.equal(createOnlyAdapterStatus(" ready "), null); -}); - test("status-only inspect preserves omitted capability and expiry fields", () => { const omitted = parseAdapterWorkspaceResult({ id: "fleet-a-is-101", status: "ready" }); assert.equal(omitted?.capabilitiesPresent, false); @@ -513,10 +505,6 @@ test("runtime adapter lifecycle cannot escape durable session ownership", async const releaseEnd = source.indexOf("function runtimeAdapterProvisionResult", releaseStart); const releaseSource = source.slice(releaseStart, releaseEnd); - assert.match( - source, - /versioned runtime adapter requires a durable interactive session lifecycle/, - ); assert.match(stopSource, /recordConfirmedRuntimeAdapterRelease/); assert.match(stopSource, /select\(\[[\s\S]*"adapter_create_pending"[\s\S]*"terminal_status"/); assert.match(stopSource, /AND adapter_create_pending = \$\{lifecycle\.adapter_create_pending\}/); @@ -921,7 +909,7 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a "utf8", ); const endpointStart = source.indexOf("async function provisionInteractiveEndpoint"); - const endpointEnd = source.indexOf("function isBuiltInInteractiveProvisionUrl", endpointStart); + const endpointEnd = source.indexOf("function authorizeProvisionBearerToken", endpointStart); const endpointSource = source.slice(endpointStart, endpointEnd); const ownershipStart = source.indexOf("function sandboxCredentialPolicyOwnerCondition"); const ownershipEnd = source.indexOf( @@ -934,7 +922,7 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a const managedSource = source.slice(managedStart, managedEnd); const managedCommitSource = managedSource.slice(managedSource.indexOf("const commitRevision")); const ptyStart = source.indexOf("async function standaloneSandboxPty"); - const ptyEnd = source.indexOf("function isBuiltInInteractiveProvisionUrl", ptyStart); + const ptyEnd = source.indexOf("function authorizeProvisionBearerToken", ptyStart); const ptySource = source.slice(ptyStart, ptyEnd); const sandboxStart = source.indexOf("async function provisionWithSandbox"); const sandboxEnd = source.indexOf("function sandboxManagedOwnershipCondition", sandboxStart); @@ -1005,7 +993,7 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a assert.match(managedSource, /previousSandboxId/); assert.match(managedSource, /state: "cleanup_pending"/); assert.match(managedSource, /claimed\.numUpdatedRows/); - assert.match(ptySource, /authorizeProvisionEndpoint\(request, env\)/); + assert.match(ptySource, /authorizeProvisionBearerToken\(request, env\)/); assert.match(ptySource, /standalone_sandbox_provisions/); assert.match(ptySource, /where\("state", "=", "active"\)/); assert.match(ptySource, /owner\.expires_at <= Date\.now\(\)/); @@ -1208,20 +1196,6 @@ test("Sandbox credential registration always proves exact durable ownership", as ); }); -test("create-only adapters reject stopping responses before persistence", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const externalStart = source.indexOf("async function provisionInteractiveSession"); - const externalEnd = source.indexOf("async function provisionInteractiveEndpoint", externalStart); - const externalSource = source.slice(externalStart, externalEnd); - const forwardedStart = source.indexOf("function provisionResultFromBody"); - const forwardedEnd = source.indexOf("function failedProvision", forwardedStart); - const forwardedSource = source.slice(forwardedStart, forwardedEnd); - - assert.match(externalSource, /createOnlyAdapterStatus\(body\.status\)/); - assert.match(forwardedSource, /createOnlyAdapterStatus\(body\.status\)/); - assert.match(forwardedSource, /if \(!status\) return failedProvision/); -}); - test("legacy and GitHub Actions stop wrappers finalize persisted transitions", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const completeStart = source.indexOf("async function completeLegacyInteractiveSessionStop"); diff --git a/tests/session-provisioning.test.ts b/tests/session-provisioning.test.ts new file mode 100644 index 0000000..adac48d --- /dev/null +++ b/tests/session-provisioning.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + managedInteractiveProvisionBackend, + standaloneInteractiveProvisionSupported, +} from "../src/worker/session-provisioning.ts"; + +test("managed provisioning prefers built-in Sandbox for container sessions", () => { + assert.equal( + managedInteractiveProvisionBackend("container", { + sandbox: true, + runtimeAdapter: true, + }), + "sandbox", + ); + assert.equal( + managedInteractiveProvisionBackend("crabbox", { + sandbox: true, + runtimeAdapter: true, + }), + "runtime-adapter", + ); +}); + +test("managed provisioning has one external lifecycle protocol", () => { + assert.equal( + managedInteractiveProvisionBackend("container", { + sandbox: false, + runtimeAdapter: true, + }), + "runtime-adapter", + ); + assert.equal( + managedInteractiveProvisionBackend("crabbox", { + sandbox: false, + runtimeAdapter: true, + }), + "runtime-adapter", + ); + assert.equal( + managedInteractiveProvisionBackend("container", { + sandbox: false, + runtimeAdapter: false, + }), + null, + ); + assert.equal( + managedInteractiveProvisionBackend("crabbox", { + sandbox: true, + runtimeAdapter: false, + }), + null, + ); +}); + +test("standalone provisioning is built-in Sandbox only", () => { + assert.equal(standaloneInteractiveProvisionSupported("container", true), true); + assert.equal(standaloneInteractiveProvisionSupported("container", false), false); + assert.equal(standaloneInteractiveProvisionSupported("crabbox", true), false); +}); diff --git a/tests/session-terminal-route.test.ts b/tests/session-terminal-route.test.ts index 4edbd67..9891056 100644 --- a/tests/session-terminal-route.test.ts +++ b/tests/session-terminal-route.test.ts @@ -42,13 +42,6 @@ test("terminal route selection follows managed backend priority", () => { "bridge", ); assert.equal(interactivePtyRouteKind({} as RuntimeEnv, routed), "attach"); - assert.equal( - interactivePtyRouteKind( - { CRABBOX_CLOUDFLARE_RUNNER_URL: "https://runner.example" } as RuntimeEnv, - session({ lease_id: "cloudflare:sandbox-1", attach_url: null }), - ), - "cloudflare", - ); }); test("bridge targets expand templates, append session context, and use bridge auth", () => { @@ -114,24 +107,11 @@ test("signed attach targets remain opaque and adapter auth is origin-bound", () ); }); -test("Cloudflare runner targets and terminal headers carry canonical session context", () => { - const current = session({ lease_id: "cloudflare:sandbox/1", attach_url: null }); - const target = interactiveTerminalTarget( - { - CRABBOX_CLOUDFLARE_RUNNER_URL: "https://runner.example/base", - CRABBOX_CLOUDFLARE_RUNNER_TOKEN: "runner-token", - } as RuntimeEnv, - current, - "cloudflare", - ); - assert.equal( - target?.url, - "https://runner.example/v1/sandboxes/sandbox%2F1/pty?sessionId=IS-route&leaseId=cloudflare%3Asandbox%2F1&repo=openclaw%2Fcrabfleet&branch=feature%2Fterminal+route&runtime=crabbox&profile=default&command=codex+--yolo", - ); - assert.equal(target?.authorization, "Bearer runner-token"); +test("terminal headers carry canonical session context", () => { + const current = session({ lease_id: "sandbox:owned" }); assert.deepEqual(terminalQuery(current), { sessionId: "IS-route", - leaseId: "cloudflare:sandbox/1", + leaseId: "sandbox:owned", repo: "openclaw/crabfleet", branch: "feature/terminal route", runtime: "crabbox", diff --git a/tests/terminal-target.test.ts b/tests/terminal-target.test.ts index b57b6df..0228856 100644 --- a/tests/terminal-target.test.ts +++ b/tests/terminal-target.test.ts @@ -23,13 +23,9 @@ test("opaque provider terminal URLs pass through the multiplex hub unchanged", a assert.doesNotMatch(source, /async function interactiveSessionPty/); }); -test("known bridge and runner targets receive terminal dimensions", () => { +test("configured bridge targets receive terminal dimensions", () => { assert.equal( sizedTerminalTargetUrl("wss://bridge.example/pty?token=opaque", "bridge", 120, 34), "wss://bridge.example/pty?token=opaque&cols=120&rows=34", ); - assert.equal( - sizedTerminalTargetUrl("wss://runner.example/pty?cols=80", "cloudflare", 132, 40), - "wss://runner.example/pty?cols=132&rows=40", - ); }); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 29b9d59..e2f7c9c 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -11,28 +11,16 @@ interface Env { GITHUB_REDIRECT_URI?: string; GITHUB_ORG?: string; GITHUB_TOKEN?: string; - CRABBOX_INTERACTIVE_PROVISION_URL?: string; CRABBOX_INTERACTIVE_PROVISION_TOKEN?: string; CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS?: string; - CRABBOX_RUNTIME_PROVISION_URL?: string; - CRABBOX_RUNTIME_PROVISION_TOKEN?: string; CRABBOX_RUNTIME_ADAPTER_URL?: string; CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE?: string; CRABBOX_RUNTIME_ADAPTER_TOKEN?: string; CRABBOX_RUNTIME_ADAPTER_NAMESPACE?: string; CRABBOX_RUNTIME_ADAPTER_TTL_SECONDS?: string; CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS?: string; - CRABBOX_CLOUDFLARE_RUNNER_URL?: string; - CRABBOX_CLOUDFLARE_RUNNER_TOKEN?: string; - CRABBOX_CLOUDFLARE_RUNNER_INSTANCE_TYPE?: string; - CRABBOX_CLOUDFLARE_RUNNER_WORKDIR?: string; - CRABBOX_CLOUDFLARE_RUNNER_TTL_SECONDS?: string; - CRABBOX_CLOUDFLARE_RUNNER_IDLE_SECONDS?: string; CRABBOX_PTY_BRIDGE_URL?: string; CRABBOX_PTY_BRIDGE_TOKEN?: string; - CRABBOX_CLAWFLEET_URL?: string; - CRABBOX_CLAWFLEET_TOKEN?: string; - CRABBOX_CLAWFLEET_PUBLIC_URL?: string; CRABBOX_OPENCLAW_TOKEN?: string; CRABBOX_TOKEN_ENCRYPTION_KEY?: string; BACKUP_BUCKET_NAME?: string; diff --git a/wrangler.jsonc b/wrangler.jsonc index 1e67048..c59a363 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -25,7 +25,6 @@ "CLOUDFLARE_ACCOUNT_ID": "91b59577e757131d68d55a471fe32aca", "CRABFLEET_DEV_LOGIN_ENABLED": "false", "CRABBOX_COORDINATOR_ORIGIN": "https://crabbox.openclaw.ai", - "CRABBOX_INTERACTIVE_PROVISION_URL": "https://crabfleet.openclaw.ai/api/provision/interactive", "CRABBOX_RUNTIME_ADAPTER_NAMESPACE": "openclaw", "CRABBOX_RUNTIME_ADAPTER_URL": "https://crabbox.openclaw.ai", "GITHUB_REDIRECT_URI": "https://crabfleet.openclaw.ai/auth/github/callback", From 9838d40aa76124c7f59d9d13d20c6a99bb79e8ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 15:18:51 +0100 Subject: [PATCH 051/109] refactor: remove legacy terminal bridge --- CHANGELOG.md | 5 ++- README.md | 2 - docs/api.md | 6 +-- docs/architecture.md | 2 +- docs/quickstart.md | 2 +- docs/runs.md | 16 +++---- docs/spec-v2.md | 2 +- docs/spec.md | 4 +- src/app/terminal.js | 4 +- src/fleet-state.ts | 23 +---------- src/index.ts | 8 ++-- src/terminal-target.ts | 18 -------- src/worker/env.ts | 2 - src/worker/session-metadata.ts | 2 +- src/worker/session-terminal-route.ts | 62 ---------------------------- tests/fleet-state.test.ts | 15 +------ tests/session-terminal-route.test.ts | 35 +--------------- tests/terminal-target.test.ts | 31 -------------- worker-configuration.d.ts | 2 - 19 files changed, 27 insertions(+), 214 deletions(-) delete mode 100644 src/terminal-target.ts delete mode 100644 tests/terminal-target.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 343b0a4..7048a54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Remove the configurable PTY bridge so managed terminal upstreams are limited to built-in Sandbox, versioned runtime-adapter attach, and GitHub Actions relay behind the multiplex terminal protocol. - Remove generic create-only provisioning, the external Cloudflare runner, and the ClawFleet compatibility provider so managed workspaces use only built-in Sandbox or the versioned runtime adapter. - Extract OpenClaw nudge and stop validation, audit ordering, terminal delivery, and best-effort delivery records into a directly tested mutation service. - Extract OpenClaw crabbox normalization, replay handling, branch preparation, timeout policy, and creation audit into a directly tested service. @@ -37,7 +38,7 @@ - Extract GitHub Actions session validation, idempotent registration, token rotation, resume reset, runner replacement, and evidence ordering. - Extract GitHub Actions work-state projection, heartbeat persistence, event suppression, terminal mapping, runner disconnect, and reread. - Extract GitHub Actions runner-connect validation, lifecycle projection, heartbeat persistence, and durable connection evidence. -- Extract interactive terminal route selection, bridge expansion, signed attach preservation, adapter authorization, query projection, and headers. +- Extract interactive terminal route selection, signed attach preservation, adapter authorization, and headers. - Extract terminal WebSocket relay queues, output acknowledgements, message normalization, authorization polling, and peer close handling. - Extract runtime-adapter lifecycle and terminal transport, coordinator binding selection, redirect refusal, and bounded response parsing. - Centralize OpenClaw room visibility, log-free summaries, and bounded transcript sentinel/truncation policy behind direct query tests. @@ -87,7 +88,7 @@ - Reject stale same-generation credential-policy registrations, preflight and atomically stage failed managed Sandbox claims, require the provision bearer for standalone stop after backend removal, and backfill D1-only terminal archives when R2 is enabled later. - Proactively generation-wrap migrated legacy Sandbox credential policies under a live durable lease before cleanup, preserve live pre-token sessions, and use crash-safe cron retries that retain unattended session credentials. - Bound every runtime-adapter response stream, revalidate desktop authorization after minting, make legacy local stops atomic with scheduled crash recovery, and redact credentials before opaque provider identifiers. -- Recover active credential policies after a post-registration crash, redact provider identities from structured adapter errors, and propagate terminal dimensions through configured bridge PTY routes without rewriting opaque adapter URLs. +- Recover active credential policies after a post-registration crash and redact provider identities from structured adapter errors. - Support an optional authoritative `GITHUB_REDIRECT_URI` deployment binding with strict HTTPS callback validation, canonical-origin login handoff, and callback host/path enforcement while retaining safe request-origin defaults. - Replace native browser confirms and prompts with accessible Crabfleet dialogs for session cleanup, shutdown, and share-link fallback. - Sharpen the app visual system with flatter controls, tighter surfaces, and restrained overlay elevation. diff --git a/README.md b/README.md index c7bb7ca..0ea6747 100644 --- a/README.md +++ b/README.md @@ -213,8 +213,6 @@ The Crabbox namespace cutover intentionally has no old-name compatibility. Exist - `CRABBOX_RUNTIME_ADAPTER_NAMESPACE` – Required stable tenant namespace when the versioned adapter is enabled; a DNS-safe label of at most 32 characters used in every workspace ID and idempotency key - `CRABBOX_RUNTIME_ADAPTER_TTL_SECONDS` – Optional requested workspace TTL, default `14400` - `CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS` – Optional requested workspace idle timeout, default `1800` -- `CRABBOX_PTY_BRIDGE_URL` – Optional WebSocket PTY bridge URL/template for live Ghostty attach; requires WSS except literal loopback WS and supports `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}` -- `CRABBOX_PTY_BRIDGE_TOKEN` – Optional bearer token sent from Crabfleet to the PTY bridge - `CRABBOX_OPENCLAW_TOKEN` – Internal bearer token for OpenClaw service crabbox and GitHub Actions session registration - `CRABBOX_MULTICODEX_TOKEN` – Optional dedicated bearer token for MultiCodex room supervision - `CRABFLEET_SSH_GATEWAY_TOKEN` / `CRABBOX_SSH_GATEWAY_TOKEN` – Shared bearer token for the Go SSH gateway internal API diff --git a/docs/api.md b/docs/api.md index 313216a..fd8e751 100644 --- a/docs/api.md +++ b/docs/api.md @@ -314,10 +314,10 @@ Server messages include `Welcome`, `Output`, `Event`, `Error`, `ControlRevoked`, Target resolution: -- `CRABBOX_PTY_BRIDGE_URL`: explicit bridge WebSocket URL/template. Templates support `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}`. Crabfleet appends `sessionId`, `leaseId`, `repo`, `branch`, `runtime`, and `command` query parameters. +- Built-in Sandbox terminal: Crabfleet opens the session PTY through the `SANDBOX` binding and forwards the requested terminal dimensions. - Provider terminal connection: if the provision adapter returned a `wss://` URL, or literal loopback `ws://` URL, Crabfleet retains it server-side and proxies to it unchanged, including its path and signed query string. -The hub appends terminal `cols` and `rows` only to configured bridge endpoints, never to an adapter `attachUrl`. Crabfleet authenticates versioned-adapter terminal upgrades with `CRABBOX_RUNTIME_ADAPTER_TOKEN` only when the terminal shares the persisted and currently configured adapter origin; adapter URLs never carry reusable shell credentials. If `CRABBOX_PTY_BRIDGE_TOKEN` is set, Crabfleet sends it only to the upstream bridge. Clients never receive upstream credentials. +Crabfleet never rewrites an adapter `attachUrl`. It authenticates versioned-adapter terminal upgrades with `CRABBOX_RUNTIME_ADAPTER_TOKEN` only when the terminal shares the persisted and currently configured adapter origin; adapter URLs never carry reusable shell credentials. Clients never receive upstream credentials. ### POST /api/interactive-sessions/:id/clipboard @@ -418,7 +418,7 @@ Fields: Container sessions use the built-in Sandbox when its binding is available. Otherwise, and for Crabbox sessions, `CRABBOX_RUNTIME_ADAPTER_URL` or `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` creates and reconciles the versioned adapter workspace and records its resolved lifecycle identity, status, capabilities, expiry, and terminal connection. Without either supported backend the session is stored as `pending_adapter`. -Session responses include `ptyAvailable`, the authenticated Worker's authoritative answer for whether the current terminal capability, lifecycle state, and configured Sandbox, bridge, or adapter route can resolve a PTY connection. Every controllable session exposes only the Worker-owned `/api/terminal/ws` route in `attachUrl`; signed provider connections remain server-side even for owners and controllers. +Session responses include `ptyAvailable`, the authenticated Worker's authoritative answer for whether the current terminal capability, lifecycle state, and configured Sandbox or adapter route can resolve a PTY connection. Every controllable session exposes only the Worker-owned `/api/terminal/ws` route in `attachUrl`; signed provider connections remain server-side even for owners and controllers. When the selected runtime profile configures `codexSsh`, a ready `runtime-v1` session response may include `codexSsh: { alias, setupCommand }` for session managers. The alias and optional command are resolved from bounded `{providerResourceId}`, `{workspaceId}`, `{sessionId}`, and `{profile}` placeholders. Alias components use a strict OpenSSH-safe character set. `codexSsh.setupCommand` is an argv-like array whose first and static items use a shell-safe character set and whose dynamic items must each be one complete placeholder; Crabfleet POSIX-shell-quotes every substituted argument so opaque provider identifiers remain data. Missing values, an unsafe resolved alias, or a current profile route that differs from the workspace's immutable registered adapter control plane suppresses the handoff. Shared links and delegated terminal-only controllers never receive it. The command is display/copy data only; Crabfleet never executes it. diff --git a/docs/architecture.md b/docs/architecture.md index 79a487b..0f7827b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -152,7 +152,7 @@ Browser, CLI, agent, and SSH gateway clients use the multiplex `/api/terminal/ws - rechecks D1 authorization without waiting on provider I/O; - forwards input only for the current controller; - closes subscriptions after control or terminal capability is revoked; -- appends dimensions only to configured bridge routes, never opaque signed adapter URLs; +- forwards initial dimensions directly to Sandbox terminals and leaves opaque signed adapter URLs unchanged; - keeps runtime bearer credentials out of browser responses. Versioned adapter VNC uses the authenticated Worker route `/api/interactive-sessions/:id/vnc`, which mints a fresh provider desktop connection after authorization. diff --git a/docs/quickstart.md b/docs/quickstart.md index 7c67f10..ee6deeb 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -130,7 +130,7 @@ The Worker will: - Store a run attempt with selection reason and capabilities. - Move the card to Running and append events. -Click Attach to open the Ghostty WASM session grid. The grid immediately shows D1 event replay and switches to live PTY output through the terminal hub when the session has a sandbox or bridge. +Click Attach to open the Ghostty WASM session grid. The grid immediately shows D1 event replay and switches to live PTY output through the terminal hub when the session has a Sandbox or provider terminal. The card attempt itself is scheduling/control evidence. It does not launch an autonomous Codex process; live work appears as a Fleet interactive session. diff --git a/docs/runs.md b/docs/runs.md index bf6d399..e4babf3 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -82,7 +82,7 @@ Attach opens a fullscreen Ghostty WASM grid. Current behavior: - Shows one or more Codex session tiles. - Includes standalone interactive Codex CLI sessions created from New session. - Uses the local `ghostty-web` bundle served by the Worker. -- Streams live PTY bytes through the multiplex `/api/terminal/ws` hub when a Sandbox, bridge, or versioned adapter terminal is available. +- Streams live PTY bytes through the multiplex `/api/terminal/ws` hub when a Sandbox or versioned adapter terminal is available. - Replays D1 event logs into the terminal surface while a live PTY is unavailable. - Falls back to a text terminal if Ghostty cannot initialize. - Copies terminal selection, pastes clipboard text when the viewer has writable control, and uploads clipboard images/files for Cloudflare Sandbox sessions. @@ -100,7 +100,7 @@ Deployments can expose an allowlisted set of generic Crabbox profiles. The creat Interactive sessions also store `parentSessionId`, `rootSessionId`, `createdBy`, `purpose`, and `summary`. Built-in Sandbox sessions export `CRABFLEET_SESSION_ID`, `CRABFLEET_PARENT_SESSION_ID`, `CRABFLEET_ROOT_SESSION_ID`, `CRABFLEET_AGENT_TOKEN`, and `CRABFLEET_API_URL`; the Go CLI uses those values to list sibling/child sessions, create children, send PTY messages, fetch transcripts, and update summaries without an SSH key. -Adapter capability arrays are authoritative: omitting `terminal`, `pty`, or `ssh` withdraws terminal access. A valid WSS (or literal-loopback WS) terminal URL implies terminal access only when capabilities are omitted entirely or an object omits all terminal-related keys. `ptyAvailable` additionally requires a ready lifecycle state and a resolvable configured Sandbox, bridge, or direct WebSocket route. +Adapter capability arrays are authoritative: omitting `terminal`, `pty`, or `ssh` withdraws terminal access. A valid WSS (or literal-loopback WS) terminal URL implies terminal access only when capabilities are omitted entirely or an object omits all terminal-related keys. `ptyAvailable` additionally requires a ready lifecycle state and a resolvable configured Sandbox or direct adapter WebSocket route. Session events are mirrored into the `SESSION_LOGS` R2 binding when configured. Crabfleet writes NDJSON, Markdown transcript, and summary objects under `orgs/openclaw/interactive-sessions//`, while D1 keeps the compact event list and archive keys for the app, CLI, and SSH gateway. If the binding is enabled after D1-only terminal archives were finalized, cron and targeted reconciliation requeue their null-key snapshots and backfill the objects before cleanup. Cleanup transactionally removes the finalized D1 session, events, and archive pointers before best-effort R2 deletion, so a partial object-delete failure is an unreferenced leak rather than a dangling archive reference. Stops for local legacy sessions atomically commit the request event, stopped event, terminal state, and finalization marker; cron and targeted access repair older `stopping` rows left by interrupted deployments. @@ -110,18 +110,12 @@ Managed session creation first uses the built-in Sandbox when `runtime=container Crabfleet also ships a built-in Sandbox provision hook at `/api/provision/interactive`. Every provision, PTY, and stop request requires `CRABBOX_INTERACTIVE_PROVISION_TOKEN`. Direct standalone Sandboxes reject the reserved `IS-` namespace, expire after the bounded `CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS`, and stop through `/api/provision/interactive/:id/stop`. The hook accepts only `runtime=container`; external workspaces are deliberately created through the managed versioned lifecycle instead. -Optional bridge configuration: - -- `CRABBOX_PTY_BRIDGE_URL`: optional explicit PTY bridge WebSocket URL/template. Templates support `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}`. -- `CRABBOX_PTY_BRIDGE_TOKEN`: optional bearer token sent only from Crabfleet to the bridge. - -Bridge PTY contract: +Terminal contract: - Crabfleet accepts browser, CLI, agent, and SSH gateway WebSockets on `/api/terminal/ws` and multiplexes one or more subscribed sessions. -- Crabfleet connects upstream to the configured bridge with `Upgrade: websocket`. - Browser-to-Crabfleet messages use binary terminal frames for subscribe, input, resize, and stop. -- Runner-to-browser output is wrapped in terminal output frames with session IDs. -- The bridge receives `x-crabbox-session`, `x-crabbox-repo`, and `x-crabbox-runtime` headers plus session query parameters. +- Upstream output is wrapped in terminal output frames with session IDs. +- Crabfleet resolves each subscription to the built-in Sandbox, a versioned adapter terminal, or the GitHub Actions relay. GitHub Actions PTY contract: diff --git a/docs/spec-v2.md b/docs/spec-v2.md index 9a3c251..c5bf2b9 100644 --- a/docs/spec-v2.md +++ b/docs/spec-v2.md @@ -119,7 +119,7 @@ Per-session summary: - `policy.hasGithubToken` - `policy.openAIBaseUrlHost` -`attachable` is true only for terminal-capable `ready`, `attached`, or `detached` sessions with current control and a resolvable Sandbox, configured bridge, or valid WSS/literal-loopback WS URL. +`attachable` is true only for terminal-capable `ready`, `attached`, or `detached` sessions with current control and a resolvable Sandbox or valid WSS/literal-loopback WS provider URL. ## Security diff --git a/docs/spec.md b/docs/spec.md index 9a10c72..be3680f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -198,7 +198,7 @@ Terminal attachability requires: - active lifecycle status; - current `terminal` capability; -- a resolvable built-in Sandbox, bridge, or provider terminal; +- a resolvable built-in Sandbox or provider terminal; - current viewer authorization and control state. `ptyAvailable` is the Worker-authoritative result. Raw provider terminal credentials are never returned to clients. @@ -275,7 +275,7 @@ D1 is the source of truth for current app state. - Sandbox credential-policy registry; - checkpoint registry. -The Worker owns the general multiplex terminal hub and connects each subscription to its Sandbox, bridge, or adapter backend. There is no `BoardDO` or `RunDO`. Board and Fleet state use D1 plus REST polling. The browser refreshes general state every 15 seconds; terminal bytes use WebSockets. +The Worker owns the general multiplex terminal hub and connects each subscription to its Sandbox, adapter, or GitHub Actions backend. There is no `BoardDO` or `RunDO`. Board and Fleet state use D1 plus REST polling. The browser refreshes general state every 15 seconds; terminal bytes use WebSockets. ### R2 diff --git a/src/app/terminal.js b/src/app/terminal.js index a4afacc..158baaa 100644 --- a/src/app/terminal.js +++ b/src/app/terminal.js @@ -75,7 +75,7 @@ export async function mountTerminal(session, mount, options = {}) { ? previous.canInput ? "Live PTY" : "Read-only PTY" - : "PTY bridge", + : "Connecting", ); return; } @@ -610,9 +610,9 @@ function isTerminalFinalError(message) { text.includes("session is expired") || text.includes("session is failed") || text.includes("upstream terminal error") || + text.includes("terminal upstream") || text.includes("terminal unavailable") || text.includes("sandbox terminal") || - text.includes("pty bridge") || text.includes("not configured") ); } diff --git a/src/fleet-state.ts b/src/fleet-state.ts index 36764c9..b4d3253 100644 --- a/src/fleet-state.ts +++ b/src/fleet-state.ts @@ -72,7 +72,6 @@ export type FleetStateOptions = { productUrl: string; registryAvailable?: boolean; sandboxAvailable?: boolean | undefined; - ptyBridgeUrl?: string | null | undefined; }; export type FleetSessionSummary = { @@ -145,7 +144,7 @@ export type FleetState = { sessions: FleetSessionSummary[]; }; -export type PtyRouteKind = "sandbox" | "bridge" | "attach"; +export type PtyRouteKind = "sandbox" | "attach"; export type PtyRouteSession = { adapter?: string | null; @@ -155,7 +154,6 @@ export type PtyRouteSession = { export type PtyRouteConfig = { sandboxAvailable?: boolean | undefined; - bridgeUrl?: string | null | undefined; }; const allStatuses: FleetStatus[] = [ @@ -232,7 +230,7 @@ export function buildFleetState( export function fleetSessionSummary( session: FleetSessionInput, policy: FleetSandboxPolicySummary | null, - options: Pick = {}, + options: Pick = {}, ): FleetSessionSummary { const sandboxId = sandboxIdFromLeaseId(session.leaseId); const archived = Boolean(session.logArchive?.eventCount); @@ -270,7 +268,6 @@ export function fleetSessionSummary( Boolean( ptyRouteKind(session, { sandboxAvailable: options.sandboxAvailable, - bridgeUrl: options.ptyBridgeUrl, }), ))) && ptyReadyStatuses.has(session.status), @@ -313,26 +310,10 @@ export function ptyRouteKind( ): PtyRouteKind | null { const leaseId = session.adapter === "runtime-v1" ? null : session.leaseId; if (config.sandboxAvailable && leaseId?.startsWith("sandbox:")) return "sandbox"; - if (configuredBridgeWebSocketUrl(config.bridgeUrl)) return "bridge"; if (safePtyWebSocketUrl(session.attachUrl)) return "attach"; return null; } -function configuredBridgeWebSocketUrl(value: string | null | undefined): string | null { - const candidate = String(value ?? "") - .trim() - .replaceAll(/\{(?:id|leaseId|repo|branch|runtime)\}/g, "route-value"); - if (!candidate) return null; - try { - const url = new URL(candidate); - if (url.protocol === "https:") url.protocol = "wss:"; - if (url.protocol === "http:") url.protocol = "ws:"; - return safePtyWebSocketUrl(url.toString()); - } catch { - return null; - } -} - function safePtyWebSocketUrl(value: string | null | undefined): string | null { return normalizedSecureWebSocketUrl(value); } diff --git a/src/index.ts b/src/index.ts index a4b5e5b..e72b6cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,7 +82,6 @@ import { import { allocateInteractiveSessionIdSql, formatInteractiveSessionId } from "./session-id"; import { preferredEnabledRepo } from "./repo-selection"; import { sandboxGitAuthorEmail } from "./git-identity"; -import { sizedTerminalTargetUrl } from "./terminal-target"; import { cachedBooleanGrant } from "./terminal-authorization"; import { openClawGitHubRepoParts, @@ -3172,7 +3171,6 @@ async function readFleetState( generatedAt: Date.now(), registryAvailable: policyResult.available, sandboxAvailable: Boolean(env.SANDBOX), - ptyBridgeUrl: env.CRABBOX_PTY_BRIDGE_URL, }); } @@ -5814,16 +5812,16 @@ async function openInteractiveTerminalUpstream( } const target = interactiveTerminalTarget(env, session, routeKind); - if (!target) throw serviceUnavailable("PTY bridge is not configured for this session"); + if (!target) throw serviceUnavailable("terminal upstream is not configured for this session"); const upstreamResponse = await interactiveTerminalFetch( env, session, - sizedTerminalTargetUrl(target.url, routeKind, cols, rows), + target.url, interactiveTerminalHeaders(session, target.authorization), ); const upstream = upstreamResponse.webSocket; if (!upstream || upstreamResponse.status !== 101) { - throw serviceUnavailable(`PTY bridge HTTP ${upstreamResponse.status}`); + throw serviceUnavailable(`terminal upstream HTTP ${upstreamResponse.status}`); } upstream.accept(); return { diff --git a/src/terminal-target.ts b/src/terminal-target.ts deleted file mode 100644 index a160191..0000000 --- a/src/terminal-target.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type TerminalRouteKind = "sandbox" | "bridge" | "attach"; - -export function sizedTerminalTargetUrl( - rawUrl: string, - routeKind: TerminalRouteKind | null, - cols: number, - rows: number, -): string { - if (routeKind !== "bridge") return rawUrl; - try { - const url = new URL(rawUrl); - url.searchParams.set("cols", String(cols)); - url.searchParams.set("rows", String(rows)); - return url.toString(); - } catch { - return ""; - } -} diff --git a/src/worker/env.ts b/src/worker/env.ts index bcdcc24..306b969 100644 --- a/src/worker/env.ts +++ b/src/worker/env.ts @@ -22,8 +22,6 @@ export type RuntimeEnv = Env & { CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS?: string; CRABBOX_COORDINATOR?: Fetcher; CRABBOX_COORDINATOR_ORIGIN?: string; - CRABBOX_PTY_BRIDGE_URL?: string; - CRABBOX_PTY_BRIDGE_TOKEN?: string; CRABBOX_SSH_GATEWAY_TOKEN?: string; CRABFLEET_SSH_GATEWAY_TOKEN?: string; CRABBOX_OPENCLAW_TOKEN?: string; diff --git a/src/worker/session-metadata.ts b/src/worker/session-metadata.ts index 640605d..e769dd5 100644 --- a/src/worker/session-metadata.ts +++ b/src/worker/session-metadata.ts @@ -227,7 +227,7 @@ export class InteractiveSessionMetadataService { private requireDelegatedControl(policy: InteractiveSessionMetadataPolicy): void { if (!policy.delegatedControlAvailable) { - throw badRequest("delegated terminal control requires a revocable PTY bridge"); + throw badRequest("delegated terminal control requires a revocable live terminal"); } } diff --git a/src/worker/session-terminal-route.ts b/src/worker/session-terminal-route.ts index d0df318..1058212 100644 --- a/src/worker/session-terminal-route.ts +++ b/src/worker/session-terminal-route.ts @@ -1,6 +1,5 @@ import { ptyRouteKind, type PtyRouteKind } from "../fleet-state.ts"; import { - legacyLeaseIdForAdapter, runtimeAdapterName, runtimeAdapterTerminalOriginMatches, safeWebSocketUrl, @@ -23,15 +22,6 @@ export function interactiveTerminalTarget( session: InteractiveSession, routeKind = interactivePtyRouteKind(env, session), ): InteractiveTerminalTarget | null { - if (routeKind === "bridge" && env.CRABBOX_PTY_BRIDGE_URL) { - const url = interactiveBridgeUrl(env.CRABBOX_PTY_BRIDGE_URL, session); - if (!url) return null; - return { - url, - authorization: bearer(env.CRABBOX_PTY_BRIDGE_TOKEN), - }; - } - const attachUrl = routeKind === "attach" ? safeWebSocketUrl(session.attachUrl) : null; if (attachUrl) { if (session.adapter === runtimeAdapterName) { @@ -77,38 +67,9 @@ export function interactivePtyRouteKind( ): PtyRouteKind | null { return ptyRouteKind(session, { sandboxAvailable: Boolean(env.SANDBOX), - bridgeUrl: env.CRABBOX_PTY_BRIDGE_URL, }); } -export function interactiveBridgeUrl(base: string, session: InteractiveSession): string { - const leaseId = legacyLeaseIdForAdapter(session.adapter, session.leaseId) ?? ""; - const replacements: Record = { - id: session.id, - leaseId, - repo: session.repo, - branch: session.branch, - runtime: session.runtime, - }; - let url = base; - for (const [key, value] of Object.entries(replacements)) { - url = url.replaceAll(`{${key}}`, encodeURIComponent(value)); - } - return safeWebSocketUrl(addQuery(httpToWebSocketUrl(url), terminalQuery(session))) ?? ""; -} - -export function terminalQuery(session: InteractiveSession): Record { - return { - sessionId: session.id, - leaseId: legacyLeaseIdForAdapter(session.adapter, session.leaseId) ?? "", - repo: session.repo, - branch: session.branch, - runtime: session.runtime, - profile: session.profile, - command: session.command, - }; -} - export function interactiveTerminalHeaders( session: InteractiveSession, authorization: string | null, @@ -122,26 +83,3 @@ export function interactiveTerminalHeaders( if (authorization) headers.set("authorization", authorization); return headers; } - -function addQuery(rawUrl: string, params: Record): string { - try { - const url = new URL(rawUrl); - for (const [key, value] of Object.entries(params)) { - if (value) url.searchParams.set(key, value); - } - return url.toString(); - } catch { - return ""; - } -} - -function httpToWebSocketUrl(rawUrl: string): string { - try { - const url = new URL(rawUrl); - if (url.protocol === "http:") url.protocol = "ws:"; - if (url.protocol === "https:") url.protocol = "wss:"; - return url.toString(); - } catch { - return ""; - } -} diff --git a/tests/fleet-state.test.ts b/tests/fleet-state.test.ts index 2c8c824..122df57 100644 --- a/tests/fleet-state.test.ts +++ b/tests/fleet-state.test.ts @@ -244,20 +244,7 @@ test("fleet attachability follows resolvable PTY routes", () => { summary({ ...baseSession, leaseId: null, attachUrl: "ws://127.1:9000/session" }), false, ); - assert.equal( - summary( - { ...baseSession, leaseId: null, attachUrl: null }, - { ptyBridgeUrl: "https://bridge.example/pty/{id}" }, - ), - true, - ); - assert.equal( - summary( - { ...baseSession, leaseId: null, attachUrl: null, canControl: false }, - { ptyBridgeUrl: "https://bridge.example/pty/{id}" }, - ), - false, - ); + assert.equal(summary({ ...baseSession, leaseId: null, attachUrl: null }), false); }); test("legacy sessions require an actual VNC URL", () => { diff --git a/tests/session-terminal-route.test.ts b/tests/session-terminal-route.test.ts index 9891056..2c2136a 100644 --- a/tests/session-terminal-route.test.ts +++ b/tests/session-terminal-route.test.ts @@ -3,12 +3,10 @@ import test from "node:test"; import type { RuntimeEnv } from "../src/worker/env.ts"; import { - interactiveBridgeUrl, interactivePtyRouteKind, interactiveTerminalHeaders, interactiveTerminalTarget, runtimeAdapterTerminalAuthorization, - terminalQuery, } from "../src/worker/session-terminal-route.ts"; import { interactiveSession } from "../src/worker/session-model.ts"; import { sessionRow } from "./helpers/session-row.ts"; @@ -34,30 +32,11 @@ test("terminal route selection follows managed backend priority", () => { attach_url: "wss://attach.example/pty", }); assert.equal(interactivePtyRouteKind({ SANDBOX: {} } as RuntimeEnv, routed), "sandbox"); - assert.equal( - interactivePtyRouteKind( - { CRABBOX_PTY_BRIDGE_URL: "https://bridge.example/{id}" } as RuntimeEnv, - routed, - ), - "bridge", - ); assert.equal(interactivePtyRouteKind({} as RuntimeEnv, routed), "attach"); -}); - -test("bridge targets expand templates, append session context, and use bridge auth", () => { - const current = session({ lease_id: "bridge:lease/1" }); - const env = { - CRABBOX_PTY_BRIDGE_URL: "https://bridge.example/pty/{id}/{leaseId}?existing=opaque&repo={repo}", - CRABBOX_PTY_BRIDGE_TOKEN: "bridge-token", - } as RuntimeEnv; - const target = interactiveTerminalTarget(env, current, "bridge"); - assert.equal( - target?.url, - "wss://bridge.example/pty/IS-route/bridge%3Alease%2F1?existing=opaque&repo=openclaw%2Fcrabfleet&sessionId=IS-route&leaseId=bridge%3Alease%2F1&branch=feature%2Fterminal+route&runtime=crabbox&profile=default&command=codex+--yolo", + interactivePtyRouteKind({} as RuntimeEnv, session({ lease_id: null, attach_url: null })), + null, ); - assert.equal(target?.authorization, "Bearer bridge-token"); - assert.equal(interactiveBridgeUrl("not a url", current), ""); }); test("signed attach targets remain opaque and adapter auth is origin-bound", () => { @@ -109,16 +88,6 @@ test("signed attach targets remain opaque and adapter auth is origin-bound", () test("terminal headers carry canonical session context", () => { const current = session({ lease_id: "sandbox:owned" }); - assert.deepEqual(terminalQuery(current), { - sessionId: "IS-route", - leaseId: "sandbox:owned", - repo: "openclaw/crabfleet", - branch: "feature/terminal route", - runtime: "crabbox", - profile: "default", - command: "codex --yolo", - }); - const headers = interactiveTerminalHeaders(current, "Bearer upstream"); assert.equal(headers.get("upgrade"), "websocket"); assert.equal(headers.get("x-crabbox-session"), "IS-route"); diff --git a/tests/terminal-target.test.ts b/tests/terminal-target.test.ts deleted file mode 100644 index 0228856..0000000 --- a/tests/terminal-target.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -import { sizedTerminalTargetUrl } from "../src/terminal-target.ts"; - -test("opaque provider terminal URLs pass through the multiplex hub unchanged", async () => { - const signed = - "wss://controller.example/v1/pty?signature=a%2Bb%2Fc%3D&cols=provider-owned&opaque=1"; - assert.equal(sizedTerminalTargetUrl(signed, "attach", 120, 34), signed); - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const upstreamStart = source.indexOf("async function openInteractiveTerminalUpstream"); - const upstreamEnd = source.indexOf( - "async function markInteractiveTerminalConnected", - upstreamStart, - ); - const upstreamSource = source.slice(upstreamStart, upstreamEnd); - assert.match( - upstreamSource, - /interactiveTerminalFetch\(\s*env,\s*session,\s*sizedTerminalTargetUrl\(target\.url, routeKind, cols, rows\)/, - ); - assert.doesNotMatch(upstreamSource, /addQuery\(target\.url/); - assert.doesNotMatch(source, /async function interactiveSessionPty/); -}); - -test("configured bridge targets receive terminal dimensions", () => { - assert.equal( - sizedTerminalTargetUrl("wss://bridge.example/pty?token=opaque", "bridge", 120, 34), - "wss://bridge.example/pty?token=opaque&cols=120&rows=34", - ); -}); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index e2f7c9c..66e74f8 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -19,8 +19,6 @@ interface Env { CRABBOX_RUNTIME_ADAPTER_NAMESPACE?: string; CRABBOX_RUNTIME_ADAPTER_TTL_SECONDS?: string; CRABBOX_RUNTIME_ADAPTER_IDLE_SECONDS?: string; - CRABBOX_PTY_BRIDGE_URL?: string; - CRABBOX_PTY_BRIDGE_TOKEN?: string; CRABBOX_OPENCLAW_TOKEN?: string; CRABBOX_TOKEN_ENCRYPTION_KEY?: string; BACKUP_BUCKET_NAME?: string; From c7a2755a75869a8d14831769b04f2c4b3708eab5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 15:24:26 +0100 Subject: [PATCH 052/109] fix: synchronize terminal protocol clients --- CHANGELOG.md | 1 + cmd/crabbox-ssh-gateway/main.go | 137 ++++++++++++++++++--------- cmd/crabbox-ssh-gateway/main_test.go | 20 ++++ internal/fleetapi/client.go | 5 +- internal/terminalws/client.go | 73 +++++++++++--- internal/terminalws/client_test.go | 136 +++++++++++++++++++++++--- protocol/terminal-v1.json | 39 ++++++++ tests/terminal-protocol.test.ts | 72 ++++++++++++++ 8 files changed, 414 insertions(+), 69 deletions(-) create mode 100644 protocol/terminal-v1.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7048a54..b1be225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Keep SSH terminal dimensions synchronized after attach and verify the TypeScript and Go multiplex clients against shared protocol vectors. - Remove the configurable PTY bridge so managed terminal upstreams are limited to built-in Sandbox, versioned runtime-adapter attach, and GitHub Actions relay behind the multiplex terminal protocol. - Remove generic create-only provisioning, the external Cloudflare runner, and the ClawFleet compatibility provider so managed workspaces use only built-in Sandbox or the versioned runtime adapter. - Extract OpenClaw nudge and stop validation, audit ordering, terminal delivery, and best-effort delivery records into a directly tested mutation service. diff --git a/cmd/crabbox-ssh-gateway/main.go b/cmd/crabbox-ssh-gateway/main.go index 944b5f7..84975e6 100644 --- a/cmd/crabbox-ssh-gateway/main.go +++ b/cmd/crabbox-ssh-gateway/main.go @@ -49,8 +49,9 @@ type keyAuth struct { } type sessionPTY struct { - cols uint32 - rows uint32 + cols uint32 + rows uint32 + resizes chan fleetapi.TerminalSize } func main() { @@ -154,55 +155,99 @@ func handleConn(raw net.Conn, config *ssh.ServerConfig, client *apiClient) { func handleSession(channel ssh.Channel, requests <-chan *ssh.Request, perms *ssh.Permissions, client *apiClient) { defer channel.Close() - pty := sessionPTY{cols: 120, rows: 34} - for req := range requests { - switch req.Type { - case "pty-req": - var payload struct { - Term string - Cols uint32 - Rows uint32 - Width uint32 - Height uint32 - Modes string - } - ssh.Unmarshal(req.Payload, &payload) - if payload.Cols > 0 { - pty.cols = payload.Cols - } - if payload.Rows > 0 { - pty.rows = payload.Rows - } - req.Reply(true, nil) - case "window-change": - var payload struct { - Cols uint32 - Rows uint32 - Width uint32 - Height uint32 - } - ssh.Unmarshal(req.Payload, &payload) - if payload.Cols > 0 { - pty.cols = payload.Cols + pty := sessionPTY{ + cols: 120, + rows: 34, + resizes: make(chan fleetapi.TerminalSize, 1), + } + exitCh := make(chan uint32, 1) + commandStarted := false + for { + select { + case req, ok := <-requests: + if !ok { + return } - if payload.Rows > 0 { - pty.rows = payload.Rows + switch req.Type { + case "pty-req": + var payload struct { + Term string + Cols uint32 + Rows uint32 + Width uint32 + Height uint32 + Modes string + } + ssh.Unmarshal(req.Payload, &payload) + pty.resize(payload.Cols, payload.Rows, commandStarted) + req.Reply(true, nil) + case "window-change": + var payload struct { + Cols uint32 + Rows uint32 + Width uint32 + Height uint32 + } + ssh.Unmarshal(req.Payload, &payload) + pty.resize(payload.Cols, payload.Rows, commandStarted) + case "shell": + if commandStarted { + req.Reply(false, nil) + continue + } + commandStarted = true + req.Reply(true, nil) + go func(current sessionPTY) { + exitCh <- runCommand(context.Background(), channel, perms, client, "", current) + }(pty) + case "exec": + if commandStarted { + req.Reply(false, nil) + continue + } + var payload struct{ Command string } + ssh.Unmarshal(req.Payload, &payload) + commandStarted = true + req.Reply(true, nil) + go func(current sessionPTY, command string) { + exitCh <- runCommand( + context.Background(), + channel, + perms, + client, + command, + current, + ) + }(pty, payload.Command) + default: + req.Reply(false, nil) } - case "shell": - req.Reply(true, nil) - exit := runCommand(context.Background(), channel, perms, client, "", pty) - replyExit(channel, exit) - return - case "exec": - var payload struct{ Command string } - ssh.Unmarshal(req.Payload, &payload) - req.Reply(true, nil) - exit := runCommand(context.Background(), channel, perms, client, payload.Command, pty) + case exit := <-exitCh: replyExit(channel, exit) return + } + } +} + +func (pty *sessionPTY) resize(cols uint32, rows uint32, notify bool) { + if cols > 0 { + pty.cols = cols + } + if rows > 0 { + pty.rows = rows + } + if !notify || pty.resizes == nil || pty.cols == 0 || pty.rows == 0 { + return + } + size := fleetapi.TerminalSize{Cols: pty.cols, Rows: pty.rows} + select { + case pty.resizes <- size: + default: + select { + case <-pty.resizes: default: - req.Reply(false, nil) } + pty.resizes <- size } } @@ -612,7 +657,7 @@ func attach( id string, pty sessionPTY, ) uint32 { - err := api.Attach(ctx, id, terminal, pty.cols, pty.rows) + err := api.Attach(ctx, id, terminal, pty.cols, pty.rows, pty.resizes) if err != nil && !errors.Is(err, net.ErrClosed) && !strings.Contains(err.Error(), "closed") { fmt.Fprintf(terminal, "\nattach closed: %v\n", err) return 1 diff --git a/cmd/crabbox-ssh-gateway/main_test.go b/cmd/crabbox-ssh-gateway/main_test.go index c2f952b..ce0026c 100644 --- a/cmd/crabbox-ssh-gateway/main_test.go +++ b/cmd/crabbox-ssh-gateway/main_test.go @@ -32,6 +32,26 @@ func TestSplitCommandKeepsQuotedValues(t *testing.T) { } } +func TestSessionPTYPublishesLatestLiveResize(t *testing.T) { + pty := sessionPTY{ + cols: 120, + rows: 34, + resizes: make(chan fleetapi.TerminalSize, 1), + } + pty.resize(100, 40, false) + select { + case size := <-pty.resizes: + t.Fatalf("resize published before attach: %#v", size) + default: + } + + pty.resize(132, 43, true) + pty.resize(144, 50, true) + if size := <-pty.resizes; size != (fleetapi.TerminalSize{Cols: 144, Rows: 50}) { + t.Fatalf("resize = %#v", size) + } +} + func TestSplitCommandPreservesBackslashesInSingleQuotes(t *testing.T) { args, err := splitCommand(`new 'fix regex \d+ in parser'`) if err != nil { diff --git a/internal/fleetapi/client.go b/internal/fleetapi/client.go index bee2e49..224c72e 100644 --- a/internal/fleetapi/client.go +++ b/internal/fleetapi/client.go @@ -14,6 +14,8 @@ import ( "github.com/openclaw/crabfleet/internal/terminalws" ) +type TerminalSize = terminalws.Size + const maxResponseBytes = 4 * 1024 * 1024 const maxErrorBytes = 512 @@ -191,13 +193,14 @@ func (c *Client) Attach( terminal io.ReadWriter, cols uint32, rows uint32, + resizes <-chan TerminalSize, ) error { client, err := c.terminal(ctx, id, cols, rows) if err != nil { return err } defer client.Close() - return client.Attach(ctx, terminal) + return client.Attach(ctx, terminal, resizes) } func (c *Client) terminal(ctx context.Context, id string, cols uint32, rows uint32) (*terminalws.Client, error) { diff --git a/internal/terminalws/client.go b/internal/terminalws/client.go index 4a04fe1..acdc2fc 100644 --- a/internal/terminalws/client.go +++ b/internal/terminalws/client.go @@ -19,17 +19,28 @@ const ( version = 1 maxFrameBytes = 16 * 1024 * 1024 - messageHello = 1 - messageWelcome = 2 - messageSubscribe = 10 - messageOutput = 20 - messageEvent = 22 - messageError = 23 - messageInput = 30 - messageControlRevoked = 53 - messageAck = 62 + messageHello = 1 + messageWelcome = 2 + messageSubscribe = 10 + messageUnsubscribe = 11 + messageOutput = 20 + messageSnapshot = 21 + messageEvent = 22 + messageError = 23 + messageInput = 30 + messageKey = 31 + messageResize = 32 + messageStop = 33 + messageControlRequest = 50 + messageControlDecision = 51 + messageControlGranted = 52 + messageControlRevoked = 53 + messagePing = 60 + messagePong = 61 + messageAck = 62 subscribeOutput = 1 << 0 + subscribeSnapshot = 1 << 1 subscribeEvents = 1 << 2 subscribeOutputAcknowledgements = 1 << 3 ) @@ -40,6 +51,11 @@ type Options struct { Rows uint32 } +type Size struct { + Cols uint32 + Rows uint32 +} + type Client struct { conn *websocket.Conn sessionID string @@ -152,11 +168,22 @@ func (c *Client) SendInput(ctx context.Context, payload []byte) error { }) } -func (c *Client) Attach(ctx context.Context, terminal io.ReadWriter) error { +func (c *Client) Resize(ctx context.Context, size Size) error { + if size.Cols == 0 || size.Rows == 0 { + return nil + } + return c.write(ctx, frame{ + messageType: messageResize, + sessionID: c.sessionID, + payload: resizePayload(size), + }) +} + +func (c *Client) Attach(ctx context.Context, terminal io.ReadWriter, resizes <-chan Size) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - errCh := make(chan error, 2) + errCh := make(chan error, 3) go func() { buffer := make([]byte, 32*1024) for { @@ -177,6 +204,23 @@ func (c *Client) Attach(ctx context.Context, terminal io.ReadWriter) error { } } }() + go func() { + for { + select { + case <-ctx.Done(): + return + case size, ok := <-resizes: + if !ok { + resizes = nil + continue + } + if err := c.Resize(ctx, size); err != nil { + errCh <- err + return + } + } + } + }() go func() { for { current, err := c.read(ctx) @@ -285,6 +329,13 @@ func subscribePayload(cols uint32, rows uint32) []byte { return payload } +func resizePayload(size Size) []byte { + payload := make([]byte, 8) + binary.LittleEndian.PutUint32(payload[0:4], size.Cols) + binary.LittleEndian.PutUint32(payload[4:8], size.Rows) + return payload +} + func ackPayload(bytes uint32) []byte { payload := make([]byte, 4) binary.LittleEndian.PutUint32(payload, bytes) diff --git a/internal/terminalws/client_test.go b/internal/terminalws/client_test.go index 10f6dd3..16a562c 100644 --- a/internal/terminalws/client_test.go +++ b/internal/terminalws/client_test.go @@ -4,15 +4,110 @@ import ( "bytes" "context" "encoding/binary" + "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" + "os" "testing" "github.com/coder/websocket" ) +type protocolFixture struct { + Magic uint16 `json:"magic"` + Version byte `json:"version"` + Messages map[string]byte `json:"messages"` + SubscribeFlags map[string]uint32 `json:"subscribeFlags"` + Vectors struct { + OutputFrame string `json:"outputFrame"` + PingFrame string `json:"pingFrame"` + SubscribeSized string `json:"subscribeSized"` + Resize string `json:"resize"` + Ack string `json:"ack"` + } `json:"vectors"` +} + +func TestGoTerminalConstantsAndEncodersMatchSharedV1Protocol(t *testing.T) { + data, err := os.ReadFile("../../protocol/terminal-v1.json") + if err != nil { + t.Fatal(err) + } + var fixture protocolFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if magic != fixture.Magic || version != fixture.Version { + t.Fatalf("protocol identity = %#x/%d", magic, version) + } + messages := map[string]byte{ + "Hello": messageHello, + "Welcome": messageWelcome, + "Subscribe": messageSubscribe, + "Unsubscribe": messageUnsubscribe, + "Output": messageOutput, + "Snapshot": messageSnapshot, + "Event": messageEvent, + "Error": messageError, + "Input": messageInput, + "Key": messageKey, + "Resize": messageResize, + "Stop": messageStop, + "ControlRequest": messageControlRequest, + "ControlDecision": messageControlDecision, + "ControlGranted": messageControlGranted, + "ControlRevoked": messageControlRevoked, + "Ping": messagePing, + "Pong": messagePong, + "Ack": messageAck, + } + if !mapsEqual(messages, fixture.Messages) { + t.Fatalf("messages = %#v", messages) + } + flags := map[string]uint32{ + "Output": subscribeOutput, + "Snapshot": subscribeSnapshot, + "Events": subscribeEvents, + "OutputAcknowledgements": subscribeOutputAcknowledgements, + } + if !mapsEqual(flags, fixture.SubscribeFlags) { + t.Fatalf("subscribe flags = %#v", flags) + } + output := encodeFrame(frame{ + messageType: messageOutput, + sessionID: "IS-123", + payload: []byte{0, 1, 2, 255}, + }) + if got := hex.EncodeToString(output); got != fixture.Vectors.OutputFrame { + t.Fatalf("output frame = %q", got) + } + if got := hex.EncodeToString(encodeFrame(frame{messageType: messagePing})); got != fixture.Vectors.PingFrame { + t.Fatalf("ping frame = %q", got) + } + if got := hex.EncodeToString(subscribePayload(144, 41)); got != fixture.Vectors.SubscribeSized { + t.Fatalf("subscribe payload = %q", got) + } + if got := hex.EncodeToString(resizePayload(Size{Cols: 132, Rows: 43})); got != fixture.Vectors.Resize { + t.Fatalf("resize payload = %q", got) + } + if got := hex.EncodeToString(ackPayload(65_535)); got != fixture.Vectors.Ack { + t.Fatalf("ack payload = %q", got) + } +} + +func mapsEqual[K comparable, V comparable](left map[K]V, right map[K]V) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + if right[key] != value { + return false + } + } + return true +} + func TestEndpointUsesTerminalHub(t *testing.T) { got, err := Endpoint("https://fleet.example/base?ignored=1") if err != nil { @@ -26,6 +121,7 @@ func TestEndpointUsesTerminalHub(t *testing.T) { func TestClientSubscribesSendsInputAndAcknowledgesOutput(t *testing.T) { receivedInput := make(chan []byte, 1) acknowledged := make(chan uint32, 1) + receivedResize := make(chan Size, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/terminal/ws" { t.Errorf("path = %q", r.URL.Path) @@ -82,17 +178,30 @@ func TestClientSubscribesSendsInputAndAcknowledgesOutput(t *testing.T) { return } - _, inputPayload, err := conn.Read(r.Context()) - if err != nil { - t.Error(err) - return - } - input, err := decodeFrame(inputPayload) - if err != nil { - t.Error(err) - return + for range 2 { + _, payload, err := conn.Read(r.Context()) + if err != nil { + t.Error(err) + return + } + current, err := decodeFrame(payload) + if err != nil { + t.Error(err) + return + } + switch current.messageType { + case messageInput: + receivedInput <- append([]byte(nil), current.payload...) + case messageResize: + receivedResize <- Size{ + Cols: binary.LittleEndian.Uint32(current.payload[0:4]), + Rows: binary.LittleEndian.Uint32(current.payload[4:8]), + } + default: + t.Errorf("unexpected message type = %d", current.messageType) + return + } } - receivedInput <- append([]byte(nil), input.payload...) if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ messageType: messageOutput, @@ -144,7 +253,9 @@ func TestClientSubscribesSendsInputAndAcknowledgesOutput(t *testing.T) { go func() { _, _ = inputWriter.Write([]byte("hello\n")) }() - if err := client.Attach(context.Background(), terminal); err != nil { + resizes := make(chan Size, 1) + resizes <- Size{Cols: 132, Rows: 43} + if err := client.Attach(context.Background(), terminal, resizes); err != nil { t.Fatal(err) } if input := <-receivedInput; string(input) != "hello\n" { @@ -153,6 +264,9 @@ func TestClientSubscribesSendsInputAndAcknowledgesOutput(t *testing.T) { if terminal.String() != "ready\n" { t.Fatalf("output = %q", terminal.String()) } + if size := <-receivedResize; size != (Size{Cols: 132, Rows: 43}) { + t.Fatalf("resize = %#v", size) + } if bytes := <-acknowledged; bytes != uint32(len("ready\n")) { t.Fatalf("acknowledged = %d", bytes) } diff --git a/protocol/terminal-v1.json b/protocol/terminal-v1.json new file mode 100644 index 0000000..129de38 --- /dev/null +++ b/protocol/terminal-v1.json @@ -0,0 +1,39 @@ +{ + "magic": 22851, + "version": 1, + "messages": { + "Hello": 1, + "Welcome": 2, + "Subscribe": 10, + "Unsubscribe": 11, + "Output": 20, + "Snapshot": 21, + "Event": 22, + "Error": 23, + "Input": 30, + "Key": 31, + "Resize": 32, + "Stop": 33, + "ControlRequest": 50, + "ControlDecision": 51, + "ControlGranted": 52, + "ControlRevoked": 53, + "Ping": 60, + "Pong": 61, + "Ack": 62 + }, + "subscribeFlags": { + "Output": 1, + "Snapshot": 2, + "Events": 4, + "OutputAcknowledgements": 8 + }, + "vectors": { + "outputFrame": "435901140600000049532d31323304000000000102ff", + "pingFrame": "4359013c0000000000000000", + "subscribeLegacy": "0d00000064000000f4010000", + "subscribeSized": "0d00000000000000000000009000000029000000", + "resize": "840000002b000000", + "ack": "ffff0000" + } +} diff --git a/tests/terminal-protocol.test.ts b/tests/terminal-protocol.test.ts index f46c791..7762c39 100644 --- a/tests/terminal-protocol.test.ts +++ b/tests/terminal-protocol.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { test } from "node:test"; import { + TERMINAL_WS_MAGIC, + TERMINAL_WS_VERSION, TerminalMessageType, TerminalSubscribeFlags, decodeAckPayload, @@ -14,6 +17,75 @@ import { encodeTerminalFrame, } from "../src/terminal-protocol.ts"; +type TerminalProtocolFixture = { + magic: number; + version: number; + messages: typeof TerminalMessageType; + subscribeFlags: typeof TerminalSubscribeFlags; + vectors: { + outputFrame: string; + pingFrame: string; + subscribeLegacy: string; + subscribeSized: string; + resize: string; + ack: string; + }; +}; + +const fixture = JSON.parse( + readFileSync(new URL("../protocol/terminal-v1.json", import.meta.url), "utf8"), +) as TerminalProtocolFixture; +const hex = (value: Uint8Array): string => Buffer.from(value).toString("hex"); + +test("TypeScript terminal constants and encoders match the shared v1 protocol", () => { + assert.equal(TERMINAL_WS_MAGIC, fixture.magic); + assert.equal(TERMINAL_WS_VERSION, fixture.version); + assert.deepEqual(TerminalMessageType, fixture.messages); + assert.deepEqual(TerminalSubscribeFlags, fixture.subscribeFlags); + assert.equal( + hex( + encodeTerminalFrame({ + type: TerminalMessageType.Output, + sessionId: "IS-123", + payload: new Uint8Array([0, 1, 2, 255]), + }), + ), + fixture.vectors.outputFrame, + ); + assert.equal( + hex(encodeTerminalFrame({ type: TerminalMessageType.Ping })), + fixture.vectors.pingFrame, + ); + assert.equal( + hex( + encodeSubscribePayload({ + flags: + TerminalSubscribeFlags.Output | + TerminalSubscribeFlags.Events | + TerminalSubscribeFlags.OutputAcknowledgements, + snapshotMinIntervalMs: 100, + snapshotMaxIntervalMs: 500, + }), + ), + fixture.vectors.subscribeLegacy, + ); + assert.equal( + hex( + encodeSubscribePayload({ + flags: + TerminalSubscribeFlags.Output | + TerminalSubscribeFlags.Events | + TerminalSubscribeFlags.OutputAcknowledgements, + cols: 144, + rows: 41, + }), + ), + fixture.vectors.subscribeSized, + ); + assert.equal(hex(encodeResizePayload(132, 43)), fixture.vectors.resize); + assert.equal(hex(encodeAckPayload(65_535)), fixture.vectors.ack); +}); + test("terminal frames round-trip binary payloads and session ids", () => { const payload = new Uint8Array([0, 1, 2, 255]); const encoded = encodeTerminalFrame({ From 1f791bd05c02cc29876e6e05b9888e59028bd608 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 15:29:42 +0100 Subject: [PATCH 053/109] refactor: extract provisioning dispatch --- src/index.ts | 74 ++------- src/worker/provisioning/service.ts | 70 +++++++++ .../types.ts} | 50 ++++--- src/worker/session-creation.ts | 2 +- src/worker/session-reconciliation.ts | 2 +- src/worker/session-repository.ts | 2 +- tests/provisioning-service.test.ts | 141 ++++++++++++++++++ tests/session-provisioning.test.ts | 61 -------- tests/session-reconciliation.test.ts | 2 +- 9 files changed, 259 insertions(+), 145 deletions(-) create mode 100644 src/worker/provisioning/service.ts rename src/worker/{session-provisioning.ts => provisioning/types.ts} (53%) create mode 100644 tests/provisioning-service.test.ts delete mode 100644 tests/session-provisioning.test.ts diff --git a/src/index.ts b/src/index.ts index e72b6cc..d5b2732 100644 --- a/src/index.ts +++ b/src/index.ts @@ -316,11 +316,11 @@ import { type RuntimeAdapterWorkspaceStopResult, } from "./worker/session-runtime-adapter-stop"; import { sharedInteractiveSession } from "./worker/session-sharing"; -import { - managedInteractiveProvisionBackend, - standaloneInteractiveProvisionSupported, - type InteractiveProvisionResult, -} from "./worker/session-provisioning"; +import { InteractiveProvisioningService } from "./worker/provisioning/service"; +import type { + InteractiveProvisionRequest, + InteractiveProvisionResult, +} from "./worker/provisioning/types"; import { interactiveCommand, interactiveSessionPurpose, @@ -520,29 +520,6 @@ type SandboxCredentialPolicyOwnershipFence = | SandboxManagedOwnershipFence | StandaloneSandboxProvisionFence; -type InteractiveProvisionRequest = { - id: string; - adapterWorkspaceId?: string | null; - adapterControlPlane?: string | null; - adapterTtlSeconds?: number | null; - adapterIdleTimeoutSeconds?: number | null; - adapterRequestedCapabilities?: RuntimeCapabilities | null; - adapterCreatePayloadJson?: string | null; - parentSessionId: string | null; - rootSessionId: string | null; - repo: string; - branch: string; - runtime: "crabbox" | "container"; - profile: string; - command: string; - prompt: string; - purpose: string; - summary: string; - owner: string; - createdBy: string; - githubToken?: string; -}; - type SandboxCredentialPolicy = { allowedHosts: string[]; expiresAt?: number; @@ -3309,8 +3286,7 @@ async function createInteractiveSessionFromInput( }, options.afterReserve, () => - provisionInteractiveSession( - env, + interactiveProvisioningService(env).provisionManaged( { id, ...(adapterWorkspaceId ? { adapterWorkspaceId } : {}), @@ -6555,35 +6531,15 @@ async function readInteractiveSessionMultiplayerMode( } } -async function provisionInteractiveSession( - env: RuntimeEnv, - session: InteractiveProvisionRequest, - agentToken?: string, - sandboxProvision?: { - lease: SandboxLease; - ownership: SandboxCurrentLeaseFence; - }, -): Promise { - const backend = managedInteractiveProvisionBackend(session.runtime, { - sandbox: Boolean(env.SANDBOX), - runtimeAdapter: runtimeAdapterConfigurationPresent(env), +function interactiveProvisioningService(env: RuntimeEnv): InteractiveProvisioningService { + return new InteractiveProvisioningService({ + sandboxAvailable: Boolean(env.SANDBOX), + runtimeAdapterAvailable: runtimeAdapterConfigurationPresent(env), + provisionSandbox: (session, agentToken, sandbox) => + provisionWithSandbox(env, session, agentToken, sandbox.lease, sandbox.ownership), + provisionRuntimeAdapter: (session, agentToken) => + provisionWithRuntimeAdapter(env, session, agentToken), }); - if (backend === "sandbox") { - if (!sandboxProvision) { - return failedProvision("Cloudflare Sandbox durable ownership is missing"); - } - return provisionWithSandbox( - env, - session, - agentToken, - sandboxProvision.lease, - sandboxProvision.ownership, - ); - } - if (backend === "runtime-adapter") { - return provisionWithRuntimeAdapter(env, session, agentToken); - } - return null; } async function provisionInteractiveEndpoint( @@ -6640,7 +6596,7 @@ async function provisionInteractiveEndpoint( } return provisionManagedSandboxEndpoint(env, payload, managed); } - if (standaloneInteractiveProvisionSupported(payload.runtime, Boolean(env.SANDBOX))) { + if (interactiveProvisioningService(env).supportsStandalone(payload.runtime)) { if (managedInteractiveSessionId(payload.id)) { return failedProvision( "interactive provision failed: standalone provision id uses the managed session namespace", diff --git a/src/worker/provisioning/service.ts b/src/worker/provisioning/service.ts new file mode 100644 index 0000000..008bf03 --- /dev/null +++ b/src/worker/provisioning/service.ts @@ -0,0 +1,70 @@ +import type { + InteractiveProvisionRequest, + InteractiveProvisionResult, + InteractiveProvisionRuntime, + SandboxProvisionOwnership, +} from "./types.ts"; + +export type ManagedInteractiveProvisionBackend = "sandbox" | "runtime-adapter"; + +export type InteractiveProvisioningDependencies = { + sandboxAvailable: boolean; + runtimeAdapterAvailable: boolean; + provisionSandbox( + session: InteractiveProvisionRequest, + agentToken: string | undefined, + sandbox: SandboxProvisionOwnership, + ): Promise; + provisionRuntimeAdapter( + session: InteractiveProvisionRequest, + agentToken: string | undefined, + ): Promise; +}; + +export class InteractiveProvisioningService { + private readonly dependencies: InteractiveProvisioningDependencies; + + constructor(dependencies: InteractiveProvisioningDependencies) { + this.dependencies = dependencies; + } + + async provisionManaged( + session: InteractiveProvisionRequest, + agentToken?: string, + sandbox?: SandboxProvisionOwnership, + ): Promise { + const backend = this.managedBackend(session.runtime); + if (backend === "sandbox") { + if (!sandbox) { + return failedProvision("Cloudflare Sandbox durable ownership is missing"); + } + return this.dependencies.provisionSandbox(session, agentToken, sandbox); + } + if (backend === "runtime-adapter") { + return this.dependencies.provisionRuntimeAdapter(session, agentToken); + } + return null; + } + + supportsStandalone(runtime: InteractiveProvisionRuntime): boolean { + return runtime === "container" && this.dependencies.sandboxAvailable; + } + + private managedBackend( + runtime: InteractiveProvisionRuntime, + ): ManagedInteractiveProvisionBackend | null { + if (runtime === "container" && this.dependencies.sandboxAvailable) return "sandbox"; + if (this.dependencies.runtimeAdapterAvailable) return "runtime-adapter"; + return null; + } +} + +function failedProvision(message: string): InteractiveProvisionResult { + return { + status: "failed", + leaseId: null, + attachUrl: null, + vncUrl: null, + message, + }; +} diff --git a/src/worker/session-provisioning.ts b/src/worker/provisioning/types.ts similarity index 53% rename from src/worker/session-provisioning.ts rename to src/worker/provisioning/types.ts index 158ec46..6dfd64f 100644 --- a/src/worker/session-provisioning.ts +++ b/src/worker/provisioning/types.ts @@ -1,5 +1,6 @@ -import type { InteractiveSessionStatus } from "./models.ts"; -import type { RuntimeCapabilities } from "./session-model.ts"; +import type { InteractiveSessionStatus } from "../models.ts"; +import type { SandboxCurrentLeaseFence, SandboxLease } from "../sandbox-lease.ts"; +import type { RuntimeCapabilities } from "../session-model.ts"; export type InteractiveProvisionResult = { status: InteractiveSessionStatus; @@ -40,23 +41,30 @@ export type InteractiveProvisionPersistenceInput = { export type InteractiveProvisionRuntime = "container" | "crabbox"; -export type ManagedInteractiveProvisionBackend = "sandbox" | "runtime-adapter"; - -export function managedInteractiveProvisionBackend( - runtime: InteractiveProvisionRuntime, - availability: { - sandbox: boolean; - runtimeAdapter: boolean; - }, -): ManagedInteractiveProvisionBackend | null { - if (runtime === "container" && availability.sandbox) return "sandbox"; - if (availability.runtimeAdapter) return "runtime-adapter"; - return null; -} +export type InteractiveProvisionRequest = { + id: string; + adapterWorkspaceId?: string | null; + adapterControlPlane?: string | null; + adapterTtlSeconds?: number | null; + adapterIdleTimeoutSeconds?: number | null; + adapterRequestedCapabilities?: RuntimeCapabilities | null; + adapterCreatePayloadJson?: string | null; + parentSessionId: string | null; + rootSessionId: string | null; + repo: string; + branch: string; + runtime: InteractiveProvisionRuntime; + profile: string; + command: string; + prompt: string; + purpose: string; + summary: string; + owner: string; + createdBy: string; + githubToken?: string; +}; -export function standaloneInteractiveProvisionSupported( - runtime: InteractiveProvisionRuntime, - sandboxAvailable: boolean, -): boolean { - return runtime === "container" && sandboxAvailable; -} +export type SandboxProvisionOwnership = { + lease: SandboxLease; + ownership: SandboxCurrentLeaseFence; +}; diff --git a/src/worker/session-creation.ts b/src/worker/session-creation.ts index 71f7f27..accc44b 100644 --- a/src/worker/session-creation.ts +++ b/src/worker/session-creation.ts @@ -3,7 +3,7 @@ import type { InteractiveProvisionPersistence, InteractiveProvisionPersistenceInput, InteractiveProvisionResult, -} from "./session-provisioning.ts"; +} from "./provisioning/types.ts"; export type InteractiveSessionCreationReservation = { id: string; diff --git a/src/worker/session-reconciliation.ts b/src/worker/session-reconciliation.ts index 645f647..2689ee6 100644 --- a/src/worker/session-reconciliation.ts +++ b/src/worker/session-reconciliation.ts @@ -4,7 +4,7 @@ import { clearedAdapterCapabilities } from "../runtime-adapter.ts"; import { database, type CompilableQuery, type InteractiveSessionRow } from "./database.ts"; import type { RuntimeEnv } from "./env.ts"; import type { InteractiveSessionStatus } from "./models.ts"; -import type { InteractiveProvisionResult } from "./session-provisioning.ts"; +import type { InteractiveProvisionResult } from "./provisioning/types.ts"; import type { InteractiveSession } from "./session-model.ts"; export type RuntimeAdapterReconciliationTransition = { diff --git a/src/worker/session-repository.ts b/src/worker/session-repository.ts index eaf6d01..9cb805c 100644 --- a/src/worker/session-repository.ts +++ b/src/worker/session-repository.ts @@ -13,7 +13,7 @@ import type { InteractiveProvisionPersistence, InteractiveProvisionPersistenceInput, InteractiveProvisionResult, -} from "./session-provisioning.ts"; +} from "./provisioning/types.ts"; import { interactiveSession, interactiveSessionLogArchive, diff --git a/tests/provisioning-service.test.ts b/tests/provisioning-service.test.ts new file mode 100644 index 0000000..328d227 --- /dev/null +++ b/tests/provisioning-service.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { InteractiveProvisioningService } from "../src/worker/provisioning/service.ts"; +import type { + InteractiveProvisionRequest, + InteractiveProvisionResult, + SandboxProvisionOwnership, +} from "../src/worker/provisioning/types.ts"; + +const session: InteractiveProvisionRequest = { + id: "session-1", + parentSessionId: null, + rootSessionId: "session-1", + repo: "openclaw/openclaw", + branch: "main", + runtime: "container", + profile: "default", + command: "pnpm test", + prompt: "", + purpose: "test", + summary: "test", + owner: "operator", + createdBy: "operator", +}; + +const sandbox: SandboxProvisionOwnership = { + lease: { + sandboxId: "sandbox-1", + terminalSessionId: "terminal-1", + }, + ownership: { + leaseId: "sandbox:sandbox-1:terminal-1:autostart-v4", + sandboxId: "sandbox-1", + }, +}; + +function result(message: string): InteractiveProvisionResult { + return { + status: "ready", + leaseId: null, + attachUrl: null, + vncUrl: null, + message, + }; +} + +test("managed container provisioning prefers built-in Sandbox", async () => { + const calls: string[] = []; + const service = new InteractiveProvisioningService({ + sandboxAvailable: true, + runtimeAdapterAvailable: true, + provisionSandbox: async (request, agentToken, ownership) => { + calls.push(`sandbox:${request.id}:${agentToken}:${ownership.lease.sandboxId}`); + return result("sandbox"); + }, + provisionRuntimeAdapter: async () => { + calls.push("runtime-adapter"); + return result("runtime-adapter"); + }, + }); + + assert.equal( + (await service.provisionManaged(session, "agent-token", sandbox))?.message, + "sandbox", + ); + assert.deepEqual(calls, ["sandbox:session-1:agent-token:sandbox-1"]); +}); + +test("managed Crabbox and container fallback use the versioned runtime adapter", async () => { + const calls: string[] = []; + const service = new InteractiveProvisioningService({ + sandboxAvailable: false, + runtimeAdapterAvailable: true, + provisionSandbox: async () => { + calls.push("sandbox"); + return result("sandbox"); + }, + provisionRuntimeAdapter: async (request, agentToken) => { + calls.push(`${request.runtime}:${agentToken}`); + return result("runtime-adapter"); + }, + }); + + assert.equal( + (await service.provisionManaged({ ...session, runtime: "crabbox" }, "crabbox-token"))?.message, + "runtime-adapter", + ); + assert.equal( + (await service.provisionManaged(session, "container-token"))?.message, + "runtime-adapter", + ); + assert.deepEqual(calls, ["crabbox:crabbox-token", "container:container-token"]); +}); + +test("managed Sandbox provisioning requires durable ownership", async () => { + const service = new InteractiveProvisioningService({ + sandboxAvailable: true, + runtimeAdapterAvailable: true, + provisionSandbox: async () => result("sandbox"), + provisionRuntimeAdapter: async () => result("runtime-adapter"), + }); + + assert.deepEqual(await service.provisionManaged(session), { + status: "failed", + leaseId: null, + attachUrl: null, + vncUrl: null, + message: "Cloudflare Sandbox durable ownership is missing", + }); +}); + +test("managed provisioning returns null when no provider supports the runtime", async () => { + const service = new InteractiveProvisioningService({ + sandboxAvailable: true, + runtimeAdapterAvailable: false, + provisionSandbox: async () => result("sandbox"), + provisionRuntimeAdapter: async () => result("runtime-adapter"), + }); + + assert.equal(await service.provisionManaged({ ...session, runtime: "crabbox" }), null); +}); + +test("standalone provisioning is built-in Sandbox only", () => { + const available = new InteractiveProvisioningService({ + sandboxAvailable: true, + runtimeAdapterAvailable: true, + provisionSandbox: async () => result("sandbox"), + provisionRuntimeAdapter: async () => result("runtime-adapter"), + }); + const unavailable = new InteractiveProvisioningService({ + sandboxAvailable: false, + runtimeAdapterAvailable: true, + provisionSandbox: async () => result("sandbox"), + provisionRuntimeAdapter: async () => result("runtime-adapter"), + }); + + assert.equal(available.supportsStandalone("container"), true); + assert.equal(available.supportsStandalone("crabbox"), false); + assert.equal(unavailable.supportsStandalone("container"), false); +}); diff --git a/tests/session-provisioning.test.ts b/tests/session-provisioning.test.ts deleted file mode 100644 index adac48d..0000000 --- a/tests/session-provisioning.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - managedInteractiveProvisionBackend, - standaloneInteractiveProvisionSupported, -} from "../src/worker/session-provisioning.ts"; - -test("managed provisioning prefers built-in Sandbox for container sessions", () => { - assert.equal( - managedInteractiveProvisionBackend("container", { - sandbox: true, - runtimeAdapter: true, - }), - "sandbox", - ); - assert.equal( - managedInteractiveProvisionBackend("crabbox", { - sandbox: true, - runtimeAdapter: true, - }), - "runtime-adapter", - ); -}); - -test("managed provisioning has one external lifecycle protocol", () => { - assert.equal( - managedInteractiveProvisionBackend("container", { - sandbox: false, - runtimeAdapter: true, - }), - "runtime-adapter", - ); - assert.equal( - managedInteractiveProvisionBackend("crabbox", { - sandbox: false, - runtimeAdapter: true, - }), - "runtime-adapter", - ); - assert.equal( - managedInteractiveProvisionBackend("container", { - sandbox: false, - runtimeAdapter: false, - }), - null, - ); - assert.equal( - managedInteractiveProvisionBackend("crabbox", { - sandbox: true, - runtimeAdapter: false, - }), - null, - ); -}); - -test("standalone provisioning is built-in Sandbox only", () => { - assert.equal(standaloneInteractiveProvisionSupported("container", true), true); - assert.equal(standaloneInteractiveProvisionSupported("container", false), false); - assert.equal(standaloneInteractiveProvisionSupported("crabbox", true), false); -}); diff --git a/tests/session-reconciliation.test.ts b/tests/session-reconciliation.test.ts index 24f56ca..a2182d0 100644 --- a/tests/session-reconciliation.test.ts +++ b/tests/session-reconciliation.test.ts @@ -13,7 +13,7 @@ import { runtimeAdapterReconciliationTransition, type InteractiveSessionReconciliationStore, } from "../src/worker/session-reconciliation.ts"; -import type { InteractiveProvisionResult } from "../src/worker/session-provisioning.ts"; +import type { InteractiveProvisionResult } from "../src/worker/provisioning/types.ts"; import { interactiveSession } from "../src/worker/session-model.ts"; import { sessionRow } from "./helpers/session-row.ts"; From ad2fd725125a4886ece7fba80ecdc87e8c741110 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 15:38:45 +0100 Subject: [PATCH 054/109] refactor: extract runtime adapter provisioning --- src/index.ts | 755 ++---------------- .../runtime-adapter-repository.ts | 269 +++++++ src/worker/provisioning/runtime-adapter.ts | 576 +++++++++++++ tests/runtime-adapter-provisioning.test.ts | 531 ++++++++++++ tests/runtime-adapter.test.ts | 154 +--- tests/runtime-profiles.test.ts | 13 +- 6 files changed, 1434 insertions(+), 864 deletions(-) create mode 100644 src/worker/provisioning/runtime-adapter-repository.ts create mode 100644 src/worker/provisioning/runtime-adapter.ts create mode 100644 tests/runtime-adapter-provisioning.test.ts diff --git a/src/index.ts b/src/index.ts index d5b2732..87c55af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,19 +49,12 @@ import { } from "./generated"; import { appCanonicalOrigin, canonicalAppRedirect, productHostResponse } from "./canonical-host"; import { - adapterFailureReleaseState, adapterWorkspaceIdMatches, - definitiveRuntimeAdapterCreateFailure, - effectiveAdapterCapabilities, currentAdapterDesktopConnection, legacyLeaseIdForAdapter, - namespacedAdapterWorkspaceId, - normalizeAdapterNamespace, - normalizeAdapterWorkspaceId, parseAdapterWorkspaceResult, redactedAdapterMessage, redactedAdapterResponseMessage, - runtimeAdapterCreatePayload, runtimeAdapterCollectionUrl, runtimeAdapterBrowserVncUrl, runtimeAdapterDesktopUrl, @@ -77,7 +70,6 @@ import { shouldReplayRuntimeAdapterCreate, validatedRuntimeAdapterCreatePayloadJson, type AdapterProvisionRecord, - type AdapterWorkspaceResult, } from "./runtime-adapter"; import { allocateInteractiveSessionIdSql, formatInteractiveSessionId } from "./session-id"; import { preferredEnabledRepo } from "./repo-selection"; @@ -317,6 +309,17 @@ import { } from "./worker/session-runtime-adapter-stop"; import { sharedInteractiveSession } from "./worker/session-sharing"; import { InteractiveProvisioningService } from "./worker/provisioning/service"; +import { + persistedRuntimeAdapterSeconds, + RuntimeAdapterProvisioningService, + runtimeAdapterProvisionResult, +} from "./worker/provisioning/runtime-adapter"; +import { + failRuntimeAdapterWorkspaceIdConflict, + persistRuntimeAdapterStopEvidence, + stageFailedRuntimeAdapterRelease, + stageRuntimeAdapterProvision, +} from "./worker/provisioning/runtime-adapter-repository"; import type { InteractiveProvisionRequest, InteractiveProvisionResult, @@ -6532,13 +6535,45 @@ async function readInteractiveSessionMultiplayerMode( } function interactiveProvisioningService(env: RuntimeEnv): InteractiveProvisioningService { + const runtimeAdapter = runtimeAdapterProvisioningService(env); return new InteractiveProvisioningService({ sandboxAvailable: Boolean(env.SANDBOX), runtimeAdapterAvailable: runtimeAdapterConfigurationPresent(env), provisionSandbox: (session, agentToken, sandbox) => provisionWithSandbox(env, session, agentToken, sandbox.lease, sandbox.ownership), - provisionRuntimeAdapter: (session, agentToken) => - provisionWithRuntimeAdapter(env, session, agentToken), + provisionRuntimeAdapter: (session) => runtimeAdapter.provision(session), + }); +} + +function runtimeAdapterProvisioningService(env: RuntimeEnv): RuntimeAdapterProvisioningService { + return new RuntimeAdapterProvisioningService({ + namespace: env.CRABBOX_RUNTIME_ADAPTER_NAMESPACE ?? "", + now: Date.now, + resolveControlPlane: (profile, registeredControlPlane) => + requireRegisteredRuntimeAdapterControlPlane(env, profile, registeredControlPlane), + stageProvision: (input) => stageRuntimeAdapterProvision(env, input), + createWorkspace: async ({ url, adapterWorkspaceId, createPayloadJson }) => { + const response = await runtimeAdapterFetch(env, url, { + method: "POST", + headers: { "idempotency-key": adapterWorkspaceId }, + body: createPayloadJson, + }); + return { + ok: response.ok, + status: response.status, + body: await readRuntimeAdapterResponseBody(response), + }; + }, + failWorkspaceIdConflict: (input) => failRuntimeAdapterWorkspaceIdConflict(env, input), + stageFailedRelease: (sessionId, adapterWorkspaceId, message, now) => + stageFailedRuntimeAdapterRelease(env, sessionId, adapterWorkspaceId, message, now), + stopWorkspaceForSession: (sessionId, adapterWorkspaceId) => + stopRuntimeAdapterWorkspaceForSession(env, sessionId, adapterWorkspaceId), + recordConfirmedRelease: async (sessionId, adapterWorkspaceId, now, message) => { + await recordConfirmedRuntimeAdapterRelease(env, sessionId, adapterWorkspaceId, now, message); + }, + persistStopEvidence: (sessionId, adapterWorkspaceId, message, now) => + persistRuntimeAdapterStopEvidence(env, sessionId, adapterWorkspaceId, message, now), }); } @@ -8999,686 +9034,6 @@ function sandboxHasGitHubCredential(env: RuntimeEnv, session: SandboxRuntimeSess return Boolean(("githubToken" in session && session.githubToken) || env.GITHUB_TOKEN); } -async function provisionWithRuntimeAdapter( - env: RuntimeEnv, - session: InteractiveProvisionRequest, - _agentToken?: string, - reconciliationOwner?: RuntimeAdapterCreateAttemptFence, -): Promise { - const replayingPendingCreate = reconciliationOwner !== undefined; - const namespace = normalizeAdapterNamespace(env.CRABBOX_RUNTIME_ADAPTER_NAMESPACE ?? ""); - const adapterWorkspaceId = session.adapterWorkspaceId - ? normalizeAdapterWorkspaceId(session.adapterWorkspaceId) === session.adapterWorkspaceId - ? session.adapterWorkspaceId - : null - : namespace - ? namespacedAdapterWorkspaceId(namespace, session.id) - : null; - if (!adapterWorkspaceId) { - if (replayingPendingCreate) { - throw new Error("runtime adapter create replay blocked: persisted workspace id is invalid"); - } - return failedProvision( - "runtime adapter provision failed: persisted workspace id or valid namespace is required", - ); - } - const fallbackCapabilities = - session.runtime === "crabbox" ? crabboxCapabilities : containerCapabilities; - let baseUrl: string; - try { - baseUrl = requireRegisteredRuntimeAdapterControlPlane( - env, - session.profile, - session.adapterControlPlane, - ); - } catch (error) { - return unresolvedRuntimeAdapterProvision( - session, - adapterWorkspaceId, - fallbackCapabilities, - clean(error instanceof Error ? error.message : String(error), 240), - ); - } - const requestedCapabilities = session.adapterRequestedCapabilities; - const ttlSeconds = persistedRuntimeAdapterSeconds(session.adapterTtlSeconds); - const idleTimeoutSeconds = persistedRuntimeAdapterSeconds(session.adapterIdleTimeoutSeconds); - if (!requestedCapabilities || !ttlSeconds || !idleTimeoutSeconds) { - if (replayingPendingCreate) { - return ambiguousRuntimeAdapterProvision( - session, - adapterWorkspaceId, - requestedCapabilities ?? fallbackCapabilities, - "runtime adapter create replay blocked: persisted create settings are incomplete", - ); - } - return releaseFailedRuntimeAdapterProvision( - env, - session.id, - runtimeAdapterFailureProvision( - session, - adapterWorkspaceId, - requestedCapabilities ?? fallbackCapabilities, - "runtime adapter provision failed: persisted create settings are incomplete", - ), - ); - } - const generatedPayload = session.adapterCreatePayloadJson - ? null - : runtimeAdapterCreatePayload( - { - namespace: namespace ?? "", - id: session.id, - parentSessionId: session.parentSessionId, - rootSessionId: session.rootSessionId, - repo: session.repo, - branch: session.branch, - runtime: session.runtime, - profile: session.profile, - command: session.command, - prompt: session.prompt, - purpose: session.purpose, - summary: session.summary, - owner: session.owner, - createdBy: session.createdBy, - ttlSeconds, - idleTimeoutSeconds, - desktop: requestedCapabilities.desktop, - }, - adapterWorkspaceId, - ); - const createPayloadJson = validatedRuntimeAdapterCreatePayloadJson( - session.adapterCreatePayloadJson ?? (generatedPayload ? JSON.stringify(generatedPayload) : ""), - { - workspaceId: adapterWorkspaceId, - ttlSeconds, - idleTimeoutSeconds, - desktop: requestedCapabilities.desktop, - }, - ); - if (!createPayloadJson) { - if (replayingPendingCreate) { - return ambiguousRuntimeAdapterProvision( - session, - adapterWorkspaceId, - requestedCapabilities, - "runtime adapter create replay blocked: persisted create payload is invalid", - ); - } - return releaseFailedRuntimeAdapterProvision( - env, - session.id, - runtimeAdapterFailureProvision( - session, - adapterWorkspaceId, - requestedCapabilities, - "runtime adapter provision failed: persisted create payload is invalid", - ), - ); - } - const createAttempt = await stageRuntimeAdapterProvision( - env, - session, - baseUrl, - adapterWorkspaceId, - requestedCapabilities, - ttlSeconds, - idleTimeoutSeconds, - createPayloadJson, - reconciliationOwner, - ); - if (!createAttempt) { - return unresolvedRuntimeAdapterProvision( - session, - adapterWorkspaceId, - requestedCapabilities, - "runtime adapter control-plane registration changed before create", - ); - } - let response: Response; - try { - response = await runtimeAdapterFetch(env, runtimeAdapterCollectionUrl(baseUrl), { - method: "POST", - headers: { "idempotency-key": adapterWorkspaceId }, - body: createPayloadJson, - }); - } catch (error) { - return ambiguousRuntimeAdapterProvision( - session, - adapterWorkspaceId, - requestedCapabilities, - `runtime adapter create outcome unknown: ${safeProviderError(error, [adapterWorkspaceId])}`, - ); - } - let responseBody: unknown; - try { - responseBody = await readRuntimeAdapterResponseBody(response); - } catch (error) { - return ambiguousRuntimeAdapterProvision( - session, - adapterWorkspaceId, - requestedCapabilities, - `runtime adapter create outcome unknown: ${safeProviderError(error, [adapterWorkspaceId])}`, - ); - } - if (!response.ok) { - const responseMessage = redactedAdapterResponseMessage( - responseBody, - `HTTP ${response.status}`, - [adapterWorkspaceId], - ); - if (runtimeAdapterWorkspaceIdConflict(response.status, responseBody)) { - const conflictResult = await failRuntimeAdapterWorkspaceIdConflict( - env, - session, - baseUrl, - adapterWorkspaceId, - createPayloadJson, - requestedCapabilities, - createAttempt, - `runtime adapter provision failed: ${responseMessage}`, - ); - if (conflictResult) return conflictResult; - throw conflict("runtime adapter workspace conflict response is stale"); - } - if (!replayingPendingCreate && definitiveRuntimeAdapterCreateFailure(response.status)) { - return releaseFailedRuntimeAdapterProvision( - env, - session.id, - runtimeAdapterFailureProvision( - session, - adapterWorkspaceId, - requestedCapabilities, - `runtime adapter provision failed: ${responseMessage}`, - ), - ); - } - const messagePrefix = replayingPendingCreate - ? "runtime adapter create replay pending" - : "runtime adapter create outcome unknown"; - return ambiguousRuntimeAdapterProvision( - session, - adapterWorkspaceId, - requestedCapabilities, - `${messagePrefix}: ${responseMessage}`, - ); - } - const parsed = parseAdapterWorkspaceResult(responseBody, { - workspaceId: adapterWorkspaceId, - profile: session.profile, - }); - if (!parsed) { - return ambiguousRuntimeAdapterProvision( - session, - adapterWorkspaceId, - requestedCapabilities, - "runtime adapter create outcome unknown: invalid workspace response", - ); - } - if (!adapterWorkspaceIdMatches(parsed, adapterWorkspaceId)) { - return ambiguousRuntimeAdapterProvision( - session, - adapterWorkspaceId, - requestedCapabilities, - "runtime adapter create outcome unknown: workspace identity mismatch", - ); - } - if (parsed.profile !== session.profile) { - return ambiguousRuntimeAdapterProvision( - session, - adapterWorkspaceId, - requestedCapabilities, - "runtime adapter create outcome unknown: workspace profile mismatch", - ); - } - const result = runtimeAdapterProvisionResult( - parsed, - session, - Date.now(), - adapterWorkspaceId, - true, - ); - return result.status === "failed" - ? releaseFailedRuntimeAdapterProvision(env, session.id, result) - : result; -} - -function persistedRuntimeAdapterSeconds(value: number | null | undefined): number | null { - return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; -} - -type RuntimeAdapterCreateAttemptFence = { - status: InteractiveSessionStatus; - updatedAt: number; - lastReconciledAt: number | null; - terminalStatus: "failed" | null; -}; - -async function stageRuntimeAdapterProvision( - env: RuntimeEnv, - session: InteractiveProvisionRequest, - adapterControlPlane: string, - adapterWorkspaceId: string, - capabilities: RuntimeCapabilities, - ttlSeconds: number, - idleTimeoutSeconds: number, - createPayloadJson: string, - reconciliationOwner?: RuntimeAdapterCreateAttemptFence, -): Promise { - const stageAt = Date.now(); - let stage = database(env) - .updateTable("interactive_sessions") - .set({ - adapter: runtimeAdapterName, - profile: session.profile, - adapter_workspace_id: adapterWorkspaceId, - capabilities_json: JSON.stringify(capabilities), - adapter_ttl_seconds: ttlSeconds, - adapter_idle_timeout_seconds: idleTimeoutSeconds, - adapter_requested_capabilities_json: JSON.stringify(capabilities), - adapter_create_payload_json: createPayloadJson, - adapter_create_pending: 1, - reconcile_error: "runtime adapter create pending", - ...(reconciliationOwner ? {} : { updated_at: sql`MAX(updated_at + 1, ${stageAt})` }), - }) - .where("id", "=", session.id) - .where("adapter_control_plane", "=", adapterControlPlane) - .where("adapter_workspace_id", "=", adapterWorkspaceId); - if (reconciliationOwner) { - stage = stage - .where("status", "=", reconciliationOwner.status) - .where("updated_at", "=", reconciliationOwner.updatedAt); - stage = - reconciliationOwner.lastReconciledAt === null - ? stage.where("last_reconciled_at", "is", null) - : stage.where("last_reconciled_at", "=", reconciliationOwner.lastReconciledAt); - stage = - reconciliationOwner.terminalStatus === null - ? stage.where("terminal_status", "is", null) - : stage.where("terminal_status", "=", reconciliationOwner.terminalStatus); - } else { - stage = stage.where("status", "in", ["provisioning", "pending_adapter"]); - } - const staged = await stage - .returning(["status", "updated_at", "last_reconciled_at", "terminal_status"]) - .executeTakeFirst(); - return staged - ? { - status: staged.status, - updatedAt: staged.updated_at, - lastReconciledAt: staged.last_reconciled_at, - terminalStatus: staged.terminal_status, - } - : null; -} - -function ambiguousRuntimeAdapterProvision( - session: Pick, - adapterWorkspaceId: string, - capabilities: RuntimeCapabilities, - message: string, -): InteractiveProvisionResult { - return { - status: "provisioning", - leaseId: null, - attachUrl: null, - attachUrlPresent: true, - vncUrl: null, - message, - adapter: runtimeAdapterName, - profile: session.profile, - adapterWorkspaceId, - providerResourceId: null, - capabilities, - capabilitiesPresent: true, - expiresAt: null, - expiresAtPresent: false, - reconciledAt: Date.now(), - reconcileError: message, - terminalStatus: null, - createPending: true, - }; -} - -function runtimeAdapterFailureProvision( - session: Pick, - adapterWorkspaceId: string, - capabilities: RuntimeCapabilities, - message: string, -): InteractiveProvisionResult { - return { - ...ambiguousRuntimeAdapterProvision(session, adapterWorkspaceId, capabilities, message), - status: "failed", - terminalStatus: null, - createPending: false, - }; -} - -function unresolvedRuntimeAdapterProvision( - session: Pick, - adapterWorkspaceId: string, - capabilities: RuntimeCapabilities, - message: string, -): InteractiveProvisionResult { - return { - ...runtimeAdapterFailureProvision(session, adapterWorkspaceId, capabilities, message), - status: "stopping", - message: `${message}; runtime workspace outcome unresolved`, - reconcileError: message, - terminalStatus: "failed", - createPending: true, - }; -} - -async function failRuntimeAdapterWorkspaceIdConflict( - env: RuntimeEnv, - session: Pick, - adapterControlPlane: string, - adapterWorkspaceId: string, - createPayloadJson: string, - capabilities: RuntimeCapabilities, - createAttempt: RuntimeAdapterCreateAttemptFence, - message: string, -): Promise { - const now = Date.now(); - const failureMessage = clean(message, 500); - const lastReconciledOwner = - createAttempt.lastReconciledAt === null - ? sql`last_reconciled_at IS NULL` - : sql`last_reconciled_at = ${createAttempt.lastReconciledAt}`; - const terminalStatusOwner = - createAttempt.terminalStatus === null - ? sql`terminal_status IS NULL` - : sql`terminal_status = ${createAttempt.terminalStatus}`; - const expectedOwner = sql` - id = ${session.id} - AND adapter = ${runtimeAdapterName} - AND adapter_workspace_id = ${adapterWorkspaceId} - AND adapter_control_plane = ${adapterControlPlane} - AND adapter_create_payload_json = ${createPayloadJson} - AND adapter_requested_capabilities_json = ${JSON.stringify(capabilities)} - AND adapter_create_pending = 1 - AND status = ${createAttempt.status} - AND updated_at = ${createAttempt.updatedAt} - AND ${lastReconciledOwner} - AND ${terminalStatusOwner} - `; - const db = database(env); - const event = sql` - INSERT INTO interactive_session_events (session_id, actor, message, created_at) - SELECT ${session.id}, 'system', ${failureMessage}, ${now} - FROM interactive_sessions - WHERE ${expectedOwner} - `; - const detach = db - .updateTable("interactive_sessions") - .set({ - status: "failed", - adapter: null, - adapter_workspace_id: null, - adapter_control_plane: null, - provider_resource_id: null, - adapter_ttl_seconds: null, - adapter_idle_timeout_seconds: null, - adapter_requested_capabilities_json: null, - adapter_create_payload_json: null, - adapter_create_pending: 0, - lease_id: null, - attach_url: null, - vnc_url: null, - expires_at: null, - last_reconciled_at: now, - reconcile_error: failureMessage, - terminal_status: null, - terminal_failure_reason: failureMessage, - terminal_finalize_pending: 1, - stopped_at: now, - agent_token_hash: null, - controller: null, - control_requested_by: null, - control_requested_at: null, - control_granted_at: null, - control_expires_at: null, - last_event: failureMessage, - updated_at: sql`MAX(updated_at + 1, ${now})`, - }) - .where(expectedOwner) - .returning("updated_at"); - const results = await env.DB.batch<{ updated_at: number }>( - [event, detach].map((query) => { - const compiled = query.compile(db); - return env.DB.prepare(compiled.sql).bind(...compiled.parameters); - }), - ); - if (!results.at(-1)?.results.length) return null; - await archiveInteractiveSessionLogs(env, session.id, now).catch(() => undefined); - await finalizeTerminalInteractiveSession(env, session.id, "failed", now).catch(() => undefined); - return { - status: "failed", - leaseId: null, - attachUrl: null, - attachUrlPresent: true, - vncUrl: null, - message: failureMessage, - adapter: null, - profile: session.profile, - adapterWorkspaceId: null, - providerResourceId: null, - capabilities, - capabilitiesPresent: true, - expiresAt: null, - expiresAtPresent: true, - reconciledAt: now, - reconcileError: failureMessage, - terminalStatus: null, - createPending: false, - }; -} - -async function releaseFailedRuntimeAdapterProvision( - env: RuntimeEnv, - sessionId: string, - result: InteractiveProvisionResult, -): Promise { - const adapterWorkspaceId = result.adapterWorkspaceId; - if (!adapterWorkspaceId) return result; - await stageFailedRuntimeAdapterRelease(env, sessionId, adapterWorkspaceId, result.message); - try { - const release = await stopRuntimeAdapterWorkspaceForSession(env, sessionId, adapterWorkspaceId); - const releaseState = adapterFailureReleaseState(release.status); - if (release.status === "stopped") { - await recordConfirmedRuntimeAdapterRelease( - env, - sessionId, - adapterWorkspaceId, - Date.now(), - release.message, - ); - } - const releaseMessage = `${result.message}; ${release.message}`; - if (release.status === "stopping") { - await persistRuntimeAdapterStopEvidence( - env, - sessionId, - adapterWorkspaceId, - releaseMessage, - Date.now(), - ); - } - return { - ...result, - status: releaseState.status, - attachUrl: null, - vncUrl: null, - message: releaseMessage, - reconcileError: release.status === "stopping" ? releaseMessage : result.message, - terminalStatus: releaseState.terminalStatus, - }; - } catch (error) { - const releaseError = safeProviderError( - error, - [adapterWorkspaceId, result.providerResourceId ?? null], - [result.attachUrl], - ); - const releaseState = adapterFailureReleaseState("stopping"); - const pendingMessage = `${result.message}; ${releaseState.message}: ${releaseError}`; - await persistRuntimeAdapterStopEvidence( - env, - sessionId, - adapterWorkspaceId, - pendingMessage, - Date.now(), - ); - return { - ...result, - status: releaseState.status, - attachUrl: null, - vncUrl: null, - message: pendingMessage, - reconcileError: pendingMessage, - terminalStatus: releaseState.terminalStatus, - }; - } -} - -async function persistRuntimeAdapterStopEvidence( - env: RuntimeEnv, - sessionId: string, - adapterWorkspaceId: string, - message: string, - now: number, - reconcileError: string | null = message, - eventActor = "system", -): Promise { - const evidence = clean(message, 500); - const errorEvidence = reconcileError ? clean(reconcileError, 500) : null; - const actorName = clean(eventActor, 120) || "system"; - const reconcileErrorOwner = errorEvidence - ? sql`reconcile_error = ${errorEvidence}` - : sql`reconcile_error IS NULL`; - const db = database(env); - await executeBatch(env, [ - db - .updateTable("interactive_sessions") - .set({ - last_reconciled_at: now, - reconcile_error: errorEvidence, - last_event: evidence, - updated_at: sql`MAX(updated_at + 1, ${now})`, - }) - .where("id", "=", sessionId) - .where("adapter", "=", runtimeAdapterName) - .where("adapter_workspace_id", "=", adapterWorkspaceId) - .where("status", "=", "stopping").where(sql` - COALESCE(last_event, '') != ${evidence} - OR COALESCE(reconcile_error, '') != ${errorEvidence ?? ""} - `), - sql` - INSERT INTO interactive_session_events (session_id, actor, message, created_at) - SELECT ${sessionId}, ${actorName}, ${evidence}, ${now} - WHERE EXISTS ( - SELECT 1 - FROM interactive_sessions - WHERE id = ${sessionId} - AND adapter = ${runtimeAdapterName} - AND adapter_workspace_id = ${adapterWorkspaceId} - AND status = 'stopping' - AND last_event = ${evidence} - AND ${reconcileErrorOwner} - ) - AND NOT EXISTS ( - SELECT 1 - FROM interactive_session_events - WHERE session_id = ${sessionId} - AND actor = ${actorName} - AND message = ${evidence} - ) - `, - ]); - await archiveInteractiveSessionLogs(env, sessionId, now).catch(() => undefined); -} - -async function stageFailedRuntimeAdapterRelease( - env: RuntimeEnv, - sessionId: string, - adapterWorkspaceId: string, - message: string, -): Promise { - const now = Date.now(); - await database(env) - .updateTable("interactive_sessions") - .set({ - status: "stopping", - lease_id: null, - attach_url: null, - vnc_url: null, - terminal_status: "failed", - terminal_failure_reason: message, - adapter_create_pending: 0, - last_reconciled_at: now, - reconcile_error: message, - agent_token_hash: null, - controller: null, - control_requested_by: null, - control_requested_at: null, - control_granted_at: null, - control_expires_at: null, - updated_at: sql`MAX(updated_at + 1, ${now})`, - last_event: `${message}; runtime workspace release pending`, - }) - .where("id", "=", sessionId) - .where("adapter", "=", runtimeAdapterName) - .where("adapter_workspace_id", "=", adapterWorkspaceId) - .where("status", "in", [ - "provisioning", - "pending_adapter", - "ready", - "attached", - "detached", - "stopping", - ]) - .execute(); -} - -function runtimeAdapterProvisionResult( - result: AdapterWorkspaceResult, - session: Pick & { - adapterRequestedCapabilities?: RuntimeCapabilities | null; - capabilities_json?: string; - }, - reconciledAt: number, - adapterWorkspaceId: string, - initialCreate: boolean, -): InteractiveProvisionResult { - const defaultCapabilities = - session.adapterRequestedCapabilities ?? - (session.capabilities_json - ? runtimeCapabilities(session.runtime, session.capabilities_json) - : session.runtime === "crabbox" - ? crabboxCapabilities - : containerCapabilities); - const capabilities = effectiveAdapterCapabilities(result, defaultCapabilities, initialCreate); - return { - status: result.status, - leaseId: null, - attachUrl: result.terminalUrl, - attachUrlPresent: initialCreate || result.terminalUrlPresent, - // Desktop access is minted only after Crabfleet authenticates the viewer. - vncUrl: null, - message: result.message, - adapter: runtimeAdapterName, - profile: session.profile, - adapterWorkspaceId, - providerResourceId: result.providerResourceId, - ...(capabilities === undefined ? {} : { capabilities, capabilitiesPresent: true }), - ...(initialCreate || result.expiresAtPresent - ? { expiresAt: result.expiresAt, expiresAtPresent: true } - : {}), - reconciledAt, - reconcileError: null, - createPending: false, - }; -} - function runtimeAdapterRecord(session: InteractiveSessionRow): AdapterProvisionRecord { if (session.runtime === githubActionsRuntime) { throw new Error("GitHub Actions sessions cannot use the runtime adapter"); @@ -9705,10 +9060,8 @@ async function inspectRuntimeAdapterWorkspace( return reconcileStoppingRuntimeAdapterWorkspace(env, session, reconciliationClaimAt); } if (shouldReplayRuntimeAdapterCreate(session.status, session.adapter_create_pending === 1)) { - return provisionWithRuntimeAdapter( - env, + return runtimeAdapterProvisioningService(env).provision( runtimeAdapterReplayRequest(runtimeAdapterRecord(session)), - undefined, { status: session.status, updatedAt: session.updated_at, @@ -9769,7 +9122,7 @@ async function inspectRuntimeAdapterWorkspace( false, ); return result.status === "failed" - ? releaseFailedRuntimeAdapterProvision(env, session.id, result) + ? runtimeAdapterProvisioningService(env).releaseFailed(session.id, result) : result; } @@ -9991,21 +9344,21 @@ async function replayStoppingRuntimeAdapterCreate( [adapterWorkspaceId], ); if (runtimeAdapterWorkspaceIdConflict(response.status, responseBody)) { - const terminalResult = await failRuntimeAdapterWorkspaceIdConflict( - env, + const terminalResult = await failRuntimeAdapterWorkspaceIdConflict(env, { session, - controlPlane, + now: Date.now(), + adapterControlPlane: controlPlane, adapterWorkspaceId, createPayloadJson, - requestedCapabilities, - { + capabilities: requestedCapabilities, + createAttempt: { status: "stopping", updatedAt: session.updated_at, lastReconciledAt: reconciliationClaimAt, terminalStatus: session.terminal_status, }, - `runtime adapter create replay failed: ${responseMessage}`, - ); + message: `runtime adapter create replay failed: ${responseMessage}`, + }); if (!terminalResult) { return { message: "runtime adapter create replay deferred: conflict response is stale", diff --git a/src/worker/provisioning/runtime-adapter-repository.ts b/src/worker/provisioning/runtime-adapter-repository.ts new file mode 100644 index 0000000..aa59c16 --- /dev/null +++ b/src/worker/provisioning/runtime-adapter-repository.ts @@ -0,0 +1,269 @@ +import { sql } from "kysely"; + +import { runtimeAdapterName } from "../../runtime-adapter.ts"; +import { database, executeBatch } from "../database.ts"; +import type { RuntimeEnv } from "../env.ts"; +import { archiveInteractiveSessionLogs } from "../session-log-archive.ts"; +import { finalizeTerminalInteractiveSession } from "../session-terminal-finalization.ts"; +import type { + RuntimeAdapterProvisionStageInput, + RuntimeAdapterWorkspaceConflictInput, +} from "./runtime-adapter.ts"; +import type { InteractiveProvisionResult } from "./types.ts"; + +export async function stageRuntimeAdapterProvision( + env: RuntimeEnv, + input: RuntimeAdapterProvisionStageInput, +) { + let stage = database(env) + .updateTable("interactive_sessions") + .set({ + adapter: runtimeAdapterName, + profile: input.session.profile, + adapter_workspace_id: input.adapterWorkspaceId, + capabilities_json: JSON.stringify(input.capabilities), + adapter_ttl_seconds: input.ttlSeconds, + adapter_idle_timeout_seconds: input.idleTimeoutSeconds, + adapter_requested_capabilities_json: JSON.stringify(input.capabilities), + adapter_create_payload_json: input.createPayloadJson, + adapter_create_pending: 1, + reconcile_error: "runtime adapter create pending", + ...(input.reconciliationOwner + ? {} + : { updated_at: sql`MAX(updated_at + 1, ${input.now})` }), + }) + .where("id", "=", input.session.id) + .where("adapter_control_plane", "=", input.adapterControlPlane) + .where("adapter_workspace_id", "=", input.adapterWorkspaceId); + if (input.reconciliationOwner) { + stage = stage + .where("status", "=", input.reconciliationOwner.status) + .where("updated_at", "=", input.reconciliationOwner.updatedAt); + stage = + input.reconciliationOwner.lastReconciledAt === null + ? stage.where("last_reconciled_at", "is", null) + : stage.where("last_reconciled_at", "=", input.reconciliationOwner.lastReconciledAt); + stage = + input.reconciliationOwner.terminalStatus === null + ? stage.where("terminal_status", "is", null) + : stage.where("terminal_status", "=", input.reconciliationOwner.terminalStatus); + } else { + stage = stage.where("status", "in", ["provisioning", "pending_adapter"]); + } + const staged = await stage + .returning(["status", "updated_at", "last_reconciled_at", "terminal_status"]) + .executeTakeFirst(); + return staged + ? { + status: staged.status, + updatedAt: staged.updated_at, + lastReconciledAt: staged.last_reconciled_at, + terminalStatus: staged.terminal_status, + } + : null; +} + +export async function failRuntimeAdapterWorkspaceIdConflict( + env: RuntimeEnv, + input: RuntimeAdapterWorkspaceConflictInput, +): Promise { + const failureMessage = clean(input.message, 500); + const lastReconciledOwner = + input.createAttempt.lastReconciledAt === null + ? sql`last_reconciled_at IS NULL` + : sql`last_reconciled_at = ${input.createAttempt.lastReconciledAt}`; + const terminalStatusOwner = + input.createAttempt.terminalStatus === null + ? sql`terminal_status IS NULL` + : sql`terminal_status = ${input.createAttempt.terminalStatus}`; + const expectedOwner = sql` + id = ${input.session.id} + AND adapter = ${runtimeAdapterName} + AND adapter_workspace_id = ${input.adapterWorkspaceId} + AND adapter_control_plane = ${input.adapterControlPlane} + AND adapter_create_payload_json = ${input.createPayloadJson} + AND adapter_requested_capabilities_json = ${JSON.stringify(input.capabilities)} + AND adapter_create_pending = 1 + AND status = ${input.createAttempt.status} + AND updated_at = ${input.createAttempt.updatedAt} + AND ${lastReconciledOwner} + AND ${terminalStatusOwner} + `; + const db = database(env); + const event = sql` + INSERT INTO interactive_session_events (session_id, actor, message, created_at) + SELECT ${input.session.id}, 'system', ${failureMessage}, ${input.now} + FROM interactive_sessions + WHERE ${expectedOwner} + `; + const detach = db + .updateTable("interactive_sessions") + .set({ + status: "failed", + adapter: null, + adapter_workspace_id: null, + adapter_control_plane: null, + provider_resource_id: null, + adapter_ttl_seconds: null, + adapter_idle_timeout_seconds: null, + adapter_requested_capabilities_json: null, + adapter_create_payload_json: null, + adapter_create_pending: 0, + lease_id: null, + attach_url: null, + vnc_url: null, + expires_at: null, + last_reconciled_at: input.now, + reconcile_error: failureMessage, + terminal_status: null, + terminal_failure_reason: failureMessage, + terminal_finalize_pending: 1, + stopped_at: input.now, + agent_token_hash: null, + controller: null, + control_requested_by: null, + control_requested_at: null, + control_granted_at: null, + control_expires_at: null, + last_event: failureMessage, + updated_at: sql`MAX(updated_at + 1, ${input.now})`, + }) + .where(expectedOwner) + .returning("updated_at"); + const results = await env.DB.batch<{ updated_at: number }>( + [event, detach].map((query) => { + const compiled = query.compile(db); + return env.DB.prepare(compiled.sql).bind(...compiled.parameters); + }), + ); + if (!results.at(-1)?.results.length) return null; + await archiveInteractiveSessionLogs(env, input.session.id, input.now).catch(() => undefined); + await finalizeTerminalInteractiveSession(env, input.session.id, "failed", input.now).catch( + () => undefined, + ); + return { + status: "failed", + leaseId: null, + attachUrl: null, + attachUrlPresent: true, + vncUrl: null, + message: failureMessage, + adapter: null, + profile: input.session.profile, + adapterWorkspaceId: null, + providerResourceId: null, + capabilities: input.capabilities, + capabilitiesPresent: true, + expiresAt: null, + expiresAtPresent: true, + reconciledAt: input.now, + reconcileError: failureMessage, + terminalStatus: null, + createPending: false, + }; +} + +export async function persistRuntimeAdapterStopEvidence( + env: RuntimeEnv, + sessionId: string, + adapterWorkspaceId: string, + message: string, + now: number, + reconcileError: string | null = message, + eventActor = "system", +): Promise { + const evidence = clean(message, 500); + const errorEvidence = reconcileError ? clean(reconcileError, 500) : null; + const actorName = clean(eventActor, 120) || "system"; + const reconcileErrorOwner = errorEvidence + ? sql`reconcile_error = ${errorEvidence}` + : sql`reconcile_error IS NULL`; + const db = database(env); + await executeBatch(env, [ + db + .updateTable("interactive_sessions") + .set({ + last_reconciled_at: now, + reconcile_error: errorEvidence, + last_event: evidence, + updated_at: sql`MAX(updated_at + 1, ${now})`, + }) + .where("id", "=", sessionId) + .where("adapter", "=", runtimeAdapterName) + .where("adapter_workspace_id", "=", adapterWorkspaceId) + .where("status", "=", "stopping").where(sql` + COALESCE(last_event, '') != ${evidence} + OR COALESCE(reconcile_error, '') != ${errorEvidence ?? ""} + `), + sql` + INSERT INTO interactive_session_events (session_id, actor, message, created_at) + SELECT ${sessionId}, ${actorName}, ${evidence}, ${now} + WHERE EXISTS ( + SELECT 1 + FROM interactive_sessions + WHERE id = ${sessionId} + AND adapter = ${runtimeAdapterName} + AND adapter_workspace_id = ${adapterWorkspaceId} + AND status = 'stopping' + AND last_event = ${evidence} + AND ${reconcileErrorOwner} + ) + AND NOT EXISTS ( + SELECT 1 + FROM interactive_session_events + WHERE session_id = ${sessionId} + AND actor = ${actorName} + AND message = ${evidence} + ) + `, + ]); + await archiveInteractiveSessionLogs(env, sessionId, now).catch(() => undefined); +} + +export async function stageFailedRuntimeAdapterRelease( + env: RuntimeEnv, + sessionId: string, + adapterWorkspaceId: string, + message: string, + now: number, +): Promise { + await database(env) + .updateTable("interactive_sessions") + .set({ + status: "stopping", + lease_id: null, + attach_url: null, + vnc_url: null, + terminal_status: "failed", + terminal_failure_reason: message, + adapter_create_pending: 0, + last_reconciled_at: now, + reconcile_error: message, + agent_token_hash: null, + controller: null, + control_requested_by: null, + control_requested_at: null, + control_granted_at: null, + control_expires_at: null, + updated_at: sql`MAX(updated_at + 1, ${now})`, + last_event: `${message}; runtime workspace release pending`, + }) + .where("id", "=", sessionId) + .where("adapter", "=", runtimeAdapterName) + .where("adapter_workspace_id", "=", adapterWorkspaceId) + .where("status", "in", [ + "provisioning", + "pending_adapter", + "ready", + "attached", + "detached", + "stopping", + ]) + .execute(); +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/src/worker/provisioning/runtime-adapter.ts b/src/worker/provisioning/runtime-adapter.ts new file mode 100644 index 0000000..eb5c362 --- /dev/null +++ b/src/worker/provisioning/runtime-adapter.ts @@ -0,0 +1,576 @@ +import { + adapterFailureReleaseState, + adapterWorkspaceIdMatches, + definitiveRuntimeAdapterCreateFailure, + effectiveAdapterCapabilities, + namespacedAdapterWorkspaceId, + normalizeAdapterNamespace, + normalizeAdapterWorkspaceId, + parseAdapterWorkspaceResult, + redactedAdapterMessage, + redactedAdapterResponseMessage, + runtimeAdapterCollectionUrl, + runtimeAdapterCreatePayload, + runtimeAdapterName, + runtimeAdapterWorkspaceIdConflict, + validatedRuntimeAdapterCreatePayloadJson, + type AdapterWorkspaceResult, +} from "../../runtime-adapter.ts"; +import { conflict } from "../http.ts"; +import type { InteractiveSessionStatus } from "../models.ts"; +import { + containerCapabilities, + crabboxCapabilities, + runtimeCapabilities, + type RuntimeCapabilities, +} from "../session-model.ts"; +import type { RuntimeAdapterWorkspaceStopResult } from "../session-runtime-adapter-stop.ts"; +import type { InteractiveProvisionRequest, InteractiveProvisionResult } from "./types.ts"; + +export type RuntimeAdapterCreateAttemptFence = { + status: InteractiveSessionStatus; + updatedAt: number; + lastReconciledAt: number | null; + terminalStatus: "failed" | null; +}; + +export type RuntimeAdapterProvisionStageInput = { + session: Pick; + now: number; + adapterControlPlane: string; + adapterWorkspaceId: string; + capabilities: RuntimeCapabilities; + ttlSeconds: number; + idleTimeoutSeconds: number; + createPayloadJson: string; + reconciliationOwner?: RuntimeAdapterCreateAttemptFence; +}; + +export type RuntimeAdapterWorkspaceConflictInput = { + session: Pick; + now: number; + adapterControlPlane: string; + adapterWorkspaceId: string; + createPayloadJson: string; + capabilities: RuntimeCapabilities; + createAttempt: RuntimeAdapterCreateAttemptFence; + message: string; +}; + +export type RuntimeAdapterCreateTransportInput = { + url: string; + adapterWorkspaceId: string; + createPayloadJson: string; +}; + +export type RuntimeAdapterCreateTransportResult = { + ok: boolean; + status: number; + body: unknown; +}; + +export type RuntimeAdapterProvisioningDependencies = { + namespace: string; + now(): number; + resolveControlPlane(profile: string, registeredControlPlane: string | null | undefined): string; + stageProvision( + input: RuntimeAdapterProvisionStageInput, + ): Promise; + createWorkspace( + input: RuntimeAdapterCreateTransportInput, + ): Promise; + failWorkspaceIdConflict( + input: RuntimeAdapterWorkspaceConflictInput, + ): Promise; + stageFailedRelease( + sessionId: string, + adapterWorkspaceId: string, + message: string, + now: number, + ): Promise; + stopWorkspaceForSession( + sessionId: string, + adapterWorkspaceId: string, + ): Promise; + recordConfirmedRelease( + sessionId: string, + adapterWorkspaceId: string, + now: number, + message: string, + ): Promise; + persistStopEvidence( + sessionId: string, + adapterWorkspaceId: string, + message: string, + now: number, + ): Promise; +}; + +export class RuntimeAdapterProvisioningService { + private readonly dependencies: RuntimeAdapterProvisioningDependencies; + + constructor(dependencies: RuntimeAdapterProvisioningDependencies) { + this.dependencies = dependencies; + } + + async provision( + session: InteractiveProvisionRequest, + reconciliationOwner?: RuntimeAdapterCreateAttemptFence, + ): Promise { + const replayingPendingCreate = reconciliationOwner !== undefined; + const namespace = normalizeAdapterNamespace(this.dependencies.namespace); + const adapterWorkspaceId = session.adapterWorkspaceId + ? normalizeAdapterWorkspaceId(session.adapterWorkspaceId) === session.adapterWorkspaceId + ? session.adapterWorkspaceId + : null + : namespace + ? namespacedAdapterWorkspaceId(namespace, session.id) + : null; + if (!adapterWorkspaceId) { + if (replayingPendingCreate) { + throw new Error("runtime adapter create replay blocked: persisted workspace id is invalid"); + } + return failedProvision( + "runtime adapter provision failed: persisted workspace id or valid namespace is required", + ); + } + + const fallbackCapabilities = + session.runtime === "crabbox" ? crabboxCapabilities : containerCapabilities; + let adapterControlPlane: string; + try { + adapterControlPlane = this.dependencies.resolveControlPlane( + session.profile, + session.adapterControlPlane, + ); + } catch (error) { + return unresolvedRuntimeAdapterProvision( + session, + adapterWorkspaceId, + fallbackCapabilities, + clean(error instanceof Error ? error.message : String(error), 240), + this.dependencies.now(), + ); + } + + const requestedCapabilities = session.adapterRequestedCapabilities; + const ttlSeconds = persistedRuntimeAdapterSeconds(session.adapterTtlSeconds); + const idleTimeoutSeconds = persistedRuntimeAdapterSeconds(session.adapterIdleTimeoutSeconds); + if (!requestedCapabilities || !ttlSeconds || !idleTimeoutSeconds) { + if (replayingPendingCreate) { + return ambiguousRuntimeAdapterProvision( + session, + adapterWorkspaceId, + requestedCapabilities ?? fallbackCapabilities, + "runtime adapter create replay blocked: persisted create settings are incomplete", + this.dependencies.now(), + ); + } + return this.releaseFailed( + session.id, + runtimeAdapterFailureProvision( + session, + adapterWorkspaceId, + requestedCapabilities ?? fallbackCapabilities, + "runtime adapter provision failed: persisted create settings are incomplete", + this.dependencies.now(), + ), + ); + } + + const generatedPayload = session.adapterCreatePayloadJson + ? null + : runtimeAdapterCreatePayload( + { + namespace: namespace ?? "", + id: session.id, + parentSessionId: session.parentSessionId, + rootSessionId: session.rootSessionId, + repo: session.repo, + branch: session.branch, + runtime: session.runtime, + profile: session.profile, + command: session.command, + prompt: session.prompt, + purpose: session.purpose, + summary: session.summary, + owner: session.owner, + createdBy: session.createdBy, + ttlSeconds, + idleTimeoutSeconds, + desktop: requestedCapabilities.desktop, + }, + adapterWorkspaceId, + ); + const createPayloadJson = validatedRuntimeAdapterCreatePayloadJson( + session.adapterCreatePayloadJson ?? + (generatedPayload ? JSON.stringify(generatedPayload) : ""), + { + workspaceId: adapterWorkspaceId, + ttlSeconds, + idleTimeoutSeconds, + desktop: requestedCapabilities.desktop, + }, + ); + if (!createPayloadJson) { + if (replayingPendingCreate) { + return ambiguousRuntimeAdapterProvision( + session, + adapterWorkspaceId, + requestedCapabilities, + "runtime adapter create replay blocked: persisted create payload is invalid", + this.dependencies.now(), + ); + } + return this.releaseFailed( + session.id, + runtimeAdapterFailureProvision( + session, + adapterWorkspaceId, + requestedCapabilities, + "runtime adapter provision failed: persisted create payload is invalid", + this.dependencies.now(), + ), + ); + } + + const createAttempt = await this.dependencies.stageProvision({ + session, + now: this.dependencies.now(), + adapterControlPlane, + adapterWorkspaceId, + capabilities: requestedCapabilities, + ttlSeconds, + idleTimeoutSeconds, + createPayloadJson, + ...(reconciliationOwner ? { reconciliationOwner } : {}), + }); + if (!createAttempt) { + return unresolvedRuntimeAdapterProvision( + session, + adapterWorkspaceId, + requestedCapabilities, + "runtime adapter control-plane registration changed before create", + this.dependencies.now(), + ); + } + + let response: RuntimeAdapterCreateTransportResult; + try { + response = await this.dependencies.createWorkspace({ + url: runtimeAdapterCollectionUrl(adapterControlPlane), + adapterWorkspaceId, + createPayloadJson, + }); + } catch (error) { + return ambiguousRuntimeAdapterProvision( + session, + adapterWorkspaceId, + requestedCapabilities, + `runtime adapter create outcome unknown: ${safeProviderError(error, [adapterWorkspaceId])}`, + this.dependencies.now(), + ); + } + + if (!response.ok) { + const responseMessage = redactedAdapterResponseMessage( + response.body, + `HTTP ${response.status}`, + [adapterWorkspaceId], + ); + if (runtimeAdapterWorkspaceIdConflict(response.status, response.body)) { + const conflictResult = await this.dependencies.failWorkspaceIdConflict({ + session, + now: this.dependencies.now(), + adapterControlPlane, + adapterWorkspaceId, + createPayloadJson, + capabilities: requestedCapabilities, + createAttempt, + message: `runtime adapter provision failed: ${responseMessage}`, + }); + if (conflictResult) return conflictResult; + throw conflict("runtime adapter workspace conflict response is stale"); + } + if (!replayingPendingCreate && definitiveRuntimeAdapterCreateFailure(response.status)) { + return this.releaseFailed( + session.id, + runtimeAdapterFailureProvision( + session, + adapterWorkspaceId, + requestedCapabilities, + `runtime adapter provision failed: ${responseMessage}`, + this.dependencies.now(), + ), + ); + } + const messagePrefix = replayingPendingCreate + ? "runtime adapter create replay pending" + : "runtime adapter create outcome unknown"; + return ambiguousRuntimeAdapterProvision( + session, + adapterWorkspaceId, + requestedCapabilities, + `${messagePrefix}: ${responseMessage}`, + this.dependencies.now(), + ); + } + + const parsed = parseAdapterWorkspaceResult(response.body, { + workspaceId: adapterWorkspaceId, + profile: session.profile, + }); + if (!parsed) { + return ambiguousRuntimeAdapterProvision( + session, + adapterWorkspaceId, + requestedCapabilities, + "runtime adapter create outcome unknown: invalid workspace response", + this.dependencies.now(), + ); + } + if (!adapterWorkspaceIdMatches(parsed, adapterWorkspaceId)) { + return ambiguousRuntimeAdapterProvision( + session, + adapterWorkspaceId, + requestedCapabilities, + "runtime adapter create outcome unknown: workspace identity mismatch", + this.dependencies.now(), + ); + } + if (parsed.profile !== session.profile) { + return ambiguousRuntimeAdapterProvision( + session, + adapterWorkspaceId, + requestedCapabilities, + "runtime adapter create outcome unknown: workspace profile mismatch", + this.dependencies.now(), + ); + } + + const result = runtimeAdapterProvisionResult( + parsed, + session, + this.dependencies.now(), + adapterWorkspaceId, + true, + ); + return result.status === "failed" ? this.releaseFailed(session.id, result) : result; + } + + async releaseFailed( + sessionId: string, + result: InteractiveProvisionResult, + ): Promise { + const adapterWorkspaceId = result.adapterWorkspaceId; + if (!adapterWorkspaceId) return result; + await this.dependencies.stageFailedRelease( + sessionId, + adapterWorkspaceId, + result.message, + this.dependencies.now(), + ); + try { + const release = await this.dependencies.stopWorkspaceForSession( + sessionId, + adapterWorkspaceId, + ); + const releaseState = adapterFailureReleaseState(release.status); + const now = this.dependencies.now(); + if (release.status === "stopped") { + await this.dependencies.recordConfirmedRelease( + sessionId, + adapterWorkspaceId, + now, + release.message, + ); + } + const releaseMessage = `${result.message}; ${release.message}`; + if (release.status === "stopping") { + await this.dependencies.persistStopEvidence( + sessionId, + adapterWorkspaceId, + releaseMessage, + now, + ); + } + return { + ...result, + status: releaseState.status, + attachUrl: null, + vncUrl: null, + message: releaseMessage, + reconcileError: release.status === "stopping" ? releaseMessage : result.message, + terminalStatus: releaseState.terminalStatus, + }; + } catch (error) { + const releaseError = safeProviderError( + error, + [adapterWorkspaceId, result.providerResourceId ?? null], + [result.attachUrl], + ); + const releaseState = adapterFailureReleaseState("stopping"); + const pendingMessage = `${result.message}; ${releaseState.message}: ${releaseError}`; + await this.dependencies.persistStopEvidence( + sessionId, + adapterWorkspaceId, + pendingMessage, + this.dependencies.now(), + ); + return { + ...result, + status: releaseState.status, + attachUrl: null, + vncUrl: null, + message: pendingMessage, + reconcileError: pendingMessage, + terminalStatus: releaseState.terminalStatus, + }; + } + } +} + +export function persistedRuntimeAdapterSeconds(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; +} + +export function runtimeAdapterProvisionResult( + result: AdapterWorkspaceResult, + session: Pick & { + adapterRequestedCapabilities?: RuntimeCapabilities | null; + capabilities_json?: string; + }, + reconciledAt: number, + adapterWorkspaceId: string, + initialCreate: boolean, +): InteractiveProvisionResult { + const defaultCapabilities = + session.adapterRequestedCapabilities ?? + (session.capabilities_json + ? runtimeCapabilities(session.runtime, session.capabilities_json) + : session.runtime === "crabbox" + ? crabboxCapabilities + : containerCapabilities); + const capabilities = effectiveAdapterCapabilities(result, defaultCapabilities, initialCreate); + return { + status: result.status, + leaseId: null, + attachUrl: result.terminalUrl, + attachUrlPresent: initialCreate || result.terminalUrlPresent, + vncUrl: null, + message: result.message, + adapter: runtimeAdapterName, + profile: session.profile, + adapterWorkspaceId, + providerResourceId: result.providerResourceId, + ...(capabilities === undefined ? {} : { capabilities, capabilitiesPresent: true }), + ...(initialCreate || result.expiresAtPresent + ? { expiresAt: result.expiresAt, expiresAtPresent: true } + : {}), + reconciledAt, + reconcileError: null, + createPending: false, + }; +} + +function ambiguousRuntimeAdapterProvision( + session: Pick, + adapterWorkspaceId: string, + capabilities: RuntimeCapabilities, + message: string, + reconciledAt: number, +): InteractiveProvisionResult { + return { + status: "provisioning", + leaseId: null, + attachUrl: null, + attachUrlPresent: true, + vncUrl: null, + message, + adapter: runtimeAdapterName, + profile: session.profile, + adapterWorkspaceId, + providerResourceId: null, + capabilities, + capabilitiesPresent: true, + expiresAt: null, + expiresAtPresent: false, + reconciledAt, + reconcileError: message, + terminalStatus: null, + createPending: true, + }; +} + +function runtimeAdapterFailureProvision( + session: Pick, + adapterWorkspaceId: string, + capabilities: RuntimeCapabilities, + message: string, + reconciledAt: number, +): InteractiveProvisionResult { + return { + ...ambiguousRuntimeAdapterProvision( + session, + adapterWorkspaceId, + capabilities, + message, + reconciledAt, + ), + status: "failed", + terminalStatus: null, + createPending: false, + }; +} + +function unresolvedRuntimeAdapterProvision( + session: Pick, + adapterWorkspaceId: string, + capabilities: RuntimeCapabilities, + message: string, + reconciledAt: number, +): InteractiveProvisionResult { + return { + ...runtimeAdapterFailureProvision( + session, + adapterWorkspaceId, + capabilities, + message, + reconciledAt, + ), + status: "stopping", + message: `${message}; runtime workspace outcome unresolved`, + reconcileError: message, + terminalStatus: "failed", + createPending: true, + }; +} + +function failedProvision(message: string): InteractiveProvisionResult { + return { + status: "failed", + leaseId: null, + attachUrl: null, + vncUrl: null, + message, + }; +} + +function safeProviderError( + error: unknown, + identifiers: Array = [], + connectionValues: Array = [], +): string { + return redactedAdapterMessage( + clean(error instanceof Error ? error.message : String(error), 2000), + "failed", + identifiers, + connectionValues, + ); +} + +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} diff --git a/tests/runtime-adapter-provisioning.test.ts b/tests/runtime-adapter-provisioning.test.ts new file mode 100644 index 0000000..2819b77 --- /dev/null +++ b/tests/runtime-adapter-provisioning.test.ts @@ -0,0 +1,531 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseAdapterWorkspaceResult } from "../src/runtime-adapter.ts"; +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + failRuntimeAdapterWorkspaceIdConflict, + stageRuntimeAdapterProvision, +} from "../src/worker/provisioning/runtime-adapter-repository.ts"; +import { + RuntimeAdapterProvisioningService, + runtimeAdapterProvisionResult, + type RuntimeAdapterProvisioningDependencies, +} from "../src/worker/provisioning/runtime-adapter.ts"; +import type { + InteractiveProvisionRequest, + InteractiveProvisionResult, +} from "../src/worker/provisioning/types.ts"; +import { containerCapabilities } from "../src/worker/session-model.ts"; + +const session: InteractiveProvisionRequest = { + id: "IS-101", + adapterWorkspaceId: "fleet-a-is-101", + adapterControlPlane: "https://controller.example/api/", + adapterTtlSeconds: 14_400, + adapterIdleTimeoutSeconds: 1_800, + adapterRequestedCapabilities: { + terminal: true, + takeover: true, + vnc: true, + desktop: true, + logs: true, + artifacts: true, + }, + parentSessionId: "IS-100", + rootSessionId: "IS-99", + repo: "example/project", + branch: "feature/refactor", + runtime: "crabbox", + profile: "desktop-large", + command: "codex --yolo", + prompt: "continue", + purpose: "refactor", + summary: "starting", + owner: "operator", + createdBy: "service", +}; + +const createAttempt = { + status: "provisioning" as const, + updatedAt: 101, + lastReconciledAt: null, + terminalStatus: null, +}; + +function failedConflictResult(message = "workspace conflict"): InteractiveProvisionResult { + return { + status: "failed", + leaseId: null, + attachUrl: null, + vncUrl: null, + message, + adapter: null, + profile: session.profile, + adapterWorkspaceId: null, + createPending: false, + }; +} + +function dependencies( + overrides: Partial = {}, +): RuntimeAdapterProvisioningDependencies { + return { + namespace: "fleet-a", + now: () => 200, + resolveControlPlane: (_profile, registeredControlPlane) => { + if (!registeredControlPlane) throw new Error("registered control plane missing"); + return registeredControlPlane; + }, + async stageProvision() { + return createAttempt; + }, + async createWorkspace() { + return { + ok: true, + status: 201, + body: { + id: "fleet-a-is-101", + status: "ready", + profile: "desktop-large", + attachUrl: "wss://controller.example/terminal/IS-101", + capabilities: session.adapterRequestedCapabilities, + expiresAt: "2026-06-15T12:00:00Z", + message: "ready", + }, + }; + }, + async failWorkspaceIdConflict() { + return failedConflictResult(); + }, + async stageFailedRelease() {}, + async stopWorkspaceForSession() { + return { status: "stopped", message: "runtime workspace released" }; + }, + async recordConfirmedRelease() {}, + async persistStopEvidence() {}, + ...overrides, + }; +} + +test("runtime adapter create stages and sends the immutable registered payload", async () => { + const stages: unknown[] = []; + const creates: unknown[] = []; + const service = new RuntimeAdapterProvisioningService( + dependencies({ + async stageProvision(input) { + stages.push(input); + return createAttempt; + }, + async createWorkspace(input) { + creates.push(input); + return dependencies().createWorkspace(input); + }, + }), + ); + + const result = await service.provision(session); + + assert.equal(result.status, "ready"); + assert.equal(result.profile, "desktop-large"); + assert.equal(result.adapterWorkspaceId, "fleet-a-is-101"); + assert.equal(result.attachUrl, "wss://controller.example/terminal/IS-101"); + assert.equal(stages.length, 1); + assert.equal(creates.length, 1); + const stage = stages[0] as { + adapterControlPlane: string; + adapterWorkspaceId: string; + createPayloadJson: string; + }; + assert.equal(stage.adapterControlPlane, "https://controller.example/api/"); + assert.equal(stage.adapterWorkspaceId, "fleet-a-is-101"); + assert.deepEqual(JSON.parse(stage.createPayloadJson), { + id: "fleet-a-is-101", + parentSessionId: "IS-100", + rootSessionId: "IS-99", + repo: "example/project", + branch: "feature/refactor", + runtime: "crabbox", + profile: "desktop-large", + command: "codex --yolo", + prompt: "continue", + purpose: "refactor", + summary: "starting", + owner: "operator", + createdBy: "service", + ttlSeconds: 14_400, + idleTimeoutSeconds: 1_800, + capabilities: { desktop: true }, + }); + assert.deepEqual(creates[0], { + url: "https://controller.example/api/v1/workspaces", + adapterWorkspaceId: "fleet-a-is-101", + createPayloadJson: stage.createPayloadJson, + }); +}); + +test("runtime adapter create rejects response identity and profile changes as ambiguous", async () => { + const wrongIdentity = new RuntimeAdapterProvisioningService( + dependencies({ + async createWorkspace() { + return { + ok: true, + status: 201, + body: { + id: "different-workspace", + status: "ready", + profile: session.profile, + }, + }; + }, + }), + ); + const wrongProfile = new RuntimeAdapterProvisioningService( + dependencies({ + async createWorkspace() { + return { + ok: true, + status: 201, + body: { + id: session.adapterWorkspaceId, + status: "ready", + profile: "different-profile", + }, + }; + }, + }), + ); + + assert.match((await wrongIdentity.provision(session)).message, /workspace identity mismatch/); + assert.match((await wrongProfile.provision(session)).message, /workspace profile mismatch/); +}); + +test("runtime adapter transport failures remain ambiguous and retain create ownership", async () => { + const service = new RuntimeAdapterProvisioningService( + dependencies({ + async createWorkspace() { + throw new Error("request failed token=secret"); + }, + }), + ); + + const result = await service.provision(session); + + assert.equal(result.status, "provisioning"); + assert.equal(result.createPending, true); + assert.match(result.message, /create outcome unknown/); + assert.doesNotMatch(result.message, /secret/); +}); + +test("definitive create failures stage release before stopping and confirming", async () => { + const calls: string[] = []; + const service = new RuntimeAdapterProvisioningService( + dependencies({ + async createWorkspace() { + return { + ok: false, + status: 422, + body: { detail: "capacity unavailable; token=private-value" }, + }; + }, + async stageFailedRelease(_sessionId, _workspaceId, message) { + calls.push(`stage:${message}`); + }, + async stopWorkspaceForSession() { + calls.push("stop"); + return { status: "stopped", message: "runtime workspace released" }; + }, + async recordConfirmedRelease() { + calls.push("confirm"); + }, + }), + ); + + const result = await service.provision(session); + + assert.equal(result.status, "failed"); + assert.equal(result.terminalStatus, null); + assert.match(result.message, /capacity unavailable; \[credential\]/); + assert.deepEqual( + calls.map((call) => call.split(":")[0]), + ["stage", "stop", "confirm"], + ); +}); + +test("pending failed releases retain terminal failure and persist stop evidence", async () => { + const calls: string[] = []; + const service = new RuntimeAdapterProvisioningService( + dependencies({ + async stageFailedRelease(_sessionId, _workspaceId, _message, now) { + calls.push(`stage:${now}`); + }, + async stopWorkspaceForSession() { + calls.push("stop"); + return { status: "stopping", message: "runtime workspace release pending" }; + }, + async persistStopEvidence(_sessionId, _workspaceId, _message, now) { + calls.push(`evidence:${now}`); + }, + }), + ); + + const result = await service.releaseFailed("IS-101", { + status: "failed", + leaseId: null, + attachUrl: "wss://controller.example/private-terminal", + vncUrl: null, + message: "provider failed", + adapterWorkspaceId: "fleet-a-is-101", + }); + + assert.equal(result.status, "stopping"); + assert.equal(result.terminalStatus, "failed"); + assert.equal(result.attachUrl, null); + assert.equal(result.reconcileError, "provider failed; runtime workspace release pending"); + assert.deepEqual(calls, ["stage:200", "stop", "evidence:200"]); +}); + +test("failed release transport errors stay retryable and redact connection authority", async () => { + const evidence: string[] = []; + const service = new RuntimeAdapterProvisioningService( + dependencies({ + async stopWorkspaceForSession() { + throw new Error("delete failed token=private wss://controller.example/private-terminal"); + }, + async persistStopEvidence(_sessionId, _workspaceId, message) { + evidence.push(message); + }, + }), + ); + + const result = await service.releaseFailed("IS-101", { + status: "failed", + leaseId: null, + attachUrl: "wss://controller.example/private-terminal", + vncUrl: null, + message: "provider failed", + adapterWorkspaceId: "fleet-a-is-101", + }); + + assert.equal(result.status, "stopping"); + assert.equal(result.terminalStatus, "failed"); + assert.match(result.message, /runtime workspace release pending/); + assert.doesNotMatch(result.message, /private/); + assert.deepEqual(evidence, [result.message]); +}); + +test("create replays keep retryable and definitive responses ambiguous", async () => { + const calls: string[] = []; + const service = new RuntimeAdapterProvisioningService( + dependencies({ + async createWorkspace() { + return { ok: false, status: 422, body: { message: "invalid while replaying" } }; + }, + async stageFailedRelease() { + calls.push("stage-release"); + }, + }), + ); + + const result = await service.provision(session, { + status: "provisioning", + updatedAt: 100, + lastReconciledAt: 90, + terminalStatus: null, + }); + + assert.equal(result.status, "provisioning"); + assert.equal(result.createPending, true); + assert.match(result.message, /create replay pending/); + assert.deepEqual(calls, []); +}); + +test("workspace id conflicts detach through the fenced repository callback only", async () => { + const calls: string[] = []; + const service = new RuntimeAdapterProvisioningService( + dependencies({ + async createWorkspace() { + return { + ok: false, + status: 409, + body: { error: { code: "workspace_id_conflict", message: "already owned" } }, + }; + }, + async failWorkspaceIdConflict(input) { + calls.push(`conflict:${input.createAttempt.updatedAt}:${input.adapterControlPlane}`); + return failedConflictResult("already owned"); + }, + async stageFailedRelease() { + calls.push("stage-release"); + }, + async stopWorkspaceForSession() { + calls.push("stop"); + return { status: "stopped", message: "stopped" }; + }, + }), + ); + + const result = await service.provision(session); + + assert.equal(result.message, "already owned"); + assert.deepEqual(calls, ["conflict:101:https://controller.example/api/"]); +}); + +test("runtime adapter result mapping preserves registered profile and requested capabilities", () => { + const parsed = parseAdapterWorkspaceResult( + { + id: "fleet-a-is-101", + status: "ready", + profile: "provider-profile", + capabilities: { terminal: true }, + }, + { workspaceId: "fleet-a-is-101" }, + ); + assert.ok(parsed); + + const result = runtimeAdapterProvisionResult( + parsed, + { + runtime: "container", + profile: "registered-profile", + adapterRequestedCapabilities: containerCapabilities, + }, + 200, + "fleet-a-is-101", + true, + ); + + assert.equal(result.profile, "registered-profile"); + assert.deepEqual(result.capabilities, { + ...containerCapabilities, + terminal: true, + }); +}); + +type PreparedStatement = { + sql: string; + parameters: unknown[]; + all(): Promise; + run(): Promise; +}; + +function runtimeEnv( + handler: (sql: string, parameters: unknown[], kind: "all" | "run") => unknown[], + batchHandler: (statements: PreparedStatement[]) => unknown[] = () => [], +): RuntimeEnv { + return { + DB: { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + return { + sql, + parameters, + async all() { + return { results: handler(sql, parameters, "all"), meta: { changes: 1 } }; + }, + async run() { + handler(sql, parameters, "run"); + return { meta: { changes: 1 } }; + }, + }; + }, + }; + }, + async batch(statements: unknown[]) { + return batchHandler(statements as PreparedStatement[]); + }, + } as unknown as D1Database, + } as RuntimeEnv; +} + +test("runtime adapter create staging compiles exact lifecycle fences", async () => { + const executions: Array<{ sql: string; parameters: unknown[] }> = []; + const env = runtimeEnv((sql, parameters) => { + executions.push({ sql, parameters }); + return [ + { + status: "provisioning", + updated_at: 102, + last_reconciled_at: 90, + terminal_status: null, + }, + ]; + }); + + const result = await stageRuntimeAdapterProvision(env, { + session, + now: 100, + adapterControlPlane: "https://controller.example/api/", + adapterWorkspaceId: "fleet-a-is-101", + capabilities: session.adapterRequestedCapabilities!, + ttlSeconds: 14_400, + idleTimeoutSeconds: 1_800, + createPayloadJson: '{"id":"fleet-a-is-101"}', + reconciliationOwner: { + status: "provisioning", + updatedAt: 101, + lastReconciledAt: 90, + terminalStatus: null, + }, + }); + + assert.deepEqual(result, { + status: "provisioning", + updatedAt: 102, + lastReconciledAt: 90, + terminalStatus: null, + }); + assert.equal(executions.length, 1); + assert.match(executions[0].sql, /adapter_control_plane/); + assert.match(executions[0].sql, /adapter_workspace_id/); + assert.match(executions[0].sql, /last_reconciled_at/); + assert.match(executions[0].sql, /terminal_status"?\s+is null/i); + assert.ok(executions[0].parameters.includes("https://controller.example/api/")); + assert.ok(executions[0].parameters.includes("fleet-a-is-101")); + assert.ok(executions[0].parameters.includes(101)); + assert.ok(executions[0].parameters.includes(90)); +}); + +test("workspace conflict detachment batches the full ownership fence", async () => { + let statements: PreparedStatement[] = []; + const env = runtimeEnv( + () => [], + (prepared) => { + statements = prepared; + return [{ results: [] }, { results: [] }]; + }, + ); + + const result = await failRuntimeAdapterWorkspaceIdConflict(env, { + session, + now: 200, + adapterControlPlane: "https://controller.example/api/", + adapterWorkspaceId: "fleet-a-is-101", + createPayloadJson: '{"id":"fleet-a-is-101"}', + capabilities: session.adapterRequestedCapabilities!, + createAttempt: { + status: "provisioning", + updatedAt: 101, + lastReconciledAt: 90, + terminalStatus: null, + }, + message: "workspace already owned", + }); + + assert.equal(result, null); + assert.equal(statements.length, 2); + const batchSql = statements.map((statement) => statement.sql).join("\n"); + assert.match(batchSql, /adapter_create_pending/); + assert.match(batchSql, /adapter_control_plane/); + assert.match(batchSql, /adapter_create_payload_json/); + assert.match(batchSql, /adapter_requested_capabilities_json/); + assert.match(batchSql, /last_reconciled_at/); + assert.match(batchSql, /terminal_status"?\s+is null/i); + assert.match(batchSql, /terminal_finalize_pending/); + const parameters = statements.flatMap((statement) => statement.parameters); + assert.ok(parameters.includes("https://controller.example/api/")); + assert.ok(parameters.includes("fleet-a-is-101")); + assert.ok(parameters.includes(101)); + assert.ok(parameters.includes(90)); +}); diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 5e89705..f2e0ea3 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -113,38 +113,6 @@ test("adapter create payload matches the strict controller contract", () => { ); }); -test("configured profiles fence every adapter runtime and preserve requested capabilities", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const resultStart = source.indexOf("function runtimeAdapterProvisionResult"); - const resultEnd = source.indexOf( - "async function reconcileStoppingRuntimeAdapterWorkspace", - resultStart, - ); - const resultSource = source.slice(resultStart, resultEnd); - - assert.match(resultSource, /session\.adapterRequestedCapabilities \?\?/); - assert.match(resultSource, /profile: session\.profile/); - assert.doesNotMatch(resultSource, /profile: result\.profile/); -}); - -test("profile-routed adapter responses cannot rewrite their lifecycle route", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const createStart = source.indexOf("async function provisionWithRuntimeAdapter"); - const createEnd = source.indexOf("function persistedRuntimeAdapterSeconds", createStart); - const createSource = source.slice(createStart, createEnd); - const inspectStart = source.indexOf("async function inspectRuntimeAdapterWorkspace"); - const inspectEnd = source.indexOf( - "async function reconcileStoppingRuntimeAdapterWorkspace", - inspectStart, - ); - const inspectSource = source.slice(inspectStart, inspectEnd); - - assert.match(createSource, /parsed\.profile !== session\.profile/); - assert.match(createSource, /workspace profile mismatch/); - assert.match(inspectSource, /parsed\.profile !== session\.profile/); - assert.match(inspectSource, /different workspace profile/); -}); - test("adapter workspace id stays distinct from provider resource id", () => { const result = parseAdapterWorkspaceResult({ id: "fleet-a-is-101", @@ -501,9 +469,6 @@ test("runtime adapter lifecycle cannot escape durable session ownership", async const stopStart = source.indexOf("async function stopSupersededRuntimeAdapterProvision"); const stopEnd = source.indexOf("async function resolveInteractiveSessionLineage", stopStart); const stopSource = source.slice(stopStart, stopEnd); - const releaseStart = source.indexOf("async function releaseFailedRuntimeAdapterProvision"); - const releaseEnd = source.indexOf("function runtimeAdapterProvisionResult", releaseStart); - const releaseSource = source.slice(releaseStart, releaseEnd); assert.match(stopSource, /recordConfirmedRuntimeAdapterRelease/); assert.match(stopSource, /select\(\[[\s\S]*"adapter_create_pending"[\s\S]*"terminal_status"/); @@ -519,15 +484,6 @@ test("runtime adapter lifecycle cannot escape durable session ownership", async stopSource.indexOf("clearRuntimeAdapterCreatePending") < stopSource.indexOf("const release = await stopRuntimeAdapterWorkspaceForSession"), ); - assert.ok( - releaseSource.indexOf("stageFailedRuntimeAdapterRelease") < - releaseSource.indexOf("stopRuntimeAdapterWorkspace"), - ); - assert.match(releaseSource, /status: "stopping"/); - assert.match(releaseSource, /release\.message/); - assert.match(releaseSource, /pendingMessage/); - assert.match(releaseSource, /terminal_status: "failed"/); - assert.match(releaseSource, /adapter_create_pending: 0/); assert.match(source, /AND NOT EXISTS \(/); assert.match( finalizationSource, @@ -758,9 +714,6 @@ test("runtime adapter operations stay bound to the registered control plane", as new URL("../migrations/0020_runtime_adapter_lifecycle.sql", import.meta.url), "utf8", ); - const provisionStart = source.indexOf("async function provisionWithRuntimeAdapter"); - const provisionEnd = source.indexOf("function persistedRuntimeAdapterSeconds", provisionStart); - const provisionSource = source.slice(provisionStart, provisionEnd); const inspectStart = source.indexOf("async function inspectRuntimeAdapterWorkspace"); const inspectEnd = source.indexOf( "async function reconcileStoppingRuntimeAdapterWorkspace", @@ -803,8 +756,6 @@ test("runtime adapter operations stay bound to the registered control plane", as message: "runtime adapter control plane differs from workspace registration", }, ); - assert.match(provisionSource, /requireRegisteredRuntimeAdapterControlPlane/); - assert.match(provisionSource, /runtimeAdapterCollectionUrl\(baseUrl\)/); assert.match(inspectSource, /session\.adapter_control_plane/); assert.match(inspectSource, /runtimeAdapterWorkspaceUrl\(controlPlane, adapterWorkspaceId\)/); assert.match(stopSource, /requireRegisteredRuntimeAdapterControlPlane/); @@ -824,9 +775,6 @@ test("pending runtime adapter creates replay before any inspect", async () => { inspectStart, ); const inspectSource = source.slice(inspectStart, inspectEnd); - const provisionStart = source.indexOf("async function provisionWithRuntimeAdapter"); - const provisionEnd = source.indexOf("function persistedRuntimeAdapterSeconds", provisionStart); - const provisionSource = source.slice(provisionStart, provisionEnd); const replayIndex = inspectSource.indexOf( "shouldReplayRuntimeAdapterCreate(session.status, session.adapter_create_pending === 1)", ); @@ -840,12 +788,6 @@ test("pending runtime adapter creates replay before any inspect", async () => { assert.match(inspectSource, /runtimeAdapterReplayRequest\(runtimeAdapterRecord\(session\)\)/); assert.ok(missingIndex >= 0); assert.doesNotMatch(missingSource, /provisionWithRuntimeAdapter/); - assert.match(provisionSource, /const replayingPendingCreate = reconciliationOwner !== undefined/); - assert.match( - provisionSource, - /!replayingPendingCreate && definitiveRuntimeAdapterCreateFailure\(response\.status\)/, - ); - assert.match(provisionSource, /runtime adapter create replay blocked/); }); test("stopping create replay owns the exact persisted lifecycle", async () => { @@ -1527,96 +1469,17 @@ test("explicit workspace id conflicts are distinct from retryable 409 responses" ); }); -test("workspace id conflicts detach without adopting or deleting the existing workspace", async () => { - const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const createStart = source.indexOf("async function provisionWithRuntimeAdapter"); - const createEnd = source.indexOf("function persistedRuntimeAdapterSeconds", createStart); - const createSource = source.slice(createStart, createEnd); - const conflictStart = source.indexOf("async function failRuntimeAdapterWorkspaceIdConflict"); - const conflictEnd = source.indexOf( - "async function releaseFailedRuntimeAdapterProvision", - conflictStart, - ); - const conflictSource = source.slice(conflictStart, conflictEnd); - const stageStart = source.indexOf("async function stageRuntimeAdapterProvision"); - const stageEnd = source.indexOf("function ambiguousRuntimeAdapterProvision", stageStart); - const stageSource = source.slice(stageStart, stageEnd); - const stoppingReplayStart = source.indexOf("async function replayStoppingRuntimeAdapterCreate"); - const stoppingReplayEnd = source.indexOf( - "async function stopRuntimeAdapterWorkspace(", - stoppingReplayStart, - ); - const stoppingReplaySource = source.slice(stoppingReplayStart, stoppingReplayEnd); - - assert.ok( - createSource.indexOf("runtimeAdapterWorkspaceIdConflict") < - createSource.indexOf("definitiveRuntimeAdapterCreateFailure"), - ); - assert.match(createSource, /failRuntimeAdapterWorkspaceIdConflict/); - assert.match( - createSource, - /throw conflict\("runtime adapter workspace conflict response is stale"\)/, - ); - assert.match(stageSource, /updated_at: sql`MAX\(updated_at \+ 1, \$\{stageAt\}\)`/); - assert.match( - stageSource, - /returning\(\["status", "updated_at", "last_reconciled_at", "terminal_status"\]\)/, - ); - assert.match(stageSource, /last_reconciled_at", "=", reconciliationOwner\.lastReconciledAt/); - assert.match(conflictSource, /adapter: null/); - assert.match(conflictSource, /adapter_workspace_id: null/); - assert.match(conflictSource, /adapter_control_plane: null/); - assert.match(conflictSource, /adapter_create_payload_json: null/); - assert.match(conflictSource, /adapter_create_pending: 0/); - assert.match(conflictSource, /AND adapter_create_pending = 1/); - assert.match(conflictSource, /AND status = \$\{createAttempt\.status\}/); - assert.match(conflictSource, /AND updated_at = \$\{createAttempt\.updatedAt\}/); - assert.match(conflictSource, /AND \$\{lastReconciledOwner\}/); - assert.match(conflictSource, /AND \$\{terminalStatusOwner\}/); - assert.match(conflictSource, /if \(!results\.at\(-1\)\?\.results\.length\) return null/); - assert.match(conflictSource, /terminal_finalize_pending: 1/); - assert.match(conflictSource, /env\.DB\.batch/); - assert.match( - conflictSource, - /finalizeTerminalInteractiveSession\(env, session\.id, "failed", now\)/, - ); - assert.doesNotMatch(conflictSource, /stopRuntimeAdapterWorkspace/); - assert.match(stoppingReplaySource, /runtimeAdapterWorkspaceIdConflict/); - assert.match(stoppingReplaySource, /terminalResult/); - assert.doesNotMatch(stoppingReplaySource, /definitiveRuntimeAdapterCreateFailure/); -}); - -test("definitive adapter create errors retain a redacted provider reason before release", async () => { +test("stopping create replay retains a redacted provider reason", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); - const createStart = source.indexOf("async function provisionWithRuntimeAdapter"); - const createEnd = source.indexOf("function persistedRuntimeAdapterSeconds", createStart); - const createSource = source.slice(createStart, createEnd); const replayStart = source.indexOf("async function replayStoppingRuntimeAdapterCreate"); const replayEnd = source.indexOf("async function stopRuntimeAdapterWorkspace", replayStart); const replaySource = source.slice(replayStart, replayEnd); - const releaseStart = source.indexOf("async function releaseFailedRuntimeAdapterProvision"); - const releaseEnd = source.indexOf( - "async function persistRuntimeAdapterStopEvidence", - releaseStart, - ); - const releaseSource = source.slice(releaseStart, releaseEnd); - const bodyReadIndex = createSource.indexOf( - "responseBody = await readRuntimeAdapterResponseBody(response)", - ); - assert.ok(bodyReadIndex >= 0 && bodyReadIndex < createSource.indexOf("if (!response.ok)")); - assert.match(createSource, /redactedAdapterResponseMessage/); - assert.match(createSource, /runtime adapter provision failed: \$\{responseMessage\}/); - assert.match(createSource, /releaseFailedRuntimeAdapterProvision/); - assert.doesNotMatch(createSource, /response\.json/); + assert.match(replaySource, /readRuntimeAdapterResponseBody\(response\)/); assert.match(replaySource, /redactedAdapterResponseMessage/); assert.match(replaySource, /reconcile_error: message/); assert.match(replaySource, /INSERT INTO interactive_session_events/); assert.doesNotMatch(replaySource, /response\.json/); - assert.ok( - releaseSource.indexOf("stageFailedRuntimeAdapterRelease") < - releaseSource.indexOf("stopRuntimeAdapterWorkspaceForSession"), - ); assert.equal( redactedAdapterResponseMessage( { detail: "capacity unavailable; token=private-value" }, @@ -1668,12 +1531,6 @@ test("adapter DELETE evidence survives pending and confirmed release", async () releaseStart, ); const releaseSource = source.slice(releaseStart, releaseEnd); - const failedReleaseStart = source.indexOf("async function releaseFailedRuntimeAdapterProvision"); - const failedReleaseEnd = source.indexOf( - "async function stageFailedRuntimeAdapterRelease", - failedReleaseStart, - ); - const failedReleaseSource = source.slice(failedReleaseStart, failedReleaseEnd); assert.match(deleteSource, /await readRuntimeAdapterResponseBody\(response\)/); assert.doesNotMatch(deleteSource, /response\.json/); @@ -1687,10 +1544,6 @@ test("adapter DELETE evidence survives pending and confirmed release", async () assert.match(releaseSource, /env\.DB\.batch/); assert.match(releaseSource, /INSERT INTO interactive_session_events/); assert.match(releaseSource, /terminal_finalize_pending: 1/); - assert.match(failedReleaseSource, /persistRuntimeAdapterStopEvidence/); - assert.match(failedReleaseSource, /await executeBatch\(env, \[/); - assert.match(failedReleaseSource, /INSERT INTO interactive_session_events/); - assert.match(failedReleaseSource, /AND NOT EXISTS/); }); test("adapter workspace paths use the controller id and encode it", () => { @@ -1804,10 +1657,9 @@ test("runtime adapter profile routes expand one allowlisted path segment", () => ); }); -test("adapter operations share the bounded response parser", async () => { +test("adapter inspect, replay, stop, and desktop operations share the bounded parser", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const ranges = [ - ["async function provisionWithRuntimeAdapter", "function persistedRuntimeAdapterSeconds"], [ "async function inspectRuntimeAdapterWorkspace", "async function reconcileStoppingRuntimeAdapterWorkspace", diff --git a/tests/runtime-profiles.test.ts b/tests/runtime-profiles.test.ts index 6fb7abb..9fb3ff9 100644 --- a/tests/runtime-profiles.test.ts +++ b/tests/runtime-profiles.test.ts @@ -185,7 +185,7 @@ test("runtime profiles resolve bounded Codex SSH handoff data", () => { assert.equal(client.runtimeProfiles[0]?.codexSsh, undefined); }); -test("profile allowlisting and capability withdrawals stay enforced at provisioning", async () => { +test("profile allowlisting stays enforced before provisioning", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const selectionStart = source.indexOf("const profile = clean(body.profile"); assert.equal(selectionStart, -1); @@ -196,15 +196,4 @@ test("profile allowlisting and capability withdrawals stay enforced at provision assert.equal(selectedRuntimeProfile(deployment, "linux").descriptor?.id, "linux"); assert.throws(() => selectedRuntimeProfile(deployment, "unknown"), /profile is not configured/); assert.ok(source.indexOf("selectedRuntimeProfile(deploymentConfig(env), session.profile)") > 0); - - const resultStart = source.indexOf("function runtimeAdapterProvisionResult"); - const resultEnd = source.indexOf( - "async function reconcileStoppingRuntimeAdapterWorkspace", - resultStart, - ); - const result = source.slice(resultStart, resultEnd); - assert.match( - result, - /session\.adapterRequestedCapabilities \?\?[\s\S]*session\.capabilities_json/, - ); }); From 3b5ee4c70dcbdd6a3490fec7faef67c0431cd67d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 15:47:20 +0100 Subject: [PATCH 055/109] refactor: extract managed sandbox provisioning --- src/index.ts | 243 +--------- src/worker/provisioning/sandbox-repository.ts | 175 +++++++ src/worker/provisioning/sandbox.ts | 179 +++++++ tests/runtime-adapter.test.ts | 39 +- tests/sandbox-provisioning.test.ts | 437 ++++++++++++++++++ 5 files changed, 819 insertions(+), 254 deletions(-) create mode 100644 src/worker/provisioning/sandbox-repository.ts create mode 100644 src/worker/provisioning/sandbox.ts create mode 100644 tests/sandbox-provisioning.test.ts diff --git a/src/index.ts b/src/index.ts index 87c55af..f530847 100644 --- a/src/index.ts +++ b/src/index.ts @@ -320,6 +320,11 @@ import { stageFailedRuntimeAdapterRelease, stageRuntimeAdapterProvision, } from "./worker/provisioning/runtime-adapter-repository"; +import { ManagedSandboxProvisioningService } from "./worker/provisioning/sandbox"; +import { + claimManagedSandboxProvision, + commitManagedSandboxProvision, +} from "./worker/provisioning/sandbox-repository"; import type { InteractiveProvisionRequest, InteractiveProvisionResult, @@ -6577,6 +6582,24 @@ function runtimeAdapterProvisioningService(env: RuntimeEnv): RuntimeAdapterProvi }); } +function managedSandboxProvisioningService(env: RuntimeEnv): ManagedSandboxProvisioningService { + return new ManagedSandboxProvisioningService({ + now: Date.now, + preflight: (session) => sandboxProvisionPreflightError(env, session), + claim: (session, owner, now) => + claimManagedSandboxProvision(env, session, owner, now, credentialPolicyProvisioningStaleMs), + provision: (session, claim) => + provisionWithSandbox(env, session, claim.agentToken, claim.lease, claim.fence), + stageFailure: (sessionId, fence, message, now) => + stageFailedManagedSandboxProvision(env, sessionId, fence, message, now), + commit: (sessionId, claim, result, now) => + commitManagedSandboxProvision(env, sessionId, claim, result, now), + reconcileCleanup: (sessionId, now) => + reconcileCredentialPolicyCleanupBatch(env, now, sessionId), + providerError: safeProviderError, + }); +} + async function provisionInteractiveEndpoint( request: Request, env: RuntimeEnv, @@ -6629,7 +6652,7 @@ async function provisionInteractiveEndpoint( "interactive provision failed: managed session id is not available to this backend", ); } - return provisionManagedSandboxEndpoint(env, payload, managed); + return managedSandboxProvisioningService(env).provision(payload, managed); } if (interactiveProvisioningService(env).supportsStandalone(payload.runtime)) { if (managedInteractiveSessionId(payload.id)) { @@ -6646,224 +6669,6 @@ async function provisionInteractiveEndpoint( ); } -function managedSandboxProvisionPayloadMatches( - payload: InteractiveProvisionRequest, - session: InteractiveSessionRow, -): boolean { - return ( - payload.id === session.id && - payload.parentSessionId === session.parent_session_id && - payload.rootSessionId === (session.root_session_id ?? session.id) && - payload.repo === session.repo && - payload.branch === session.branch && - payload.runtime === session.runtime && - payload.profile === session.profile && - payload.command === session.command && - payload.prompt === session.prompt && - payload.purpose === session.purpose && - payload.summary === session.summary && - payload.owner === session.owner && - payload.createdBy === session.created_by - ); -} - -async function provisionManagedSandboxEndpoint( - env: RuntimeEnv, - payload: InteractiveProvisionRequest, - session: InteractiveSessionRow, -): Promise { - if ( - !managedSandboxProvisionPayloadMatches(payload, session) || - !["provisioning", "pending_adapter"].includes(session.status) || - session.preparation_pending !== 0 || - session.adapter === runtimeAdapterName || - session.credential_cleanup_terminal_status !== null - ) { - return failedProvision( - "interactive provision failed: managed session request does not match durable ownership", - ); - } - const preflightError = sandboxProvisionPreflightError(env, payload); - if (preflightError) return failedProvision(preflightError); - const now = Date.now(); - const claimRevision = Math.max(now, session.updated_at + 1); - const agentToken = newAgentToken(); - const agentTokenHash = await sha256(agentToken); - const lease = newSandboxLease(payload.id); - const fence: SandboxLeaseRefreshFence = { - claim: `managed-provision:${crypto.randomUUID()}`, - expiresAt: now + credentialPolicyProvisioningStaleMs, - refreshLeaseId: session.lease_id, - sandboxId: lease.sandboxId, - }; - const claimed = await database(env) - .updateTable("interactive_sessions") - .set({ - sandbox_refresh_sandbox_id: fence.sandboxId, - sandbox_refresh_claim: fence.claim, - sandbox_refresh_claim_expires_at: fence.expiresAt, - agent_token_hash: agentTokenHash, - last_event: "managed Sandbox provision claimed", - updated_at: claimRevision, - }) - .where("id", "=", session.id) - .where("updated_at", "=", session.updated_at) - .where("status", "in", ["provisioning", "pending_adapter"]) - .where("preparation_pending", "=", 0) - .where(sql`parent_session_id IS ${payload.parentSessionId}`) - .where(sql`COALESCE(root_session_id, id) = ${payload.rootSessionId}`) - .where("runtime", "=", payload.runtime) - .where("repo", "=", payload.repo) - .where("branch", "=", payload.branch) - .where("profile", "=", payload.profile) - .where("command", "=", payload.command) - .where("prompt", "=", payload.prompt) - .where("purpose", "=", payload.purpose) - .where("summary", "=", payload.summary) - .where("owner", "=", payload.owner) - .where("created_by", "=", payload.createdBy) - .where((expression) => - expression.or([ - expression("adapter", "is", null), - expression("adapter", "!=", runtimeAdapterName), - ]), - ) - .where("credential_cleanup_terminal_status", "is", null) - .where(sql`agent_token_hash IS ${session.agent_token_hash}`) - .where(sql`lease_id IS ${session.lease_id}`) - .where((expression) => - expression.or([ - expression("sandbox_refresh_claim", "is", null), - expression("sandbox_refresh_claim_expires_at", "<=", now), - ]), - ) - .executeTakeFirst(); - if ((claimed.numUpdatedRows ?? 0n) === 0n) { - return failedProvision("interactive provision failed: managed session claim was not acquired"); - } - - let provisioned: InteractiveProvisionResult; - try { - provisioned = await provisionWithSandbox(env, payload, agentToken, lease, fence); - } catch (error) { - const message = `Cloudflare Sandbox provision failed: ${safeProviderError(error)}`; - await stageFailedManagedSandboxProvision(env, session.id, fence, message, Date.now()); - return failedProvision(message); - } - if (provisioned.status !== "ready") { - await stageFailedManagedSandboxProvision( - env, - session.id, - fence, - provisioned.message, - Date.now(), - ); - return provisioned; - } - const expectedLeaseId = sandboxLeaseId(lease); - const previousSandboxId = session.lease_id?.startsWith(sandboxLeasePrefix) - ? sandboxLeaseInfo({ - id: session.id, - leaseId: sandboxLeaseWithoutRefresh(session.lease_id), - }).sandboxId - : null; - const finishedAt = Date.now(); - if (provisioned.leaseId !== expectedLeaseId) { - const message = "interactive provision failed: managed Sandbox lease mismatch"; - const staged = await stageTerminalCredentialPolicyCleanupById( - env, - session.id, - "failed", - message, - finishedAt, - message, - fence, - ); - if (!staged) { - return failedProvision("interactive provision failed: managed session ownership changed"); - } - await reconcileCredentialPolicyCleanupBatch(env, finishedAt, session.id); - return failedProvision(message); - } - const db = database(env); - const commitRevision = Math.max(finishedAt, claimRevision + 1); - const commitQueries: CompilableQuery[] = [ - db - .updateTable("interactive_sessions") - .set({ - status: "ready", - lease_id: expectedLeaseId, - attach_url: provisioned.attachUrl, - vnc_url: provisioned.vncUrl, - sandbox_refresh_sandbox_id: null, - sandbox_refresh_claim: null, - sandbox_refresh_claim_expires_at: null, - last_event: provisioned.message, - updated_at: sql`MAX(updated_at + 1, ${commitRevision})`, - }) - .where("id", "=", session.id) - .where("status", "in", ["provisioning", "pending_adapter"]) - .where(sql`lease_id IS ${fence.refreshLeaseId}`) - .where("sandbox_refresh_sandbox_id", "=", fence.sandboxId) - .where("sandbox_refresh_claim", "=", fence.claim) - .where("sandbox_refresh_claim_expires_at", "=", fence.expiresAt) - .where("sandbox_refresh_claim_expires_at", ">", finishedAt) - .where("agent_token_hash", "=", agentTokenHash), - ]; - if (previousSandboxId && previousSandboxId !== lease.sandboxId) { - commitQueries.push( - db - .updateTable("interactive_session_credential_policies") - .set({ - state: "cleanup_pending", - cleanup_claim: null, - cleanup_claim_expires_at: null, - updated_at: commitRevision, - }) - .where("session_id", "=", session.id) - .where("sandbox_id", "=", previousSandboxId).where(sql` - EXISTS ( - SELECT 1 - FROM interactive_sessions AS owner - WHERE owner.id = ${session.id} - AND owner.status = 'ready' - AND owner.lease_id = ${expectedLeaseId} - AND owner.agent_token_hash = ${agentTokenHash} - AND owner.credential_cleanup_terminal_status IS NULL - AND owner.sandbox_refresh_sandbox_id IS NULL - AND owner.sandbox_refresh_claim IS NULL - AND owner.sandbox_refresh_claim_expires_at IS NULL - ) - `), - ); - } - await executeBatch(env, commitQueries); - const current = await db - .selectFrom("interactive_sessions") - .select(["lease_id", "status", "sandbox_refresh_claim", "agent_token_hash"]) - .where("id", "=", session.id) - .executeTakeFirst(); - if ( - current?.lease_id === expectedLeaseId && - current.sandbox_refresh_claim === null && - current.agent_token_hash === agentTokenHash && - ["ready", "attached", "detached"].includes(current.status) - ) { - if (previousSandboxId && previousSandboxId !== lease.sandboxId) { - await reconcileCredentialPolicyCleanupBatch(env, commitRevision, session.id); - } - return provisioned; - } - await stageFailedManagedSandboxProvision( - env, - session.id, - fence, - "interactive provision failed: managed session ownership changed", - finishedAt, - ); - return failedProvision("interactive provision failed: managed session ownership changed"); -} - async function provisionStandaloneSandbox( env: RuntimeEnv, payload: InteractiveProvisionRequest, diff --git a/src/worker/provisioning/sandbox-repository.ts b/src/worker/provisioning/sandbox-repository.ts new file mode 100644 index 0000000..3601d8c --- /dev/null +++ b/src/worker/provisioning/sandbox-repository.ts @@ -0,0 +1,175 @@ +import { sql } from "kysely"; + +import { sha256 } from "../crypto.ts"; +import { + database, + executeBatch, + type CompilableQuery, + type InteractiveSessionRow, +} from "../database.ts"; +import type { RuntimeEnv } from "../env.ts"; +import { + newSandboxLease, + sandboxLeaseId, + sandboxLeaseInfo, + sandboxLeasePrefix, + sandboxLeaseWithoutRefresh, + type SandboxLeaseRefreshFence, +} from "../sandbox-lease.ts"; +import { newAgentToken } from "../session-reservation-context.ts"; +import type { ManagedSandboxProvisionClaim, ManagedSandboxProvisionCommit } from "./sandbox.ts"; +import type { InteractiveProvisionRequest, InteractiveProvisionResult } from "./types.ts"; + +export async function claimManagedSandboxProvision( + env: RuntimeEnv, + session: InteractiveProvisionRequest, + owner: InteractiveSessionRow, + now: number, + claimTtlMs: number, +): Promise { + const claimRevision = Math.max(now, owner.updated_at + 1); + const agentToken = newAgentToken(); + const agentTokenHash = await sha256(agentToken); + const lease = newSandboxLease(session.id); + const fence: SandboxLeaseRefreshFence = { + claim: `managed-provision:${crypto.randomUUID()}`, + expiresAt: now + claimTtlMs, + refreshLeaseId: owner.lease_id, + sandboxId: lease.sandboxId, + }; + const claimed = await database(env) + .updateTable("interactive_sessions") + .set({ + sandbox_refresh_sandbox_id: fence.sandboxId, + sandbox_refresh_claim: fence.claim, + sandbox_refresh_claim_expires_at: fence.expiresAt, + agent_token_hash: agentTokenHash, + last_event: "managed Sandbox provision claimed", + updated_at: claimRevision, + }) + .where("id", "=", owner.id) + .where("updated_at", "=", owner.updated_at) + .where("status", "in", ["provisioning", "pending_adapter"]) + .where("preparation_pending", "=", 0) + .where(sql`parent_session_id IS ${session.parentSessionId}`) + .where(sql`COALESCE(root_session_id, id) = ${session.rootSessionId}`) + .where("runtime", "=", session.runtime) + .where("repo", "=", session.repo) + .where("branch", "=", session.branch) + .where("profile", "=", session.profile) + .where("command", "=", session.command) + .where("prompt", "=", session.prompt) + .where("purpose", "=", session.purpose) + .where("summary", "=", session.summary) + .where("owner", "=", session.owner) + .where("created_by", "=", session.createdBy) + // Never adopt retired or unknown adapter ownership as a built-in Sandbox session. + .where("adapter", "is", null) + .where("credential_cleanup_terminal_status", "is", null) + .where(sql`agent_token_hash IS ${owner.agent_token_hash}`) + .where(sql`lease_id IS ${owner.lease_id}`) + .where((expression) => + expression.or([ + expression("sandbox_refresh_claim", "is", null), + expression("sandbox_refresh_claim_expires_at", "<=", now), + ]), + ) + .executeTakeFirst(); + if ((claimed.numUpdatedRows ?? 0n) === 0n) return null; + + const previousSandboxId = owner.lease_id?.startsWith(sandboxLeasePrefix) + ? sandboxLeaseInfo({ + id: owner.id, + leaseId: sandboxLeaseWithoutRefresh(owner.lease_id), + }).sandboxId + : null; + return { + agentToken, + agentTokenHash, + lease, + fence, + previousSandboxId, + claimRevision, + }; +} + +export async function commitManagedSandboxProvision( + env: RuntimeEnv, + sessionId: string, + claim: ManagedSandboxProvisionClaim, + provisioned: InteractiveProvisionResult, + finishedAt: number, +): Promise { + const expectedLeaseId = sandboxLeaseId(claim.lease); + const commitRevision = Math.max(finishedAt, claim.claimRevision + 1); + const db = database(env); + const commitQueries: CompilableQuery[] = [ + db + .updateTable("interactive_sessions") + .set({ + status: "ready", + lease_id: expectedLeaseId, + attach_url: provisioned.attachUrl, + vnc_url: provisioned.vncUrl, + sandbox_refresh_sandbox_id: null, + sandbox_refresh_claim: null, + sandbox_refresh_claim_expires_at: null, + last_event: provisioned.message, + updated_at: sql`MAX(updated_at + 1, ${commitRevision})`, + }) + .where("id", "=", sessionId) + .where("status", "in", ["provisioning", "pending_adapter"]) + .where(sql`lease_id IS ${claim.fence.refreshLeaseId}`) + .where("sandbox_refresh_sandbox_id", "=", claim.fence.sandboxId) + .where("sandbox_refresh_claim", "=", claim.fence.claim) + .where("sandbox_refresh_claim_expires_at", "=", claim.fence.expiresAt) + .where("sandbox_refresh_claim_expires_at", ">", finishedAt) + .where("agent_token_hash", "=", claim.agentTokenHash), + ]; + const cleanupPending = Boolean( + claim.previousSandboxId && claim.previousSandboxId !== claim.lease.sandboxId, + ); + if (cleanupPending) { + commitQueries.push( + db + .updateTable("interactive_session_credential_policies") + .set({ + state: "cleanup_pending", + cleanup_claim: null, + cleanup_claim_expires_at: null, + updated_at: commitRevision, + }) + .where("session_id", "=", sessionId) + .where("sandbox_id", "=", claim.previousSandboxId!).where(sql` + EXISTS ( + SELECT 1 + FROM interactive_sessions AS owner + WHERE owner.id = ${sessionId} + AND owner.status = 'ready' + AND owner.lease_id = ${expectedLeaseId} + AND owner.agent_token_hash = ${claim.agentTokenHash} + AND owner.credential_cleanup_terminal_status IS NULL + AND owner.sandbox_refresh_sandbox_id IS NULL + AND owner.sandbox_refresh_claim IS NULL + AND owner.sandbox_refresh_claim_expires_at IS NULL + ) + `), + ); + } + await executeBatch(env, commitQueries); + const current = await db + .selectFrom("interactive_sessions") + .select(["lease_id", "status", "sandbox_refresh_claim", "agent_token_hash"]) + .where("id", "=", sessionId) + .executeTakeFirst(); + return { + committed: Boolean( + current?.lease_id === expectedLeaseId && + current.sandbox_refresh_claim === null && + current.agent_token_hash === claim.agentTokenHash && + ["ready", "attached", "detached"].includes(current.status), + ), + cleanupPending, + commitRevision, + }; +} diff --git a/src/worker/provisioning/sandbox.ts b/src/worker/provisioning/sandbox.ts new file mode 100644 index 0000000..a2881c6 --- /dev/null +++ b/src/worker/provisioning/sandbox.ts @@ -0,0 +1,179 @@ +import type { InteractiveSessionRow } from "../database.ts"; +import { + sandboxLeaseId, + type SandboxLease, + type SandboxLeaseRefreshFence, +} from "../sandbox-lease.ts"; +import type { InteractiveProvisionRequest, InteractiveProvisionResult } from "./types.ts"; + +export type ManagedSandboxProvisionClaim = { + agentToken: string; + agentTokenHash: string; + lease: SandboxLease; + fence: SandboxLeaseRefreshFence; + previousSandboxId: string | null; + claimRevision: number; +}; + +export type ManagedSandboxProvisionCommit = { + committed: boolean; + cleanupPending: boolean; + commitRevision: number; +}; + +export type ManagedSandboxProvisioningDependencies = { + now(): number; + preflight(session: InteractiveProvisionRequest): string | null; + claim( + session: InteractiveProvisionRequest, + owner: InteractiveSessionRow, + now: number, + ): Promise; + provision( + session: InteractiveProvisionRequest, + claim: ManagedSandboxProvisionClaim, + ): Promise; + stageFailure( + sessionId: string, + fence: SandboxLeaseRefreshFence, + message: string, + now: number, + ): Promise; + commit( + sessionId: string, + claim: ManagedSandboxProvisionClaim, + result: InteractiveProvisionResult, + now: number, + ): Promise; + reconcileCleanup(sessionId: string, now: number): Promise; + providerError(error: unknown): string; +}; + +export class ManagedSandboxProvisioningService { + private readonly dependencies: ManagedSandboxProvisioningDependencies; + + constructor(dependencies: ManagedSandboxProvisioningDependencies) { + this.dependencies = dependencies; + } + + async provision( + session: InteractiveProvisionRequest, + owner: InteractiveSessionRow, + ): Promise { + if ( + !managedSandboxProvisionPayloadMatches(session, owner) || + !managedSandboxOwnerReady(owner) + ) { + return failedProvision( + "interactive provision failed: managed session request does not match durable ownership", + ); + } + const preflightError = this.dependencies.preflight(session); + if (preflightError) return failedProvision(preflightError); + + const claim = await this.dependencies.claim(session, owner, this.dependencies.now()); + if (!claim) { + return failedProvision( + "interactive provision failed: managed session claim was not acquired", + ); + } + + let provisioned: InteractiveProvisionResult; + try { + provisioned = await this.dependencies.provision(session, claim); + } catch (error) { + const message = `Cloudflare Sandbox provision failed: ${this.dependencies.providerError(error)}`; + await this.dependencies.stageFailure( + session.id, + claim.fence, + message, + this.dependencies.now(), + ); + return failedProvision(message); + } + if (provisioned.status !== "ready") { + await this.dependencies.stageFailure( + session.id, + claim.fence, + provisioned.message, + this.dependencies.now(), + ); + return provisioned; + } + + const expectedLeaseId = sandboxLeaseId(claim.lease); + if (provisioned.leaseId !== expectedLeaseId) { + const message = "interactive provision failed: managed Sandbox lease mismatch"; + const staged = await this.dependencies.stageFailure( + session.id, + claim.fence, + message, + this.dependencies.now(), + ); + return failedProvision( + staged ? message : "interactive provision failed: managed session ownership changed", + ); + } + + const committed = await this.dependencies.commit( + session.id, + claim, + provisioned, + this.dependencies.now(), + ); + if (!committed.committed) { + await this.dependencies.stageFailure( + session.id, + claim.fence, + "interactive provision failed: managed session ownership changed", + this.dependencies.now(), + ); + return failedProvision("interactive provision failed: managed session ownership changed"); + } + if (committed.cleanupPending) { + await this.dependencies.reconcileCleanup(session.id, committed.commitRevision); + } + return provisioned; + } +} + +export function managedSandboxProvisionPayloadMatches( + payload: InteractiveProvisionRequest, + session: InteractiveSessionRow, +): boolean { + return ( + payload.id === session.id && + payload.parentSessionId === session.parent_session_id && + payload.rootSessionId === (session.root_session_id ?? session.id) && + payload.repo === session.repo && + payload.branch === session.branch && + payload.runtime === session.runtime && + payload.profile === session.profile && + payload.command === session.command && + payload.prompt === session.prompt && + payload.purpose === session.purpose && + payload.summary === session.summary && + payload.owner === session.owner && + payload.createdBy === session.created_by + ); +} + +function managedSandboxOwnerReady(session: InteractiveSessionRow): boolean { + return ( + ["provisioning", "pending_adapter"].includes(session.status) && + session.preparation_pending === 0 && + // Built-in Sandbox ownership is adapterless; non-null adapters belong to an external protocol. + session.adapter === null && + session.credential_cleanup_terminal_status === null + ); +} + +function failedProvision(message: string): InteractiveProvisionResult { + return { + status: "failed", + leaseId: null, + attachUrl: null, + vncUrl: null, + message, + }; +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index f2e0ea3..1451b36 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -859,10 +859,6 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a ownershipStart, ); const ownershipSource = source.slice(ownershipStart, ownershipEnd); - const managedStart = source.indexOf("async function provisionManagedSandboxEndpoint"); - const managedEnd = source.indexOf("async function provisionStandaloneSandbox", managedStart); - const managedSource = source.slice(managedStart, managedEnd); - const managedCommitSource = managedSource.slice(managedSource.indexOf("const commitRevision")); const ptyStart = source.indexOf("async function standaloneSandboxPty"); const ptyEnd = source.indexOf("function authorizeProvisionBearerToken", ptyStart); const ptySource = source.slice(ptyStart, ptyEnd); @@ -894,7 +890,10 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a endpointSource.indexOf("if (managed)") < endpointSource.indexOf("return provisionStandaloneSandbox(env, payload)"), ); - assert.match(endpointSource, /provisionManagedSandboxEndpoint\(env, payload, managed\)/); + assert.match( + endpointSource, + /managedSandboxProvisioningService\(env\)\.provision\(payload, managed\)/, + ); assert.match(endpointSource, /managedInteractiveSessionId\(payload\.id\)/); assert.match(endpointSource, /managed session namespace/); assert.match(endpointSource, /INSERT INTO standalone_sandbox_provisions/); @@ -905,36 +904,6 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a assert.match(endpointSource, /state: "active"/); assert.match(ownershipSource, /FROM standalone_sandbox_provisions AS owner/); assert.match(ownershipSource, /owner\.ownership_claim = \$\{ownershipFence\.claim\}/); - assert.match(managedSource, /managedSandboxProvisionPayloadMatches/); - assert.ok( - managedSource.indexOf("managedSandboxProvisionPayloadMatches") < - managedSource.indexOf("newSandboxLease(payload.id)"), - ); - assert.match(managedSource, /where\("updated_at", "=", session\.updated_at\)/); - assert.match(managedSource, /session\.preparation_pending !== 0/); - assert.match(managedSource, /\.where\("preparation_pending", "=", 0\)/); - assert.match(managedSource, /sandbox_refresh_claim: fence\.claim/); - assert.match(managedSource, /const agentToken = newAgentToken\(\)/); - assert.match(managedSource, /const agentTokenHash = await sha256\(agentToken\)/); - assert.match(managedSource, /agent_token_hash: agentTokenHash/); - assert.match(managedSource, /agent_token_hash IS \$\{session\.agent_token_hash\}/); - assert.match(managedSource, /provisionWithSandbox\(env, payload, agentToken, lease, fence\)/); - assert.ok( - managedSource.indexOf("sandboxProvisionPreflightError(env, payload)") < - managedSource.indexOf("const agentToken = newAgentToken()"), - ); - assert.match(managedSource, /stageFailedManagedSandboxProvision/); - assert.match(managedSource, /where\("agent_token_hash", "=", agentTokenHash\)/); - assert.doesNotMatch(managedSource, /provisionWithSandbox\(env, payload, undefined/); - assert.match(managedSource, /executeBatch\(env, commitQueries\)/); - assert.match(managedCommitSource, /MAX\(updated_at \+ 1, \$\{commitRevision\}\)/); - assert.doesNotMatch(managedCommitSource, /where\("updated_at", "=", now\)/); - assert.match(managedCommitSource, /lease_id IS \$\{fence\.refreshLeaseId\}/); - assert.match(managedCommitSource, /sandbox_refresh_claim", "=", fence\.claim/); - assert.match(managedCommitSource, /sandbox_refresh_claim_expires_at", "=", fence\.expiresAt/); - assert.match(managedSource, /previousSandboxId/); - assert.match(managedSource, /state: "cleanup_pending"/); - assert.match(managedSource, /claimed\.numUpdatedRows/); assert.match(ptySource, /authorizeProvisionBearerToken\(request, env\)/); assert.match(ptySource, /standalone_sandbox_provisions/); assert.match(ptySource, /where\("state", "=", "active"\)/); diff --git a/tests/sandbox-provisioning.test.ts b/tests/sandbox-provisioning.test.ts new file mode 100644 index 0000000..1b7e39a --- /dev/null +++ b/tests/sandbox-provisioning.test.ts @@ -0,0 +1,437 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + claimManagedSandboxProvision, + commitManagedSandboxProvision, +} from "../src/worker/provisioning/sandbox-repository.ts"; +import { + ManagedSandboxProvisioningService, + managedSandboxProvisionPayloadMatches, + type ManagedSandboxProvisionClaim, + type ManagedSandboxProvisioningDependencies, +} from "../src/worker/provisioning/sandbox.ts"; +import type { + InteractiveProvisionRequest, + InteractiveProvisionResult, +} from "../src/worker/provisioning/types.ts"; +import { sandboxLeaseId } from "../src/worker/sandbox-lease.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +const request: InteractiveProvisionRequest = { + id: "IS-42", + parentSessionId: null, + rootSessionId: "IS-42", + repo: "openclaw/crabfleet", + branch: "main", + runtime: "container", + profile: "cloudflare-sandbox", + command: "codex", + prompt: "Fix the issue", + purpose: "Fix the issue", + summary: "Working", + owner: "owner", + createdBy: "github:42", +}; + +const owner = sessionRow({ + status: "provisioning", + lease_id: "sandbox:old:terminal-old:autostart-v4", + agent_token_hash: "old-agent-hash", +}); + +const claim: ManagedSandboxProvisionClaim = { + agentToken: "agent-token", + agentTokenHash: "agent-token-hash", + lease: { + sandboxId: "sandbox-new", + terminalSessionId: "terminal-new", + }, + fence: { + claim: "managed-provision:claim", + expiresAt: 1_000, + refreshLeaseId: owner.lease_id, + sandboxId: "sandbox-new", + }, + previousSandboxId: "old", + claimRevision: 101, +}; + +function readyResult(values: Partial = {}): InteractiveProvisionResult { + return { + status: "ready", + leaseId: sandboxLeaseId(claim.lease), + attachUrl: "/api/terminal/ws", + vncUrl: null, + message: "Cloudflare Sandbox ready", + ...values, + }; +} + +function dependencies( + overrides: Partial = {}, +): ManagedSandboxProvisioningDependencies { + return { + now: () => 200, + preflight: () => null, + async claim() { + return claim; + }, + async provision() { + return readyResult(); + }, + async stageFailure() { + return true; + }, + async commit() { + return { + committed: true, + cleanupPending: false, + commitRevision: 201, + }; + }, + async reconcileCleanup() {}, + providerError: (error) => (error instanceof Error ? error.message : String(error)), + ...overrides, + }; +} + +test("managed Sandbox validates durable ownership before preflight or claim", async () => { + const calls: string[] = []; + const service = new ManagedSandboxProvisioningService( + dependencies({ + preflight() { + calls.push("preflight"); + return null; + }, + async claim() { + calls.push("claim"); + return claim; + }, + }), + ); + + const result = await service.provision({ ...request, branch: "different" }, owner); + + assert.equal(result.status, "failed"); + assert.match(result.message, /does not match durable ownership/); + assert.deepEqual(calls, []); + assert.equal(managedSandboxProvisionPayloadMatches(request, owner), true); + assert.equal( + managedSandboxProvisionPayloadMatches(request, sessionRow({ ...owner, summary: "different" })), + false, + ); +}); + +test("managed Sandbox rejects rows owned by any adapter", async () => { + const calls: string[] = []; + const service = new ManagedSandboxProvisioningService( + dependencies({ + preflight() { + calls.push("preflight"); + return null; + }, + async claim() { + calls.push("claim"); + return claim; + }, + }), + ); + + const result = await service.provision(request, sessionRow({ ...owner, adapter: "other-v1" })); + + assert.equal(result.status, "failed"); + assert.match(result.message, /does not match durable ownership/); + assert.deepEqual(calls, []); +}); + +test("managed Sandbox preflight runs before durable claim", async () => { + const calls: string[] = []; + const service = new ManagedSandboxProvisioningService( + dependencies({ + preflight() { + calls.push("preflight"); + return "Sandbox binding is not configured"; + }, + async claim() { + calls.push("claim"); + return claim; + }, + }), + ); + + assert.deepEqual(await service.provision(request, owner), { + status: "failed", + leaseId: null, + attachUrl: null, + vncUrl: null, + message: "Sandbox binding is not configured", + }); + assert.deepEqual(calls, ["preflight"]); +}); + +test("managed Sandbox claim contention stops before provider work", async () => { + const calls: string[] = []; + const service = new ManagedSandboxProvisioningService( + dependencies({ + async claim() { + calls.push("claim"); + return null; + }, + async provision() { + calls.push("provision"); + return readyResult(); + }, + }), + ); + + const result = await service.provision(request, owner); + + assert.equal(result.status, "failed"); + assert.match(result.message, /claim was not acquired/); + assert.deepEqual(calls, ["claim"]); +}); + +test("managed Sandbox provisions and commits under one claim before cleanup", async () => { + const calls: string[] = []; + const service = new ManagedSandboxProvisioningService( + dependencies({ + async claim(_session, _owner, now) { + calls.push(`claim:${now}`); + return claim; + }, + async provision(_session, claimed) { + calls.push(`provision:${claimed.agentToken}:${claimed.fence.claim}`); + return readyResult(); + }, + async commit(_sessionId, committedClaim, _result, now) { + calls.push(`commit:${committedClaim.agentTokenHash}:${now}`); + return { + committed: true, + cleanupPending: true, + commitRevision: 202, + }; + }, + async reconcileCleanup(sessionId, now) { + calls.push(`cleanup:${sessionId}:${now}`); + }, + }), + ); + + const result = await service.provision(request, owner); + + assert.equal(result.status, "ready"); + assert.deepEqual(calls, [ + "claim:200", + "provision:agent-token:managed-provision:claim", + "commit:agent-token-hash:200", + "cleanup:IS-42:202", + ]); +}); + +test("managed Sandbox provider failures stage cleanup and return redacted failure", async () => { + const calls: string[] = []; + const service = new ManagedSandboxProvisioningService( + dependencies({ + async provision() { + throw new Error("provider token=private"); + }, + async stageFailure(_sessionId, _fence, message, now) { + calls.push(`${message}:${now}`); + return true; + }, + providerError: () => "[credential]", + }), + ); + + const result = await service.provision(request, owner); + + assert.equal(result.status, "failed"); + assert.equal(result.message, "Cloudflare Sandbox provision failed: [credential]"); + assert.deepEqual(calls, ["Cloudflare Sandbox provision failed: [credential]:200"]); +}); + +test("managed Sandbox non-ready results and lease mismatches stage cleanup", async () => { + const messages: string[] = []; + const nonReady = new ManagedSandboxProvisioningService( + dependencies({ + async provision() { + return readyResult({ + status: "stopping", + message: "credential cleanup pending", + }); + }, + async stageFailure(_sessionId, _fence, message) { + messages.push(message); + return true; + }, + }), + ); + const mismatch = new ManagedSandboxProvisioningService( + dependencies({ + async provision() { + return readyResult({ leaseId: "sandbox:different:terminal:autostart-v4" }); + }, + async stageFailure(_sessionId, _fence, message) { + messages.push(message); + return false; + }, + }), + ); + + assert.equal((await nonReady.provision(request, owner)).status, "stopping"); + assert.match((await mismatch.provision(request, owner)).message, /ownership changed/); + assert.deepEqual(messages, [ + "credential cleanup pending", + "interactive provision failed: managed Sandbox lease mismatch", + ]); +}); + +test("managed Sandbox commit ownership loss stages failure", async () => { + const messages: string[] = []; + const service = new ManagedSandboxProvisioningService( + dependencies({ + async commit() { + return { + committed: false, + cleanupPending: false, + commitRevision: 201, + }; + }, + async stageFailure(_sessionId, _fence, message) { + messages.push(message); + return false; + }, + }), + ); + + const result = await service.provision(request, owner); + + assert.equal(result.status, "failed"); + assert.match(result.message, /ownership changed/); + assert.deepEqual(messages, ["interactive provision failed: managed session ownership changed"]); +}); + +type PreparedStatement = { + sql: string; + parameters: unknown[]; + all(): Promise; + run(): Promise; +}; + +function runtimeEnv( + handler: ( + sql: string, + parameters: unknown[], + kind: "all" | "run", + ) => { + results?: unknown[]; + changes?: number; + }, + batchHandler: (statements: PreparedStatement[]) => unknown[] = () => [], +): RuntimeEnv { + return { + DB: { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + return { + sql, + parameters, + async all() { + const result = handler(sql, parameters, "all"); + return { results: result.results ?? [], meta: { changes: result.changes ?? 0 } }; + }, + async run() { + const result = handler(sql, parameters, "run"); + return { meta: { changes: result.changes ?? 0 } }; + }, + }; + }, + }; + }, + async batch(statements: unknown[]) { + return batchHandler(statements as PreparedStatement[]); + }, + } as unknown as D1Database, + } as RuntimeEnv; +} + +test("managed Sandbox claim compiles the complete durable ownership fence", async () => { + const executions: Array<{ sql: string; parameters: unknown[] }> = []; + const env = runtimeEnv((sql, parameters) => { + executions.push({ sql, parameters }); + return { changes: 1 }; + }); + + const claimed = await claimManagedSandboxProvision(env, request, owner, 200, 900); + + assert.ok(claimed); + assert.equal(claimed.claimRevision, 200); + assert.equal(claimed.fence.expiresAt, 1_100); + assert.equal(claimed.fence.refreshLeaseId, owner.lease_id); + assert.equal(claimed.previousSandboxId, "old"); + assert.equal(executions.length, 1); + assert.match(executions[0].sql, /preparation_pending/); + assert.match(executions[0].sql, /parent_session_id/); + assert.match(executions[0].sql, /root_session_id/); + assert.match(executions[0].sql, /agent_token_hash/); + assert.match(executions[0].sql, /"adapter" is null/i); + assert.match(executions[0].sql, /sandbox_refresh_claim_expires_at/); + assert.match(executions[0].sql, /credential_cleanup_terminal_status/); + assert.ok(executions[0].parameters.includes(owner.updated_at)); + assert.ok(executions[0].parameters.includes(owner.agent_token_hash)); + assert.ok(executions[0].parameters.includes(owner.lease_id)); +}); + +test("managed Sandbox claim returns null when the ownership fence loses", async () => { + const env = runtimeEnv(() => ({ changes: 0 })); + + assert.equal(await claimManagedSandboxProvision(env, request, owner, 200, 900), null); +}); + +test("managed Sandbox commit fences activation and previous-policy cleanup", async () => { + let statements: PreparedStatement[] = []; + const expectedLeaseId = sandboxLeaseId(claim.lease); + const env = runtimeEnv( + (sql) => + /select .*lease_id/i.test(sql) + ? { + results: [ + { + lease_id: expectedLeaseId, + status: "ready", + sandbox_refresh_claim: null, + agent_token_hash: claim.agentTokenHash, + }, + ], + } + : { changes: 1 }, + (prepared) => { + statements = prepared; + return prepared.map(() => ({ results: [], meta: { changes: 1 } })); + }, + ); + + const committed = await commitManagedSandboxProvision(env, request.id, claim, readyResult(), 200); + + assert.deepEqual(committed, { + committed: true, + cleanupPending: true, + commitRevision: 200, + }); + assert.equal(statements.length, 2); + const batchSql = statements.map((statement) => statement.sql).join("\n"); + assert.match(batchSql, /sandbox_refresh_claim_expires_at/); + assert.match(batchSql, /agent_token_hash/); + assert.match(batchSql, /credential_cleanup_terminal_status/); + assert.match(batchSql, /interactive_session_credential_policies/); + const parameters = statements.flatMap((statement) => statement.parameters); + assert.ok(parameters.includes("cleanup_pending")); + assert.ok(parameters.includes(claim.fence.claim)); + assert.ok(parameters.includes(claim.fence.expiresAt)); + assert.ok(parameters.includes(claim.agentTokenHash)); + assert.ok(parameters.includes(expectedLeaseId)); + assert.ok(parameters.includes(claim.previousSandboxId)); +}); From 9ae4fc53f4cec38ede9366597ff5b08cebdef81e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 15:54:38 +0100 Subject: [PATCH 056/109] refactor: extract standalone sandbox provisioning --- src/index.ts | 412 ++------------ src/worker/database.ts | 2 + .../standalone-sandbox-repository.ts | 194 +++++++ src/worker/provisioning/standalone-sandbox.ts | 206 +++++++ src/worker/sandbox-policy-state.ts | 83 +++ tests/runtime-adapter.test.ts | 27 +- tests/standalone-sandbox-provisioning.test.ts | 528 ++++++++++++++++++ 7 files changed, 1073 insertions(+), 379 deletions(-) create mode 100644 src/worker/provisioning/standalone-sandbox-repository.ts create mode 100644 src/worker/provisioning/standalone-sandbox.ts create mode 100644 src/worker/sandbox-policy-state.ts create mode 100644 tests/standalone-sandbox-provisioning.test.ts diff --git a/src/index.ts b/src/index.ts index f530847..fee2ee8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -325,10 +325,28 @@ import { claimManagedSandboxProvision, commitManagedSandboxProvision, } from "./worker/provisioning/sandbox-repository"; +import { + isManagedInteractiveSessionId, + standaloneSandboxDefaultTtlSeconds, + standaloneSandboxProvisionRequestHashInput, + StandaloneSandboxProvisioningService, + type StandaloneSandboxProvisionFence, +} from "./worker/provisioning/standalone-sandbox"; +import { + activateStandaloneSandboxProvision, + claimStandaloneSandboxProvision, + readStandaloneSandboxProvision, + stageStandaloneSandboxClaimCleanup, +} from "./worker/provisioning/standalone-sandbox-repository"; import type { InteractiveProvisionRequest, InteractiveProvisionResult, } from "./worker/provisioning/types"; +import { + activeSandboxCredentialPolicyCondition, + activeSandboxCredentialPolicyGeneration, + sandboxLookupIds, +} from "./worker/sandbox-policy-state"; import { interactiveCommand, interactiveSessionPurpose, @@ -510,12 +528,6 @@ type SandboxRuntimeSession = (InteractiveProvisionRequest | InteractiveSession) githubToken?: string; }; -type StandaloneSandboxProvisionFence = { - claim: string; - provisionId: string; - sandboxId: string; -}; - type SandboxManagedOwnershipFence = SandboxCurrentLeaseFence | SandboxLeaseRefreshFence; type SandboxTerminalCleanupOwnership = { @@ -645,8 +657,6 @@ const credentialPolicyRegistrationClaimMs = 60_000; const credentialPolicyProvisioningStaleMs = 15 * 60_000; const credentialPolicyLegacyGenerationPrefix = "legacy:"; const credentialPolicyLegacyRepairClaimPrefix = "legacy-repair:"; -const standaloneSandboxDefaultTtlSeconds = 14_400; - const defaultSandboxEgressHosts = [ "api.github.com", "api.openai.com", @@ -4059,79 +4069,6 @@ async function existingSandboxCredentialPolicyGeneration( return existing?.registration_generation ?? null; } -function activeSandboxCredentialPolicyCondition( - env: RuntimeEnv, - sessionId: string, - sandboxId: string, - generation: string, - updatedAt?: number, -): RawBuilder { - const lookupIds = sandboxLookupIds(env, sandboxId); - const updatedAtCondition = - updatedAt === undefined ? sql`1 = 1` : sql`updated_at = ${updatedAt}`; - return sql` - ( - SELECT count(DISTINCT lookup_id) - FROM interactive_session_credential_policies - WHERE session_id = ${sessionId} - AND sandbox_id = ${sandboxId} - AND lookup_id IN (${sql.join(lookupIds)}) - AND state = 'active' - AND registration_generation = ${generation} - AND registration_claim IS NULL - AND ${updatedAtCondition} - ) = ${lookupIds.length} - AND NOT EXISTS ( - SELECT 1 - FROM interactive_session_credential_policies - WHERE session_id = ${sessionId} - AND sandbox_id = ${sandboxId} - AND ( - state != 'active' - OR registration_generation != ${generation} - OR registration_claim IS NOT NULL - OR NOT (${updatedAtCondition}) - ) - ) - `; -} - -async function activeSandboxCredentialPolicyGeneration( - env: RuntimeEnv, - sessionId: string, - sandboxId: string, -): Promise { - const rows = await database(env) - .selectFrom("interactive_session_credential_policies") - .select(["lookup_id", "state", "registration_generation", "registration_claim"]) - .where("session_id", "=", sessionId) - .where("sandbox_id", "=", sandboxId) - .execute(); - const expected = sandboxLookupIds(env, sandboxId); - const generation = rows[0]?.registration_generation; - if ( - !generation || - !expected.every((lookupId) => - rows.some( - (row) => - row.lookup_id === lookupId && - row.state === "active" && - row.registration_generation === generation && - row.registration_claim === null, - ), - ) || - rows.some( - (row) => - row.state !== "active" || - row.registration_generation !== generation || - row.registration_claim !== null, - ) - ) { - return null; - } - return generation; -} - async function queueSandboxCredentialPolicyCleanup( env: RuntimeEnv, sessionId: string, @@ -6600,6 +6537,42 @@ function managedSandboxProvisioningService(env: RuntimeEnv): ManagedSandboxProvi }); } +function standaloneSandboxProvisioningService( + env: RuntimeEnv, +): StandaloneSandboxProvisioningService { + const provisionTtlMs = + clampedSeconds(env.CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS, standaloneSandboxDefaultTtlSeconds) * + 1000; + return new StandaloneSandboxProvisioningService({ + now: Date.now, + requestHash: (session) => + sha256(JSON.stringify(standaloneSandboxProvisionRequestHashInput(session))), + readOwner: (provisionId) => readStandaloneSandboxProvision(env, provisionId), + stageOwnerCleanup: (owner, message, now) => + stageStandaloneSandboxProvisionCleanup(env, owner, message, now), + reconcileCleanup: (provisionId, now) => + reconcileCredentialPolicyCleanupBatch(env, now, provisionId), + claim: (session, requestHash, now) => + claimStandaloneSandboxProvision( + env, + session, + requestHash, + now, + credentialPolicyProvisioningStaleMs, + provisionTtlMs, + ), + provision: (session, claim) => + provisionWithSandbox(env, session, undefined, claim.lease, claim.fence), + stageClaimCleanup: (claim, message, now) => + stageStandaloneSandboxClaimCleanup(env, claim, message, now), + queuePolicyCleanup: (provisionId, sandboxId, now) => + queueSandboxCredentialPolicyCleanup(env, provisionId, sandboxId, now), + activate: (provisionId, claim, result, now) => + activateStandaloneSandboxProvision(env, provisionId, claim, result, now), + providerError: safeProviderError, + }); +} + async function provisionInteractiveEndpoint( request: Request, env: RuntimeEnv, @@ -6655,12 +6628,7 @@ async function provisionInteractiveEndpoint( return managedSandboxProvisioningService(env).provision(payload, managed); } if (interactiveProvisioningService(env).supportsStandalone(payload.runtime)) { - if (managedInteractiveSessionId(payload.id)) { - return failedProvision( - "interactive provision failed: standalone provision id uses the managed session namespace", - ); - } - return provisionStandaloneSandbox(env, payload); + return standaloneSandboxProvisioningService(env).provision(payload); } return failedProvision( payload.runtime === "container" @@ -6669,264 +6637,6 @@ async function provisionInteractiveEndpoint( ); } -async function provisionStandaloneSandbox( - env: RuntimeEnv, - payload: InteractiveProvisionRequest, -): Promise { - if (managedInteractiveSessionId(payload.id)) { - return failedProvision( - "interactive provision failed: standalone provision id uses the managed session namespace", - ); - } - const { githubToken: _githubToken, ...ownershipPayload } = payload; - const requestHash = await sha256(JSON.stringify(ownershipPayload)); - const db = database(env); - const now = Date.now(); - let previous = await db - .selectFrom("standalone_sandbox_provisions") - .selectAll() - .where("id", "=", payload.id) - .executeTakeFirst(); - if (previous && previous.request_hash !== requestHash) { - return failedProvision("interactive provision failed: provision id is already registered"); - } - if (previous?.state === "active") { - if (!previous.expires_at || previous.expires_at <= Date.now()) { - await stageStandaloneSandboxProvisionCleanup( - env, - previous, - "standalone Sandbox provision expired", - Date.now(), - ); - await reconcileCredentialPolicyCleanupBatch(env, Date.now(), payload.id); - return failedProvision("interactive provision failed: standalone Sandbox provision expired"); - } - return { - status: "ready", - leaseId: previous.lease_id, - attachUrl: previous.attach_url, - vncUrl: previous.vnc_url, - expiresAt: previous.expires_at, - expiresAtPresent: true, - message: previous.message, - }; - } - if (previous?.state === "cleanup_pending") { - return failedProvision("interactive provision failed: previous credential cleanup is pending"); - } - if ( - previous?.state === "provisioning" && - (previous.ownership_claim_expires_at ?? Number.NEGATIVE_INFINITY) <= now - ) { - const staged = await stageStandaloneSandboxProvisionCleanup( - env, - previous, - "abandoned standalone Sandbox provision cleanup", - now, - ); - if (!staged) { - return failedProvision("interactive provision failed: standalone ownership changed"); - } - await reconcileCredentialPolicyCleanupBatch(env, now, payload.id); - previous = await db - .selectFrom("standalone_sandbox_provisions") - .selectAll() - .where("id", "=", payload.id) - .executeTakeFirst(); - if (previous) { - return failedProvision( - "interactive provision failed: previous credential cleanup is pending", - ); - } - } - - const expiresAt = - now + - clampedSeconds(env.CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS, standaloneSandboxDefaultTtlSeconds) * - 1000; - const lease = newSandboxLease(payload.id); - const claim = `standalone:${crypto.randomUUID()}`; - await sql` - INSERT INTO standalone_sandbox_provisions ( - id, - request_hash, - sandbox_id, - state, - ownership_claim, - ownership_claim_expires_at, - lease_id, - attach_url, - vnc_url, - expires_at, - message, - created_at, - updated_at - ) VALUES ( - ${payload.id}, - ${requestHash}, - ${lease.sandboxId}, - 'provisioning', - ${claim}, - ${now + credentialPolicyProvisioningStaleMs}, - ${sandboxLeaseId(lease)}, - NULL, - NULL, - ${expiresAt}, - 'standalone Sandbox provision started', - ${now}, - ${now} - ) - ON CONFLICT(id) DO UPDATE SET - sandbox_id = excluded.sandbox_id, - ownership_claim = excluded.ownership_claim, - ownership_claim_expires_at = excluded.ownership_claim_expires_at, - lease_id = excluded.lease_id, - attach_url = NULL, - vnc_url = NULL, - expires_at = excluded.expires_at, - message = excluded.message, - updated_at = excluded.updated_at - WHERE standalone_sandbox_provisions.request_hash = excluded.request_hash - AND standalone_sandbox_provisions.state = 'provisioning' - AND standalone_sandbox_provisions.ownership_claim_expires_at <= ${now} - `.execute(db); - const ownership = await db - .selectFrom("standalone_sandbox_provisions") - .select(["sandbox_id", "state", "ownership_claim", "ownership_claim_expires_at", "expires_at"]) - .where("id", "=", payload.id) - .executeTakeFirst(); - if ( - ownership?.sandbox_id !== lease.sandboxId || - ownership.state !== "provisioning" || - ownership.ownership_claim !== claim || - (ownership.ownership_claim_expires_at ?? 0) <= now || - ownership.expires_at !== expiresAt - ) { - return failedProvision("interactive provision failed: provision id is already in progress"); - } - if (previous?.sandbox_id && previous.sandbox_id !== lease.sandboxId) { - await queueSandboxCredentialPolicyCleanup(env, payload.id, previous.sandbox_id, now); - } - const fence: StandaloneSandboxProvisionFence = { - claim, - provisionId: payload.id, - sandboxId: lease.sandboxId, - }; - const result = await provisionWithSandbox(env, payload, undefined, lease, fence); - const finishedAt = Date.now(); - if (result.status !== "ready") { - await db - .updateTable("standalone_sandbox_provisions") - .set({ - state: "cleanup_pending", - ownership_claim: null, - ownership_claim_expires_at: null, - message: result.message, - updated_at: finishedAt, - }) - .where("id", "=", payload.id) - .where("sandbox_id", "=", lease.sandboxId) - .where("ownership_claim", "=", claim) - .where("expires_at", "=", expiresAt) - .execute(); - await queueSandboxCredentialPolicyCleanup(env, payload.id, lease.sandboxId, finishedAt); - await reconcileCredentialPolicyCleanupBatch(env, finishedAt, payload.id); - return result; - } - const policyGeneration = await activeSandboxCredentialPolicyGeneration( - env, - payload.id, - lease.sandboxId, - ); - const activationVersion = Math.max(Date.now(), finishedAt + 1); - if (policyGeneration) { - const ownerStillClaimed = sql`EXISTS ( - SELECT 1 - FROM standalone_sandbox_provisions AS owner - WHERE owner.id = ${payload.id} - AND owner.sandbox_id = ${lease.sandboxId} - AND owner.state = 'provisioning' - AND owner.ownership_claim = ${claim} - AND owner.ownership_claim_expires_at > ${activationVersion} - AND owner.expires_at = ${expiresAt} - AND owner.expires_at > ${activationVersion} - )`; - await executeBatch(env, [ - db - .updateTable("interactive_session_credential_policies") - .set({ updated_at: activationVersion }) - .where("session_id", "=", payload.id) - .where("sandbox_id", "=", lease.sandboxId) - .where("state", "=", "active") - .where("registration_generation", "=", policyGeneration) - .where("registration_claim", "is", null) - .where(ownerStillClaimed), - db - .updateTable("standalone_sandbox_provisions") - .set({ - state: "active", - ownership_claim: null, - ownership_claim_expires_at: null, - lease_id: result.leaseId, - attach_url: result.attachUrl, - vnc_url: result.vncUrl, - message: result.message, - updated_at: activationVersion, - }) - .where("id", "=", payload.id) - .where("sandbox_id", "=", lease.sandboxId) - .where("state", "=", "provisioning") - .where("ownership_claim", "=", claim) - .where("ownership_claim_expires_at", ">", activationVersion) - .where("expires_at", "=", expiresAt) - .where("expires_at", ">", activationVersion) - .where( - activeSandboxCredentialPolicyCondition( - env, - payload.id, - lease.sandboxId, - policyGeneration, - activationVersion, - ), - ), - ]); - } - const activated = await db - .selectFrom("standalone_sandbox_provisions") - .select(["state", "sandbox_id", "lease_id", "expires_at"]) - .where("id", "=", payload.id) - .executeTakeFirst(); - if ( - activated?.state !== "active" || - activated.sandbox_id !== lease.sandboxId || - activated.lease_id !== result.leaseId || - activated.expires_at !== expiresAt - ) { - await db - .updateTable("standalone_sandbox_provisions") - .set({ - state: "cleanup_pending", - ownership_claim: null, - ownership_claim_expires_at: null, - message: "standalone ownership claim expired", - updated_at: finishedAt, - }) - .where("id", "=", payload.id) - .where("sandbox_id", "=", lease.sandboxId) - .where("ownership_claim", "=", claim) - .where("expires_at", "=", expiresAt) - .execute(); - await queueSandboxCredentialPolicyCleanup(env, payload.id, lease.sandboxId, finishedAt); - await reconcileCredentialPolicyCleanupBatch(env, finishedAt, payload.id); - return failedProvision("interactive provision failed: standalone ownership claim expired"); - } - return { ...result, expiresAt, expiresAtPresent: true }; -} - -function managedInteractiveSessionId(id: string): boolean { - return /^is-[0-9]+$/i.test(id); -} - async function stageStandaloneSandboxProvisionCleanup( env: RuntimeEnv, owner: Selectable, @@ -7036,7 +6746,7 @@ async function expireStandaloneSandboxProvisions( await stageStandaloneSandboxProvisionCleanup( env, owner, - managedInteractiveSessionId(owner.id) + isManagedInteractiveSessionId(owner.id) ? "standalone provision used the reserved managed session namespace" : "standalone Sandbox provision expired", now, @@ -7111,14 +6821,14 @@ async function standaloneSandboxPty( !isCurrentSandboxLease(owner.lease_id) || !owner.expires_at || owner.expires_at <= Date.now() || - managedInteractiveSessionId(provisionId) + isManagedInteractiveSessionId(provisionId) ) { if (owner) { const now = Date.now(); await stageStandaloneSandboxProvisionCleanup( env, owner, - managedInteractiveSessionId(provisionId) + isManagedInteractiveSessionId(provisionId) ? "standalone provision used the reserved managed session namespace" : "standalone Sandbox provision expired", now, @@ -8170,12 +7880,6 @@ async function githubNodeBelongsToRepo( ); } -function sandboxLookupIds(env: RuntimeEnv, sandboxId: string): string[] { - const ids = new Set([sandboxId]); - if (env.SANDBOX) ids.add(env.SANDBOX.idFromName(sandboxId).toString()); - return [...ids]; -} - async function ensureCurrentSandboxLease( request: Request, env: RuntimeEnv, diff --git a/src/worker/database.ts b/src/worker/database.ts index 7a2641d..d2aa3cb 100644 --- a/src/worker/database.ts +++ b/src/worker/database.ts @@ -264,6 +264,8 @@ export type StandaloneSandboxProvisionTable = { updated_at: number; }; +export type StandaloneSandboxProvisionRow = Selectable; + export type AuditEventTable = { id: Generated; actor: string; diff --git a/src/worker/provisioning/standalone-sandbox-repository.ts b/src/worker/provisioning/standalone-sandbox-repository.ts new file mode 100644 index 0000000..6dedbd7 --- /dev/null +++ b/src/worker/provisioning/standalone-sandbox-repository.ts @@ -0,0 +1,194 @@ +import { sql } from "kysely"; + +import { database, executeBatch, type StandaloneSandboxProvisionRow } from "../database.ts"; +import type { RuntimeEnv } from "../env.ts"; +import { + activeSandboxCredentialPolicyCondition, + activeSandboxCredentialPolicyGeneration, +} from "../sandbox-policy-state.ts"; +import { newSandboxLease, sandboxLeaseId } from "../sandbox-lease.ts"; +import type { + StandaloneSandboxProvisionClaim, + StandaloneSandboxProvisionFence, +} from "./standalone-sandbox.ts"; +import type { InteractiveProvisionRequest, InteractiveProvisionResult } from "./types.ts"; + +export async function readStandaloneSandboxProvision( + env: RuntimeEnv, + provisionId: string, +): Promise { + return ( + (await database(env) + .selectFrom("standalone_sandbox_provisions") + .selectAll() + .where("id", "=", provisionId) + .executeTakeFirst()) ?? null + ); +} + +export async function claimStandaloneSandboxProvision( + env: RuntimeEnv, + session: InteractiveProvisionRequest, + requestHash: string, + now: number, + ownershipTtlMs: number, + provisionTtlMs: number, +): Promise { + const lease = newSandboxLease(session.id); + const fence: StandaloneSandboxProvisionFence = { + claim: `standalone:${crypto.randomUUID()}`, + provisionId: session.id, + sandboxId: lease.sandboxId, + }; + const expiresAt = now + provisionTtlMs; + await sql` + INSERT INTO standalone_sandbox_provisions ( + id, + request_hash, + sandbox_id, + state, + ownership_claim, + ownership_claim_expires_at, + lease_id, + attach_url, + vnc_url, + expires_at, + message, + created_at, + updated_at + ) VALUES ( + ${session.id}, + ${requestHash}, + ${lease.sandboxId}, + 'provisioning', + ${fence.claim}, + ${now + ownershipTtlMs}, + ${sandboxLeaseId(lease)}, + NULL, + NULL, + ${expiresAt}, + 'standalone Sandbox provision started', + ${now}, + ${now} + ) + ON CONFLICT(id) DO NOTHING + `.execute(database(env)); + const owner = await readStandaloneSandboxProvision(env, session.id); + if ( + owner?.request_hash !== requestHash || + owner.sandbox_id !== lease.sandboxId || + owner.state !== "provisioning" || + owner.ownership_claim !== fence.claim || + (owner.ownership_claim_expires_at ?? 0) <= now || + owner.lease_id !== sandboxLeaseId(lease) || + owner.expires_at !== expiresAt + ) { + return null; + } + return { lease, fence, expiresAt, claimRevision: now }; +} + +export async function stageStandaloneSandboxClaimCleanup( + env: RuntimeEnv, + claim: StandaloneSandboxProvisionClaim, + message: string, + now: number, +): Promise { + const transitionRevision = Math.max(now, claim.claimRevision + 1); + await database(env) + .updateTable("standalone_sandbox_provisions") + .set({ + state: "cleanup_pending", + ownership_claim: null, + ownership_claim_expires_at: null, + attach_url: null, + vnc_url: null, + message, + updated_at: transitionRevision, + }) + .where("id", "=", claim.fence.provisionId) + .where("sandbox_id", "=", claim.fence.sandboxId) + .where("state", "=", "provisioning") + .where("ownership_claim", "=", claim.fence.claim) + .where("lease_id", "=", sandboxLeaseId(claim.lease)) + .where("expires_at", "=", claim.expiresAt) + .execute(); +} + +export async function activateStandaloneSandboxProvision( + env: RuntimeEnv, + provisionId: string, + claim: StandaloneSandboxProvisionClaim, + result: InteractiveProvisionResult, + now: number, +): Promise { + const generation = await activeSandboxCredentialPolicyGeneration( + env, + provisionId, + claim.lease.sandboxId, + ); + if (!generation) return false; + const activationVersion = Math.max(now, claim.claimRevision + 1); + const ownerStillClaimed = sql`EXISTS ( + SELECT 1 + FROM standalone_sandbox_provisions AS owner + WHERE owner.id = ${provisionId} + AND owner.sandbox_id = ${claim.lease.sandboxId} + AND owner.state = 'provisioning' + AND owner.ownership_claim = ${claim.fence.claim} + AND owner.ownership_claim_expires_at > ${activationVersion} + AND owner.expires_at = ${claim.expiresAt} + AND owner.expires_at > ${activationVersion} + )`; + const db = database(env); + await executeBatch(env, [ + db + .updateTable("interactive_session_credential_policies") + .set({ updated_at: activationVersion }) + .where("session_id", "=", provisionId) + .where("sandbox_id", "=", claim.lease.sandboxId) + .where("state", "=", "active") + .where("registration_generation", "=", generation) + .where("registration_claim", "is", null) + .where(ownerStillClaimed), + db + .updateTable("standalone_sandbox_provisions") + .set({ + state: "active", + ownership_claim: null, + ownership_claim_expires_at: null, + lease_id: result.leaseId, + attach_url: result.attachUrl, + vnc_url: result.vncUrl, + message: result.message, + updated_at: activationVersion, + }) + .where("id", "=", provisionId) + .where("sandbox_id", "=", claim.lease.sandboxId) + .where("state", "=", "provisioning") + .where("ownership_claim", "=", claim.fence.claim) + .where("ownership_claim_expires_at", ">", activationVersion) + .where("expires_at", "=", claim.expiresAt) + .where("expires_at", ">", activationVersion) + .where( + activeSandboxCredentialPolicyCondition( + env, + provisionId, + claim.lease.sandboxId, + generation, + activationVersion, + ), + ), + ]); + const activated = await db + .selectFrom("standalone_sandbox_provisions") + .select(["state", "sandbox_id", "lease_id", "expires_at"]) + .where("id", "=", provisionId) + .executeTakeFirst(); + return Boolean( + activated?.state === "active" && + activated.sandbox_id === claim.lease.sandboxId && + activated.lease_id === result.leaseId && + activated.expires_at === claim.expiresAt, + ); +} diff --git a/src/worker/provisioning/standalone-sandbox.ts b/src/worker/provisioning/standalone-sandbox.ts new file mode 100644 index 0000000..36c4ca4 --- /dev/null +++ b/src/worker/provisioning/standalone-sandbox.ts @@ -0,0 +1,206 @@ +import type { StandaloneSandboxProvisionRow } from "../database.ts"; +import { sandboxLeaseId, type SandboxLease } from "../sandbox-lease.ts"; +import type { InteractiveProvisionRequest, InteractiveProvisionResult } from "./types.ts"; + +export const standaloneSandboxDefaultTtlSeconds = 14_400; + +export type StandaloneSandboxProvisionFence = { + claim: string; + provisionId: string; + sandboxId: string; +}; + +export type StandaloneSandboxProvisionClaim = { + lease: SandboxLease; + fence: StandaloneSandboxProvisionFence; + expiresAt: number; + claimRevision: number; +}; + +export type StandaloneSandboxProvisioningDependencies = { + now(): number; + requestHash(session: InteractiveProvisionRequest): Promise; + readOwner(provisionId: string): Promise; + stageOwnerCleanup( + owner: StandaloneSandboxProvisionRow, + message: string, + now: number, + ): Promise; + reconcileCleanup(provisionId: string, now: number): Promise; + claim( + session: InteractiveProvisionRequest, + requestHash: string, + now: number, + ): Promise; + provision( + session: InteractiveProvisionRequest, + claim: StandaloneSandboxProvisionClaim, + ): Promise; + stageClaimCleanup( + claim: StandaloneSandboxProvisionClaim, + message: string, + now: number, + ): Promise; + queuePolicyCleanup(provisionId: string, sandboxId: string, now: number): Promise; + activate( + provisionId: string, + claim: StandaloneSandboxProvisionClaim, + result: InteractiveProvisionResult, + now: number, + ): Promise; + providerError(error: unknown): string; +}; + +export class StandaloneSandboxProvisioningService { + private readonly dependencies: StandaloneSandboxProvisioningDependencies; + + constructor(dependencies: StandaloneSandboxProvisioningDependencies) { + this.dependencies = dependencies; + } + + async provision(session: InteractiveProvisionRequest): Promise { + if (isManagedInteractiveSessionId(session.id)) { + return failedProvision( + "interactive provision failed: standalone provision id uses the managed session namespace", + ); + } + + const requestHash = await this.dependencies.requestHash(session); + const now = this.dependencies.now(); + let owner = await this.dependencies.readOwner(session.id); + if (owner && owner.request_hash !== requestHash) { + return failedProvision("interactive provision failed: provision id is already registered"); + } + if (owner?.state === "active") { + if (!owner.expires_at || owner.expires_at <= now) { + await this.cleanupOwner(owner, "standalone Sandbox provision expired", now); + return failedProvision( + "interactive provision failed: standalone Sandbox provision expired", + ); + } + return activeProvisionResult(owner); + } + if (owner?.state === "cleanup_pending") { + return failedProvision( + "interactive provision failed: previous credential cleanup is pending", + ); + } + if ( + owner?.state === "provisioning" && + (owner.ownership_claim_expires_at ?? Number.NEGATIVE_INFINITY) <= now + ) { + const staged = await this.dependencies.stageOwnerCleanup( + owner, + "abandoned standalone Sandbox provision cleanup", + now, + ); + if (!staged) { + return failedProvision("interactive provision failed: standalone ownership changed"); + } + await this.dependencies.reconcileCleanup(session.id, now); + owner = await this.dependencies.readOwner(session.id); + if (owner) { + return failedProvision( + "interactive provision failed: previous credential cleanup is pending", + ); + } + } + + const claim = await this.dependencies.claim(session, requestHash, now); + if (!claim) { + return failedProvision("interactive provision failed: provision id is already in progress"); + } + + let result: InteractiveProvisionResult; + try { + result = await this.dependencies.provision(session, claim); + } catch (error) { + const message = `Cloudflare Sandbox provision failed: ${this.dependencies.providerError(error)}`; + await this.cleanupClaim(session.id, claim, message, this.dependencies.now()); + return failedProvision(message); + } + if (result.status !== "ready") { + await this.cleanupClaim(session.id, claim, result.message, this.dependencies.now()); + return result; + } + if (result.leaseId !== sandboxLeaseId(claim.lease)) { + const message = "interactive provision failed: standalone Sandbox lease mismatch"; + await this.cleanupClaim(session.id, claim, message, this.dependencies.now()); + return failedProvision(message); + } + + const activated = await this.dependencies.activate( + session.id, + claim, + result, + this.dependencies.now(), + ); + if (!activated) { + await this.cleanupClaim( + session.id, + claim, + "standalone ownership claim expired", + this.dependencies.now(), + ); + return failedProvision("interactive provision failed: standalone ownership claim expired"); + } + return { + ...result, + expiresAt: claim.expiresAt, + expiresAtPresent: true, + }; + } + + private async cleanupOwner( + owner: StandaloneSandboxProvisionRow, + stageMessage: string, + now: number, + ): Promise { + await this.dependencies.stageOwnerCleanup(owner, stageMessage, now); + await this.dependencies.reconcileCleanup(owner.id, now); + } + + private async cleanupClaim( + provisionId: string, + claim: StandaloneSandboxProvisionClaim, + message: string, + now: number, + ): Promise { + await this.dependencies.stageClaimCleanup(claim, message, now); + await this.dependencies.queuePolicyCleanup(provisionId, claim.lease.sandboxId, now); + await this.dependencies.reconcileCleanup(provisionId, now); + } +} + +export function standaloneSandboxProvisionRequestHashInput( + session: InteractiveProvisionRequest, +): Omit { + const { githubToken: _githubToken, ...ownershipPayload } = session; + return ownershipPayload; +} + +export function isManagedInteractiveSessionId(id: string): boolean { + return /^is-[0-9]+$/i.test(id); +} + +function activeProvisionResult(owner: StandaloneSandboxProvisionRow): InteractiveProvisionResult { + return { + status: "ready", + leaseId: owner.lease_id, + attachUrl: owner.attach_url, + vncUrl: owner.vnc_url, + expiresAt: owner.expires_at, + expiresAtPresent: true, + message: owner.message, + }; +} + +function failedProvision(message: string): InteractiveProvisionResult { + return { + status: "failed", + leaseId: null, + attachUrl: null, + vncUrl: null, + message, + }; +} diff --git a/src/worker/sandbox-policy-state.ts b/src/worker/sandbox-policy-state.ts new file mode 100644 index 0000000..411f306 --- /dev/null +++ b/src/worker/sandbox-policy-state.ts @@ -0,0 +1,83 @@ +import { sql, type RawBuilder } from "kysely"; + +import { database } from "./database.ts"; +import type { RuntimeEnv } from "./env.ts"; + +export function sandboxLookupIds(env: RuntimeEnv, sandboxId: string): string[] { + const ids = new Set([sandboxId]); + if (env.SANDBOX) ids.add(env.SANDBOX.idFromName(sandboxId).toString()); + return [...ids]; +} + +export function activeSandboxCredentialPolicyCondition( + env: RuntimeEnv, + sessionId: string, + sandboxId: string, + generation: string, + updatedAt?: number, +): RawBuilder { + const lookupIds = sandboxLookupIds(env, sandboxId); + const updatedAtCondition = + updatedAt === undefined ? sql`1 = 1` : sql`updated_at = ${updatedAt}`; + return sql` + ( + SELECT count(DISTINCT lookup_id) + FROM interactive_session_credential_policies + WHERE session_id = ${sessionId} + AND sandbox_id = ${sandboxId} + AND lookup_id IN (${sql.join(lookupIds)}) + AND state = 'active' + AND registration_generation = ${generation} + AND registration_claim IS NULL + AND ${updatedAtCondition} + ) = ${lookupIds.length} + AND NOT EXISTS ( + SELECT 1 + FROM interactive_session_credential_policies + WHERE session_id = ${sessionId} + AND sandbox_id = ${sandboxId} + AND ( + state != 'active' + OR registration_generation != ${generation} + OR registration_claim IS NOT NULL + OR NOT (${updatedAtCondition}) + ) + ) + `; +} + +export async function activeSandboxCredentialPolicyGeneration( + env: RuntimeEnv, + sessionId: string, + sandboxId: string, +): Promise { + const rows = await database(env) + .selectFrom("interactive_session_credential_policies") + .select(["lookup_id", "state", "registration_generation", "registration_claim"]) + .where("session_id", "=", sessionId) + .where("sandbox_id", "=", sandboxId) + .execute(); + const expected = sandboxLookupIds(env, sandboxId); + const generation = rows[0]?.registration_generation; + if ( + !generation || + !expected.every((lookupId) => + rows.some( + (row) => + row.lookup_id === lookupId && + row.state === "active" && + row.registration_generation === generation && + row.registration_claim === null, + ), + ) || + rows.some( + (row) => + row.state !== "active" || + row.registration_generation !== generation || + row.registration_claim !== null, + ) + ) { + return null; + } + return generation; +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 1451b36..46b104c 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -865,7 +865,6 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a const sandboxStart = source.indexOf("async function provisionWithSandbox"); const sandboxEnd = source.indexOf("function sandboxManagedOwnershipCondition", sandboxStart); const sandboxSource = source.slice(sandboxStart, sandboxEnd); - const activationSource = endpointSource.slice(endpointSource.indexOf("const activationVersion")); const stopStart = source.indexOf("async function stopStandaloneSandboxProvision"); const stopEnd = source.indexOf("function standaloneSandboxAttachUrl", stopStart); const stopSource = source.slice(stopStart, stopEnd); @@ -888,20 +887,13 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a assert.match(endpointSource, /if \(managed\)/); assert.ok( endpointSource.indexOf("if (managed)") < - endpointSource.indexOf("return provisionStandaloneSandbox(env, payload)"), + endpointSource.indexOf("return standaloneSandboxProvisioningService(env).provision(payload)"), ); assert.match( endpointSource, /managedSandboxProvisioningService\(env\)\.provision\(payload, managed\)/, ); - assert.match(endpointSource, /managedInteractiveSessionId\(payload\.id\)/); - assert.match(endpointSource, /managed session namespace/); - assert.match(endpointSource, /INSERT INTO standalone_sandbox_provisions/); - assert.match(endpointSource, /ownership_claim_expires_at/); - assert.match(endpointSource, /\$\{sandboxLeaseId\(lease\)\}/); - assert.match(endpointSource, /lease_id = excluded\.lease_id/); - assert.match(endpointSource, /provisionWithSandbox\([\s\S]*fence/); - assert.match(endpointSource, /state: "active"/); + assert.match(endpointSource, /standaloneSandboxProvisioningService\(env\)\.provision\(payload\)/); assert.match(ownershipSource, /FROM standalone_sandbox_provisions AS owner/); assert.match(ownershipSource, /owner\.ownership_claim = \$\{ownershipFence\.claim\}/); assert.match(ptySource, /authorizeProvisionBearerToken\(request, env\)/); @@ -930,20 +922,6 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a ); assert.match(standaloneCleanupSource, /where\("updated_at", "=", owner\.updated_at\)/); assert.match(sandboxSource, /standaloneSandboxAttachUrl\(env, session\.id\)/); - assert.match( - endpointSource, - /const activationVersion = Math\.max\(Date\.now\(\), finishedAt \+ 1\)/, - ); - assert.match(activationSource, /executeBatch\(env, \[/); - assert.ok( - activationSource.indexOf('.updateTable("interactive_session_credential_policies")') < - activationSource.indexOf('.updateTable("standalone_sandbox_provisions")'), - ); - assert.match(activationSource, /set\(\{ updated_at: activationVersion \}\)/); - assert.match( - endpointSource, - /activeSandboxCredentialPolicyCondition\([\s\S]*policyGeneration,[\s\S]*activationVersion/, - ); assert.match(migration, /CREATE TABLE IF NOT EXISTS standalone_sandbox_provisions/); assert.match(migration, /request_hash TEXT NOT NULL/); assert.match(migration, /sandbox_id TEXT NOT NULL UNIQUE/); @@ -961,7 +939,6 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a assert.match(stopSource, /reconcileCredentialPolicyCleanupBatch/); assert.match(stopSource, /status: remaining \? "stopping" : "stopped"/); assert.match(source, /CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS/); - assert.match(source, /function managedInteractiveSessionId/); assert.match(source, /standaloneProvisionStopMatch/); assert.match(source, /stopStandaloneSandboxProvision/); assert.match(source, /policy\?\.expiresAt !== undefined && policy\.expiresAt <= Date\.now\(\)/); diff --git a/tests/standalone-sandbox-provisioning.test.ts b/tests/standalone-sandbox-provisioning.test.ts new file mode 100644 index 0000000..3ee27cd --- /dev/null +++ b/tests/standalone-sandbox-provisioning.test.ts @@ -0,0 +1,528 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { StandaloneSandboxProvisionRow } from "../src/worker/database.ts"; +import type { RuntimeEnv } from "../src/worker/env.ts"; +import { + activateStandaloneSandboxProvision, + claimStandaloneSandboxProvision, + stageStandaloneSandboxClaimCleanup, +} from "../src/worker/provisioning/standalone-sandbox-repository.ts"; +import { + isManagedInteractiveSessionId, + StandaloneSandboxProvisioningService, + standaloneSandboxProvisionRequestHashInput, + type StandaloneSandboxProvisionClaim, + type StandaloneSandboxProvisioningDependencies, +} from "../src/worker/provisioning/standalone-sandbox.ts"; +import type { + InteractiveProvisionRequest, + InteractiveProvisionResult, +} from "../src/worker/provisioning/types.ts"; +import { sandboxLeaseId } from "../src/worker/sandbox-lease.ts"; + +const request: InteractiveProvisionRequest = { + id: "external-42", + parentSessionId: null, + rootSessionId: "external-42", + repo: "openclaw/crabfleet", + branch: "main", + runtime: "container", + profile: "cloudflare-sandbox", + command: "codex", + prompt: "Fix the issue", + purpose: "Fix the issue", + summary: "Working", + owner: "owner", + createdBy: "github:42", + githubToken: "secret", +}; + +const claim: StandaloneSandboxProvisionClaim = { + lease: { + sandboxId: "sandbox-new", + terminalSessionId: "terminal-new", + }, + fence: { + claim: "standalone:claim", + provisionId: request.id, + sandboxId: "sandbox-new", + }, + expiresAt: 10_000, + claimRevision: 200, +}; + +function owner(values: Partial = {}): StandaloneSandboxProvisionRow { + return { + id: request.id, + request_hash: "hash", + sandbox_id: "sandbox-old", + state: "provisioning", + ownership_claim: "standalone:old", + ownership_claim_expires_at: 100, + lease_id: "sandbox:sandbox-old:terminal-old:autostart-v4", + attach_url: null, + vnc_url: null, + expires_at: 9_000, + message: "standalone Sandbox provision started", + created_at: 1, + updated_at: 2, + ...values, + }; +} + +function readyResult(values: Partial = {}): InteractiveProvisionResult { + return { + status: "ready", + leaseId: sandboxLeaseId(claim.lease), + attachUrl: "wss://crabfleet.example/pty", + vncUrl: null, + message: "Cloudflare Sandbox ready", + ...values, + }; +} + +function dependencies( + overrides: Partial = {}, +): StandaloneSandboxProvisioningDependencies { + return { + now: () => 200, + requestHash: async () => "hash", + readOwner: async () => null, + stageOwnerCleanup: async () => true, + reconcileCleanup: async () => {}, + claim: async () => claim, + provision: async () => readyResult(), + stageClaimCleanup: async () => {}, + queuePolicyCleanup: async () => {}, + activate: async () => true, + providerError: (error) => (error instanceof Error ? error.message : String(error)), + ...overrides, + }; +} + +test("standalone Sandbox rejects the managed namespace before persistence", async () => { + const calls: string[] = []; + const service = new StandaloneSandboxProvisioningService( + dependencies({ + async requestHash() { + calls.push("hash"); + return "hash"; + }, + async readOwner() { + calls.push("read"); + return null; + }, + }), + ); + + const result = await service.provision({ ...request, id: "IS-42" }); + + assert.equal(isManagedInteractiveSessionId("is-42"), true); + assert.equal(result.status, "failed"); + assert.match(result.message, /managed session namespace/); + assert.deepEqual(calls, []); +}); + +test("standalone Sandbox hash excludes transient GitHub credentials", () => { + const { githubToken: _githubToken, ...expected } = request; + const hashInput = standaloneSandboxProvisionRequestHashInput(request); + + assert.deepEqual(hashInput, expected); + assert.equal("githubToken" in hashInput, false); +}); + +test("standalone Sandbox replays an active owner and rejects hash conflicts", async () => { + const active = owner({ + state: "active", + ownership_claim: null, + ownership_claim_expires_at: null, + expires_at: 1_000, + attach_url: "wss://crabfleet.example/existing", + message: "existing", + }); + const calls: string[] = []; + const replay = new StandaloneSandboxProvisioningService( + dependencies({ + async readOwner() { + calls.push("read"); + return active; + }, + async claim() { + calls.push("claim"); + return claim; + }, + }), + ); + const conflict = new StandaloneSandboxProvisioningService( + dependencies({ + requestHash: async () => "different", + readOwner: async () => active, + }), + ); + + assert.deepEqual(await replay.provision(request), { + status: "ready", + leaseId: active.lease_id, + attachUrl: active.attach_url, + vncUrl: active.vnc_url, + expiresAt: active.expires_at, + expiresAtPresent: true, + message: active.message, + }); + assert.deepEqual(calls, ["read"]); + assert.match((await conflict.provision(request)).message, /already registered/); +}); + +test("standalone Sandbox expires active owners through staged cleanup", async () => { + const calls: string[] = []; + const expired = owner({ + state: "active", + ownership_claim: null, + ownership_claim_expires_at: null, + expires_at: 200, + }); + const service = new StandaloneSandboxProvisioningService( + dependencies({ + readOwner: async () => expired, + async stageOwnerCleanup(_owner, message, now) { + calls.push(`stage:${message}:${now}`); + return true; + }, + async reconcileCleanup(provisionId, now) { + calls.push(`reconcile:${provisionId}:${now}`); + }, + }), + ); + + const result = await service.provision(request); + + assert.equal(result.status, "failed"); + assert.match(result.message, /provision expired/); + assert.deepEqual(calls, [ + "stage:standalone Sandbox provision expired:200", + "reconcile:external-42:200", + ]); +}); + +test("standalone Sandbox recovers a stale owner before taking a new claim", async () => { + const calls: string[] = []; + const owners = [owner(), null]; + const service = new StandaloneSandboxProvisioningService( + dependencies({ + async readOwner() { + calls.push("read"); + return owners.shift() ?? null; + }, + async stageOwnerCleanup(_owner, message, now) { + calls.push(`stage:${message}:${now}`); + return true; + }, + async reconcileCleanup(provisionId, now) { + calls.push(`reconcile:${provisionId}:${now}`); + }, + async claim(_session, requestHash, now) { + calls.push(`claim:${requestHash}:${now}`); + return claim; + }, + async provision() { + calls.push("provision"); + return readyResult(); + }, + async activate() { + calls.push("activate"); + return true; + }, + }), + ); + + const result = await service.provision(request); + + assert.equal(result.status, "ready"); + assert.equal(result.expiresAt, claim.expiresAt); + assert.deepEqual(calls, [ + "read", + "stage:abandoned standalone Sandbox provision cleanup:200", + "reconcile:external-42:200", + "read", + "claim:hash:200", + "provision", + "activate", + ]); +}); + +test("standalone Sandbox refuses lost or incomplete stale-owner cleanup", async () => { + const stale = owner(); + const ownershipLost = new StandaloneSandboxProvisioningService( + dependencies({ + readOwner: async () => stale, + stageOwnerCleanup: async () => false, + }), + ); + const cleanupPending = new StandaloneSandboxProvisioningService( + dependencies({ + readOwner: async () => stale, + stageOwnerCleanup: async () => true, + reconcileCleanup: async () => {}, + }), + ); + + assert.match((await ownershipLost.provision(request)).message, /ownership changed/); + assert.match((await cleanupPending.provision(request)).message, /cleanup is pending/); +}); + +test("standalone Sandbox claim contention stops before provider work", async () => { + const calls: string[] = []; + const service = new StandaloneSandboxProvisioningService( + dependencies({ + async claim() { + calls.push("claim"); + return null; + }, + async provision() { + calls.push("provision"); + return readyResult(); + }, + }), + ); + + const result = await service.provision(request); + + assert.equal(result.status, "failed"); + assert.match(result.message, /already in progress/); + assert.deepEqual(calls, ["claim"]); +}); + +test("standalone Sandbox failures stage owner and policy cleanup", async () => { + const messages: string[] = []; + const service = new StandaloneSandboxProvisioningService( + dependencies({ + async provision() { + throw new Error("token=private"); + }, + providerError: () => "[credential]", + async stageClaimCleanup(_claim, message) { + messages.push(`owner:${message}`); + }, + async queuePolicyCleanup(_provisionId, _sandboxId, now) { + messages.push(`policy:${now}`); + }, + async reconcileCleanup(_provisionId, now) { + messages.push(`reconcile:${now}`); + }, + }), + ); + + const result = await service.provision(request); + + assert.equal(result.status, "failed"); + assert.equal(result.message, "Cloudflare Sandbox provision failed: [credential]"); + assert.deepEqual(messages, [ + "owner:Cloudflare Sandbox provision failed: [credential]", + "policy:200", + "reconcile:200", + ]); +}); + +test("standalone Sandbox rejects non-ready, mismatched, and lost activations", async () => { + const messages: string[] = []; + const cleanup = { + async stageClaimCleanup( + _claim: StandaloneSandboxProvisionClaim, + message: string, + ): Promise { + messages.push(message); + }, + async queuePolicyCleanup(): Promise {}, + async reconcileCleanup(): Promise {}, + }; + const nonReady = new StandaloneSandboxProvisioningService( + dependencies({ + ...cleanup, + provision: async () => readyResult({ status: "stopping", message: "cleanup pending" }), + }), + ); + const mismatch = new StandaloneSandboxProvisioningService( + dependencies({ + ...cleanup, + provision: async () => readyResult({ leaseId: "sandbox:different:terminal:autostart-v4" }), + }), + ); + const activationLost = new StandaloneSandboxProvisioningService( + dependencies({ + ...cleanup, + activate: async () => false, + }), + ); + + assert.equal((await nonReady.provision(request)).status, "stopping"); + assert.match((await mismatch.provision(request)).message, /lease mismatch/); + assert.match((await activationLost.provision(request)).message, /ownership claim expired/); + assert.deepEqual(messages, [ + "cleanup pending", + "interactive provision failed: standalone Sandbox lease mismatch", + "standalone ownership claim expired", + ]); +}); + +type PreparedStatement = { + sql: string; + parameters: unknown[]; + all(): Promise; + run(): Promise; +}; + +function runtimeEnv( + handler: ( + sql: string, + parameters: unknown[], + kind: "all" | "run", + ) => { + results?: unknown[]; + changes?: number; + }, + batchHandler: (statements: PreparedStatement[]) => unknown[] = () => [], +): RuntimeEnv { + return { + DB: { + prepare(sql: string) { + return { + bind(...parameters: unknown[]) { + return { + sql, + parameters, + async all() { + const result = handler(sql, parameters, "all"); + return { results: result.results ?? [], meta: { changes: result.changes ?? 0 } }; + }, + async run() { + const result = handler(sql, parameters, "run"); + return { meta: { changes: result.changes ?? 0 } }; + }, + }; + }, + }; + }, + async batch(statements: unknown[]) { + return batchHandler(statements as PreparedStatement[]); + }, + } as unknown as D1Database, + } as RuntimeEnv; +} + +test("standalone Sandbox claim inserts without overwriting an existing owner", async () => { + let insertSql = ""; + let insertParameters: unknown[] = []; + const env = runtimeEnv((sql, parameters, kind) => { + if (kind === "run") { + insertSql = sql; + insertParameters = parameters; + return { changes: 1 }; + } + return { + results: [ + owner({ + request_hash: String(insertParameters[1]), + sandbox_id: String(insertParameters[2]), + ownership_claim: String(insertParameters[3]), + ownership_claim_expires_at: Number(insertParameters[4]), + lease_id: String(insertParameters[5]), + expires_at: Number(insertParameters[6]), + created_at: Number(insertParameters[7]), + updated_at: Number(insertParameters[8]), + }), + ], + }; + }); + + const claimed = await claimStandaloneSandboxProvision(env, request, "hash", 200, 900, 5_000); + + assert.ok(claimed); + assert.match(insertSql, /ON CONFLICT\(id\) DO NOTHING/i); + assert.doesNotMatch(insertSql, /DO UPDATE/i); + assert.equal(claimed.expiresAt, 5_200); + assert.equal(claimed.claimRevision, 200); + assert.equal(claimed.fence.provisionId, request.id); + assert.equal(claimed.fence.sandboxId, claimed.lease.sandboxId); +}); + +test("standalone Sandbox claim returns null when another owner wins", async () => { + const env = runtimeEnv((_sql, _parameters, kind) => + kind === "all" ? { results: [owner({ ownership_claim_expires_at: 1_000 })] } : { changes: 0 }, + ); + + assert.equal(await claimStandaloneSandboxProvision(env, request, "hash", 200, 900, 5_000), null); +}); + +test("standalone Sandbox claim cleanup fences the exact owner revision", async () => { + let execution: { sql: string; parameters: unknown[] } | null = null; + const env = runtimeEnv((sql, parameters) => { + execution = { sql, parameters }; + return { changes: 1 }; + }); + + await stageStandaloneSandboxClaimCleanup(env, claim, "failed", 200); + + assert.ok(execution); + assert.match(execution.sql, /ownership_claim/i); + assert.match(execution.sql, /lease_id/i); + assert.match(execution.sql, /expires_at/i); + assert.ok(execution.parameters.includes(claim.fence.claim)); + assert.ok(execution.parameters.includes(sandboxLeaseId(claim.lease))); + assert.ok(execution.parameters.includes(claim.expiresAt)); + assert.ok(execution.parameters.includes(201)); +}); + +test("standalone Sandbox activation fences owner and complete policy generation", async () => { + let statements: PreparedStatement[] = []; + const expectedLeaseId = sandboxLeaseId(claim.lease); + const env = runtimeEnv( + (sql) => { + if (/from "interactive_session_credential_policies"/i.test(sql)) { + return { + results: [ + { + lookup_id: claim.lease.sandboxId, + state: "active", + registration_generation: "generation-1", + registration_claim: null, + }, + ], + }; + } + if (/from "standalone_sandbox_provisions"/i.test(sql)) { + return { + results: [ + { + state: "active", + sandbox_id: claim.lease.sandboxId, + lease_id: expectedLeaseId, + expires_at: claim.expiresAt, + }, + ], + }; + } + return { changes: 1 }; + }, + (prepared) => { + statements = prepared; + return prepared.map(() => ({ results: [], meta: { changes: 1 } })); + }, + ); + + assert.equal( + await activateStandaloneSandboxProvision(env, request.id, claim, readyResult(), 200), + true, + ); + assert.equal(statements.length, 2); + const sql = statements.map((statement) => statement.sql).join("\n"); + assert.match(sql, /interactive_session_credential_policies/); + assert.match(sql, /standalone_sandbox_provisions/); + assert.match(sql, /ownership_claim_expires_at/); + assert.match(sql, /registration_generation/); + const parameters = statements.flatMap((statement) => statement.parameters); + assert.ok(parameters.includes(claim.fence.claim)); + assert.ok(parameters.includes("generation-1")); + assert.ok(parameters.includes(expectedLeaseId)); + assert.ok(parameters.includes(201)); +}); From be38bff8626f17bd0457444d88977738103c9912 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 15:59:12 +0100 Subject: [PATCH 057/109] refactor: extract auth routes --- src/index.ts | 107 +++++++------------------------- src/worker/auth.ts | 57 ++++++++++++++++- src/worker/routes/auth.ts | 58 +++++++++++++++++ tests/auth-routes.test.ts | 127 ++++++++++++++++++++++++++++++++++++++ tests/auth.test.ts | 71 +++++++++++++++++++++ 5 files changed, 334 insertions(+), 86 deletions(-) create mode 100644 src/worker/routes/auth.ts create mode 100644 tests/auth-routes.test.ts diff --git a/src/index.ts b/src/index.ts index fee2ee8..342415f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -146,16 +146,13 @@ import { actor, authMethods, authorize, - bootstrapSubject, - createSession, - devIdentityEnabled, - devIdentityId, + devIdentityLogin, logout, optionalUser, - parseRole, requireRole, requireUser, sessionGitHubToken, + tokenLogin, upsertUser, } from "./worker/auth"; import { base64FromBytes, openSecret, sealSecret, sha256 } from "./worker/crypto"; @@ -192,6 +189,7 @@ import { type RuntimeCapabilities, } from "./worker/session-model"; import { normalizeRepo } from "./worker/repositories"; +import { handlePublicAuthRoute, handleSessionAuthRoute } from "./worker/routes/auth"; import { isCurrentSandboxLease, newSandboxLease, @@ -1409,18 +1407,20 @@ export default { return text(SPEC_HTML, "text/html; charset=utf-8", { vary: "Accept" }); } - if (url.pathname === "/login/github") { - return await githubLogin(request, env); - } - - if (url.pathname === "/auth/github/callback") { - return await githubCallback(request, env); - } - - const sshLinkMatch = url.pathname.match(/^\/ssh\/link\/([^/]+)$/); - if (sshLinkMatch && (request.method === "GET" || request.method === "POST")) { - return await sshLink(request, env, decodeURIComponent(sshLinkMatch[1] ?? ""), trustedProxy); - } + const authResponse = await handlePublicAuthRoute(request, url, trustedProxy, { + githubLogin: (authRequest) => githubLogin(authRequest, env), + githubCallback: (authRequest) => githubCallback(authRequest, env), + sshLink: (authRequest, code, requestAuth) => sshLink(authRequest, env, code, requestAuth), + tokenLogin: (authRequest) => tokenLogin(authRequest, env), + devIdentityLogin: (authRequest) => devIdentityLogin(authRequest, env), + logout: (authRequest) => logout(authRequest, env), + authState: (authRequest) => + json({ + auth: authMethods(env, authRequest), + deployment: publicDeploymentConfig(env), + }), + }); + if (authResponse) return authResponse; if (url.pathname.startsWith("/api/")) { return await api(request, env, context, trustedProxy); @@ -1474,22 +1474,6 @@ async function api( ): Promise { const url = new URL(request.url); - if (request.method === "POST" && url.pathname === "/api/login/token") { - return tokenLogin(request, env); - } - - if (request.method === "POST" && url.pathname === "/api/login/dev") { - return devIdentityLogin(request, env); - } - - if (request.method === "POST" && url.pathname === "/api/logout") { - return logout(request, env); - } - - if (request.method === "GET" && url.pathname === "/api/auth") { - return json({ auth: authMethods(env, request), deployment: publicDeploymentConfig(env) }); - } - const standaloneProvisionPtyMatch = url.pathname.match( /^\/api\/provision\/interactive\/([^/]+)\/pty$/, ); @@ -1819,9 +1803,11 @@ async function api( const user = await requireUser(request, env, requestAuth); - if (request.method === "GET" && url.pathname === "/api/session") { - return json({ user, auth: authMethods(env, request) }); - } + const sessionAuthResponse = handleSessionAuthRoute(request, url, user, { + sessionState: (authRequest, authenticatedUser) => + json({ user: authenticatedUser, auth: authMethods(env, authRequest) }), + }); + if (sessionAuthResponse) return sessionAuthResponse; if (request.method === "GET" && url.pathname === "/api/state") { return json(await readState(request, env, user, context)); @@ -2045,55 +2031,6 @@ async function api( return json({ error: "not found" }, { status: 404 }); } -async function tokenLogin(request: Request, env: RuntimeEnv): Promise { - const { token } = await readJson<{ token?: string }>(request); - if (!env.CRABBOX_BOOTSTRAP_TOKEN || token !== env.CRABBOX_BOOTSTRAP_TOKEN) { - return json({ error: "invalid token" }, { status: 401 }); - } - - const now = Date.now(); - const subject = await bootstrapSubject(env); - const user: User = { - subject, - login: "bootstrap", - email: null, - name: "Bootstrap Admin", - role: "owner", - allowed: true, - teams: [], - }; - await upsertUser(env, user, now); - const cookieHeader = await createSession(env, request, user.subject, now); - return json( - { user, auth: authMethods(env, request) }, - { headers: { "set-cookie": cookieHeader } }, - ); -} - -async function devIdentityLogin(request: Request, env: RuntimeEnv): Promise { - if (!devIdentityEnabled(env, request)) return json({ error: "not found" }, { status: 404 }); - - const body = await readJson<{ id?: string; name?: string; role?: string }>(request); - const id = devIdentityId(body.id); - const role = parseRole(body.role); - const user: User = { - subject: `dev:${id}`, - login: id, - email: null, - name: clean(body.name, 120) || id, - role, - allowed: true, - teams: [], - }; - const now = Date.now(); - await upsertUser(env, user, now); - const cookieHeader = await createSession(env, request, user.subject, now); - return json( - { user, auth: authMethods(env, request) }, - { headers: { "set-cookie": cookieHeader } }, - ); -} - async function sshLink( request: Request, env: RuntimeEnv, diff --git a/src/worker/auth.ts b/src/worker/auth.ts index 78ca3bb..b6cb615 100644 --- a/src/worker/auth.ts +++ b/src/worker/auth.ts @@ -7,7 +7,7 @@ import { import { openSecret, sealSecret, sha256 } from "./crypto.ts"; import { database } from "./database.ts"; import type { RuntimeEnv } from "./env.ts"; -import { cookie, cookies, forbidden, json, unauthorized } from "./http.ts"; +import { cookie, cookies, forbidden, json, readJson, unauthorized } from "./http.ts"; import type { Role, User } from "./models.ts"; const sessionCookie = "crabbox_session"; @@ -205,6 +205,55 @@ export async function logout(request: Request, env: RuntimeEnv): Promise { + const { token } = await readJson<{ token?: string }>(request); + if (!env.CRABBOX_BOOTSTRAP_TOKEN || token !== env.CRABBOX_BOOTSTRAP_TOKEN) { + return json({ error: "invalid token" }, { status: 401 }); + } + + const now = Date.now(); + const subject = await bootstrapSubject(env); + const user: User = { + subject, + login: "bootstrap", + email: null, + name: "Bootstrap Admin", + role: "owner", + allowed: true, + teams: [], + }; + await upsertUser(env, user, now); + const cookieHeader = await createSession(env, request, user.subject, now); + return json( + { user, auth: authMethods(env, request) }, + { headers: { "set-cookie": cookieHeader } }, + ); +} + +export async function devIdentityLogin(request: Request, env: RuntimeEnv): Promise { + if (!devIdentityEnabled(env, request)) return json({ error: "not found" }, { status: 404 }); + + const body = await readJson<{ id?: string; name?: string; role?: string }>(request); + const id = devIdentityId(body.id); + const role = parseRole(body.role); + const user: User = { + subject: `dev:${id}`, + login: id, + email: null, + name: clean(body.name, 120) || id, + role, + allowed: true, + teams: [], + }; + const now = Date.now(); + await upsertUser(env, user, now); + const cookieHeader = await createSession(env, request, user.subject, now); + return json( + { user, auth: authMethods(env, request) }, + { headers: { "set-cookie": cookieHeader } }, + ); +} + export function authMethods(env: RuntimeEnv, request?: Request): Record { return { github: Boolean(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET), @@ -292,6 +341,12 @@ function parseJson(value: string, fallback: T): T { } } +function clean(value: unknown, maximum: number): string { + return String(value ?? "") + .trim() + .slice(0, maximum); +} + async function deleteSession(db: ReturnType, tokenHash: string): Promise { await db.deleteFrom("sessions").where("token_hash", "=", tokenHash).execute(); } diff --git a/src/worker/routes/auth.ts b/src/worker/routes/auth.ts new file mode 100644 index 0000000..090c5ed --- /dev/null +++ b/src/worker/routes/auth.ts @@ -0,0 +1,58 @@ +import type { TrustedProxyAuthResult } from "../../trusted-proxy-auth.ts"; +import type { User } from "../models.ts"; + +export type PublicAuthRouteDependencies = { + githubLogin(request: Request): Promise; + githubCallback(request: Request): Promise; + sshLink(request: Request, code: string, requestAuth: TrustedProxyAuthResult): Promise; + tokenLogin(request: Request): Promise; + devIdentityLogin(request: Request): Promise; + logout(request: Request): Promise; + authState(request: Request): Response; +}; + +export type SessionAuthRouteDependencies = { + sessionState(request: Request, user: User): Response; +}; + +export async function handlePublicAuthRoute( + request: Request, + url: URL, + requestAuth: TrustedProxyAuthResult, + dependencies: PublicAuthRouteDependencies, +): Promise { + if (url.pathname === "/login/github") { + return dependencies.githubLogin(request); + } + if (url.pathname === "/auth/github/callback") { + return dependencies.githubCallback(request); + } + const sshLinkMatch = url.pathname.match(/^\/ssh\/link\/([^/]+)$/); + if (sshLinkMatch && (request.method === "GET" || request.method === "POST")) { + return dependencies.sshLink(request, decodeURIComponent(sshLinkMatch[1] ?? ""), requestAuth); + } + if (request.method === "POST" && url.pathname === "/api/login/token") { + return dependencies.tokenLogin(request); + } + if (request.method === "POST" && url.pathname === "/api/login/dev") { + return dependencies.devIdentityLogin(request); + } + if (request.method === "POST" && url.pathname === "/api/logout") { + return dependencies.logout(request); + } + if (request.method === "GET" && url.pathname === "/api/auth") { + return dependencies.authState(request); + } + return null; +} + +export function handleSessionAuthRoute( + request: Request, + url: URL, + user: User, + dependencies: SessionAuthRouteDependencies, +): Response | null { + return request.method === "GET" && url.pathname === "/api/session" + ? dependencies.sessionState(request, user) + : null; +} diff --git a/tests/auth-routes.test.ts b/tests/auth-routes.test.ts new file mode 100644 index 0000000..ffb1988 --- /dev/null +++ b/tests/auth-routes.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { TrustedProxyAuthResult } from "../src/trusted-proxy-auth.ts"; +import { + handlePublicAuthRoute, + handleSessionAuthRoute, + type PublicAuthRouteDependencies, +} from "../src/worker/routes/auth.ts"; +import type { User } from "../src/worker/models.ts"; + +const requestAuth: TrustedProxyAuthResult = { kind: "disabled" }; + +function response(name: string): Response { + return new Response(name, { headers: { "x-handler": name } }); +} + +function dependencies(calls: string[]): PublicAuthRouteDependencies { + return { + async githubLogin() { + calls.push("github-login"); + return response("github-login"); + }, + async githubCallback() { + calls.push("github-callback"); + return response("github-callback"); + }, + async sshLink(_request, code, auth) { + calls.push(`ssh-link:${code}:${auth.kind}`); + return response("ssh-link"); + }, + async tokenLogin() { + calls.push("token-login"); + return response("token-login"); + }, + async devIdentityLogin() { + calls.push("dev-login"); + return response("dev-login"); + }, + async logout() { + calls.push("logout"); + return response("logout"); + }, + authState() { + calls.push("auth-state"); + return response("auth-state"); + }, + }; +} + +test("public auth routes dispatch exact paths and methods", async () => { + const cases: Array<[string, string, string]> = [ + ["GET", "/login/github", "github-login"], + ["GET", "/auth/github/callback", "github-callback"], + ["POST", "/ssh/link/code%2Fvalue", "ssh-link"], + ["POST", "/api/login/token", "token-login"], + ["POST", "/api/login/dev", "dev-login"], + ["POST", "/api/logout", "logout"], + ["GET", "/api/auth", "auth-state"], + ]; + + for (const [method, path, expected] of cases) { + const calls: string[] = []; + const request = new Request(`https://fleet.example${path}`, { method }); + const result = await handlePublicAuthRoute( + request, + new URL(request.url), + requestAuth, + dependencies(calls), + ); + + assert.equal(result?.headers.get("x-handler"), expected); + assert.equal(calls.length, 1); + if (expected === "ssh-link") { + assert.equal(calls[0], "ssh-link:code/value:disabled"); + } else { + assert.equal(calls[0], expected); + } + } +}); + +test("public auth routes fall through on inexact methods and paths", async () => { + const calls: string[] = []; + const deps = dependencies(calls); + const requests = [ + new Request("https://fleet.example/api/login/token"), + new Request("https://fleet.example/api/auth", { method: "POST" }), + new Request("https://fleet.example/ssh/link/code", { method: "DELETE" }), + new Request("https://fleet.example/api/session"), + ]; + + for (const request of requests) { + assert.equal( + await handlePublicAuthRoute(request, new URL(request.url), requestAuth, deps), + null, + ); + } + assert.deepEqual(calls, []); +}); + +test("authenticated session metadata has one exact route", () => { + const user: User = { + subject: "github:42", + login: "owner", + email: null, + name: "Owner", + role: "owner", + allowed: true, + teams: [], + }; + const calls: string[] = []; + const dependencies = { + sessionState(_request: Request, authenticatedUser: User) { + calls.push(authenticatedUser.subject); + return response("session"); + }, + }; + const get = new Request("https://fleet.example/api/session"); + const post = new Request("https://fleet.example/api/session", { method: "POST" }); + + assert.equal( + handleSessionAuthRoute(get, new URL(get.url), user, dependencies)?.headers.get("x-handler"), + "session", + ); + assert.equal(handleSessionAuthRoute(post, new URL(post.url), user, dependencies), null); + assert.deepEqual(calls, ["github:42"]); +}); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 850f45a..58f4f70 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -8,12 +8,14 @@ import { authorize, bootstrapSubject, createSession, + devIdentityLogin, devIdentityId, logout, parseRole, requireRole, requireUser, sessionGitHubToken, + tokenLogin, } from "../src/worker/auth.ts"; import { openSecret, sealSecret, sha256 } from "../src/worker/crypto.ts"; import type { RuntimeEnv } from "../src/worker/env.ts"; @@ -180,6 +182,75 @@ test("session creation and logout persist only hashed browser tokens", async () assert.match(writes.at(-1)?.sql ?? "", /^delete from "sessions"/i); }); +test("bootstrap and development login handlers own their validation and sessions", async () => { + const writes: string[] = []; + const env = runtimeEnv( + d1((sql, _parameters, kind) => { + if (kind === "run") writes.push(sql); + return { changes: 1 }; + }), + { + CRABBOX_BOOTSTRAP_TOKEN: "bootstrap", + CRABFLEET_DEV_LOGIN_ENABLED: "true", + }, + ); + const invalid = await tokenLogin( + new Request("https://fleet.example/api/login/token", { + method: "POST", + body: JSON.stringify({ token: "wrong" }), + }), + env, + ); + assert.equal(invalid.status, 401); + assert.equal(writes.length, 0); + + const bootstrap = await tokenLogin( + new Request("https://fleet.example/api/login/token", { + method: "POST", + body: JSON.stringify({ token: "bootstrap" }), + }), + env, + ); + assert.equal(bootstrap.status, 200); + assert.match(bootstrap.headers.get("set-cookie") ?? "", /^crabbox_session=/); + assert.equal( + (await bootstrap.json<{ user: User }>()).user.subject.startsWith("bootstrap:"), + true, + ); + + const development = await devIdentityLogin( + new Request("http://127.0.0.1:8787/api/login/dev", { + method: "POST", + body: JSON.stringify({ id: " Jane Doe ", name: "Jane", role: "maintainer" }), + }), + env, + ); + assert.equal(development.status, 200); + assert.deepEqual((await development.json<{ user: User }>()).user, { + subject: "dev:jane-doe", + login: "jane-doe", + email: null, + name: "Jane", + role: "maintainer", + allowed: true, + teams: [], + }); + assert.ok(writes.some((sql) => /^insert into "users"/i.test(sql))); + assert.ok(writes.some((sql) => /^insert into "sessions"/i.test(sql))); +}); + +test("development login is hidden outside its explicit local gate", async () => { + const response = await devIdentityLogin( + new Request("https://fleet.example/api/login/dev", { + method: "POST", + body: JSON.stringify({ id: "owner" }), + }), + runtimeEnv({} as D1Database, { CRABFLEET_DEV_LOGIN_ENABLED: "true" }), + ); + + assert.equal(response.status, 404); +}); + test("auth policy helpers normalize identities, advertise configured methods, and enforce roles", async () => { assert.equal(devIdentityId(" DEV:Jane Doe "), "jane-doe"); assert.equal(devIdentityId("***"), "dev"); From 19523c03e0d7942819614df666326c95cda93f97 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 16:06:35 +0100 Subject: [PATCH 058/109] refactor: extract OpenClaw routes --- src/index.ts | 347 ++++-------------------------- src/worker/openclaw-controller.ts | 195 +++++++++++++++++ src/worker/routes/openclaw.ts | 137 ++++++++++++ tests/openclaw-controller.test.ts | 184 ++++++++++++++++ tests/openclaw-routes.test.ts | 252 ++++++++++++++++++++++ 5 files changed, 809 insertions(+), 306 deletions(-) create mode 100644 src/worker/openclaw-controller.ts create mode 100644 src/worker/routes/openclaw.ts create mode 100644 tests/openclaw-controller.test.ts create mode 100644 tests/openclaw-routes.test.ts diff --git a/src/index.ts b/src/index.ts index 342415f..ecd351c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,11 +75,7 @@ import { allocateInteractiveSessionIdSql, formatInteractiveSessionId } from "./s import { preferredEnabledRepo } from "./repo-selection"; import { sandboxGitAuthorEmail } from "./git-identity"; import { cachedBooleanGrant } from "./terminal-authorization"; -import { - openClawGitHubRepoParts, - openClawRoomMaxSessions, - openClawServiceAuthorized, -} from "./openclaw-service"; +import { openClawGitHubRepoParts, openClawRoomMaxSessions } from "./openclaw-service"; import { sanitizeTrustedProxyRequest, trustedProxyPublicOrigin, @@ -165,7 +161,6 @@ import { githubCallback, githubLogin, sshLinkCookie } from "./worker/github-auth import { GitHubApiError, githubFetch, githubHeaders, refreshGitHubUser } from "./worker/github"; import { GitHubActionsSessionRegistrationService, - type GitHubActionsSessionRegistrationInput, type GitHubActionsSessionRegistrationStore, } from "./worker/github-actions-session-registration"; import { @@ -190,6 +185,7 @@ import { } from "./worker/session-model"; import { normalizeRepo } from "./worker/repositories"; import { handlePublicAuthRoute, handleSessionAuthRoute } from "./worker/routes/auth"; +import { handleOpenClawRoute } from "./worker/routes/openclaw"; import { isCurrentSandboxLease, newSandboxLease, @@ -222,19 +218,13 @@ import { type OpenClawSupervisionStore, } from "./worker/openclaw-supervision"; import { OpenClawRootStopService, type OpenClawRootStopStore } from "./worker/openclaw-root-stop"; -import { - buildOpenClawTranscript, - openClawSessionSummary, - openClawTranscriptEventWindow, - openClawVisibleRoomSessions, -} from "./worker/openclaw-queries"; import { OpenClawMutationService, type OpenClawMutationStore } from "./worker/openclaw-mutations"; import { OpenClawCreateService, openClawServiceBranch, type OpenClawCreateStore, - type OpenClawCreateInput, } from "./worker/openclaw-create"; +import { OpenClawController, type OpenClawControllerStore } from "./worker/openclaw-controller"; import { InteractiveSessionLineageService, type InteractiveSessionLineageStore, @@ -1704,87 +1694,12 @@ async function api( ); } - if (request.method === "POST" && url.pathname === "/api/openclaw/action-sessions") { - return json(await openClawRegisterActionSession(request, env), { status: 201 }); - } - - if (request.method === "POST" && url.pathname === "/api/openclaw/crabboxes") { - return json(await openClawCreateCrabbox(request, env), { status: 201 }); - } - - const openClawSessionRootMatch = url.pathname.match(/^\/api\/openclaw\/session-roots\/([^/]+)$/); - if (request.method === "GET" && openClawSessionRootMatch) { - return json( - await openClawReadSessionRoot( - request, - env, - decodeURIComponent(openClawSessionRootMatch[1] ?? ""), - ), - ); - } - - const openClawSessionRootActionMatch = url.pathname.match( - /^\/api\/openclaw\/session-roots\/([^/]+)\/actions$/, - ); - if (request.method === "POST" && openClawSessionRootActionMatch) { - return json( - await openClawMutateSessionRoot( - request, - env, - decodeURIComponent(openClawSessionRootActionMatch[1] ?? ""), - ), - ); - } - - const openClawCrabboxTranscriptMatch = url.pathname.match( - /^\/api\/openclaw\/crabboxes\/([^/]+)\/transcript$/, - ); - if (request.method === "GET" && openClawCrabboxTranscriptMatch) { - return json( - await openClawReadCrabboxTranscript( - request, - env, - decodeURIComponent(openClawCrabboxTranscriptMatch[1] ?? ""), - ), - ); - } - - const openClawCrabboxMessageMatch = url.pathname.match( - /^\/api\/openclaw\/crabboxes\/([^/]+)\/message$/, - ); - if (request.method === "POST" && openClawCrabboxMessageMatch) { - return json( - await openClawMessageCrabbox( - request, - env, - decodeURIComponent(openClawCrabboxMessageMatch[1] ?? ""), - ), - ); - } - - const openClawCrabboxActionMatch = url.pathname.match( - /^\/api\/openclaw\/crabboxes\/([^/]+)\/actions$/, - ); - if (request.method === "POST" && openClawCrabboxActionMatch) { - return json( - await openClawMutateCrabbox( - request, - env, - decodeURIComponent(openClawCrabboxActionMatch[1] ?? ""), - ), - ); - } - - const openClawCrabboxReadMatch = url.pathname.match(/^\/api\/openclaw\/crabboxes\/([^/]+)$/); - if (request.method === "GET" && openClawCrabboxReadMatch) { - return json( - await openClawReadCrabbox( - request, - env, - decodeURIComponent(openClawCrabboxReadMatch[1] ?? ""), - ), - ); - } + const openClawResponse = await handleOpenClawRoute(request, url, { + controller: openClawController(env), + automationTokens: [env.CRABBOX_OPENCLAW_TOKEN], + roomTokens: [env.CRABBOX_OPENCLAW_TOKEN, env.CRABBOX_MULTICODEX_TOKEN], + }); + if (openClawResponse) return openClawResponse; const sharedSessionMatch = url.pathname.match(/^\/api\/shared-sessions\/([^/]+)$/); if (request.method === "GET" && sharedSessionMatch) { @@ -2347,149 +2262,6 @@ async function agentCreateInteractiveSession( return result; } -async function openClawCreateCrabbox( - request: Request, - env: RuntimeEnv, -): Promise<{ session: InteractiveSession; browserUrl: string }> { - requireOpenClawRoomService(request, env); - const body = await readJson(request); - const serviceUser = openClawServiceUser(); - const session = await openClawCreateService(env, serviceUser).create(body); - return openClawDecoratedCrabboxResponse(env, session); -} - -async function openClawReadSessionRoot( - request: Request, - env: RuntimeEnv, - rootSessionId: string, -): Promise<{ - rootSessionId: string; - crabboxes: Array<{ session: InteractiveSession; browserUrl: string }>; -}> { - requireOpenClawRoomService(request, env); - const root = clean(rootSessionId, 120); - if (!root) throw badRequest("root session id is required"); - const rootSession = await readOpenClawRoomRoot(env, root); - const room = await readOpenClawRoomSessions(env, root, openClawRoomMaxSessions); - const sessions = openClawVisibleRoomSessions(root, rootSession, room); - const serviceUser = openClawServiceUser(); - return { - rootSessionId: root, - crabboxes: sessions.map((session) => openClawCrabboxSummaryResponse(env, serviceUser, session)), - }; -} - -async function openClawMutateSessionRoot( - request: Request, - env: RuntimeEnv, - rootSessionId: string, -): Promise<{ - rootSessionId: string; - admissionClosed: true; - crabboxes: Array<{ session: InteractiveSession; browserUrl: string }>; -}> { - requireOpenClawRoomService(request, env); - const body = await readJson<{ action?: string }>(request); - if (body.action !== "stop") throw badRequest("only stop is supported"); - const root = clean(rootSessionId, 120); - if (!root) throw badRequest("root session id is required"); - const serviceUser = openClawServiceUser(); - const result = await openClawRootStopService(request, env, serviceUser).stop(root); - return { - rootSessionId: result.rootSessionId, - admissionClosed: true, - crabboxes: result.sessions.map((session) => - openClawCrabboxSummaryResponse(env, serviceUser, session), - ), - }; -} - -async function openClawReadCrabbox( - request: Request, - env: RuntimeEnv, - id: string, -): Promise<{ session: InteractiveSession; browserUrl: string }> { - requireOpenClawRoomService(request, env); - const session = await openClawRootScopedCrabbox(request, env, id); - return openClawCrabboxSummaryResponse(env, openClawServiceUser(), session); -} - -async function openClawReadCrabboxTranscript( - request: Request, - env: RuntimeEnv, - id: string, -): Promise<{ - session: InteractiveSession; - browserUrl: string; - transcript: string; - eventCount: number; - truncated: boolean; -}> { - requireOpenClawRoomService(request, env); - const session = await openClawRootScopedCrabbox(request, env, id); - const [eventWindow, eventCount] = await Promise.all([ - readInteractiveSessionEventRows(env, id, { - limit: openClawTranscriptEventWindow, - newest: true, - }), - countInteractiveSessionEvents(env, id), - ]); - const transcript = buildOpenClawTranscript(eventWindow, eventCount, (events) => - sessionLogTranscript(session, events), - ); - const response = openClawCrabboxSummaryResponse(env, openClawServiceUser(), session); - return { - ...response, - ...transcript, - }; -} - -async function openClawMessageCrabbox( - request: Request, - env: RuntimeEnv, - id: string, -): Promise<{ delivered: true; session: InteractiveSession; browserUrl: string }> { - requireOpenClawRoomService(request, env); - const body = await readJson<{ rootSessionId?: string; message?: string; enter?: boolean }>( - request, - ); - const session = await openClawRootScopedCrabbox(request, env, id, body.rootSessionId); - const serviceUser = openClawServiceUser(); - await openClawMutationService(request, env, serviceUser).sendMessage(session, body); - return { - delivered: true, - ...openClawCrabboxSummaryResponse(env, serviceUser, session), - }; -} - -async function openClawMutateCrabbox( - request: Request, - env: RuntimeEnv, - id: string, -): Promise<{ session: InteractiveSession; browserUrl: string }> { - requireOpenClawRoomService(request, env); - const body = await readJson<{ rootSessionId?: string; action?: string }>(request); - await openClawRootScopedCrabbox(request, env, id, body.rootSessionId); - if (body.action !== "stop") throw badRequest("only stop is supported"); - const serviceUser = openClawServiceUser(); - const session = await openClawMutationService(request, env, serviceUser).stopSession(id); - return openClawCrabboxSummaryResponse(env, serviceUser, session); -} - -async function openClawRootScopedCrabbox( - request: Request, - env: RuntimeEnv, - id: string, - bodyRootSessionId?: string, -): Promise { - const rootSessionId = clean( - bodyRootSessionId ?? request.headers.get("x-crabfleet-root-session-id"), - 120, - ); - if (!rootSessionId) throw badRequest("root session id is required"); - return openClawSupervision(env).requireRootScopedSession(id, rootSessionId); -} - async function cleanupAbandonedInteractiveSessionPreparations( env: RuntimeEnv, now: number, @@ -2608,48 +2380,41 @@ function openClawCreateService(env: RuntimeEnv, serviceUser: User): OpenClawCrea return new OpenClawCreateService(store); } -function openClawCrabboxResponse( - env: RuntimeEnv, - serviceUser: User, - session: InteractiveSession, -): { session: InteractiveSession; browserUrl: string } { - return openClawDecoratedCrabboxResponse( - env, - decorateInteractiveSession(session, serviceUser, env), - ); -} - -function openClawCrabboxSummaryResponse( - env: RuntimeEnv, - serviceUser: User, - session: InteractiveSession, -): { session: InteractiveSession; browserUrl: string } { - const response = openClawCrabboxResponse(env, serviceUser, session); - return { ...response, session: openClawSessionSummary(response.session) }; -} - -function openClawDecoratedCrabboxResponse( - env: RuntimeEnv, - session: InteractiveSession, -): { session: InteractiveSession; browserUrl: string } { - return { - session, - browserUrl: browserSessionUrl(env, session.id), +function openClawController(env: RuntimeEnv): OpenClawController { + const serviceUser = openClawServiceUser(); + const store: OpenClawControllerStore = { + createCrabbox: (input) => openClawCreateService(env, serviceUser).create(input), + readRoomRoot: (rootSessionId) => readOpenClawRoomRoot(env, rootSessionId), + readRoomSessions: (rootSessionId) => + readOpenClawRoomSessions(env, rootSessionId, openClawRoomMaxSessions), + stopSessionRoot: (request, rootSessionId) => + openClawRootStopService(request, env, serviceUser).stop(rootSessionId), + requireRootScopedSession: (sessionId, rootSessionId) => + openClawSupervision(env).requireRootScopedSession(sessionId, rootSessionId), + readTranscriptEvents: (sessionId, maximumEvents) => + readInteractiveSessionEventRows(env, sessionId, { + limit: maximumEvents, + newest: true, + }), + countTranscriptEvents: (sessionId) => countInteractiveSessionEvents(env, sessionId), + sendMessage: (request, session, input) => + openClawMutationService(request, env, serviceUser).sendMessage(session, input), + stopSession: (request, sessionId) => + openClawMutationService(request, env, serviceUser).stopSession(sessionId), + registerActionSession: (input) => + openClawActionSessionRegistrationService(env, serviceUser).register(input), + decorateSession: (session) => decorateInteractiveSession(session, serviceUser, env), + browserUrl: (sessionId) => browserSessionUrl(env, sessionId), + runnerPtyUrl: (sessionId, agentToken) => + buildGitHubActionsRunnerPtyUrl(appCanonicalOrigin, sessionId, agentToken), }; + return new OpenClawController(store); } -async function openClawRegisterActionSession( - request: Request, +function openClawActionSessionRegistrationService( env: RuntimeEnv, -): Promise<{ - session: InteractiveSession; - agentToken: string; - runnerPtyUrl: string; - browserUrl: string; -}> { - requireOpenClawAutomationService(request, env); - const body = await readJson(request); - const serviceUser = openClawServiceUser(); + serviceUser: User, +): GitHubActionsSessionRegistrationService { const db = database(env); const store: GitHubActionsSessionRegistrationStore = { now: () => Date.now(), @@ -2682,17 +2447,7 @@ async function openClawRegisterActionSession( audit: (message, now) => audit(env, serviceUser, message, now), readSession: (id) => readInteractiveSession(env, id), }; - const result = await new GitHubActionsSessionRegistrationService(store).register(body); - return { - session: decorateInteractiveSession(result.session, serviceUser, env), - agentToken: result.agentToken, - runnerPtyUrl: buildGitHubActionsRunnerPtyUrl( - appCanonicalOrigin, - result.session.id, - result.agentToken, - ), - browserUrl: browserSessionUrl(env, result.session.id), - }; + return new GitHubActionsSessionRegistrationService(store); } function openClawServiceUser(): User { @@ -2707,26 +2462,6 @@ function openClawServiceUser(): User { }; } -function requireOpenClawAutomationService(request: Request, env: RuntimeEnv): void { - requireOpenClawServiceToken(request, [env.CRABBOX_OPENCLAW_TOKEN]); -} - -function requireOpenClawRoomService(request: Request, env: RuntimeEnv): void { - requireOpenClawServiceToken(request, [env.CRABBOX_OPENCLAW_TOKEN, env.CRABBOX_MULTICODEX_TOKEN]); -} - -function requireOpenClawServiceToken( - request: Request, - tokens: Array, -): void { - if (!tokens.some(Boolean)) { - throw serviceUnavailable("OpenClaw service token is not configured"); - } - if (!openClawServiceAuthorized(request.headers.get("authorization"), tokens)) { - throw unauthorized(); - } -} - async function ensureOpenClawServiceBranch( env: RuntimeEnv, repoInput: unknown, diff --git a/src/worker/openclaw-controller.ts b/src/worker/openclaw-controller.ts new file mode 100644 index 0000000..6326785 --- /dev/null +++ b/src/worker/openclaw-controller.ts @@ -0,0 +1,195 @@ +import type { + GitHubActionsSessionRegistration, + GitHubActionsSessionRegistrationInput, +} from "./github-actions-session-registration.ts"; +import { badRequest } from "./http.ts"; +import type { OpenClawCreateInput } from "./openclaw-create.ts"; +import { + buildOpenClawTranscript, + openClawSessionSummary, + openClawTranscriptEventWindow, + openClawVisibleRoomSessions, + type OpenClawRoomRead, +} from "./openclaw-queries.ts"; +import type { OpenClawRootStopResult } from "./openclaw-root-stop.ts"; +import { sessionLogTranscript } from "./session-log-archive.ts"; +import type { InteractiveSession, InteractiveSessionEventRow } from "./session-model.ts"; + +export type OpenClawCrabboxResponse = { + session: InteractiveSession; + browserUrl: string; +}; + +export type OpenClawMessageInput = { + message?: unknown; + enter?: unknown; +}; + +export type OpenClawControllerStore = { + createCrabbox(input: OpenClawCreateInput): Promise; + readRoomRoot(rootSessionId: string): Promise; + readRoomSessions(rootSessionId: string): Promise; + stopSessionRoot(request: Request, rootSessionId: string): Promise; + requireRootScopedSession(sessionId: string, rootSessionId: string): Promise; + readTranscriptEvents( + sessionId: string, + maximumEvents: number, + ): Promise; + countTranscriptEvents(sessionId: string): Promise; + sendMessage( + request: Request, + session: InteractiveSession, + input: OpenClawMessageInput, + ): Promise; + stopSession(request: Request, sessionId: string): Promise; + registerActionSession( + input: GitHubActionsSessionRegistrationInput, + ): Promise; + decorateSession(session: InteractiveSession): InteractiveSession; + browserUrl(sessionId: string): string; + runnerPtyUrl(sessionId: string, agentToken: string): string; +}; + +export class OpenClawController { + private readonly store: OpenClawControllerStore; + + constructor(store: OpenClawControllerStore) { + this.store = store; + } + + async createCrabbox(input: OpenClawCreateInput): Promise { + const session = await this.store.createCrabbox(input); + return this.crabboxResponse(session); + } + + async readSessionRoot(rootSessionId: string): Promise<{ + rootSessionId: string; + crabboxes: OpenClawCrabboxResponse[]; + }> { + const root = requiredIdentifier(rootSessionId, "root session id"); + const [rootSession, room] = await Promise.all([ + this.store.readRoomRoot(root), + this.store.readRoomSessions(root), + ]); + const sessions = openClawVisibleRoomSessions(root, rootSession, room); + return { + rootSessionId: root, + crabboxes: sessions.map((session) => this.crabboxSummaryResponse(session)), + }; + } + + async stopSessionRoot( + request: Request, + rootSessionId: string, + ): Promise<{ + rootSessionId: string; + admissionClosed: true; + crabboxes: OpenClawCrabboxResponse[]; + }> { + const root = requiredIdentifier(rootSessionId, "root session id"); + const result = await this.store.stopSessionRoot(request, root); + return { + rootSessionId: result.rootSessionId, + admissionClosed: true, + crabboxes: result.sessions.map((session) => this.crabboxSummaryResponse(session)), + }; + } + + async readCrabbox(sessionId: string, rootSessionId: string): Promise { + const session = await this.rootScopedSession(sessionId, rootSessionId); + return this.crabboxSummaryResponse(session); + } + + async readCrabboxTranscript( + sessionId: string, + rootSessionId: string, + ): Promise< + OpenClawCrabboxResponse & { + transcript: string; + eventCount: number; + truncated: boolean; + } + > { + const session = await this.rootScopedSession(sessionId, rootSessionId); + const [eventWindow, eventCount] = await Promise.all([ + this.store.readTranscriptEvents(session.id, openClawTranscriptEventWindow), + this.store.countTranscriptEvents(session.id), + ]); + const transcript = buildOpenClawTranscript(eventWindow, eventCount, (events) => + sessionLogTranscript(session, events), + ); + return { + ...this.crabboxSummaryResponse(session), + ...transcript, + }; + } + + async messageCrabbox( + request: Request, + sessionId: string, + rootSessionId: string, + input: OpenClawMessageInput, + ): Promise { + const session = await this.rootScopedSession(sessionId, rootSessionId); + await this.store.sendMessage(request, session, input); + return { + delivered: true, + ...this.crabboxSummaryResponse(session), + }; + } + + async stopCrabbox( + request: Request, + sessionId: string, + rootSessionId: string, + ): Promise { + await this.rootScopedSession(sessionId, rootSessionId); + const session = await this.store.stopSession(request, sessionId); + return this.crabboxSummaryResponse(session); + } + + async registerActionSession(input: GitHubActionsSessionRegistrationInput): Promise<{ + session: InteractiveSession; + agentToken: string; + runnerPtyUrl: string; + browserUrl: string; + }> { + const result = await this.store.registerActionSession(input); + return { + session: this.store.decorateSession(result.session), + agentToken: result.agentToken, + runnerPtyUrl: this.store.runnerPtyUrl(result.session.id, result.agentToken), + browserUrl: this.store.browserUrl(result.session.id), + }; + } + + private async rootScopedSession( + sessionId: string, + rootSessionId: string, + ): Promise { + return this.store.requireRootScopedSession( + requiredIdentifier(sessionId, "session id"), + requiredIdentifier(rootSessionId, "root session id"), + ); + } + + private crabboxSummaryResponse(session: InteractiveSession): OpenClawCrabboxResponse { + const response = this.crabboxResponse(this.store.decorateSession(session)); + return { ...response, session: openClawSessionSummary(response.session) }; + } + + private crabboxResponse(session: InteractiveSession): OpenClawCrabboxResponse { + return { + session, + browserUrl: this.store.browserUrl(session.id), + }; + } +} + +function requiredIdentifier(value: unknown, name: string): string { + const identifier = String(value ?? "") + .trim() + .slice(0, 120); + if (!identifier) throw badRequest(`${name} is required`); + return identifier; +} diff --git a/src/worker/routes/openclaw.ts b/src/worker/routes/openclaw.ts new file mode 100644 index 0000000..745060e --- /dev/null +++ b/src/worker/routes/openclaw.ts @@ -0,0 +1,137 @@ +import { openClawServiceAuthorized } from "../../openclaw-service.ts"; +import type { GitHubActionsSessionRegistrationInput } from "../github-actions-session-registration.ts"; +import { badRequest, json, readJson, serviceUnavailable, unauthorized } from "../http.ts"; +import type { OpenClawController, OpenClawMessageInput } from "../openclaw-controller.ts"; +import type { OpenClawCreateInput } from "../openclaw-create.ts"; + +export type OpenClawRouteDependencies = { + controller: OpenClawController; + automationTokens: Array; + roomTokens: Array; +}; + +export async function handleOpenClawRoute( + request: Request, + url: URL, + dependencies: OpenClawRouteDependencies, +): Promise { + if (request.method === "POST" && url.pathname === "/api/openclaw/action-sessions") { + requireServiceToken(request, dependencies.automationTokens); + const body = await readJson(request); + return json(await dependencies.controller.registerActionSession(body), { status: 201 }); + } + + if (request.method === "POST" && url.pathname === "/api/openclaw/crabboxes") { + requireServiceToken(request, dependencies.roomTokens); + const body = await readJson(request); + return json(await dependencies.controller.createCrabbox(body), { status: 201 }); + } + + const sessionRootMatch = url.pathname.match(/^\/api\/openclaw\/session-roots\/([^/]+)$/); + if (request.method === "GET" && sessionRootMatch) { + requireServiceToken(request, dependencies.roomTokens); + return json( + await dependencies.controller.readSessionRoot(decodedIdentifier(sessionRootMatch[1])), + ); + } + + const sessionRootActionMatch = url.pathname.match( + /^\/api\/openclaw\/session-roots\/([^/]+)\/actions$/, + ); + if (request.method === "POST" && sessionRootActionMatch) { + requireServiceToken(request, dependencies.roomTokens); + const body = await readJson<{ action?: unknown }>(request); + requireStopAction(body.action); + return json( + await dependencies.controller.stopSessionRoot( + request, + decodedIdentifier(sessionRootActionMatch[1]), + ), + ); + } + + const crabboxTranscriptMatch = url.pathname.match( + /^\/api\/openclaw\/crabboxes\/([^/]+)\/transcript$/, + ); + if (request.method === "GET" && crabboxTranscriptMatch) { + requireServiceToken(request, dependencies.roomTokens); + return json( + await dependencies.controller.readCrabboxTranscript( + decodedIdentifier(crabboxTranscriptMatch[1]), + requiredRootSessionId(request), + ), + ); + } + + const crabboxMessageMatch = url.pathname.match(/^\/api\/openclaw\/crabboxes\/([^/]+)\/message$/); + if (request.method === "POST" && crabboxMessageMatch) { + requireServiceToken(request, dependencies.roomTokens); + const body = await readJson< + OpenClawMessageInput & { + rootSessionId?: unknown; + } + >(request); + return json( + await dependencies.controller.messageCrabbox( + request, + decodedIdentifier(crabboxMessageMatch[1]), + requiredRootSessionId(request, body.rootSessionId), + body, + ), + ); + } + + const crabboxActionMatch = url.pathname.match(/^\/api\/openclaw\/crabboxes\/([^/]+)\/actions$/); + if (request.method === "POST" && crabboxActionMatch) { + requireServiceToken(request, dependencies.roomTokens); + const body = await readJson<{ rootSessionId?: unknown; action?: unknown }>(request); + requireStopAction(body.action); + return json( + await dependencies.controller.stopCrabbox( + request, + decodedIdentifier(crabboxActionMatch[1]), + requiredRootSessionId(request, body.rootSessionId), + ), + ); + } + + const crabboxReadMatch = url.pathname.match(/^\/api\/openclaw\/crabboxes\/([^/]+)$/); + if (request.method === "GET" && crabboxReadMatch) { + requireServiceToken(request, dependencies.roomTokens); + return json( + await dependencies.controller.readCrabbox( + decodedIdentifier(crabboxReadMatch[1]), + requiredRootSessionId(request), + ), + ); + } + + return null; +} + +function requireServiceToken(request: Request, tokens: Array): void { + if (!tokens.some(Boolean)) { + throw serviceUnavailable("OpenClaw service token is not configured"); + } + if (!openClawServiceAuthorized(request.headers.get("authorization"), tokens)) { + throw unauthorized(); + } +} + +function requiredRootSessionId(request: Request, bodyValue?: unknown): string { + const rootSessionId = String( + bodyValue ?? request.headers.get("x-crabfleet-root-session-id") ?? "", + ) + .trim() + .slice(0, 120); + if (!rootSessionId) throw badRequest("root session id is required"); + return rootSessionId; +} + +function requireStopAction(action: unknown): void { + if (action !== "stop") throw badRequest("only stop is supported"); +} + +function decodedIdentifier(value: string | undefined): string { + return decodeURIComponent(value ?? ""); +} diff --git a/tests/openclaw-controller.test.ts b/tests/openclaw-controller.test.ts new file mode 100644 index 0000000..9ae773a --- /dev/null +++ b/tests/openclaw-controller.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + OpenClawController, + type OpenClawControllerStore, +} from "../src/worker/openclaw-controller.ts"; +import { interactiveSession, type InteractiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function session( + id: string, + values: Parameters[0] = {}, + logs: string[] = [], +): InteractiveSession { + return interactiveSession(sessionRow({ id, ...values }), logs); +} + +function rootSession(id = "IS-1"): InteractiveSession { + return session(id, { + root_session_id: id, + created_by: "service:openclaw", + }); +} + +function controllerStore( + overrides: Partial = {}, +): OpenClawControllerStore { + const root = rootSession(); + return { + createCrabbox: async () => session("IS-created"), + readRoomRoot: async () => root, + readRoomSessions: async () => ({ sessions: [root], overflow: false }), + stopSessionRoot: async (_request, rootSessionId) => ({ + rootSessionId, + sessions: [root], + }), + requireRootScopedSession: async (sessionId) => session(sessionId), + readTranscriptEvents: async (sessionId) => [ + { + id: 1, + session_id: sessionId, + actor: "openclaw", + message: "continued", + created_at: 10, + }, + ], + countTranscriptEvents: async () => 1, + sendMessage: async () => undefined, + stopSession: async (_request, sessionId) => session(sessionId, { status: "stopped" }), + registerActionSession: async () => ({ + session: session("IS-action", { runtime: "github_actions" }), + agentToken: "agent-token", + resumed: false, + workKey: "work-1", + }), + decorateSession: (value) => ({ ...value, summary: `decorated:${value.summary}` }), + browserUrl: (sessionId) => `https://fleet.example/sessions/${sessionId}`, + runnerPtyUrl: (sessionId, token) => `wss://fleet.example/actions/${sessionId}?token=${token}`, + ...overrides, + }; +} + +test("OpenClaw controller presents creation and action registration results", async () => { + const created = session("IS-created", {}, ["full log"]); + const controller = new OpenClawController( + controllerStore({ + createCrabbox: async (input) => { + assert.equal(input.owner, "maintainer"); + return created; + }, + }), + ); + + const createResponse = await controller.createCrabbox({ owner: "maintainer" }); + assert.equal(createResponse.session, created); + assert.deepEqual(createResponse.session.logs, ["full log"]); + assert.equal(createResponse.browserUrl, "https://fleet.example/sessions/IS-created"); + + const registration = await controller.registerActionSession({ + workKey: "work-1", + workKind: "review", + repo: "openclaw/crabfleet", + }); + assert.equal(registration.session.summary, "decorated:Working"); + assert.equal(registration.agentToken, "agent-token"); + assert.equal( + registration.runnerPtyUrl, + "wss://fleet.example/actions/IS-action?token=agent-token", + ); + assert.equal(registration.browserUrl, "https://fleet.example/sessions/IS-action"); +}); + +test("OpenClaw controller validates room roots and builds bounded transcript responses", async () => { + const root = rootSession(); + const child = session( + "IS-2", + { + parent_session_id: root.id, + root_session_id: root.id, + created_by: `session:${root.id}`, + }, + ["sensitive log"], + ); + const calls: string[] = []; + const controller = new OpenClawController( + controllerStore({ + readRoomRoot: async (rootSessionId) => { + calls.push(`root:${rootSessionId}`); + return root; + }, + readRoomSessions: async (rootSessionId) => { + calls.push(`room:${rootSessionId}`); + return { sessions: [root, child], overflow: false }; + }, + requireRootScopedSession: async (sessionId, rootSessionId) => { + calls.push(`scope:${sessionId}:${rootSessionId}`); + return child; + }, + }), + ); + + const room = await controller.readSessionRoot(" IS-1 "); + assert.deepEqual( + room.crabboxes.map((crabbox) => crabbox.session.id), + ["IS-1", "IS-2"], + ); + assert.ok(room.crabboxes.every((crabbox) => crabbox.session.logs.length === 0)); + + const transcript = await controller.readCrabboxTranscript("IS-2", "IS-1"); + assert.equal(transcript.session.id, "IS-2"); + assert.deepEqual(transcript.session.logs, []); + assert.match(transcript.transcript, /continued/); + assert.equal(transcript.eventCount, 1); + assert.equal(transcript.truncated, false); + assert.deepEqual(calls, ["root:IS-1", "room:IS-1", "scope:IS-2:IS-1"]); +}); + +test("OpenClaw controller fences mutations by root before delivery or stop", async () => { + const calls: string[] = []; + const child = session("IS-2", { + parent_session_id: "IS-1", + root_session_id: "IS-1", + created_by: "session:IS-1", + }); + const controller = new OpenClawController( + controllerStore({ + requireRootScopedSession: async (sessionId, rootSessionId) => { + calls.push(`scope:${sessionId}:${rootSessionId}`); + return child; + }, + sendMessage: async (_request, value, input) => { + calls.push(`message:${value.id}:${String(input.message)}`); + }, + stopSession: async (_request, sessionId) => { + calls.push(`stop:${sessionId}`); + return { ...child, status: "stopped" }; + }, + stopSessionRoot: async (_request, rootSessionId) => { + calls.push(`stop-root:${rootSessionId}`); + return { rootSessionId, sessions: [child] }; + }, + }), + ); + const request = new Request("https://fleet.example/api/openclaw/crabboxes/IS-2/message"); + + const message = await controller.messageCrabbox(request, "IS-2", "IS-1", { + message: "continue", + }); + assert.equal(message.delivered, true); + + const stopped = await controller.stopCrabbox(request, "IS-2", "IS-1"); + assert.equal(stopped.session.status, "stopped"); + + const stoppedRoot = await controller.stopSessionRoot(request, "IS-1"); + assert.equal(stoppedRoot.admissionClosed, true); + assert.deepEqual(calls, [ + "scope:IS-2:IS-1", + "message:IS-2:continue", + "scope:IS-2:IS-1", + "stop:IS-2", + "stop-root:IS-1", + ]); +}); diff --git a/tests/openclaw-routes.test.ts b/tests/openclaw-routes.test.ts new file mode 100644 index 0000000..c53b2ad --- /dev/null +++ b/tests/openclaw-routes.test.ts @@ -0,0 +1,252 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + OpenClawController, + type OpenClawControllerStore, +} from "../src/worker/openclaw-controller.ts"; +import { handleOpenClawRoute } from "../src/worker/routes/openclaw.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +function status(error: unknown): number | undefined { + return typeof error === "object" && error !== null && "status" in error + ? Number(error.status) + : undefined; +} + +function routeController(calls: string[]): OpenClawController { + const root = interactiveSession( + sessionRow({ + id: "IS-1", + root_session_id: "IS-1", + created_by: "service:openclaw", + }), + [], + ); + const child = interactiveSession( + sessionRow({ + id: "IS/2", + parent_session_id: "IS-1", + root_session_id: "IS-1", + created_by: "session:IS-1", + }), + [], + ); + const store: OpenClawControllerStore = { + createCrabbox: async (input) => { + calls.push(`create:${String(input.owner)}`); + return child; + }, + readRoomRoot: async (rootSessionId) => { + calls.push(`read-root:${rootSessionId}`); + return root; + }, + readRoomSessions: async (rootSessionId) => { + calls.push(`read-room:${rootSessionId}`); + return { sessions: [root], overflow: false }; + }, + stopSessionRoot: async (_request, rootSessionId) => { + calls.push(`stop-root:${rootSessionId}`); + return { rootSessionId, sessions: [root] }; + }, + requireRootScopedSession: async (sessionId, rootSessionId) => { + calls.push(`scope:${sessionId}:${rootSessionId}`); + return { ...child, id: sessionId, rootSessionId }; + }, + readTranscriptEvents: async (sessionId) => { + calls.push(`events:${sessionId}`); + return []; + }, + countTranscriptEvents: async (sessionId) => { + calls.push(`event-count:${sessionId}`); + return 0; + }, + sendMessage: async (_request, session, input) => { + calls.push(`message:${session.id}:${String(input.message)}`); + }, + stopSession: async (_request, sessionId) => { + calls.push(`stop:${sessionId}`); + return { ...child, id: sessionId, status: "stopped" }; + }, + registerActionSession: async (input) => { + calls.push(`register:${String(input.workKey)}`); + return { + session: child, + agentToken: "agent-token", + resumed: false, + workKey: String(input.workKey), + }; + }, + decorateSession: (session) => session, + browserUrl: (sessionId) => `https://fleet.example/sessions/${sessionId}`, + runnerPtyUrl: (sessionId) => `wss://fleet.example/actions/${sessionId}`, + }; + return new OpenClawController(store); +} + +function request( + method: string, + path: string, + token: string, + body?: Record, + headers: HeadersInit = {}, +): Request { + return new Request(`https://fleet.example${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body ? { "content-type": "application/json" } : {}), + ...headers, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +} + +async function dispatch( + value: Request, + calls: string[], + tokens: { + automationTokens?: Array; + roomTokens?: Array; + } = {}, +): Promise { + return handleOpenClawRoute(value, new URL(value.url), { + controller: routeController(calls), + automationTokens: tokens.automationTokens ?? ["automation"], + roomTokens: tokens.roomTokens ?? ["room", "multicodex"], + }); +} + +test("OpenClaw routes dispatch every exact service endpoint", async () => { + const cases: Array<{ + request: Request; + expectedStatus: number; + expectedCalls: string[]; + }> = [ + { + request: request("POST", "/api/openclaw/action-sessions", "automation", { + workKey: "work-1", + }), + expectedStatus: 201, + expectedCalls: ["register:work-1"], + }, + { + request: request("POST", "/api/openclaw/crabboxes", "multicodex", { + owner: "maintainer", + }), + expectedStatus: 201, + expectedCalls: ["create:maintainer"], + }, + { + request: request("GET", "/api/openclaw/session-roots/IS-1", "room"), + expectedStatus: 200, + expectedCalls: ["read-root:IS-1", "read-room:IS-1"], + }, + { + request: request("POST", "/api/openclaw/session-roots/IS-1/actions", "room", { + action: "stop", + }), + expectedStatus: 200, + expectedCalls: ["stop-root:IS-1"], + }, + { + request: request("GET", "/api/openclaw/crabboxes/IS%2F2/transcript", "room", undefined, { + "x-crabfleet-root-session-id": "IS-1", + }), + expectedStatus: 200, + expectedCalls: ["scope:IS/2:IS-1", "events:IS/2", "event-count:IS/2"], + }, + { + request: request("POST", "/api/openclaw/crabboxes/IS%2F2/message", "room", { + rootSessionId: "IS-1", + message: "continue", + }), + expectedStatus: 200, + expectedCalls: ["scope:IS/2:IS-1", "message:IS/2:continue"], + }, + { + request: request("POST", "/api/openclaw/crabboxes/IS%2F2/actions", "room", { + rootSessionId: "IS-1", + action: "stop", + }), + expectedStatus: 200, + expectedCalls: ["scope:IS/2:IS-1", "stop:IS/2"], + }, + { + request: request("GET", "/api/openclaw/crabboxes/IS%2F2", "room", undefined, { + "x-crabfleet-root-session-id": "IS-1", + }), + expectedStatus: 200, + expectedCalls: ["scope:IS/2:IS-1"], + }, + ]; + + for (const example of cases) { + const calls: string[] = []; + const response = await dispatch(example.request, calls); + assert.equal(response?.status, example.expectedStatus); + assert.deepEqual(calls, example.expectedCalls); + } +}); + +test("OpenClaw routes preserve token scopes and reject invalid commands before mutation", async () => { + await assert.rejects( + dispatch(request("POST", "/api/openclaw/action-sessions", "room", { workKey: "work-1" }), []), + (error) => { + assert.equal(status(error), 401); + return true; + }, + ); + await assert.rejects( + dispatch(request("GET", "/api/openclaw/crabboxes/IS-2", "room"), []), + (error) => { + assert.equal(status(error), 400); + assert.equal((error as Error).message, "root session id is required"); + return true; + }, + ); + + const invalidActionCalls: string[] = []; + await assert.rejects( + dispatch( + request("POST", "/api/openclaw/crabboxes/IS-2/actions", "room", { + rootSessionId: "IS-1", + action: "restart", + }), + invalidActionCalls, + ), + (error) => { + assert.equal(status(error), 400); + assert.equal((error as Error).message, "only stop is supported"); + return true; + }, + ); + assert.deepEqual(invalidActionCalls, []); + + await assert.rejects( + dispatch(request("POST", "/api/openclaw/crabboxes", "room", { owner: "x" }), [], { + roomTokens: [], + }), + (error) => { + assert.equal(status(error), 503); + return true; + }, + ); +}); + +test("OpenClaw routes fall through on inexact paths and methods", async () => { + const calls: string[] = []; + const requests = [ + request("GET", "/api/openclaw/action-sessions", "automation"), + request("GET", "/api/openclaw/crabboxes", "room"), + request("DELETE", "/api/openclaw/crabboxes/IS-2", "room"), + request("GET", "/api/openclaw/crabboxes/IS-2/unknown", "room"), + request("GET", "/api/openclaw/session-roots/IS-1/actions", "room"), + ]; + + for (const value of requests) { + assert.equal(await dispatch(value, calls), null); + } + assert.deepEqual(calls, []); +}); From 1997c54eb87b8e38af4eebb5e432198ab58d81d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 16:11:40 +0100 Subject: [PATCH 059/109] refactor: unify service session routes --- src/index.ts | 241 ++++------------------ src/worker/routes/service-sessions.ts | 123 +++++++++++ tests/service-session-routes.test.ts | 280 ++++++++++++++++++++++++++ 3 files changed, 444 insertions(+), 200 deletions(-) create mode 100644 src/worker/routes/service-sessions.ts create mode 100644 tests/service-session-routes.test.ts diff --git a/src/index.ts b/src/index.ts index ecd351c..30b0c92 100644 --- a/src/index.ts +++ b/src/index.ts @@ -186,6 +186,10 @@ import { import { normalizeRepo } from "./worker/repositories"; import { handlePublicAuthRoute, handleSessionAuthRoute } from "./worker/routes/auth"; import { handleOpenClawRoute } from "./worker/routes/openclaw"; +import { + handleServiceSessionRoute, + type ServiceSessionRouteDependencies, +} from "./worker/routes/service-sessions"; import { isCurrentSandboxLease, newSandboxLease, @@ -1492,207 +1496,12 @@ async function api( return json(await provisionInteractiveEndpoint(request, env)); } - if (request.method === "POST" && url.pathname === "/api/ssh/auth") { - return json(await sshAuth(request, env)); - } - - if (request.method === "GET" && url.pathname === "/api/ssh/state") { - return json(await sshState(request, env)); - } - - if (request.method === "GET" && url.pathname === "/api/agent/state") { - return json(await agentState(request, env)); - } - - if (request.method === "POST" && url.pathname === "/api/ssh/interactive-sessions") { - return json(await sshCreateInteractiveSession(request, env), { status: 201 }); - } - - if (request.method === "POST" && url.pathname === "/api/agent/interactive-sessions") { - return json(await agentCreateInteractiveSession(request, env), { status: 201 }); - } - - const agentInteractiveWorkStateMatch = url.pathname.match( - /^\/api\/agent\/interactive-sessions\/([^/]+)\/work-state$/, - ); - if (request.method === "POST" && agentInteractiveWorkStateMatch) { - return json( - await updateGitHubActionsWorkState( - request, - env, - decodeURIComponent(agentInteractiveWorkStateMatch[1] ?? ""), - ), - ); - } - - const agentInteractiveRunnerPtyMatch = url.pathname.match( - /^\/api\/agent\/interactive-sessions\/([^/]+)\/runner-pty$/, - ); - if (request.method === "GET" && agentInteractiveRunnerPtyMatch) { - return githubActionsRunnerPty( - request, - env, - decodeURIComponent(agentInteractiveRunnerPtyMatch[1] ?? ""), - ); - } - - const sshInteractiveReadMatch = url.pathname.match(/^\/api\/ssh\/interactive-sessions\/([^/]+)$/); - if (request.method === "GET" && sshInteractiveReadMatch) { - const user = await requireSshGatewayUser(request, env); - requireRole(user, "viewer"); - const session = await readFreshInteractiveSession( - env, - decodeURIComponent(sshInteractiveReadMatch[1] ?? ""), - ); - if (!session) throw notFound("interactive session not found"); - return json({ session: decorateInteractiveSession(session, user, env) }); - } - - const agentInteractiveReadMatch = url.pathname.match( - /^\/api\/agent\/interactive-sessions\/([^/]+)$/, - ); - if (request.method === "GET" && agentInteractiveReadMatch) { - const { user } = await agentSessionAuthentication(env).require(request); - const session = await readFreshInteractiveSession( - env, - decodeURIComponent(agentInteractiveReadMatch[1] ?? ""), - ); - if (!session) throw notFound("interactive session not found"); - return json({ session: decorateInteractiveSession(session, user, env) }); - } - - const sshInteractiveActionMatch = url.pathname.match( - /^\/api\/ssh\/interactive-sessions\/([^/]+)\/actions$/, - ); - if (request.method === "POST" && sshInteractiveActionMatch) { - const user = await requireSshGatewayUser(request, env); - requireRole(user, "viewer"); - const body = await readJson<{ action?: string }>(request); - return json( - await mutateInteractiveSession( - request, - env, - user, - decodeURIComponent(sshInteractiveActionMatch[1] ?? ""), - body.action ?? "", - ), - ); - } - - const sshInteractiveCheckpointsMatch = url.pathname.match( - /^\/api\/ssh\/interactive-sessions\/([^/]+)\/checkpoints$/, - ); - if (sshInteractiveCheckpointsMatch) { - const user = await requireSshGatewayUser(request, env); - requireRole(user, "viewer"); - const id = decodeURIComponent(sshInteractiveCheckpointsMatch[1] ?? ""); - if (request.method === "GET") - return json(await listInteractiveSessionCheckpoints(env, user, id)); - if (request.method === "POST") { - return json(await checkpointInteractiveSession(env, user, id), { status: 201 }); - } - } - - const sshInteractiveRestoreMatch = url.pathname.match( - /^\/api\/ssh\/interactive-sessions\/([^/]+)\/checkpoints\/([^/]+)\/restore$/, - ); - if (request.method === "POST" && sshInteractiveRestoreMatch) { - const user = await requireSshGatewayUser(request, env); - requireRole(user, "viewer"); - return json( - await restoreInteractiveSessionCheckpoint( - env, - user, - decodeURIComponent(sshInteractiveRestoreMatch[1] ?? ""), - decodeURIComponent(sshInteractiveRestoreMatch[2] ?? ""), - ), - ); - } - - const sshInteractiveLogsMatch = url.pathname.match( - /^\/api\/ssh\/interactive-sessions\/([^/]+)\/logs$/, + const serviceSessionResponse = await handleServiceSessionRoute( + request, + url, + serviceSessionRouteDependencies(env), ); - if (request.method === "GET" && sshInteractiveLogsMatch) { - const user = await requireSshGatewayUser(request, env); - requireRole(user, "viewer"); - return json( - await readInteractiveSessionLogBundle( - env, - user, - decodeURIComponent(sshInteractiveLogsMatch[1] ?? ""), - ), - ); - } - - const agentInteractiveLogsMatch = url.pathname.match( - /^\/api\/agent\/interactive-sessions\/([^/]+)\/logs$/, - ); - if (request.method === "GET" && agentInteractiveLogsMatch) { - const { user } = await agentSessionAuthentication(env).require(request); - return json( - await readInteractiveSessionLogBundle( - env, - user, - decodeURIComponent(agentInteractiveLogsMatch[1] ?? ""), - ), - ); - } - - const sshInteractiveTranscriptMatch = url.pathname.match( - /^\/api\/ssh\/interactive-sessions\/([^/]+)\/transcript$/, - ); - if (request.method === "GET" && sshInteractiveTranscriptMatch) { - const user = await requireSshGatewayUser(request, env); - requireRole(user, "viewer"); - return interactiveSessionTranscriptResponse( - env, - user, - decodeURIComponent(sshInteractiveTranscriptMatch[1] ?? ""), - ); - } - - const agentInteractiveTranscriptMatch = url.pathname.match( - /^\/api\/agent\/interactive-sessions\/([^/]+)\/transcript$/, - ); - if (request.method === "GET" && agentInteractiveTranscriptMatch) { - const { user } = await agentSessionAuthentication(env).require(request); - return interactiveSessionTranscriptResponse( - env, - user, - decodeURIComponent(agentInteractiveTranscriptMatch[1] ?? ""), - ); - } - - const sshInteractiveSummaryMatch = url.pathname.match( - /^\/api\/ssh\/interactive-sessions\/([^/]+)\/summary$/, - ); - if (request.method === "POST" && sshInteractiveSummaryMatch) { - const user = await requireSshGatewayUser(request, env); - requireRole(user, "viewer"); - return json( - await updateInteractiveSessionSummary( - request, - env, - user, - decodeURIComponent(sshInteractiveSummaryMatch[1] ?? ""), - ), - ); - } - - const agentInteractiveSummaryMatch = url.pathname.match( - /^\/api\/agent\/interactive-sessions\/([^/]+)\/summary$/, - ); - if (request.method === "POST" && agentInteractiveSummaryMatch) { - const { user } = await agentSessionAuthentication(env).require(request); - return json( - await updateInteractiveSessionSummary( - request, - env, - user, - decodeURIComponent(agentInteractiveSummaryMatch[1] ?? ""), - ), - ); - } + if (serviceSessionResponse) return serviceSessionResponse; const openClawResponse = await handleOpenClawRoute(request, url, { controller: openClawController(env), @@ -2088,6 +1897,38 @@ function sshLinkConfirmHtml( `; } +function serviceSessionRouteDependencies(env: RuntimeEnv): ServiceSessionRouteDependencies { + return { + sshAuth: (request) => sshAuth(request, env), + sshState: (request) => sshState(request, env), + agentState: (request) => agentState(request, env), + createSshSession: (request) => sshCreateInteractiveSession(request, env), + createAgentSession: (request) => agentCreateInteractiveSession(request, env), + updateAgentWorkState: (request, sessionId) => + updateGitHubActionsWorkState(request, env, sessionId), + openAgentRunnerPty: (request, sessionId) => githubActionsRunnerPty(request, env, sessionId), + requireSshViewer: async (request) => { + const user = await requireSshGatewayUser(request, env); + requireRole(user, "viewer"); + return user; + }, + requireAgentUser: async (request) => + (await agentSessionAuthentication(env).require(request)).user, + readFreshSession: (sessionId) => readFreshInteractiveSession(env, sessionId), + presentSession: (session, user) => decorateInteractiveSession(session, user, env), + mutateSession: (request, user, sessionId, action) => + mutateInteractiveSession(request, env, user, sessionId, action), + listCheckpoints: (user, sessionId) => listInteractiveSessionCheckpoints(env, user, sessionId), + createCheckpoint: (user, sessionId) => checkpointInteractiveSession(env, user, sessionId), + restoreCheckpoint: (user, sessionId, checkpointId) => + restoreInteractiveSessionCheckpoint(env, user, sessionId, checkpointId), + readLogs: (user, sessionId) => readInteractiveSessionLogBundle(env, user, sessionId), + readTranscript: (user, sessionId) => interactiveSessionTranscriptResponse(env, user, sessionId), + updateSummary: (request, user, sessionId) => + updateInteractiveSessionSummary(request, env, user, sessionId), + }; +} + async function terminalHubUser( request: Request, env: RuntimeEnv, diff --git a/src/worker/routes/service-sessions.ts b/src/worker/routes/service-sessions.ts new file mode 100644 index 0000000..03b9b1c --- /dev/null +++ b/src/worker/routes/service-sessions.ts @@ -0,0 +1,123 @@ +import { json, notFound, readJson } from "../http.ts"; +import type { User } from "../models.ts"; +import type { InteractiveSession } from "../session-model.ts"; + +type ServiceSessionPrincipal = "ssh" | "agent"; + +export type ServiceSessionRouteDependencies = { + sshAuth(request: Request): Promise; + sshState(request: Request): Promise; + agentState(request: Request): Promise; + createSshSession(request: Request): Promise; + createAgentSession(request: Request): Promise; + updateAgentWorkState(request: Request, sessionId: string): Promise; + openAgentRunnerPty(request: Request, sessionId: string): Promise; + requireSshViewer(request: Request): Promise; + requireAgentUser(request: Request): Promise; + readFreshSession(sessionId: string): Promise; + presentSession(session: InteractiveSession, user: User): InteractiveSession; + mutateSession(request: Request, user: User, sessionId: string, action: string): Promise; + listCheckpoints(user: User, sessionId: string): Promise; + createCheckpoint(user: User, sessionId: string): Promise; + restoreCheckpoint(user: User, sessionId: string, checkpointId: string): Promise; + readLogs(user: User, sessionId: string): Promise; + readTranscript(user: User, sessionId: string): Promise; + updateSummary(request: Request, user: User, sessionId: string): Promise; +}; + +export async function handleServiceSessionRoute( + request: Request, + url: URL, + dependencies: ServiceSessionRouteDependencies, +): Promise { + if (request.method === "POST" && url.pathname === "/api/ssh/auth") { + return json(await dependencies.sshAuth(request)); + } + if (request.method === "GET" && url.pathname === "/api/ssh/state") { + return json(await dependencies.sshState(request)); + } + if (request.method === "GET" && url.pathname === "/api/agent/state") { + return json(await dependencies.agentState(request)); + } + if (request.method === "POST" && url.pathname === "/api/ssh/interactive-sessions") { + return json(await dependencies.createSshSession(request), { status: 201 }); + } + if (request.method === "POST" && url.pathname === "/api/agent/interactive-sessions") { + return json(await dependencies.createAgentSession(request), { status: 201 }); + } + + const match = url.pathname.match( + /^\/api\/(ssh|agent)\/interactive-sessions\/([^/]+)(?:\/(.+))?$/, + ); + if (!match) return null; + const principal = match[1] as ServiceSessionPrincipal; + const sessionId = decoded(match[2]); + const resource = match[3] ?? ""; + + if (principal === "agent" && request.method === "POST" && resource === "work-state") { + return json(await dependencies.updateAgentWorkState(request, sessionId)); + } + if (principal === "agent" && request.method === "GET" && resource === "runner-pty") { + return dependencies.openAgentRunnerPty(request, sessionId); + } + + if (request.method === "GET" && !resource) { + const user = await requirePrincipal(request, principal, dependencies); + const session = await dependencies.readFreshSession(sessionId); + if (!session) throw notFound("interactive session not found"); + return json({ session: dependencies.presentSession(session, user) }); + } + + if (principal === "ssh" && request.method === "POST" && resource === "actions") { + const user = await dependencies.requireSshViewer(request); + const body = await readJson<{ action?: string }>(request); + return json(await dependencies.mutateSession(request, user, sessionId, body.action ?? "")); + } + + if (principal === "ssh" && resource === "checkpoints") { + const user = await dependencies.requireSshViewer(request); + if (request.method === "GET") { + return json(await dependencies.listCheckpoints(user, sessionId)); + } + if (request.method === "POST") { + return json(await dependencies.createCheckpoint(user, sessionId), { status: 201 }); + } + return null; + } + + const restoreMatch = + principal === "ssh" ? resource.match(/^checkpoints\/([^/]+)\/restore$/) : null; + if (request.method === "POST" && restoreMatch) { + const user = await dependencies.requireSshViewer(request); + return json(await dependencies.restoreCheckpoint(user, sessionId, decoded(restoreMatch[1]))); + } + + if (request.method === "GET" && resource === "logs") { + const user = await requirePrincipal(request, principal, dependencies); + return json(await dependencies.readLogs(user, sessionId)); + } + if (request.method === "GET" && resource === "transcript") { + const user = await requirePrincipal(request, principal, dependencies); + return dependencies.readTranscript(user, sessionId); + } + if (request.method === "POST" && resource === "summary") { + const user = await requirePrincipal(request, principal, dependencies); + return json(await dependencies.updateSummary(request, user, sessionId)); + } + + return null; +} + +function requirePrincipal( + request: Request, + principal: ServiceSessionPrincipal, + dependencies: ServiceSessionRouteDependencies, +): Promise { + return principal === "ssh" + ? dependencies.requireSshViewer(request) + : dependencies.requireAgentUser(request); +} + +function decoded(value: string | undefined): string { + return decodeURIComponent(value ?? ""); +} diff --git a/tests/service-session-routes.test.ts b/tests/service-session-routes.test.ts new file mode 100644 index 0000000..69fad65 --- /dev/null +++ b/tests/service-session-routes.test.ts @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { User } from "../src/worker/models.ts"; +import { + handleServiceSessionRoute, + type ServiceSessionRouteDependencies, +} from "../src/worker/routes/service-sessions.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +const sshUser: User = { + subject: "github:1", + login: "ssh-user", + email: null, + name: "SSH User", + role: "viewer", + allowed: true, + teams: [], +}; + +const agentUser: User = { + ...sshUser, + subject: "session:IS-parent", + login: "agent-user", + name: "Agent User", +}; + +function response(name: string): Response { + return new Response(name, { headers: { "x-handler": name } }); +} + +function dependencies(calls: string[]): ServiceSessionRouteDependencies { + return { + async sshAuth() { + calls.push("ssh-auth"); + return { handler: "ssh-auth" }; + }, + async sshState() { + calls.push("ssh-state"); + return { handler: "ssh-state" }; + }, + async agentState() { + calls.push("agent-state"); + return { handler: "agent-state" }; + }, + async createSshSession() { + calls.push("create:ssh"); + return { handler: "create:ssh" }; + }, + async createAgentSession() { + calls.push("create:agent"); + return { handler: "create:agent" }; + }, + async updateAgentWorkState(_request: Request, sessionId: string) { + calls.push(`work-state:${sessionId}`); + return { handler: "work-state" }; + }, + async openAgentRunnerPty(_request: Request, sessionId: string) { + calls.push(`runner-pty:${sessionId}`); + return response("runner-pty"); + }, + async requireSshViewer() { + calls.push("auth:ssh"); + return sshUser; + }, + async requireAgentUser() { + calls.push("auth:agent"); + return agentUser; + }, + async readFreshSession(sessionId: string) { + calls.push(`read:${sessionId}`); + return interactiveSession(sessionRow({ id: sessionId }), []); + }, + presentSession(session: ReturnType, user: User) { + calls.push(`present:${session.id}:${user.login}`); + return { ...session, owner: user.login }; + }, + async mutateSession(_request: Request, user: User, sessionId: string, action: string) { + calls.push(`mutate:${user.login}:${sessionId}:${action}`); + return { handler: "mutate" }; + }, + async listCheckpoints(user: User, sessionId: string) { + calls.push(`checkpoints:list:${user.login}:${sessionId}`); + return { handler: "checkpoints:list" }; + }, + async createCheckpoint(user: User, sessionId: string) { + calls.push(`checkpoints:create:${user.login}:${sessionId}`); + return { handler: "checkpoints:create" }; + }, + async restoreCheckpoint(user: User, sessionId: string, checkpointId: string) { + calls.push(`checkpoints:restore:${user.login}:${sessionId}:${checkpointId}`); + return { handler: "checkpoints:restore" }; + }, + async readLogs(user: User, sessionId: string) { + calls.push(`logs:${user.login}:${sessionId}`); + return { handler: "logs" }; + }, + async readTranscript(user: User, sessionId: string) { + calls.push(`transcript:${user.login}:${sessionId}`); + return response("transcript"); + }, + async updateSummary(_request: Request, user: User, sessionId: string) { + calls.push(`summary:${user.login}:${sessionId}`); + return { handler: "summary" }; + }, + }; +} + +function request(method: string, path: string, body?: Record): Request { + return new Request(`https://fleet.example${path}`, { + method, + headers: body ? { "content-type": "application/json" } : undefined, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +} + +async function dispatch(value: Request, calls: string[]): Promise { + return handleServiceSessionRoute(value, new URL(value.url), dependencies(calls)); +} + +test("service-session routes dispatch collection and agent-specialized endpoints", async () => { + const cases: Array<[Request, number, string[]]> = [ + [request("POST", "/api/ssh/auth", {}), 200, ["ssh-auth"]], + [request("GET", "/api/ssh/state"), 200, ["ssh-state"]], + [request("GET", "/api/agent/state"), 200, ["agent-state"]], + [request("POST", "/api/ssh/interactive-sessions", {}), 201, ["create:ssh"]], + [request("POST", "/api/agent/interactive-sessions", {}), 201, ["create:agent"]], + [ + request("POST", "/api/agent/interactive-sessions/IS%2F2/work-state", {}), + 200, + ["work-state:IS/2"], + ], + ]; + + for (const [value, expectedStatus, expectedCalls] of cases) { + const calls: string[] = []; + const result = await dispatch(value, calls); + assert.equal(result?.status, expectedStatus); + assert.deepEqual(calls, expectedCalls); + } + + const runnerCalls: string[] = []; + const runner = await dispatch( + request("GET", "/api/agent/interactive-sessions/IS%2F2/runner-pty"), + runnerCalls, + ); + assert.equal(runner?.headers.get("x-handler"), "runner-pty"); + assert.deepEqual(runnerCalls, ["runner-pty:IS/2"]); +}); + +test("service-session routes share read, log, transcript, and summary behavior by principal", async () => { + const cases: Array<[Request, string[]]> = [ + [ + request("GET", "/api/ssh/interactive-sessions/IS%2F2"), + ["auth:ssh", "read:IS/2", "present:IS/2:ssh-user"], + ], + [ + request("GET", "/api/agent/interactive-sessions/IS%2F2"), + ["auth:agent", "read:IS/2", "present:IS/2:agent-user"], + ], + [ + request("GET", "/api/ssh/interactive-sessions/IS%2F2/logs"), + ["auth:ssh", "logs:ssh-user:IS/2"], + ], + [ + request("GET", "/api/agent/interactive-sessions/IS%2F2/logs"), + ["auth:agent", "logs:agent-user:IS/2"], + ], + [ + request("POST", "/api/ssh/interactive-sessions/IS%2F2/summary", {}), + ["auth:ssh", "summary:ssh-user:IS/2"], + ], + [ + request("POST", "/api/agent/interactive-sessions/IS%2F2/summary", {}), + ["auth:agent", "summary:agent-user:IS/2"], + ], + ]; + + for (const [value, expectedCalls] of cases) { + const calls: string[] = []; + assert.equal((await dispatch(value, calls))?.status, 200); + assert.deepEqual(calls, expectedCalls); + } + + for (const principal of ["ssh", "agent"] as const) { + const calls: string[] = []; + const result = await dispatch( + request("GET", `/api/${principal}/interactive-sessions/IS%2F2/transcript`), + calls, + ); + assert.equal(result?.headers.get("x-handler"), "transcript"); + assert.deepEqual(calls, [ + `auth:${principal}`, + `transcript:${principal === "ssh" ? "ssh-user" : "agent-user"}:IS/2`, + ]); + } +}); + +test("service-session routes keep actions and checkpoints SSH-only", async () => { + const cases: Array<[Request, number, string[]]> = [ + [ + request("POST", "/api/ssh/interactive-sessions/IS%2F2/actions", { action: "stop" }), + 200, + ["auth:ssh", "mutate:ssh-user:IS/2:stop"], + ], + [ + request("GET", "/api/ssh/interactive-sessions/IS%2F2/checkpoints"), + 200, + ["auth:ssh", "checkpoints:list:ssh-user:IS/2"], + ], + [ + request("POST", "/api/ssh/interactive-sessions/IS%2F2/checkpoints", {}), + 201, + ["auth:ssh", "checkpoints:create:ssh-user:IS/2"], + ], + [ + request( + "POST", + "/api/ssh/interactive-sessions/IS%2F2/checkpoints/checkpoint%2F1/restore", + {}, + ), + 200, + ["auth:ssh", "checkpoints:restore:ssh-user:IS/2:checkpoint/1"], + ], + ]; + + for (const [value, expectedStatus, expectedCalls] of cases) { + const calls: string[] = []; + assert.equal((await dispatch(value, calls))?.status, expectedStatus); + assert.deepEqual(calls, expectedCalls); + } + + const calls: string[] = []; + assert.equal( + await dispatch( + request("POST", "/api/agent/interactive-sessions/IS-2/actions", { action: "stop" }), + calls, + ), + null, + ); + assert.deepEqual(calls, []); +}); + +test("service-session routes report missing reads and fall through on inexact resources", async () => { + const missingCalls: string[] = []; + const missingDependencies = dependencies(missingCalls); + missingDependencies.readFreshSession = async (sessionId: string) => { + missingCalls.push(`read:${sessionId}`); + return null; + }; + await assert.rejects( + handleServiceSessionRoute( + request("GET", "/api/ssh/interactive-sessions/IS-404"), + new URL("https://fleet.example/api/ssh/interactive-sessions/IS-404"), + missingDependencies, + ), + (error) => { + assert.equal( + typeof error === "object" && error !== null && "status" in error + ? Number(error.status) + : undefined, + 404, + ); + return true; + }, + ); + assert.deepEqual(missingCalls, ["auth:ssh", "read:IS-404"]); + + const calls: string[] = []; + for (const value of [ + request("GET", "/api/ssh/auth"), + request("POST", "/api/agent/state", {}), + request("GET", "/api/agent/interactive-sessions/IS-2/actions"), + request("GET", "/api/ssh/interactive-sessions/IS-2/unknown"), + ]) { + assert.equal(await dispatch(value, calls), null); + } + assert.deepEqual(calls, []); +}); From 700584246279cb3a602bb5397fac2992c0f8fbf1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 16:15:28 +0100 Subject: [PATCH 060/109] refactor: share interactive session routes --- src/index.ts | 181 +++----------- src/worker/routes/browser-sessions.ts | 40 +++ .../routes/interactive-session-resources.ts | 100 ++++++++ src/worker/routes/service-sessions.ts | 76 ++---- tests/browser-session-routes.test.ts | 233 ++++++++++++++++++ 5 files changed, 426 insertions(+), 204 deletions(-) create mode 100644 src/worker/routes/browser-sessions.ts create mode 100644 src/worker/routes/interactive-session-resources.ts create mode 100644 tests/browser-session-routes.test.ts diff --git a/src/index.ts b/src/index.ts index 30b0c92..8dbf6af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -185,6 +185,10 @@ import { } from "./worker/session-model"; import { normalizeRepo } from "./worker/repositories"; import { handlePublicAuthRoute, handleSessionAuthRoute } from "./worker/routes/auth"; +import { + handleBrowserSessionRoute, + type BrowserSessionRouteDependencies, +} from "./worker/routes/browser-sessions"; import { handleOpenClawRoute } from "./worker/routes/openclaw"; import { handleServiceSessionRoute, @@ -1547,155 +1551,13 @@ async function api( return json(await searchGitHubRefs(request, env)); } - if (request.method === "POST" && url.pathname === "/api/interactive-sessions") { - requireRole(user, "maintainer"); - return json(await createInteractiveSession(request, env, user), { status: 201 }); - } - - if (request.method === "POST" && url.pathname === "/api/interactive-sessions/cleanup") { - requireRole(user, "viewer"); - return json(await cleanupInteractiveSessions(request, env, user)); - } - - const interactiveSessionReadMatch = url.pathname.match(/^\/api\/interactive-sessions\/([^/]+)$/); - if (request.method === "GET" && interactiveSessionReadMatch) { - requireRole(user, "viewer"); - const session = await readFreshInteractiveSession( - env, - decodeURIComponent(interactiveSessionReadMatch[1] ?? ""), - ); - if (!session) throw notFound("interactive session not found"); - return json({ session: decorateInteractiveSession(session, user, env) }); - } - - const interactiveSessionLogsMatch = url.pathname.match( - /^\/api\/interactive-sessions\/([^/]+)\/logs$/, - ); - if (request.method === "GET" && interactiveSessionLogsMatch) { - requireRole(user, "viewer"); - return json( - await readInteractiveSessionLogBundle( - env, - user, - decodeURIComponent(interactiveSessionLogsMatch[1] ?? ""), - ), - ); - } - - const interactiveSessionTranscriptMatch = url.pathname.match( - /^\/api\/interactive-sessions\/([^/]+)\/transcript$/, - ); - if (request.method === "GET" && interactiveSessionTranscriptMatch) { - const user = await requireUser(request, env, requestAuth); - return interactiveSessionTranscriptResponse( - env, - user, - decodeURIComponent(interactiveSessionTranscriptMatch[1] ?? ""), - ); - } - - const interactiveSessionSummaryMatch = url.pathname.match( - /^\/api\/interactive-sessions\/([^/]+)\/summary$/, - ); - if (request.method === "POST" && interactiveSessionSummaryMatch) { - const user = await requireUser(request, env, requestAuth); - return json( - await updateInteractiveSessionSummary( - request, - env, - user, - decodeURIComponent(interactiveSessionSummaryMatch[1] ?? ""), - ), - ); - } - - const interactiveSessionDiagnosticsMatch = url.pathname.match( - /^\/api\/interactive-sessions\/([^/]+)\/diagnostics$/, - ); - if (request.method === "GET" && interactiveSessionDiagnosticsMatch) { - requireRole(user, "viewer"); - return json( - await readInteractiveSessionDiagnostics( - env, - user, - decodeURIComponent(interactiveSessionDiagnosticsMatch[1] ?? ""), - ), - ); - } - - const interactiveSessionVncMatch = url.pathname.match( - /^\/api\/interactive-sessions\/([^/]+)\/vnc$/, - ); - if (request.method === "GET" && interactiveSessionVncMatch) { - requireRole(user, "viewer"); - return interactiveSessionVnc( - env, - user, - decodeURIComponent(interactiveSessionVncMatch[1] ?? ""), - ); - } - - const interactiveSessionCheckpointsMatch = url.pathname.match( - /^\/api\/interactive-sessions\/([^/]+)\/checkpoints$/, - ); - if (interactiveSessionCheckpointsMatch) { - requireRole(user, "viewer"); - const id = decodeURIComponent(interactiveSessionCheckpointsMatch[1] ?? ""); - if (request.method === "GET") - return json(await listInteractiveSessionCheckpoints(env, user, id)); - if (request.method === "POST") { - return json(await checkpointInteractiveSession(env, user, id), { status: 201 }); - } - } - - const interactiveSessionRestoreMatch = url.pathname.match( - /^\/api\/interactive-sessions\/([^/]+)\/checkpoints\/([^/]+)\/restore$/, - ); - if (request.method === "POST" && interactiveSessionRestoreMatch) { - requireRole(user, "viewer"); - return json( - await restoreInteractiveSessionCheckpoint( - env, - user, - decodeURIComponent(interactiveSessionRestoreMatch[1] ?? ""), - decodeURIComponent(interactiveSessionRestoreMatch[2] ?? ""), - ), - ); - } - - const interactiveSessionMatch = url.pathname.match( - /^\/api\/interactive-sessions\/([^/]+)\/actions$/, - ); - if (request.method === "POST" && interactiveSessionMatch) { - const body = await readJson<{ action?: string }>(request); - const action = body.action ?? ""; - requireRole(user, "viewer"); - return json( - await mutateInteractiveSession( - request, - env, - user, - decodeURIComponent(interactiveSessionMatch[1] ?? ""), - action, - ), - ); - } - - const interactiveClipboardMatch = url.pathname.match( - /^\/api\/interactive-sessions\/([^/]+)\/clipboard$/, + const browserSessionResponse = await handleBrowserSessionRoute( + request, + url, + user, + browserSessionRouteDependencies(env), ); - if (request.method === "POST" && interactiveClipboardMatch) { - requireRole(user, "viewer"); - return json( - await uploadInteractiveSessionClipboard( - request, - env, - user, - decodeURIComponent(interactiveClipboardMatch[1] ?? ""), - ), - { status: 201 }, - ); - } + if (browserSessionResponse) return browserSessionResponse; if (request.method === "POST" && url.pathname === "/api/cards") { requireRole(user, "maintainer"); @@ -1897,6 +1759,29 @@ function sshLinkConfirmHtml( `; } +function browserSessionRouteDependencies(env: RuntimeEnv): BrowserSessionRouteDependencies { + return { + createSession: (request, user) => createInteractiveSession(request, env, user), + cleanupSessions: (request, user) => cleanupInteractiveSessions(request, env, user), + readFreshSession: (sessionId) => readFreshInteractiveSession(env, sessionId), + presentSession: (session, user) => decorateInteractiveSession(session, user, env), + readLogs: (user, sessionId) => readInteractiveSessionLogBundle(env, user, sessionId), + readTranscript: (user, sessionId) => interactiveSessionTranscriptResponse(env, user, sessionId), + updateSummary: (request, user, sessionId) => + updateInteractiveSessionSummary(request, env, user, sessionId), + mutateSession: (request, user, sessionId, action) => + mutateInteractiveSession(request, env, user, sessionId, action), + listCheckpoints: (user, sessionId) => listInteractiveSessionCheckpoints(env, user, sessionId), + createCheckpoint: (user, sessionId) => checkpointInteractiveSession(env, user, sessionId), + restoreCheckpoint: (user, sessionId, checkpointId) => + restoreInteractiveSessionCheckpoint(env, user, sessionId, checkpointId), + readDiagnostics: (user, sessionId) => readInteractiveSessionDiagnostics(env, user, sessionId), + openVnc: (user, sessionId) => interactiveSessionVnc(env, user, sessionId), + uploadClipboard: (request, user, sessionId) => + uploadInteractiveSessionClipboard(request, env, user, sessionId), + }; +} + function serviceSessionRouteDependencies(env: RuntimeEnv): ServiceSessionRouteDependencies { return { sshAuth: (request) => sshAuth(request, env), diff --git a/src/worker/routes/browser-sessions.ts b/src/worker/routes/browser-sessions.ts new file mode 100644 index 0000000..2933c10 --- /dev/null +++ b/src/worker/routes/browser-sessions.ts @@ -0,0 +1,40 @@ +import { requireRole } from "../auth.ts"; +import { json } from "../http.ts"; +import type { User } from "../models.ts"; +import { + handleInteractiveSessionResourceRoute, + type InteractiveSessionResourceRouteDependencies, +} from "./interactive-session-resources.ts"; + +export type BrowserSessionRouteDependencies = Omit< + InteractiveSessionResourceRouteDependencies, + "basePath" | "requireUser" +> & { + createSession(request: Request, user: User): Promise; + cleanupSessions(request: Request, user: User): Promise; +}; + +export async function handleBrowserSessionRoute( + request: Request, + url: URL, + user: User, + dependencies: BrowserSessionRouteDependencies, +): Promise { + if (request.method === "POST" && url.pathname === "/api/interactive-sessions") { + requireRole(user, "maintainer"); + return json(await dependencies.createSession(request, user), { status: 201 }); + } + if (request.method === "POST" && url.pathname === "/api/interactive-sessions/cleanup") { + requireRole(user, "viewer"); + return json(await dependencies.cleanupSessions(request, user)); + } + + return handleInteractiveSessionResourceRoute(request, url, { + ...dependencies, + basePath: "/api/interactive-sessions", + requireUser: async () => { + requireRole(user, "viewer"); + return user; + }, + }); +} diff --git a/src/worker/routes/interactive-session-resources.ts b/src/worker/routes/interactive-session-resources.ts new file mode 100644 index 0000000..189482e --- /dev/null +++ b/src/worker/routes/interactive-session-resources.ts @@ -0,0 +1,100 @@ +import { json, notFound, readJson } from "../http.ts"; +import type { User } from "../models.ts"; +import type { InteractiveSession } from "../session-model.ts"; + +export type InteractiveSessionResourceRouteDependencies = { + basePath: string; + requireUser(request: Request): Promise; + readFreshSession(sessionId: string): Promise; + presentSession(session: InteractiveSession, user: User): InteractiveSession; + readLogs(user: User, sessionId: string): Promise; + readTranscript(user: User, sessionId: string): Promise; + updateSummary(request: Request, user: User, sessionId: string): Promise; + mutateSession?(request: Request, user: User, sessionId: string, action: string): Promise; + listCheckpoints?(user: User, sessionId: string): Promise; + createCheckpoint?(user: User, sessionId: string): Promise; + restoreCheckpoint?(user: User, sessionId: string, checkpointId: string): Promise; + readDiagnostics?(user: User, sessionId: string): Promise; + openVnc?(user: User, sessionId: string): Promise; + uploadClipboard?(request: Request, user: User, sessionId: string): Promise; +}; + +export async function handleInteractiveSessionResourceRoute( + request: Request, + url: URL, + dependencies: InteractiveSessionResourceRouteDependencies, +): Promise { + const match = url.pathname.match( + new RegExp(`^${escapedPattern(dependencies.basePath)}/([^/]+)(?:/(.+))?$`), + ); + if (!match) return null; + const sessionId = decoded(match[1]); + const resource = match[2] ?? ""; + + if (request.method === "GET" && !resource) { + const user = await dependencies.requireUser(request); + const session = await dependencies.readFreshSession(sessionId); + if (!session) throw notFound("interactive session not found"); + return json({ session: dependencies.presentSession(session, user) }); + } + if (request.method === "GET" && resource === "logs") { + const user = await dependencies.requireUser(request); + return json(await dependencies.readLogs(user, sessionId)); + } + if (request.method === "GET" && resource === "transcript") { + const user = await dependencies.requireUser(request); + return dependencies.readTranscript(user, sessionId); + } + if (request.method === "POST" && resource === "summary") { + const user = await dependencies.requireUser(request); + return json(await dependencies.updateSummary(request, user, sessionId)); + } + if (request.method === "POST" && resource === "actions" && dependencies.mutateSession) { + const user = await dependencies.requireUser(request); + const body = await readJson<{ action?: string }>(request); + return json(await dependencies.mutateSession(request, user, sessionId, body.action ?? "")); + } + + if (resource === "checkpoints" && dependencies.listCheckpoints && dependencies.createCheckpoint) { + const user = await dependencies.requireUser(request); + if (request.method === "GET") { + return json(await dependencies.listCheckpoints(user, sessionId)); + } + if (request.method === "POST") { + return json(await dependencies.createCheckpoint(user, sessionId), { status: 201 }); + } + return null; + } + + const restoreMatch = dependencies.restoreCheckpoint + ? resource.match(/^checkpoints\/([^/]+)\/restore$/) + : null; + if (request.method === "POST" && restoreMatch && dependencies.restoreCheckpoint) { + const user = await dependencies.requireUser(request); + return json(await dependencies.restoreCheckpoint(user, sessionId, decoded(restoreMatch[1]))); + } + if (request.method === "GET" && resource === "diagnostics" && dependencies.readDiagnostics) { + const user = await dependencies.requireUser(request); + return json(await dependencies.readDiagnostics(user, sessionId)); + } + if (request.method === "GET" && resource === "vnc" && dependencies.openVnc) { + const user = await dependencies.requireUser(request); + return dependencies.openVnc(user, sessionId); + } + if (request.method === "POST" && resource === "clipboard" && dependencies.uploadClipboard) { + const user = await dependencies.requireUser(request); + return json(await dependencies.uploadClipboard(request, user, sessionId), { + status: 201, + }); + } + + return null; +} + +function escapedPattern(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function decoded(value: string | undefined): string { + return decodeURIComponent(value ?? ""); +} diff --git a/src/worker/routes/service-sessions.ts b/src/worker/routes/service-sessions.ts index 03b9b1c..fb4d250 100644 --- a/src/worker/routes/service-sessions.ts +++ b/src/worker/routes/service-sessions.ts @@ -1,6 +1,7 @@ -import { json, notFound, readJson } from "../http.ts"; +import { json } from "../http.ts"; import type { User } from "../models.ts"; import type { InteractiveSession } from "../session-model.ts"; +import { handleInteractiveSessionResourceRoute } from "./interactive-session-resources.ts"; type ServiceSessionPrincipal = "ssh" | "agent"; @@ -61,61 +62,24 @@ export async function handleServiceSessionRoute( return dependencies.openAgentRunnerPty(request, sessionId); } - if (request.method === "GET" && !resource) { - const user = await requirePrincipal(request, principal, dependencies); - const session = await dependencies.readFreshSession(sessionId); - if (!session) throw notFound("interactive session not found"); - return json({ session: dependencies.presentSession(session, user) }); - } - - if (principal === "ssh" && request.method === "POST" && resource === "actions") { - const user = await dependencies.requireSshViewer(request); - const body = await readJson<{ action?: string }>(request); - return json(await dependencies.mutateSession(request, user, sessionId, body.action ?? "")); - } - - if (principal === "ssh" && resource === "checkpoints") { - const user = await dependencies.requireSshViewer(request); - if (request.method === "GET") { - return json(await dependencies.listCheckpoints(user, sessionId)); - } - if (request.method === "POST") { - return json(await dependencies.createCheckpoint(user, sessionId), { status: 201 }); - } - return null; - } - - const restoreMatch = - principal === "ssh" ? resource.match(/^checkpoints\/([^/]+)\/restore$/) : null; - if (request.method === "POST" && restoreMatch) { - const user = await dependencies.requireSshViewer(request); - return json(await dependencies.restoreCheckpoint(user, sessionId, decoded(restoreMatch[1]))); - } - - if (request.method === "GET" && resource === "logs") { - const user = await requirePrincipal(request, principal, dependencies); - return json(await dependencies.readLogs(user, sessionId)); - } - if (request.method === "GET" && resource === "transcript") { - const user = await requirePrincipal(request, principal, dependencies); - return dependencies.readTranscript(user, sessionId); - } - if (request.method === "POST" && resource === "summary") { - const user = await requirePrincipal(request, principal, dependencies); - return json(await dependencies.updateSummary(request, user, sessionId)); - } - - return null; -} - -function requirePrincipal( - request: Request, - principal: ServiceSessionPrincipal, - dependencies: ServiceSessionRouteDependencies, -): Promise { - return principal === "ssh" - ? dependencies.requireSshViewer(request) - : dependencies.requireAgentUser(request); + return handleInteractiveSessionResourceRoute(request, url, { + basePath: `/api/${principal}/interactive-sessions`, + requireUser: + principal === "ssh" ? dependencies.requireSshViewer : dependencies.requireAgentUser, + readFreshSession: dependencies.readFreshSession, + presentSession: dependencies.presentSession, + readLogs: dependencies.readLogs, + readTranscript: dependencies.readTranscript, + updateSummary: dependencies.updateSummary, + ...(principal === "ssh" + ? { + mutateSession: dependencies.mutateSession, + listCheckpoints: dependencies.listCheckpoints, + createCheckpoint: dependencies.createCheckpoint, + restoreCheckpoint: dependencies.restoreCheckpoint, + } + : {}), + }); } function decoded(value: string | undefined): string { diff --git a/tests/browser-session-routes.test.ts b/tests/browser-session-routes.test.ts new file mode 100644 index 0000000..1402fa3 --- /dev/null +++ b/tests/browser-session-routes.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { User } from "../src/worker/models.ts"; +import { + handleBrowserSessionRoute, + type BrowserSessionRouteDependencies, +} from "../src/worker/routes/browser-sessions.ts"; +import { interactiveSession } from "../src/worker/session-model.ts"; +import { sessionRow } from "./helpers/session-row.ts"; + +const viewer: User = { + subject: "github:1", + login: "viewer", + email: null, + name: "Viewer", + role: "viewer", + allowed: true, + teams: [], +}; + +const maintainer: User = { + ...viewer, + subject: "github:2", + login: "maintainer", + name: "Maintainer", + role: "maintainer", +}; + +function response(name: string): Response { + return new Response(name, { headers: { "x-handler": name } }); +} + +function dependencies(calls: string[]): BrowserSessionRouteDependencies { + return { + async createSession(_request, user) { + calls.push(`create:${user.login}`); + return { handler: "create" }; + }, + async cleanupSessions(_request, user) { + calls.push(`cleanup:${user.login}`); + return { handler: "cleanup" }; + }, + async readFreshSession(sessionId) { + calls.push(`read:${sessionId}`); + return interactiveSession(sessionRow({ id: sessionId }), []); + }, + presentSession(session, user) { + calls.push(`present:${session.id}:${user.login}`); + return { ...session, owner: user.login }; + }, + async readLogs(user, sessionId) { + calls.push(`logs:${user.login}:${sessionId}`); + return { handler: "logs" }; + }, + async readTranscript(user, sessionId) { + calls.push(`transcript:${user.login}:${sessionId}`); + return response("transcript"); + }, + async updateSummary(_request, user, sessionId) { + calls.push(`summary:${user.login}:${sessionId}`); + return { handler: "summary" }; + }, + async mutateSession(_request, user, sessionId, action) { + calls.push(`mutate:${user.login}:${sessionId}:${action}`); + return { handler: "mutate" }; + }, + async listCheckpoints(user, sessionId) { + calls.push(`checkpoints:list:${user.login}:${sessionId}`); + return { handler: "checkpoints:list" }; + }, + async createCheckpoint(user, sessionId) { + calls.push(`checkpoints:create:${user.login}:${sessionId}`); + return { handler: "checkpoints:create" }; + }, + async restoreCheckpoint(user, sessionId, checkpointId) { + calls.push(`checkpoints:restore:${user.login}:${sessionId}:${checkpointId}`); + return { handler: "checkpoints:restore" }; + }, + async readDiagnostics(user, sessionId) { + calls.push(`diagnostics:${user.login}:${sessionId}`); + return { handler: "diagnostics" }; + }, + async openVnc(user, sessionId) { + calls.push(`vnc:${user.login}:${sessionId}`); + return response("vnc"); + }, + async uploadClipboard(_request, user, sessionId) { + calls.push(`clipboard:${user.login}:${sessionId}`); + return { handler: "clipboard" }; + }, + }; +} + +function request(method: string, path: string, body?: Record): Request { + return new Request(`https://fleet.example${path}`, { + method, + headers: body ? { "content-type": "application/json" } : undefined, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +} + +async function dispatch( + value: Request, + user: User, + calls: string[], + overrides?: Partial, +): Promise { + return handleBrowserSessionRoute(value, new URL(value.url), user, { + ...dependencies(calls), + ...overrides, + }); +} + +function status(error: unknown): number | undefined { + return typeof error === "object" && error !== null && "status" in error + ? Number(error.status) + : undefined; +} + +test("browser session collection routes enforce create and cleanup roles", async () => { + const createCalls: string[] = []; + assert.equal( + (await dispatch(request("POST", "/api/interactive-sessions", {}), maintainer, createCalls)) + ?.status, + 201, + ); + assert.deepEqual(createCalls, ["create:maintainer"]); + + await assert.rejects( + dispatch(request("POST", "/api/interactive-sessions", {}), viewer, []), + (error) => { + assert.equal(status(error), 403); + return true; + }, + ); + + const cleanupCalls: string[] = []; + assert.equal( + (await dispatch(request("POST", "/api/interactive-sessions/cleanup", {}), viewer, cleanupCalls)) + ?.status, + 200, + ); + assert.deepEqual(cleanupCalls, ["cleanup:viewer"]); +}); + +test("browser session routes dispatch all JSON resources with decoded identities", async () => { + const cases: Array<[Request, number, string[]]> = [ + [request("GET", "/api/interactive-sessions/IS%2F2"), 200, ["read:IS/2", "present:IS/2:viewer"]], + [request("GET", "/api/interactive-sessions/IS%2F2/logs"), 200, ["logs:viewer:IS/2"]], + [request("POST", "/api/interactive-sessions/IS%2F2/summary", {}), 200, ["summary:viewer:IS/2"]], + [ + request("POST", "/api/interactive-sessions/IS%2F2/actions", { action: "stop" }), + 200, + ["mutate:viewer:IS/2:stop"], + ], + [ + request("GET", "/api/interactive-sessions/IS%2F2/checkpoints"), + 200, + ["checkpoints:list:viewer:IS/2"], + ], + [ + request("POST", "/api/interactive-sessions/IS%2F2/checkpoints", {}), + 201, + ["checkpoints:create:viewer:IS/2"], + ], + [ + request("POST", "/api/interactive-sessions/IS%2F2/checkpoints/checkpoint%2F1/restore", {}), + 200, + ["checkpoints:restore:viewer:IS/2:checkpoint/1"], + ], + [ + request("GET", "/api/interactive-sessions/IS%2F2/diagnostics"), + 200, + ["diagnostics:viewer:IS/2"], + ], + [ + request("POST", "/api/interactive-sessions/IS%2F2/clipboard", {}), + 201, + ["clipboard:viewer:IS/2"], + ], + ]; + + for (const [value, expectedStatus, expectedCalls] of cases) { + const calls: string[] = []; + assert.equal((await dispatch(value, viewer, calls))?.status, expectedStatus); + assert.deepEqual(calls, expectedCalls); + } +}); + +test("browser session routes preserve raw transcript and VNC responses", async () => { + for (const [resource, handler] of [ + ["transcript", "transcript"], + ["vnc", "vnc"], + ] as const) { + const calls: string[] = []; + const result = await dispatch( + request("GET", `/api/interactive-sessions/IS%2F2/${resource}`), + viewer, + calls, + ); + assert.equal(result?.headers.get("x-handler"), handler); + assert.deepEqual(calls, [`${resource}:viewer:IS/2`]); + } +}); + +test("browser session resources report missing sessions and exact fallthrough", async () => { + const missingCalls: string[] = []; + await assert.rejects( + dispatch(request("GET", "/api/interactive-sessions/IS-404"), viewer, missingCalls, { + readFreshSession: async (sessionId) => { + missingCalls.push(`read:${sessionId}`); + return null; + }, + }), + (error) => { + assert.equal(status(error), 404); + return true; + }, + ); + assert.deepEqual(missingCalls, ["read:IS-404"]); + + const calls: string[] = []; + for (const value of [ + request("GET", "/api/interactive-sessions"), + request("DELETE", "/api/interactive-sessions/IS-2"), + request("GET", "/api/interactive-sessions/IS-2/unknown"), + request("GET", "/api/interactive-sessions/IS-2/"), + ]) { + assert.equal(await dispatch(value, viewer, calls), null); + } + assert.deepEqual(calls, []); +}); From 2ed6984b2efac9c79600fe5c0726650c30fd8911 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 16:18:57 +0100 Subject: [PATCH 061/109] refactor: extract control plane routes --- src/index.ts | 100 ++++--------- src/worker/routes/control-plane.ts | 91 ++++++++++++ tests/control-plane-routes.test.ts | 227 +++++++++++++++++++++++++++++ 3 files changed, 350 insertions(+), 68 deletions(-) create mode 100644 src/worker/routes/control-plane.ts create mode 100644 tests/control-plane-routes.test.ts diff --git a/src/index.ts b/src/index.ts index 8dbf6af..1a51c96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -189,6 +189,10 @@ import { handleBrowserSessionRoute, type BrowserSessionRouteDependencies, } from "./worker/routes/browser-sessions"; +import { + handleControlPlaneRoute, + type ControlPlaneRouteDependencies, +} from "./worker/routes/control-plane"; import { handleOpenClawRoute } from "./worker/routes/openclaw"; import { handleServiceSessionRoute, @@ -1537,19 +1541,13 @@ async function api( }); if (sessionAuthResponse) return sessionAuthResponse; - if (request.method === "GET" && url.pathname === "/api/state") { - return json(await readState(request, env, user, context)); - } - - if (request.method === "GET" && url.pathname === "/api/fleet") { - requireRole(user, "viewer"); - return json({ fleet: await readFleetState(env, user, undefined, context) }); - } - - if (request.method === "GET" && url.pathname === "/api/github/refs") { - requireRole(user, "maintainer"); - return json(await searchGitHubRefs(request, env)); - } + const controlPlaneResponse = await handleControlPlaneRoute( + request, + url, + user, + controlPlaneRouteDependencies(env, context), + ); + if (controlPlaneResponse) return controlPlaneResponse; const browserSessionResponse = await handleBrowserSessionRoute( request, @@ -1559,61 +1557,6 @@ async function api( ); if (browserSessionResponse) return browserSessionResponse; - if (request.method === "POST" && url.pathname === "/api/cards") { - requireRole(user, "maintainer"); - return json(await createCard(request, env, user), { status: 201 }); - } - - const runsMatch = url.pathname.match(/^\/api\/cards\/([^/]+)\/runs$/); - if (request.method === "GET" && runsMatch) { - const cardId = decodeURIComponent(runsMatch[1] ?? ""); - const card = await readCard(env, cardId); - if (!card) throw notFound("card not found"); - return json({ runs: await readRunsForCard(env, cardId) }); - } - - if (request.method === "PUT" && url.pathname === "/api/admin/policy") { - requireRole(user, "owner"); - return json(await updatePolicy(request, env, user)); - } - - if (request.method === "POST" && url.pathname === "/api/admin/workflows/evaluate") { - requireRole(user, "owner"); - return json(await evaluateWorkflow(request, env, user)); - } - - const actionMatch = url.pathname.match(/^\/api\/cards\/([^/]+)\/actions$/); - if (request.method === "POST" && actionMatch) { - const body = await readJson<{ action?: string }>(request); - const action = body.action ?? ""; - requireRole(user, action === "attach" || action === "watch" ? "viewer" : "maintainer"); - return json(await mutateCard(env, user, decodeURIComponent(actionMatch[1] ?? ""), action)); - } - - if (request.method === "POST" && url.pathname === "/api/admin/allow") { - requireRole(user, "owner"); - return json(await addAllowEntry(request, env, user), { status: 201 }); - } - - const allowMatch = url.pathname.match(/^\/api\/admin\/allow\/(.+)$/); - if (request.method === "DELETE" && allowMatch) { - requireRole(user, "owner"); - return json( - await removeAllowEntry(request, env, user, decodeURIComponent(allowMatch[1] ?? "")), - ); - } - - if (request.method === "POST" && url.pathname === "/api/admin/repos") { - requireRole(user, "owner"); - return json(await addRepo(request, env, user), { status: 201 }); - } - - const repoMatch = url.pathname.match(/^\/api\/admin\/repos\/(.+)$/); - if (request.method === "DELETE" && repoMatch) { - requireRole(user, "owner"); - return json(await removeRepo(request, env, user, decodeURIComponent(repoMatch[1] ?? ""))); - } - return json({ error: "not found" }, { status: 404 }); } @@ -1759,6 +1702,27 @@ function sshLinkConfirmHtml( `; } +function controlPlaneRouteDependencies( + env: RuntimeEnv, + context: ExecutionContext, +): ControlPlaneRouteDependencies { + return { + readState: (request, user) => readState(request, env, user, context), + readFleet: (user) => readFleetState(env, user, undefined, context), + searchGitHubRefs: (request) => searchGitHubRefs(request, env), + createCard: (request, user) => createCard(request, env, user), + readCardRuns: async (cardId) => + (await readCard(env, cardId)) ? readRunsForCard(env, cardId) : null, + mutateCard: (user, cardId, action) => mutateCard(env, user, cardId, action), + updatePolicy: (request, user) => updatePolicy(request, env, user), + evaluateWorkflow: (request, user) => evaluateWorkflow(request, env, user), + addAllowEntry: (request, user) => addAllowEntry(request, env, user), + removeAllowEntry: (request, user, entry) => removeAllowEntry(request, env, user, entry), + addRepo: (request, user) => addRepo(request, env, user), + removeRepo: (request, user, repo) => removeRepo(request, env, user, repo), + }; +} + function browserSessionRouteDependencies(env: RuntimeEnv): BrowserSessionRouteDependencies { return { createSession: (request, user) => createInteractiveSession(request, env, user), diff --git a/src/worker/routes/control-plane.ts b/src/worker/routes/control-plane.ts new file mode 100644 index 0000000..67b5b78 --- /dev/null +++ b/src/worker/routes/control-plane.ts @@ -0,0 +1,91 @@ +import { requireRole } from "../auth.ts"; +import { json, notFound, readJson } from "../http.ts"; +import type { User } from "../models.ts"; + +export type ControlPlaneRouteDependencies = { + readState(request: Request, user: User): Promise; + readFleet(user: User): Promise; + searchGitHubRefs(request: Request): Promise; + createCard(request: Request, user: User): Promise; + readCardRuns(cardId: string): Promise; + mutateCard(user: User, cardId: string, action: string): Promise; + updatePolicy(request: Request, user: User): Promise; + evaluateWorkflow(request: Request, user: User): Promise; + addAllowEntry(request: Request, user: User): Promise; + removeAllowEntry(request: Request, user: User, entry: string): Promise; + addRepo(request: Request, user: User): Promise; + removeRepo(request: Request, user: User, repo: string): Promise; +}; + +export async function handleControlPlaneRoute( + request: Request, + url: URL, + user: User, + dependencies: ControlPlaneRouteDependencies, +): Promise { + if (request.method === "GET" && url.pathname === "/api/state") { + return json(await dependencies.readState(request, user)); + } + if (request.method === "GET" && url.pathname === "/api/fleet") { + requireRole(user, "viewer"); + return json({ fleet: await dependencies.readFleet(user) }); + } + if (request.method === "GET" && url.pathname === "/api/github/refs") { + requireRole(user, "maintainer"); + return json(await dependencies.searchGitHubRefs(request)); + } + if (request.method === "POST" && url.pathname === "/api/cards") { + requireRole(user, "maintainer"); + return json(await dependencies.createCard(request, user), { status: 201 }); + } + + const runsMatch = url.pathname.match(/^\/api\/cards\/([^/]+)\/runs$/); + if (request.method === "GET" && runsMatch) { + const runs = await dependencies.readCardRuns(decoded(runsMatch[1])); + if (!runs) throw notFound("card not found"); + return json({ runs }); + } + + const actionMatch = url.pathname.match(/^\/api\/cards\/([^/]+)\/actions$/); + if (request.method === "POST" && actionMatch) { + const body = await readJson<{ action?: string }>(request); + const action = body.action ?? ""; + requireRole(user, action === "attach" || action === "watch" ? "viewer" : "maintainer"); + return json(await dependencies.mutateCard(user, decoded(actionMatch[1]), action)); + } + + if (request.method === "PUT" && url.pathname === "/api/admin/policy") { + requireRole(user, "owner"); + return json(await dependencies.updatePolicy(request, user)); + } + if (request.method === "POST" && url.pathname === "/api/admin/workflows/evaluate") { + requireRole(user, "owner"); + return json(await dependencies.evaluateWorkflow(request, user)); + } + if (request.method === "POST" && url.pathname === "/api/admin/allow") { + requireRole(user, "owner"); + return json(await dependencies.addAllowEntry(request, user), { status: 201 }); + } + + const allowMatch = url.pathname.match(/^\/api\/admin\/allow\/(.+)$/); + if (request.method === "DELETE" && allowMatch) { + requireRole(user, "owner"); + return json(await dependencies.removeAllowEntry(request, user, decoded(allowMatch[1]))); + } + if (request.method === "POST" && url.pathname === "/api/admin/repos") { + requireRole(user, "owner"); + return json(await dependencies.addRepo(request, user), { status: 201 }); + } + + const repoMatch = url.pathname.match(/^\/api\/admin\/repos\/(.+)$/); + if (request.method === "DELETE" && repoMatch) { + requireRole(user, "owner"); + return json(await dependencies.removeRepo(request, user, decoded(repoMatch[1]))); + } + + return null; +} + +function decoded(value: string | undefined): string { + return decodeURIComponent(value ?? ""); +} diff --git a/tests/control-plane-routes.test.ts b/tests/control-plane-routes.test.ts new file mode 100644 index 0000000..9f1d245 --- /dev/null +++ b/tests/control-plane-routes.test.ts @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { User } from "../src/worker/models.ts"; +import { + handleControlPlaneRoute, + type ControlPlaneRouteDependencies, +} from "../src/worker/routes/control-plane.ts"; + +const viewer: User = { + subject: "github:1", + login: "viewer", + email: null, + name: "Viewer", + role: "viewer", + allowed: true, + teams: [], +}; + +const maintainer: User = { + ...viewer, + subject: "github:2", + login: "maintainer", + role: "maintainer", +}; + +const owner: User = { + ...maintainer, + subject: "github:3", + login: "owner", + role: "owner", +}; + +function dependencies(calls: string[]): ControlPlaneRouteDependencies { + return { + async readState(_request, user) { + calls.push(`state:${user.login}`); + return { handler: "state" }; + }, + async readFleet(user) { + calls.push(`fleet:${user.login}`); + return { handler: "fleet" }; + }, + async searchGitHubRefs() { + calls.push("github-refs"); + return { handler: "github-refs" }; + }, + async createCard(_request, user) { + calls.push(`card:create:${user.login}`); + return { handler: "card:create" }; + }, + async readCardRuns(cardId) { + calls.push(`card:runs:${cardId}`); + return [{ id: "run-1" }]; + }, + async mutateCard(user, cardId, action) { + calls.push(`card:mutate:${user.login}:${cardId}:${action}`); + return { handler: "card:mutate" }; + }, + async updatePolicy(_request, user) { + calls.push(`policy:${user.login}`); + return { handler: "policy" }; + }, + async evaluateWorkflow(_request, user) { + calls.push(`workflow:${user.login}`); + return { handler: "workflow" }; + }, + async addAllowEntry(_request, user) { + calls.push(`allow:add:${user.login}`); + return { handler: "allow:add" }; + }, + async removeAllowEntry(_request, user, entry) { + calls.push(`allow:remove:${user.login}:${entry}`); + return { handler: "allow:remove" }; + }, + async addRepo(_request, user) { + calls.push(`repo:add:${user.login}`); + return { handler: "repo:add" }; + }, + async removeRepo(_request, user, repo) { + calls.push(`repo:remove:${user.login}:${repo}`); + return { handler: "repo:remove" }; + }, + }; +} + +function request(method: string, path: string, body?: Record): Request { + return new Request(`https://fleet.example${path}`, { + method, + headers: body ? { "content-type": "application/json" } : undefined, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +} + +async function dispatch( + value: Request, + user: User, + calls: string[], + overrides?: Partial, +): Promise { + return handleControlPlaneRoute(value, new URL(value.url), user, { + ...dependencies(calls), + ...overrides, + }); +} + +function status(error: unknown): number | undefined { + return typeof error === "object" && error !== null && "status" in error + ? Number(error.status) + : undefined; +} + +test("control-plane read and card routes enforce their role boundaries", async () => { + const cases: Array<[Request, User, number, string[]]> = [ + [request("GET", "/api/state"), viewer, 200, ["state:viewer"]], + [request("GET", "/api/fleet"), viewer, 200, ["fleet:viewer"]], + [request("GET", "/api/github/refs"), maintainer, 200, ["github-refs"]], + [request("POST", "/api/cards", {}), maintainer, 201, ["card:create:maintainer"]], + [request("GET", "/api/cards/card%2F1/runs"), viewer, 200, ["card:runs:card/1"]], + ]; + + for (const [value, user, expectedStatus, expectedCalls] of cases) { + const calls: string[] = []; + assert.equal((await dispatch(value, user, calls))?.status, expectedStatus); + assert.deepEqual(calls, expectedCalls); + } + + await assert.rejects(dispatch(request("GET", "/api/github/refs"), viewer, []), (error) => { + assert.equal(status(error), 403); + return true; + }); + await assert.rejects(dispatch(request("POST", "/api/cards", {}), viewer, []), (error) => { + assert.equal(status(error), 403); + return true; + }); +}); + +test("card actions derive viewer or maintainer authorization from the action", async () => { + for (const action of ["attach", "watch"]) { + const calls: string[] = []; + assert.equal( + (await dispatch(request("POST", "/api/cards/card%2F1/actions", { action }), viewer, calls)) + ?.status, + 200, + ); + assert.deepEqual(calls, [`card:mutate:viewer:card/1:${action}`]); + } + + await assert.rejects( + dispatch(request("POST", "/api/cards/card-1/actions", { action: "start" }), viewer, []), + (error) => { + assert.equal(status(error), 403); + return true; + }, + ); + + const calls: string[] = []; + assert.equal( + ( + await dispatch( + request("POST", "/api/cards/card-1/actions", { action: "start" }), + maintainer, + calls, + ) + )?.status, + 200, + ); + assert.deepEqual(calls, ["card:mutate:maintainer:card-1:start"]); +}); + +test("control-plane admin routes are owner-only and decode path identities", async () => { + const cases: Array<[Request, number, string[]]> = [ + [request("PUT", "/api/admin/policy", {}), 200, ["policy:owner"]], + [request("POST", "/api/admin/workflows/evaluate", {}), 200, ["workflow:owner"]], + [request("POST", "/api/admin/allow", {}), 201, ["allow:add:owner"]], + [request("DELETE", "/api/admin/allow/team%2Fcore"), 200, ["allow:remove:owner:team/core"]], + [request("POST", "/api/admin/repos", {}), 201, ["repo:add:owner"]], + [ + request("DELETE", "/api/admin/repos/openclaw%2Fcrabfleet"), + 200, + ["repo:remove:owner:openclaw/crabfleet"], + ], + ]; + + for (const [value, expectedStatus, expectedCalls] of cases) { + const calls: string[] = []; + assert.equal((await dispatch(value, owner, calls))?.status, expectedStatus); + assert.deepEqual(calls, expectedCalls); + } + + await assert.rejects( + dispatch(request("PUT", "/api/admin/policy", {}), maintainer, []), + (error) => { + assert.equal(status(error), 403); + return true; + }, + ); +}); + +test("control-plane routes report missing cards and exact fallthrough", async () => { + const calls: string[] = []; + await assert.rejects( + dispatch(request("GET", "/api/cards/missing/runs"), viewer, calls, { + readCardRuns: async (cardId) => { + calls.push(`card:runs:${cardId}`); + return null; + }, + }), + (error) => { + assert.equal(status(error), 404); + return true; + }, + ); + assert.deepEqual(calls, ["card:runs:missing"]); + + const fallthroughCalls: string[] = []; + for (const value of [ + request("POST", "/api/state", {}), + request("GET", "/api/cards"), + request("GET", "/api/cards/card-1/actions"), + request("DELETE", "/api/admin/allow"), + request("GET", "/api/admin/repos/openclaw/crabfleet"), + ]) { + assert.equal(await dispatch(value, owner, fallthroughCalls), null); + } + assert.deepEqual(fallthroughCalls, []); +}); From 2f8a7341fdf721a50945e316424996749fb957b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 16:21:29 +0100 Subject: [PATCH 062/109] refactor: extract session ingress routes --- src/index.ts | 78 ++++++++++++++-------------- src/worker/routes/provisioning.ts | 30 +++++++++++ src/worker/routes/session-ingress.ts | 26 ++++++++++ tests/provisioning-routes.test.ts | 72 +++++++++++++++++++++++++ tests/runtime-adapter.test.ts | 1 - tests/session-ingress-routes.test.ts | 58 +++++++++++++++++++++ 6 files changed, 224 insertions(+), 41 deletions(-) create mode 100644 src/worker/routes/provisioning.ts create mode 100644 src/worker/routes/session-ingress.ts create mode 100644 tests/provisioning-routes.test.ts create mode 100644 tests/session-ingress-routes.test.ts diff --git a/src/index.ts b/src/index.ts index 1a51c96..91aa82b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -194,6 +194,14 @@ import { type ControlPlaneRouteDependencies, } from "./worker/routes/control-plane"; import { handleOpenClawRoute } from "./worker/routes/openclaw"; +import { + handleProvisioningRoute, + type ProvisioningRouteDependencies, +} from "./worker/routes/provisioning"; +import { + handleSessionIngressRoute, + type SessionIngressRouteDependencies, +} from "./worker/routes/session-ingress"; import { handleServiceSessionRoute, type ServiceSessionRouteDependencies, @@ -1476,33 +1484,12 @@ async function api( ): Promise { const url = new URL(request.url); - const standaloneProvisionPtyMatch = url.pathname.match( - /^\/api\/provision\/interactive\/([^/]+)\/pty$/, - ); - if (request.method === "GET" && standaloneProvisionPtyMatch) { - return standaloneSandboxPty( - request, - env, - decodeURIComponent(standaloneProvisionPtyMatch[1] ?? ""), - ); - } - - const standaloneProvisionStopMatch = url.pathname.match( - /^\/api\/provision\/interactive\/([^/]+)\/stop$/, + const provisioningResponse = await handleProvisioningRoute( + request, + url, + provisioningRouteDependencies(env), ); - if (request.method === "POST" && standaloneProvisionStopMatch) { - return json( - await stopStandaloneSandboxProvision( - request, - env, - decodeURIComponent(standaloneProvisionStopMatch[1] ?? ""), - ), - ); - } - - if (request.method === "POST" && url.pathname === "/api/provision/interactive") { - return json(await provisionInteractiveEndpoint(request, env)); - } + if (provisioningResponse) return provisioningResponse; const serviceSessionResponse = await handleServiceSessionRoute( request, @@ -1518,20 +1505,12 @@ async function api( }); if (openClawResponse) return openClawResponse; - const sharedSessionMatch = url.pathname.match(/^\/api\/shared-sessions\/([^/]+)$/); - if (request.method === "GET" && sharedSessionMatch) { - return json( - await readSharedInteractiveSession( - env, - decodeURIComponent(sharedSessionMatch[1] ?? ""), - url.searchParams.get("token") ?? "", - ), - ); - } - - if (request.method === "GET" && url.pathname === "/api/terminal/ws") { - return interactiveTerminalHub(request, env, await terminalHubUser(request, env, requestAuth)); - } + const sessionIngressResponse = await handleSessionIngressRoute( + request, + url, + sessionIngressRouteDependencies(env, requestAuth), + ); + if (sessionIngressResponse) return sessionIngressResponse; const user = await requireUser(request, env, requestAuth); @@ -1723,6 +1702,25 @@ function controlPlaneRouteDependencies( }; } +function provisioningRouteDependencies(env: RuntimeEnv): ProvisioningRouteDependencies { + return { + provision: (request) => provisionInteractiveEndpoint(request, env), + stop: (request, provisionId) => stopStandaloneSandboxProvision(request, env, provisionId), + openPty: (request, provisionId) => standaloneSandboxPty(request, env, provisionId), + }; +} + +function sessionIngressRouteDependencies( + env: RuntimeEnv, + requestAuth: TrustedProxyAuthResult, +): SessionIngressRouteDependencies { + return { + readSharedSession: (sessionId, token) => readSharedInteractiveSession(env, sessionId, token), + openTerminal: async (request) => + interactiveTerminalHub(request, env, await terminalHubUser(request, env, requestAuth)), + }; +} + function browserSessionRouteDependencies(env: RuntimeEnv): BrowserSessionRouteDependencies { return { createSession: (request, user) => createInteractiveSession(request, env, user), diff --git a/src/worker/routes/provisioning.ts b/src/worker/routes/provisioning.ts new file mode 100644 index 0000000..33399a3 --- /dev/null +++ b/src/worker/routes/provisioning.ts @@ -0,0 +1,30 @@ +import { json } from "../http.ts"; + +export type ProvisioningRouteDependencies = { + provision(request: Request): Promise; + stop(request: Request, provisionId: string): Promise; + openPty(request: Request, provisionId: string): Promise; +}; + +export async function handleProvisioningRoute( + request: Request, + url: URL, + dependencies: ProvisioningRouteDependencies, +): Promise { + if (request.method === "POST" && url.pathname === "/api/provision/interactive") { + return json(await dependencies.provision(request)); + } + + const provisionMatch = url.pathname.match(/^\/api\/provision\/interactive\/([^/]+)\/(pty|stop)$/); + if (!provisionMatch) return null; + + const provisionId = decodeURIComponent(provisionMatch[1] ?? ""); + const resource = provisionMatch[2]; + if (request.method === "GET" && resource === "pty") { + return dependencies.openPty(request, provisionId); + } + if (request.method === "POST" && resource === "stop") { + return json(await dependencies.stop(request, provisionId)); + } + return null; +} diff --git a/src/worker/routes/session-ingress.ts b/src/worker/routes/session-ingress.ts new file mode 100644 index 0000000..5f11385 --- /dev/null +++ b/src/worker/routes/session-ingress.ts @@ -0,0 +1,26 @@ +import { json } from "../http.ts"; + +export type SessionIngressRouteDependencies = { + readSharedSession(sessionId: string, token: string): Promise; + openTerminal(request: Request): Promise; +}; + +export async function handleSessionIngressRoute( + request: Request, + url: URL, + dependencies: SessionIngressRouteDependencies, +): Promise { + const sharedSessionMatch = url.pathname.match(/^\/api\/shared-sessions\/([^/]+)$/); + if (request.method === "GET" && sharedSessionMatch) { + return json( + await dependencies.readSharedSession( + decodeURIComponent(sharedSessionMatch[1] ?? ""), + url.searchParams.get("token") ?? "", + ), + ); + } + if (request.method === "GET" && url.pathname === "/api/terminal/ws") { + return dependencies.openTerminal(request); + } + return null; +} diff --git a/tests/provisioning-routes.test.ts b/tests/provisioning-routes.test.ts new file mode 100644 index 0000000..ef8d188 --- /dev/null +++ b/tests/provisioning-routes.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + handleProvisioningRoute, + type ProvisioningRouteDependencies, +} from "../src/worker/routes/provisioning.ts"; + +function request(method: string, path: string): Request { + return new Request(`https://fleet.example${path}`, { method }); +} + +function dependencies(calls: string[]): ProvisioningRouteDependencies { + return { + async provision() { + calls.push("provision"); + return { handler: "provision" }; + }, + async stop(_request, provisionId) { + calls.push(`stop:${provisionId}`); + return { handler: "stop" }; + }, + async openPty(_request, provisionId) { + calls.push(`pty:${provisionId}`); + return new Response("pty", { headers: { "x-handler": "pty" } }); + }, + }; +} + +async function dispatch(value: Request, calls: string[]): Promise { + return handleProvisioningRoute(value, new URL(value.url), dependencies(calls)); +} + +test("provisioning routes dispatch JSON operations and preserve raw PTY responses", async () => { + const cases: Array<[Request, number, string[], string | null]> = [ + [request("POST", "/api/provision/interactive"), 200, ["provision"], null], + [ + request("POST", "/api/provision/interactive/provision%2F1/stop"), + 200, + ["stop:provision/1"], + null, + ], + [ + request("GET", "/api/provision/interactive/provision%2F1/pty"), + 200, + ["pty:provision/1"], + "pty", + ], + ]; + + for (const [value, expectedStatus, expectedCalls, expectedHandler] of cases) { + const calls: string[] = []; + const result = await dispatch(value, calls); + assert.equal(result?.status, expectedStatus); + assert.deepEqual(calls, expectedCalls); + assert.equal(result?.headers.get("x-handler"), expectedHandler); + } +}); + +test("provisioning routes use exact methods and paths", async () => { + const calls: string[] = []; + for (const value of [ + request("GET", "/api/provision/interactive"), + request("DELETE", "/api/provision/interactive/provision-1/stop"), + request("POST", "/api/provision/interactive/provision-1/pty"), + request("GET", "/api/provision/interactive/provision-1"), + request("GET", "/api/provision/interactive/provision-1/pty/extra"), + ]) { + assert.equal(await dispatch(value, calls), null); + } + assert.deepEqual(calls, []); +}); diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 46b104c..b349090 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -939,7 +939,6 @@ test("stateless Sandbox provision hook acquires durable standalone ownership", a assert.match(stopSource, /reconcileCredentialPolicyCleanupBatch/); assert.match(stopSource, /status: remaining \? "stopping" : "stopped"/); assert.match(source, /CRABBOX_STANDALONE_SANDBOX_TTL_SECONDS/); - assert.match(source, /standaloneProvisionStopMatch/); assert.match(source, /stopStandaloneSandboxProvision/); assert.match(source, /policy\?\.expiresAt !== undefined && policy\.expiresAt <= Date\.now\(\)/); assert.match(source, /standaloneSandboxPolicyExpiresAt/); diff --git a/tests/session-ingress-routes.test.ts b/tests/session-ingress-routes.test.ts new file mode 100644 index 0000000..a7052bd --- /dev/null +++ b/tests/session-ingress-routes.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + handleSessionIngressRoute, + type SessionIngressRouteDependencies, +} from "../src/worker/routes/session-ingress.ts"; + +function request(method: string, path: string): Request { + return new Request(`https://fleet.example${path}`, { method }); +} + +function dependencies(calls: string[]): SessionIngressRouteDependencies { + return { + async readSharedSession(sessionId, token) { + calls.push(`shared:${sessionId}:${token}`); + return { handler: "shared" }; + }, + async openTerminal() { + calls.push("terminal"); + return new Response("terminal", { headers: { "x-handler": "terminal" } }); + }, + }; +} + +async function dispatch(value: Request, calls: string[]): Promise { + return handleSessionIngressRoute(value, new URL(value.url), dependencies(calls)); +} + +test("session ingress routes decode shared sessions and preserve terminal responses", async () => { + const sharedCalls: string[] = []; + const shared = await dispatch( + request("GET", "/api/shared-sessions/IS%2F2?token=share%20token"), + sharedCalls, + ); + assert.equal(shared?.status, 200); + assert.deepEqual(sharedCalls, ["shared:IS/2:share token"]); + + const terminalCalls: string[] = []; + const terminal = await dispatch(request("GET", "/api/terminal/ws"), terminalCalls); + assert.equal(terminal?.status, 200); + assert.equal(terminal?.headers.get("x-handler"), "terminal"); + assert.deepEqual(terminalCalls, ["terminal"]); +}); + +test("session ingress routes use exact methods and paths", async () => { + const calls: string[] = []; + for (const value of [ + request("POST", "/api/shared-sessions/IS-2"), + request("GET", "/api/shared-sessions"), + request("GET", "/api/shared-sessions/IS-2/"), + request("POST", "/api/terminal/ws"), + request("GET", "/api/terminal/ws/extra"), + ]) { + assert.equal(await dispatch(value, calls), null); + } + assert.deepEqual(calls, []); +}); From 1baa7ce29565f030f44a8ac44b1d2b98c27ec4a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 16:24:14 +0100 Subject: [PATCH 063/109] refactor: isolate scheduled reconciliation --- src/index.ts | 26 ++++++++-------- src/worker/scheduled.ts | 20 +++++++++++++ tests/runtime-adapter.test.ts | 9 +----- tests/scheduled.test.ts | 56 +++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 src/worker/scheduled.ts create mode 100644 tests/scheduled.test.ts diff --git a/src/index.ts b/src/index.ts index 91aa82b..feecf12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -282,6 +282,7 @@ import { canManageInteractiveSession, } from "./worker/session-access"; import { presentInteractiveSession } from "./worker/session-presentation"; +import { scheduleInteractiveSessionReconciliation } from "./worker/scheduled"; import { archiveInteractiveSessionLogs, sessionLogTranscript } from "./worker/session-log-archive"; import { appendInteractiveSessionEventRecord } from "./worker/session-events"; import { createInteractiveSessionCleanupService } from "./worker/session-cleanup"; @@ -1468,11 +1469,13 @@ export default { env: RuntimeEnv, context: ExecutionContext, ): Promise { - context.waitUntil( - reconcileInteractiveSessionLifecycleBatch(env, Date.now()).catch((error) => { + scheduleInteractiveSessionReconciliation(context, { + now: Date.now, + reconcile: (now) => interactiveSessionReconciliationScheduler(env).runBatch(now), + reportError: (error) => { console.error("scheduled interactive session reconciliation failed", error); - }), - ); + }, + }); }, } satisfies ExportedHandler; @@ -2339,9 +2342,11 @@ async function reconcileExternalInteractiveSessions( now: number, context?: ExecutionContext, ): Promise { - const reconciliation = reconcileInteractiveSessionLifecycleBatch(env, now).catch((error) => { - console.error("interactive session reconciliation failed", error); - }); + const reconciliation = interactiveSessionReconciliationScheduler(env) + .runBatch(now) + .catch((error) => { + console.error("interactive session reconciliation failed", error); + }); if (!context) { await reconciliation; return; @@ -2353,13 +2358,6 @@ async function reconcileExternalInteractiveSessions( ]); } -async function reconcileInteractiveSessionLifecycleBatch( - env: RuntimeEnv, - now: number, -): Promise { - await interactiveSessionReconciliationScheduler(env).runBatch(now); -} - function interactiveSessionReconciliationScheduler( env: RuntimeEnv, ): InteractiveSessionReconciliationScheduler { diff --git a/src/worker/scheduled.ts b/src/worker/scheduled.ts new file mode 100644 index 0000000..0390f2a --- /dev/null +++ b/src/worker/scheduled.ts @@ -0,0 +1,20 @@ +export type ScheduledExecutionContext = { + waitUntil(promise: Promise): void; +}; + +export type ScheduledReconciliationDependencies = { + now(): number; + reconcile(now: number): Promise; + reportError(error: unknown): void; +}; + +export function scheduleInteractiveSessionReconciliation( + context: ScheduledExecutionContext, + dependencies: ScheduledReconciliationDependencies, +): void { + context.waitUntil( + dependencies.reconcile(dependencies.now()).catch((error) => { + dependencies.reportError(error); + }), + ); +} diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index b349090..d367712 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -565,12 +565,10 @@ test("terminal archive finalization remains durably retryable", async () => { assert.match(migration, /status IN \('stopped', 'expired', 'failed'\)/); }); -test("runtime reconciliation has scheduled and targeted lifecycle clocks", async () => { +test("runtime reconciliation keeps its cron and targeted refresh paths", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const config = await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"); - assert.match(source, /async scheduled\(/); - assert.match(source, /context\.waitUntil\(\s*reconcileInteractiveSessionLifecycleBatch/); assert.match(config, /"crons": \["\* \* \* \* \*"\]/); assert.match(source, /async function readFreshInteractiveSession/); assert.match( @@ -578,11 +576,6 @@ test("runtime reconciliation has scheduled and targeted lifecycle clocks", async /async function subscribeTerminalHubSession[\s\S]*readFreshInteractiveSession/, ); assert.match(source, /async function interactiveSessionVnc[\s\S]*readFreshInteractiveSession/); - assert.match(source, /scheduled interactive session reconciliation failed/); - assert.match( - source, - /async function reconcileInteractiveSessionLifecycleBatch[\s\S]*interactiveSessionReconciliationScheduler\(env\)\.runBatch\(now\)/, - ); assert.match( source, /async function reconcileExternalInteractiveSessionById[\s\S]*interactiveSessionReconciliationScheduler\(env\)\.reconcileById\(id, now\)/, diff --git a/tests/scheduled.test.ts b/tests/scheduled.test.ts new file mode 100644 index 0000000..516d430 --- /dev/null +++ b/tests/scheduled.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + scheduleInteractiveSessionReconciliation, + type ScheduledExecutionContext, +} from "../src/worker/scheduled.ts"; + +function context(tasks: Promise[]): ScheduledExecutionContext { + return { + waitUntil(task) { + tasks.push(task); + }, + }; +} + +test("scheduled reconciliation captures one clock and registers its task", async () => { + const tasks: Promise[] = []; + const calls: string[] = []; + + scheduleInteractiveSessionReconciliation(context(tasks), { + now: () => { + calls.push("now"); + return 123; + }, + reconcile: async (now) => { + calls.push(`reconcile:${now}`); + }, + reportError: () => { + calls.push("error"); + }, + }); + + assert.equal(tasks.length, 1); + await tasks[0]; + assert.deepEqual(calls, ["now", "reconcile:123"]); +}); + +test("scheduled reconciliation reports task failures without rejecting waitUntil", async () => { + const tasks: Promise[] = []; + const failure = new Error("failed"); + const reported: unknown[] = []; + + scheduleInteractiveSessionReconciliation(context(tasks), { + now: () => 456, + reconcile: async () => { + throw failure; + }, + reportError: (error) => { + reported.push(error); + }, + }); + + await assert.doesNotReject(tasks[0]); + assert.deepEqual(reported, [failure]); +}); From ffd51f1f4c74258dcbe5c73e4da345ccf53e2696 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 16:30:11 +0100 Subject: [PATCH 064/109] refactor: extract app dialogs --- src/app/dialog-state.js | 41 +++++++ src/app/dialogs.jsx | 195 ++++++++++++++++++++++++++++++++ src/app/main.jsx | 196 +-------------------------------- tests/app-dialog-state.test.ts | 54 +++++++++ tests/html-dialogs.test.ts | 10 +- 5 files changed, 298 insertions(+), 198 deletions(-) create mode 100644 src/app/dialog-state.js create mode 100644 src/app/dialogs.jsx create mode 100644 tests/app-dialog-state.test.ts diff --git a/src/app/dialog-state.js b/src/app/dialog-state.js new file mode 100644 index 0000000..2d2e3d0 --- /dev/null +++ b/src/app/dialog-state.js @@ -0,0 +1,41 @@ +export const initialActionDialogState = { + nextId: 1, + dialog: null, +}; + +export function actionDialogReducer(state, action) { + switch (action.type) { + case "open": + return { + nextId: state.nextId + 1, + dialog: { + id: state.nextId, + pending: false, + error: "", + ...action.options, + }, + }; + case "close": + return state.dialog?.pending ? state : { ...state, dialog: null }; + case "start": + return updateDialog(state, action.id, (dialog) => ({ + ...dialog, + pending: true, + error: "", + })); + case "resolve": + return state.dialog?.id === action.id ? { ...state, dialog: null } : state; + case "reject": + return updateDialog(state, action.id, (dialog) => ({ + ...dialog, + pending: false, + error: action.message || "The action could not be completed.", + })); + default: + return state; + } +} + +function updateDialog(state, id, update) { + return state.dialog?.id === id ? { ...state, dialog: update(state.dialog) } : state; +} diff --git a/src/app/dialogs.jsx b/src/app/dialogs.jsx new file mode 100644 index 0000000..c3e5b6b --- /dev/null +++ b/src/app/dialogs.jsx @@ -0,0 +1,195 @@ +import { useLayoutEffect, useReducer, useRef, useState } from "preact/hooks"; + +import { Icon } from "./components.jsx"; +import { actionDialogReducer, initialActionDialogState } from "./dialog-state.js"; + +export function useActionDialog() { + const [state, dispatch] = useReducer(actionDialogReducer, initialActionDialogState); + + function openActionDialog(options) { + dispatch({ type: "open", options }); + } + + function closeActionDialog() { + dispatch({ type: "close" }); + } + + async function confirmActionDialog() { + const current = state.dialog; + if (!current?.action || current.pending) return; + dispatch({ type: "start", id: current.id }); + try { + await current.action(); + dispatch({ type: "resolve", id: current.id }); + } catch (error) { + dispatch({ + type: "reject", + id: current.id, + message: error?.message || "The action could not be completed.", + }); + } + } + + return { + dialog: state.dialog, + openActionDialog, + closeActionDialog, + confirmActionDialog, + }; +} + +export function Drawer({ id, open, title, wide, onClose, children }) { + const elementRef = useRef(null); + const titleId = `${id}-title`; + + useLayoutEffect(() => { + const element = elementRef.current; + if (!open || !element) return; + const previousFocus = document.activeElement; + if (!element.open) element.showModal(); + element + .querySelector( + ".panel-body input, .panel-body select, .panel-body textarea, .panel-body button", + ) + ?.focus(); + return () => { + if (element.open) element.close(); + previousFocus?.focus?.(); + }; + }, [open]); + + return ( + { + event.preventDefault(); + onClose(); + }} + onClick={(event) => { + if (event.target === event.currentTarget) onClose(); + }} + onKeyDown={(event) => event.stopPropagation()} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ); +} + +export function ActionDialog({ dialog, onCancel, onConfirm }) { + const elementRef = useRef(null); + const cancelRef = useRef(null); + const valueRef = useRef(null); + const [copied, setCopied] = useState(false); + + useLayoutEffect(() => { + if (!dialog) return; + const element = elementRef.current; + const previousFocus = document.activeElement; + setCopied(false); + if (element && !element.open) element.showModal(); + const focusTarget = dialog.kind === "share" ? valueRef.current : cancelRef.current; + focusTarget?.focus(); + if (dialog.kind === "share") focusTarget?.select(); + return () => { + if (element?.open) element.close(); + previousFocus?.focus?.(); + }; + }, [dialog?.id]); + + if (!dialog) return null; + const titleId = `action-dialog-title-${dialog.id}`; + const descriptionId = `action-dialog-description-${dialog.id}`; + + async function copyValue() { + const value = dialog.value || ""; + let success = false; + try { + await navigator.clipboard?.writeText(value); + success = Boolean(navigator.clipboard); + } catch {} + if (!success && valueRef.current) { + valueRef.current.select(); + success = Boolean(document.execCommand?.("copy")); + } + setCopied(success); + } + + return ( + { + event.preventDefault(); + if (!dialog.pending) onCancel(); + }} + onClick={(event) => { + if (event.target === event.currentTarget && !dialog.pending) onCancel(); + }} + onKeyDown={(event) => event.stopPropagation()} + > +
+
+ +
+ {dialog.eyebrow} +

{dialog.title}

+

{dialog.description}

+
+ {dialog.subject ? {dialog.subject} : null} + {dialog.kind === "share" ? ( + + ) : null} + {dialog.error ? ( + + ) : null} +
+
+ {dialog.kind === "share" ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+ ); +} diff --git a/src/app/main.jsx b/src/app/main.jsx index 5e3ee11..fff3be2 100644 --- a/src/app/main.jsx +++ b/src/app/main.jsx @@ -2,6 +2,7 @@ import { render } from "preact"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import { api } from "./api.js"; import { CopyCommand, Icon } from "./components.jsx"; +import { ActionDialog, Drawer, useActionDialog } from "./dialogs.jsx"; import { FleetPage } from "./fleet.jsx"; import { canDeleteInteractiveWorkspace, @@ -114,7 +115,7 @@ function App() { const [theme, setThemeState] = useState( document.documentElement.dataset.theme === "light" ? "light" : "dark", ); - const [dialog, setDialog] = useState(null); + const { dialog, openActionDialog, closeActionDialog, confirmActionDialog } = useActionDialog(); const [sessionLayout, setSessionLayout] = useState(loadSessionLayout); const [terminalStatus, setTerminalStatus] = useState({}); const stateRef = useRef(state); @@ -129,7 +130,6 @@ function App() { const refPreviewSeq = useRef(0); const draggedSessionId = useRef(null); const autoLoginStarted = useRef(false); - const dialogSequence = useRef(0); const allSessionItems = useMemo(() => sessionItems(state), [state]); const sessionItemById = useMemo( @@ -504,42 +504,6 @@ function App() { setThemeState(value === "light" ? "light" : "dark"); } - function openActionDialog(options) { - dialogSequence.current += 1; - setDialog({ - id: dialogSequence.current, - pending: false, - error: "", - ...options, - }); - } - - function closeActionDialog() { - setDialog((current) => (current?.pending ? current : null)); - } - - async function confirmActionDialog() { - const current = dialog; - if (!current?.action || current.pending) return; - setDialog((value) => - value?.id === current.id ? { ...value, pending: true, error: "" } : value, - ); - try { - await current.action(); - setDialog((value) => (value?.id === current.id ? null : value)); - } catch (error) { - setDialog((value) => - value?.id === current.id - ? { - ...value, - pending: false, - error: error?.message || "The action could not be completed.", - } - : value, - ); - } - } - async function beginLogin() { try { sessionStorage.removeItem(skipAutoGithubLoginKey); @@ -2672,162 +2636,6 @@ function WorkflowBox({ disabled, workflows, refreshWorkflow, preferred = preferr ); } -function Drawer({ id, open, title, wide, onClose, children }) { - const elementRef = useRef(null); - const titleId = `${id}-title`; - - useLayoutEffect(() => { - const element = elementRef.current; - if (!open || !element) return; - const previousFocus = document.activeElement; - if (!element.open) element.showModal(); - element - .querySelector( - ".panel-body input, .panel-body select, .panel-body textarea, .panel-body button", - ) - ?.focus(); - return () => { - if (element.open) element.close(); - previousFocus?.focus?.(); - }; - }, [open]); - - return ( - { - event.preventDefault(); - onClose(); - }} - onClick={(event) => { - if (event.target === event.currentTarget) onClose(); - }} - onKeyDown={(event) => event.stopPropagation()} - > -
-
-

{title}

- -
-
{children}
-
-
- ); -} - -function ActionDialog({ dialog, onCancel, onConfirm }) { - const elementRef = useRef(null); - const cancelRef = useRef(null); - const valueRef = useRef(null); - const [copied, setCopied] = useState(false); - - useLayoutEffect(() => { - if (!dialog) return; - const element = elementRef.current; - const previousFocus = document.activeElement; - setCopied(false); - if (element && !element.open) element.showModal(); - const focusTarget = dialog.kind === "share" ? valueRef.current : cancelRef.current; - focusTarget?.focus(); - if (dialog.kind === "share") focusTarget?.select(); - return () => { - if (element?.open) element.close(); - previousFocus?.focus?.(); - }; - }, [dialog?.id]); - - if (!dialog) return null; - const titleId = `action-dialog-title-${dialog.id}`; - const descriptionId = `action-dialog-description-${dialog.id}`; - - async function copyValue() { - const value = dialog.value || ""; - let success = false; - try { - await navigator.clipboard?.writeText(value); - success = Boolean(navigator.clipboard); - } catch {} - if (!success && valueRef.current) { - valueRef.current.select(); - success = Boolean(document.execCommand?.("copy")); - } - setCopied(success); - } - - return ( - { - event.preventDefault(); - if (!dialog.pending) onCancel(); - }} - onClick={(event) => { - if (event.target === event.currentTarget && !dialog.pending) onCancel(); - }} - onKeyDown={(event) => event.stopPropagation()} - > -
-
- -
- {dialog.eyebrow} -

{dialog.title}

-

{dialog.description}

-
- {dialog.subject ? {dialog.subject} : null} - {dialog.kind === "share" ? ( - - ) : null} - {dialog.error ? ( - - ) : null} -
-
- {dialog.kind === "share" ? ( - <> - - - - ) : ( - <> - - - - )} -
-
-
- ); -} - function orderedSessionItems(items, layout) { const currentIds = new Set(items.map((item) => item.id)); if (!layout.manualOrder) return items; diff --git a/tests/app-dialog-state.test.ts b/tests/app-dialog-state.test.ts new file mode 100644 index 0000000..b8c6e4c --- /dev/null +++ b/tests/app-dialog-state.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { actionDialogReducer, initialActionDialogState } from "../src/app/dialog-state.js"; + +test("action dialogs allocate stable identities and close only when idle", () => { + const opened = actionDialogReducer(initialActionDialogState, { + type: "open", + options: { kind: "danger", title: "Delete", action: () => undefined }, + }); + assert.equal(opened.nextId, 2); + assert.deepEqual( + { ...opened.dialog, action: undefined }, + { + id: 1, + pending: false, + error: "", + kind: "danger", + title: "Delete", + action: undefined, + }, + ); + + const pending = actionDialogReducer(opened, { type: "start", id: 1 }); + assert.equal(pending.dialog?.pending, true); + assert.equal(actionDialogReducer(pending, { type: "close" }), pending); + + const closed = actionDialogReducer(opened, { type: "close" }); + assert.equal(closed.dialog, null); + assert.equal(closed.nextId, 2); +}); + +test("action dialog completion is fenced to the active identity", () => { + const first = actionDialogReducer(initialActionDialogState, { + type: "open", + options: { title: "First" }, + }); + const second = actionDialogReducer(first, { + type: "open", + options: { title: "Second" }, + }); + + assert.equal(actionDialogReducer(second, { type: "resolve", id: 1 }), second); + assert.equal(actionDialogReducer(second, { type: "reject", id: 1, message: "old" }), second); + + const failed = actionDialogReducer(second, { + type: "reject", + id: 2, + message: "Failed safely", + }); + assert.equal(failed.dialog?.pending, false); + assert.equal(failed.dialog?.error, "Failed safely"); + assert.equal(actionDialogReducer(failed, { type: "resolve", id: 2 }).dialog, null); +}); diff --git a/tests/html-dialogs.test.ts b/tests/html-dialogs.test.ts index 9bf7ad2..1d09d80 100644 --- a/tests/html-dialogs.test.ts +++ b/tests/html-dialogs.test.ts @@ -4,12 +4,14 @@ import { test } from "node:test"; test("app actions use styled HTML dialogs instead of browser prompts", async () => { const source = await readFile(new URL("../src/app/main.jsx", import.meta.url), "utf8"); + const dialogs = await readFile(new URL("../src/app/dialogs.jsx", import.meta.url), "utf8"); assert.doesNotMatch(source, /\bwindow\.(?:alert|confirm|prompt)\s*\(/); - assert.match(source, //); From 5bfb1f75e48691404fada2c4b6241bc0facd3dc3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 16:35:41 +0100 Subject: [PATCH 065/109] refactor: extract app routing and layout --- src/app/main.jsx | 149 ++++++------------------------- src/app/routing.js | 70 +++++++++++++++ src/app/session-layout.js | 65 ++++++++++++++ tests/app-routing.test.ts | 81 +++++++++++++++++ tests/app-session-layout.test.ts | 72 +++++++++++++++ 5 files changed, 316 insertions(+), 121 deletions(-) create mode 100644 src/app/routing.js create mode 100644 src/app/session-layout.js create mode 100644 tests/app-routing.test.ts create mode 100644 tests/app-session-layout.test.ts diff --git a/src/app/main.jsx b/src/app/main.jsx index fff3be2..5cb2fd5 100644 --- a/src/app/main.jsx +++ b/src/app/main.jsx @@ -4,6 +4,22 @@ import { api } from "./api.js"; import { CopyCommand, Icon } from "./components.jsx"; import { ActionDialog, Drawer, useActionDialog } from "./dialogs.jsx"; import { FleetPage } from "./fleet.jsx"; +import { + appViewUrl, + initialAppView, + isGithubLoginCallback, + loginReturnKey, + parseSessionLink, + restoreSessionReturnUrl, + sessionRouteUrl, +} from "./routing.js"; +import { + defaultSessionLayout, + loadSessionLayout, + moveSessionLayoutItem, + orderedSessionItems, + saveSessionLayout, +} from "./session-layout.js"; import { canDeleteInteractiveWorkspace, canMaintain, @@ -54,10 +70,8 @@ const defaultDeployment = { defaultProfile: "default", runtimeProfiles: [], }; -const loginReturnKey = "crabbox-login-return"; const skipAutoGithubLoginKey = "crabbox-skip-auto-github-login"; const githubAutoLoginReadyKey = "crabbox-github-auto-login-ready"; -const sessionLayoutStorageKey = "crabbox-session-layout-v1"; const emptyState = { cards: [], interactiveSessions: [], @@ -449,10 +463,7 @@ function App() { setAppViewState(next); closeAllDrawers(); if (!history.pushState) return; - const url = new URL(location.href); - url.pathname = next === "board" ? "/app/board" : "/app/fleet"; - url.search = ""; - history.pushState(null, "", url); + history.pushState(null, "", appViewUrl(location.href, next)); } function closeTopDrawer() { @@ -486,18 +497,17 @@ function App() { function setSessionUrl(id, options = {}) { if (!history.replaceState) return; - if (id) { - const url = new URL(location.href); - url.pathname = `/sessions/${encodeURIComponent(id)}`; - url.search = ""; - if (sharedToken && id === sharedSessionId) url.searchParams.set("token", sharedToken); - history.replaceState(null, "", url); - return; - } - const url = new URL(location.href); - url.pathname = options.grid ? "/sessions" : appView === "board" ? "/app/board" : "/app/fleet"; - url.search = ""; - history.replaceState(null, "", url); + history.replaceState( + null, + "", + sessionRouteUrl(location.href, { + id, + grid: options.grid, + appView, + sharedSessionId, + sharedToken, + }), + ); } function setTheme(value) { @@ -2636,109 +2646,6 @@ function WorkflowBox({ disabled, workflows, refreshWorkflow, preferred = preferr ); } -function orderedSessionItems(items, layout) { - const currentIds = new Set(items.map((item) => item.id)); - if (!layout.manualOrder) return items; - const order = [ - ...layout.order.filter((id) => currentIds.has(id)), - ...items.map((item) => item.id).filter((id) => !layout.order.includes(id)), - ]; - const rank = new Map(order.map((id, index) => [id, index])); - return [...items].sort( - (left, right) => - (rank.get(left.id) ?? Number.MAX_SAFE_INTEGER) - - (rank.get(right.id) ?? Number.MAX_SAFE_INTEGER), - ); -} - -function moveSessionLayoutItem(layout, items, sourceId, targetId) { - const ids = orderedSessionItems(items, layout).map((item) => item.id); - const sourceIndex = ids.indexOf(sourceId); - const targetIndex = ids.indexOf(targetId); - if (sourceIndex === -1 || targetIndex === -1) return layout; - ids.splice(sourceIndex, 1); - ids.splice(targetIndex, 0, sourceId); - return { ...layout, manualOrder: true, order: ids }; -} - -function defaultSessionLayout(edit = false) { - return { columns: "auto", edit, manualOrder: false, order: [], sizes: {} }; -} - -function loadSessionLayout() { - try { - return normalizeSessionLayout( - JSON.parse(localStorage.getItem(sessionLayoutStorageKey) || "null") || defaultSessionLayout(), - ); - } catch { - return defaultSessionLayout(); - } -} - -function saveSessionLayout(layout) { - try { - localStorage.setItem( - sessionLayoutStorageKey, - JSON.stringify({ - columns: layout.columns, - manualOrder: layout.manualOrder, - order: layout.order, - sizes: layout.sizes, - }), - ); - } catch {} -} - -function normalizeSessionLayout(value) { - return { - columns: ["auto", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"].includes( - String(value?.columns), - ) - ? String(value.columns) - : "auto", - edit: false, - manualOrder: Boolean(value?.manualOrder), - order: Array.isArray(value?.order) ? value.order.map(String).slice(0, 200) : [], - sizes: typeof value?.sizes === "object" && value.sizes ? value.sizes : {}, - }; -} - -function parseSessionLink() { - const match = location.pathname.match(/^\/(?:app\/)?sessions(?:\/([^/]+))?\/?$/); - return { - route: Boolean(match), - id: match?.[1] ? decodeURIComponent(match[1]) : null, - token: new URLSearchParams(location.search).get("token"), - }; -} - -function initialAppView() { - return location.pathname === "/app/board" || location.pathname === "/app/board/" - ? "board" - : "fleet"; -} - -function isGithubLoginCallback() { - return new URLSearchParams(location.search).get("login") === "github"; -} - -function restoreSessionReturnUrl() { - try { - const saved = sessionStorage.getItem(loginReturnKey); - if (!saved || !history.replaceState) return; - const url = new URL(saved, location.origin); - const isSessionUrl = - url.pathname === "/sessions" || - url.pathname === "/sessions/" || - url.pathname.startsWith("/sessions/") || - url.pathname.startsWith("/app/sessions/"); - if (url.origin !== location.origin || !isSessionUrl) return; - if (location.pathname !== "/app" && location.pathname !== "/app/") return; - sessionStorage.removeItem(loginReturnKey); - history.replaceState(null, "", `${url.pathname}${url.search}`); - } catch {} -} - function isTerminalKeyTarget(event) { const active = document.activeElement; return Boolean( diff --git a/src/app/routing.js b/src/app/routing.js new file mode 100644 index 0000000..99ccb74 --- /dev/null +++ b/src/app/routing.js @@ -0,0 +1,70 @@ +export const loginReturnKey = "crabbox-login-return"; + +export function parseSessionLink(locationLike = location) { + const match = locationLike.pathname.match(/^\/(?:app\/)?sessions(?:\/([^/]+))?\/?$/); + return { + route: Boolean(match), + id: match?.[1] ? decodeURIComponent(match[1]) : null, + token: new URLSearchParams(locationLike.search).get("token"), + }; +} + +export function initialAppView(locationLike = location) { + return locationLike.pathname === "/app/board" || locationLike.pathname === "/app/board/" + ? "board" + : "fleet"; +} + +export function isGithubLoginCallback(locationLike = location) { + return new URLSearchParams(locationLike.search).get("login") === "github"; +} + +export function appViewUrl(href, value) { + const url = new URL(href); + url.pathname = value === "board" ? "/app/board" : "/app/fleet"; + url.search = ""; + return url; +} + +export function sessionRouteUrl( + href, + { id, grid = false, appView = "fleet", sharedSessionId = null, sharedToken = null }, +) { + const url = new URL(href); + if (id) { + url.pathname = `/sessions/${encodeURIComponent(id)}`; + url.search = ""; + if (sharedToken && id === sharedSessionId) url.searchParams.set("token", sharedToken); + return url; + } + url.pathname = grid ? "/sessions" : appView === "board" ? "/app/board" : "/app/fleet"; + url.search = ""; + return url; +} + +export function restorableSessionReturnUrl(saved, locationLike = location) { + if (!saved) return null; + const url = new URL(saved, locationLike.origin); + const isSessionUrl = + url.pathname === "/sessions" || + url.pathname === "/sessions/" || + url.pathname.startsWith("/sessions/") || + url.pathname.startsWith("/app/sessions/"); + if (url.origin !== locationLike.origin || !isSessionUrl) return null; + if (locationLike.pathname !== "/app" && locationLike.pathname !== "/app/") return null; + return `${url.pathname}${url.search}`; +} + +export function restoreSessionReturnUrl({ + storage = sessionStorage, + historyApi = history, + locationLike = location, +} = {}) { + try { + if (!historyApi.replaceState) return; + const restored = restorableSessionReturnUrl(storage.getItem(loginReturnKey), locationLike); + if (!restored) return; + storage.removeItem(loginReturnKey); + historyApi.replaceState(null, "", restored); + } catch {} +} diff --git a/src/app/session-layout.js b/src/app/session-layout.js new file mode 100644 index 0000000..200ad79 --- /dev/null +++ b/src/app/session-layout.js @@ -0,0 +1,65 @@ +const sessionLayoutStorageKey = "crabbox-session-layout-v1"; +const sessionLayoutColumns = new Set(["auto", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]); + +export function orderedSessionItems(items, layout) { + const currentIds = new Set(items.map((item) => item.id)); + if (!layout.manualOrder) return items; + const order = [ + ...layout.order.filter((id) => currentIds.has(id)), + ...items.map((item) => item.id).filter((id) => !layout.order.includes(id)), + ]; + const rank = new Map(order.map((id, index) => [id, index])); + return [...items].sort( + (left, right) => + (rank.get(left.id) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(right.id) ?? Number.MAX_SAFE_INTEGER), + ); +} + +export function moveSessionLayoutItem(layout, items, sourceId, targetId) { + const ids = orderedSessionItems(items, layout).map((item) => item.id); + const sourceIndex = ids.indexOf(sourceId); + const targetIndex = ids.indexOf(targetId); + if (sourceIndex === -1 || targetIndex === -1) return layout; + ids.splice(sourceIndex, 1); + ids.splice(targetIndex, 0, sourceId); + return { ...layout, manualOrder: true, order: ids }; +} + +export function defaultSessionLayout(edit = false) { + return { columns: "auto", edit, manualOrder: false, order: [], sizes: {} }; +} + +export function normalizeSessionLayout(value) { + return { + columns: sessionLayoutColumns.has(String(value?.columns)) ? String(value.columns) : "auto", + edit: false, + manualOrder: Boolean(value?.manualOrder), + order: Array.isArray(value?.order) ? value.order.map(String).slice(0, 200) : [], + sizes: typeof value?.sizes === "object" && value.sizes ? value.sizes : {}, + }; +} + +export function loadSessionLayout(storage = localStorage) { + try { + return normalizeSessionLayout( + JSON.parse(storage.getItem(sessionLayoutStorageKey) || "null") || defaultSessionLayout(), + ); + } catch { + return defaultSessionLayout(); + } +} + +export function saveSessionLayout(layout, storage = localStorage) { + try { + storage.setItem( + sessionLayoutStorageKey, + JSON.stringify({ + columns: layout.columns, + manualOrder: layout.manualOrder, + order: layout.order, + sizes: layout.sizes, + }), + ); + } catch {} +} diff --git a/tests/app-routing.test.ts b/tests/app-routing.test.ts new file mode 100644 index 0000000..072cb4c --- /dev/null +++ b/tests/app-routing.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + appViewUrl, + initialAppView, + isGithubLoginCallback, + loginReturnKey, + parseSessionLink, + restorableSessionReturnUrl, + restoreSessionReturnUrl, + sessionRouteUrl, +} from "../src/app/routing.js"; + +test("app routing parses board and shared session locations", () => { + assert.equal(initialAppView({ pathname: "/app/board/" }), "board"); + assert.equal(initialAppView({ pathname: "/app/fleet" }), "fleet"); + assert.equal(isGithubLoginCallback({ search: "?login=github" }), true); + assert.deepEqual( + parseSessionLink({ pathname: "/app/sessions/IS%2F2", search: "?token=share%20token" }), + { route: true, id: "IS/2", token: "share token" }, + ); + assert.deepEqual(parseSessionLink({ pathname: "/app/fleet", search: "" }), { + route: false, + id: null, + token: null, + }); +}); + +test("app and session route builders preserve only owned URL state", () => { + assert.equal( + appViewUrl("https://fleet.example/app?old=1#anchor", "board").toString(), + "https://fleet.example/app/board#anchor", + ); + assert.equal( + sessionRouteUrl("https://fleet.example/app/fleet?old=1#anchor", { + id: "IS/2", + sharedSessionId: "IS/2", + sharedToken: "share token", + }).toString(), + "https://fleet.example/sessions/IS%2F2?token=share+token#anchor", + ); + assert.equal( + sessionRouteUrl("https://fleet.example/sessions/IS-2?token=old", { + id: null, + grid: true, + }).toString(), + "https://fleet.example/sessions", + ); +}); + +test("login return restoration accepts only same-origin session routes", () => { + const locationLike = { + origin: "https://fleet.example", + pathname: "/app", + }; + assert.equal( + restorableSessionReturnUrl("https://fleet.example/sessions/IS-2?token=share", locationLike), + "/sessions/IS-2?token=share", + ); + assert.equal( + restorableSessionReturnUrl("https://attacker.example/sessions/IS-2", locationLike), + null, + ); + assert.equal(restorableSessionReturnUrl("https://fleet.example/admin", locationLike), null); + + const values = new Map([[loginReturnKey, "/sessions/IS-3"]]); + const calls: unknown[][] = []; + restoreSessionReturnUrl({ + storage: { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + }, + historyApi: { + replaceState: (...args: unknown[]) => calls.push(args), + }, + locationLike, + }); + assert.equal(values.has(loginReturnKey), false); + assert.deepEqual(calls, [[null, "", "/sessions/IS-3"]]); +}); diff --git a/tests/app-session-layout.test.ts b/tests/app-session-layout.test.ts new file mode 100644 index 0000000..3eaba9a --- /dev/null +++ b/tests/app-session-layout.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + defaultSessionLayout, + loadSessionLayout, + moveSessionLayoutItem, + normalizeSessionLayout, + orderedSessionItems, + saveSessionLayout, +} from "../src/app/session-layout.js"; + +test("session layout normalization bounds persisted values", () => { + assert.deepEqual( + normalizeSessionLayout({ + columns: 4, + edit: true, + manualOrder: 1, + order: Array.from({ length: 205 }, (_, index) => index), + sizes: { "IS-1": "large" }, + }), + { + columns: "4", + edit: false, + manualOrder: true, + order: Array.from({ length: 200 }, (_, index) => String(index)), + sizes: { "IS-1": "large" }, + }, + ); + assert.equal(normalizeSessionLayout({ columns: "wide" }).columns, "auto"); +}); + +test("session layout ordering retains new items and supports stable moves", () => { + const items = [{ id: "IS-1" }, { id: "IS-2" }, { id: "IS-3" }]; + const layout = { ...defaultSessionLayout(), manualOrder: true, order: ["IS-2", "missing"] }; + + assert.deepEqual( + orderedSessionItems(items, layout).map((item) => item.id), + ["IS-2", "IS-1", "IS-3"], + ); + assert.deepEqual(moveSessionLayoutItem(layout, items, "IS-3", "IS-2").order, [ + "IS-3", + "IS-2", + "IS-1", + ]); + assert.equal(moveSessionLayoutItem(layout, items, "missing", "IS-2"), layout); +}); + +test("session layout persistence stores durable fields and fails closed", () => { + const values = new Map(); + const storage = { + getItem(key: string) { + return values.get(key) ?? null; + }, + setItem(key: string, value: string) { + values.set(key, value); + }, + }; + const layout = { + columns: "3", + edit: true, + manualOrder: true, + order: ["IS-2"], + sizes: { "IS-2": "large" }, + }; + + saveSessionLayout(layout, storage); + assert.deepEqual(loadSessionLayout(storage), { ...layout, edit: false }); + + values.set("crabbox-session-layout-v1", "{"); + assert.deepEqual(loadSessionLayout(storage), defaultSessionLayout()); +}); From beeb772c8c66e5b8d41815892ee55b8ec31209a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 16:44:48 +0100 Subject: [PATCH 066/109] refactor: extract app data lifecycle --- src/app/app-data.js | 478 +++++++++++++++++++++++++++++++++++++ src/app/main.jsx | 330 +++---------------------- tests/app-data.test.ts | 113 +++++++++ tests/html-dialogs.test.ts | 4 +- 4 files changed, 630 insertions(+), 295 deletions(-) create mode 100644 src/app/app-data.js create mode 100644 tests/app-data.test.ts diff --git a/src/app/app-data.js b/src/app/app-data.js new file mode 100644 index 0000000..37f5838 --- /dev/null +++ b/src/app/app-data.js @@ -0,0 +1,478 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { api } from "./api.js"; +import { isGithubLoginCallback, loginReturnKey } from "./routing.js"; +import { linkedInteractiveSessionPlaceholder, preferredRepo } from "./utils.js"; + +export const defaultDeployment = { + label: "Crabfleet", + canonicalUrl: "https://crabfleet.openclaw.ai", + productUrl: "https://crabfleet.ai", + sshHost: "crabd.sh", + preferredRepo, + defaultRuntime: "container", + defaultProfile: "default", + runtimeProfiles: [], +}; + +export const defaultAuthMethods = { + github: false, + token: false, + devIdentity: false, + trustedProxy: false, +}; + +export const emptyState = { + cards: [], + interactiveSessions: [], + fleet: null, + allow: [], + repos: [], + workflows: [], + cap: 20, + retention: "30", + merge: "guarded", + deployment: defaultDeployment, +}; + +const skipAutoGithubLoginKey = "crabbox-skip-auto-github-login"; +const githubAutoLoginReadyKey = "crabbox-github-auto-login-ready"; + +export function initialAppState(initialSessionLink) { + if (!initialSessionLink.id) return emptyState; + return { + ...emptyState, + interactiveSessions: [ + linkedInteractiveSessionPlaceholder(initialSessionLink.id, { + sharedReadOnly: Boolean(initialSessionLink.token), + }), + ], + }; +} + +export function retainLinkedSession(nextState, linkedSession) { + if ( + !linkedSession || + (nextState.interactiveSessions || []).some((session) => session.id === linkedSession.id) + ) { + return nextState; + } + return { + ...nextState, + interactiveSessions: [linkedSession, ...(nextState.interactiveSessions || [])], + }; +} + +export function sharedSessionState(session, auth, deployment = defaultDeployment) { + return { + user: { subject: "shared", login: "shared link", role: "viewer" }, + auth, + org: "OpenClaw", + cap: 20, + retention: "30", + merge: "guarded", + allow: [], + repos: [session.repo], + workflows: [], + cards: [], + interactiveSessions: [session], + deployment, + }; +} + +export function shouldAutoGithubLogin({ + signedIn, + started, + methods, + shared, + tokenBypass, + skipped, + ready, +}) { + if (signedIn || started || !methods?.github || methods.devIdentity) return false; + if (methods.token && tokenBypass) return false; + if (shared?.id && shared?.token) return false; + return !skipped && ready; +} + +export function createAppPolling({ + runInitial, + runInterval, + runRetry = runInitial, + timers = globalThis, + pollIntervalMs = 15000, + retryDelayMs = 5000, +}) { + let intervalId = null; + let retryId = null; + + return { + start() { + if (intervalId !== null) return; + void runInitial(); + intervalId = timers.setInterval(() => void runInterval(), pollIntervalMs); + }, + scheduleRetry() { + if (retryId !== null) return; + retryId = timers.setTimeout(() => { + retryId = null; + void runRetry(); + }, retryDelayMs); + }, + clearRetry() { + if (retryId === null) return; + timers.clearTimeout(retryId); + retryId = null; + }, + stop() { + if (intervalId !== null) timers.clearInterval(intervalId); + intervalId = null; + if (retryId !== null) timers.clearTimeout(retryId); + retryId = null; + }, + }; +} + +export function createRequestFence() { + let generation = 0; + return { + next() { + generation += 1; + return generation; + }, + isCurrent(candidate) { + return candidate === generation; + }, + }; +} + +export function useAppData({ + initialSessionLink, + activeRunId, + runDrawerOpen, + sharedSessionId, + sharedToken, + onSignedOut, + onSharedSessionLoaded, + onSharedSessionRejected, +}) { + const [state, setState] = useState(() => initialAppState(initialSessionLink)); + const [signedIn, setSignedIn] = useState(false); + const [authMethods, setAuthMethods] = useState(defaultAuthMethods); + const [loginMessage, setLoginMessage] = useState(""); + const stateRef = useRef(state); + const signedInRef = useRef(signedIn); + const authMethodsRef = useRef(authMethods); + const activeRunRef = useRef({ id: activeRunId, open: runDrawerOpen }); + const sharedRef = useRef({ id: sharedSessionId, token: sharedToken }); + const callbacksRef = useRef({ + onSignedOut, + onSharedSessionLoaded, + onSharedSessionRejected, + }); + const mountedRef = useRef(false); + const autoLoginStarted = useRef(false); + const githubLoginCallback = useRef(isGithubLoginCallback()); + const stateRequestRef = useRef(null); + const stateRequestFence = useRef(createRequestFence()); + const sharedRequestRef = useRef(null); + const loadStateRef = useRef(null); + const loadSharedSessionRef = useRef(null); + const pollingRef = useRef(null); + + stateRef.current = state; + signedInRef.current = signedIn; + authMethodsRef.current = authMethods; + activeRunRef.current = { id: activeRunId, open: runDrawerOpen }; + sharedRef.current = { id: sharedSessionId, token: sharedToken }; + callbacksRef.current = { + onSignedOut, + onSharedSessionLoaded, + onSharedSessionRejected, + }; + + if (!pollingRef.current) { + pollingRef.current = createAppPolling({ + runInitial: () => loadStateRef.current?.(), + runInterval: () => { + if (signedInRef.current) return loadStateRef.current?.(); + const shared = sharedRef.current; + if (!shared.id || !shared.token || document.body.classList.contains("locked")) return; + return loadSharedSessionRef.current?.().catch((error) => { + if (error.status === 403 || error.status === 404) { + return showSharedLinkError(error); + } + console.warn("Shared session refresh failed", error); + }); + }, + runRetry: () => loadStateRef.current?.(), + }); + } + + useEffect(() => { + mountedRef.current = true; + pollingRef.current.start(); + return () => { + mountedRef.current = false; + pollingRef.current.stop(); + }; + }, []); + + useEffect(() => { + if (!signedIn && !loginMessage) void maybeAutoGithubLogin(authMethods); + }, [signedIn, loginMessage, authMethods, sharedSessionId, sharedToken]); + + async function performLoadState(generation) { + try { + let nextState = await api("/api/state", { authOptional: true }); + if (!isCurrentStateRequest(generation)) return; + const linkedSessionId = sharedRef.current.id; + const linkedSession = linkedSessionId + ? (stateRef.current.interactiveSessions || []).find( + (session) => session.id === linkedSessionId, + ) + : null; + nextState = retainLinkedSession(nextState, linkedSession); + const activeRun = activeRunRef.current; + const activeCard = nextState.cards.find((card) => card.id === activeRun.id); + if (activeRun.id && activeRun.open && activeCard?.changes?.files?.length) { + const result = await api(`/api/cards/${encodeURIComponent(activeRun.id)}/actions`, { + method: "POST", + body: { action: "attach" }, + }); + if (!isCurrentStateRequest(generation)) return; + nextState.cards = nextState.cards.map((card) => + card.id === result.card.id ? result.card : card, + ); + } + pollingRef.current.clearRetry(); + setAuthMethods(nextState.auth || authMethodsRef.current); + setState(nextState); + setSignedIn(true); + setLoginMessage(""); + finishGithubLoginCallback(true); + } catch (error) { + if (!isCurrentStateRequest(generation)) return; + if (error.status === 401 || error.status === 403) { + const shared = sharedRef.current; + if (shared.id && shared.token) { + try { + await loadSharedSession(); + } catch (sharedError) { + await showSharedLinkError(sharedError); + } + return; + } + const methods = await loadAuthMethods(); + if (!isCurrentStateRequest(generation)) return; + finishGithubLoginCallback(false); + if (error.status === 401 && (await maybeAutoGithubLogin(methods))) return; + callbacksRef.current.onSignedOut?.(); + setSignedIn(false); + setLoginMessage(error.message === "unauthorized" ? "" : error.message); + return; + } + setLoginMessage(error.message); + pollingRef.current.scheduleRetry(); + } + } + + function loadState({ fresh = false } = {}) { + if (!fresh && stateRequestRef.current) return stateRequestRef.current.request; + const generation = stateRequestFence.current.next(); + const request = performLoadState(generation).finally(() => { + if (stateRequestRef.current?.request === request) stateRequestRef.current = null; + }); + stateRequestRef.current = { generation, request }; + return request; + } + + function refreshState() { + return loadState({ fresh: true }); + } + + function isCurrentStateRequest(generation) { + return mountedRef.current && stateRequestFence.current.isCurrent(generation); + } + + async function performLoadSharedSession(shared) { + let result; + try { + result = await api( + `/api/shared-sessions/${encodeURIComponent(shared.id)}?token=${encodeURIComponent(shared.token)}`, + { authOptional: true }, + ); + } catch (error) { + if (!sameSharedLink(sharedRef.current, shared)) return null; + throw error; + } + if (!sameSharedLink(sharedRef.current, shared)) return null; + const methods = await loadAuthMethods(); + if (!mountedRef.current || !sameSharedLink(sharedRef.current, shared)) return null; + setState( + sharedSessionState(result.session, methods, stateRef.current.deployment || defaultDeployment), + ); + setSignedIn(false); + callbacksRef.current.onSharedSessionLoaded?.(result.session); + return result.session; + } + + function loadSharedSession() { + const shared = { ...sharedRef.current }; + const key = sharedLinkKey(shared); + if (sharedRequestRef.current?.key === key) return sharedRequestRef.current.request; + const request = performLoadSharedSession(shared).finally(() => { + if (sharedRequestRef.current?.request === request) sharedRequestRef.current = null; + }); + sharedRequestRef.current = { key, request }; + return request; + } + + async function showSharedLinkError(error) { + await loadAuthMethods(); + if (!mountedRef.current) return; + callbacksRef.current.onSharedSessionRejected?.(); + setSignedIn(false); + setLoginMessage( + error?.status === 404 + ? "Shared session link is invalid or expired." + : error?.message || "Shared session could not be loaded.", + ); + } + + async function loadAuthMethods() { + try { + const result = await api("/api/auth", { authOptional: true }); + const methods = result.auth || authMethodsRef.current; + if (!mountedRef.current) return methods; + if (result.deployment) { + setState((current) => ({ ...current, deployment: result.deployment })); + } + setAuthMethods(methods); + return methods; + } catch { + const methods = { github: false, token: true, devIdentity: false, trustedProxy: false }; + if (mountedRef.current) setAuthMethods(methods); + return methods; + } + } + + async function beginLogin() { + try { + sessionStorage.removeItem(skipAutoGithubLoginKey); + } catch {} + preserveLoginReturnUrl(); + let methods = authMethodsRef.current; + if (!methods.github && !methods.token) methods = await loadAuthMethods(); + if (methods.github) { + location.href = "/login/github"; + return; + } + setLoginMessage("Sign in to request terminal control."); + } + + async function tokenLogin(token) { + try { + await api("/api/login/token", { method: "POST", body: { token }, authOptional: true }); + await refreshState(); + } catch (error) { + if (mountedRef.current) setLoginMessage(String(error.message || error)); + } + } + + async function devIdentityLogin(identity) { + try { + await api("/api/login/dev", { + method: "POST", + body: identity, + authOptional: true, + }); + await refreshState(); + } catch (error) { + if (mountedRef.current) setLoginMessage(String(error.message || error)); + } + } + + async function logout() { + try { + sessionStorage.setItem(skipAutoGithubLoginKey, "1"); + localStorage.removeItem(githubAutoLoginReadyKey); + } catch {} + autoLoginStarted.current = false; + await api("/api/logout", { method: "POST", authOptional: true }); + await refreshState(); + } + + async function maybeAutoGithubLogin(methods = authMethodsRef.current) { + let skipped; + let ready; + try { + skipped = sessionStorage.getItem(skipAutoGithubLoginKey) === "1"; + ready = localStorage.getItem(githubAutoLoginReadyKey) === "1"; + } catch { + return false; + } + const shouldStart = shouldAutoGithubLogin({ + signedIn: signedInRef.current, + started: autoLoginStarted.current, + methods, + shared: sharedRef.current, + tokenBypass: new URLSearchParams(location.search).get("auth") === "token", + skipped, + ready, + }); + if (!shouldStart) return false; + autoLoginStarted.current = true; + preserveLoginReturnUrl(); + location.href = "/login/github"; + return true; + } + + function preserveLoginReturnUrl() { + try { + if (sharedRef.current.id) sessionStorage.setItem(loginReturnKey, location.href); + } catch {} + } + + function finishGithubLoginCallback(remember) { + if (!githubLoginCallback.current) return; + githubLoginCallback.current = false; + if (remember) { + try { + localStorage.setItem(githubAutoLoginReadyKey, "1"); + } catch {} + } + if (!history.replaceState) return; + const url = new URL(location.href); + if (url.searchParams.get("login") !== "github") return; + url.searchParams.delete("login"); + history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`); + } + + loadStateRef.current = loadState; + loadSharedSessionRef.current = loadSharedSession; + + return { + state, + setState, + stateRef, + signedIn, + authMethods, + loginMessage, + setLoginMessage, + loadState, + refreshState, + loadSharedSession, + beginLogin, + tokenLogin, + devIdentityLogin, + logout, + }; +} + +function sharedLinkKey(shared) { + return `${shared.id || ""}\0${shared.token || ""}`; +} + +export function sameSharedLink(current, expected) { + return sharedLinkKey(current) === sharedLinkKey(expected); +} diff --git a/src/app/main.jsx b/src/app/main.jsx index 5cb2fd5..bb45bcf 100644 --- a/src/app/main.jsx +++ b/src/app/main.jsx @@ -1,14 +1,13 @@ import { render } from "preact"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import { api } from "./api.js"; +import { defaultDeployment, useAppData } from "./app-data.js"; import { CopyCommand, Icon } from "./components.jsx"; import { ActionDialog, Drawer, useActionDialog } from "./dialogs.jsx"; import { FleetPage } from "./fleet.jsx"; import { appViewUrl, initialAppView, - isGithubLoginCallback, - loginReturnKey, parseSessionLink, restoreSessionReturnUrl, sessionRouteUrl, @@ -57,60 +56,12 @@ import { } from "./terminal.js"; const logo = "__CRABBOX_LOGO__"; -const productName = "Crabfleet"; -const productDomain = "crabfleet.openclaw.ai"; -const sshHost = "crabd.sh"; -const defaultDeployment = { - label: productName, - canonicalUrl: `https://${productDomain}`, - productUrl: "https://crabfleet.ai", - sshHost, - preferredRepo, - defaultRuntime: "container", - defaultProfile: "default", - runtimeProfiles: [], -}; -const skipAutoGithubLoginKey = "crabbox-skip-auto-github-login"; -const githubAutoLoginReadyKey = "crabbox-github-auto-login-ready"; -const emptyState = { - cards: [], - interactiveSessions: [], - fleet: null, - allow: [], - repos: [], - workflows: [], - cap: 20, - retention: "30", - merge: "guarded", - deployment: defaultDeployment, -}; -function initialState(initialSessionLink) { - if (!initialSessionLink.id) return emptyState; - return { - ...emptyState, - interactiveSessions: [ - linkedInteractiveSessionPlaceholder(initialSessionLink.id, { - sharedReadOnly: Boolean(initialSessionLink.token), - }), - ], - }; -} function App() { - const githubLoginCallback = useRef(isGithubLoginCallback()); const initialSessionLink = useMemo(() => { restoreSessionReturnUrl(); return parseSessionLink(); }, []); - const [state, setState] = useState(() => initialState(initialSessionLink)); - const [signedIn, setSignedIn] = useState(false); - const [authMethods, setAuthMethods] = useState({ - github: false, - token: false, - devIdentity: false, - trustedProxy: false, - }); - const [loginMessage, setLoginMessage] = useState(""); const [filter, setFilter] = useState("all"); const [search, setSearch] = useState(""); const [appView, setAppViewState] = useState(initialAppView); @@ -132,18 +83,44 @@ function App() { const { dialog, openActionDialog, closeActionDialog, confirmActionDialog } = useActionDialog(); const [sessionLayout, setSessionLayout] = useState(loadSessionLayout); const [terminalStatus, setTerminalStatus] = useState({}); - const stateRef = useRef(state); - const authMethodsRef = useRef(authMethods); - const signedInRef = useRef(signedIn); - const activeRunIdRef = useRef(activeRunId); const focusedSessionIdRef = useRef(focusedSessionId); - const drawersRef = useRef(drawers); - const sharedRef = useRef({ id: sharedSessionId, token: sharedToken }); - const stateRetryTimer = useRef(null); const refPreviewTimer = useRef(null); const refPreviewSeq = useRef(0); const draggedSessionId = useRef(null); - const autoLoginStarted = useRef(false); + const { + state, + setState, + stateRef, + signedIn, + authMethods, + loginMessage, + setLoginMessage, + refreshState, + loadSharedSession, + beginLogin, + tokenLogin, + devIdentityLogin, + logout, + } = useAppData({ + initialSessionLink, + activeRunId, + runDrawerOpen: Boolean(drawers.run), + sharedSessionId, + sharedToken, + onSignedOut: closeAllDrawers, + onSharedSessionLoaded: (session) => { + setFocusedSessionId(session.id); + openSessionGrid(session.id, { deepLink: true }); + }, + onSharedSessionRejected: () => { + closeAllDrawers(); + setSharedSessionId(null); + setSharedToken(null); + setFocusedSessionId(null); + setInitialSessionOpened(true); + setSessionUrl(null); + }, + }); const allSessionItems = useMemo(() => sessionItems(state), [state]); const sessionItemById = useMemo( @@ -151,35 +128,9 @@ function App() { [allSessionItems], ); - stateRef.current = state; - authMethodsRef.current = authMethods; - signedInRef.current = signedIn; - activeRunIdRef.current = activeRunId; focusedSessionIdRef.current = focusedSessionId; - drawersRef.current = drawers; - sharedRef.current = { id: sharedSessionId, token: sharedToken }; - useEffect(() => { - void loadState(); - const interval = setInterval(() => { - if (signedInRef.current) { - void loadState(); - return; - } - const shared = sharedRef.current; - if (shared.id && shared.token && !document.body.classList.contains("locked")) { - loadSharedSession().catch((error) => { - if (error.status === 403 || error.status === 404) { - void showSharedLinkError(error); - return; - } - console.warn("Shared session refresh failed", error); - }); - } - }, 15000); return () => { - clearInterval(interval); - if (stateRetryTimer.current) clearTimeout(stateRetryTimer.current); if (refPreviewTimer.current) clearTimeout(refPreviewTimer.current); disposeAllTerminals(); }; @@ -196,10 +147,6 @@ function App() { return () => window.removeEventListener("popstate", onPopState); }, []); - useEffect(() => { - if (!signedIn && !loginMessage) void maybeAutoGithubLogin(authMethods); - }, [signedIn, loginMessage, authMethods.github, sharedSessionId, sharedToken]); - useEffect(() => { document.documentElement.dataset.theme = theme; try { @@ -225,87 +172,6 @@ function App() { void openInitialSessionLink(); }, [sharedSessionId, signedIn, state.interactiveSessions]); - async function loadState() { - try { - const nextState = await api("/api/state", { authOptional: true }); - const linkedSessionId = sharedRef.current.id; - const linkedSession = linkedSessionId ? findInteractiveSession(linkedSessionId) : null; - if ( - linkedSession && - !(nextState.interactiveSessions || []).some((session) => session.id === linkedSessionId) - ) { - nextState.interactiveSessions = [linkedSession, ...(nextState.interactiveSessions || [])]; - } - const activeRunId = activeRunIdRef.current; - const activeCard = nextState.cards.find((card) => card.id === activeRunId); - if (activeRunId && drawersRef.current.run && activeCard?.changes?.files?.length) { - const result = await api(`/api/cards/${encodeURIComponent(activeRunId)}/actions`, { - method: "POST", - body: { action: "attach" }, - }); - nextState.cards = nextState.cards.map((card) => - card.id === result.card.id ? result.card : card, - ); - } - if (stateRetryTimer.current) clearTimeout(stateRetryTimer.current); - stateRetryTimer.current = null; - setAuthMethods(nextState.auth || authMethodsRef.current); - setState(nextState); - setSignedIn(true); - setLoginMessage(""); - finishGithubLoginCallback(true); - } catch (error) { - if (error.status === 401 || error.status === 403) { - const shared = sharedRef.current; - if (shared.id && shared.token) { - try { - await loadSharedSession(); - } catch (sharedError) { - await showSharedLinkError(sharedError); - } - return; - } - const methods = await loadAuthMethods(); - finishGithubLoginCallback(false); - if (error.status === 401 && (await maybeAutoGithubLogin(methods))) return; - closeAllDrawers(); - setSignedIn(false); - setLoginMessage(error.message === "unauthorized" ? "" : error.message); - return; - } - setLoginMessage(error.message); - stateRetryTimer.current ||= setTimeout(() => { - stateRetryTimer.current = null; - void loadState(); - }, 5000); - } - } - - async function loadSharedSession() { - const result = await api( - `/api/shared-sessions/${encodeURIComponent(sharedSessionId)}?token=${encodeURIComponent(sharedToken)}`, - { authOptional: true }, - ); - await loadAuthMethods(); - setState({ - user: { subject: "shared", login: "shared link", role: "viewer" }, - auth: authMethods, - org: "OpenClaw", - cap: 20, - retention: "30", - merge: "guarded", - allow: [], - repos: [result.session.repo], - workflows: [], - cards: [], - interactiveSessions: [result.session], - deployment: stateRef.current.deployment || defaultDeployment, - }); - setSignedIn(false); - setFocusedSessionId(result.session.id); - openSessionGrid(result.session.id, { deepLink: true }); - } - async function loadLinkedInteractiveSession(id) { const result = await api(`/api/interactive-sessions/${encodeURIComponent(id)}`); upsertInteractiveSession(result.session); @@ -314,38 +180,6 @@ function App() { openSessionGrid(result.session.id, { deepLink: true }); } - async function showSharedLinkError(error) { - await loadAuthMethods(); - closeAllDrawers(); - setSharedSessionId(null); - setSharedToken(null); - setFocusedSessionId(null); - setInitialSessionOpened(true); - setSessionUrl(null); - setSignedIn(false); - setLoginMessage( - error?.status === 404 - ? "Shared session link is invalid or expired." - : error?.message || "Shared session could not be loaded.", - ); - } - - async function loadAuthMethods() { - try { - const result = await api("/api/auth", { authOptional: true }); - const methods = result.auth || authMethodsRef.current; - if (result.deployment) { - setState((current) => ({ ...current, deployment: result.deployment })); - } - setAuthMethods(methods); - return methods; - } catch { - const methods = { github: false, token: true, devIdentity: false, trustedProxy: false }; - setAuthMethods(methods); - return methods; - } - } - async function openInitialSessionLink() { if (!sharedSessionId) return; const existing = findInteractiveSession(sharedSessionId); @@ -514,96 +348,6 @@ function App() { setThemeState(value === "light" ? "light" : "dark"); } - async function beginLogin() { - try { - sessionStorage.removeItem(skipAutoGithubLoginKey); - } catch {} - preserveLoginReturnUrl(); - let methods = authMethods; - if (!methods.github && !methods.token) methods = await loadAuthMethods(); - if (methods.github) { - location.href = "/login/github"; - return; - } - setLoginMessage("Sign in to request terminal control."); - } - - async function tokenLogin(token) { - try { - await api("/api/login/token", { method: "POST", body: { token }, authOptional: true }); - await loadState(); - } catch (error) { - setLoginMessage(String(error.message || error)); - } - } - - async function devIdentityLogin(identity) { - try { - await api("/api/login/dev", { - method: "POST", - body: identity, - authOptional: true, - }); - await loadState(); - } catch (error) { - setLoginMessage(String(error.message || error)); - } - } - - async function logout() { - try { - sessionStorage.setItem(skipAutoGithubLoginKey, "1"); - localStorage.removeItem(githubAutoLoginReadyKey); - } catch {} - autoLoginStarted.current = false; - await api("/api/logout", { method: "POST", authOptional: true }); - await loadState(); - } - - async function maybeAutoGithubLogin(methods = authMethodsRef.current) { - if (signedInRef.current || autoLoginStarted.current || !methods?.github) return false; - if (methods.devIdentity) return false; - if (methods.token && wantsTokenLoginBypass()) return false; - const shared = sharedRef.current; - if (shared.id && shared.token) return false; - try { - if (sessionStorage.getItem(skipAutoGithubLoginKey) === "1") return false; - if (localStorage.getItem(githubAutoLoginReadyKey) !== "1") return false; - } catch { - return false; - } - autoLoginStarted.current = true; - preserveLoginReturnUrl(); - location.href = "/login/github"; - return true; - } - - function preserveLoginReturnUrl() { - try { - if (sharedRef.current.id) sessionStorage.setItem(loginReturnKey, location.href); - } catch {} - } - - function wantsTokenLoginBypass() { - const params = new URLSearchParams(location.search); - return params.get("auth") === "token"; - } - - function finishGithubLoginCallback(remember) { - if (!githubLoginCallback.current) return; - githubLoginCallback.current = false; - if (remember) { - try { - localStorage.setItem(githubAutoLoginReadyKey, "1"); - } catch {} - } - if (!history.replaceState) return; - const url = new URL(location.href); - if (url.searchParams.get("login") !== "github") return; - url.searchParams.delete("login"); - history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`); - } - async function cardAction(id, action) { const result = await api(`/api/cards/${encodeURIComponent(id)}/actions`, { method: "POST", @@ -805,7 +549,7 @@ function App() { }); setRefPreview({ number: "", loading: false, matches: [], error: "" }); setSearch(""); - await loadState(); + await refreshState(); } async function createCard(form) { @@ -823,7 +567,7 @@ function App() { }); form.reset(); closeDrawer("card"); - await loadState(); + await refreshState(); } async function createInteractiveSession(form) { diff --git a/tests/app-data.test.ts b/tests/app-data.test.ts new file mode 100644 index 0000000..b8466de --- /dev/null +++ b/tests/app-data.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createAppPolling, + createRequestFence, + defaultDeployment, + initialAppState, + retainLinkedSession, + sameSharedLink, + sharedSessionState, + shouldAutoGithubLogin, +} from "../src/app/app-data.js"; + +test("app data initializes and retains routed sessions across authenticated refreshes", () => { + const initial = initialAppState({ id: "IS-1", token: "share" }); + assert.equal(initial.interactiveSessions[0].id, "IS-1"); + assert.equal(initial.interactiveSessions[0].sharedReadOnly, true); + + const linked = { id: "IS-1", repo: "openclaw/openclaw" }; + const next = retainLinkedSession({ interactiveSessions: [{ id: "IS-2" }] }, linked); + assert.deepEqual( + next.interactiveSessions.map((session: { id: string }) => session.id), + ["IS-1", "IS-2"], + ); + assert.equal(retainLinkedSession(next, linked), next); +}); + +test("shared session state uses current auth and deployment metadata", () => { + const auth = { github: true, token: false, devIdentity: false, trustedProxy: false }; + const deployment = { ...defaultDeployment, label: "Test Fleet" }; + const state = sharedSessionState({ id: "IS-1", repo: "openclaw/openclaw" }, auth, deployment); + + assert.equal(state.user.role, "viewer"); + assert.equal(state.auth, auth); + assert.equal(state.deployment, deployment); + assert.deepEqual(state.repos, ["openclaw/openclaw"]); +}); + +test("shared session identity fences both the session and credential", () => { + assert.equal(sameSharedLink({ id: "IS-1", token: "one" }, { id: "IS-1", token: "one" }), true); + assert.equal(sameSharedLink({ id: "IS-1", token: "one" }, { id: "IS-1", token: "two" }), false); + assert.equal(sameSharedLink({ id: "IS-1", token: "one" }, { id: "IS-2", token: "one" }), false); +}); + +test("automatic GitHub login requires an explicit remembered login", () => { + const base = { + signedIn: false, + started: false, + methods: { github: true, token: true, devIdentity: false }, + shared: { id: null, token: null }, + tokenBypass: false, + skipped: false, + ready: true, + }; + assert.equal(shouldAutoGithubLogin(base), true); + assert.equal(shouldAutoGithubLogin({ ...base, signedIn: true }), false); + assert.equal(shouldAutoGithubLogin({ ...base, tokenBypass: true }), false); + assert.equal(shouldAutoGithubLogin({ ...base, shared: { id: "IS-1", token: "share" } }), false); + assert.equal(shouldAutoGithubLogin({ ...base, ready: false }), false); +}); + +test("app polling owns one interval and one retry timer", () => { + const calls: string[] = []; + let interval: (() => void) | null = null; + let retry: (() => void) | null = null; + const cleared: unknown[] = []; + const polling = createAppPolling({ + runInitial: () => calls.push("initial"), + runInterval: () => calls.push("interval"), + runRetry: () => calls.push("retry"), + timers: { + setInterval(callback: () => void, delay: number) { + assert.equal(delay, 15000); + interval = callback; + return "interval"; + }, + clearInterval(id: unknown) { + cleared.push(id); + }, + setTimeout(callback: () => void, delay: number) { + assert.equal(delay, 5000); + retry = callback; + return "retry"; + }, + clearTimeout(id: unknown) { + cleared.push(id); + }, + }, + }); + + polling.start(); + polling.start(); + assert.deepEqual(calls, ["initial"]); + interval?.(); + polling.scheduleRetry(); + polling.scheduleRetry(); + retry?.(); + assert.deepEqual(calls, ["initial", "interval", "retry"]); + + polling.scheduleRetry(); + polling.stop(); + assert.deepEqual(cleared, ["interval", "retry"]); +}); + +test("fresh state requests fence older responses", () => { + const fence = createRequestFence(); + const passive = fence.next(); + assert.equal(fence.isCurrent(passive), true); + const postMutation = fence.next(); + assert.equal(fence.isCurrent(passive), false); + assert.equal(fence.isCurrent(postMutation), true); +}); diff --git a/tests/html-dialogs.test.ts b/tests/html-dialogs.test.ts index 1d09d80..44f1419 100644 --- a/tests/html-dialogs.test.ts +++ b/tests/html-dialogs.test.ts @@ -4,16 +4,16 @@ import { test } from "node:test"; test("app actions use styled HTML dialogs instead of browser prompts", async () => { const source = await readFile(new URL("../src/app/main.jsx", import.meta.url), "utf8"); + const appData = await readFile(new URL("../src/app/app-data.js", import.meta.url), "utf8"); const dialogs = await readFile(new URL("../src/app/dialogs.jsx", import.meta.url), "utf8"); assert.doesNotMatch(source, /\bwindow\.(?:alert|confirm|prompt)\s*\(/); + assert.doesNotMatch(appData, /\bwindow\.(?:alert|confirm|prompt)\s*\(/); assert.doesNotMatch(dialogs, /\bwindow\.(?:alert|confirm|prompt)\s*\(/); assert.match(dialogs, //); assert.match(source, /runtimeProfileOptionLabel\(profile\)/); assert.match(source, /onReset=\{\(\) => setRuntime\(defaultRuntime\)\}/); From 65e048384693b52ce817081d2939ab23f0ac7349 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 16:51:17 +0100 Subject: [PATCH 067/109] refactor: extract app shell components --- src/app/app-shell-state.js | 23 + src/app/app-shell.jsx | 123 ++++++ src/app/board-state.js | 17 + src/app/board.jsx | 197 +++++++++ src/app/branding.js | 1 + src/app/login-state.js | 23 + src/app/login.jsx | 218 +++++++++ src/app/main.jsx | 559 +----------------------- tests/app-board.test.ts | 48 ++ tests/app-login.test.ts | 49 +++ tests/app-shell-state.test.ts | 32 ++ tests/html-dialogs.test.ts | 4 + tests/trusted-proxy-integration.test.ts | 15 +- 13 files changed, 752 insertions(+), 557 deletions(-) create mode 100644 src/app/app-shell-state.js create mode 100644 src/app/app-shell.jsx create mode 100644 src/app/board-state.js create mode 100644 src/app/board.jsx create mode 100644 src/app/branding.js create mode 100644 src/app/login-state.js create mode 100644 src/app/login.jsx create mode 100644 tests/app-board.test.ts create mode 100644 tests/app-login.test.ts create mode 100644 tests/app-shell-state.test.ts diff --git a/src/app/app-shell-state.js b/src/app/app-shell-state.js new file mode 100644 index 0000000..80469f4 --- /dev/null +++ b/src/app/app-shell-state.js @@ -0,0 +1,23 @@ +import { isFleetSessionAttachable } from "./utils.js"; + +export function appShellMetrics(state) { + return { + active: state.cards.filter((card) => card.lane === "Running").length, + queue: state.cards.filter((card) => card.lane === "Todo").length, + review: state.cards.filter((card) => card.lane === "Human Review").length, + cli: + state.fleet?.totals?.attachable ?? + (state.interactiveSessions || []).filter(isFleetSessionAttachable).length, + }; +} + +export function appUserPresentation({ signedIn, user }) { + const trustedProxyUser = Boolean(signedIn && user?.subject?.startsWith("proxy:")); + const userLabel = + !signedIn && user?.subject === "shared" + ? "Sign in for control" + : user + ? `${user.login || user.email || user.subject} / ${user.role}` + : "Signed out"; + return { trustedProxyUser, userLabel }; +} diff --git a/src/app/app-shell.jsx b/src/app/app-shell.jsx new file mode 100644 index 0000000..34863ef --- /dev/null +++ b/src/app/app-shell.jsx @@ -0,0 +1,123 @@ +import { defaultDeployment } from "./app-data.js"; +import { appShellMetrics, appUserPresentation } from "./app-shell-state.js"; +import { BoardPage } from "./board.jsx"; +import { appLogo } from "./branding.js"; +import { Icon } from "./components.jsx"; +import { FleetPage } from "./fleet.jsx"; +import { DevIdentityPanel } from "./login.jsx"; +import { canOwn } from "./utils.js"; + +export function AppShell(props) { + const deployment = props.state.deployment || defaultDeployment; + const { active, queue, review, cli } = appShellMetrics(props.state); + const user = props.state.user; + const { trustedProxyUser, userLabel } = appUserPresentation({ + signedIn: props.signedIn, + user, + }); + return ( +
+ +
+
+
+

{props.appView === "board" ? "Board" : deployment.label}

+

+ {props.appView === "board" + ? "Prompt cards and run attempts, separated from the live crabbox fleet." + : "All visible Codex crabboxes grouped by person, with SSH, WebVNC, and session supervision."} +

+
+ +
+
+
+ ); +} diff --git a/src/app/board-state.js b/src/app/board-state.js new file mode 100644 index 0000000..583b5c3 --- /dev/null +++ b/src/app/board-state.js @@ -0,0 +1,17 @@ +export function visibleBoardCards(cards, { filter, current, query }) { + return cards.filter((card) => { + if (filter === "mine" && card.owner !== current) return false; + if (filter === "hot" && card.lane !== "Running") return false; + return matchesCard(card, query); + }); +} + +export function matchesCard(card, query) { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return true; + const changedPaths = (card.changes?.files || []).map((file) => file.path).join(" "); + return [card.id, card.title, card.repo, card.source, card.runtime, card.policy, changedPaths] + .join(" ") + .toLowerCase() + .includes(normalizedQuery); +} diff --git a/src/app/board.jsx b/src/app/board.jsx new file mode 100644 index 0000000..270767c --- /dev/null +++ b/src/app/board.jsx @@ -0,0 +1,197 @@ +import { visibleBoardCards } from "./board-state.js"; +import { canMaintain, canOwn, elapsed, lanes, statusLabel } from "./utils.js"; + +export function BoardPage(props) { + return ( +
+
+
+ props.setSearch(event.currentTarget.value)} + /> + +
+
+ {["all", "mine", "hot"].map((key) => ( + + ))} +
+ + + +
+ +
+ ); +} + +function RefPreview({ preview, canCreate, onCreate }) { + if (!preview.number) return