diff --git a/apps/mobile/src/features/threads/SandboxDesktopPanel.tsx b/apps/mobile/src/features/threads/SandboxDesktopPanel.tsx new file mode 100644 index 000000000000..94ef5a224e12 --- /dev/null +++ b/apps/mobile/src/features/threads/SandboxDesktopPanel.tsx @@ -0,0 +1,186 @@ +import type { EnvironmentId, OrchestrationThreadShell } from "@t3tools/contracts"; +import { useCallback, useRef, useState } from "react"; +import { ActivityIndicator, Modal, Pressable, View } from "react-native"; +import { WebView } from "react-native-webview"; + +import { AppText as Text } from "../../components/AppText"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function SandboxDesktopPanel(props: { + readonly environmentId: EnvironmentId; + readonly thread: OrchestrationThreadShell; +}) { + const sandbox = props.thread.sandbox; + const [open, setOpen] = useState(false); + const [generation, setGeneration] = useState(0); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [viewerUrl, setViewerUrl] = useState(null); + const leaseSessionId = useRef(`mobile-${Date.now()}-${Math.random().toString(16).slice(2)}`); + const provision = useAtomCommand(threadEnvironment.provisionSandbox, { reportFailure: false }); + const takeover = useAtomCommand(threadEnvironment.takeOverSandbox, { reportFailure: false }); + const resume = useAtomCommand(threadEnvironment.resumeSandbox, { reportFailure: false }); + const exportBranch = useAtomCommand(threadEnvironment.exportSandboxBranch, { + reportFailure: false, + }); + const viewerTicket = useAtomCommand(threadEnvironment.requestSandboxViewerTicket, { + reportFailure: false, + }); + + const run = useCallback( + async (action: () => Promise<{ readonly _tag: string }>) => { + if (busy) return; + setBusy(true); + setError(null); + try { + const result = await action(); + if (result._tag === "Failure") + setError("The sandbox action failed. Refresh and try again."); + } finally { + setBusy(false); + } + }, + [busy], + ); + const target = { environmentId: props.environmentId } as const; + const human = sandbox?.controller.kind === "human" ? sandbox.controller : null; + const openViewer = async () => { + if (busy) return; + setBusy(true); + setError(null); + try { + const result = await viewerTicket({ ...target, input: { threadId: props.thread.id } }); + if (result._tag === "Failure") { + setError("The desktop viewer ticket was denied or expired."); + return; + } + setViewerUrl(result.value.viewerUrl); + setGeneration((value) => value + 1); + setOpen(true); + } finally { + setBusy(false); + } + }; + + return ( + + + + Isolated desktop + + {sandbox + ? `${sandbox.lifecycle.replaceAll("-", " ")} · ${sandbox.branch.branchName}` + : "Starts on first use"} + + + {busy ? : null} + {!sandbox ? ( + + void run(() => provision({ ...target, input: { threadId: props.thread.id } })) + } + > + Start + + ) : null} + {sandbox?.desktop.status === "ready" ? ( + void openViewer()}> + Open + + ) : null} + {sandbox && human === null && sandbox.lifecycle === "ready" ? ( + + void run(() => + takeover({ + ...target, + input: { threadId: props.thread.id, sessionId: leaseSessionId.current }, + }), + ) + } + > + Take control + + ) : null} + {human ? ( + + void run(() => + resume({ + ...target, + input: { + threadId: props.thread.id, + leaseId: human.leaseId, + takeoverSummary: + "Mobile desktop control ended; repository and browser state may have changed.", + }, + }), + ) + } + > + Resume agent + + ) : null} + {sandbox ? ( + + void run(() => exportBranch({ ...target, input: { threadId: props.thread.id } })) + } + > + Export + + ) : null} + + {sandbox ? ( + + Services {sandbox.services.filter((service) => service.status === "healthy").length}/ + {sandbox.services.length} + {human ? " · human control active" : ""} + + ) : null} + {error ? {error} : null} + + setOpen(false)} + > + + + Thread desktop + + void openViewer()}> + Reconnect + + setOpen(false)}> + Close + + + + {viewerUrl ? ( + setError("The desktop stream disconnected. Reconnect to try again.")} + style={{ flex: 1, backgroundColor: "black" }} + /> + ) : null} + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 82607b371761..6e19939f35bc 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -81,6 +81,7 @@ import { } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { SandboxDesktopPanel } from "./SandboxDesktopPanel"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThreadShell; @@ -716,6 +717,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* Hidden (not unmounted) while a user-input request owns the composer slot, so composer drafts and editor state survive. */} + (operation: string, run: () => Promise) => export interface OrchestrationIntegrationHarness { readonly rootDir: string; readonly workspaceDir: string; + readonly baseCommit: string; readonly dbPath: string; readonly adapterHarness: TestProviderAdapterHarness | null; readonly engine: OrchestrationEngineShape; @@ -261,7 +275,7 @@ export const makeOrchestrationIntegrationHarness = ( yield* fileSystem.makeDirectory(workspaceDir, { recursive: true }); yield* fileSystem.makeDirectory(stateDir, { recursive: true }); yield* initializeGitWorkspace(workspaceDir); - + const baseCommit = runGit(workspaceDir, ["rev-parse", "HEAD"]).trim(); const persistenceLayer = makeSqlitePersistenceLive(dbPath); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -301,7 +315,13 @@ export const makeOrchestrationIntegrationHarness = ( ); const providerRegistryLayer = makeProviderRegistryLayer(); - const checkpointStoreLayer = CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer)); + const sandboxRuntimeManagerLayer = makeSandboxRuntimeManagerLayer(); + const checkpointStoreCommonLayer = CheckpointStore.layer.pipe( + Layer.provide(VcsDriverRegistry.layer), + ); + const checkpointStoreLayer = useRealCodex + ? checkpointStoreCommonLayer + : checkpointStoreCommonLayer.pipe(Layer.provide(sandboxRuntimeManagerLayer)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -324,6 +344,17 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(serverSettingsLayer), ); const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ + localStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: true, + refName: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + resolveRemoteTrackingCommit: () => + Effect.succeed({ commitSha: baseCommit, remoteRefName: "main" }), renameBranch: (input: { readonly cwd: string; readonly oldBranch: string; @@ -334,13 +365,135 @@ export const makeOrchestrationIntegrationHarness = ( generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), } as unknown as TextGenerationShape); - const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( + const threadSandboxRuntimeLayer = Layer.succeed(ThreadSandboxRuntime, { + ensureReady: (thread: OrchestrationThread) => + Effect.succeed({ + kind: "sandbox" as const, + threadId: thread.id, + sandboxId: thread.sandbox?.sandboxId ?? `test-${thread.id}`, + runtimeRef: thread.sandbox?.runtimeRef ?? `test-${thread.id}`, + runtime: "docker" as const, + workspaceCwd: "/workspace/repo", + }), + }); + function makeSandboxRuntimeManagerLayer() { + return Layer.succeed(SandboxRuntimeManager, { + exec: (_runtime, _threadId, input) => + Effect.gen(function* () { + if (input.cwd !== "/workspace/repo") { + return yield* new SandboxManagerError({ + message: `test sandbox exec rejected cwd '${input.cwd ?? ""}'`, + }); + } + if (input.executable !== "git" && input.executable !== "rm") { + return yield* new SandboxManagerError({ + message: `test sandbox exec rejected executable '${input.executable}'`, + }); + } + const mapSandboxPath = (value: string) => + value === "/workspace/repo" + ? workspaceDir + : value.startsWith("/workspace/repo/") + ? NodePath.join(workspaceDir, value.slice("/workspace/repo/".length)) + : value; + const args = [...(input.args ?? [])].map(mapSandboxPath); + if ( + input.executable === "rm" && + args.some((arg) => { + if (arg.startsWith("-")) return false; + const relative = NodePath.relative( + workspaceDir, + NodePath.resolve(workspaceDir, arg), + ); + return relative === ".." || relative.startsWith(`..${NodePath.sep}`); + }) + ) { + return yield* new SandboxManagerError({ + message: "test sandbox exec rejected rm path outside workspace", + }); + } + const result = NodeChildProcess.spawnSync(input.executable, args, { + cwd: workspaceDir, + env: { + ...process.env, + ...Object.fromEntries( + Object.entries(input.env ?? {}).map(([key, value]) => [ + key, + mapSandboxPath(value), + ]), + ), + }, + encoding: "utf8", + input: input.stdin, + timeout: input.timeoutMs ?? 30_000, + maxBuffer: 1024 * 1024, + }); + if (result.error) { + return yield* new SandboxManagerError({ + message: result.error.message, + cause: result.error, + }); + } + return { + exitCode: result.status ?? 1, + stdout: (result.stdout ?? "").slice(0, 1024 * 1024), + stderr: (result.stderr ?? "").slice(0, 1024 * 1024), + }; + }), + provision: (input) => + Effect.succeed({ + sandboxId: `test-${input.bootstrap.threadId}`, + runtime: input.config?.runtime ?? "docker", + containerName: `test-${input.bootstrap.threadId}`, + networkName: `test-${input.bootstrap.threadId}`, + workspaceVolumeName: `test-${input.bootstrap.threadId}-workspace`, + desktopVolumeName: `test-${input.bootstrap.threadId}-desktop`, + branchName: input.bootstrap.branchName, + limits: input.config?.limits ?? DEFAULT_SANDBOX_RESOURCE_LIMITS, + desktopSessionId: `test-${input.bootstrap.threadId}`, + desktopStreamPath: `/desktop/test-${input.bootstrap.threadId}`, + services: [], + }), + exportBranch: () => Effect.die("exportBranch should not be called in this test"), + stop: () => Effect.die("stop should not be called in this test"), + reconcile: () => Effect.die("reconcile should not be called in this test"), + sampleUsage: () => Effect.die("sampleUsage should not be called in this test"), + recoverPreview: () => Effect.die("recoverPreview should not be called in this test"), + revokeCredentials: () => Effect.succeed(0), + }); + } + const projectFileLoaderLayer = Layer.succeed(T3ProjectFileLoader, { + load: () => + Effect.succeed( + Option.some({ + sandbox: { + image: + "registry.example/t3-desktop@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }), + ), + }); + const providerCommandReactorCommonLayer = ( + useRealCodex + ? ProviderCommandReactorLive + : Layer.effect(ProviderCommandReactor, makeProviderCommandReactor).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(ProviderTurnSendClaimRepositoryLive), + Layer.provide(projectFileLoaderLayer), + ) + ).pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), ); - const checkpointReactorLayer = CheckpointReactorLive.pipe( + const providerCommandReactorLayer = useRealCodex + ? providerCommandReactorCommonLayer + : providerCommandReactorCommonLayer.pipe( + Layer.provideMerge(threadSandboxRuntimeLayer), + Layer.provideMerge(sandboxRuntimeManagerLayer), + ); + const checkpointReactorCommonLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge( Layer.succeed(VcsStatusBroadcaster, { @@ -368,6 +521,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), ); + const checkpointReactorLayer = checkpointReactorCommonLayer; const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), @@ -562,6 +716,7 @@ export const makeOrchestrationIntegrationHarness = ( return { rootDir, workspaceDir, + baseCommit, dbPath, adapterHarness, engine, diff --git a/apps/server/integration/TransferBudgetScenario.integration.ts b/apps/server/integration/TransferBudgetScenario.integration.ts index 77dfbc1dd7fb..91c0c4710143 100644 --- a/apps/server/integration/TransferBudgetScenario.integration.ts +++ b/apps/server/integration/TransferBudgetScenario.integration.ts @@ -7,6 +7,7 @@ import { MessageId, ProjectId, ProviderDriverKind, + SandboxId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -80,8 +81,43 @@ export const seedTransferBudgetHistory = Effect.fn("TransferBudget.seedHistory") interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, branch: "main", worktreePath: harness.workspaceDir, + sandboxBranch: { + branchName: `t3/thread/${TRANSFER_THREAD_ID}`, + baseCommit: harness.baseCommit, + }, createdAt: turnTimestamp(0), }); + yield* harness.waitForThread( + TRANSFER_THREAD_ID, + (thread) => thread.sandbox?.lifecycle === "unprovisioned", + ); + yield* harness.engine.dispatch({ + type: "sandbox.provision", + commandId: CommandId.make(`transfer:${provider}:sandbox-provision`), + threadId: TRANSFER_THREAD_ID, + branch: { + branchName: `t3/thread/${TRANSFER_THREAD_ID}`, + baseCommit: harness.baseCommit, + }, + createdAt: turnTimestamp(0), + }); + yield* harness.waitForThread( + TRANSFER_THREAD_ID, + (thread) => thread.sandbox?.lifecycle === "provisioning", + ); + yield* harness.engine.dispatch({ + type: "sandbox.provision.ready", + commandId: CommandId.make(`transfer:${provider}:sandbox-ready`), + threadId: TRANSFER_THREAD_ID, + sandboxId: SandboxId.make(`transfer-${provider}`), + runtime: "docker", + runtimeRef: `transfer-${provider}`, + createdAt: turnTimestamp(0), + }); + yield* harness.waitForThread( + TRANSFER_THREAD_ID, + (thread) => thread.sandbox?.lifecycle === "ready", + ); for (let turnIndex = 0; turnIndex < TRANSFER_HISTORY_TURN_COUNT; turnIndex += 1) { const response = makeRecordedTransferTurn(provider, turnIndex); diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index ab10db6cb088..fcd8b40cdc45 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -32,6 +32,7 @@ import { import type { CheckpointServiceError } from "./Errors.ts"; import { checkpointRefForThreadTurn } from "./Utils.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; +import { checkpointExecutionTargetForThread } from "./CheckpointStore.ts"; /** Service tag for checkpoint diff queries. */ export class CheckpointDiffQuery extends Context.Service< @@ -171,6 +172,10 @@ export const make = Effect.gen(function* () { toCheckpointRef, fallbackFromToHead: false, ignoreWhitespace, + target: checkpointExecutionTargetForThread({ + id: input.threadId, + sandbox: threadContext.value.sandbox, + }), }) .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); @@ -261,6 +266,10 @@ export const make = Effect.gen(function* () { toCheckpointRef: threadContext.value.toCheckpointRef as CheckpointRef, fallbackFromToHead: false, ignoreWhitespace, + target: checkpointExecutionTargetForThread({ + id: input.threadId, + sandbox: threadContext.value.sandbox, + }), }) .pipe(Effect.withSpan("checkpoint.fullThread.diffCheckpoints")); diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index bf332d20d0da..8459783757ac 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -16,6 +16,11 @@ import * as CheckpointStore from "./CheckpointStore.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as ServerConfig from "../config.ts"; +import { + SandboxRuntimeManager, + type SandboxRuntimeManagerShape, +} from "../sandbox/SandboxRuntimeManager.ts"; +import type { SandboxExecInput } from "../sandbox/types.ts"; const ServerConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", @@ -24,6 +29,9 @@ const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.lay const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); const CheckpointStoreTestLayer = CheckpointStore.layer.pipe( Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge( + Layer.succeed(SandboxRuntimeManager, {} as unknown as SandboxRuntimeManagerShape), + ), Layer.provideMerge(NodeServices.layer), ); const TestLayer = CheckpointStoreTestLayer.pipe( @@ -223,3 +231,102 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { ); }); }); + +describe("sandbox checkpoint boundary", () => { + it("fails closed for non-ready and unsupported sandbox targets", () => { + const threadId = ThreadId.make("sandbox-unavailable-thread"); + expect( + CheckpointStore.checkpointExecutionTargetForThread({ + id: threadId, + sandbox: { lifecycle: "ready", runtime: "microvm" }, + }), + ).toMatchObject({ kind: "unavailable", threadId }); + expect( + CheckpointStore.checkpointExecutionTargetForThread({ + id: threadId, + sandbox: { lifecycle: "provisioning", runtime: "docker" }, + }), + ).toMatchObject({ kind: "unavailable", threadId }); + }); + + it.effect("captures and diffs through sandbox exec without addressing host VCS", () => { + const calls: Array<{ runtime: string; threadId: string; input: SandboxExecInput }> = []; + const manager = { + exec: (runtime: "docker" | "podman", threadId: string, input: SandboxExecInput) => { + return Effect.sync(() => { + calls.push({ runtime, threadId, input }); + const subcommand = input.args?.[0]; + const stdout = + subcommand === "write-tree" + ? "tree123\n" + : subcommand === "commit-tree" + ? "commit123\n" + : subcommand === "diff" + ? "diff --git a/file b/file\n" + : subcommand === "rev-parse" && input.args?.[1] === "--git-common-dir" + ? ".git\n" + : "abc123\n"; + return { exitCode: 0, stdout, stderr: "" }; + }); + }, + } as unknown as SandboxRuntimeManagerShape; + const hostRegistry = VcsDriverRegistry.VcsDriverRegistry.of({ + get: () => Effect.die("host VCS must not be used"), + detect: () => Effect.die("host filesystem must not be detected"), + resolve: () => Effect.die("host filesystem must not be resolved"), + }); + const layer = CheckpointStore.layer.pipe( + Layer.provide(Layer.succeed(VcsDriverRegistry.VcsDriverRegistry, hostRegistry)), + Layer.provide(Layer.succeed(SandboxRuntimeManager, manager)), + Layer.provide(NodeServices.layer), + ); + + return Effect.gen(function* () { + const store = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("sandbox-checkpoint-thread"); + const from = checkpointRefForThreadTurn(threadId, 0); + const to = checkpointRefForThreadTurn(threadId, 1); + yield* store.captureCheckpoint({ + cwd: "/tmp/host-worktree-that-must-not-enter-the-sandbox", + checkpointRef: to, + target: { kind: "sandbox", threadId, runtime: "podman" }, + }); + const diff = yield* store.diffCheckpoints({ + cwd: "/tmp/host-worktree-that-must-not-enter-the-sandbox", + fromCheckpointRef: from, + toCheckpointRef: to, + ignoreWhitespace: false, + target: { kind: "sandbox", threadId, runtime: "podman" }, + }); + + expect(diff).toContain("diff --git"); + expect(calls.every((call) => call.runtime === "podman" && call.threadId === threadId)).toBe( + true, + ); + expect(calls.every((call) => call.input.cwd === "/workspace/repo")).toBe(true); + expect( + calls + .flatMap((call) => [call.input.cwd ?? "", ...(call.input.args ?? [])]) + .some((value) => value.includes("host-worktree-that-must-not-enter-the-sandbox")), + ).toBe(false); + const indexPaths = calls + .flatMap((call) => Object.values(call.input.env ?? {})) + .filter((value) => value.includes("t3-checkpoint-index-")); + expect(indexPaths.length).toBeGreaterThan(0); + expect( + indexPaths.every((value) => /^\/workspace\/repo\/\.git\/t3-checkpoint-index-/.test(value)), + ).toBe(true); + expect(calls.map((call) => call.input.args?.[0])).toEqual([ + "rev-parse", + "rev-parse", + "read-tree", + "add", + "write-tree", + "commit-tree", + "update-ref", + "-f", + "diff", + ]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index 9e79d140d9f4..81135434c328 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -13,27 +13,76 @@ * * @module CheckpointStore */ -import { VcsUnsupportedOperationError, type CheckpointRef } from "@t3tools/contracts"; +import { + VcsProcessExitError, + VcsUnsupportedOperationError, + type CheckpointRef, + type ThreadId, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type { CheckpointStoreError } from "./Errors.ts"; import type { VcsCheckpointOps } from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import { SandboxRuntimeManager } from "../sandbox/SandboxRuntimeManager.ts"; -export interface CaptureCheckpointInput { +export type CheckpointExecutionTarget = + | { readonly kind: "legacy-host" } + | { readonly kind: "unavailable"; readonly threadId: ThreadId; readonly detail: string } + | { + readonly kind: "sandbox"; + readonly threadId: ThreadId; + readonly runtime: "docker" | "podman"; + }; + +export function checkpointExecutionTargetForThread(thread: { + readonly id: ThreadId; + readonly sandbox?: + | { + readonly runtime?: "docker" | "podman" | "microvm" | undefined; + readonly lifecycle?: string; + } + | null + | undefined; +}): CheckpointExecutionTarget { + if ( + thread.sandbox?.lifecycle === "ready" && + (thread.sandbox.runtime === "docker" || thread.sandbox.runtime === "podman") + ) { + return { + kind: "sandbox", + threadId: thread.id, + runtime: thread.sandbox.runtime, + }; + } + if (thread.sandbox != null) { + return { + kind: "unavailable", + threadId: thread.id, + detail: `Sandbox checkpoint target is ${thread.sandbox.lifecycle ?? "unknown"}/${thread.sandbox.runtime ?? "unknown"}.`, + }; + } + return { kind: "legacy-host" }; +} + +type TargetedInput = { readonly target?: CheckpointExecutionTarget }; +const SANDBOX_WORKSPACE_CWD = "/workspace/repo"; + +export interface CaptureCheckpointInput extends TargetedInput { readonly cwd: string; readonly checkpointRef: CheckpointRef; } -export interface RestoreCheckpointInput { +export interface RestoreCheckpointInput extends TargetedInput { readonly cwd: string; readonly checkpointRef: CheckpointRef; readonly fallbackToHead?: boolean; } -export interface DiffCheckpointsInput { +export interface DiffCheckpointsInput extends TargetedInput { readonly cwd: string; readonly fromCheckpointRef: CheckpointRef; readonly toCheckpointRef: CheckpointRef; @@ -41,7 +90,7 @@ export interface DiffCheckpointsInput { readonly ignoreWhitespace: boolean; } -export interface DeleteCheckpointRefsInput { +export interface DeleteCheckpointRefsInput extends TargetedInput { readonly cwd: string; readonly checkpointRefs: ReadonlyArray; } @@ -98,6 +147,117 @@ export class CheckpointStore extends Context.Service< export const make = Effect.gen(function* () { const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + const sandboxRuntime = yield* SandboxRuntimeManager; + const randomUUID = (yield* Crypto.Crypto).randomUUIDv4; + + const sandboxGit = Effect.fn("CheckpointStore.sandboxGit")(function* ( + target: Extract, + _cwd: string, + args: ReadonlyArray, + options?: { readonly env?: Readonly>; readonly allowNonZero?: boolean }, + ) { + const cwd = SANDBOX_WORKSPACE_CWD; + if (sandboxRuntime.exec === undefined) { + return yield* new VcsProcessExitError({ + operation: "CheckpointStore.sandboxGit", + command: "git", + cwd, + exitCode: 1, + detail: "Sandbox command execution is unavailable.", + }); + } + const result = yield* sandboxRuntime + .exec(target.runtime, target.threadId, { + executable: "git", + args, + cwd, + ...(options?.env === undefined ? {} : { env: options.env }), + timeoutMs: 30_000, + }) + .pipe( + Effect.mapError( + (cause) => + new VcsProcessExitError({ + operation: "CheckpointStore.sandboxGit", + command: "git", + cwd, + exitCode: 1, + detail: cause.message, + }), + ), + ); + if (result.exitCode !== 0 && options?.allowNonZero !== true) { + return yield* new VcsProcessExitError({ + operation: "CheckpointStore.sandboxGit", + command: "git", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || `git ${args[0] ?? "command"} failed`, + }); + } + return result; + }); + + const resolveSandboxCommit = ( + target: Extract, + cwd: string, + ref: string, + ) => + sandboxGit(target, cwd, ["rev-parse", "--verify", `${ref}^{commit}`], { + allowNonZero: true, + }).pipe(Effect.map((result) => (result.exitCode === 0 ? result.stdout.trim() || null : null))); + + const sandboxCapture = Effect.fn("CheckpointStore.sandboxCapture")(function* ( + target: Extract, + input: CaptureCheckpointInput, + ) { + const gitDir = (yield* sandboxGit(target, SANDBOX_WORKSPACE_CWD, [ + "rev-parse", + "--git-common-dir", + ])).stdout.trim(); + const uuid = yield* randomUUID.pipe( + Effect.mapError( + (cause) => + new VcsProcessExitError({ + operation: "CheckpointStore.captureCheckpoint", + command: "git", + cwd: SANDBOX_WORKSPACE_CWD, + exitCode: 1, + detail: cause.message, + }), + ), + ); + const index = `${gitDir.startsWith("/") ? gitDir : `${SANDBOX_WORKSPACE_CWD}/${gitDir}`}/t3-checkpoint-index-${uuid}`; + const env = { + GIT_INDEX_FILE: index, + GIT_AUTHOR_NAME: "Command Center", + GIT_AUTHOR_EMAIL: "command-center@example.com", + GIT_COMMITTER_NAME: "Command Center", + GIT_COMMITTER_EMAIL: "command-center@example.com", + }; + yield* Effect.gen(function* () { + const head = yield* resolveSandboxCommit(target, input.cwd, "HEAD"); + if (head !== null) yield* sandboxGit(target, input.cwd, ["read-tree", "HEAD"], { env }); + yield* sandboxGit(target, input.cwd, ["add", "-A", "--", "."], { env }); + const tree = (yield* sandboxGit(target, input.cwd, ["write-tree"], { env })).stdout.trim(); + const commit = (yield* sandboxGit( + target, + input.cwd, + ["commit-tree", tree, "-m", `t3 checkpoint ref=${input.checkpointRef}`], + { env }, + )).stdout.trim(); + yield* sandboxGit(target, input.cwd, ["update-ref", input.checkpointRef, commit]); + }).pipe( + Effect.ensuring( + sandboxRuntime.exec!(target.runtime, target.threadId, { + executable: "rm", + args: ["-f", "--", index], + cwd: SANDBOX_WORKSPACE_CWD, + timeoutMs: 5_000, + }).pipe(Effect.ignore), + ), + ); + }); const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* ( operation: string, @@ -114,6 +274,19 @@ export const make = Effect.gen(function* () { return handle.driver.checkpoints satisfies VcsCheckpointOps; }); + const rejectUnavailable = (target: CheckpointExecutionTarget | undefined, cwd: string) => + target?.kind === "unavailable" + ? Effect.fail( + new VcsProcessExitError({ + operation: "CheckpointStore.resolveTarget", + command: "git", + cwd, + exitCode: 1, + detail: target.detail, + }), + ) + : Effect.void; + const isGitRepository: CheckpointStore["Service"]["isGitRepository"] = (cwd) => vcsRegistry .detect({ cwd, requestedKind: "git" }) @@ -122,6 +295,8 @@ export const make = Effect.gen(function* () { const captureCheckpoint: CheckpointStore["Service"]["captureCheckpoint"] = Effect.fn( "captureCheckpoint", )(function* (input) { + yield* rejectUnavailable(input.target, input.cwd); + if (input.target?.kind === "sandbox") return yield* sandboxCapture(input.target, input); const checkpoints = yield* resolveCheckpoints("CheckpointStore.captureCheckpoint", input.cwd); return yield* checkpoints.captureCheckpoint(input); }); @@ -129,6 +304,10 @@ export const make = Effect.gen(function* () { const hasCheckpointRef: CheckpointStore["Service"]["hasCheckpointRef"] = Effect.fn( "hasCheckpointRef", )(function* (input) { + yield* rejectUnavailable(input.target, input.cwd); + if (input.target?.kind === "sandbox") { + return (yield* resolveSandboxCommit(input.target, input.cwd, input.checkpointRef)) !== null; + } const checkpoints = yield* resolveCheckpoints("CheckpointStore.hasCheckpointRef", input.cwd); return yield* checkpoints.hasCheckpointRef(input); }); @@ -136,6 +315,26 @@ export const make = Effect.gen(function* () { const restoreCheckpoint: CheckpointStore["Service"]["restoreCheckpoint"] = Effect.fn( "restoreCheckpoint", )(function* (input) { + yield* rejectUnavailable(input.target, input.cwd); + if (input.target?.kind === "sandbox") { + let commit = yield* resolveSandboxCommit(input.target, input.cwd, input.checkpointRef); + if (commit === null && input.fallbackToHead === true) + commit = yield* resolveSandboxCommit(input.target, input.cwd, "HEAD"); + if (commit === null) return false; + yield* sandboxGit(input.target, input.cwd, [ + "restore", + "--source", + commit, + "--worktree", + "--staged", + "--", + ".", + ]); + yield* sandboxGit(input.target, input.cwd, ["clean", "-fd", "--", "."]); + if ((yield* resolveSandboxCommit(input.target, input.cwd, "HEAD")) !== null) + yield* sandboxGit(input.target, input.cwd, ["reset", "--quiet", "--", "."]); + return true; + } const checkpoints = yield* resolveCheckpoints("CheckpointStore.restoreCheckpoint", input.cwd); return yield* checkpoints.restoreCheckpoint(input); }); @@ -143,6 +342,49 @@ export const make = Effect.gen(function* () { const diffCheckpoints: CheckpointStore["Service"]["diffCheckpoints"] = Effect.fn( "diffCheckpoints", )(function* (input) { + yield* rejectUnavailable(input.target, input.cwd); + if (input.target?.kind === "sandbox") { + let from: string = input.fromCheckpointRef; + if ( + input.fallbackFromToHead === true && + (yield* resolveSandboxCommit(input.target, input.cwd, from)) === null + ) { + const head = yield* resolveSandboxCommit(input.target, input.cwd, "HEAD"); + if (head === null) + return yield* new VcsProcessExitError({ + operation: "CheckpointStore.diffCheckpoints", + command: "git diff", + cwd: input.cwd, + exitCode: 1, + detail: "Checkpoint ref is unavailable for diff operation.", + }); + from = head; + } + const result = yield* sandboxGit( + input.target, + input.cwd, + [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + `${from}^{commit}`, + `${input.toCheckpointRef}^{commit}`, + ], + { allowNonZero: true }, + ); + if (result.exitCode !== 0) + return yield* new VcsProcessExitError({ + operation: "CheckpointStore.diffCheckpoints", + command: "git diff", + cwd: input.cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "Checkpoint ref is unavailable for diff operation.", + }); + return result.stdout; + } const checkpoints = yield* resolveCheckpoints("CheckpointStore.diffCheckpoints", input.cwd); return yield* checkpoints.diffCheckpoints(input); }); @@ -150,6 +392,16 @@ export const make = Effect.gen(function* () { const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn( "deleteCheckpointRefs", )(function* (input) { + yield* rejectUnavailable(input.target, input.cwd); + if (input.target?.kind === "sandbox") { + const target = input.target; + yield* Effect.forEach( + input.checkpointRefs, + (ref) => sandboxGit(target, input.cwd, ["update-ref", "-d", ref], { allowNonZero: true }), + { discard: true }, + ); + return; + } const checkpoints = yield* resolveCheckpoints( "CheckpointStore.deleteCheckpointRefs", input.cwd, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c9a0fe1cda97..8c58cdc0914b 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -103,7 +103,7 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } -const authenticateRawRouteWithScope = ( +export const authenticateRawRouteWithScope = ( scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, ) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index b6989148f521..5d0d9211d442 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -33,6 +33,7 @@ import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import { desktopGateway } from "../sandbox/DesktopGatewayService.ts"; export interface PreviewAutomationInvokeInput { readonly scope: McpInvocationContext.McpInvocationScope; @@ -508,6 +509,27 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { ] as const; }); if (!route) { + const directTarget = desktopGateway.automationTarget(input.scope.threadId); + if (directTarget !== null) { + const direct = yield* Effect.tryPromise({ + try: () => + desktopGateway.invokeAutomation( + input.scope.threadId, + input.operation, + input.input, + timeoutMs, + ), + catch: () => + new PreviewAutomationNoAvailableHostError({ + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + }), + }); + if (direct !== null) return direct as A; + } return yield* new PreviewAutomationNoAvailableHostError({ operation: input.operation, environmentId: input.scope.environmentId, diff --git a/apps/server/src/orchestration/CommandDispatcher.ts b/apps/server/src/orchestration/CommandDispatcher.ts index 2b1c8b5d08c7..bf3fbf5a1d1e 100644 --- a/apps/server/src/orchestration/CommandDispatcher.ts +++ b/apps/server/src/orchestration/CommandDispatcher.ts @@ -298,6 +298,8 @@ export const make = Effect.gen(function* () { interactionMode: bootstrap.createThread.interactionMode, branch: bootstrap.createThread.branch, worktreePath: bootstrap.createThread.worktreePath, + sandboxConfig: bootstrap.createThread.sandboxConfig, + sandboxBranch: bootstrap.createThread.sandboxBranch, createdAt: bootstrap.createThread.createdAt, }); createdThread = true; @@ -380,8 +382,79 @@ export const make = Effect.gen(function* () { ); }; + const ensureThreadSandbox = Effect.fn("OrchestrationCommandDispatcher.ensureThreadSandbox")( + function* ( + resolvedCommand: OrchestrationCommand, + ): Effect.fn.Return { + const create = + resolvedCommand.type === "thread.create" + ? resolvedCommand + : resolvedCommand.type === "thread.turn.start" + ? resolvedCommand.bootstrap?.createThread + : undefined; + if (create === undefined || create.sandboxBranch !== undefined) return resolvedCommand; + const targetThreadId = + resolvedCommand.type === "thread.create" || resolvedCommand.type === "thread.turn.start" + ? resolvedCommand.threadId + : undefined; + if (targetThreadId === undefined) return resolvedCommand; + const snapshot = yield* projectionSnapshotQuery + .getSnapshot() + .pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to load project for sandbox creation"), + ), + ); + const project = snapshot.projects.find((item) => item.id === create.projectId); + if (!project) + return yield* new OrchestrationDispatchCommandError({ + message: `Project '${create.projectId}' was not found.`, + }); + const local = yield* gitWorkflow + .localStatus({ cwd: project.workspaceRoot }) + .pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to inspect sandbox Git base"), + ), + ); + if (!local.isRepo || local.refName === null) + return yield* new OrchestrationDispatchCommandError({ + message: "Isolated threads require a Git repository with a selected branch.", + }); + const base = yield* gitWorkflow + .resolveRemoteTrackingCommit({ + cwd: project.workspaceRoot, + refName: local.refName, + fallbackRemoteName: "origin", + }) + .pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to resolve immutable sandbox Git base"), + ), + ); + const sandboxFields = { + sandboxConfig: create.sandboxConfig ?? {}, + sandboxBranch: { + branchName: `t3/thread/${targetThreadId}`, + baseCommit: base.commitSha, + }, + }; + if (resolvedCommand.type === "thread.create") return { ...resolvedCommand, ...sandboxFields }; + if (resolvedCommand.type === "thread.turn.start") + return { + ...resolvedCommand, + bootstrap: { + ...resolvedCommand.bootstrap!, + createThread: { ...resolvedCommand.bootstrap!.createThread!, ...sandboxFields }, + }, + }; + return resolvedCommand; + }, + ); + const dispatchNormalized: OrchestrationCommandDispatcherShape["dispatchNormalized"] = (command) => resolveEfficiency(command).pipe( + Effect.flatMap(ensureThreadSandbox), Effect.flatMap((resolvedCommand) => resolvedCommand.type === "thread.turn.start" && resolvedCommand.bootstrap ? dispatchBootstrapTurnStart(resolvedCommand) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 95adee0cf7f8..b6a1d2c3cd9b 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -27,6 +27,7 @@ import { resolveThreadWorkspaceCwd, } from "../../checkpointing/Utils.ts"; import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; +import { checkpointExecutionTargetForThread } from "../../checkpointing/CheckpointStore.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; import { forkParked } from "../../serverActivation.ts"; @@ -185,7 +186,11 @@ const make = Effect.gen(function* () { // a git repository. const resolveCheckpointCwd = Effect.fn("resolveCheckpointCwd")(function* (input: { readonly threadId: ThreadId; - readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null }; + readonly thread: { + readonly projectId: ProjectId; + readonly worktreePath: string | null; + readonly sandbox?: unknown | null; + }; readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>; readonly preferSessionRuntime: boolean; }): Effect.fn.Return { @@ -209,7 +214,7 @@ const make = Effect.gen(function* () { if (!cwd) { return undefined; } - if (!isGitWorkspace(cwd)) { + if (input.thread.sandbox == null && !isGitWorkspace(cwd)) { return undefined; } return cwd; @@ -229,6 +234,7 @@ const make = Effect.gen(function* () { }>; }; readonly cwd: string; + readonly target: CheckpointStore.CheckpointExecutionTarget; readonly turnCount: number; readonly status: "ready" | "missing" | "error"; readonly assistantMessageId: MessageId | undefined; @@ -241,6 +247,7 @@ const make = Effect.gen(function* () { const fromCheckpointExists = yield* checkpointStore.hasCheckpointRef({ cwd: input.cwd, checkpointRef: fromCheckpointRef, + target: input.target, }); if (!fromCheckpointExists) { yield* Effect.logWarning("checkpoint capture missing pre-turn baseline", { @@ -253,11 +260,14 @@ const make = Effect.gen(function* () { yield* checkpointStore.captureCheckpoint({ cwd: input.cwd, checkpointRef: targetCheckpointRef, + target: input.target, }); // Refresh the workspace entry index so the @-mention file picker // reflects files created or deleted during this turn. - yield* workspaceEntries.refresh(input.cwd); + if (input.target.kind === "legacy-host") { + yield* workspaceEntries.refresh(input.cwd); + } const files = yield* checkpointStore .diffCheckpoints({ @@ -266,6 +276,7 @@ const make = Effect.gen(function* () { toCheckpointRef: targetCheckpointRef, fallbackFromToHead: false, ignoreWhitespace: false, + target: input.target, }) .pipe( Effect.map((diff) => @@ -409,6 +420,7 @@ const make = Effect.gen(function* () { turnId, thread, cwd: checkpointCwd, + target: checkpointExecutionTargetForThread(thread), turnCount: nextTurnCount, status: checkpointStatusFromRuntime(event.payload.state), assistantMessageId: undefined, @@ -472,6 +484,7 @@ const make = Effect.gen(function* () { turnId, thread, cwd: checkpointCwd, + target: checkpointExecutionTargetForThread(thread), turnCount: checkpointTurnCount, status: "ready", assistantMessageId: event.payload.assistantMessageId ?? undefined, @@ -510,6 +523,7 @@ const make = Effect.gen(function* () { const baselineExists = yield* checkpointStore.hasCheckpointRef({ cwd: checkpointCwd, checkpointRef: baselineCheckpointRef, + target: checkpointExecutionTargetForThread(thread), }); if (baselineExists) { return; @@ -518,6 +532,7 @@ const make = Effect.gen(function* () { yield* checkpointStore.captureCheckpoint({ cwd: checkpointCwd, checkpointRef: baselineCheckpointRef, + target: checkpointExecutionTargetForThread(thread), }); yield* receiptBus.publish({ type: "checkpoint.baseline.captured", @@ -669,6 +684,7 @@ const make = Effect.gen(function* () { const baselineExists = yield* checkpointStore.hasCheckpointRef({ cwd: checkpointCwd, checkpointRef: baselineCheckpointRef, + target: checkpointExecutionTargetForThread(thread), }); if (baselineExists) { return; @@ -677,6 +693,7 @@ const make = Effect.gen(function* () { yield* checkpointStore.captureCheckpoint({ cwd: checkpointCwd, checkpointRef: baselineCheckpointRef, + target: checkpointExecutionTargetForThread(thread), }); yield* receiptBus.publish({ type: "checkpoint.baseline.captured", @@ -713,7 +730,8 @@ const make = Effect.gen(function* () { }).pipe(Effect.catch(() => Effect.void)); return; } - if (!isGitWorkspace(sessionRuntime.value.cwd)) { + const checkpointTarget = checkpointExecutionTargetForThread(thread); + if (checkpointTarget.kind === "legacy-host" && !isGitWorkspace(sessionRuntime.value.cwd)) { yield* appendRevertFailureActivity({ threadId: event.payload.threadId, turnCount: event.payload.turnCount, @@ -759,6 +777,7 @@ const make = Effect.gen(function* () { cwd: sessionRuntime.value.cwd, checkpointRef: targetCheckpointRef, fallbackToHead: event.payload.turnCount === 0, + target: checkpointTarget, }); if (!restored) { yield* appendRevertFailureActivity({ @@ -772,7 +791,9 @@ const make = Effect.gen(function* () { // Refresh the workspace entry index so the @-mention file picker // reflects the reverted filesystem state. - yield* workspaceEntries.refresh(sessionRuntime.value.cwd); + if (checkpointTarget.kind === "legacy-host") { + yield* workspaceEntries.refresh(sessionRuntime.value.cwd); + } const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); if (rolledBackTurns > 0) { @@ -793,6 +814,7 @@ const make = Effect.gen(function* () { yield* checkpointStore.deleteCheckpointRefs({ cwd: sessionRuntime.value.cwd, checkpointRefs: staleCheckpointRefs, + target: checkpointTarget, }); } diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index c5c6301adb9f..53b1e9047aa2 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -69,6 +69,14 @@ function commandToAggregateRef(command: OrchestrationCommand): { aggregateKind: "project", aggregateId: command.projectId, }; + case "sandbox.worker.spawn": + case "sandbox.worker.status": + case "sandbox.worker.message": + case "sandbox.worker.stop": + return { + aggregateKind: "thread", + aggregateId: command.parentThreadId, + }; default: return { aggregateKind: "thread", diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fc7c9ae17b39..8c922947a812 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { SandboxLifecycleReactor } from "../Services/SandboxLifecycleReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const sandboxLifecycleReactor = yield* SandboxLifecycleReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* sandboxLifecycleReactor.start(); yield* agentAwarenessRelay.start(); }); @@ -31,6 +34,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.drain; yield* checkpointReactor.drain; yield* threadDeletionReactor.drain; + yield* sandboxLifecycleReactor.drain; }); return { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..da07c53cc2c8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2,9 +2,11 @@ import { CheckpointRef, CommandId, CorrelationId, + DEFAULT_SANDBOX_RESOURCE_LIMITS, EventId, MessageId, ProjectId, + SandboxId, ThreadId, TurnId, ProviderInstanceId, @@ -14,6 +16,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -24,6 +27,7 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { @@ -54,6 +58,146 @@ const exists = (filePath: string) => const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); +it.layer(makeProjectionPipelinePrefixedTestLayer("t3-sandbox-projection-test-"))( + "sandbox projection lifecycle", + (it) => { + it.effect("persists sandbox state from a thread.created payload", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const threads = yield* ProjectionThreadRepository; + const now = "2026-08-15T12:00:00.000Z"; + const branch = { + branchName: "t3/thread-sandbox-projection", + baseCommit: "0123456789abcdef0123456789abcdef01234567", + }; + const sandbox = { + lifecycle: "unprovisioned" as const, + branch, + limits: DEFAULT_SANDBOX_RESOURCE_LIMITS, + desktop: { + status: "unavailable" as const, + resolution: { width: 1440, height: 900, webRtcEnabled: true }, + }, + services: [], + controller: { kind: "none" as const }, + createdAt: now, + lastActiveAt: now, + }; + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-thread-sandbox-projection"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-sandbox-projection"), + occurredAt: now, + commandId: CommandId.make("cmd-thread-sandbox-projection"), + causationEventId: null, + correlationId: CommandId.make("cmd-thread-sandbox-projection"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-sandbox-projection"), + projectId: ProjectId.make("project-sandbox-projection"), + title: "Sandbox projection", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + sandboxBranch: branch, + sandbox, + createdAt: now, + updatedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap; + + const projected = yield* threads.getById({ + threadId: ThreadId.make("thread-sandbox-projection"), + }); + assert.deepEqual(Option.getOrNull(projected)?.sandbox, sandbox); + + const provisioningAt = "2026-08-15T12:00:01.000Z"; + const provisioning = { + ...sandbox, + lifecycle: "provisioning" as const, + desktop: { ...sandbox.desktop, status: "starting" as const }, + lastActiveAt: provisioningAt, + }; + yield* eventStore.append({ + type: "sandbox.provisioning-started", + eventId: EventId.make("evt-sandbox-provisioning"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-sandbox-projection"), + occurredAt: provisioningAt, + commandId: CommandId.make("cmd-sandbox-provisioning"), + causationEventId: null, + correlationId: CommandId.make("cmd-sandbox-provisioning"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-sandbox-projection"), + event: { + type: "sandbox.provisioning-started", + threadId: ThreadId.make("thread-sandbox-projection"), + occurredAt: provisioningAt, + }, + sandbox: provisioning, + }, + }); + yield* projectionPipeline.bootstrap; + const projectedProvisioning = yield* threads.getById({ + threadId: ThreadId.make("thread-sandbox-projection"), + }); + assert.deepEqual(Option.getOrNull(projectedProvisioning)?.sandbox, provisioning); + + const readyAt = "2026-08-15T12:00:02.000Z"; + const sandboxId = SandboxId.make("sandbox-projection"); + const ready = { + ...provisioning, + lifecycle: "ready" as const, + sandboxId, + runtime: "docker" as const, + runtimeRef: "container-projection", + desktop: { ...provisioning.desktop, status: "ready" as const, readyAt }, + lastActiveAt: readyAt, + }; + yield* eventStore.append({ + type: "sandbox.ready", + eventId: EventId.make("evt-sandbox-ready"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-sandbox-projection"), + occurredAt: readyAt, + commandId: CommandId.make("cmd-sandbox-ready"), + causationEventId: null, + correlationId: CommandId.make("cmd-sandbox-ready"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-sandbox-projection"), + event: { + type: "sandbox.ready", + threadId: ThreadId.make("thread-sandbox-projection"), + occurredAt: readyAt, + sandboxId, + runtime: "docker", + runtimeRef: "container-projection", + }, + sandbox: ready, + }, + }); + yield* projectionPipeline.bootstrap; + const projectedReady = yield* threads.getById({ + threadId: ThreadId.make("thread-sandbox-projection"), + }); + assert.deepEqual(Option.getOrNull(projectedReady)?.sandbox, ready); + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index eaa41d25db09..a8110f86106e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -635,6 +635,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + sandbox: event.payload.sandbox ?? null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -655,6 +656,32 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; + case "sandbox.provisioning-started": + case "sandbox.ready": + case "sandbox.failed": + case "sandbox.paused": + case "sandbox.takeover-requested": + case "sandbox.takeover-acquired": + case "sandbox.resumed": + case "sandbox.stopping": + case "sandbox.expired": + case "sandbox.stopped": + case "sandbox.reconciled": + case "sandbox.branch-exported": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + sandbox: event.payload.sandbox, + updatedAt: event.occurredAt, + }); + return; + } + case "thread.archived": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 0be423cb73fc..97b2b8309934 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -12,6 +12,7 @@ import { OrchestrationThread, OrchestrationThreadDetailSnapshot, ProjectScript, + SandboxState, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -85,6 +86,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + sandbox: Schema.NullOr(Schema.fromJsonString(SandboxState)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -175,6 +177,7 @@ const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({ projectId: ProjectId, workspaceRoot: Schema.String, worktreePath: Schema.NullOr(Schema.String), + sandbox: Schema.NullOr(Schema.fromJsonString(SandboxState)), }); const FullThreadDiffContextLookupInput = Schema.Struct({ threadId: ThreadId, @@ -185,6 +188,7 @@ const ProjectionFullThreadDiffContextRowSchema = Schema.Struct({ projectId: ProjectId, workspaceRoot: Schema.String, worktreePath: Schema.NullOr(Schema.String), + sandbox: Schema.NullOr(Schema.fromJsonString(SandboxState)), latestCheckpointTurnCount: Schema.NullOr(NonNegativeInt), toCheckpointRef: Schema.NullOr(CheckpointRef), }); @@ -416,6 +420,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, model_selection_json AS "modelSelection", + sandbox_json AS "sandbox", routing_mode AS "routingMode", efficiency_tier AS "efficiencyTier", runtime_mode AS "runtimeMode", @@ -454,6 +459,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, model_selection_json AS "modelSelection", + sandbox_json AS "sandbox", routing_mode AS "routingMode", efficiency_tier AS "efficiencyTier", runtime_mode AS "runtimeMode", @@ -494,6 +500,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, model_selection_json AS "modelSelection", + sandbox_json AS "sandbox", routing_mode AS "routingMode", efficiency_tier AS "efficiencyTier", runtime_mode AS "runtimeMode", @@ -923,6 +930,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threads.project_id AS "projectId", projects.workspace_root AS "workspaceRoot", threads.worktree_path AS "worktreePath" + , threads.sandbox_json AS "sandbox" FROM projection_threads AS threads INNER JOIN projection_projects AS projects ON projects.project_id = threads.project_id @@ -942,6 +950,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, model_selection_json AS "modelSelection", + sandbox_json AS "sandbox", routing_mode AS "routingMode", efficiency_tier AS "efficiencyTier", runtime_mode AS "runtimeMode", @@ -1309,6 +1318,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threads.project_id AS "projectId", projects.workspace_root AS "workspaceRoot", threads.worktree_path AS "worktreePath", + threads.sandbox_json AS "sandbox", ( SELECT MAX(turns.checkpoint_turn_count) FROM projection_turns AS turns @@ -1586,6 +1596,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + sandbox: row.sandbox, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1795,6 +1806,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + sandbox: row.sandbox, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1933,6 +1945,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + sandbox: row.sandbox, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2080,6 +2093,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + sandbox: row.sandbox, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2274,6 +2288,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { projectId: threadRow.value.projectId, workspaceRoot: threadRow.value.workspaceRoot, worktreePath: threadRow.value.worktreePath, + ...(threadRow.value.sandbox === null ? {} : { sandbox: threadRow.value.sandbox }), checkpoints: checkpointRows.map( (row): OrchestrationCheckpointSummary => ({ turnId: row.turnId, @@ -2312,6 +2327,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { projectId: row.value.projectId, workspaceRoot: row.value.workspaceRoot, worktreePath: row.value.worktreePath, + ...(row.value.sandbox === null ? {} : { sandbox: row.value.sandbox }), latestCheckpointTurnCount: row.value.latestCheckpointTurnCount ?? 0, toCheckpointRef: row.value.toCheckpointRef, }); @@ -2363,6 +2379,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + sandbox: threadRow.value.sandbox, latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, @@ -2488,6 +2505,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + sandbox: threadRow.value.sandbox, latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 5334f2182182..b8948b9f4305 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -66,6 +66,11 @@ import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; +import { + SandboxRuntimeManager, + type SandboxRuntimeManagerShape, +} from "../../sandbox/SandboxRuntimeManager.ts"; +import { DEFAULT_SANDBOX_RESOURCE_LIMITS } from "@t3tools/contracts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -104,6 +109,7 @@ describe("ProviderCommandReactor", () => { const createdBaseDirs = new Set(); afterEach(async () => { + vi.unstubAllEnvs(); if (scope) { await Effect.runPromise(Scope.close(scope, Exit.void)); } @@ -160,6 +166,7 @@ describe("ProviderCommandReactor", () => { ProviderAdapterRequestError | ProviderAdapterValidationError >; }) { + vi.stubEnv("T3_SANDBOX_IMAGE", `desktop@sha256:${"d".repeat(64)}`); const now = "2026-01-01T00:00:00.000Z"; const baseDir = input?.baseDir ?? NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-reactor-")); @@ -174,6 +181,22 @@ describe("ProviderCommandReactor", () => { model: "gpt-5-codex", }; const startSessionEffect = input?.startSessionEffect; + const provisionSandbox = vi.fn( + (request: Parameters[0]) => + Effect.succeed({ + sandboxId: request.bootstrap.threadId, + runtime: "docker" as const, + containerName: `sandbox-${request.bootstrap.threadId}`, + networkName: `network-${request.bootstrap.threadId}`, + workspaceVolumeName: `workspace-${request.bootstrap.threadId}`, + desktopVolumeName: `desktop-${request.bootstrap.threadId}`, + branchName: request.bootstrap.branchName, + limits: DEFAULT_SANDBOX_RESOURCE_LIMITS, + desktopSessionId: `desktop-${request.bootstrap.threadId}`, + desktopStreamPath: `/desktop/${request.bootstrap.threadId}`, + services: [], + }), + ); const startSession = vi.fn((_: unknown, input: unknown) => { const sessionIndex = nextSessionIndex++; const resumeCursor = @@ -401,6 +424,17 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ renameBranch, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + resolveRemoteTrackingCommit: () => + Effect.succeed({ commitSha: "a".repeat(40), remoteRefName: "origin/main" }), } satisfies Partial), ), Layer.provideMerge( @@ -419,6 +453,30 @@ describe("ProviderCommandReactor", () => { }), ), Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + Layer.succeed(SandboxRuntimeManager, { + provision: provisionSandbox, + exportBranch: () => + Effect.succeed({ + commit: "a".repeat(40), + patch: "", + artifactId: "b".repeat(64), + bundleSha256: "c".repeat(64), + }), + stop: () => Effect.void, + reconcile: () => + Effect.succeed({ + activeThreadIds: [], + missingThreadIds: [], + orphanThreadIds: [], + removedRuntimeRefs: [], + }), + sampleUsage: () => + Effect.succeed({ cpuPercent: 0, memoryBytes: 0, diskBytes: 0, processCount: 0 }), + recoverPreview: () => Effect.succeed(false), + revokeCredentials: () => Effect.succeed(0), + } satisfies SandboxRuntimeManagerShape), + ), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -453,6 +511,8 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + sandboxConfig: {}, + sandboxBranch: { branchName: "t3/thread/thread-1", baseCommit: "a".repeat(40) }, createdAt: now, }), ); @@ -469,6 +529,8 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + sandboxConfig: {}, + sandboxBranch: { branchName: "t3/thread/thread-2", baseCommit: "a".repeat(40) }, createdAt: now, }), ); @@ -510,6 +572,7 @@ describe("ProviderCommandReactor", () => { generateBranchName, generateThreadTitle, runtimeSessions, + provisionSandbox, stateDir, drain, runEffect, @@ -519,7 +582,7 @@ describe("ProviderCommandReactor", () => { }; } - it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { + it("lazily provisions before starting and sending a provider turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -544,13 +607,14 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.make("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - cwd: "/tmp/provider-project", + cwd: "/workspace/repo", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "approval-required", }); + expect(harness.provisionSandbox).toHaveBeenCalledTimes(1); const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); @@ -2033,7 +2097,7 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); }); - it("restarts the provider session when the thread workspace changes", async () => { + it("keeps provider execution in the sandbox when host workspace metadata changes", async () => { const harness = await createHarness({ threadModelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), @@ -2062,7 +2126,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - cwd: "/tmp/provider-project", + cwd: "/workspace/repo", }); await Effect.runPromise( @@ -2091,13 +2155,12 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.startSession).toHaveBeenCalledTimes(1); expect(harness.stopSession.mock.calls.length).toBe(0); - expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ threadId: ThreadId.make("thread-1"), - cwd: "/tmp/provider-project-worktree", - resumeCursor: { opaque: "resume-1" }, + cwd: "/workspace/repo", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index c2896242a7bc..f092c98d4f49 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -8,6 +8,7 @@ import { type OrchestrationEvent, type OrchestrationProposedPlanId, ProviderDriverKind, + SandboxId, type ProjectId, type OrchestrationSession, ThreadId, @@ -21,10 +22,12 @@ import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -64,6 +67,15 @@ import { } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { + ThreadSandboxRuntime, + type ProviderExecutionTarget, +} from "../../sandbox/ThreadSandboxRuntime.ts"; +import { SandboxRuntimeManager } from "../../sandbox/SandboxRuntimeManager.ts"; +import { + T3ProjectFileLoader, + layer as T3ProjectFileLoaderLive, +} from "../../project/T3ProjectFileLoader.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); @@ -342,13 +354,21 @@ function buildGeneratedWorktreeBranchName(raw: string): string { return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEventStore = yield* OrchestrationEventStore; const providerTurnSendClaimRepository = yield* ProviderTurnSendClaimRepository; const providerService = yield* ProviderService; + const threadSandboxRuntime = yield* ThreadSandboxRuntime; + const sandboxRuntimeManager = yield* SandboxRuntimeManager; + const projectFileLoader = yield* T3ProjectFileLoader; + const sandboxProvisionLocks = new Map(); + const provisionedTargets = new Map< + string, + Extract + >(); const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; @@ -357,6 +377,191 @@ const make = Effect.gen(function* () { const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); + const ensureExecutionTarget = Effect.fn("ProviderCommandReactor.ensureExecutionTarget")( + function* ( + thread: Parameters[0], + legacyCwd: string | undefined, + ) { + if (thread.sandbox != null && thread.sandbox.lifecycle !== "unprovisioned") { + return yield* threadSandboxRuntime.ensureReady(thread, legacyCwd); + } + let lock = sandboxProvisionLocks.get(thread.id); + if (lock === undefined) { + lock = yield* Semaphore.make(1); + sandboxProvisionLocks.set(thread.id, lock); + } + return yield* lock.withPermits(1)( + Effect.gen(function* () { + const cached = provisionedTargets.get(thread.id); + if (cached !== undefined) return cached; + const project = yield* resolveProject(thread.projectId); + if (project === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: "sandbox", + method: "sandbox.provision", + detail: `Project '${thread.projectId}' was not found.`, + }); + } + const runtime = thread.sandboxConfig?.runtime ?? "docker"; + if (runtime !== "docker" && runtime !== "podman") { + return yield* new ProviderAdapterRequestError({ + provider: "sandbox", + method: "sandbox.provision", + detail: `Sandbox runtime '${runtime}' is not available in v1.`, + }); + } + const projectFile = Option.getOrUndefined( + yield* projectFileLoader.load(project.workspaceRoot), + ); + const declaration = projectFile?.sandbox; + const image = declaration?.image ?? process.env.T3_SANDBOX_IMAGE?.trim(); + if (!image) { + return yield* new ProviderAdapterRequestError({ + provider: "sandbox", + method: "sandbox.provision", + detail: "T3_SANDBOX_IMAGE must name a digest-pinned desktop sandbox image.", + }); + } + const occurredAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const branch = + thread.sandbox?.branch ?? + (yield* Effect.gen(function* () { + const local = yield* gitWorkflow.localStatus({ cwd: project.workspaceRoot }); + if (!local.isRepo || local.refName === null) + return yield* new ProviderAdapterRequestError({ + provider: "sandbox", + method: "sandbox.provision", + detail: "Isolated threads require a Git repository with a selected branch.", + }); + const base = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: project.workspaceRoot, + refName: local.refName, + fallbackRemoteName: "origin", + }); + return { branchName: `t3/thread/${thread.id}`, baseCommit: base.commitSha }; + }).pipe( + Effect.mapError((cause) => + isProviderAdapterRequestError(cause) + ? cause + : new ProviderAdapterRequestError({ + provider: "sandbox", + method: "sandbox.provision", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + )); + yield* orchestrationEngine.dispatch({ + type: "sandbox.provision", + commandId: yield* serverCommandId("sandbox-provision"), + threadId: thread.id, + config: thread.sandboxConfig ?? {}, + ...(thread.sandbox === null ? { branch } : {}), + createdAt: occurredAt, + }); + const provision = yield* sandboxRuntimeManager + .provision({ + bootstrap: { + threadId: thread.id, + projectId: thread.projectId, + repositoryUrl: + project.repositoryIdentity?.locator.remoteUrl ?? project.workspaceRoot, + baseCommit: branch.baseCommit, + branchName: branch.branchName, + ...("parentThreadId" in branch && branch.parentThreadId + ? { parentThreadId: branch.parentThreadId } + : {}), + }, + config: thread.sandboxConfig ?? {}, + image, + ...(process.env.T3_SANDBOX_EGRESS_PROXY_IMAGE?.trim() + ? { egressProxyImage: process.env.T3_SANDBOX_EGRESS_PROXY_IMAGE.trim() } + : {}), + ...(declaration?.caches ? { caches: declaration.caches } : {}), + ...(declaration?.setup ? { setup: declaration.setup } : {}), + ...(declaration?.teardown ? { teardown: declaration.teardown } : {}), + ...(declaration?.services ? { services: declaration.services } : {}), + ...(declaration?.previewPorts ? { previewPorts: declaration.previewPorts } : {}), + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: "sandbox", + method: "sandbox.provision", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + Effect.catch((error) => + Effect.gen(function* () { + const failedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + yield* orchestrationEngine + .dispatch({ + type: "sandbox.operation.fail", + commandId: yield* serverCommandId("sandbox-failed"), + threadId: thread.id, + failure: { + stage: "provision", + code: "sandbox_provision_failed", + message: error.detail, + retryable: true, + occurredAt: failedAt, + }, + createdAt: failedAt, + }) + .pipe(Effect.ignore); + return yield* error; + }), + ), + ); + const readyAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + yield* orchestrationEngine.dispatch({ + type: "sandbox.provision.ready", + commandId: yield* serverCommandId("sandbox-ready"), + threadId: thread.id, + sandboxId: SandboxId.make(provision.sandboxId), + runtime: provision.runtime, + runtimeRef: provision.containerName, + createdAt: readyAt, + }); + const readyThread = Option.getOrUndefined( + yield* projectionSnapshotQuery.getThreadDetailById(thread.id), + ); + if (readyThread?.sandbox !== null && readyThread?.sandbox !== undefined) { + yield* orchestrationEngine.dispatch({ + type: "sandbox.reconcile.result", + commandId: yield* serverCommandId("sandbox-service-health"), + threadId: thread.id, + disposition: "matched", + sandbox: { + ...readyThread.sandbox, + services: provision.services.map((service) => ({ + name: service.name, + status: "healthy" as const, + ...(service.internalPorts[0] === undefined + ? {} + : { internalPort: service.internalPorts[0] }), + checkedAt: readyAt, + })), + }, + createdAt: readyAt, + }); + } + const target = { + kind: "sandbox", + threadId: thread.id, + sandboxId: provision.sandboxId, + runtimeRef: provision.containerName, + runtime, + workspaceCwd: "/workspace/repo", + } as const; + provisionedTargets.set(thread.id, target); + return target; + }), + ); + }, + ); const handledTurnStartKeys = yield* Cache.make({ capacity: HANDLED_TURN_START_KEY_MAX, timeToLive: HANDLED_TURN_START_KEY_TTL, @@ -577,6 +782,7 @@ const make = Effect.gen(function* () { options?: { readonly modelSelection?: ModelSelection; readonly pendingTurnStart?: boolean; + readonly executionTarget?: ProviderExecutionTarget; }, ) { const thread = yield* resolveThread(threadId); @@ -705,25 +911,33 @@ const make = Effect.gen(function* () { } } const project = yield* resolveProject(thread.projectId); - const effectiveCwd = resolveThreadWorkspaceCwd({ + const legacyCwd = resolveThreadWorkspaceCwd({ thread, projects: project ? [project] : [], }); + const executionTarget = + options?.executionTarget ?? (yield* ensureExecutionTarget(thread, legacyCwd)); + const effectiveCwd = + executionTarget.kind === "sandbox" ? executionTarget.workspaceCwd : executionTarget.cwd; const startProviderSession = (input?: { readonly resumeCursor?: unknown; readonly provider?: ProviderDriverKind; }) => - providerService.startSession(threadId, { + providerService.startSession( threadId, - projectId: thread.projectId, - ...(preferredProvider ? { provider: preferredProvider } : {}), - providerInstanceId: desiredInstanceId, - ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - modelSelection: desiredModelSelection, - ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), - runtimeMode: desiredRuntimeMode, - }); + { + threadId, + projectId: thread.projectId, + ...(preferredProvider ? { provider: preferredProvider } : {}), + providerInstanceId: desiredInstanceId, + ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + modelSelection: desiredModelSelection, + ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + runtimeMode: desiredRuntimeMode, + }, + executionTarget, + ); const bindSessionToThread = (session: ProviderSession) => Effect.gen(function* () { @@ -860,6 +1074,7 @@ const make = Effect.gen(function* () { * legitimately fall back to oldest-first adoption. */ readonly turnRequestSequence?: number; + readonly executionTarget?: ProviderExecutionTarget; }) { const thread = yield* resolveThread(input.threadId); if (!thread) { @@ -870,6 +1085,7 @@ const make = Effect.gen(function* () { yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), pendingTurnStart: true, + ...(input.executionTarget !== undefined ? { executionTarget: input.executionTarget } : {}), }); if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); @@ -2032,15 +2248,25 @@ const make = Effect.gen(function* () { return; } + const project = yield* resolveProject(thread.projectId); + const legacyCwd = resolveThreadWorkspaceCwd({ + thread, + projects: project ? [project] : [], + }); + const executionTarget = yield* ensureExecutionTarget(thread, legacyCwd); + // Provisioning can take long enough for a stop or a replacement request to + // land. Re-check before any cwd-dependent or provider side effect. + const postReadyClaim = yield* readTurnStartClaim; + if (postReadyClaim.supersededBySameMessage || postReadyClaim.interruptedAfter) { + return; + } + const executionCwd = + executionTarget.kind === "sandbox" ? executionTarget.workspaceCwd : executionTarget.cwd; + const isFirstUserMessageTurn = thread.messages.filter((entry) => entry.role === "user").length === 1; if (isFirstUserMessageTurn) { - const project = yield* resolveProject(thread.projectId); - const generationCwd = - resolveThreadWorkspaceCwd({ - thread, - projects: project ? [project] : [], - }) ?? process.cwd(); + const generationCwd = executionCwd; const generationInput = { messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), @@ -2111,6 +2337,7 @@ const make = Effect.gen(function* () { : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, + executionTarget, turnRequestSequence: event.sequence, }).pipe( Effect.map(Option.some), @@ -2852,4 +3079,5 @@ const make = Effect.gen(function* () { export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make).pipe( Layer.provide(OrchestrationEventStoreLive), Layer.provide(ProviderTurnSendClaimRepositoryLive), + Layer.provide(T3ProjectFileLoaderLive), ); diff --git a/apps/server/src/orchestration/Layers/SandboxLifecycleReactor.test.ts b/apps/server/src/orchestration/Layers/SandboxLifecycleReactor.test.ts new file mode 100644 index 000000000000..320a4a3a2d69 --- /dev/null +++ b/apps/server/src/orchestration/Layers/SandboxLifecycleReactor.test.ts @@ -0,0 +1,322 @@ +import { + CommandId, + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationEvent, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it, vi } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; + +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { T3ProjectFileLoader } from "../../project/T3ProjectFileLoader.ts"; +import { SandboxRuntimeManager } from "../../sandbox/SandboxRuntimeManager.ts"; +import type { SandboxRuntimeManagerShape } from "../../sandbox/SandboxRuntimeManager.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { SandboxLifecycleReactor } from "../Services/SandboxLifecycleReactor.ts"; +import { make } from "./SandboxLifecycleReactor.ts"; + +const NOW = "2026-08-16T00:00:00.000Z"; +const threadId = ThreadId.make("thread-manual"); +const projectId = ProjectId.make("project-manual"); + +const snapshot: OrchestrationReadModel = { + snapshotSequence: 1, + projects: [ + { + id: projectId, + title: "Project", + workspaceRoot: "/tmp/manual-sandbox-project", + defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + }, + ], + threads: [ + { + id: threadId, + projectId, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + sandbox: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, +}; + +const request: OrchestrationEvent = { + sequence: 1, + eventId: EventId.make("manual-request"), + commandId: CommandId.make("manual-command"), + aggregateKind: "thread", + aggregateId: threadId, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "sandbox.provision-requested", + payload: { threadId, config: { runtime: "podman" } }, + occurredAt: NOW, +}; + +it.layer(NodeServices.layer)("manual sandbox lifecycle provisioning", (it) => { + it.effect("resolves immutable provenance and invokes the sandbox runtime", () => + Effect.gen(function* () { + const provisioned = yield* Deferred.make(); + const events = yield* PubSub.unbounded(); + const dispatched: OrchestrationCommand[] = []; + const provision = vi.fn((input: Parameters[0]) => + Deferred.succeed(provisioned, undefined).pipe( + Effect.as({ + sandboxId: "sandbox-manual", + runtime: "podman" as const, + containerName: "t3-thread-manual", + desktopSessionId: "desktop-manual", + desktopStreamPath: "/desktop/manual", + services: [], + }), + ), + ); + const layer = Layer.effect(SandboxLifecycleReactor, make).pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mock(GitWorkflowService)({ + localStatus: () => Effect.succeed({ isRepo: true, refName: "main" } as never), + resolveRemoteTrackingCommit: () => + Effect.succeed({ + commitSha: "0123456789abcdef0123456789abcdef01234567", + remoteRefName: "origin/main", + }), + }), + ), + Layer.provide(Layer.mock(ProviderService)({ listSessions: () => Effect.succeed([]) })), + Layer.provide( + Layer.succeed(T3ProjectFileLoader, { + load: () => + Effect.succeed( + Option.some({ + sandbox: { + image: + "registry.example/t3-desktop@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + } as never), + ), + }), + ), + Layer.provide( + Layer.succeed(SandboxRuntimeManager, { + provision, + reconcile: () => + Effect.succeed({ activeThreadIds: [], missingThreadIds: [], orphanThreadIds: [] }), + } as never), + ), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery)({ + getSnapshot: () => Effect.succeed(snapshot), + getThreadDetailById: (id) => + Effect.succeed(id === threadId ? Option.some(snapshot.threads[0]!) : Option.none()), + }), + ), + Layer.provide( + Layer.mock(OrchestrationEngineService)({ + dispatch: (command) => + Effect.gen(function* () { + dispatched.push(command); + if (command.type === "sandbox.provision") { + yield* PubSub.publish(events, { + ...request, + sequence: 2, + eventId: EventId.make("manual-provisioning"), + commandId: command.commandId, + type: "sandbox.provisioning-started", + payload: { + threadId, + event: { type: "sandbox.provisioning-started", threadId, occurredAt: NOW }, + sandbox: { + lifecycle: "provisioning", + runtime: "podman", + branch: command.branch!, + limits: { + cpuCount: 2, + memoryBytes: 4_294_967_296, + diskBytes: 21_474_836_480, + processCount: 512, + idleTimeoutSeconds: 3600, + maximumLifetimeSeconds: 28_800, + }, + desktop: { + status: "starting", + resolution: { width: 1440, height: 900, webRtcEnabled: true }, + }, + services: [], + controller: { kind: "none" }, + createdAt: NOW, + lastActiveAt: NOW, + }, + }, + }); + } + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.concat(Stream.make(request), Stream.fromPubSub(events)), + }), + ), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const reactor = yield* SandboxLifecycleReactor; + yield* reactor.start(); + yield* Deferred.await(provisioned); + yield* reactor.drain; + }).pipe(Effect.provide(layer)), + ); + + expect(provision).toHaveBeenCalledTimes(1); + expect(provision.mock.calls[0]?.[0]).toMatchObject({ + bootstrap: { + threadId, + projectId, + baseCommit: "0123456789abcdef0123456789abcdef01234567", + branchName: `t3/thread/${threadId}`, + }, + config: { runtime: "podman" }, + }); + expect(dispatched.map((command) => command.type)).toEqual([ + "sandbox.provision", + "sandbox.provision.ready", + ]); + }), + ); + + it.effect("waits for provisioning projection before recording a missing-image failure", () => + Effect.gen(function* () { + const failed = yield* Deferred.make(); + const events = yield* PubSub.unbounded(); + const dispatched: OrchestrationCommand[] = []; + let projected = false; + const layer = Layer.effect(SandboxLifecycleReactor, make).pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mock(GitWorkflowService)({ + localStatus: () => Effect.succeed({ isRepo: true, refName: "main" } as never), + resolveRemoteTrackingCommit: () => + Effect.succeed({ + commitSha: "0123456789abcdef0123456789abcdef01234567", + remoteRefName: "origin/main", + }), + }), + ), + Layer.provide(Layer.mock(ProviderService)({ listSessions: () => Effect.succeed([]) })), + Layer.provide( + Layer.succeed(T3ProjectFileLoader, { load: () => Effect.succeed(Option.none()) }), + ), + Layer.provide( + Layer.succeed(SandboxRuntimeManager, { + provision: () => Effect.die("runtime must not run without an image"), + reconcile: () => + Effect.succeed({ activeThreadIds: [], missingThreadIds: [], orphanThreadIds: [] }), + } as never), + ), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery)({ + getSnapshot: () => Effect.succeed(snapshot), + getThreadDetailById: (id) => + Effect.succeed(id === threadId ? Option.some(snapshot.threads[0]!) : Option.none()), + }), + ), + Layer.provide( + Layer.mock(OrchestrationEngineService)({ + dispatch: (command) => + Effect.gen(function* () { + dispatched.push(command); + if (command.type === "sandbox.provision") { + projected = true; + yield* PubSub.publish(events, { + ...request, + sequence: 2, + eventId: EventId.make("missing-image-provisioning"), + commandId: command.commandId, + type: "sandbox.provisioning-started", + payload: { + threadId, + event: { type: "sandbox.provisioning-started", threadId, occurredAt: NOW }, + sandbox: { + lifecycle: "provisioning", + runtime: "podman", + branch: command.branch!, + limits: { + cpuCount: 2, + memoryBytes: 4_294_967_296, + diskBytes: 21_474_836_480, + processCount: 512, + idleTimeoutSeconds: 3600, + maximumLifetimeSeconds: 28_800, + }, + desktop: { + status: "starting", + resolution: { width: 1440, height: 900, webRtcEnabled: true }, + }, + services: [], + controller: { kind: "none" }, + createdAt: NOW, + lastActiveAt: NOW, + }, + }, + }); + } + if (command.type === "sandbox.operation.fail") { + expect(projected).toBe(true); + yield* Deferred.succeed(failed, undefined); + } + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.concat(Stream.make(request), Stream.fromPubSub(events)), + }), + ), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const reactor = yield* SandboxLifecycleReactor; + yield* reactor.start(); + yield* Deferred.await(failed).pipe(Effect.timeout("5 seconds")); + yield* reactor.drain; + }).pipe(Effect.provide(layer)), + ); + + expect(dispatched.map((command) => command.type)).toEqual([ + "sandbox.provision", + "sandbox.operation.fail", + ]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/SandboxLifecycleReactor.ts b/apps/server/src/orchestration/Layers/SandboxLifecycleReactor.ts new file mode 100644 index 000000000000..746e75c980d6 --- /dev/null +++ b/apps/server/src/orchestration/Layers/SandboxLifecycleReactor.ts @@ -0,0 +1,637 @@ +// @effect-diagnostics nodeBuiltinImport:off - validates inherited Git patch content before container handoff. +import { createHash } from "node:crypto"; +import { + CommandId, + EventId, + MessageId, + SandboxId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { SandboxManagerError, SandboxRuntimeManager } from "../../sandbox/SandboxRuntimeManager.ts"; +import { forkParked } from "../../serverActivation.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { + SandboxLifecycleReactor, + type SandboxLifecycleReactorShape, +} from "../Services/SandboxLifecycleReactor.ts"; +import { + T3ProjectFileLoader, + layer as T3ProjectFileLoaderLive, +} from "../../project/T3ProjectFileLoader.ts"; +import { desktopGateway } from "../../sandbox/DesktopGatewayService.ts"; + +type SandboxRequestEvent = Extract< + OrchestrationEvent, + { + type: + | "sandbox.branch-export-requested" + | "sandbox.provision-requested" + | "sandbox.worker-spawn-requested" + | "sandbox.worker-status-requested" + | "sandbox.worker-message-requested" + | "sandbox.worker-stop-requested" + | "sandbox.stopping" + | "sandbox.takeover-requested" + | "sandbox.takeover-acquired" + | "sandbox.resumed"; + } +>; + +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery; + const providers = yield* ProviderService; + const runtimes = yield* SandboxRuntimeManager; + const projectFiles = yield* T3ProjectFileLoader; + const gitWorkflow = yield* GitWorkflowService; + const crypto = yield* Crypto.Crypto; + const commandId = (tag: string) => + crypto.randomUUIDv4.pipe(Effect.map((id) => CommandId.make(`server:${tag}:${id}`))); + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const dispatchAndAwaitProjection = Effect.fn( + "SandboxLifecycleReactor.dispatchAndAwaitProjection", + )(function* (command: Parameters[0]) { + return yield* Effect.scoped( + Effect.gen(function* () { + const pull = yield* Stream.toPull( + engine.streamDomainEvents.pipe( + Stream.filter((candidate) => candidate.commandId === command.commandId), + ), + ); + const projected = yield* pull.pipe(Effect.timeout(Duration.seconds(30)), Effect.forkScoped); + // Let the stream fiber acquire its PubSub subscription before dispatch + // can publish the matching committed event. + yield* Effect.yieldNow; + const receipt = yield* engine.dispatch(command); + yield* Fiber.join(projected); + return receipt; + }), + ); + }); + + const getThread = (threadId: Parameters[0]) => + snapshots.getThreadDetailById(threadId).pipe(Effect.map(Option.getOrUndefined)); + + const exportBranch = Effect.fn("SandboxLifecycleReactor.exportBranch")(function* ( + threadId: Parameters[0], + ) { + const thread = yield* getThread(threadId); + const runtime = thread?.sandbox?.runtime; + if (thread?.sandbox == null || (runtime !== "docker" && runtime !== "podman")) return; + const result = yield* runtimes.exportBranch(runtime, threadId); + yield* engine.dispatch({ + type: "sandbox.branch-export.result", + commandId: yield* commandId("sandbox-export"), + threadId, + branchName: thread.sandbox.branch.branchName, + headCommit: result.commit, + createdAt: yield* nowIso, + artifactId: result.artifactId, + bundleSha256: result.bundleSha256, + }); + }); + + const stop = Effect.fn("SandboxLifecycleReactor.stop")(function* ( + threadId: Parameters[0], + expired: boolean, + ) { + const thread = yield* getThread(threadId); + const runtime = thread?.sandbox?.runtime; + if (thread?.sandbox == null || (runtime !== "docker" && runtime !== "podman")) return; + const sessions = yield* providers.listSessions(); + if (sessions.some((session) => session.threadId === threadId)) + yield* providers.stopSession({ threadId }); + yield* exportBranch(threadId); + yield* runtimes.stop(runtime, threadId); + yield* engine.dispatch({ + type: "sandbox.stop.complete", + commandId: yield* commandId("sandbox-stop-complete"), + threadId, + expired, + createdAt: yield* nowIso, + }); + }); + + const processEvent = Effect.fn("SandboxLifecycleReactor.processEvent")(function* ( + event: SandboxRequestEvent, + ) { + if (event.type === "sandbox.provision-requested") { + const thread = yield* getThread(event.payload.threadId); + if (!thread) return; + const snapshot = yield* snapshots.getSnapshot(); + const project = snapshot.projects.find((item) => item.id === thread.projectId); + if (!project) + return yield* new SandboxManagerError({ + message: `project '${thread.projectId}' was not found`, + }); + const branch = + thread.sandbox?.branch ?? + thread.sandboxBranch ?? + (yield* Effect.gen(function* () { + const local = yield* gitWorkflow.localStatus({ cwd: project.workspaceRoot }); + if (!local.isRepo || local.refName === null) + return yield* new SandboxManagerError({ + message: "Isolated threads require a Git repository with a selected branch.", + }); + const base = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: project.workspaceRoot, + refName: local.refName, + fallbackRemoteName: "origin", + }); + return { + branchName: `t3/thread/${thread.id}`, + baseCommit: base.commitSha, + }; + })); + const config = event.payload.config ?? thread.sandboxConfig ?? {}; + const createdAt = yield* nowIso; + const provisionCommandId = yield* commandId("sandbox-manual-provision"); + yield* dispatchAndAwaitProjection({ + type: "sandbox.provision", + commandId: provisionCommandId, + threadId: thread.id, + config, + branch, + createdAt, + }); + const declaration = Option.getOrUndefined( + yield* projectFiles.load(project.workspaceRoot), + )?.sandbox; + const image = declaration?.image ?? process.env.T3_SANDBOX_IMAGE?.trim(); + if (!image) + return yield* new SandboxManagerError({ + message: "T3_SANDBOX_IMAGE must name a digest-pinned desktop sandbox image.", + }); + const provision = yield* runtimes.provision({ + bootstrap: { + threadId: thread.id, + projectId: thread.projectId, + repositoryUrl: project.repositoryIdentity?.locator.remoteUrl ?? project.workspaceRoot, + baseCommit: branch.baseCommit, + branchName: branch.branchName, + }, + config, + image, + ...(process.env.T3_SANDBOX_EGRESS_PROXY_IMAGE?.trim() + ? { egressProxyImage: process.env.T3_SANDBOX_EGRESS_PROXY_IMAGE.trim() } + : {}), + ...(declaration?.caches ? { caches: declaration.caches } : {}), + ...(declaration?.setup ? { setup: declaration.setup } : {}), + ...(declaration?.teardown ? { teardown: declaration.teardown } : {}), + ...(declaration?.services ? { services: declaration.services } : {}), + ...(declaration?.previewPorts ? { previewPorts: declaration.previewPorts } : {}), + }); + yield* engine.dispatch({ + type: "sandbox.provision.ready", + commandId: yield* commandId("sandbox-manual-ready"), + threadId: thread.id, + sandboxId: SandboxId.make(provision.sandboxId), + runtime: provision.runtime, + runtimeRef: provision.containerName, + createdAt: yield* nowIso, + }); + const readyThread = yield* getThread(thread.id); + if (readyThread?.sandbox) { + const checkedAt = yield* nowIso; + yield* engine.dispatch({ + type: "sandbox.reconcile.result", + commandId: yield* commandId("sandbox-manual-service-health"), + threadId: thread.id, + disposition: "matched", + sandbox: { + ...readyThread.sandbox, + services: provision.services.map((service) => ({ + name: service.name, + status: "healthy" as const, + ...(service.internalPorts[0] === undefined + ? {} + : { internalPort: service.internalPorts[0] }), + checkedAt, + })), + }, + createdAt: checkedAt, + }); + } + return; + } + if (event.type === "sandbox.takeover-acquired") { + desktopGateway.setHumanControl(event.payload.threadId, true); + return; + } + if (event.type === "sandbox.resumed") { + desktopGateway.setHumanControl(event.payload.threadId, false); + return; + } + if (event.type === "sandbox.branch-export-requested") + return yield* exportBranch(event.payload.threadId); + if (event.type === "sandbox.takeover-requested") { + const sessions = yield* providers.listSessions(); + if (sessions.some((session) => session.threadId === event.payload.threadId)) { + yield* providers + .stopSession({ threadId: event.payload.threadId }) + .pipe(Effect.timeout(Duration.seconds(30))); + } + const request = event.payload.event as Extract< + typeof event.payload.event, + { type: "sandbox.takeover-requested" } + >; + yield* engine.dispatch({ + type: "sandbox.takeover.complete", + commandId: yield* commandId("sandbox-takeover-complete"), + threadId: event.payload.threadId, + sessionId: request.sessionId, + createdAt: yield* nowIso, + }); + return; + } + if (event.type === "sandbox.stopping") { + desktopGateway.setHumanControl(event.payload.threadId, false); + const stopping = event.payload.event as Extract< + typeof event.payload.event, + { type: "sandbox.stopping" } + >; + return yield* stop(event.payload.threadId, stopping.expired); + } + if (event.type === "sandbox.worker-spawn-requested") { + const parent = yield* getThread(event.payload.parentThreadId); + if (!parent) return; + const createdAt = yield* nowIso; + const inheritedPatch = event.payload.inheritedPatch; + if (inheritedPatch && inheritedPatch.content === undefined) { + return yield* new SandboxManagerError({ + message: "inherited worker patch metadata did not include patch content", + }); + } + if (inheritedPatch?.content !== undefined) { + const bytes = Buffer.byteLength(inheritedPatch.content); + const digest = createHash("sha256").update(inheritedPatch.content).digest("hex"); + if (bytes !== inheritedPatch.sizeBytes || digest !== inheritedPatch.sha256) { + return yield* new SandboxManagerError({ + message: "inherited worker patch content failed size or digest validation", + }); + } + } + const snapshot = yield* snapshots.getSnapshot(); + const project = snapshot.projects.find((item) => item.id === parent.projectId); + if (!project) + return yield* new SandboxManagerError({ + message: `project '${parent.projectId}' was not found`, + }); + const declaration = Option.getOrUndefined( + yield* projectFiles.load(project.workspaceRoot), + )?.sandbox; + const fallbackImage = process.env.T3_SANDBOX_IMAGE?.trim(); + const image = declaration?.image ?? fallbackImage; + if (!image) + return yield* new SandboxManagerError({ + message: "A digest-pinned sandbox image is required to spawn an isolated worker", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: yield* commandId("sandbox-worker-create"), + threadId: event.payload.childThreadId, + projectId: parent.projectId, + title: event.payload.task.slice(0, 120), + modelSelection: parent.modelSelection, + routingMode: parent.routingMode, + efficiencyTier: parent.efficiencyTier, + runtimeMode: parent.runtimeMode, + interactionMode: parent.interactionMode, + branch: null, + worktreePath: null, + sandboxConfig: event.payload.config, + sandboxBranch: { + branchName: event.payload.branchName, + baseCommit: event.payload.inheritedCommit, + parentThreadId: event.payload.parentThreadId, + inheritedCommit: event.payload.inheritedCommit, + ...(event.payload.inheritedPatch + ? { inheritedPatchSha256: event.payload.inheritedPatch.sha256 } + : {}), + }, + createdAt, + }); + yield* engine.dispatch({ + type: "sandbox.provision", + commandId: yield* commandId("sandbox-worker-provision"), + threadId: event.payload.childThreadId, + config: event.payload.config ?? {}, + createdAt, + }); + const provision = yield* runtimes.provision({ + bootstrap: { + threadId: event.payload.childThreadId, + projectId: parent.projectId, + repositoryUrl: project.repositoryIdentity?.locator.remoteUrl ?? project.workspaceRoot, + baseCommit: event.payload.inheritedCommit, + branchName: event.payload.branchName, + parentThreadId: event.payload.parentThreadId, + ...(inheritedPatch?.content !== undefined + ? { inheritedPatch: inheritedPatch.content } + : {}), + }, + config: event.payload.config ?? {}, + image, + ...(process.env.T3_SANDBOX_EGRESS_PROXY_IMAGE?.trim() + ? { egressProxyImage: process.env.T3_SANDBOX_EGRESS_PROXY_IMAGE.trim() } + : {}), + ...(declaration?.caches ? { caches: declaration.caches } : {}), + ...(declaration?.setup ? { setup: declaration.setup } : {}), + ...(declaration?.teardown ? { teardown: declaration.teardown } : {}), + ...(declaration?.services ? { services: declaration.services } : {}), + ...(declaration?.previewPorts ? { previewPorts: declaration.previewPorts } : {}), + }); + yield* engine.dispatch({ + type: "sandbox.provision.ready", + commandId: yield* commandId("sandbox-worker-ready"), + threadId: event.payload.childThreadId, + sandboxId: SandboxId.make(provision.sandboxId), + runtime: provision.runtime, + runtimeRef: provision.containerName, + createdAt: yield* nowIso, + }); + const readyChild = yield* getThread(event.payload.childThreadId); + if (readyChild?.sandbox) { + const checkedAt = yield* nowIso; + yield* engine.dispatch({ + type: "sandbox.reconcile.result", + commandId: yield* commandId("worker-service-health"), + threadId: event.payload.childThreadId, + disposition: "matched", + sandbox: { + ...readyChild.sandbox, + services: provision.services.map((service) => ({ + name: service.name, + status: "healthy" as const, + ...(service.internalPorts[0] === undefined + ? {} + : { internalPort: service.internalPorts[0] }), + checkedAt, + })), + }, + createdAt: checkedAt, + }); + } + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: yield* commandId("sandbox-worker-run"), + threadId: event.payload.childThreadId, + message: { + messageId: MessageId.make(`worker:${yield* crypto.randomUUIDv4}`), + role: "user", + text: event.payload.task, + attachments: [], + }, + runtimeMode: parent.runtimeMode, + interactionMode: parent.interactionMode, + createdAt, + }); + return; + } + if (event.type === "sandbox.worker-message-requested" && event.payload.message) { + const child = yield* getThread(event.payload.childThreadId); + if (!child || child.sandbox?.branch.parentThreadId !== event.payload.parentThreadId) return; + const id = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`server:worker-message:${id}`), + threadId: child.id, + message: { + messageId: MessageId.make(`worker:${id}`), + role: "user", + text: event.payload.message, + attachments: [], + }, + runtimeMode: child.runtimeMode, + interactionMode: child.interactionMode, + createdAt: yield* nowIso, + }); + return; + } + if (event.type === "sandbox.worker-stop-requested") { + const child = yield* getThread(event.payload.childThreadId); + if (!child || child.sandbox?.branch.parentThreadId !== event.payload.parentThreadId) return; + yield* engine.dispatch({ + type: "sandbox.stop", + commandId: yield* commandId("worker-stop"), + threadId: child.id, + createdAt: yield* nowIso, + }); + return; + } + if (event.type === "sandbox.worker-status-requested") { + const child = yield* getThread(event.payload.childThreadId); + if (!child || child.sandbox?.branch.parentThreadId !== event.payload.parentThreadId) return; + const createdAt = yield* nowIso; + yield* engine.dispatch({ + type: "thread.activity.append", + commandId: yield* commandId("worker-status"), + threadId: event.payload.parentThreadId, + createdAt, + activity: { + id: EventId.make(yield* crypto.randomUUIDv4), + tone: "info", + kind: "sandbox.worker.status", + summary: `Worker ${child.id} is ${child.sandbox.lifecycle}`, + payload: { + childThreadId: child.id, + lifecycle: child.sandbox.lifecycle, + latestTurn: child.latestTurn, + }, + turnId: null, + createdAt, + }, + }); + } + }); + const worker = yield* makeDrainableWorker((event: SandboxRequestEvent) => + processEvent(event).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const threadId = + "threadId" in event.payload ? event.payload.threadId : event.payload.childThreadId; + const occurredAt = yield* nowIso; + yield* engine + .dispatch({ + type: "sandbox.operation.fail", + commandId: yield* commandId("sandbox-lifecycle-failed"), + threadId, + failure: { + stage: + event.type === "sandbox.branch-export-requested" + ? "export" + : event.type === "sandbox.stopping" + ? "teardown" + : "runtime", + code: "sandbox_lifecycle_failed", + message: String(cause), + retryable: true, + occurredAt, + }, + createdAt: occurredAt, + }) + .pipe(Effect.ignore); + yield* Effect.logWarning("sandbox lifecycle event failed", { type: event.type, cause }); + }), + ), + ), + ); + + const reconcile = Effect.fn("SandboxLifecycleReactor.reconcile")(function* () { + const snapshot = yield* snapshots.getSnapshot(); + for (const runtime of ["docker", "podman"] as const) { + const expected = new Set( + snapshot.threads + .filter( + (thread) => + thread.sandbox?.runtime === runtime && + !["stopped", "expired", "deleted"].includes(thread.sandbox.lifecycle), + ) + .map((thread) => thread.id), + ); + const result = yield* runtimes.reconcile(runtime, expected).pipe(Effect.option); + if (Option.isNone(result)) continue; + for (const threadId of result.value.activeThreadIds) { + const thread = snapshot.threads.find((item) => item.id === threadId); + desktopGateway.setServiceStatus( + threadId, + (thread?.sandbox?.services ?? []).map((service) => ({ + name: service.name, + healthy: service.status === "healthy", + })), + ); + const project = snapshot.projects.find((item) => item.id === thread?.projectId); + const declaration = + project === undefined + ? undefined + : Option.getOrUndefined(yield* projectFiles.load(project.workspaceRoot))?.sandbox; + if (thread?.sandbox?.runtimeRef && (declaration?.previewPorts?.length ?? 0) > 0) { + yield* runtimes + .recoverPreview( + runtime, + threadId, + thread.sandbox.runtimeRef, + declaration!.previewPorts!, + ) + .pipe(Effect.ignore); + } + } + for (const threadId of result.value.missingThreadIds) { + const thread = snapshot.threads.find((item) => item.id === threadId); + if (!thread?.sandbox) continue; + const createdAt = yield* nowIso; + yield* engine + .dispatch({ + type: "sandbox.reconcile.result", + commandId: yield* commandId("sandbox-missing"), + threadId: thread.id, + disposition: "missing", + sandbox: { + ...thread.sandbox, + lifecycle: "failed", + failure: { + stage: "reconcile", + code: "sandbox_container_missing", + message: "Recorded sandbox container was not found during startup reconciliation.", + retryable: true, + occurredAt: createdAt, + }, + lastActiveAt: createdAt, + }, + createdAt, + }) + .pipe(Effect.ignore); + } + } + }); + + const expire = Effect.fn("SandboxLifecycleReactor.expire")(function* () { + const snapshot = yield* snapshots.getSnapshot(); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const activeSessions = new Set( + (yield* providers.listSessions()).map((session) => session.threadId), + ); + for (const thread of snapshot.threads) { + const sandbox = thread.sandbox; + if (!sandbox || !["ready", "paused"].includes(sandbox.lifecycle)) continue; + const activeAt = activeSessions.has(thread.id) ? yield* nowIso : sandbox.lastActiveAt; + if (sandbox.runtime === "docker" || sandbox.runtime === "podman") { + const sampledAt = yield* nowIso; + const usage = yield* runtimes.sampleUsage(sandbox.runtime, thread.id).pipe(Effect.option); + if (Option.isSome(usage)) { + yield* engine + .dispatch({ + type: "sandbox.reconcile.result", + commandId: yield* commandId("sandbox-usage"), + threadId: thread.id, + disposition: "matched", + sandbox: { ...sandbox, usage: { ...usage.value, sampledAt }, lastActiveAt: activeAt }, + createdAt: sampledAt, + }) + .pipe(Effect.ignore); + } + } + const idleAt = + DateTime.toEpochMillis(DateTime.makeUnsafe(activeAt)) + + sandbox.limits.idleTimeoutSeconds * 1000; + const maxAt = + DateTime.toEpochMillis(DateTime.makeUnsafe(sandbox.createdAt)) + + sandbox.limits.maximumLifetimeSeconds * 1000; + const deadline = sandbox.controller.kind === "human" ? maxAt : Math.min(idleAt, maxAt); + if (now < deadline) continue; + yield* engine + .dispatch({ + type: "sandbox.expire", + commandId: yield* commandId("sandbox-expire"), + threadId: thread.id, + createdAt: yield* nowIso, + }) + .pipe(Effect.ignore); + } + }); + + const start: SandboxLifecycleReactorShape["start"] = Effect.fn("start")(function* () { + yield* reconcile().pipe( + Effect.catchCause((cause) => Effect.logWarning("sandbox reconciliation failed", { cause })), + ); + yield* forkParked( + Stream.runForEach(engine.streamDomainEvents, (event) => + event.type.startsWith("sandbox.") && + [ + "sandbox.branch-export-requested", + "sandbox.provision-requested", + "sandbox.worker-spawn-requested", + "sandbox.worker-status-requested", + "sandbox.worker-message-requested", + "sandbox.worker-stop-requested", + "sandbox.stopping", + "sandbox.takeover-requested", + "sandbox.takeover-acquired", + "sandbox.resumed", + ].includes(event.type) + ? worker.enqueue(event as SandboxRequestEvent) + : Effect.void, + ), + ); + yield* forkParked(expire().pipe(Effect.repeat(Schedule.spaced(Duration.minutes(1))))); + }); + return { start, drain: worker.drain } satisfies SandboxLifecycleReactorShape; +}); + +export const SandboxLifecycleReactorLive = Layer.effect(SandboxLifecycleReactor, make).pipe( + Layer.provide(T3ProjectFileLoaderLive), +); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 38aaa7229914..212464b2507e 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -20,6 +20,7 @@ import type { OrchestrationThreadDetailWindow, OrchestrationThreadShell, ProjectId, + SandboxState, ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -42,6 +43,7 @@ export interface ProjectionThreadCheckpointContext { readonly projectId: ProjectId; readonly workspaceRoot: string; readonly worktreePath: string | null; + readonly sandbox?: SandboxState | null; readonly checkpoints: ReadonlyArray; } @@ -50,6 +52,7 @@ export interface ProjectionFullThreadDiffContext { readonly projectId: ProjectId; readonly workspaceRoot: string; readonly worktreePath: string | null; + readonly sandbox?: SandboxState | null; readonly latestCheckpointTurnCount: number; readonly toCheckpointRef: CheckpointRef | null; } diff --git a/apps/server/src/orchestration/Services/SandboxLifecycleReactor.ts b/apps/server/src/orchestration/Services/SandboxLifecycleReactor.ts new file mode 100644 index 000000000000..c77c8ad0694e --- /dev/null +++ b/apps/server/src/orchestration/Services/SandboxLifecycleReactor.ts @@ -0,0 +1,13 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface SandboxLifecycleReactorShape { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; +} + +export class SandboxLifecycleReactor extends Context.Reference( + "@awtprod/command-center/orchestration/Services/SandboxLifecycleReactor", + { defaultValue: () => ({ start: () => Effect.void, drain: Effect.void }) }, +) {} diff --git a/apps/server/src/orchestration/decider.sandbox.test.ts b/apps/server/src/orchestration/decider.sandbox.test.ts new file mode 100644 index 000000000000..f65d40f57b5c --- /dev/null +++ b/apps/server/src/orchestration/decider.sandbox.test.ts @@ -0,0 +1,370 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-08-15T12:00:00.000Z"; +const BRANCH = { + branchName: "t3/thread-1", + baseCommit: "0123456789abcdef0123456789abcdef01234567", +}; + +function readModel( + sandbox: OrchestrationReadModel["threads"][number]["sandbox"] = null, +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + sandbox, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("sandbox decider", (it) => { + it.effect("lazily provisions a historical thread with no sandbox state", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + readModel: readModel(), + command: { + type: "sandbox.provision", + commandId: CommandId.make("provision"), + threadId: ThreadId.make("thread-1"), + branch: BRANCH, + createdAt: NOW, + }, + }); + expect(Array.isArray(event)).toBe(false); + const provisioned = event as Omit< + Extract, + "sequence" + >; + expect(provisioned.type).toBe("sandbox.provisioning-started"); + expect(provisioned.payload.sandbox.lifecycle).toBe("provisioning"); + expect(provisioned.payload.sandbox.branch).toEqual(BRANCH); + expect(provisioned.payload.sandbox.limits.cpuCount).toBe(2); + }), + ); + + it.effect("durably requests provenance resolution for manual provisioning", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + readModel: readModel(), + command: { + type: "sandbox.provision", + commandId: CommandId.make("manual-provision"), + threadId: ThreadId.make("thread-1"), + config: { runtime: "podman" }, + createdAt: NOW, + }, + }); + expect(Array.isArray(event)).toBe(false); + const requested = event as Omit< + Extract, + "sequence" + >; + expect(requested.type).toBe("sandbox.provision-requested"); + expect(requested.payload).toEqual({ + threadId: ThreadId.make("thread-1"), + config: { runtime: "podman" }, + }); + }), + ); + + it.effect("rejects simultaneous human takeover leases", () => { + const sandbox = { + lifecycle: "paused" as const, + branch: BRANCH, + limits: { + cpuCount: 2, + memoryBytes: 4_294_967_296, + diskBytes: 21_474_836_480, + processCount: 512, + idleTimeoutSeconds: 3600, + maximumLifetimeSeconds: 28800, + }, + desktop: { status: "ready" as const }, + services: [], + controller: { + kind: "human" as const, + leaseId: "first", + sessionId: "viewer-1", + acquiredAt: NOW, + }, + pauseReason: "human-takeover" as const, + createdAt: NOW, + lastActiveAt: NOW, + }; + return Effect.gen(function* () { + const exit = yield* Effect.exit( + decideOrchestrationCommand({ + readModel: readModel(sandbox), + command: { + type: "sandbox.takeover", + commandId: CommandId.make("second"), + threadId: ThreadId.make("thread-1"), + sessionId: "viewer-2", + createdAt: NOW, + }, + }), + ); + expect(exit._tag).toBe("Failure"); + }); + }); + + it.effect("does not grant a takeover lease until provider drain completes", () => + Effect.gen(function* () { + const ready = { + lifecycle: "ready" as const, + branch: BRANCH, + limits: { + cpuCount: 2, + memoryBytes: 4_294_967_296, + diskBytes: 21_474_836_480, + processCount: 512, + idleTimeoutSeconds: 3600, + maximumLifetimeSeconds: 28800, + }, + desktop: { status: "ready" as const }, + services: [], + controller: { kind: "none" as const }, + createdAt: NOW, + lastActiveAt: NOW, + }; + const requestedResult = yield* decideOrchestrationCommand({ + readModel: readModel(ready), + command: { + type: "sandbox.takeover", + commandId: CommandId.make("request"), + threadId: ThreadId.make("thread-1"), + sessionId: "viewer", + createdAt: NOW, + }, + }); + expect(Array.isArray(requestedResult)).toBe(false); + const requested = requestedResult as unknown as OrchestrationEvent; + expect(requested.type).toBe("sandbox.takeover-requested"); + if (requested.type !== "sandbox.takeover-requested") return; + expect(requested.payload.sandbox.lifecycle).toBe("pausing"); + expect(requested.payload.sandbox.controller.kind).toBe("none"); + const acquiredResult = yield* decideOrchestrationCommand({ + readModel: readModel(requested.payload.sandbox), + command: { + type: "sandbox.takeover.complete", + commandId: CommandId.make("complete"), + threadId: ThreadId.make("thread-1"), + sessionId: "viewer", + createdAt: NOW, + }, + }); + expect(Array.isArray(acquiredResult)).toBe(false); + const acquired = acquiredResult as unknown as OrchestrationEvent; + expect(acquired.type).toBe("sandbox.takeover-acquired"); + if (acquired.type !== "sandbox.takeover-acquired") return; + expect(acquired.payload.sandbox.controller.kind).toBe("human"); + }), + ); + + it.effect("rejects new agent turns while a human holds the desktop lease", () => { + const sandbox = { + lifecycle: "paused" as const, + branch: BRANCH, + limits: { + cpuCount: 2, + memoryBytes: 4_294_967_296, + diskBytes: 21_474_836_480, + processCount: 512, + idleTimeoutSeconds: 3600, + maximumLifetimeSeconds: 28800, + }, + desktop: { status: "ready" as const }, + services: [], + controller: { + kind: "human" as const, + leaseId: "lease", + sessionId: "viewer", + acquiredAt: NOW, + }, + pauseReason: "human-takeover" as const, + createdAt: NOW, + lastActiveAt: NOW, + }; + return Effect.gen(function* () { + const exit = yield* Effect.exit( + decideOrchestrationCommand({ + readModel: readModel(sandbox), + command: { + type: "thread.turn.start", + commandId: CommandId.make("turn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: "message-1" as never, + role: "user", + text: "continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + }), + ); + expect(exit._tag).toBe("Failure"); + }); + }); + + it.effect("requires the active human lease to resume", () => { + const sandbox = { + lifecycle: "paused" as const, + branch: BRANCH, + limits: { + cpuCount: 2, + memoryBytes: 4_294_967_296, + diskBytes: 21_474_836_480, + processCount: 512, + idleTimeoutSeconds: 3600, + maximumLifetimeSeconds: 28800, + }, + desktop: { status: "ready" as const }, + services: [], + controller: { + kind: "human" as const, + leaseId: "lease-1", + sessionId: "viewer-1", + acquiredAt: NOW, + }, + pauseReason: "human-takeover" as const, + createdAt: NOW, + lastActiveAt: NOW, + }; + return Effect.gen(function* () { + const exit = yield* Effect.exit( + decideOrchestrationCommand({ + readModel: readModel(sandbox), + command: { + type: "sandbox.resume", + commandId: CommandId.make("resume"), + threadId: ThreadId.make("thread-1"), + leaseId: "wrong", + createdAt: NOW, + }, + }), + ); + expect(exit._tag).toBe("Failure"); + }); + }); + + it.effect("records explicit child branch and inherited commit in worker spawn requests", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + readModel: readModel(), + command: { + type: "sandbox.worker.spawn", + commandId: CommandId.make("spawn-worker"), + parentThreadId: ThreadId.make("thread-1"), + childThreadId: ThreadId.make("thread-child"), + branchName: "t3/thread-child", + inheritedCommit: BRANCH.baseCommit, + task: "Implement child task", + createdAt: NOW, + }, + }); + + expect(Array.isArray(event)).toBe(false); + const requested = event as Omit< + Extract, + "sequence" + >; + expect(requested.type).toBe("sandbox.worker-spawn-requested"); + if (requested.type === "sandbox.worker-spawn-requested") { + expect(requested.payload).toMatchObject({ + parentThreadId: "thread-1", + childThreadId: "thread-child", + branchName: "t3/thread-child", + inheritedCommit: BRANCH.baseCommit, + }); + } + }), + ); + + it.effect("keeps stop non-terminal until export and teardown complete", () => + Effect.gen(function* () { + const ready = { + lifecycle: "ready" as const, + sandboxId: "sandbox-1" as never, + runtime: "docker" as const, + runtimeRef: "container-1", + branch: BRANCH, + limits: { + cpuCount: 2, + memoryBytes: 4_294_967_296, + diskBytes: 21_474_836_480, + processCount: 512, + idleTimeoutSeconds: 3600, + maximumLifetimeSeconds: 28800, + }, + desktop: { status: "ready" as const }, + services: [], + controller: { kind: "none" as const }, + createdAt: NOW, + lastActiveAt: NOW, + }; + const stoppingEvent = (yield* decideOrchestrationCommand({ + readModel: readModel(ready), + command: { + type: "sandbox.stop", + commandId: CommandId.make("stop"), + threadId: ThreadId.make("thread-1"), + createdAt: NOW, + }, + })) as Omit, "sequence">; + expect(stoppingEvent.type).toBe("sandbox.stopping"); + expect(stoppingEvent.payload.sandbox.lifecycle).toBe("stopping"); + + const completed = (yield* decideOrchestrationCommand({ + readModel: readModel(stoppingEvent.payload.sandbox), + command: { + type: "sandbox.stop.complete", + commandId: CommandId.make("complete"), + threadId: ThreadId.make("thread-1"), + expired: false, + createdAt: NOW, + }, + })) as Omit, "sequence">; + expect(completed.type).toBe("sandbox.stopped"); + expect(completed.payload.sandbox.lifecycle).toBe("stopped"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index d04b82e48138..2d014bd680fd 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,8 +1,12 @@ import { + DEFAULT_SANDBOX_DESKTOP_CONFIG, + DEFAULT_SANDBOX_RESOURCE_LIMITS, EventId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type SandboxEvent, + type SandboxState, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -24,6 +28,9 @@ import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const sandboxInvariant = (commandType: string, detail: string) => + new OrchestrationCommandInvariantError({ commandType, detail }); + // Session adoption takes seconds; a user message still unadopted after this // window is a failed/stale start, not pending work. Mirrors the client's // QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. @@ -223,6 +230,24 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" OrchestrationCommandInvariantError | PlatformError.PlatformError, Crypto.Crypto > { + const sandboxTransition = Effect.fn("sandboxTransition")(function* ( + threadId: SandboxEvent["threadId"], + commandId: OrchestrationCommand["commandId"], + type: SandboxEvent["type"], + event: SandboxEvent, + sandbox: SandboxState, + ) { + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: event.occurredAt, + commandId, + })), + type, + payload: { threadId, event, sandbox }, + } as PlannedOrchestrationEvent; + }); switch (command.type) { case "project.create": { yield* requireProjectAbsent({ @@ -360,6 +385,23 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + const sandbox = + command.sandbox ?? + (command.sandboxBranch + ? { + lifecycle: "unprovisioned" as const, + branch: command.sandboxBranch, + limits: command.sandboxConfig?.limits ?? DEFAULT_SANDBOX_RESOURCE_LIMITS, + desktop: { + status: "unavailable" as const, + resolution: command.sandboxConfig?.desktop ?? DEFAULT_SANDBOX_DESKTOP_CONFIG, + }, + services: [], + controller: { kind: "none" as const }, + createdAt: command.createdAt, + lastActiveAt: command.createdAt, + } + : null); return { ...(yield* withEventBase({ aggregateKind: "thread", @@ -381,6 +423,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + sandboxConfig: command.sandboxConfig, + sandboxBranch: command.sandboxBranch, + sandbox, createdAt: command.createdAt, updatedAt: command.createdAt, }, @@ -929,6 +974,21 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + if (targetThread.sandbox?.controller.kind === "human") { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} is controlled by an active human takeover lease`, + ); + } + if ( + targetThread.sandbox != null && + !["unprovisioned", "ready"].includes(targetThread.sandbox.lifecycle) + ) { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox is ${targetThread.sandbox.lifecycle}`, + ); + } const sourceProposedPlan = command.sourceProposedPlan; const sourceThread = sourceProposedPlan ? yield* requireThread({ @@ -1522,6 +1582,466 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" return [unsettledEvent, activityAppendedEvent]; } + case "sandbox.branch-export": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + if (thread.sandbox === null || thread.sandbox === undefined) { + return yield* sandboxInvariant(command.type, `thread ${command.threadId} has no sandbox`); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "sandbox.branch-export-requested", + payload: { threadId: command.threadId }, + }; + } + + case "sandbox.worker.spawn": { + yield* requireThread({ readModel, command, threadId: command.parentThreadId }); + yield* requireThreadAbsent({ readModel, command, threadId: command.childThreadId }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.parentThreadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "sandbox.worker-spawn-requested", + payload: { + parentThreadId: command.parentThreadId, + childThreadId: command.childThreadId, + task: command.task, + inheritedCommit: command.inheritedCommit, + ...(command.inheritedPatch === undefined + ? {} + : { inheritedPatch: command.inheritedPatch }), + ...(command.config === undefined ? {} : { config: command.config }), + branchName: command.branchName, + }, + }; + } + + case "sandbox.worker.status": + case "sandbox.worker.message": + case "sandbox.worker.stop": { + yield* requireThread({ readModel, command, threadId: command.parentThreadId }); + const child = yield* requireThread({ readModel, command, threadId: command.childThreadId }); + if (child.sandboxBranch?.parentThreadId !== command.parentThreadId) { + return yield* sandboxInvariant( + command.type, + `thread ${command.childThreadId} is not a worker of ${command.parentThreadId}`, + ); + } + const suffix = command.type.slice("sandbox.worker.".length); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.parentThreadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: `sandbox.worker-${suffix}-requested` as + | "sandbox.worker-status-requested" + | "sandbox.worker-message-requested" + | "sandbox.worker-stop-requested", + payload: { + parentThreadId: command.parentThreadId, + childThreadId: command.childThreadId, + ...(command.type === "sandbox.worker.message" ? { message: command.message } : {}), + ...(command.type === "sandbox.worker.stop" && command.reason !== undefined + ? { reason: command.reason } + : {}), + }, + }; + } + + case "sandbox.provision": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const current = thread.sandbox ?? null; + if (current !== null && current.lifecycle !== "unprovisioned") { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox is not unprovisioned`, + ); + } + const branch = current?.branch ?? command.branch; + if (branch === undefined) { + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "sandbox.provision-requested", + payload: { + threadId: command.threadId, + ...(command.config === undefined ? {} : { config: command.config }), + }, + }; + } + const config = command.config ?? thread.sandboxConfig ?? {}; + const currentWithoutFailure = + current === null + ? { + branch, + limits: config.limits ?? DEFAULT_SANDBOX_RESOURCE_LIMITS, + desktop: { + status: "unavailable" as const, + resolution: config.desktop ?? DEFAULT_SANDBOX_DESKTOP_CONFIG, + }, + services: [], + controller: { kind: "none" as const }, + createdAt: command.createdAt, + lastActiveAt: command.createdAt, + } + : (({ failure: _failure, ...rest }) => rest)(current); + const sandbox: SandboxState = { + ...currentWithoutFailure, + lifecycle: "provisioning", + runtime: config.runtime ?? "docker", + limits: config.limits ?? currentWithoutFailure.limits, + desktop: { + status: "starting", + resolution: + config.desktop ?? + currentWithoutFailure.desktop.resolution ?? + DEFAULT_SANDBOX_DESKTOP_CONFIG, + }, + lastActiveAt: command.createdAt, + }; + const event: SandboxEvent = { + type: "sandbox.provisioning-started", + threadId: command.threadId, + occurredAt: command.createdAt, + }; + return yield* sandboxTransition( + command.threadId, + command.commandId, + event.type, + event, + sandbox, + ); + } + + case "sandbox.provision.ready": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + const current = thread.sandbox ?? null; + if (current?.lifecycle !== "provisioning") { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox is not provisioning`, + ); + } + const { failure: _failure, ...currentWithoutFailure } = current; + const sandbox: SandboxState = { + ...currentWithoutFailure, + lifecycle: "ready", + sandboxId: command.sandboxId, + runtime: command.runtime, + runtimeRef: command.runtimeRef, + desktop: { ...current.desktop, status: "ready", readyAt: command.createdAt }, + lastActiveAt: command.createdAt, + }; + const event: SandboxEvent = { + type: "sandbox.ready", + threadId: command.threadId, + occurredAt: command.createdAt, + sandboxId: command.sandboxId, + runtime: command.runtime, + runtimeRef: command.runtimeRef, + }; + return yield* sandboxTransition( + command.threadId, + command.commandId, + event.type, + event, + sandbox, + ); + } + + case "sandbox.operation.fail": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + const current = thread.sandbox ?? null; + if (current === null || ["stopped", "expired", "deleted"].includes(current.lifecycle)) { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox cannot fail from its current lifecycle`, + ); + } + const sandbox: SandboxState = { + ...current, + lifecycle: "failed", + failure: command.failure, + lastActiveAt: command.createdAt, + }; + const event: SandboxEvent = { + type: "sandbox.failed", + threadId: command.threadId, + occurredAt: command.createdAt, + failure: command.failure, + }; + return yield* sandboxTransition( + command.threadId, + command.commandId, + event.type, + event, + sandbox, + ); + } + + case "sandbox.pause": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + const current = thread.sandbox ?? null; + if (current?.lifecycle !== "ready" || current.controller.kind === "human") { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox cannot be paused`, + ); + } + const sandbox: SandboxState = { + ...current, + lifecycle: "paused", + pauseReason: command.reason, + controller: { kind: "none" }, + lastActiveAt: command.createdAt, + }; + const event: SandboxEvent = { + type: "sandbox.paused", + threadId: command.threadId, + occurredAt: command.createdAt, + reason: command.reason, + }; + return yield* sandboxTransition( + command.threadId, + command.commandId, + event.type, + event, + sandbox, + ); + } + + case "sandbox.takeover": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + const current = thread.sandbox ?? null; + if (current === null || !["ready", "paused"].includes(current.lifecycle)) { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox is not available for takeover`, + ); + } + if (current.controller.kind === "human") { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} already has an active human takeover lease`, + ); + } + const sandbox: SandboxState = { + ...current, + lifecycle: "pausing", + pauseReason: "human-takeover", + controller: { kind: "none" }, + lastActiveAt: command.createdAt, + }; + const event: SandboxEvent = { + type: "sandbox.takeover-requested", + threadId: command.threadId, + occurredAt: command.createdAt, + sessionId: command.sessionId, + }; + return yield* sandboxTransition( + command.threadId, + command.commandId, + event.type, + event, + sandbox, + ); + } + + case "sandbox.takeover.complete": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + const current = thread.sandbox ?? null; + if (current?.lifecycle !== "pausing" || current.pauseReason !== "human-takeover") { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox is not awaiting takeover`, + ); + } + const controller = { + kind: "human" as const, + leaseId: String(command.commandId), + sessionId: command.sessionId, + acquiredAt: command.createdAt, + }; + const sandbox: SandboxState = { + ...current, + lifecycle: "paused", + controller, + lastActiveAt: command.createdAt, + }; + const event: SandboxEvent = { + type: "sandbox.takeover-acquired", + threadId: command.threadId, + occurredAt: command.createdAt, + controller, + }; + return yield* sandboxTransition( + command.threadId, + command.commandId, + event.type, + event, + sandbox, + ); + } + + case "sandbox.resume": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + const current = thread.sandbox ?? null; + if (current?.lifecycle !== "paused") { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox is not paused`, + ); + } + if (current.controller.kind === "human" && command.leaseId !== current.controller.leaseId) { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} resume does not hold the active takeover lease`, + ); + } + const summary = command.takeoverSummary ?? "Sandbox resumed without manual changes."; + const { pauseReason: _pauseReason, ...currentWithoutPauseReason } = current; + const sandbox: SandboxState = { + ...currentWithoutPauseReason, + lifecycle: "ready", + controller: { kind: "none" }, + lastActiveAt: command.createdAt, + }; + const event: SandboxEvent = { + type: "sandbox.resumed", + threadId: command.threadId, + occurredAt: command.createdAt, + summary, + }; + return yield* sandboxTransition( + command.threadId, + command.commandId, + event.type, + event, + sandbox, + ); + } + + case "sandbox.expire": + case "sandbox.stop": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + const current = thread.sandbox ?? null; + if (current === null || ["stopped", "expired", "deleted"].includes(current.lifecycle)) { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox is already terminal`, + ); + } + if (current.controller.kind === "human" && command.type !== "sandbox.expire") { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox has an active takeover lease`, + ); + } + const expired = command.type === "sandbox.expire"; + const sandbox: SandboxState = { + ...current, + lifecycle: "stopping", + controller: { kind: "none" }, + lastActiveAt: command.createdAt, + }; + const event: SandboxEvent = { + type: "sandbox.stopping", + threadId: command.threadId, + occurredAt: command.createdAt, + expired, + }; + return yield* sandboxTransition( + command.threadId, + command.commandId, + event.type, + event, + sandbox, + ); + } + + case "sandbox.stop.complete": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + const current = thread.sandbox ?? null; + if (current?.lifecycle !== "stopping") { + return yield* sandboxInvariant( + command.type, + `thread ${command.threadId} sandbox is not stopping`, + ); + } + const sandbox: SandboxState = { + ...current, + lifecycle: command.expired ? "expired" : "stopped", + lastActiveAt: command.createdAt, + }; + const event: SandboxEvent = { + type: command.expired ? "sandbox.expired" : "sandbox.stopped", + threadId: command.threadId, + occurredAt: command.createdAt, + }; + return yield* sandboxTransition( + command.threadId, + command.commandId, + event.type, + event, + sandbox, + ); + } + + case "sandbox.reconcile.result": { + yield* requireThread({ readModel, command, threadId: command.threadId }); + const event: SandboxEvent = { + type: "sandbox.reconciled", + threadId: command.threadId, + occurredAt: command.createdAt, + disposition: command.disposition, + }; + return yield* sandboxTransition( + command.threadId, + command.commandId, + event.type, + event, + command.sandbox, + ); + } + + case "sandbox.branch-export.result": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + const current = thread.sandbox ?? null; + if (current === null) + return yield* sandboxInvariant(command.type, `thread ${command.threadId} has no sandbox`); + const event: SandboxEvent = { + type: "sandbox.branch-exported", + threadId: command.threadId, + occurredAt: command.createdAt, + branchName: command.branchName, + headCommit: command.headCommit, + artifactId: command.artifactId, + bundleSha256: command.bundleSha256, + }; + return yield* sandboxTransition(command.threadId, command.commandId, event.type, event, { + ...current, + lastActiveAt: command.createdAt, + }); + } + default: { command satisfies never; const fallback = command as never as { type: string }; diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index a1dee8fc368f..caf6deb6c6fd 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -85,6 +85,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + sandbox: null, latestTurn: null, createdAt: now, updatedAt: now, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fe6ea2104463..b7e1f4188c41 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -9,6 +9,7 @@ import { OrchestrationMessage, OrchestrationSession, OrchestrationThread, + ThreadSandboxLifecyclePayload, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -348,6 +349,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + sandbox: payload.sandbox ?? null, latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -918,6 +920,33 @@ export function projectEvent( }), ); + case "sandbox.provisioning-started": + case "sandbox.ready": + case "sandbox.failed": + case "sandbox.paused": + case "sandbox.takeover-requested": + case "sandbox.takeover-acquired": + case "sandbox.resumed": + case "sandbox.stopping": + case "sandbox.expired": + case "sandbox.stopped": + case "sandbox.reconciled": + case "sandbox.branch-exported": + return decodeForEvent( + ThreadSandboxLifecyclePayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + sandbox: payload.sandbox, + updatedAt: payload.event.occurredAt, + }), + })), + ); + default: return Effect.succeed(nextBase); } diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index bebd8fbb4a7d..608cd08daabf 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,4 +1,9 @@ -import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; +import { + DEFAULT_SANDBOX_RESOURCE_LIMITS, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -135,6 +140,85 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }), ); + it.effect("round-trips validated sandbox state and preserves historical nulls", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + const sql = yield* SqlClient.SqlClient; + const sandbox = { + lifecycle: "ready" as const, + sandboxId: "sandbox-thread-1" as never, + runtime: "podman" as const, + runtimeRef: "container-thread-1", + branch: { + branchName: "threads/thread-sandbox", + baseCommit: "a".repeat(40), + }, + limits: DEFAULT_SANDBOX_RESOURCE_LIMITS, + desktop: { status: "ready" as const, sessionId: "desktop-thread-1" }, + services: [], + controller: { kind: "none" as const }, + createdAt: "2026-08-15T00:00:00.000Z", + lastActiveAt: "2026-08-15T00:00:01.000Z", + }; + + yield* threads.upsert({ + threadId: ThreadId.make("thread-sandbox"), + projectId: ProjectId.make("project-sandbox"), + title: "Sandbox thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "threads/thread-sandbox", + worktreePath: null, + sandbox, + latestTurnId: null, + createdAt: "2026-08-15T00:00:00.000Z", + updatedAt: "2026-08-15T00:00:01.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + + const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-sandbox") }); + assert.deepStrictEqual(Option.getOrNull(persisted)?.sandbox, sandbox); + + yield* sql` + UPDATE projection_threads SET sandbox_json = NULL WHERE thread_id = 'thread-sandbox' + `; + const historical = yield* threads.getById({ threadId: ThreadId.make("thread-sandbox") }); + assert.strictEqual(Option.getOrNull(historical)?.sandbox, null); + }), + ); + + it.effect("fails closed when persisted sandbox JSON is malformed", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_threads + SET sandbox_json = '{"lifecycle":"ready"}' + WHERE thread_id = 'thread-null-options' + `; + + const result = yield* Effect.result( + threads.getById({ threadId: ThreadId.make("thread-null-options") }), + ); + assert.isTrue(result._tag === "Failure"); + }), + ); + it.effect("round-trips non-null settlement values through the thread row", () => Effect.gen(function* () { const threads = yield* ProjectionThreadRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 73c8b0fa1dcd..7ac154aad7dc 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,12 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, SandboxState } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + sandbox: Schema.NullOr(Schema.fromJsonString(SandboxState)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -41,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + sandbox_json, latest_turn_id, created_at, updated_at, @@ -70,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.sandbox == null ? null : JSON.stringify(row.sandbox)}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -99,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + sandbox_json = excluded.sandbox_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -135,6 +139,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + sandbox_json AS "sandbox", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -173,6 +178,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + sandbox_json AS "sandbox", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index f2e70ccce4af..f6e366033837 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -72,6 +72,7 @@ import Migration0056 from "./Migrations/056_ProjectionTurnsKeysetIndex.ts"; import Migration0057 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0058 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0059 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0060 from "./Migrations/060_ProjectionThreadSandbox.ts"; /** * Migration loader with all migrations defined inline. @@ -143,6 +144,7 @@ export const migrationEntries = [ [57, "ProjectionThreadsPinOrderKey", Migration0057], [58, "ProjectionProjectsDefaultThreadEnvMode", Migration0058], [59, "ProjectionProjectFaviconPath", Migration0059], + [60, "ProjectionThreadSandbox", Migration0060], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/060_ProjectionThreadSandbox.test.ts b/apps/server/src/persistence/Migrations/060_ProjectionThreadSandbox.test.ts new file mode 100644 index 000000000000..ba5bf685fa5f --- /dev/null +++ b/apps/server/src/persistence/Migrations/060_ProjectionThreadSandbox.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("060_ProjectionThreadSandbox", (it) => { + it.effect("adds nullable sandbox JSON to thread projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 59 }); + yield* runMigrations({ toMigrationInclusive: 60 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_threads) + `; + const sandbox = columns.find((column) => column.name === "sandbox_json"); + + assert.equal(sandbox?.name, "sandbox_json"); + assert.equal(sandbox?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/060_ProjectionThreadSandbox.ts b/apps/server/src/persistence/Migrations/060_ProjectionThreadSandbox.ts new file mode 100644 index 000000000000..0ea65e5cfc8c --- /dev/null +++ b/apps/server/src/persistence/Migrations/060_ProjectionThreadSandbox.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "sandbox_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN sandbox_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 337263a16cbf..6088b88a8248 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -15,6 +15,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + SandboxState, ThreadId, ThreadRoutingMode, TurnId, @@ -37,6 +38,7 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + sandbox: Schema.optional(Schema.NullOr(SandboxState)), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f2538bef048a..85207c2870dc 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -76,6 +76,10 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; +import { + sandboxProviderTarget, + spawnClaudeInSandbox, +} from "../../sandbox/SandboxProviderProcess.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, @@ -4144,6 +4148,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, env: claudeEnvironment, additionalDirectories, + ...(sandboxProviderTarget(input.threadId) + ? { + spawnClaudeCodeProcess: (options) => + spawnClaudeInSandbox(sandboxProviderTarget(input.threadId)!, options), + } + : {}), ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession ? { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index dc19b8bc6a72..c4de12a8bbba 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -82,6 +82,10 @@ import { resolveCommandCenterCodexRuntimeExecutable, resolveCommandCenterManagedGitMetadata, } from "../security/CommandCenterProviderIsolation.ts"; +import { + makeSandboxChildProcessSpawner, + sandboxProviderTarget, +} from "../../sandbox/SandboxProviderProcess.ts"; import { describeCodexPermissionRequest } from "../security/CodexPermissionEscalation.ts"; import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); @@ -1973,7 +1977,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; - const commandCenterThread = isCommandCenterThreadId(input.threadId); + const sandboxTarget = sandboxProviderTarget(input.threadId); + const commandCenterThread = + isCommandCenterThreadId(input.threadId) && sandboxTarget === undefined; const sourceEnvironment = options?.environment ?? process.env; const commandCenterIsolationIssue = commandCenterProviderIsolationIssue({ threadId: input.threadId, @@ -2204,7 +2210,12 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); const runtime = yield* createRuntime(attemptInput).pipe( Effect.provideService(Scope.Scope, sessionScope), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + sandboxTarget + ? makeSandboxChildProcessSpawner(sandboxTarget, childProcessSpawner) + : childProcessSpawner, + ), Effect.provideService(Crypto.Crypto, crypto), ); const eventFiber = yield* Stream.runForEach(runtime.events, (event) => diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 92191c3eb417..4469b90049b4 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -62,6 +62,12 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import { resolveSupabaseConnection } from "../../database/SupabaseMcpConnector.ts"; import * as ServerSettings from "../../serverSettings.ts"; +import { + bindSandboxProviderTarget, + makeSandboxProviderBindingOwner, + unbindAllSandboxProviderTargets, + unbindSandboxProviderTarget, +} from "../../sandbox/SandboxProviderProcess.ts"; import { commandCenterProviderIsolationIssue } from "../security/CommandCenterProviderIsolation.ts"; const isModelSelection = Schema.is(ModelSelection); @@ -235,6 +241,7 @@ const correlateRuntimeEventWithInstance = ( const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { + const sandboxBindingOwner = makeSandboxProviderBindingOwner(); const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); const serverConfig = yield* ServerConfig.ServerConfig; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; @@ -637,7 +644,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); const startSession: ProviderServiceMethod<"startSession"> = Effect.fn("startSession")( - function* (threadId, rawInput) { + function* (threadId, rawInput, executionTarget) { const parsed = yield* decodeInputOrValidationError({ operation: "ProviderService.startSession", schema: ProviderSessionStartInput, @@ -658,6 +665,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return yield* Effect.gen(function* () { const instanceInfo = yield* registry.getInstanceInfo(resolvedInstanceId); const resolvedProvider = instanceInfo.driverKind; + if (executionTarget?.kind === "sandbox") { + if (resolvedProvider !== "codex" && resolvedProvider !== "claudeAgent") { + return yield* toValidationError( + "ProviderService.startSession", + `Sandbox provider startup is not supported for '${resolvedProvider}'.`, + ); + } + } metricProvider = resolvedProvider; if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) { return yield* toValidationError( @@ -738,6 +753,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.cwd.effective": effectiveCwd ?? "", }); const adapter = yield* registry.getByInstance(resolvedInstanceId); + if (executionTarget?.kind === "sandbox") { + bindSandboxProviderTarget(executionTarget, sandboxBindingOwner); + } yield* prepareMcpSession(threadId, resolvedInstanceId, input.projectId, effectiveCwd); const session = yield* adapter .startSession({ @@ -793,6 +811,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return sessionWithInstance; }).pipe( + Effect.onError(() => + Effect.sync(() => { + if (executionTarget?.kind === "sandbox") { + unbindSandboxProviderTarget(threadId, sandboxBindingOwner); + } + }), + ), withMetrics({ counter: providerSessionsTotal, attributes: () => @@ -1076,6 +1101,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( provider: routed.adapter.provider, }); }).pipe( + Effect.ensuring( + Effect.sync(() => unbindSandboxProviderTarget(input.threadId, sandboxBindingOwner)), + ), withMetrics({ counter: providerSessionsTotal, outcomeAttributes: () => @@ -1273,6 +1301,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* Effect.addFinalizer(() => runStopAll().pipe( + Effect.ensuring(Effect.sync(() => unbindAllSandboxProviderTargets(sandboxBindingOwner))), Effect.catchCause((cause) => Effect.logWarning("failed to stop provider service", { errorTag: causeErrorTag(cause), diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 2640e50e6b61..ae657a23b7fa 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -31,6 +31,7 @@ import type * as Stream from "effect/Stream"; import type { ProviderServiceError } from "../Errors.ts"; import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; import type { ProviderInstanceRoutingInfo } from "./ProviderAdapterRegistry.ts"; +import type { ProviderExecutionTarget } from "../../sandbox/ThreadSandboxRuntime.ts"; /** * ProviderServiceShape - Service API for provider session and turn orchestration. @@ -42,6 +43,7 @@ export interface ProviderServiceShape { readonly startSession: ( threadId: ThreadId, input: ProviderSessionStartInput, + executionTarget?: ProviderExecutionTarget, ) => Effect.Effect; /** diff --git a/apps/server/src/sandbox/AuthenticatedPreviewRouter.ts b/apps/server/src/sandbox/AuthenticatedPreviewRouter.ts new file mode 100644 index 000000000000..d2fed0db062d --- /dev/null +++ b/apps/server/src/sandbox/AuthenticatedPreviewRouter.ts @@ -0,0 +1,53 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +type Route = { + readonly threadId: string; + readonly hostname: string; + readonly internalPort: number; + readonly tokenHash: Buffer; +}; + +export class AuthenticatedPreviewRouter { + readonly #routes = new Map(); + + register(input: { + routeId: string; + threadId: string; + hostname: string; + internalPort: number; + token: string; + }) { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(input.routeId)) + throw new Error("invalid route id"); + if ( + !Number.isInteger(input.internalPort) || + input.internalPort < 1 || + input.internalPort > 65535 + ) + throw new Error("invalid preview port"); + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(input.hostname)) + throw new Error("invalid preview hostname"); + this.#routes.set(input.routeId, { + threadId: input.threadId, + hostname: input.hostname, + internalPort: input.internalPort, + tokenHash: hash(input.token), + }); + } + + resolve(input: { routeId: string; threadId: string; token: string }) { + const route = this.#routes.get(input.routeId); + if (route === undefined) return null; + const tokenHash = hash(input.token); + if (route.threadId !== input.threadId || !timingSafeEqual(route.tokenHash, tokenHash)) + return null; + return { hostname: route.hostname, port: route.internalPort }; + } + + removeThread(threadId: string) { + for (const [id, route] of this.#routes) + if (route.threadId === threadId) this.#routes.delete(id); + } +} + +const hash = (value: string) => createHash("sha256").update(value).digest(); diff --git a/apps/server/src/sandbox/ContainerSandboxBackend.test.ts b/apps/server/src/sandbox/ContainerSandboxBackend.test.ts new file mode 100644 index 000000000000..aa27be01f315 --- /dev/null +++ b/apps/server/src/sandbox/ContainerSandboxBackend.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ContainerSandboxBackend } from "./ContainerSandboxBackend.ts"; +import type { + SandboxCommand, + SandboxCommandExecutor, + SandboxCommandResult, + SandboxProvisionInput, +} from "./types.ts"; + +class FakeExecutor implements SandboxCommandExecutor { + readonly commands: SandboxCommand[] = []; + readonly respond: ((command: SandboxCommand) => SandboxCommandResult) | undefined; + constructor(respond?: (command: SandboxCommand) => SandboxCommandResult) { + this.respond = respond; + } + async run(command: SandboxCommand): Promise { + this.commands.push(command); + return this.respond?.(command) ?? { exitCode: 0, stdout: "", stderr: "" }; + } +} + +const input = (overrides: Partial = {}): SandboxProvisionInput => ({ + bootstrap: { + threadId: "thread-1", + projectId: "project-1", + repositoryUrl: "https://example.test/repository.git", + baseCommit: "a".repeat(40), + branchName: "thread/thread-1", + }, + image: "sandbox@sha256:" + "b".repeat(64), + ...overrides, +}); + +function successfulExecutor() { + return new FakeExecutor((command) => { + if (command.args[0] === "info") return { exitCode: 0, stdout: '["name=rootless"]', stderr: "" }; + if (command.args[0] === "inspect" && command.args.length === 2) + return { exitCode: 1, stdout: "", stderr: "missing" }; + if (command.args[0] === "volume" && command.args[1] === "inspect") { + const name = command.args.at(-1) ?? ""; + if (name.startsWith("t3-cache-")) return { exitCode: 1, stdout: "", stderr: "missing" }; + const bytes = name.startsWith("t3-desktop-") + ? Math.max(256 * 1024 ** 2, Math.floor(20 * 1024 ** 3 * 0.1)) + : Math.floor(20 * 1024 ** 3 * 0.9); + return { exitCode: 0, stdout: `size=${bytes}\n`, stderr: "" }; + } + if (command.args[0] === "exec" && command.args.includes("rev-parse")) + return { exitCode: 0, stdout: `${"c".repeat(40)}\n`, stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }); +} + +describe("ContainerSandboxBackend", () => { + it("constructs a hardened, per-thread container without host bind mounts", async () => { + const executor = successfulExecutor(); + const backend = new ContainerSandboxBackend("docker", executor); + const ready = await backend.ensureReady( + input({ caches: [{ digest: "d".repeat(64), target: "/cache/deps" }] }), + ); + expect(ready.containerName).toBe("t3-thread-921ca543f9cf4d28fe0b81d81cdb33b5"); + const run = executor.commands.find( + (command) => command.args[0] === "run" && command.args.includes(input().image), + )!; + expect(run.args).toEqual( + expect.arrayContaining([ + "--read-only", + "--init", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--pids-limit", + "512", + "--storage-opt", + `size=${20 * 1024 ** 3}`, + ]), + ); + expect(run.args.join(" ")).not.toContain("type=bind"); + expect(run.args.join(" ")).not.toContain("/var/run/docker.sock"); + expect(executor.commands.find((command) => command.args[0] === "network")?.args).toContain( + "--internal", + ); + }); + + it("coalesces concurrent provisioning and is idempotent once ready", async () => { + const executor = successfulExecutor(); + const backend = new ContainerSandboxBackend("docker", executor); + const [first, second] = await Promise.all([ + backend.ensureReady(input()), + backend.ensureReady(input()), + ]); + expect(first).toBe(second); + await backend.ensureReady(input()); + expect(executor.commands.filter((command) => command.args[0] === "run")).toHaveLength(1); + }); + + it("derives collision-resistant names from exact thread ids", async () => { + const first = await new ContainerSandboxBackend("docker", successfulExecutor()).ensureReady( + input({ bootstrap: { ...input().bootstrap, threadId: "Thread_A" } }), + ); + const second = await new ContainerSandboxBackend("docker", successfulExecutor()).ensureReady( + input({ bootstrap: { ...input().bootstrap, threadId: "thread-a" } }), + ); + const third = await new ContainerSandboxBackend("docker", successfulExecutor()).ensureReady( + input({ bootstrap: { ...input().bootstrap, threadId: "Thread-A" } }), + ); + expect(new Set([first.containerName, second.containerName, third.containerName]).size).toBe(3); + }); + + it("keeps workloads internal and routes egress through a hardened sidecar", async () => { + const executor = successfulExecutor(); + await new ContainerSandboxBackend("docker", executor).ensureReady( + input({ egressProxyImage: `egress@sha256:${"e".repeat(64)}` }), + ); + const network = executor.commands.find((command) => command.args[0] === "network")!; + const run = executor.commands.find( + (command) => command.args[0] === "run" && command.args.includes(input().image), + )!; + // The workload network remains internal even with a proxy configured so + // direct public egress cannot bypass the authenticated proxy hop. + expect(network.args).toContain("--internal"); + expect(run.args).toEqual( + expect.arrayContaining([ + "--env", + `ALL_PROXY=${["http:/", "/egress-proxy:3128"].join("")}`, + "--env", + "NO_PROXY=localhost,127.0.0.1,::1", + ]), + ); + const proxy = executor.commands.find((command) => command.args.includes("t3-egress-proxy"))!; + expect(proxy.args).toEqual( + expect.arrayContaining(["--deny-private", "--deny-metadata", "--resolve-before-connect"]), + ); + }); + + it("cleans container, network, and workspace volume after setup failure and stop", async () => { + const failing = successfulExecutor(); + const original = failing.respond!; + const executor = new FakeExecutor((command) => + command.args[0] === "exec" && command.args.includes("clone") + ? { exitCode: 1, stdout: "", stderr: "clone failed" } + : original(command), + ); + await expect( + new ContainerSandboxBackend("docker", executor).ensureReady(input()), + ).rejects.toThrow("git failed"); + expect( + executor.commands + .filter((command) => command.args[0] === "rm" || command.args[1] === "rm") + .map((command) => command.args.at(-1)), + ).toEqual([ + "t3-thread-921ca543f9cf4d28fe0b81d81cdb33b5", + "t3-net-921ca543f9cf4d28fe0b81d81cdb33b5", + "t3-workspace-921ca543f9cf4d28fe0b81d81cdb33b5", + "t3-desktop-921ca543f9cf4d28fe0b81d81cdb33b5", + ]); + + const stoppingExecutor = successfulExecutor(); + const backend = new ContainerSandboxBackend("docker", stoppingExecutor); + await backend.ensureReady(input()); + await backend.stop("thread-1"); + expect( + stoppingExecutor.commands + .filter((command) => command.args[0] === "rm" || command.args[1] === "rm") + .map((command) => command.args.at(-1)), + ).toEqual([ + "t3-thread-921ca543f9cf4d28fe0b81d81cdb33b5", + "t3-net-921ca543f9cf4d28fe0b81d81cdb33b5", + "t3-workspace-921ca543f9cf4d28fe0b81d81cdb33b5", + "t3-desktop-921ca543f9cf4d28fe0b81d81cdb33b5", + ]); + }); + + it("attempts every cleanup after a teardown hook fails", async () => { + const base = successfulExecutor(); + const respond = base.respond!; + const executor = new FakeExecutor((command) => + command.args[0] === "exec" && command.args.includes("failing-teardown") + ? { exitCode: 1, stdout: "", stderr: "hook failed" } + : respond(command), + ); + const backend = new ContainerSandboxBackend("docker", executor); + await backend.ensureReady(input()); + await expect(backend.stop("thread-1", [{ executable: "failing-teardown" }])).rejects.toThrow( + "teardown failing-teardown", + ); + expect( + executor.commands + .filter((command) => command.args[0] === "rm" || command.args[1] === "rm") + .map((command) => command.args.at(-1)), + ).toEqual([ + "t3-thread-921ca543f9cf4d28fe0b81d81cdb33b5", + "t3-net-921ca543f9cf4d28fe0b81d81cdb33b5", + "t3-workspace-921ca543f9cf4d28fe0b81d81cdb33b5", + "t3-desktop-921ca543f9cf4d28fe0b81d81cdb33b5", + ]); + }); + + it("rejects malformed commits, paths, mounts, and environment keys before container launch", async () => { + const executor = successfulExecutor(); + const backend = new ContainerSandboxBackend("docker", executor); + await expect( + backend.ensureReady(input({ bootstrap: { ...input().bootstrap, baseCommit: "main" } })), + ).rejects.toThrow("immutable full commit"); + await expect( + backend.ensureReady(input({ bootstrap: { ...input().bootstrap, threadId: "../other" } })), + ).rejects.toThrow("unsafe"); + await expect( + backend.ensureReady(input({ caches: [{ digest: "d".repeat(64), target: "/etc/ssh" }] })), + ).rejects.toThrow("protected path"); + await expect(backend.ensureReady(input({ image: "sandbox:latest" }))).rejects.toThrow( + "pinned by sha256", + ); + expect(executor.commands.some((command) => command.args[0] === "run")).toBe(false); + }); + + it("requires a rootless daemon", async () => { + const executor = new FakeExecutor(() => ({ exitCode: 0, stdout: "false", stderr: "" })); + await expect( + new ContainerSandboxBackend("podman", executor).ensureReady(input()), + ).rejects.toThrow("rootless mode"); + }); + + it("removes only confirmed labeled orphan containers", async () => { + const executor = new FakeExecutor((command) => { + if (command.args[0] === "ps") + if (command.args.includes("label=com.t3tools.sandbox.thread=orphan-1")) + return { exitCode: 0, stdout: "abcdef654321\n", stderr: "" }; + if (command.args[0] === "ps") + return { + exitCode: 0, + stdout: "abcdef123456\tthread-1\tproject-1\nabcdef654321\torphan-1\tproject-1\n", + stderr: "", + }; + if (command.args[0] === "inspect") + return { exitCode: 0, stdout: "orphan-1\ttrue\n", stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }); + const backend = new ContainerSandboxBackend("docker", executor); + const result = await backend.reconcile({ + expectedThreadIds: new Set(["thread-1", "missing-1"]), + removeOrphans: true, + }); + expect(result).toEqual({ + activeThreadIds: [], + missingThreadIds: ["thread-1", "missing-1"], + orphanThreadIds: ["orphan-1"], + removedRuntimeRefs: ["abcdef654321"], + }); + expect( + executor.commands + .filter((command) => command.args[0] === "rm") + .map((command) => command.args.at(-1)), + ).toEqual(["abcdef654321"]); + await expect(backend.exec("thread-1", { executable: "true" })).rejects.toThrow("not ready"); + }); + + it("exports commit and patch through argv-only exec", async () => { + const executor = successfulExecutor(); + const backend = new ContainerSandboxBackend("docker", executor); + await backend.ensureReady(input()); + const exported = await backend.exportBranch("thread-1"); + expect(exported.commit).toBe("c".repeat(40)); + expect( + executor.commands + .filter((command) => command.args[0] === "exec") + .every((command) => !command.args.includes("sh")), + ).toBe(true); + }); + + it("exports and verifies a self-contained Git bundle before cleanup", async () => { + const executor = successfulExecutor(); + const backend = new ContainerSandboxBackend("docker", executor); + await backend.ensureReady(input()); + await backend.exportBundle("thread-1", "/tmp/thread-1.bundle"); + expect(executor.commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + executable: "docker", + args: expect.arrayContaining([ + "bundle", + "create", + "/tmp/t3-thread-export.bundle", + "--all", + ]), + }), + expect.objectContaining({ + executable: "docker", + args: [ + "cp", + expect.stringContaining(":/tmp/t3-thread-export.bundle"), + "/tmp/thread-1.bundle", + ], + }), + expect.objectContaining({ + executable: "git", + args: ["bundle", "verify", "/tmp/thread-1.bundle"], + }), + ]), + ); + }); + + it("samples bounded runtime and writable-volume usage", async () => { + const base = successfulExecutor(); + const executor = new FakeExecutor((command) => { + if (command.args[0] === "stats") + return { + exitCode: 0, + stdout: JSON.stringify({ CPUPerc: "12.5%", MemUsage: "256MiB / 4GiB", PIDs: "7" }), + stderr: "", + }; + if (command.args[0] === "exec" && command.args.includes("du")) + return { exitCode: 0, stdout: "1024\t/workspace\n2048\t/thread-data\n", stderr: "" }; + return base.respond!(command); + }); + const backend = new ContainerSandboxBackend("docker", executor); + await backend.ensureReady(input()); + expect(await backend.sampleUsage("thread-1")).toEqual({ + cpuPercent: 12.5, + memoryBytes: 256 * 1024 ** 2, + diskBytes: 3072, + processCount: 7, + }); + }); +}); diff --git a/apps/server/src/sandbox/ContainerSandboxBackend.ts b/apps/server/src/sandbox/ContainerSandboxBackend.ts new file mode 100644 index 000000000000..0c2e49453bce --- /dev/null +++ b/apps/server/src/sandbox/ContainerSandboxBackend.ts @@ -0,0 +1,948 @@ +import { + DEFAULT_SANDBOX_RESOURCE_LIMITS, + type SandboxConfig, + type SandboxRuntime, +} from "@t3tools/contracts"; +import type { + SandboxCommandExecutor, + SandboxExecInput, + SandboxExport, + SandboxHook, + SandboxProvisionInput, + SandboxReady, + SandboxUsageSample, + SandboxReconcileInput, + SandboxReconcileResult, + ThreadSandboxBackend, +} from "./types.ts"; +import { + sanitizeId, + validateBootstrap, + validateCache, + validateExec, + validateHook, +} from "./validation.ts"; +import { createHash } from "node:crypto"; + +const MANAGED_LABEL = "com.t3tools.sandbox.managed=true"; +const THREAD_LABEL = "com.t3tools.sandbox.thread"; +const PROJECT_LABEL = "com.t3tools.sandbox.project"; +const IMAGE_LABEL = "com.t3tools.sandbox.image"; +const BASE_LABEL = "com.t3tools.sandbox.base"; +const BRANCH_LABEL = "com.t3tools.sandbox.branch"; +const ROLE_LABEL = "com.t3tools.sandbox.role"; +const CACHE_DIGEST_LABEL = "com.t3tools.sandbox.cache-digest"; +const DEFAULT_COMMAND_TIMEOUT_MS = 60_000; +const INTERNAL_EGRESS_PROXY_URL = ["http:/", "/egress-proxy:3128"].join(""); +const MAX_HOOK_TIMEOUT_MS = 10 * 60_000; + +export class SandboxRuntimeError extends Error { + override readonly name = "SandboxRuntimeError"; + readonly stderr: string; + constructor(message: string, stderr = "") { + super(message); + this.stderr = stderr; + } +} + +type RecordEntry = { ready: SandboxReady; teardownTimeoutMs: number }; + +export class ContainerSandboxBackend implements ThreadSandboxBackend { + readonly runtime: SandboxRuntime; + readonly #binary: "docker" | "podman"; + readonly #executor: SandboxCommandExecutor; + readonly #records = new Map(); + readonly #provisioning = new Map>(); + #validatedRootless = false; + + constructor(runtime: "docker" | "podman", executor: SandboxCommandExecutor) { + this.runtime = runtime; + this.#binary = runtime; + this.#executor = executor; + } + + runtimeRef(threadIdValue: string): string | undefined { + return this.#records.get(sanitizeId(threadIdValue, "threadId"))?.ready.containerName; + } + + async ensureReady(input: SandboxProvisionInput): Promise { + const threadId = sanitizeId(input.bootstrap.threadId, "threadId"); + const ready = this.#records.get(threadId)?.ready; + if (ready !== undefined) return Promise.resolve(ready); + const pending = this.#provisioning.get(threadId); + if (pending !== undefined) return pending; + const provision = this.#provision(input).finally(() => this.#provisioning.delete(threadId)); + this.#provisioning.set(threadId, provision); + return provision; + } + + async #provision(input: SandboxProvisionInput): Promise { + validateBootstrap(input.bootstrap); + if (!/^[a-z0-9][a-z0-9._/-]{0,200}@sha256:[a-f0-9]{64}$/i.test(input.image)) { + throw new SandboxRuntimeError("sandbox image must be pinned by sha256 digest"); + } + for (const cache of input.caches ?? []) validateCache(cache); + for (const hook of input.setup ?? []) validateHook(hook); + await this.#validateRootless(); + + const threadId = sanitizeId(input.bootstrap.threadId, "threadId"); + const projectId = sanitizeId(input.bootstrap.projectId, "projectId"); + const suffix = createHash("sha256") + .update(`${projectId}\0${threadId}`) + .digest("hex") + .slice(0, 32); + const containerName = `t3-thread-${suffix}`; + const networkName = `t3-net-${suffix}`; + const workspaceVolumeName = `t3-workspace-${suffix}`; + const desktopVolumeName = `t3-desktop-${suffix}`; + const egressProxyContainerName = `t3-egress-${suffix}`; + const egressNetworkName = `t3-egress-net-${suffix}`; + const config = input.config ?? {}; + const limits = { ...DEFAULT_SANDBOX_RESOURCE_LIMITS, ...config.limits }; + validateLimits(limits); + const setupTimeoutMs = boundedTimeout(config.setupTimeoutSeconds, 300); + const teardownTimeoutMs = boundedTimeout(config.teardownTimeoutSeconds, 120); + + const inspected = await this.#run(["inspect", containerName], 10_000, true); + if (inspected.exitCode === 0) { + const labels = await this.#run( + [ + "inspect", + "--format", + `{{index .Config.Labels "${THREAD_LABEL}"}}\t{{index .Config.Labels "${PROJECT_LABEL}"}}\t{{index .Config.Labels "com.t3tools.sandbox.managed"}}\t{{index .Config.Labels "${IMAGE_LABEL}"}}\t{{index .Config.Labels "${BASE_LABEL}"}}\t{{index .Config.Labels "${BRANCH_LABEL}"}}\t{{index .Config.Labels "${ROLE_LABEL}"}}\t{{.State.Running}}`, + containerName, + ], + 10_000, + ); + if ( + labels.stdout.trim() !== + `${threadId}\t${projectId}\ttrue\t${input.image}\t${input.bootstrap.baseCommit}\t${input.bootstrap.branchName}\tworkspace\ttrue` + ) { + throw new SandboxRuntimeError(`existing sandbox name collision for thread ${threadId}`); + } + const existing = makeReady( + this.runtime, + containerName, + networkName, + workspaceVolumeName, + desktopVolumeName, + input, + limits, + ); + this.#records.set(threadId, { ready: existing, teardownTimeoutMs }); + return existing; + } + + try { + await this.#mustRun( + [ + "network", + "create", + "--internal", + "--label", + MANAGED_LABEL, + "--label", + `${THREAD_LABEL}=${threadId}`, + networkName, + ], + 30_000, + ); + if (input.egressProxyImage !== undefined) { + if (!/^[a-z0-9][a-z0-9._/-]{0,200}@sha256:[a-f0-9]{64}$/i.test(input.egressProxyImage)) + throw new SandboxRuntimeError("egress proxy image must be pinned by sha256 digest"); + await this.#mustRun( + [ + "network", + "create", + "--label", + MANAGED_LABEL, + "--label", + `${THREAD_LABEL}=${threadId}`, + egressNetworkName, + ], + 30_000, + ); + await this.#mustRun( + [ + "run", + "--detach", + "--name", + egressProxyContainerName, + "--label", + MANAGED_LABEL, + "--label", + `${THREAD_LABEL}=${threadId}`, + "--network", + egressNetworkName, + "--read-only", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--pids-limit", + "128", + input.egressProxyImage, + "t3-egress-proxy", + "serve", + "--listen", + "0.0.0.0:3128", + "--deny-loopback", + "--deny-private", + "--deny-link-local", + "--deny-metadata", + "--resolve-before-connect", + ], + 60_000, + ); + await this.#mustRun( + ["network", "connect", "--alias", "egress-proxy", networkName, egressProxyContainerName], + 30_000, + ); + } + const desktopQuota = `o=size=${Math.max(256 * 1024 ** 2, Math.floor(limits.diskBytes * 0.1))}`; + await this.#mustRun( + [ + "volume", + "create", + "--label", + MANAGED_LABEL, + "--label", + `${THREAD_LABEL}=${threadId}`, + "--opt", + desktopQuota, + desktopVolumeName, + ], + 30_000, + ); + const desktopQuotaReadback = await this.#mustRun( + ["volume", "inspect", "--format", `{{index .Options "o"}}`, desktopVolumeName], + 10_000, + ); + if (desktopQuotaReadback.stdout.trim() !== desktopQuota.slice(2)) + throw new SandboxRuntimeError("runtime did not preserve the desktop volume quota"); + const workspaceQuota = `o=size=${Math.floor(limits.diskBytes * 0.9)}`; + await this.#mustRun( + [ + "volume", + "create", + "--label", + MANAGED_LABEL, + "--label", + `${THREAD_LABEL}=${threadId}`, + "--opt", + workspaceQuota, + workspaceVolumeName, + ], + 30_000, + ); + const workspaceQuotaReadback = await this.#mustRun( + ["volume", "inspect", "--format", `{{index .Options "o"}}`, workspaceVolumeName], + 10_000, + ); + if (workspaceQuotaReadback.stdout.trim() !== workspaceQuota.slice(2)) + throw new SandboxRuntimeError("runtime did not preserve the workspace volume quota"); + for (const cache of input.caches ?? []) { + const name = `t3-cache-${cache.digest.toLowerCase()}`; + const existing = await this.#run( + ["volume", "inspect", "--format", `{{index .Labels "${CACHE_DIGEST_LABEL}"}}`, name], + 10_000, + true, + ); + if (existing.exitCode === 0) { + if (existing.stdout.trim() !== cache.digest.toLowerCase()) + throw new SandboxRuntimeError(`cache volume label mismatch for ${cache.digest}`); + } else { + await this.#mustRun( + [ + "volume", + "create", + "--label", + `${CACHE_DIGEST_LABEL}=${cache.digest.toLowerCase()}`, + name, + ], + 30_000, + ); + } + } + + const runArgs = [ + "run", + "--detach", + "--name", + containerName, + "--label", + MANAGED_LABEL, + "--label", + `${THREAD_LABEL}=${threadId}`, + "--label", + `${PROJECT_LABEL}=${projectId}`, + "--label", + `${IMAGE_LABEL}=${input.image}`, + "--label", + `${BASE_LABEL}=${input.bootstrap.baseCommit}`, + "--label", + `${BRANCH_LABEL}=${input.bootstrap.branchName}`, + "--label", + `${ROLE_LABEL}=workspace`, + "--network", + networkName, + "--mount", + `type=volume,src=${workspaceVolumeName},dst=/workspace`, + "--mount", + `type=volume,src=${desktopVolumeName},dst=/thread-data`, + "--cpus", + String(limits.cpuCount), + "--memory", + String(limits.memoryBytes), + "--pids-limit", + String(limits.processCount), + "--storage-opt", + `size=${limits.diskBytes}`, + "--read-only", + "--init", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec,size=1g", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--user", + "1000:1000", + "--workdir", + "/workspace", + ...(input.egressProxyImage === undefined && input.egressProxyUrl === undefined + ? [] + : [ + "--env", + `ALL_PROXY=${input.egressProxyImage === undefined ? validateProxy(input.egressProxyUrl!) : INTERNAL_EGRESS_PROXY_URL}`, + "--env", + `HTTPS_PROXY=${input.egressProxyImage === undefined ? validateProxy(input.egressProxyUrl!) : INTERNAL_EGRESS_PROXY_URL}`, + "--env", + `HTTP_PROXY=${input.egressProxyImage === undefined ? validateProxy(input.egressProxyUrl!) : INTERNAL_EGRESS_PROXY_URL}`, + "--env", + "NO_PROXY=localhost,127.0.0.1,::1", + ]), + ...(input.caches ?? []).flatMap((cache) => [ + "--mount", + `type=volume,src=t3-cache-${cache.digest.toLowerCase()},dst=${cache.target},readonly`, + ]), + input.image, + "sleep", + "infinity", + ]; + await this.#mustRun(runArgs, setupTimeoutMs); + if (input.bootstrap.repositoryBundlePath !== undefined) { + const containerBundle = "/tmp/t3-repository.bundle"; + await this.#mustRun( + ["cp", input.bootstrap.repositoryBundlePath, `${containerName}:${containerBundle}`], + setupTimeoutMs, + ); + await this.#mustExec( + containerName, + { executable: "git", args: ["bundle", "verify", containerBundle] }, + setupTimeoutMs, + ); + await this.#mustExec( + containerName, + { + executable: "git", + args: ["clone", "--no-checkout", containerBundle, "/workspace/repo"], + }, + setupTimeoutMs, + ); + await this.#mustExec( + containerName, + { executable: "rm", args: ["-f", containerBundle] }, + 10_000, + ); + } else { + await this.#mustExec( + containerName, + { + executable: "git", + args: ["clone", "--no-checkout", input.bootstrap.repositoryUrl, "/workspace/repo"], + }, + setupTimeoutMs, + ); + } + await this.#mustExec( + containerName, + { + executable: "git", + args: ["-C", "/workspace/repo", "checkout", "--detach", input.bootstrap.baseCommit], + }, + setupTimeoutMs, + ); + await this.#mustExec( + containerName, + { + executable: "git", + args: ["-C", "/workspace/repo", "switch", "-c", input.bootstrap.branchName], + }, + setupTimeoutMs, + ); + if (input.bootstrap.inheritedPatch !== undefined) { + await this.#mustExec( + containerName, + { + executable: "git", + args: ["-C", "/workspace/repo", "apply", "--index", "--whitespace=error", "-"], + stdin: input.bootstrap.inheritedPatch, + }, + setupTimeoutMs, + ); + } + for (const hook of input.setup ?? []) + await this.#mustExec(containerName, hook, setupTimeoutMs); + } catch (error) { + await this.#cleanup( + containerName, + networkName, + workspaceVolumeName, + desktopVolumeName, + teardownTimeoutMs, + input.egressProxyImage === undefined + ? undefined + : { container: egressProxyContainerName, network: egressNetworkName }, + ); + throw error; + } + const ready = makeReady( + this.runtime, + containerName, + networkName, + workspaceVolumeName, + desktopVolumeName, + input, + limits, + ); + this.#records.set(threadId, { ready, teardownTimeoutMs }); + return ready; + } + + async exec(threadIdValue: string, input: SandboxExecInput) { + validateExec(input); + const threadId = sanitizeId(threadIdValue, "threadId"); + const record = this.#records.get(threadId); + if (record === undefined) + throw new SandboxRuntimeError(`sandbox for thread ${threadId} is not ready`); + return this.#mustExec( + record.ready.containerName, + input, + Math.min(input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS, MAX_HOOK_TIMEOUT_MS), + ); + } + + async exportBranch(threadIdValue: string): Promise { + const threadId = sanitizeId(threadIdValue, "threadId"); + const record = this.#records.get(threadId); + if (record === undefined) + throw new SandboxRuntimeError(`sandbox for thread ${threadId} is not ready`); + const commit = await this.#mustExec( + record.ready.containerName, + { executable: "git", args: ["-C", "/workspace/repo", "rev-parse", "HEAD"] }, + 30_000, + ); + const exportIndex = "/tmp/t3-export-index"; + try { + const env = { GIT_INDEX_FILE: exportIndex }; + await this.#mustExec( + record.ready.containerName, + { executable: "git", args: ["-C", "/workspace/repo", "read-tree", "HEAD"], env }, + 30_000, + ); + await this.#mustExec( + record.ready.containerName, + { executable: "git", args: ["-C", "/workspace/repo", "add", "-A"], env }, + 60_000, + ); + const fullPatch = await this.#mustExec( + record.ready.containerName, + { + executable: "git", + args: ["-C", "/workspace/repo", "diff", "--cached", "--binary", "HEAD"], + env, + }, + 60_000, + ); + return { commit: commit.stdout.trim(), patch: fullPatch.stdout }; + } finally { + await this.#mustExec( + record.ready.containerName, + { executable: "rm", args: ["-f", exportIndex] }, + 10_000, + ).catch(() => undefined); + } + } + + /** Copy a self-contained Git bundle through the container runtime boundary + * and verify it before the sandbox can be deleted. */ + async exportBundle(threadIdValue: string, destination: string): Promise { + const threadId = sanitizeId(threadIdValue, "threadId"); + const record = this.#records.get(threadId); + if (record === undefined) + throw new SandboxRuntimeError(`sandbox for thread ${threadId} is not ready`); + if (!destination.startsWith("/") || destination.includes("\0")) + throw new SandboxRuntimeError("bundle destination must be an absolute host path"); + const containerBundle = "/tmp/t3-thread-export.bundle"; + try { + await this.#mustExec( + record.ready.containerName, + { + executable: "git", + args: ["-C", "/workspace/repo", "bundle", "create", containerBundle, "--all"], + }, + 120_000, + ); + await this.#mustRun( + ["cp", `${record.ready.containerName}:${containerBundle}`, destination], + 120_000, + ); + const verified = await this.#executor.run({ + executable: "git", + args: ["bundle", "verify", destination], + timeoutMs: 60_000, + }); + if (verified.exitCode !== 0) + throw new SandboxRuntimeError("exported Git bundle failed verification", verified.stderr); + } finally { + await this.#mustExec( + record.ready.containerName, + { + executable: "rm", + args: ["-f", containerBundle], + }, + 10_000, + ).catch(() => undefined); + } + } + + async sampleUsage(threadIdValue: string): Promise { + const threadId = sanitizeId(threadIdValue, "threadId"); + const record = this.#records.get(threadId); + if (record === undefined) + throw new SandboxRuntimeError(`sandbox for thread ${threadId} is not ready`); + const stats = await this.#mustRun( + ["stats", "--no-stream", "--format", "{{json .}}", record.ready.containerName], + 15_000, + ); + let value: Record; + try { + value = JSON.parse(stats.stdout) as Record; + } catch { + throw new SandboxRuntimeError("sandbox runtime returned malformed usage stats"); + } + const cpu = parsePercent(value.CPUPerc ?? value.CPU); + const memory = parseByteQuantity( + String(value.MemUsage ?? value.MemUsageBytes ?? "") + .split("/")[0] + ?.trim() ?? "", + ); + const pids = Number(value.PIDs ?? value.Pids ?? 0); + const disk = await this.#mustExec( + record.ready.containerName, + { + executable: "du", + args: ["-sb", "/workspace", "/thread-data"], + }, + 15_000, + ); + const diskBytes = disk.stdout + .split("\n") + .reduce((total, line) => total + (Number(line.split(/\s+/, 1)[0]) || 0), 0); + if (![cpu, memory, pids, diskBytes].every(Number.isFinite) || pids < 0 || diskBytes < 0) + throw new SandboxRuntimeError("sandbox runtime returned invalid usage stats"); + return { + cpuPercent: Math.max(0, Math.min(100, cpu)), + memoryBytes: Math.max(0, Math.floor(memory)), + diskBytes: Math.floor(diskBytes), + processCount: Math.floor(pids), + }; + } + + async stop(threadIdValue: string, teardown: ReadonlyArray = []): Promise { + const threadId = sanitizeId(threadIdValue, "threadId"); + const record = this.#records.get(threadId); + if (record === undefined) return; + const failures: string[] = []; + for (const hook of teardown) { + validateHook(hook); + try { + await this.#mustExec(record.ready.containerName, hook, record.teardownTimeoutMs); + } catch (error) { + failures.push(`teardown ${hook.executable}: ${String(error)}`); + } + } + failures.push( + ...(await this.#removeManagedSiblingContainers(threadId, record.ready.containerName)), + ); + failures.push( + ...(await this.#cleanup( + record.ready.containerName, + record.ready.networkName, + record.ready.workspaceVolumeName, + record.ready.desktopVolumeName, + record.teardownTimeoutMs, + record.ready.egressProxyContainerName && record.ready.egressNetworkName + ? { + container: record.ready.egressProxyContainerName, + network: record.ready.egressNetworkName, + } + : undefined, + )), + ); + this.#records.delete(threadId); + if (failures.length > 0) + throw new SandboxRuntimeError(`sandbox cleanup failed: ${failures.join("; ")}`); + } + + async reconcile(input: SandboxReconcileInput): Promise { + const list = await this.#mustRun( + [ + "ps", + "--all", + "--filter", + `label=${MANAGED_LABEL}`, + "--filter", + `label=${ROLE_LABEL}=workspace`, + "--filter", + "status=running", + "--format", + `{{.ID}}\t{{.Label \"${THREAD_LABEL}\"}}\t{{.Label \"${PROJECT_LABEL}\"}}`, + ], + 30_000, + ); + const active = new Map(); + for (const line of list.stdout.split("\n")) { + if (!line.trim()) continue; + const [runtimeRef, rawThreadId, rawProjectId, extra] = line.split("\t"); + if ( + !runtimeRef || + !rawThreadId || + !rawProjectId || + extra !== undefined || + !/^[a-f0-9]{12,64}$/i.test(runtimeRef) + ) + continue; + try { + active.set(sanitizeId(rawThreadId, "thread label"), { + runtimeRef, + projectId: sanitizeId(rawProjectId, "project label"), + }); + } catch { + /* Ignore malformed untrusted labels. */ + } + } + const expected = new Set( + [...input.expectedThreadIds].map((id) => sanitizeId(id, "expected threadId")), + ); + const orphanThreadIds = [...active.keys()].filter((id) => !expected.has(id)); + const removedRuntimeRefs: string[] = []; + if (input.removeOrphans) { + for (const threadId of orphanThreadIds) { + const runtimeRef = active.get(threadId)!.runtimeRef; + const inspect = await this.#mustRun( + [ + "inspect", + "--format", + `{{index .Config.Labels \"${THREAD_LABEL}\"}}\t{{index .Config.Labels \"com.t3tools.sandbox.managed\"}}`, + runtimeRef, + ], + 10_000, + ); + if (inspect.stdout.trim() !== `${threadId}\ttrue`) continue; + const threadContainers = await this.#mustRun( + [ + "ps", + "--all", + "--filter", + `label=${MANAGED_LABEL}`, + "--filter", + `label=${THREAD_LABEL}=${threadId}`, + "--format", + "{{.ID}}", + ], + 30_000, + ); + for (const candidate of threadContainers.stdout + .split("\n") + .map((item) => item.trim()) + .filter((item) => /^[a-f0-9]{12,64}$/i.test(item))) { + const candidateInspect = await this.#mustRun( + [ + "inspect", + "--format", + `{{index .Config.Labels "${THREAD_LABEL}"}}\t{{index .Config.Labels "com.t3tools.sandbox.managed"}}`, + candidate, + ], + 10_000, + ); + if (candidateInspect.stdout.trim() !== `${threadId}\ttrue`) continue; + await this.#mustRun(["rm", "--force", candidate], 30_000); + removedRuntimeRefs.push(candidate); + } + } + for (const kind of ["network", "volume"] as const) { + const resources = await this.#mustRun( + [ + kind, + "ls", + "--filter", + `label=${MANAGED_LABEL}`, + "--format", + `{{.Name}}\t{{.Label \"${THREAD_LABEL}\"}}`, + ], + 30_000, + ); + for (const line of resources.stdout.split("\n")) { + const [name, rawThreadId, extra] = line.split("\t"); + if (!name || !rawThreadId || extra !== undefined) continue; + let resourceThreadId: string; + try { + resourceThreadId = sanitizeId(rawThreadId, "thread label"); + } catch { + continue; + } + if (expected.has(resourceThreadId)) continue; + const inspect = await this.#mustRun( + [ + kind, + "inspect", + "--format", + `{{index .Labels \"${THREAD_LABEL}\"}}\t{{index .Labels \"com.t3tools.sandbox.managed\"}}`, + name, + ], + 10_000, + ); + if (inspect.stdout.trim() !== `${resourceThreadId}\ttrue`) continue; + await this.#mustRun([kind, "rm", name], 30_000); + removedRuntimeRefs.push(name); + } + } + } + // A running workspace label alone cannot prove the project declarations, + // teardown hooks, services, credentials, caches, or egress generation that + // produced it. Only records retained by this manager generation are safe to + // adopt; restart-discovered containers remain fail-closed for reconciliation. + const adopted = [...active.keys()].filter((id) => expected.has(id) && this.#records.has(id)); + return { + activeThreadIds: adopted, + missingThreadIds: [...expected].filter((id) => !adopted.includes(id)), + orphanThreadIds, + removedRuntimeRefs, + }; + } + + async #validateRootless(): Promise { + if (this.#validatedRootless) return; + const args = + this.#binary === "docker" + ? ["info", "--format", "{{json .SecurityOptions}}"] + : ["info", "--format", "{{.Host.Security.Rootless}}"]; + const result = await this.#mustRun(args, 15_000); + const rootless = + this.#binary === "docker" + ? result.stdout.toLowerCase().includes("rootless") + : result.stdout.trim() === "true"; + if (!rootless) throw new SandboxRuntimeError(`${this.#binary} must run in rootless mode`); + this.#validatedRootless = true; + } + + async #removeManagedSiblingContainers( + threadId: string, + workspaceContainer: string, + ): Promise { + const failures: string[] = []; + const listed = await this.#run( + [ + "ps", + "--all", + "--filter", + `label=${MANAGED_LABEL}`, + "--filter", + `label=${THREAD_LABEL}=${threadId}`, + "--format", + "{{.ID}}", + ], + 30_000, + true, + ); + if (listed.exitCode !== 0) return [`list sibling containers: ${listed.stderr}`]; + for (const candidate of listed.stdout + .split("\n") + .map((item) => item.trim()) + .filter(Boolean)) { + if (candidate === workspaceContainer || !/^[A-Za-z0-9_.-]{1,128}$/.test(candidate)) continue; + const inspected = await this.#run( + [ + "inspect", + "--format", + `{{index .Config.Labels "${THREAD_LABEL}"}}\t{{index .Config.Labels "com.t3tools.sandbox.managed"}}`, + candidate, + ], + 10_000, + true, + ); + if (inspected.exitCode !== 0 || inspected.stdout.trim() !== `${threadId}\ttrue`) continue; + const removed = await this.#run(["rm", "--force", candidate], 30_000, true); + if (removed.exitCode !== 0) failures.push(`remove sibling ${candidate}: ${removed.stderr}`); + } + return failures; + } + + async #cleanup( + container: string, + network: string, + volume: string, + desktopVolume: string, + timeoutMs: number, + egress?: { readonly container: string; readonly network: string }, + ): Promise { + const failures: string[] = []; + for (const args of [ + ["rm", "--force", container], + ...(egress ? [["rm", "--force", egress.container] as const] : []), + ["network", "rm", network], + ...(egress ? [["network", "rm", egress.network] as const] : []), + ["volume", "rm", volume], + ["volume", "rm", desktopVolume], + ] as const) { + const result = await this.#run(args, timeoutMs, true).catch((error) => ({ + exitCode: 1, + stdout: "", + stderr: String(error), + })); + if (result.exitCode !== 0) failures.push(`${args[0]} ${args.at(-1)}: ${result.stderr}`); + } + return failures; + } + + #run(args: ReadonlyArray, timeoutMs: number, allowFailure = false) { + return this.#executor.run({ executable: this.#binary, args, timeoutMs }).then((result) => { + if (!allowFailure && result.exitCode !== 0) + throw new SandboxRuntimeError(`${this.#binary} ${args[0]} failed`, result.stderr); + return result; + }); + } + + async #mustRun(args: ReadonlyArray, timeoutMs: number) { + return this.#run(args, timeoutMs); + } + + #mustExec(containerName: string, input: SandboxExecInput | SandboxHook, timeoutMs: number) { + validateExec(input); + const cwd = "cwd" in input ? input.cwd : undefined; + const args = [ + "exec", + "--user", + "1000:1000", + ...(cwd ? ["--workdir", cwd] : []), + ...Object.entries(input.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]), + "--", + containerName, + input.executable, + ...(input.args ?? []), + ]; + const command = { + executable: this.#binary, + args, + timeoutMs, + ...("stdin" in input && input.stdin !== undefined ? { stdin: input.stdin } : {}), + }; + return this.#executor.run(command).then((result) => { + if (result.exitCode !== 0) + throw new SandboxRuntimeError(`sandbox command ${input.executable} failed`, result.stderr); + return result; + }); + } +} + +function makeReady( + runtime: SandboxRuntime, + containerName: string, + networkName: string, + workspaceVolumeName: string, + desktopVolumeName: string, + input: SandboxProvisionInput, + limits: typeof DEFAULT_SANDBOX_RESOURCE_LIMITS, +): SandboxReady { + return { + sandboxId: sanitizeId(input.bootstrap.threadId, "threadId"), + runtime, + containerName, + networkName, + workspaceVolumeName, + desktopVolumeName, + ...(input.egressProxyImage === undefined + ? {} + : { + egressProxyContainerName: `t3-egress-${createHash("sha256").update(`${input.bootstrap.projectId}\0${input.bootstrap.threadId}`).digest("hex").slice(0, 32)}`, + egressNetworkName: `t3-egress-net-${createHash("sha256").update(`${input.bootstrap.projectId}\0${input.bootstrap.threadId}`).digest("hex").slice(0, 32)}`, + }), + branchName: input.bootstrap.branchName, + limits, + }; +} + +function boundedTimeout(seconds: number | undefined, fallback: number): number { + const value = seconds ?? fallback; + if (!Number.isInteger(value) || value < 1 || value > 600) + throw new SandboxRuntimeError("hook timeout must be between 1 and 600 seconds"); + return value * 1000; +} + +function validateLimits(limits: typeof DEFAULT_SANDBOX_RESOURCE_LIMITS): void { + if ( + !(limits.cpuCount > 0 && limits.cpuCount <= 64) || + !Number.isInteger(limits.memoryBytes) || + limits.memoryBytes < 128 * 1024 ** 2 || + !Number.isInteger(limits.processCount) || + limits.processCount < 16 || + !Number.isInteger(limits.diskBytes) || + limits.diskBytes < 1024 ** 3 + ) { + throw new SandboxRuntimeError("sandbox resource limits are invalid"); + } +} + +function validateProxy(value: string): string { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new SandboxRuntimeError("egress proxy must use http or https"); + if (url.username || url.password) + throw new SandboxRuntimeError( + "egress proxy credentials must be brokered outside configuration", + ); + return url.toString(); +} + +function parsePercent(value: unknown): number { + return Number( + String(value ?? "0") + .trim() + .replace(/%$/, ""), + ); +} + +function parseByteQuantity(value: string): number { + const match = /^([0-9]+(?:\.[0-9]+)?)\s*([kmgtpe]?i?b)?$/i.exec(value); + if (!match) return Number.NaN; + const unit = (match[2] ?? "b").toLowerCase(); + const powers: Record = { + b: 0, + kb: 1, + kib: 1, + mb: 2, + mib: 2, + gb: 3, + gib: 3, + tb: 4, + tib: 4, + pb: 5, + pib: 5, + eb: 6, + eib: 6, + }; + const power = powers[unit]; + return power === undefined ? Number.NaN : Number(match[1]) * 1024 ** power; +} diff --git a/apps/server/src/sandbox/CredentialBroker.ts b/apps/server/src/sandbox/CredentialBroker.ts new file mode 100644 index 000000000000..b3907191512c --- /dev/null +++ b/apps/server/src/sandbox/CredentialBroker.ts @@ -0,0 +1,78 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +type CredentialRecord = { + readonly threadId: string; + readonly scope: string; + readonly value: string; + readonly tokenHash: Buffer; + readonly expiresAt: number; + redeemed: boolean; +}; + +export class ThreadCredentialBroker { + readonly #records = new Map(); + readonly #now: () => number; + + constructor(now: () => number = Date.now) { + this.#now = now; + } + + issue(input: { threadId: string; scope: string; value: string; ttlMs: number }) { + if (!Number.isSafeInteger(input.ttlMs) || input.ttlMs < 1 || input.ttlMs > 15 * 60_000) + throw new Error("credential ttl must be between 1ms and 15 minutes"); + if (input.value.length === 0) throw new Error("credential value is required"); + const token = randomBytes(32).toString("base64url"); + const id = randomBytes(16).toString("hex"); + this.#records.set(id, { + threadId: input.threadId, + scope: input.scope, + value: input.value, + tokenHash: digest(token), + expiresAt: this.#now() + input.ttlMs, + redeemed: false, + }); + return { id, token, expiresAt: this.#records.get(id)!.expiresAt }; + } + + redeem(input: { id: string; token: string; threadId: string; scope: string }): string | null { + const record = this.#records.get(input.id); + if (record === undefined) return null; + if (record.expiresAt <= this.#now() || record.redeemed) { + this.#records.delete(input.id); + return null; + } + const candidate = digest(input.token); + const authorized = + record.threadId === input.threadId && + record.scope === input.scope && + timingSafeEqual(record.tokenHash, candidate); + if (!authorized) return null; + record.redeemed = true; + this.#records.delete(input.id); + return record.value; + } + + revoke(id: string, threadId: string) { + const record = this.#records.get(id); + if (record === undefined || record.threadId !== threadId) return false; + this.#records.delete(id); + return true; + } + + revokeThread(threadId: string) { + let revoked = 0; + for (const [id, record] of this.#records) { + if (record.threadId !== threadId) continue; + this.#records.delete(id); + revoked += 1; + } + return revoked; + } + + purgeExpired() { + const now = this.#now(); + for (const [id, record] of this.#records) if (record.expiresAt <= now) this.#records.delete(id); + } +} + +const digest = (value: string) => createHash("sha256").update(value).digest(); diff --git a/apps/server/src/sandbox/DesktopGatewayService.ts b/apps/server/src/sandbox/DesktopGatewayService.ts new file mode 100644 index 000000000000..d472487e778f --- /dev/null +++ b/apps/server/src/sandbox/DesktopGatewayService.ts @@ -0,0 +1,190 @@ +import { AuthenticatedPreviewRouter } from "./AuthenticatedPreviewRouter.ts"; +import { ThreadCredentialBroker } from "./CredentialBroker.ts"; +import { ThreadDesktopSignaling } from "./DesktopSession.ts"; +import type { ThreadPreviewProxy } from "./ThreadPreviewProxy.ts"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +type ViewerCredential = { readonly sessionId: string; readonly token: string }; + +const signaling = new ThreadDesktopSignaling(); +const credentials = new ThreadCredentialBroker(); +const previews = new AuthenticatedPreviewRouter(); +const viewers = new Map(); +const targets = new Map(); +const failures = new Map>(); +const services = new Map>(); +const humanControllers = new Set(); +const previewRoutes = new Map< + string, + ReadonlyArray<{ routeId: string; internalPort: number; token: string }> +>(); +const serviceCredentialGrants = new Map< + string, + ReadonlyArray<{ id: string; token: string; scope: string; expiresAt: number }> +>(); +let previewProxy: ThreadPreviewProxy | null = null; +const tickets = new Map(); + +/** Server-lifetime singleton shared by HTTP routes and sandbox lifecycle. */ +export const desktopGateway = { + signaling, + credentials, + previews, + viewer(threadId: string) { + const current = viewers.get(threadId); + if (current !== undefined && signaling.status(threadId) !== null) return current; + const issued = signaling.issue(threadId); + viewers.set(threadId, issued); + return issued; + }, + bridge(threadId: string) { + return signaling.issue(threadId, "bridge"); + }, + issueViewerTicket(threadId: string) { + const id = randomBytes(16).toString("hex"); + const secret = randomBytes(32).toString("base64url"); + const expiresAt = performance.timeOrigin + performance.now() + 60_000; + tickets.set(id, { threadId, hash: createHash("sha256").update(secret).digest(), expiresAt }); + return { + ticket: `${id}.${secret}`, + expiresAt: DateTime.formatIso(Option.getOrThrow(DateTime.make(expiresAt))), + }; + }, + consumeViewerTicket(threadId: string, ticket: string) { + this.purgeExpired(); + const separator = ticket.indexOf("."); + if (separator < 1) return false; + const id = ticket.slice(0, separator); + const record = tickets.get(id); + tickets.delete(id); + if ( + record === undefined || + record.threadId !== threadId || + record.expiresAt <= performance.timeOrigin + performance.now() + ) + return false; + const supplied = createHash("sha256") + .update(ticket.slice(separator + 1)) + .digest(); + return timingSafeEqual(record.hash, supplied); + }, + purgeExpired() { + const now = performance.timeOrigin + performance.now(); + let purged = 0; + for (const [id, ticket] of tickets) { + if (ticket.expiresAt > now) continue; + tickets.delete(id); + purged += 1; + } + signaling.purgeExpired(); + credentials.purgeExpired(); + for (const [threadId] of viewers) + if (signaling.status(threadId) === null) viewers.delete(threadId); + return purged; + }, + setAutomationTarget(threadId: string, hostname: string, profilePath: string) { + const routeId = createHash("sha256").update(`${threadId}\0cdp`).digest("hex").slice(0, 24); + const token = randomBytes(32).toString("base64url"); + previews.register({ routeId, threadId, hostname, internalPort: 9222, token }); + targets.set(threadId, { + endpoint: `/api/thread-preview/${routeId}/`, + profilePath, + token, + }); + failures.delete(threadId); + }, + automationTarget(threadId: string) { + return targets.get(threadId) ?? null; + }, + async invokeAutomation(threadId: string, operation: string, input: unknown, timeoutMs: number) { + const target = targets.get(threadId); + if (target === undefined || previewProxy === null) return null; + return previewProxy.automate({ + routeId: createHash("sha256").update(`${threadId}\0cdp`).digest("hex").slice(0, 24), + threadId, + token: target.token, + operation, + payload: input, + timeoutMs, + }); + }, + async relaySignal(threadId: string, payload: unknown) { + if (previewProxy === null) throw new Error("thread signaling sidecar is unavailable"); + return previewProxy.signal(threadId, payload); + }, + setCapabilityFailure(threadId: string, missing: ReadonlyArray) { + failures.set(threadId, [...missing]); + targets.delete(threadId); + }, + setServiceStatus(threadId: string, status: ReadonlyArray<{ name: string; healthy: boolean }>) { + services.set( + threadId, + status.map((item) => ({ ...item })), + ); + }, + setServiceCredentialGrants( + threadId: string, + grants: ReadonlyArray<{ id: string; token: string; scope: string; expiresAt: number }>, + ) { + serviceCredentialGrants.set( + threadId, + grants.map((grant) => ({ ...grant })), + ); + }, + setHumanControl(threadId: string, active: boolean) { + if (active) humanControllers.add(threadId); + else humanControllers.delete(threadId); + }, + acceptsHumanInput(threadId: string) { + return humanControllers.has(threadId); + }, + setPreviewProxy(proxy: ThreadPreviewProxy) { + previewProxy = proxy; + }, + registerPreviewRoute(input: { + routeId: string; + threadId: string; + hostname: string; + internalPort: number; + token: string; + }) { + previews.register(input); + const routes = previewRoutes.get(input.threadId) ?? []; + previewRoutes.set(input.threadId, [ + ...routes.filter((route) => route.routeId !== input.routeId), + { + routeId: input.routeId, + internalPort: input.internalPort, + token: input.token, + }, + ]); + }, + previewProxy() { + return previewProxy; + }, + status(threadId: string) { + return { + connected: signaling.status(threadId)?.connected ?? false, + ready: targets.has(threadId) && !failures.has(threadId), + capabilityFailure: failures.get(threadId) ?? [], + services: services.get(threadId) ?? [], + previewRoutes: previewRoutes.get(threadId) ?? [], + serviceCredentialGrants: serviceCredentialGrants.get(threadId) ?? [], + }; + }, + removeThread(threadId: string) { + viewers.delete(threadId); + targets.delete(threadId); + failures.delete(threadId); + services.delete(threadId); + humanControllers.delete(threadId); + previewRoutes.delete(threadId); + serviceCredentialGrants.delete(threadId); + previews.removeThread(threadId); + credentials.revokeThread(threadId); + signaling.disconnect(threadId); + signaling.remove(threadId); + }, +}; diff --git a/apps/server/src/sandbox/DesktopHttpRoutes.ts b/apps/server/src/sandbox/DesktopHttpRoutes.ts new file mode 100644 index 000000000000..2a9585c794b6 --- /dev/null +++ b/apps/server/src/sandbox/DesktopHttpRoutes.ts @@ -0,0 +1,416 @@ +// @effect-diagnostics nodeBuiltinImport:off - WebSocket proxying requires a captured duplex docker/podman exec process. +// @effect-diagnostics runEffectInsideEffect:off - Node stream callbacks bridge into the acquired downstream Socket writer. +// @effect-diagnostics outdatedApi:off - Socket.runRaw currently remains on the compatibility surface. +// @effect-diagnostics globalTimers:off - Captured bridge child owns a bounded handshake timer and exact process-group cleanup. +// @effect-diagnostics globalTimersInEffect:off - Node stream callback timer guards an external process handshake. +import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Data from "effect/Data"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as FileSystem from "effect/FileSystem"; +import { spawn } from "node:child_process"; +import { + HttpRouter, + HttpServerRequest, + HttpServerRespondable, + HttpServerResponse, +} from "effect/unstable/http"; +import * as HttpIncomingMessage from "effect/unstable/http/HttpIncomingMessage"; +import { authenticateRawRouteWithScope } from "../http.ts"; +import { desktopGateway } from "./DesktopGatewayService.ts"; +import { ServerConfig } from "../config.ts"; +import { resolve } from "node:path"; + +const prefix = "/api/thread-desktop/"; + +export const desktopHttpRouteLayer = HttpRouter.add( + "GET", + `${prefix}*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); + const [threadId, action] = url.value.pathname.slice(prefix.length).split("/"); + if (!threadId || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(threadId)) + return HttpServerResponse.text("Not Found", { status: 404 }); + const gateway = desktopGateway; + if (action === "status") { + yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); + return HttpServerResponse.jsonUnsafe(gateway.status(threadId)); + } + if (action === "automation-target") { + yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); + const target = gateway.automationTarget(threadId); + return target === null + ? HttpServerResponse.text("Desktop not ready", { status: 409 }) + : HttpServerResponse.jsonUnsafe(target); + } + if (action !== "view") return HttpServerResponse.text("Not Found", { status: 404 }); + const ticket = url.value.searchParams.get("ticket") ?? ""; + if (!gateway.consumeViewerTicket(threadId, ticket)) + return HttpServerResponse.text("Forbidden", { status: 403 }); + const credential = gateway.viewer(threadId); + return HttpServerResponse.text(viewerHtml(threadId, credential.sessionId, credential.token), { + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "content-security-policy": + "default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'; style-src 'unsafe-inline'", + "referrer-policy": "no-referrer", + "set-cookie": `${signalCookieName(threadId)}=${credential.token}; HttpOnly; SameSite=Strict; Path=${prefix}${threadId}/signal; Max-Age=3600`, + }, + }); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + ), +); + +export const desktopSignalHttpRouteLayer = HttpRouter.add( + "POST", + `${prefix}*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); + const [threadId, action] = url.value.pathname.slice(prefix.length).split("/"); + if (!threadId) return HttpServerResponse.text("Not Found", { status: 404 }); + if (action === "viewer-ticket") { + yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); + const issued = desktopGateway.issueViewerTicket(threadId); + const viewerUrl = `${prefix}${threadId}/view?ticket=${encodeURIComponent(issued.ticket)}`; + return HttpServerResponse.jsonUnsafe( + { viewerUrl, expiresAt: issued.expiresAt }, + { headers: { "cache-control": "no-store" } }, + ); + } + if (action !== "signal") return HttpServerResponse.text("Not Found", { status: 404 }); + const body = yield* request.json.pipe(Effect.orElseSucceed(() => null)); + const decodedBody = Schema.decodeUnknownOption(SignalBodySchema)(body); + if (Option.isNone(decodedBody) || !isBoundedSignalBody(decodedBody.value)) + return HttpServerResponse.text("Bad Request", { status: 400 }); + const signalBody = decodedBody.value; + const token = + signalBody.role === "viewer" + ? (request.cookies[signalCookieName(threadId)] ?? signalBody.token ?? "") + : (signalBody.token ?? ""); + const gateway = desktopGateway; + if ( + signalBody.type === "input" && + (signalBody.role !== "viewer" || !gateway.acceptsHumanInput(threadId)) + ) + return HttpServerResponse.text("Takeover lease required", { status: 423 }); + if (signalBody.type === "poll") { + if (gateway.signaling.attach({ threadId, ...signalBody, token }) === null) + return HttpServerResponse.text("Forbidden", { status: 403 }); + const relayed = yield* Effect.tryPromise({ + try: () => gateway.relaySignal(threadId, { ...signalBody, token }), + catch: (cause) => new PreviewProxyHttpError({ cause }), + }).pipe(Effect.orElseSucceed(() => null)); + return isSignalPollResponse(relayed) + ? HttpServerResponse.jsonUnsafe(relayed) + : HttpServerResponse.text("Signaling relay unavailable", { status: 503 }); + } + const published = gateway.signaling.publish({ + threadId, + sessionId: signalBody.sessionId, + token, + type: signalBody.type, + payload: signalBody.payload, + }); + if (published === null) return HttpServerResponse.text("Forbidden", { status: 403 }); + const relayed = yield* Effect.tryPromise({ + try: () => gateway.relaySignal(threadId, { ...signalBody, token }), + catch: (cause) => new PreviewProxyHttpError({ cause }), + }).pipe(Effect.orElseSucceed(() => null)); + return relayed === null + ? HttpServerResponse.text("Signaling relay unavailable", { status: 503 }) + : HttpServerResponse.jsonUnsafe(published); + }), +); + +export const sandboxCredentialHttpRouteLayer = HttpRouter.add( + "POST", + "/api/thread-credentials/*", + Effect.gen(function* () { + yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope); + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + const [threadId, action] = Option.isSome(url) + ? url.value.pathname.slice("/api/thread-credentials/".length).split("/") + : []; + const body = yield* request.json.pipe(Effect.orElseSucceed(() => null)); + const credential = Schema.decodeUnknownOption(CredentialBodySchema)(body); + if (!threadId || action !== "redeem" || Option.isNone(credential)) + return HttpServerResponse.text("Bad Request", { status: 400 }); + const value = desktopGateway.credentials.redeem({ threadId, ...credential.value }); + return value === null + ? HttpServerResponse.text("Forbidden", { status: 403 }) + : HttpServerResponse.jsonUnsafe({ value }, { headers: { "cache-control": "no-store" } }); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + ), +); + +export const sandboxArtifactHttpRouteLayer = HttpRouter.add( + "GET", + "/api/sandbox-artifacts/*", + Effect.gen(function* () { + yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + const [artifactId, kind] = Option.isSome(url) + ? url.value.pathname.slice("/api/sandbox-artifacts/".length).split("/") + : []; + if ( + !artifactId || + !/^[a-f0-9]{64}$/.test(artifactId) || + (kind !== "bundle" && kind !== "manifest") + ) + return HttpServerResponse.text("Not Found", { status: 404 }); + const config = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = resolve( + config.stateDir, + "sandbox-artifacts", + `${artifactId}.${kind === "bundle" ? "bundle" : "json"}`, + ); + const bytes = yield* fs.readFile(path).pipe(Effect.option); + if (Option.isNone(bytes)) return HttpServerResponse.text("Not Found", { status: 404 }); + return HttpServerResponse.uint8Array(bytes.value, { + headers: { + "content-type": kind === "bundle" ? "application/octet-stream" : "application/json", + "cache-control": "no-store", + }, + }); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + ), +); + +export const sandboxPreviewResolveHttpRouteLayer = HttpRouter.add( + "*", + "/api/thread-preview/*", + Effect.gen(function* () { + yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); + const suffix = url.value.pathname.slice("/api/thread-preview/".length); + const [routeId, ...pathParts] = suffix.split("/"); + const threadId = url.value.searchParams.get("threadId") ?? ""; + const token = request.headers["x-t3-preview-token"] ?? ""; + if (!routeId) return HttpServerResponse.text("Bad Request", { status: 400 }); + const proxy = desktopGateway.previewProxy(); + if (proxy === null) + return HttpServerResponse.text("Preview proxy unavailable", { status: 503 }); + if (request.headers.upgrade?.toLowerCase() === "websocket") { + const command = proxy.webSocketCommand({ + routeId, + threadId, + token, + path: `/${pathParts.join("/")}${url.value.search}`, + headers: Object.fromEntries( + Object.entries(request.headers).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ), + }); + return command === null + ? HttpServerResponse.text("Forbidden", { status: 403 }) + : yield* relayPreviewWebSocket(request, command); + } + const body = + request.method === "GET" || request.method === "HEAD" + ? undefined + : new Uint8Array( + yield* request.arrayBuffer.pipe( + Effect.provideService(HttpIncomingMessage.MaxBodySize, FileSystem.MiB(8)), + Effect.mapError((cause) => new PreviewProxyHttpError({ cause })), + ), + ); + const response = yield* Effect.tryPromise({ + try: () => + proxy.request({ + routeId, + threadId, + token, + method: request.method, + path: `/${pathParts.join("/")}${url.value.search}`, + headers: Object.fromEntries( + Object.entries(request.headers).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ), + ...(body === undefined ? {} : { body }), + }), + catch: (cause) => new PreviewProxyHttpError({ cause }), + }).pipe(Effect.orElseSucceed(() => null)); + if (response === null) return HttpServerResponse.text("Bad Gateway", { status: 502 }); + const headers = Object.fromEntries( + Object.entries(response.headers).filter( + ([name]) => + !["set-cookie", "connection", "transfer-encoding", "content-length"].includes( + name.toLowerCase(), + ), + ), + ); + return HttpServerResponse.uint8Array(response.body, { status: response.status, headers }); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + PreviewProxyHttpError: () => + Effect.succeed(HttpServerResponse.text("Preview request rejected", { status: 413 })), + }), + ), +); + +type SignalBody = { + sessionId: string; + token?: string | undefined; + type: "offer" | "answer" | "ice" | "input" | "poll"; + payload: string; + sequence: number; + role: "viewer" | "bridge"; +}; +const SignalBodySchema = Schema.Struct({ + sessionId: Schema.String, + token: Schema.optional(Schema.String), + type: Schema.Literals(["offer", "answer", "ice", "input", "poll"]), + payload: Schema.String, + sequence: Schema.Number, + role: Schema.Literals(["viewer", "bridge"]), +}); +const isBoundedSignalBody = (body: SignalBody) => + body.sessionId.length > 0 && + body.sessionId.length <= 128 && + (body.token === undefined || (body.token.length >= 32 && body.token.length <= 128)) && + body.payload.length <= 256 * 1024 && + Number.isSafeInteger(body.sequence) && + body.sequence >= 0 && + body.sequence <= Number.MAX_SAFE_INTEGER; +const isSignalPollResponse = (value: unknown): value is { messages: ReadonlyArray } => + typeof value === "object" && + value !== null && + "messages" in value && + Array.isArray(value.messages) && + value.messages.length <= 256; + +type CredentialBody = { readonly id: string; readonly token: string; readonly scope: string }; +const CredentialBodySchema = Schema.Struct({ + id: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(128)), + token: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(128)), + scope: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(128)), +}); + +class PreviewProxyHttpError extends Data.TaggedError("PreviewProxyHttpError")<{ + readonly cause: unknown; +}> {} + +const relayPreviewWebSocket = Effect.fnUntraced(function* ( + request: HttpServerRequest.HttpServerRequest, + command: { executable: string; args: ReadonlyArray; handshake: string }, +) { + const downstream = yield* Effect.orDie(request.upgrade); + const writeDownstream = yield* downstream.writer; + const child = spawn(command.executable, [...command.args], { + stdio: ["pipe", "pipe", "ignore"], + shell: false, + detached: process.platform !== "win32", + env: { PATH: process.env.PATH }, + }); + const terminate = () => { + if (child.pid === undefined || child.killed) return; + try { + process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL"); + } catch { + /* already exited */ + } + }; + child.stdin.on("error", terminate); + child.stdin.write(`${command.handshake}\n`); + let pending = Promise.resolve(undefined); + let buffer = Buffer.alloc(0); + const receive = Effect.callback((resume) => { + const handshakeTimer = setTimeout(terminate, 10_000); + let received = false; + const onData = (chunk: Buffer) => { + if (!received) { + received = true; + clearTimeout(handshakeTimer); + } + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const length = buffer.readUInt32BE(0); + if (length > 1024 * 1024) { + terminate(); + break; + } + if (buffer.length < length + 4) break; + const payload = new Uint8Array(buffer.subarray(4, length + 4)); + buffer = buffer.subarray(length + 4); + pending = pending + .then(() => Effect.runPromise(writeDownstream(payload))) + .then(() => undefined); + } + }; + const done = () => { + clearTimeout(handshakeTimer); + resume(Effect.void); + }; + child.stdout.on("data", onData); + child.once("error", done); + child.once("exit", done); + return Effect.sync(() => { + child.stdout.off("data", onData); + clearTimeout(handshakeTimer); + terminate(); + }); + }); + const send = downstream.runRaw((chunk) => + Effect.sync(() => { + const payload = + typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk as Uint8Array); + if (payload.length > 1024 * 1024) { + terminate(); + return; + } + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(payload.length); + child.stdin.write(Buffer.concat([header, payload])); + }), + ); + yield* send.pipe(Effect.raceFirst(receive), Effect.ensuring(Effect.sync(terminate))); + return HttpServerResponse.empty(); +}); + +const signalCookieName = (threadId: string) => + `t3-desktop-${threadId.replace(/[^A-Za-z0-9]/g, "-")}`; + +const viewerHtml = (threadId: string, sessionId: string, token: string) => ` + + +`; diff --git a/apps/server/src/sandbox/DesktopInfrastructure.test.ts b/apps/server/src/sandbox/DesktopInfrastructure.test.ts new file mode 100644 index 000000000000..4c353683edc1 --- /dev/null +++ b/apps/server/src/sandbox/DesktopInfrastructure.test.ts @@ -0,0 +1,424 @@ +import { describe, expect, it } from "@effect/vitest"; +import { AuthenticatedPreviewRouter } from "./AuthenticatedPreviewRouter.ts"; +import { ThreadCredentialBroker } from "./CredentialBroker.ts"; +import { + desktopLaunchCommands, + desktopSessionForThread, + detectDesktopCapability, + ThreadDesktopSignaling, + tmuxMirrorCommand, +} from "./DesktopSession.ts"; +import { evaluateEgressDestination, evaluateResolvedEgressDestination } from "./EgressPolicy.ts"; +import { planThreadServiceStack } from "./ThreadServiceStack.ts"; +import { ThreadServiceStackRuntime } from "./ThreadServiceStack.ts"; +import type { SandboxCommand, SandboxCommandResult, ThreadSandboxBackend } from "./types.ts"; +import { ThreadDesktopRuntime } from "./ThreadDesktopRuntime.ts"; +import { resolveSignalingUrl } from "./ThreadDesktopRuntime.ts"; +import { ThreadPreviewProxy } from "./ThreadPreviewProxy.ts"; +import { desktopGateway } from "./DesktopGatewayService.ts"; + +describe("thread desktop infrastructure", () => { + it("derives stable, thread-isolated desktop and browser identities without VNC", () => { + const first = desktopSessionForThread("thread-a"); + const again = desktopSessionForThread("thread-a"); + const second = desktopSessionForThread("thread-b"); + expect(first).toEqual(again); + expect(first.sessionId).not.toBe(second.sessionId); + expect(first.browserProfilePath).not.toBe(second.browserProfilePath); + expect(first.transport).toBe("webrtc"); + expect(first.vncEndpoint).toBeNull(); + expect(first.fullscreenSupported).toBe(true); + }); + + it("fails desktop readiness closed when an external capability is missing", async () => { + const executor = { + run: async (command: { args: ReadonlyArray }) => ({ + exitCode: command.args.at(-1) === "code" ? 1 : 0, + stdout: "", + stderr: "", + }), + }; + await expect(detectDesktopCapability(executor)).resolves.toEqual({ + ready: false, + missing: ["code"], + }); + }); + + it("mirrors commands through the thread's named tmux session without shell joining", () => { + const session = desktopSessionForThread("thread-a"); + expect(tmuxMirrorCommand(session, "printf", ["%s", "hello; still-an-argument"])).toEqual({ + executable: "tmux", + args: [ + "new-session", + "-A", + "-d", + "-s", + session.tmuxSession, + "--", + "printf", + "%s", + "hello; still-an-argument", + ], + }); + }); + + it("launches one visible Chromium profile and WebRTC media with no VNC process", () => { + const commands = desktopLaunchCommands(desktopSessionForThread("thread-a")); + expect(commands.map((command) => command.executable)).toEqual([ + "tmux", + "startxfce4", + "chromium", + "code", + "t3-desktop-webrtc", + ]); + expect(commands.flatMap((command) => command.args).join(" ")).not.toMatch(/vnc/i); + expect(commands.flatMap((command) => command.args)).toContain( + "--remote-debugging-address=0.0.0.0", + ); + }); + + it("detects and starts desktop processes through in-container exec", async () => { + const calls: Array<{ executable: string; args?: ReadonlyArray }> = []; + const backend = { + exec: async ( + _threadId: string, + input: { executable: string; args?: ReadonlyArray }, + ) => { + calls.push(input); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + } as unknown as ThreadSandboxBackend; + const runtime = new ThreadDesktopRuntime(backend); + await runtime.start( + "thread-a", + { sessionId: "desktop", token: "bridge-token" }, + "https://command.example.test", + ); + expect(calls.some((call) => call.executable === "chromium")).toBe(false); + expect(calls.filter((call) => call.executable === "tmux").length).toBeGreaterThanOrEqual(5); + expect(runtime.automationTarget("thread-a").endpoint).toBe("http://127.0.0.1:9222"); + }); + + it("requires an absolute sandbox-reachable signaling origin", () => { + expect( + resolveSignalingUrl("https://command.example.test", "/api/thread-desktop/thread-a/signal"), + ).toBe("https://command.example.test/api/thread-desktop/thread-a/signal"); + expect(() => resolveSignalingUrl("http://127.0.0.1:3773", "/signal")).toThrow(/reachable/); + expect(() => resolveSignalingUrl("not-an-origin", "/signal")).toThrow(/invalid/); + }); + + it("treats thrown in-container capability probes as missing", async () => { + const backend = { + exec: async (_threadId: string, input: { args?: ReadonlyArray }) => { + if (input.args?.at(-1) === "code") throw new Error("not found"); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + } as unknown as ThreadSandboxBackend; + await expect(new ThreadDesktopRuntime(backend).detect("thread-a")).resolves.toEqual({ + ready: false, + missing: ["code"], + }); + }); + + it("authenticates WebRTC attachment and keeps disconnects paused until explicit reconnect", () => { + const signaling = new ThreadDesktopSignaling(); + const issued = signaling.issue("thread-a"); + const bridge = signaling.issue("thread-a", "bridge"); + expect(signaling.attach({ ...issued, threadId: "thread-b" })).toBeNull(); + const attached = signaling.attach({ ...issued, threadId: "thread-a" }); + expect(attached?.connected).toBe(true); + signaling.disconnect("thread-a"); + expect(signaling.status("thread-a")?.connected).toBe(false); + expect(signaling.attach({ ...issued, threadId: "thread-a" })?.connected).toBe(true); + expect( + signaling.publish({ + ...bridge, + threadId: "thread-a", + role: "bridge", + type: "offer", + payload: "sdp", + }), + ).toEqual({ sequence: 1 }); + expect(signaling.messagesAfter({ ...issued, threadId: "thread-a", sequence: 0 })).toEqual([ + { sequence: 1, sender: "bridge", type: "offer", payload: "sdp" }, + ]); + }); + + it("issues one-time thread-bound viewer tickets", () => { + const issued = desktopGateway.issueViewerTicket("thread-a"); + expect(desktopGateway.consumeViewerTicket("thread-b", issued.ticket)).toBe(false); + const valid = desktopGateway.issueViewerTicket("thread-a"); + expect(desktopGateway.consumeViewerTicket("thread-a", valid.ticket)).toBe(true); + expect(desktopGateway.consumeViewerTicket("thread-a", valid.ticket)).toBe(false); + }); + + it("rejects oversized signaling payloads and bounds retained history", () => { + const signaling = new ThreadDesktopSignaling(); + const issued = signaling.issue("thread-a"); + const bridge = signaling.issue("thread-a", "bridge"); + expect( + signaling.publish({ + ...issued, + threadId: "thread-a", + type: "ice", + payload: "x".repeat(256 * 1024 + 1), + }), + ).toBeNull(); + for (let index = 0; index < 300; index += 1) + signaling.publish({ + ...bridge, + threadId: "thread-a", + role: "bridge", + type: "ice", + payload: String(index), + }); + expect(signaling.messagesAfter({ ...issued, threadId: "thread-a", sequence: 0 })).toHaveLength( + 256, + ); + }); + + it("purges expired signaling sessions and gates human input on takeover", () => { + let now = 0; + const signaling = new ThreadDesktopSignaling(() => now); + signaling.issue("thread-expiring"); + now = 8 * 60 * 60_000 + 1; + expect(signaling.purgeExpired()).toBe(1); + desktopGateway.setHumanControl("thread-a", false); + expect(desktopGateway.acceptsHumanInput("thread-a")).toBe(false); + desktopGateway.setHumanControl("thread-a", true); + expect(desktopGateway.acceptsHumanInput("thread-a")).toBe(true); + desktopGateway.removeThread("thread-a"); + }); + + it("requires both route token and matching thread identity", () => { + const router = new AuthenticatedPreviewRouter(); + router.register({ + routeId: "app", + threadId: "thread-a", + hostname: "exact-container-a", + internalPort: 3000, + token: "secret-a", + }); + expect(router.resolve({ routeId: "app", threadId: "thread-a", token: "secret-a" })).toEqual({ + hostname: "exact-container-a", + port: 3000, + }); + expect(router.resolve({ routeId: "app", threadId: "thread-b", token: "secret-a" })).toBeNull(); + expect(router.resolve({ routeId: "app", threadId: "thread-a", token: "secret-b" })).toBeNull(); + }); + + it("allows identical internal service ports in isolated thread networks", () => { + const declaration = [ + { name: "web", image: `web@sha256:${"a".repeat(64)}`, internalPorts: [3000] }, + ]; + const [a] = planThreadServiceStack("thread-a", declaration); + const [b] = planThreadServiceStack("thread-b", declaration); + expect(a!.internalPorts).toEqual([3000]); + expect(b!.internalPorts).toEqual([3000]); + expect(a!.hostPorts).toEqual([]); + expect(a!.networkName).not.toBe(b!.networkName); + expect(a!.name).not.toBe(b!.name); + }); + + it("starts service containers without host ports or credentials in argv", async () => { + const commands: SandboxCommand[] = []; + const executor = { + run: async (command: SandboxCommand): Promise => { + commands.push(command); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }; + const runtime = new ThreadServiceStackRuntime("podman", executor); + await runtime.start( + "thread-a", + [ + { + name: "database", + image: `postgres@sha256:${"b".repeat(64)}`, + internalPorts: [5432], + environment: { DATABASE_PASSWORD: "not-in-argv" }, + }, + ], + "t3-net-authoritative", + ); + const run = commands[0]!; + expect(run.args).not.toContain("-p"); + expect(run.args.join(" ")).not.toContain("not-in-argv"); + expect(run.stdin).toContain("DATABASE_PASSWORD=not-in-argv"); + expect(run.args).toContain("t3-net-authoritative"); + }); + + it("generates thread-scoped service credentials only in stdin", async () => { + const commands: SandboxCommand[] = []; + const runtime = new ThreadServiceStackRuntime("podman", { + run: async (command) => { + commands.push(command); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }); + const [service] = await runtime.start( + "thread-a", + [ + { + name: "database", + image: `postgres@sha256:${"b".repeat(64)}`, + generatedEnvironment: [ + { key: "POSTGRES_DB", kind: "database-name" }, + { key: "POSTGRES_PASSWORD", kind: "password" }, + ], + }, + ], + "t3-net-authoritative", + ); + expect(service?.environment.POSTGRES_DB).toMatch(/^db_[a-f0-9]{16}$/); + expect(service?.environment.POSTGRES_PASSWORD?.length).toBeGreaterThan(32); + expect(commands[0]?.args.join(" ")).not.toContain(service?.environment.POSTGRES_PASSWORD); + expect(commands[0]?.stdin).toContain("POSTGRES_PASSWORD="); + }); + + it("expires and thread-scopes one-shot credentials", () => { + let now = 1_000; + const broker = new ThreadCredentialBroker(() => now); + const wrongThread = broker.issue({ + threadId: "thread-a", + scope: "git", + value: "value", + ttlMs: 100, + }); + expect(broker.redeem({ ...wrongThread, threadId: "thread-b", scope: "git" })).toBeNull(); + expect(broker.redeem({ ...wrongThread, threadId: "thread-a", scope: "git" })).toBe("value"); + expect(broker.redeem({ ...wrongThread, threadId: "thread-a", scope: "git" })).toBeNull(); + const expired = broker.issue({ + threadId: "thread-a", + scope: "package", + value: "value", + ttlMs: 100, + }); + now += 101; + expect(broker.redeem({ ...expired, threadId: "thread-a", scope: "package" })).toBeNull(); + const revoked = broker.issue({ + threadId: "thread-a", + scope: "git", + value: "value", + ttlMs: 100, + }); + expect(broker.revoke(revoked.id, "thread-b")).toBe(false); + expect(broker.revoke(revoked.id, "thread-a")).toBe(true); + expect(broker.redeem({ ...revoked, threadId: "thread-a", scope: "git" })).toBeNull(); + }); + + const privateUrl = (host: string, path = "") => ["http:/", "/", host, path].join(""); + + it.each([ + "http://127.0.0.1", + privateUrl([10, 2, 3, 4].join(".")), + privateUrl([172, 16, 0, 1].join(".")), + privateUrl([192, 168, 1, 2].join(".")), + privateUrl([169, 254, 169, 254].join("."), "/latest/meta-data"), + "http://[::1]", + privateUrl(`[${["fd00", "", "1"].join(":")}]`), + "file:///etc/passwd", + ])("denies protected egress destination %s", (destination: string) => { + expect(evaluateEgressDestination(destination).allowed).toBe(false); + }); + + it("denies known cross-sandbox hosts while allowing public HTTPS", () => { + expect(evaluateEgressDestination(privateUrl("thread-b"), new Set(["thread-b"])).allowed).toBe( + false, + ); + expect(evaluateEgressDestination("https://example.com")).toEqual({ allowed: true }); + }); + + it("denies DNS rebinding to a private address", async () => { + await expect( + evaluateResolvedEgressDestination("https://public.example", async () => ["10.0.0.8"]), + ).resolves.toEqual({ allowed: false, reason: "destination resolved to a protected address" }); + }); + + it("isolates preview proxy routes, bounds bodies, and cleans up its container", async () => { + const commands: SandboxCommand[] = []; + const executor = { + run: async (command: SandboxCommand): Promise => { + commands.push(command); + return command.args.includes("request") + ? { + exitCode: 0, + stdout: JSON.stringify({ status: 200, headers: {}, bodyBase64: "" }), + stderr: "", + } + : command.args.includes("signal") + ? { exitCode: 0, stdout: JSON.stringify({ messages: [] }), stderr: "" } + : { exitCode: 0, stdout: "", stderr: "" }; + }, + }; + const router = new AuthenticatedPreviewRouter(); + router.register({ + routeId: "app", + threadId: "thread-a", + hostname: "exact-container-a", + internalPort: 3000, + token: "route-token", + }); + const proxy = new ThreadPreviewProxy("podman", executor, router); + await proxy.start("thread-a", "t3-net-authoritative", `proxy@sha256:${"a".repeat(64)}`); + expect(proxy.internalSignalingOrigin("thread-a")).toMatch( + /^http:\/\/t3-preview-[a-f0-9]{24}:8080$/, + ); + expect(commands[0]?.args).toContain("--signaling-relay"); + await expect(proxy.signal("thread-a", { type: "poll" })).resolves.toEqual({ messages: [] }); + expect(await proxy.recover("thread-a")).toBe(false); + await expect( + proxy.request({ + routeId: "app", + threadId: "thread-b", + token: "route-token", + method: "GET", + path: "/", + headers: {}, + }), + ).rejects.toThrow(/authorized/); + await expect( + proxy.request({ + routeId: "app", + threadId: "thread-a", + token: "route-token", + method: "POST", + path: "/", + headers: {}, + body: new Uint8Array(8 * 1024 * 1024 + 1), + }), + ).rejects.toThrow(/too large/); + await expect( + proxy.request({ + routeId: "app", + threadId: "thread-a", + token: "route-token", + method: "GET", + path: "/", + headers: {}, + }), + ).resolves.toMatchObject({ status: 200 }); + expect( + proxy.webSocketCommand({ + routeId: "app", + threadId: "thread-b", + token: "route-token", + path: "/hmr", + headers: {}, + }), + ).toBeNull(); + const websocket = proxy.webSocketCommand({ + routeId: "app", + threadId: "thread-a", + token: "route-token", + path: "/hmr", + headers: { authorization: "Bearer secret" }, + }); + expect(websocket?.args).toContain("websocket-framed"); + expect(websocket?.handshake).not.toContain("Bearer secret"); + await proxy.stop("thread-a"); + expect(commands.at(-1)?.args[0]).toBe("rm"); + expect(commands.at(-1)?.args[1]).toBe("--force"); + expect(commands.at(-1)?.args[2]).toMatch(/^t3-preview-[a-f0-9]{24}$/); + }); +}); diff --git a/apps/server/src/sandbox/DesktopSession.ts b/apps/server/src/sandbox/DesktopSession.ts new file mode 100644 index 000000000000..76071d17de30 --- /dev/null +++ b/apps/server/src/sandbox/DesktopSession.ts @@ -0,0 +1,246 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { SandboxCommandExecutor } from "./types.ts"; + +export const REQUIRED_DESKTOP_BINARIES = [ + "startxfce4", + "chromium", + "code", + "tmux", + "t3-desktop-webrtc", +] as const; + +export type DesktopCapability = { + readonly ready: boolean; + readonly missing: ReadonlyArray; +}; + +export type ThreadDesktopSession = { + readonly threadId: string; + readonly sessionId: string; + readonly display: ":1"; + readonly resolution: "1440x900"; + readonly browserProfilePath: string; + readonly browserAutomationEndpoint: string; + readonly tmuxSession: string; + readonly signalingPath: string; + readonly reconnectKey: string; + readonly fullscreenSupported: true; + readonly transport: "webrtc"; + readonly vncEndpoint: null; +}; + +const safeId = (value: string) => { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) throw new Error("invalid thread id"); + return value; +}; + +export const desktopSessionForThread = (threadIdValue: string): ThreadDesktopSession => { + const threadId = safeId(threadIdValue); + const digest = createHash("sha256").update(threadId).digest("hex").slice(0, 24); + return { + threadId, + sessionId: `desktop-${digest}`, + display: ":1", + resolution: "1440x900", + browserProfilePath: `/thread-data/${threadId}/chromium`, + browserAutomationEndpoint: "http://127.0.0.1:9222", + tmuxSession: `thread-${digest}`, + signalingPath: `/api/thread-desktop/${threadId}/signal`, + reconnectKey: digest, + fullscreenSupported: true, + transport: "webrtc", + vncEndpoint: null, + }; +}; + +export const detectDesktopCapability = async ( + executor: SandboxCommandExecutor, +): Promise => { + const missing: Array = []; + for (const binary of REQUIRED_DESKTOP_BINARIES) { + const result = await executor.run({ + executable: "sh", + args: ["-lc", 'command -v -- "$1" >/dev/null 2>&1', "sh", binary], + timeoutMs: 5_000, + }); + if (result.exitCode !== 0) missing.push(binary); + } + return { ready: missing.length === 0, missing }; +}; + +export const tmuxMirrorCommand = ( + session: ThreadDesktopSession, + executable: string, + args: ReadonlyArray, +) => { + if (executable.length === 0 || executable.includes("\0")) throw new Error("invalid executable"); + if (args.some((arg) => arg.includes("\0"))) throw new Error("invalid command argument"); + return { + executable: "tmux", + args: ["new-session", "-A", "-d", "-s", session.tmuxSession, "--", executable, ...args], + } as const; +}; + +export const desktopLaunchCommands = (session: ThreadDesktopSession) => + [ + { executable: "tmux", args: ["new-session", "-A", "-d", "-s", session.tmuxSession] }, + { executable: "startxfce4", args: [] }, + { + executable: "chromium", + args: [ + `--user-data-dir=${session.browserProfilePath}`, + "--remote-debugging-address=0.0.0.0", + "--remote-debugging-port=9222", + "--no-first-run", + ], + }, + { executable: "code", args: ["--reuse-window", "/workspace/repo"] }, + { + executable: "t3-desktop-webrtc", + args: [ + "--display", + session.display, + "--resolution", + session.resolution, + "--signaling-path", + session.signalingPath, + ], + }, + ] as const; + +type SignalRecord = { + readonly session: ThreadDesktopSession; + readonly tokenHashes: Partial>; + connected: boolean; + expiresAt: number; + sequence: number; + messages: Array<{ + readonly sequence: number; + readonly sender: "viewer" | "bridge"; + readonly type: "offer" | "answer" | "ice" | "input"; + readonly payload: string; + }>; +}; + +/** In-memory, thread-bound signaling authorization. Viewer disconnect only marks + * transport state; it never changes agent pause/takeover state. */ +export class ThreadDesktopSignaling { + readonly #records = new Map(); + readonly #now: () => number; + + constructor(now: () => number = () => Math.floor(process.uptime() * 1_000)) { + this.#now = now; + } + + issue(threadId: string, role: "viewer" | "bridge" = "viewer") { + const session = desktopSessionForThread(threadId); + const token = randomBytes(32).toString("base64url"); + const existing = this.#records.get(threadId); + this.#records.set( + threadId, + existing === undefined + ? { + session, + tokenHashes: { [role]: createHash("sha256").update(token).digest() }, + connected: false, + expiresAt: this.#now() + 8 * 60 * 60_000, + sequence: 0, + messages: [], + } + : { + ...existing, + tokenHashes: { + ...existing.tokenHashes, + [role]: createHash("sha256").update(token).digest(), + }, + }, + ); + return { sessionId: session.sessionId, token }; + } + + attach(input: { + threadId: string; + sessionId: string; + token: string; + role?: "viewer" | "bridge"; + }) { + const record = this.#records.get(input.threadId); + if (record === undefined || record.session.sessionId !== input.sessionId) return null; + if (record.expiresAt <= this.#now()) { + this.#records.delete(input.threadId); + return null; + } + const candidate = createHash("sha256").update(input.token).digest(); + const expected = record.tokenHashes[input.role ?? "viewer"]; + if (expected === undefined || !timingSafeEqual(expected, candidate)) return null; + record.connected = true; + return { ...record.session, connected: true as const }; + } + + disconnect(threadId: string) { + const record = this.#records.get(threadId); + if (record !== undefined) record.connected = false; + } + + status(threadId: string) { + const record = this.#records.get(threadId); + if (record !== undefined && record.expiresAt <= this.#now()) { + this.#records.delete(threadId); + return null; + } + return record === undefined ? null : { ...record.session, connected: record.connected }; + } + + publish(input: { + threadId: string; + sessionId: string; + token: string; + type: "offer" | "answer" | "ice" | "input"; + payload: string; + role?: "viewer" | "bridge"; + }) { + if (input.payload.length === 0 || input.payload.length > 256 * 1024) return null; + const attached = this.attach(input); + if (attached === null) return null; + const record = this.#records.get(input.threadId)!; + record.sequence += 1; + record.messages.push({ + sequence: record.sequence, + sender: input.role ?? "viewer", + type: input.type, + payload: input.payload, + }); + if (record.messages.length > 256) record.messages.splice(0, record.messages.length - 256); + return { sequence: record.sequence }; + } + + messagesAfter(input: { + threadId: string; + sessionId: string; + token: string; + sequence: number; + role?: "viewer" | "bridge"; + }) { + if (!Number.isSafeInteger(input.sequence) || input.sequence < 0) return null; + if (this.attach(input) === null) return null; + const role = input.role ?? "viewer"; + return this.#records + .get(input.threadId)! + .messages.filter((message) => message.sequence > input.sequence && message.sender !== role); + } + + remove(threadId: string) { + this.#records.delete(threadId); + } + + purgeExpired() { + const now = this.#now(); + let purged = 0; + for (const [threadId, record] of this.#records) { + if (record.expiresAt > now) continue; + this.#records.delete(threadId); + purged += 1; + } + return purged; + } +} diff --git a/apps/server/src/sandbox/EgressPolicy.ts b/apps/server/src/sandbox/EgressPolicy.ts new file mode 100644 index 000000000000..52f07da0c850 --- /dev/null +++ b/apps/server/src/sandbox/EgressPolicy.ts @@ -0,0 +1,94 @@ +import { isIP } from "node:net"; + +export type EgressDecision = + | { readonly allowed: true } + | { readonly allowed: false; readonly reason: string }; + +const forbiddenNames = new Set([ + "localhost", + "localhost.localdomain", + "metadata", + "metadata.google.internal", +]); + +export const evaluateEgressDestination = ( + destination: string, + crossSandboxHosts: ReadonlySet = new Set(), +): EgressDecision => { + let url: URL; + try { + url = new URL(destination); + } catch { + return { allowed: false, reason: "invalid destination" }; + } + if (url.protocol !== "http:" && url.protocol !== "https:") + return { allowed: false, reason: "unsupported protocol" }; + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, ""); + if (forbiddenNames.has(hostname) || hostname.endsWith(".localhost")) + return { allowed: false, reason: "loopback destination" }; + if (hostname === "169.254.169.254" || hostname === "100.100.100.200") + return { allowed: false, reason: "metadata destination" }; + if (crossSandboxHosts.has(hostname)) + return { allowed: false, reason: "cross-sandbox destination" }; + if (isIP(hostname) !== 0 && isForbiddenIp(hostname)) + return { allowed: false, reason: "private, loopback, or link-local destination" }; + return { allowed: true }; +}; + +/** Apply the same policy after DNS resolution so public-looking names cannot + * bypass the proxy through rebinding to an internal address. */ +export const evaluateResolvedEgressDestination = async ( + destination: string, + resolve: (hostname: string) => Promise>, + crossSandboxHosts: ReadonlySet = new Set(), +): Promise => { + const initial = evaluateEgressDestination(destination, crossSandboxHosts); + if (!initial.allowed) return initial; + const hostname = new URL(destination).hostname.replace(/^\[|\]$/g, ""); + let addresses: ReadonlyArray; + try { + addresses = await resolve(hostname); + } catch { + return { allowed: false, reason: "destination resolution failed" }; + } + if (addresses.length === 0) return { allowed: false, reason: "destination did not resolve" }; + if (addresses.some(isForbiddenIp)) + return { allowed: false, reason: "destination resolved to a protected address" }; + return { allowed: true }; +}; + +const isForbiddenIp = (ip: string) => { + if (ip.includes(":")) { + const value = ip.toLowerCase(); + return ( + value === "::" || + value === "::1" || + value.startsWith("fe8") || + value.startsWith("fe9") || + value.startsWith("fea") || + value.startsWith("feb") || + value.startsWith("fc") || + value.startsWith("fd") || + value.startsWith("::ffff:127.") || + value.startsWith("::ffff:10.") || + value.startsWith("::ffff:192.168.") || + /^::ffff:172\.(1[6-9]|2\d|3[01])\./.test(value) + ); + } + const octets = ip.split(".").map(Number); + const [a, b] = octets; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b! >= 64 && b! <= 127) || + (a === 169 && b === 254) || + (a === 172 && b! >= 16 && b! <= 31) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + a! >= 224 + ); +}; diff --git a/apps/server/src/sandbox/NodeSandboxCommandExecutor.test.ts b/apps/server/src/sandbox/NodeSandboxCommandExecutor.test.ts new file mode 100644 index 000000000000..a7adaabd1578 --- /dev/null +++ b/apps/server/src/sandbox/NodeSandboxCommandExecutor.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "@effect/vitest"; +import { NodeSandboxCommandExecutor } from "./NodeSandboxCommandExecutor.ts"; + +describe("NodeSandboxCommandExecutor", () => { + it("executes argv directly and captures bounded output", async () => { + const result = await new NodeSandboxCommandExecutor().run({ + executable: process.execPath, + args: [ + "-e", + "process.stdout.write(process.argv[1]); process.stderr.write('err')", + "literal;$HOME", + ], + timeoutMs: 5_000, + }); + expect(result).toEqual({ exitCode: 0, stdout: "literal;$HOME", stderr: "err" }); + }); + + it("kills commands that exceed their deadline", async () => { + await expect( + new NodeSandboxCommandExecutor().run({ + executable: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + timeoutMs: 25, + }), + ).rejects.toThrow("timed out"); + }); +}); diff --git a/apps/server/src/sandbox/NodeSandboxCommandExecutor.ts b/apps/server/src/sandbox/NodeSandboxCommandExecutor.ts new file mode 100644 index 000000000000..6d3224f1c47d --- /dev/null +++ b/apps/server/src/sandbox/NodeSandboxCommandExecutor.ts @@ -0,0 +1,84 @@ +// @effect-diagnostics nodeBuiltinImport:off - This is the production adapter at the Node process boundary. +// @effect-diagnostics globalTimers:off - The executor owns a native process timeout and process-group kill. +import { spawn } from "node:child_process"; +import type { SandboxCommand, SandboxCommandExecutor, SandboxCommandResult } from "./types.ts"; + +const MAX_OUTPUT_BYTES = 8 * 1024 * 1024; + +export class NodeSandboxCommandExecutor implements SandboxCommandExecutor { + run(command: SandboxCommand): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command.executable, [...command.args], { + shell: false, + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + env: { PATH: process.env.PATH }, + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let pendingError: Error | undefined; + let reapTimer: NodeJS.Timeout | undefined; + const settleError = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (reapTimer !== undefined) clearTimeout(reapTimer); + reject(error); + }; + const terminate = () => { + if (child.pid === undefined) return; + try { + process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL"); + } catch { + /* Process already exited. */ + } + }; + const failAfterClose = (error: Error) => { + if (pendingError !== undefined) return; + pendingError = error; + terminate(); + reapTimer = setTimeout(() => settleError(error), 5_000); + }; + const timer = setTimeout( + () => failAfterClose(new Error(`sandbox command timed out after ${command.timeoutMs}ms`)), + command.timeoutMs, + ); + const collect = (chunks: Buffer[], current: number, chunk: Buffer) => { + if (current + chunk.length > MAX_OUTPUT_BYTES) { + failAfterClose(new Error(`sandbox command output exceeded ${MAX_OUTPUT_BYTES} bytes`)); + return current; + } + chunks.push(chunk); + return current + chunk.length; + }; + child.stdout.on("data", (chunk: Buffer) => { + stdoutBytes = collect(stdout, stdoutBytes, chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderrBytes = collect(stderr, stderrBytes, chunk); + }); + child.once("error", (error) => { + settleError(error); + }); + child.once("close", (code) => { + clearTimeout(timer); + if (reapTimer !== undefined) clearTimeout(reapTimer); + if (!settled) { + settled = true; + if (pendingError !== undefined) reject(pendingError); + else + resolve({ + exitCode: code ?? -1, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + }); + } + }); + if (command.stdin === undefined) child.stdin.end(); + else child.stdin.end(command.stdin); + }); + } +} diff --git a/apps/server/src/sandbox/SandboxProviderProcess.test.ts b/apps/server/src/sandbox/SandboxProviderProcess.test.ts new file mode 100644 index 000000000000..69b918835ed0 --- /dev/null +++ b/apps/server/src/sandbox/SandboxProviderProcess.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "@effect/vitest"; +import { SandboxId, ThreadId } from "@t3tools/contracts"; +import { + bindSandboxProviderTarget, + makeSandboxProviderBindingOwner, + sandboxProviderInvocation, + sandboxProviderTarget, + unbindSandboxProviderTarget, +} from "./SandboxProviderProcess.ts"; + +const target = { + kind: "sandbox" as const, + threadId: ThreadId.make("thread-provider-process"), + sandboxId: SandboxId.make("sandbox-a"), + runtimeRef: "t3-thread-project-thread", + runtime: "podman" as const, + workspaceCwd: "/workspace/repo", +}; + +describe("SandboxProviderProcess", () => { + it("keeps credential values out of process argv", () => { + const invocation = sandboxProviderInvocation(target, "codex", ["app-server"], undefined, { + HTTPS_PROXY: "http://credential-proxy.example:8080", + }); + expect(invocation.args).toContain("HTTPS_PROXY"); + expect(invocation.args.join(" ")).not.toContain("credential-proxy.example"); + expect(invocation.args).toEqual( + expect.arrayContaining([ + "--workdir", + "/workspace/repo", + "--env", + "HTTPS_PROXY", + "--", + "t3-thread-project-thread", + "codex", + "app-server", + ]), + ); + }); + + it("fails closed instead of forwarding persistent provider credentials", () => { + expect(() => + sandboxProviderInvocation(target, "codex", [], undefined, { CODEX_TOKEN: "persistent" }), + ).toThrow("thread-scoped credential proxy"); + }); + + it("ignores arbitrary cwd and host environment while fixing sandbox HOME", () => { + const invocation = sandboxProviderInvocation(target, "codex", [], "/host/escape", { + SSH_AUTH_SOCK: "/host/agent.sock", + HOME: "/host/home", + LANG: "C.UTF-8", + }); + expect(invocation.args).toContain("/workspace/repo"); + expect(invocation.args).not.toContain("/host/escape"); + expect(invocation.args).not.toContain("SSH_AUTH_SOCK"); + expect(invocation.env.HOME).toBe("/thread-data/provider-home"); + }); + + it("rejects replacing a live binding with another sandbox generation", () => { + const owner = makeSandboxProviderBindingOwner(); + bindSandboxProviderTarget(target, owner); + expect(() => + bindSandboxProviderTarget( + { ...target, sandboxId: SandboxId.make("sandbox-b") }, + makeSandboxProviderBindingOwner(), + ), + ).toThrow("different sandbox generation"); + expect(sandboxProviderTarget(target.threadId)).toEqual(target); + unbindSandboxProviderTarget(target.threadId, owner); + expect(sandboxProviderTarget(target.threadId)).toBeUndefined(); + }); + + it("allows a new sandbox generation after the active binding is released", () => { + const firstOwner = makeSandboxProviderBindingOwner(); + const secondOwner = makeSandboxProviderBindingOwner(); + const nextTarget = { ...target, sandboxId: SandboxId.make("sandbox-b") }; + + bindSandboxProviderTarget(target, firstOwner); + unbindSandboxProviderTarget(target.threadId, firstOwner); + bindSandboxProviderTarget(nextTarget, secondOwner); + + expect(sandboxProviderTarget(target.threadId)).toEqual(nextTarget); + unbindSandboxProviderTarget(target.threadId, secondOwner); + }); +}); diff --git a/apps/server/src/sandbox/SandboxProviderProcess.ts b/apps/server/src/sandbox/SandboxProviderProcess.ts new file mode 100644 index 000000000000..8614d2d8b1ee --- /dev/null +++ b/apps/server/src/sandbox/SandboxProviderProcess.ts @@ -0,0 +1,171 @@ +// @effect-diagnostics nodeBuiltinImport:off - provider subprocesses cross the Node/container boundary. +import { spawn, type ChildProcess } from "node:child_process"; +import type { SpawnOptions, SpawnedProcess } from "@anthropic-ai/claude-agent-sdk"; +import { ChildProcess as EffectChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Effect from "effect/Effect"; +import type { SandboxExecutionTarget } from "./ThreadSandboxRuntime.ts"; +import { redeemSandboxProviderEnvironment } from "./SandboxRuntimeManager.ts"; + +export type SandboxProviderBindingOwner = symbol; +type SandboxProviderBinding = { + readonly target: SandboxExecutionTarget; + readonly owners: Set; +}; +const targets = new Map(); +const PROVIDER_ENV_ALLOWLIST = + /^(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN|CODEX_API_KEY|CODEX_TOKEN|HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY|LANG|LC_ALL|TERM)$/; +const PERSISTENT_PROVIDER_CREDENTIAL = + /^(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN|CODEX_API_KEY|CODEX_TOKEN)$/; +const SANDBOX_PROVIDER_ENV = { + HOME: "/thread-data/provider-home", + TMPDIR: "/tmp", + USER: "sandbox", +} as const; + +export function makeSandboxProviderBindingOwner(): SandboxProviderBindingOwner { + return Symbol("sandbox-provider-binding-owner"); +} + +export function bindSandboxProviderTarget( + target: SandboxExecutionTarget, + owner: SandboxProviderBindingOwner, +): void { + const current = targets.get(target.threadId); + if (current !== undefined && current.target.sandboxId !== target.sandboxId) { + throw new Error(`thread ${target.threadId} is already bound to a different sandbox generation`); + } + if (current !== undefined) { + current.owners.add(owner); + return; + } + targets.set(target.threadId, { target, owners: new Set([owner]) }); +} + +export function unbindSandboxProviderTarget( + threadId: string, + owner: SandboxProviderBindingOwner, +): void { + const current = targets.get(threadId); + if (current === undefined) return; + current.owners.delete(owner); + if (current.owners.size === 0) targets.delete(threadId); +} + +export function unbindAllSandboxProviderTargets(owner: SandboxProviderBindingOwner): void { + for (const [threadId, binding] of targets) { + binding.owners.delete(owner); + if (binding.owners.size === 0) targets.delete(threadId); + } +} + +export function sandboxProviderTarget(threadId: string): SandboxExecutionTarget | undefined { + return targets.get(threadId)?.target; +} + +function execArgs( + target: SandboxExecutionTarget, + command: string, + args: ReadonlyArray, + cwd: string | undefined, + env: Readonly>, +): string[] { + return [ + "exec", + "--interactive", + "--user", + "1000:1000", + "--workdir", + target.workspaceCwd, + ...Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : ["--env", key])), + "--", + target.runtimeRef, + command, + ...args, + ]; +} + +export function sandboxProviderInvocation( + target: SandboxExecutionTarget, + command: string, + args: ReadonlyArray, + cwd: string | undefined, + env: Readonly>, +) { + void cwd; + const requestedEnvironment = { + ...SANDBOX_PROVIDER_ENV, + ...Object.fromEntries( + Object.entries(env).filter( + (entry): entry is [string, string] => + entry[1] !== undefined && PROVIDER_ENV_ALLOWLIST.test(entry[0]), + ), + ), + }; + const persistentCredential = Object.keys(requestedEnvironment).find((key) => + PERSISTENT_PROVIDER_CREDENTIAL.test(key), + ); + if (persistentCredential !== undefined) { + throw new Error( + `direct forwarding of persistent provider credential ${persistentCredential} is denied; use a thread-scoped credential proxy`, + ); + } + const forwardedEnvironment = redeemSandboxProviderEnvironment( + target.threadId, + requestedEnvironment, + ); + return { + executable: target.runtime, + args: execArgs(target, command, args, cwd, forwardedEnvironment), + env: { PATH: process.env.PATH, ...forwardedEnvironment } as Record, + } as const; +} + +export function makeSandboxChildProcessSpawner( + target: SandboxExecutionTarget, + hostSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"], +): ChildProcessSpawner.ChildProcessSpawner["Service"] { + return ChildProcessSpawner.make((command) => { + if (command._tag !== "StandardCommand") { + return Effect.die( + new Error("sandbox provider pipelines are unsupported; host fallback denied"), + ); + } + const invocation = sandboxProviderInvocation( + target, + command.command, + command.args, + command.options.cwd, + command.options.env ?? {}, + ); + return hostSpawner.spawn( + EffectChildProcess.make(invocation.executable, invocation.args, { + ...command.options, + cwd: undefined, + env: { ...invocation.env, PATH: invocation.env.PATH ?? "" }, + extendEnv: false, + shell: false, + }), + ); + }); +} + +export function spawnClaudeInSandbox( + target: SandboxExecutionTarget, + options: SpawnOptions, +): SpawnedProcess { + const invocation = sandboxProviderInvocation( + target, + options.command, + options.args, + options.cwd, + options.env, + ); + const child: ChildProcess = spawn(invocation.executable, invocation.args, { + shell: false, + stdio: ["pipe", "pipe", "pipe"], + env: invocation.env, + signal: options.signal, + windowsHide: true, + }); + return child as SpawnedProcess; +} diff --git a/apps/server/src/sandbox/SandboxRuntimeManager.ts b/apps/server/src/sandbox/SandboxRuntimeManager.ts new file mode 100644 index 000000000000..ec15cc60290e --- /dev/null +++ b/apps/server/src/sandbox/SandboxRuntimeManager.ts @@ -0,0 +1,395 @@ +// @effect-diagnostics nodeBuiltinImport:off - artifact export is an explicit Node filesystem boundary. +import type { + SandboxProvisionInput, + SandboxReady, + SandboxArtifactExport, + SandboxReconcileResult, + SandboxUsageSample, + SandboxExecInput, + SandboxCommandResult, +} from "./types.ts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { ContainerSandboxBackend } from "./ContainerSandboxBackend.ts"; +import { NodeSandboxCommandExecutor } from "./NodeSandboxCommandExecutor.ts"; +import { ThreadDesktopRuntime } from "./ThreadDesktopRuntime.ts"; +import { ThreadServiceStackRuntime, type ThreadServiceDeclaration } from "./ThreadServiceStack.ts"; +import { ThreadCredentialBroker } from "./CredentialBroker.ts"; +import { desktopGateway } from "./DesktopGatewayService.ts"; +import { ThreadPreviewProxy } from "./ThreadPreviewProxy.ts"; +import { ServerConfig } from "../config.ts"; + +const credentialBroker = new ThreadCredentialBroker(); + +/** One-shot credential boundary used immediately before provider process spawn. */ +export function redeemSandboxProviderEnvironment( + threadId: string, + environment: Readonly>, +): Record { + const redeemed: Record = {}; + for (const [scope, value] of Object.entries(environment)) { + if (value === undefined) continue; + const grant = credentialBroker.issue({ threadId, scope, value, ttlMs: 60_000 }); + const result = credentialBroker.redeem({ ...grant, threadId, scope }); + if (result === null) throw new Error(`credential grant for ${scope} was denied`); + redeemed[scope] = result; + } + return redeemed; +} + +export type ManagedSandboxReady = SandboxReady & { + readonly desktopSessionId: string; + readonly desktopStreamPath: string; + readonly services: ReadonlyArray<{ + readonly name: string; + readonly internalPorts: ReadonlyArray; + }>; +}; + +export interface SandboxRuntimeManagerShape { + readonly exec?: ( + runtime: "docker" | "podman", + threadId: string, + input: SandboxExecInput, + ) => Effect.Effect; + readonly provision: ( + input: SandboxProvisionInput & { services?: ReadonlyArray }, + ) => Effect.Effect; + readonly exportBranch: ( + runtime: "docker" | "podman", + threadId: string, + ) => Effect.Effect; + readonly stop: ( + runtime: "docker" | "podman", + threadId: string, + ) => Effect.Effect; + readonly reconcile: ( + runtime: "docker" | "podman", + expectedThreadIds: ReadonlySet, + ) => Effect.Effect; + readonly sampleUsage: ( + runtime: "docker" | "podman", + threadId: string, + ) => Effect.Effect; + readonly recoverPreview: ( + runtime: "docker" | "podman", + threadId: string, + hostname: string, + ports: ReadonlyArray, + ) => Effect.Effect; + readonly revokeCredentials: (threadId: string) => Effect.Effect; +} + +export class SandboxManagerError extends Schema.TaggedErrorClass()( + "SandboxManagerError", + { + message: Schema.String, + cause: Schema.optional(Schema.Unknown), + }, +) {} + +const makeManager = (artifactRoot: string | undefined): SandboxRuntimeManagerShape => { + const executor = new NodeSandboxCommandExecutor(); + const runtimes = new Map< + "docker" | "podman", + { + backend: ContainerSandboxBackend; + desktop: ThreadDesktopRuntime; + services: ThreadServiceStackRuntime; + previews: ThreadPreviewProxy; + } + >(); + const teardownHooks = new Map>(); + const get = (runtime: "docker" | "podman") => { + const existing = runtimes.get(runtime); + if (existing) return existing; + const backend = new ContainerSandboxBackend(runtime, executor); + const value = { + backend, + desktop: new ThreadDesktopRuntime(backend), + services: new ThreadServiceStackRuntime(runtime, executor), + previews: new ThreadPreviewProxy(runtime, executor, desktopGateway.previews), + }; + runtimes.set(runtime, value); + return value; + }; + const attempt = (run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (cause) => + new SandboxManagerError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + return { + exec: Effect.fn("SandboxRuntimeManager.exec")(function* (runtime, threadId, input) { + return yield* attempt(() => get(runtime).backend.exec(threadId, input)); + }), + provision: Effect.fn("SandboxRuntimeManager.provision")(function* (input) { + const previewImage = process.env.T3_SANDBOX_PREVIEW_PROXY_IMAGE?.trim(); + if (!previewImage) + return yield* new SandboxManagerError({ + message: + "T3_SANDBOX_PREVIEW_PROXY_IMAGE is required for the internal desktop signaling sidecar", + }); + const runtime = input.config?.runtime ?? "docker"; + if (runtime !== "docker" && runtime !== "podman") + return yield* new SandboxManagerError({ + message: `unsupported sandbox runtime: ${runtime}`, + }); + const trustedCaches = new Set( + (process.env.T3_SANDBOX_TRUSTED_CACHE_DIGESTS ?? "") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean), + ); + for (const cache of input.caches ?? []) { + if (!trustedCaches.has(cache.digest.toLowerCase())) { + return yield* new SandboxManagerError({ + message: `cache ${cache.digest} is absent from the server-trusted cache manifest`, + }); + } + } + const managed = get(runtime); + let provisionInput = input; + let seedBundle: string | undefined; + if (!/^(?:https|ssh):\/\//i.test(input.bootstrap.repositoryUrl)) { + if (artifactRoot === undefined) + return yield* new SandboxManagerError({ + message: "local repository seeding requires configured server artifact storage", + }); + const seedRoot = resolve(artifactRoot, "seeds"); + yield* attempt(() => mkdir(seedRoot, { recursive: true, mode: 0o700 })); + seedBundle = resolve( + seedRoot, + `.${createHash("sha256").update(input.bootstrap.threadId).digest("hex")}.${process.pid}.bundle`, + ); + yield* attempt(async () => { + const created = await executor.run({ + executable: "git", + args: [ + "-C", + input.bootstrap.repositoryUrl, + "bundle", + "create", + seedBundle!, + input.bootstrap.baseCommit, + ], + timeoutMs: 120_000, + }); + if (created.exitCode !== 0) + throw new Error(created.stderr || "failed to create local repository seed bundle"); + const verified = await executor.run({ + executable: "git", + args: ["bundle", "verify", seedBundle!], + timeoutMs: 60_000, + }); + if (verified.exitCode !== 0) + throw new Error(verified.stderr || "local repository seed bundle failed verification"); + }); + provisionInput = { + ...input, + bootstrap: { ...input.bootstrap, repositoryBundlePath: seedBundle }, + }; + } + const ready = yield* attempt(() => managed.backend.ensureReady(provisionInput)).pipe( + Effect.ensuring( + seedBundle === undefined + ? Effect.void + : Effect.promise(() => rm(seedBundle!, { force: true })), + ), + ); + teardownHooks.set(input.bootstrap.threadId, input.teardown ?? []); + const services = yield* attempt(() => + managed.services.start(input.bootstrap.threadId, input.services ?? [], ready.networkName), + ).pipe( + Effect.tapError(() => + Effect.promise(async () => { + teardownHooks.delete(input.bootstrap.threadId); + await managed.backend.stop(input.bootstrap.threadId).catch(() => undefined); + }), + ), + ); + desktopGateway.setServiceStatus( + input.bootstrap.threadId, + services.map((service) => ({ name: service.hostname, healthy: true })), + ); + const serviceGrants = services.flatMap((service) => { + const declaration = input.services?.find( + (candidate) => candidate.name === service.hostname, + ); + return (declaration?.generatedEnvironment ?? []).map((entry) => { + const value = service.environment[entry.key]; + if (value === undefined) + throw new Error( + `generated service credential ${service.hostname}:${entry.key} is missing`, + ); + const scope = `service:${service.hostname}:${entry.key}`; + return { + ...desktopGateway.credentials.issue({ + threadId: input.bootstrap.threadId, + scope, + value, + ttlMs: 15 * 60_000, + }), + scope, + }; + }); + }); + desktopGateway.setServiceCredentialGrants(input.bootstrap.threadId, serviceGrants); + managed.services.redactCredentials(input.bootstrap.threadId); + yield* attempt(() => + managed.previews.start(input.bootstrap.threadId, ready.networkName, previewImage), + ).pipe( + Effect.tapError(() => + Effect.promise(async () => { + await managed.previews.stop(input.bootstrap.threadId); + await managed.services.stop(input.bootstrap.threadId); + teardownHooks.delete(input.bootstrap.threadId); + await managed.backend.stop(input.bootstrap.threadId).catch(() => undefined); + }), + ), + ); + desktopGateway.setPreviewProxy(managed.previews); + for (const port of input.previewPorts ?? []) { + desktopGateway.registerPreviewRoute({ + routeId: `${createHash("sha256").update(`${input.bootstrap.threadId}\0${port}`).digest("hex").slice(0, 24)}`, + threadId: input.bootstrap.threadId, + hostname: ready.containerName, + internalPort: port, + token: randomBytes(32).toString("base64url"), + }); + } + const desktop = yield* attempt(() => + managed.desktop.start( + input.bootstrap.threadId, + desktopGateway.bridge(input.bootstrap.threadId), + managed.previews.internalSignalingOrigin(input.bootstrap.threadId), + ), + ).pipe( + Effect.tapError(() => + Effect.promise(async () => { + await managed.services.stop(input.bootstrap.threadId); + await managed.previews.stop(input.bootstrap.threadId); + teardownHooks.delete(input.bootstrap.threadId); + await managed.backend.stop(input.bootstrap.threadId).catch(() => undefined); + }), + ), + ); + const automation = managed.desktop.automationTarget(input.bootstrap.threadId); + desktopGateway.setAutomationTarget( + input.bootstrap.threadId, + ready.containerName, + automation.profilePath, + ); + return { + ...ready, + desktopSessionId: desktop.sessionId, + desktopStreamPath: desktop.signalingPath, + services: services.map((service) => ({ + name: service.hostname, + internalPorts: service.internalPorts, + })), + }; + }), + exportBranch: (runtime, threadId) => + attempt(async () => { + if (artifactRoot === undefined) + throw new Error("sandbox artifact storage requires the configured server runtime layer"); + await mkdir(artifactRoot, { recursive: true, mode: 0o700 }); + const name = createHash("sha256").update(threadId).digest("hex"); + const bundleTemporary = resolve(artifactRoot, `.${name}.${process.pid}.bundle.tmp`); + const bundleDestination = resolve(artifactRoot, `${name}.bundle`); + const manifestTemporary = resolve(artifactRoot, `.${name}.${process.pid}.json.tmp`); + const manifestDestination = resolve(artifactRoot, `${name}.json`); + try { + const result = await get(runtime).backend.exportBranch(threadId); + await get(runtime).backend.exportBundle(threadId, bundleTemporary); + const bundleSha256 = createHash("sha256") + .update(await readFile(bundleTemporary)) + .digest("hex"); + await writeFile( + manifestTemporary, + JSON.stringify({ threadId, bundle: `${name}.bundle`, bundleSha256, ...result }), + { mode: 0o600, flag: "wx" }, + ); + await rename(bundleTemporary, bundleDestination); + await rename(manifestTemporary, manifestDestination); + return { ...result, artifactId: name, bundleSha256 }; + } finally { + await Promise.all([ + rm(bundleTemporary, { force: true }), + rm(manifestTemporary, { force: true }), + ]); + } + }), + stop: Effect.fn("SandboxRuntimeManager.stop")(function* (runtime, threadId) { + const managed = get(runtime); + yield* Effect.promise(() => managed.desktop.stop(threadId)); + yield* Effect.promise(() => managed.previews.stop(threadId)); + yield* Effect.promise(() => managed.services.stop(threadId)); + credentialBroker.revokeThread(threadId); + desktopGateway.removeThread(threadId); + yield* attempt(() => managed.backend.stop(threadId, teardownHooks.get(threadId) ?? [])); + teardownHooks.delete(threadId); + }), + reconcile: (runtime, expectedThreadIds) => + attempt(async () => { + const managed = get(runtime); + const result = await managed.backend.reconcile({ expectedThreadIds, removeOrphans: true }); + for (const threadId of result.activeThreadIds) { + const desktop = await managed.desktop.recover(threadId); + if (desktop === null) { + desktopGateway.setCapabilityFailure(threadId, ["desktop-session"]); + } else { + const automation = managed.desktop.automationTarget(threadId); + const runtimeRef = managed.backend.runtimeRef(threadId); + desktopGateway.setAutomationTarget( + threadId, + runtimeRef ?? automation.endpoint, + automation.profilePath, + ); + } + } + return result; + }), + sampleUsage: (runtime, threadId) => attempt(() => get(runtime).backend.sampleUsage(threadId)), + recoverPreview: (runtime, threadId, hostname, ports) => + attempt(async () => { + if (ports.length === 0) return false; + const previews = get(runtime).previews; + if (!(await previews.recover(threadId))) return false; + desktopGateway.setPreviewProxy(previews); + for (const port of ports) + desktopGateway.registerPreviewRoute({ + routeId: createHash("sha256").update(`${threadId}\0${port}`).digest("hex").slice(0, 24), + threadId, + hostname, + internalPort: port, + token: randomBytes(32).toString("base64url"), + }); + return true; + }), + revokeCredentials: (threadId) => Effect.sync(() => credentialBroker.revokeThread(threadId)), + }; +}; + +const configuredArtifactRoot = process.env.T3_SANDBOX_ARTIFACT_DIR; +const defaultManager = makeManager( + configuredArtifactRoot === undefined ? undefined : resolve(configuredArtifactRoot), +); +export class SandboxRuntimeManager extends Context.Reference( + "@awtprod/command-center/sandbox/SandboxRuntimeManager", + { defaultValue: () => defaultManager }, +) {} + +export const SandboxRuntimeManagerLive = Layer.effect( + SandboxRuntimeManager, + Effect.map(ServerConfig, (config) => makeManager(resolve(config.stateDir, "sandbox-artifacts"))), +); diff --git a/apps/server/src/sandbox/ThreadDesktopRuntime.ts b/apps/server/src/sandbox/ThreadDesktopRuntime.ts new file mode 100644 index 000000000000..92b6c94b4c76 --- /dev/null +++ b/apps/server/src/sandbox/ThreadDesktopRuntime.ts @@ -0,0 +1,218 @@ +import type { ThreadSandboxBackend } from "./types.ts"; +import { + REQUIRED_DESKTOP_BINARIES, + desktopSessionForThread, + type DesktopCapability, + type ThreadDesktopSession, +} from "./DesktopSession.ts"; + +export class ThreadDesktopRuntime { + readonly #backend: ThreadSandboxBackend; + readonly #started = new Map(); + + constructor(backend: ThreadSandboxBackend) { + this.#backend = backend; + } + + async detect(threadId: string): Promise { + const missing: Array = []; + for (const binary of REQUIRED_DESKTOP_BINARIES) { + const available = await this.#backend + .exec(threadId, { + executable: "sh", + args: ["-lc", 'command -v -- "$1" >/dev/null 2>&1', "sh", binary], + timeoutMs: 5_000, + }) + .then( + () => true, + () => false, + ); + if (!available) missing.push(binary); + } + return { ready: missing.length === 0, missing }; + } + + async start( + threadId: string, + bridgeCredential: { sessionId: string; token: string }, + signalingOrigin: string, + ) { + const existing = this.#started.get(threadId); + if (existing !== undefined) return existing; + const capability = await this.detect(threadId); + if (!capability.ready) + throw new Error( + `desktop image is missing required capabilities: ${capability.missing.join(", ")}`, + ); + const session = desktopSessionForThread(threadId); + const signalingUrl = resolveSignalingUrl(signalingOrigin, session.signalingPath); + try { + await this.#backend.exec(threadId, { + executable: "mkdir", + args: ["-p", session.browserProfilePath], + timeoutMs: 5_000, + }); + await this.#backend.exec(threadId, { + executable: "sh", + args: ["-lc", "umask 077; cat > /tmp/t3-desktop-webrtc-auth.json"], + stdin: JSON.stringify({ ...bridgeCredential, role: "bridge" }), + timeoutMs: 5_000, + }); + await this.#backend.exec(threadId, { + executable: "tmux", + args: ["new-session", "-A", "-d", "-s", session.tmuxSession, "-n", "terminal"], + env: { DISPLAY: session.display }, + timeoutMs: 10_000, + }); + await this.#tmux(threadId, session, "xserver", "Xvfb", [ + session.display, + "-screen", + "0", + `${session.resolution}x24`, + "-nolisten", + "tcp", + ]); + await this.#backend.exec(threadId, { + executable: "sh", + args: [ + "-lc", + 'i=0; while [ $i -lt 100 ]; do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && exit 0; i=$((i+1)); sleep .1; done; exit 1', + ], + env: { DISPLAY: session.display }, + timeoutMs: 12_000, + }); + await this.#tmux(threadId, session, "desktop", "startxfce4", []); + await this.#tmux(threadId, session, "browser", "chromium", [ + `--user-data-dir=${session.browserProfilePath}`, + "--remote-debugging-address=0.0.0.0", + "--remote-debugging-port=9222", + "--no-first-run", + "--disable-dev-shm-usage", + ]); + await this.#tmux(threadId, session, "editor", "code", ["--reuse-window", "/workspace/repo"]); + await this.#tmux(threadId, session, "webrtc", "t3-desktop-webrtc", [ + "--display", + session.display, + "--resolution", + session.resolution, + "--signaling-url", + signalingUrl, + "--auth-file", + "/tmp/t3-desktop-webrtc-auth.json", + ]); + await this.#backend.exec(threadId, { + executable: "sh", + args: [ + "-lc", + "i=0; while [ $i -lt 100 ]; do wget -qO- http://127.0.0.1:9222/json/version >/dev/null && exit 0; i=$((i+1)); sleep .1; done; exit 1", + ], + timeoutMs: 12_000, + }); + } catch (error) { + await this.stop(threadId); + throw error; + } + this.#started.set(threadId, session); + return session; + } + + async stop(threadId: string) { + const session = this.#started.get(threadId) ?? desktopSessionForThread(threadId); + await this.#backend + .exec(threadId, { + executable: "tmux", + args: ["kill-session", "-t", session.tmuxSession], + timeoutMs: 10_000, + }) + .catch(() => undefined); + this.#started.delete(threadId); + await this.#backend + .exec(threadId, { + executable: "rm", + args: ["-f", "/tmp/t3-desktop-webrtc-auth.json"], + timeoutMs: 5_000, + }) + .catch(() => undefined); + } + + automationTarget(threadId: string) { + const session = this.#started.get(threadId); + if (session === undefined) throw new Error(`desktop for thread ${threadId} is not ready`); + return { + threadId, + endpoint: session.browserAutomationEndpoint, + profilePath: session.browserProfilePath, + }; + } + + async recover(threadId: string) { + const session = desktopSessionForThread(threadId); + const tmux = await this.#backend + .exec(threadId, { + executable: "tmux", + args: ["has-session", "-t", session.tmuxSession], + timeoutMs: 5_000, + }) + .then( + () => true, + () => false, + ); + const cdp = await this.#backend + .exec(threadId, { + executable: "wget", + args: ["-qO-", "http://127.0.0.1:9222/json/version"], + timeoutMs: 5_000, + }) + .then( + () => true, + () => false, + ); + if (!tmux || !cdp) return null; + this.#started.set(threadId, session); + return session; + } + + async #tmux( + threadId: string, + session: ThreadDesktopSession, + windowName: string, + executable: string, + args: ReadonlyArray, + ) { + const result = await this.#backend.exec(threadId, { + executable: "tmux", + args: [ + "new-window", + "-d", + "-t", + session.tmuxSession, + "-n", + windowName, + "--", + executable, + ...args, + ], + env: { DISPLAY: session.display }, + timeoutMs: 10_000, + }); + if (result.exitCode !== 0) + throw new Error(`failed to start desktop ${windowName}: ${result.stderr}`); + } +} + +export const resolveSignalingUrl = (origin: string, path: string) => { + let url: URL; + try { + url = new URL(path, origin); + } catch { + throw new Error("desktop signaling origin is invalid"); + } + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + url.origin !== new URL(origin).origin + ) + throw new Error("desktop signaling origin must be an absolute HTTP(S) origin"); + if (["localhost", "127.0.0.1", "::1", "0.0.0.0"].includes(url.hostname.replace(/^\[|\]$/g, ""))) + throw new Error("desktop signaling origin must be reachable from the sandbox network"); + return url.toString(); +}; diff --git a/apps/server/src/sandbox/ThreadPreviewProxy.ts b/apps/server/src/sandbox/ThreadPreviewProxy.ts new file mode 100644 index 000000000000..357b545296fa --- /dev/null +++ b/apps/server/src/sandbox/ThreadPreviewProxy.ts @@ -0,0 +1,250 @@ +import type { SandboxCommand, SandboxCommandExecutor } from "./types.ts"; +import { createHash } from "node:crypto"; +import { AuthenticatedPreviewRouter } from "./AuthenticatedPreviewRouter.ts"; + +const MAX_BODY_BYTES = 8 * 1024 * 1024; +const REQUEST_TIMEOUT_MS = 30_000; + +export type PreviewProxyRequest = { + readonly routeId: string; + readonly threadId: string; + readonly token: string; + readonly method: string; + readonly path: string; + readonly headers: Readonly>; + readonly body?: Uint8Array; +}; + +export class ThreadPreviewProxy { + readonly #runtime: "docker" | "podman"; + readonly #executor: SandboxCommandExecutor; + readonly #router: AuthenticatedPreviewRouter; + readonly #containers = new Map(); + + constructor( + runtime: "docker" | "podman", + executor: SandboxCommandExecutor, + router: AuthenticatedPreviewRouter, + ) { + this.#runtime = runtime; + this.#executor = executor; + this.#router = router; + } + + async start(threadId: string, networkName: string, image: string) { + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(networkName)) + throw new Error("invalid sandbox network name"); + if (!/^[a-z0-9][a-z0-9._/-]{0,200}@sha256:[a-f0-9]{64}$/i.test(image)) + throw new Error("preview proxy image must be pinned by sha256 digest"); + const name = previewContainerName(threadId); + await this.#mustRun({ + executable: this.#runtime, + args: [ + "run", + "--detach", + "--name", + name, + "--network", + networkName, + "--label", + "com.t3tools.sandbox.managed=true", + "--label", + `com.t3tools.sandbox.thread=${threadId}`, + "--read-only", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--pids-limit", + "64", + image, + "t3-preview-bridge", + "serve", + "--stdio", + "--listen", + "0.0.0.0:8080", + "--signaling-relay", + ], + timeoutMs: 60_000, + }); + this.#containers.set(threadId, name); + } + + async request(input: PreviewProxyRequest) { + const target = this.#router.resolve(input); + const container = this.#containers.get(input.threadId); + if (target === null || container === undefined) + throw new Error("preview route is not authorized or ready"); + if (!/^(GET|HEAD|OPTIONS|POST|PUT|PATCH|DELETE)$/i.test(input.method)) + throw new Error("unsupported preview method"); + if (!input.path.startsWith("/") || input.path.startsWith("//") || input.path.includes("\\")) + throw new Error("invalid preview path"); + if ((input.body?.byteLength ?? 0) > MAX_BODY_BYTES) + throw new Error("preview request body is too large"); + const safeHeaders = Object.fromEntries( + Object.entries(input.headers).filter( + ([name]) => + ![ + "authorization", + "cookie", + "dpop", + "proxy-authorization", + "host", + "connection", + "upgrade", + ].includes(name.toLowerCase()), + ), + ); + const payload = JSON.stringify({ + target, + method: input.method.toUpperCase(), + path: input.path, + headers: safeHeaders, + bodyBase64: input.body === undefined ? "" : Buffer.from(input.body).toString("base64"), + maxResponseBytes: MAX_BODY_BYTES, + timeoutMs: REQUEST_TIMEOUT_MS, + }); + const result = await this.#mustRun({ + executable: this.#runtime, + args: ["exec", "--interactive", container, "t3-preview-bridge", "request"], + stdin: payload, + timeoutMs: REQUEST_TIMEOUT_MS + 2_000, + }); + if (Buffer.byteLength(result.stdout) > MAX_BODY_BYTES * 2) + throw new Error("preview response exceeded limit"); + const decoded: unknown = JSON.parse(result.stdout); + if (!isBridgeResponse(decoded)) throw new Error("preview bridge returned a malformed response"); + const body = Buffer.from(decoded.bodyBase64, "base64"); + if (body.byteLength > MAX_BODY_BYTES) throw new Error("preview response body is too large"); + return { status: decoded.status, headers: decoded.headers, body: new Uint8Array(body) }; + } + + webSocketCommand( + input: Pick, + ) { + const target = this.#router.resolve(input); + const container = this.#containers.get(input.threadId); + if (target === null || container === undefined) return null; + if (!input.path.startsWith("/") || input.path.startsWith("//") || input.path.includes("\\")) + return null; + return { + executable: this.#runtime, + args: ["exec", "--interactive", container, "t3-preview-bridge", "websocket-framed"], + handshake: JSON.stringify({ + target, + path: input.path, + headers: Object.fromEntries( + Object.entries(input.headers).filter( + ([name]) => + !["authorization", "cookie", "dpop", "proxy-authorization", "host"].includes( + name.toLowerCase(), + ), + ), + ), + maxFrameBytes: 1024 * 1024, + idleTimeoutMs: 60_000, + }), + }; + } + + async automate(input: { + routeId: string; + threadId: string; + token: string; + operation: string; + payload: unknown; + timeoutMs: number; + }) { + const target = this.#router.resolve(input); + const container = this.#containers.get(input.threadId); + if (target === null || container === undefined) + throw new Error("browser automation target is not authorized or ready"); + const timeoutMs = Math.min(Math.max(input.timeoutMs, 1_000), 60_000); + const result = await this.#mustRun({ + executable: this.#runtime, + args: ["exec", "--interactive", container, "t3-preview-bridge", "cdp-automation"], + stdin: JSON.stringify({ + target, + operation: input.operation, + input: input.payload, + timeoutMs, + rewriteWebSocketUrls: true, + }), + timeoutMs: timeoutMs + 2_000, + }); + if (Buffer.byteLength(result.stdout) > 8 * 1024 * 1024) + throw new Error("browser automation result exceeded limit"); + return JSON.parse(result.stdout) as unknown; + } + + async signal(threadId: string, payload: unknown) { + const container = this.#containers.get(threadId); + if (container === undefined) throw new Error("thread signaling sidecar is not ready"); + const encoded = JSON.stringify(payload); + if (Buffer.byteLength(encoded) > 256 * 1024) + throw new Error("signaling payload exceeded limit"); + const result = await this.#mustRun({ + executable: this.#runtime, + args: ["exec", "--interactive", container, "t3-preview-bridge", "signal"], + stdin: encoded, + timeoutMs: 10_000, + }); + if (Buffer.byteLength(result.stdout) > 512 * 1024) + throw new Error("signaling response exceeded limit"); + return JSON.parse(result.stdout) as unknown; + } + + async stop(threadId: string) { + const container = this.#containers.get(threadId); + if (container === undefined) return; + this.#containers.delete(threadId); + this.#router.removeThread(threadId); + await this.#executor + .run({ executable: this.#runtime, args: ["rm", "--force", container], timeoutMs: 30_000 }) + .catch(() => undefined); + } + + async recover(threadId: string) { + const name = previewContainerName(threadId); + const result = await this.#executor.run({ + executable: this.#runtime, + args: ["inspect", "--format", "{{.State.Running}}", name], + timeoutMs: 10_000, + }); + if (result.exitCode !== 0 || result.stdout.trim() !== "true") return false; + this.#containers.set(threadId, name); + return true; + } + + internalSignalingOrigin(threadId: string) { + if (!this.#containers.has(threadId)) throw new Error("thread signaling sidecar is not ready"); + return `http://${previewContainerName(threadId)}:8080`; + } + + async #mustRun(command: SandboxCommand) { + const result = await this.#executor.run(command); + if (result.exitCode !== 0) throw new Error(result.stderr || "preview proxy command failed"); + return result; + } +} + +const isBridgeResponse = ( + value: unknown, +): value is { status: number; headers: Record; bodyBase64: string } => { + if (typeof value !== "object" || value === null) return false; + const response = value as Record; + return ( + Number.isInteger(response.status) && + Number(response.status) >= 100 && + Number(response.status) <= 599 && + typeof response.bodyBase64 === "string" && + typeof response.headers === "object" && + response.headers !== null && + Object.entries(response.headers).every( + ([key, item]) => key.length <= 128 && typeof item === "string" && item.length <= 8192, + ) + ); +}; + +const previewContainerName = (threadId: string) => + `t3-preview-${createHash("sha256").update(threadId).digest("hex").slice(0, 24)}`; diff --git a/apps/server/src/sandbox/ThreadSandboxRuntime.ts b/apps/server/src/sandbox/ThreadSandboxRuntime.ts new file mode 100644 index 000000000000..6f6336b99f39 --- /dev/null +++ b/apps/server/src/sandbox/ThreadSandboxRuntime.ts @@ -0,0 +1,75 @@ +import type { OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export type SandboxExecutionTarget = { + readonly kind: "sandbox"; + readonly threadId: ThreadId; + readonly sandboxId: string; + readonly runtimeRef: string; + readonly runtime: "docker" | "podman"; + readonly workspaceCwd: string; +}; + +export type LegacyHostExecutionTarget = { + readonly kind: "legacy-host"; + readonly cwd: string; +}; + +export type ProviderExecutionTarget = SandboxExecutionTarget | LegacyHostExecutionTarget; + +export class ThreadSandboxNotReadyError extends Schema.TaggedErrorClass()( + "ThreadSandboxNotReadyError", + { threadId: Schema.String, detail: Schema.String }, +) {} + +export interface ThreadSandboxRuntimeShape { + readonly ensureReady: ( + thread: OrchestrationThread, + legacyCwd: string | undefined, + ) => Effect.Effect; +} + +const defaultRuntime: ThreadSandboxRuntimeShape = { + ensureReady: Effect.fn("ThreadSandboxRuntime.ensureReady")(function* (thread, legacyCwd) { + if (thread.sandbox == null) { + void legacyCwd; + return yield* new ThreadSandboxNotReadyError({ + threadId: thread.id, + detail: "Thread has no isolated sandbox; host execution is denied.", + }); + } + if ( + thread.sandbox.lifecycle !== "ready" || + thread.sandbox.sandboxId === undefined || + thread.sandbox.runtimeRef === undefined || + (thread.sandbox.runtime !== "docker" && thread.sandbox.runtime !== "podman") + ) { + return yield* new ThreadSandboxNotReadyError({ + threadId: thread.id, + detail: `Sandbox is ${thread.sandbox.lifecycle}, not ready.`, + }); + } + if (thread.sandbox.controller.kind === "human") { + return yield* new ThreadSandboxNotReadyError({ + threadId: thread.id, + detail: "Sandbox is controlled by an active human takeover lease.", + }); + } + return { + kind: "sandbox", + threadId: thread.id, + sandboxId: thread.sandbox.sandboxId, + runtimeRef: thread.sandbox.runtimeRef, + runtime: thread.sandbox.runtime, + workspaceCwd: "/workspace/repo", + } as const; + }), +}; + +/** Injectable now; server composition can replace this reference with the backend-backed runtime. */ +export class ThreadSandboxRuntime extends Context.Reference( + "@awtprod/command-center/sandbox/ThreadSandboxRuntime", + { defaultValue: () => defaultRuntime }, +) {} diff --git a/apps/server/src/sandbox/ThreadServiceStack.ts b/apps/server/src/sandbox/ThreadServiceStack.ts new file mode 100644 index 000000000000..26134fd7552c --- /dev/null +++ b/apps/server/src/sandbox/ThreadServiceStack.ts @@ -0,0 +1,312 @@ +// @effect-diagnostics globalTimers:off - bounded container health polling at the runtime boundary. +import { createHash, randomBytes } from "node:crypto"; +import type { SandboxCommandExecutor } from "./types.ts"; + +export type ThreadServiceDeclaration = { + readonly name: string; + readonly image: string; + readonly internalPorts?: ReadonlyArray; + readonly environment?: Readonly>; + readonly volumes?: ReadonlyArray<{ readonly name: string; readonly target: string }>; + readonly healthCheck?: { + readonly executable: string; + readonly args?: ReadonlyArray; + readonly intervalSeconds?: number; + readonly timeoutSeconds?: number; + readonly retries?: number; + }; + readonly generatedEnvironment?: ReadonlyArray<{ + readonly key: string; + readonly kind: "database-name" | "username" | "password"; + }>; +}; + +export type ThreadServiceInstance = { + readonly name: string; + readonly hostname: string; + readonly networkName: string; + readonly image: string; + readonly internalPorts: ReadonlyArray; + readonly hostPorts: ReadonlyArray; + readonly environment: Readonly>; + readonly volumes: ReadonlyArray<{ readonly name: string; readonly target: string }>; +}; + +export const planThreadServiceStack = ( + threadId: string, + declarations: ReadonlyArray, +): ReadonlyArray => { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(threadId)) throw new Error("invalid thread id"); + const suffix = createHash("sha256").update(threadId).digest("hex").slice(0, 16); + const networkName = `t3-net-${suffix}`; + const names = new Set(); + return declarations.map((service) => { + if (!/^[a-z][a-z0-9-]{0,62}$/.test(service.name) || names.has(service.name)) + throw new Error(`invalid or duplicate service name: ${service.name}`); + names.add(service.name); + if (!/^[a-z0-9][a-z0-9._/-]{0,200}@sha256:[a-f0-9]{64}$/i.test(service.image)) + throw new Error(`service image must be pinned by sha256 digest: ${service.name}`); + const ports = service.internalPorts ?? []; + if (ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) + throw new Error(`invalid internal port for ${service.name}`); + return { + name: `t3-svc-${suffix}-${service.name}`, + hostname: service.name, + networkName, + image: service.image, + internalPorts: [...ports], + hostPorts: [], + environment: { ...(service.environment ?? {}), T3_THREAD_ID: threadId }, + volumes: (service.volumes ?? []).map((volume) => ({ + name: `t3-vol-${suffix}-${volume.name}`, + target: volume.target, + })), + }; + }); +}; + +export class ThreadServiceStackRuntime { + readonly #executor: SandboxCommandExecutor; + readonly #runtime: "docker" | "podman"; + readonly #active = new Map>(); + + constructor(runtime: "docker" | "podman", executor: SandboxCommandExecutor) { + this.#runtime = runtime; + this.#executor = executor; + } + + async start( + threadId: string, + declarations: ReadonlyArray, + networkName: string, + ) { + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(networkName)) + throw new Error("invalid sandbox network name"); + const materialized = declarations.map((service) => ({ + ...service, + environment: { + ...(service.environment ?? {}), + ...Object.fromEntries( + (service.generatedEnvironment ?? []).map((entry) => [ + entry.key, + generatedServiceValue(threadId, service.name, entry), + ]), + ), + }, + })); + const services = planThreadServiceStack(threadId, materialized).map((service) => ({ + ...service, + networkName, + })); + const started: Array = []; + this.#active.set(threadId, started); + for (const service of services) { + const args = [ + this.#runtime, + "run", + "--detach", + "--name", + service.name, + "--network", + service.networkName, + "--label", + "com.t3tools.sandbox.managed=true", + "--label", + `com.t3tools.sandbox.thread=${threadId}`, + "--read-only", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--pids-limit", + "256", + ...(declarations.find((item) => item.name === service.hostname)?.healthCheck + ? healthCheckArgs( + declarations.find((item) => item.name === service.hostname)!.healthCheck!, + ) + : []), + "--env-file", + "/dev/stdin", + ...service.volumes.flatMap((volume) => [ + "--mount", + `type=volume,src=${volume.name},dst=${volume.target}`, + ]), + service.image, + ]; + for (const volume of service.volumes) { + const volumeResult = await this.#executor.run({ + executable: this.#runtime, + args: [ + "volume", + "create", + "--label", + "com.t3tools.sandbox.managed=true", + "--label", + `com.t3tools.sandbox.thread=${threadId}`, + volume.name, + ], + timeoutMs: 30_000, + }); + if (volumeResult.exitCode !== 0) { + await this.stop(threadId); + throw new Error(`failed to create service volume ${volume.name}: ${volumeResult.stderr}`); + } + } + const result = await this.#executor.run({ + executable: this.#runtime, + args: args.slice(1), + stdin: Object.entries(service.environment) + .map(([key, value]) => `${key}=${value}`) + .join("\n"), + timeoutMs: 60_000, + }); + if (result.exitCode !== 0) { + await this.stop(threadId); + throw new Error(`failed to start service ${service.name}: ${result.stderr}`); + } + const health = declarations.find((item) => item.name === service.hostname)?.healthCheck; + if (health !== undefined) { + await this.#waitHealthy(service.name, health); + } + started.push(service); + } + this.#active.set(threadId, services); + return services; + } + + async stop(threadId: string) { + const services = this.#active.get(threadId) ?? []; + for (const service of [...services].reverse()) + await this.#executor + .run({ + executable: this.#runtime, + args: ["rm", "--force", service.name], + timeoutMs: 30_000, + }) + .catch(() => undefined); + for (const volume of new Set( + services.flatMap((service) => service.volumes.map((item) => item.name)), + )) + await this.#executor + .run({ executable: this.#runtime, args: ["volume", "rm", volume], timeoutMs: 30_000 }) + .catch(() => undefined); + this.#active.delete(threadId); + } + + async recover(threadId: string, services: ReadonlyArray) { + const active: Array = []; + for (const service of services) { + const result = await this.#executor.run({ + executable: this.#runtime, + args: ["inspect", service.name], + timeoutMs: 10_000, + }); + if (result.exitCode === 0) active.push(service); + } + this.#active.set(threadId, active); + return active; + } + + redactCredentials(threadId: string) { + const services = this.#active.get(threadId); + if (services !== undefined) + this.#active.set( + threadId, + services.map((service) => ({ ...service, environment: {} })), + ); + } + + async discover(threadId: string) { + const listed = await this.#executor.run({ + executable: this.#runtime, + args: [ + "ps", + "--all", + "--filter", + `label=com.t3tools.sandbox.thread=${threadId}`, + "--format", + "{{.Names}}", + ], + timeoutMs: 10_000, + }); + if (listed.exitCode !== 0) + throw new Error(`failed to discover thread services: ${listed.stderr}`); + const names = listed.stdout + .split("\n") + .map((item) => item.trim()) + .filter((name) => name.startsWith("t3-svc-")); + return names; + } + + async #waitHealthy(name: string, health: NonNullable) { + const intervalMs = Math.min(Math.max(health.intervalSeconds ?? 10, 1), 300) * 1000; + const deadline = + performance.timeOrigin + + performance.now() + + Math.min(intervalMs * (health.retries ?? 5), 120_000); + while (performance.timeOrigin + performance.now() < deadline) { + const inspected = await this.#executor.run({ + executable: this.#runtime, + args: [ + "inspect", + "--format", + "{{.State.Running}}\t{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}", + name, + ], + timeoutMs: Math.min(health.timeoutSeconds ?? 3, 60) * 1000, + }); + const status = inspected.stdout.trim(); + if (inspected.exitCode === 0 && status === "true\thealthy") return; + if ( + inspected.exitCode !== 0 || + status.startsWith("false\t") || + status === "true\tunhealthy" + ) { + await this.#stopByName(name); + throw new Error(`service ${name} failed its health check`); + } + await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, 1_000))); + } + await this.#stopByName(name); + throw new Error(`service ${name} health check timed out`); + } + + async #stopByName(name: string) { + await this.#executor + .run({ executable: this.#runtime, args: ["rm", "--force", name], timeoutMs: 30_000 }) + .catch(() => undefined); + } +} + +const generatedServiceValue = ( + threadId: string, + service: string, + entry: { readonly key: string; readonly kind: "database-name" | "username" | "password" }, +) => + entry.kind === "database-name" + ? `db_${createHash("sha256").update(`${threadId}\0${service}`).digest("hex").slice(0, 16)}` + : entry.kind === "username" + ? `u_${randomBytes(12).toString("hex")}` + : randomBytes(32).toString("base64url"); + +const healthCheckArgs = (health: NonNullable) => { + if ( + !health.executable || + health.executable.includes("\0") || + health.args?.some((item) => item.includes("\0")) + ) + throw new Error("invalid service health check"); + const interval = Math.min(Math.max(health.intervalSeconds ?? 10, 1), 300); + const timeout = Math.min(Math.max(health.timeoutSeconds ?? 3, 1), 60); + const retries = Math.min(Math.max(health.retries ?? 5, 1), 30); + return [ + "--health-cmd", + [health.executable, ...(health.args ?? [])].join(" "), + "--health-interval", + `${interval}s`, + "--health-timeout", + `${timeout}s`, + "--health-retries", + String(retries), + ]; +}; diff --git a/apps/server/src/sandbox/image/Containerfile b/apps/server/src/sandbox/image/Containerfile new file mode 100644 index 000000000000..45a631377e5a --- /dev/null +++ b/apps/server/src/sandbox/image/Containerfile @@ -0,0 +1,26 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} + +USER root +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates chromium curl tmux wget x11-utils xfce4 xfce4-terminal xvfb \ + && rm -rf /var/lib/apt/lists/* + +COPY install-verified-desktop-assets.sh /usr/local/sbin/install-verified-desktop-assets +ARG CODE_OSS_URL +ARG CODE_OSS_SHA256 +ARG WEBRTC_BRIDGE_URL +ARG WEBRTC_BRIDGE_SHA256 +ARG PREVIEW_BRIDGE_URL +ARG PREVIEW_BRIDGE_SHA256 +RUN /usr/local/sbin/install-verified-desktop-assets \ + "$CODE_OSS_URL" "$CODE_OSS_SHA256" /usr/local/bin/code \ + && /usr/local/sbin/install-verified-desktop-assets \ + "$WEBRTC_BRIDGE_URL" "$WEBRTC_BRIDGE_SHA256" /usr/local/bin/t3-desktop-webrtc +RUN /usr/local/sbin/install-verified-desktop-assets \ + "$PREVIEW_BRIDGE_URL" "$PREVIEW_BRIDGE_SHA256" /usr/local/bin/t3-preview-bridge + +RUN mkdir -p /workspace /thread-data \ + && chown -R 1000:1000 /workspace /thread-data +USER 1000:1000 diff --git a/apps/server/src/sandbox/image/install-verified-desktop-assets.sh b/apps/server/src/sandbox/image/install-verified-desktop-assets.sh new file mode 100755 index 000000000000..2200dd1176da --- /dev/null +++ b/apps/server/src/sandbox/image/install-verified-desktop-assets.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +asset_url=${1:-} +expected_sha256=${2:-} +destination=${3:-} + +https_prefix=https: +case "$asset_url" in "$https_prefix"//*) ;; *) echo "asset URL must use HTTPS" >&2; exit 2 ;; esac +case "$expected_sha256" in + [0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]*) ;; + *) echo "asset SHA-256 is required" >&2; exit 2 ;; +esac +[ "${#expected_sha256}" -eq 64 ] || { echo "asset SHA-256 must contain 64 hex characters" >&2; exit 2; } +[ -n "$destination" ] || { echo "asset destination is required" >&2; exit 2; } + +temporary=$(mktemp) +trap 'rm -f "$temporary"' EXIT HUP INT TERM +curl --fail --location --proto '=https' --tlsv1.2 --output "$temporary" "$asset_url" +printf '%s %s\n' "$expected_sha256" "$temporary" | sha256sum --check --status +install -m 0755 "$temporary" "$destination" diff --git a/apps/server/src/sandbox/index.ts b/apps/server/src/sandbox/index.ts new file mode 100644 index 000000000000..067d8cddc4ac --- /dev/null +++ b/apps/server/src/sandbox/index.ts @@ -0,0 +1,16 @@ +export * from "./ContainerSandboxBackend.ts"; +export * from "./AuthenticatedPreviewRouter.ts"; +export * from "./CredentialBroker.ts"; +export * from "./DesktopGatewayService.ts"; +export * from "./DesktopHttpRoutes.ts"; +export * from "./DesktopSession.ts"; +export * from "./EgressPolicy.ts"; +export * from "./NodeSandboxCommandExecutor.ts"; +export * from "./ThreadDesktopRuntime.ts"; +export * from "./ThreadPreviewProxy.ts"; +export * from "./ThreadServiceStack.ts"; +export * from "./SandboxProviderProcess.ts"; +export * from "./SandboxRuntimeManager.ts"; +export * from "./ThreadSandboxRuntime.ts"; +export * from "./types.ts"; +export * from "./validation.ts"; diff --git a/apps/server/src/sandbox/types.ts b/apps/server/src/sandbox/types.ts new file mode 100644 index 000000000000..10fa5907a013 --- /dev/null +++ b/apps/server/src/sandbox/types.ts @@ -0,0 +1,118 @@ +import type { SandboxConfig, SandboxResourceLimits, SandboxRuntime } from "@t3tools/contracts"; + +export type SandboxCommand = { + readonly executable: string; + readonly args: ReadonlyArray; + readonly stdin?: string; + readonly timeoutMs: number; +}; + +export type SandboxCommandResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +export interface SandboxCommandExecutor { + readonly run: (command: SandboxCommand) => Promise; +} + +export type SandboxCache = { + /** A content-addressed, runtime-managed volume name. Host paths are not accepted. */ + readonly digest: string; + readonly target: string; +}; + +export type SandboxHook = { + readonly executable: string; + readonly args?: ReadonlyArray; + readonly env?: Readonly>; +}; + +export type SandboxBootstrap = { + readonly threadId: string; + readonly projectId: string; + readonly repositoryUrl: string; + readonly baseCommit: string; + readonly branchName: string; + readonly parentThreadId?: string; + readonly inheritedPatch?: string; + /** Manager-generated verified bundle path; never a user-supplied mount. */ + readonly repositoryBundlePath?: string; +}; + +export type SandboxProvisionInput = { + readonly bootstrap: SandboxBootstrap; + readonly config?: SandboxConfig; + readonly image: string; + readonly caches?: ReadonlyArray; + readonly setup?: ReadonlyArray; + readonly teardown?: ReadonlyArray; + /** + * Proxy URL used for public traffic. Direct egress is disabled when omitted. + * The proxy is a trusted boundary and must reject private, link-local, + * metadata, loopback, and cross-sandbox destinations. + */ + readonly egressProxyUrl?: string; + readonly egressProxyImage?: string; + readonly previewPorts?: ReadonlyArray; +}; + +export type SandboxReady = { + readonly sandboxId: string; + readonly runtime: SandboxRuntime; + readonly containerName: string; + readonly networkName: string; + readonly workspaceVolumeName: string; + readonly desktopVolumeName: string; + readonly egressProxyContainerName?: string; + readonly egressNetworkName?: string; + readonly branchName: string; + readonly limits: SandboxResourceLimits; +}; + +export type SandboxExecInput = { + readonly executable: string; + readonly args?: ReadonlyArray; + readonly cwd?: string; + readonly env?: Readonly>; + readonly timeoutMs?: number; + readonly stdin?: string; +}; + +export type SandboxExport = { + readonly commit: string; + readonly patch: string; +}; +export type SandboxArtifactExport = SandboxExport & { + readonly artifactId: string; + readonly bundleSha256: string; +}; +export type SandboxUsageSample = { + readonly cpuPercent: number; + readonly memoryBytes: number; + readonly diskBytes: number; + readonly processCount: number; +}; + +export type SandboxReconcileInput = { + readonly expectedThreadIds: ReadonlySet; + readonly removeOrphans?: boolean; +}; + +export type SandboxReconcileResult = { + readonly activeThreadIds: ReadonlyArray; + readonly missingThreadIds: ReadonlyArray; + readonly orphanThreadIds: ReadonlyArray; + readonly removedRuntimeRefs: ReadonlyArray; +}; + +export interface ThreadSandboxBackend { + readonly runtime: SandboxRuntime; + readonly ensureReady: (input: SandboxProvisionInput) => Promise; + readonly exec: (threadId: string, input: SandboxExecInput) => Promise; + readonly exportBranch: (threadId: string) => Promise; + readonly sampleUsage: (threadId: string) => Promise; + readonly stop: (threadId: string, teardown?: ReadonlyArray) => Promise; + readonly reconcile: (input: SandboxReconcileInput) => Promise; +} diff --git a/apps/server/src/sandbox/validation.ts b/apps/server/src/sandbox/validation.ts new file mode 100644 index 000000000000..a1cdf1487f35 --- /dev/null +++ b/apps/server/src/sandbox/validation.ts @@ -0,0 +1,98 @@ +import type { SandboxBootstrap, SandboxCache, SandboxExecInput, SandboxHook } from "./types.ts"; + +const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const COMMIT = /^[0-9a-f]{40,64}$/i; +const SAFE_ABSOLUTE_PATH = /^\/(?:[a-zA-Z0-9._-]+\/?)+$/; +const SAFE_ENV_KEY = /^[A-Z_][A-Z0-9_]*$/; +const SAFE_BRANCH = /^(?![-/.])(?!.*(?:\.\.|\/\/|@\{|[~^:?*\[\u005c]))[a-zA-Z0-9._/-]{1,200}$/; +const FORBIDDEN_TARGETS = ["/", "/home", "/root", "/run", "/tmp", "/etc", "/usr", "/var/run"]; + +export class SandboxValidationError extends Error { + override readonly name = "SandboxValidationError"; +} + +export function sanitizeId(value: string, field: string): string { + if (!SAFE_ID.test(value) || value.includes("..")) { + throw new SandboxValidationError(`${field} contains unsafe characters`); + } + return value; +} + +export function validateBootstrap(input: SandboxBootstrap): void { + sanitizeId(input.threadId, "threadId"); + sanitizeId(input.projectId, "projectId"); + if (!COMMIT.test(input.baseCommit)) + throw new SandboxValidationError("baseCommit must be an immutable full commit hash"); + if ( + !SAFE_BRANCH.test(input.branchName) || + input.branchName.endsWith("/") || + input.branchName.endsWith(".lock") + ) { + throw new SandboxValidationError("branchName is unsafe"); + } + if (input.repositoryBundlePath === undefined) { + let url: URL; + try { + url = new URL(input.repositoryUrl); + } catch { + throw new SandboxValidationError("repositoryUrl must be an absolute URL"); + } + if (!new Set(["https:", "ssh:"]).has(url.protocol)) { + throw new SandboxValidationError("repositoryUrl must use https or ssh"); + } + } else if ( + !SAFE_ABSOLUTE_PATH.test(input.repositoryBundlePath) || + input.repositoryBundlePath.includes("..") + ) { + throw new SandboxValidationError("repository bundle path is invalid"); + } + if ( + input.inheritedPatch !== undefined && + Buffer.byteLength(input.inheritedPatch) > 16 * 1024 * 1024 + ) { + throw new SandboxValidationError("inheritedPatch exceeds 16 MiB"); + } +} + +export function validateCache(cache: SandboxCache): void { + if (!/^[a-f0-9]{32,128}$/i.test(cache.digest)) + throw new SandboxValidationError("cache digest is invalid"); + validateSandboxPath(cache.target, "cache target"); + if ( + FORBIDDEN_TARGETS.some( + (path) => cache.target === path || (path !== "/" && cache.target.startsWith(`${path}/`)), + ) + ) { + throw new SandboxValidationError(`cache target ${cache.target} overlaps a protected path`); + } +} + +export function validateSandboxPath(path: string, field: string): void { + if (!SAFE_ABSOLUTE_PATH.test(path) || path.includes("..") || path.includes("//")) { + throw new SandboxValidationError(`${field} must be a normalized absolute sandbox path`); + } +} + +export function validateHook(hook: SandboxHook): void { + if (!hook.executable || hook.executable.includes("\0")) + throw new SandboxValidationError("hook executable is invalid"); + validateEnvironment(hook.env); + for (const arg of hook.args ?? []) + if (arg.includes("\0")) throw new SandboxValidationError("hook argument contains NUL"); +} + +export function validateExec(input: SandboxExecInput): void { + if (!input.executable || input.executable.includes("\0")) + throw new SandboxValidationError("executable is invalid"); + if (input.cwd !== undefined) validateSandboxPath(input.cwd, "cwd"); + validateEnvironment(input.env); + for (const arg of input.args ?? []) + if (arg.includes("\0")) throw new SandboxValidationError("argument contains NUL"); +} + +function validateEnvironment(env?: Readonly>): void { + for (const [key, value] of Object.entries(env ?? {})) { + if (!SAFE_ENV_KEY.test(key) || value.includes("\0")) + throw new SandboxValidationError(`environment entry ${key} is invalid`); + } +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 843deb103fed..abbf3ef8bd77 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1057,7 +1057,7 @@ const parseSessionCookieFromWsUrl = ( }; }; -const wsRpcProtocolLayer = (wsUrl: string) => { +const wsRpcProtocolLayer = (wsUrl: string, options?: { readonly origin?: string }) => { const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl); const webSocketConstructorLayer = Layer.succeed( Socket.WebSocketConstructor, @@ -1065,7 +1065,12 @@ const wsRpcProtocolLayer = (wsUrl: string) => { new NodeSocket.NodeWS.WebSocket( socketUrl, protocols, - cookie ? { headers: { cookie } } : undefined, + cookie || options?.origin + ? { + ...(cookie ? { headers: { cookie } } : {}), + ...(options?.origin ? { origin: options.origin } : {}), + } + : undefined, ) as unknown as globalThis.WebSocket, ); @@ -1082,7 +1087,8 @@ type WsRpcClient = const withWsRpcClient = ( wsUrl: string, f: (client: WsRpcClient) => Effect.Effect, -) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); + options?: { readonly origin?: string }, +) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl, options))); const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => { const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url); @@ -4087,6 +4093,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("accepts websocket rpc handshake from a configured development origin", () => + Effect.gen(function* () { + const configuredOrigin = "https://host.example.ts.net"; + yield* buildAppUnderTest({ + config: { devAllowedOrigins: [configuredOrigin] }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient( + wsUrl, + (client) => client[WS_METHODS.serverGetConfig]({}), + { origin: configuredOrigin }, + ), + ); + + assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not block server config when editor discovery never resolves", () => Effect.gen(function* () { const discoveryInterrupted = yield* Deferred.make(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9e989cfcac23..642af482f6fc 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -51,6 +51,13 @@ import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import { makePreviewGatewayRoutesLayer } from "./preview/gatewayRoute.ts"; +import { + desktopHttpRouteLayer, + desktopSignalHttpRouteLayer, + sandboxCredentialHttpRouteLayer, + sandboxArtifactHttpRouteLayer, + sandboxPreviewResolveHttpRouteLayer, +} from "./sandbox/DesktopHttpRoutes.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; @@ -61,6 +68,8 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import { SandboxLifecycleReactorLive } from "./orchestration/Layers/SandboxLifecycleReactor.ts"; +import { SandboxRuntimeManagerLive } from "./sandbox/SandboxRuntimeManager.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -318,11 +327,13 @@ const PlatformServicesLive = Layer.unwrap( ); const ReactorLayerLive = Layer.empty.pipe( + Layer.provideMerge(SandboxRuntimeManagerLive), Layer.provideMerge(OrchestrationReactorLive), Layer.provideMerge(ProviderRuntimeIngestionLive), Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(SandboxLifecycleReactorLive), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -479,7 +490,12 @@ const VcsLayerLive = Layer.empty.pipe( const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointDiffQuery.layer), - Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), + Layer.provideMerge( + CheckpointStore.layer.pipe( + Layer.provide(VcsDriverRegistryLayerLive), + Layer.provide(SandboxRuntimeManagerLive), + ), + ), ); const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); @@ -661,6 +677,11 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(environmentAuthenticatedAuthLayer), ), otlpTracesProxyRouteLayer, + desktopHttpRouteLayer, + desktopSignalHttpRouteLayer, + sandboxCredentialHttpRouteLayer, + sandboxArtifactHttpRouteLayer, + sandboxPreviewResolveHttpRouteLayer, assetRouteLayer, staticAndDevRouteLayer, webhookHttpRouteLayer, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 356e6cb0c58a..2193de32dc3a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2995,6 +2995,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const allowedWebSocketOrigins = [ ...(config.devUrl ? [config.devUrl.origin] : []), ...DESKTOP_RENDERER_ORIGINS, + ...config.devAllowedOrigins, ]; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bdc6f1b3ce57..e821298bb78d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -152,6 +152,7 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; +import { SandboxDesktopPanel } from "./SandboxDesktopPanel"; import { deriveAgentPanelModel, foldSubagentActivities, @@ -1236,6 +1237,17 @@ function ChatViewContent(props: ChatViewProps) { const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { reportFailure: false, }); + const provisionSandbox = useAtomCommand(threadEnvironment.provisionSandbox, "sandbox provision"); + const takeOverSandbox = useAtomCommand(threadEnvironment.takeOverSandbox, "sandbox takeover"); + const resumeSandbox = useAtomCommand(threadEnvironment.resumeSandbox, "sandbox resume"); + const stopSandbox = useAtomCommand(threadEnvironment.stopSandbox, "sandbox stop"); + const exportSandboxBranch = useAtomCommand( + threadEnvironment.exportSandboxBranch, + "sandbox branch export", + ); + const requestSandboxViewerTicket = useAtomCommand(threadEnvironment.requestSandboxViewerTicket, { + reportFailure: false, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); const { environments } = useEnvironments(); @@ -3356,6 +3368,10 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef) return; useRightPanelStore.getState().open(activeThreadRef, "agents"); }, [activeThreadRef]); + const addDesktopSurface = useCallback(() => { + if (!activeThreadRef || routeKind !== "server") return; + useRightPanelStore.getState().open(activeThreadRef, "desktop"); + }, [activeThreadRef, routeKind]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -6227,6 +6243,40 @@ function ChatViewContent(props: ChatViewProps) { composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> + ) : activeRightPanelSurface?.kind === "desktop" ? ( + provisionSandbox({ environmentId, input: { threadId } })} + onTakeover={() => + takeOverSandbox({ + environmentId, + input: { threadId, sessionId: `desktop-${randomHex(16)}` }, + }) + } + onResume={(leaseId) => + resumeSandbox({ + environmentId, + input: { + threadId, + ...(leaseId === undefined ? {} : { leaseId }), + takeoverSummary: + "Manual desktop control ended; repository and browser state may have changed.", + }, + }) + } + onStop={() => stopSandbox({ environmentId, input: { threadId } })} + onExport={() => exportSandboxBranch({ environmentId, input: { threadId } })} + onReconnect={() => undefined} + onRequestViewerUrl={async () => { + const result = await requestSandboxViewerTicket({ + environmentId, + input: { threadId }, + }); + if (result._tag === "Failure") + throw new Error("Could not obtain a desktop viewer ticket."); + return result.value.viewerUrl; + }} + /> ) : activeRightPanelSurface?.kind === "agents" ? ( @@ -6724,12 +6776,14 @@ function ChatViewContent(props: ChatViewProps) { onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddAgents={addAgentsSurface} + onAddDesktop={addDesktopSurface} browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} agentsAvailable + desktopAvailable={isServerThread} pullRequestStatuses={pullRequestTabStatuses} liveAgentCount={agentPanelModel.liveCount} > diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 01f55c06eb18..ab2d797b834f 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -6,6 +6,7 @@ import { Files, GitPullRequest, Globe2, + MonitorUp, Plus, TerminalSquare, X, @@ -64,12 +65,14 @@ interface RightPanelTabsProps { onAddFiles: () => void; onAddPullRequest: () => void; onAddAgents: () => void; + onAddDesktop?: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; agentsAvailable: boolean; + desktopAvailable?: boolean; pullRequestStatuses?: Readonly>; /** Running + waiting subagents; badges the Agents card in the empty state. */ liveAgentCount: number; @@ -91,6 +94,7 @@ const SURFACE_DISABLED_REASONS = { diff: "Diff is only available for server threads in Git repositories.", pullRequest: "This thread's branch has no pull request yet.", agents: "Agents are only available from a thread.", + desktop: "The isolated desktop is only available from a sandboxed thread.", } as const; /** Overlays that must win over the launcher's letter shortcuts. */ @@ -113,6 +117,7 @@ const SURFACE_UNAVAILABLE_HINTS = { diff: "Available for Git repositories.", pullRequest: "No pull request on this branch yet.", agents: "Available from a thread.", + desktop: "Available from an isolated thread.", } as const; type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; @@ -159,12 +164,14 @@ function RightPanelEmptyState(props: { onAddFiles: () => void; onAddPullRequest: () => void; onAddAgents: () => void; + onAddDesktop?: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; agentsAvailable: boolean; + desktopAvailable?: boolean; liveAgentCount: number; }) { // -1 means no highlight: it only appears on hover or arrow use. @@ -221,6 +228,16 @@ function RightPanelEmptyState(props: { onClick: props.onAddPullRequest, badgeCount: 0, }, + { + label: "Desktop", + description: "Watch or take control of the thread desktop.", + icon: MonitorUp, + shortcut: "I", + available: props.desktopAvailable === true, + disabledReason: SURFACE_UNAVAILABLE_HINTS.desktop, + onClick: props.onAddDesktop ?? (() => undefined), + badgeCount: 0, + }, { label: "Agents", description: "Follow subagents and workflows.", @@ -425,6 +442,8 @@ function surfaceTitle( return `#${surface.number}`; case "agents": return "Agents"; + case "desktop": + return "Desktop"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -483,6 +502,8 @@ function SurfaceIcon({ return ; case "files": return ; + case "desktop": + return ; case "file": return ( Pull request + undefined)} + > + + Desktop + ) : ( diff --git a/apps/web/src/components/SandboxDesktopPanel.test.tsx b/apps/web/src/components/SandboxDesktopPanel.test.tsx new file mode 100644 index 000000000000..9f94e1206144 --- /dev/null +++ b/apps/web/src/components/SandboxDesktopPanel.test.tsx @@ -0,0 +1,59 @@ +import type { SandboxState } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { SandboxDesktopPanel } from "./SandboxDesktopPanel"; + +const state = { + lifecycle: "paused", + sandboxId: "sandbox-1", + runtime: "docker", + runtimeRef: "container-1", + branch: { branchName: "thread/child", baseCommit: "0123456789abcdef0123456789abcdef01234567" }, + limits: { + cpuCount: 2, + memoryBytes: 4_294_967_296, + diskBytes: 21_474_836_480, + processCount: 512, + idleTimeoutSeconds: 3600, + maxLifetimeSeconds: 28800, + }, + usage: { cpuPercent: 12.5, memoryBytes: 268_435_456, diskBytes: 536_870_912, processCount: 7 }, + desktop: { status: "ready", sessionId: "desktop-1", streamPath: "/sandbox/desktop/thread-1" }, + services: [{ name: "db", status: "healthy" }], + controller: { + kind: "human", + leaseId: "lease-1", + sessionId: "viewer-1", + acquiredAt: "2026-08-15T00:00:00Z", + }, + pauseReason: "human-takeover", + createdAt: "2026-08-15T00:00:00Z", + lastActiveAt: "2026-08-15T00:00:01Z", +} as unknown as SandboxState; + +describe("SandboxDesktopPanel", () => { + it("shows the explicit human-control lease and observable resource state", () => { + const markup = renderToStaticMarkup( + "https://environment.example/api/thread-desktop/thread-1/view?ticket=x", + )} + />, + ); + + expect(markup).toContain("Resume agent"); + expect(markup).toContain("Agent commands remain paused"); + expect(markup).toContain("thread/child"); + expect(markup).toContain("CPU 12.5%"); + expect(markup).toContain("Services 1/1"); + expect(markup).toContain("The desktop stream is not ready"); + }); +}); diff --git a/apps/web/src/components/SandboxDesktopPanel.tsx b/apps/web/src/components/SandboxDesktopPanel.tsx new file mode 100644 index 000000000000..730ea0c8d9aa --- /dev/null +++ b/apps/web/src/components/SandboxDesktopPanel.tsx @@ -0,0 +1,195 @@ +import type { SandboxState } from "@t3tools/contracts"; +import { Download, Expand, MonitorUp, Pause, Play, RefreshCw, Square } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "~/components/ui/button"; + +export interface SandboxDesktopPanelProps { + sandbox: SandboxState | null; + busy?: boolean; + onProvision: () => Promise<{ readonly _tag?: string }>; + onTakeover: () => Promise<{ readonly _tag?: string }>; + onResume: (leaseId?: string) => Promise<{ readonly _tag?: string }>; + onStop: () => Promise<{ readonly _tag?: string }>; + onExport: () => Promise<{ readonly _tag?: string }>; + onReconnect: () => void; + onRequestViewerUrl: () => Promise; +} + +const titleCase = (value: string) => value.replaceAll("-", " "); + +/** Thread desktop controls. Closing/unmounting this view intentionally never resumes a lease. */ +export function SandboxDesktopPanel(props: SandboxDesktopPanelProps) { + const frameRef = useRef(null); + const [reconnectGeneration, setReconnectGeneration] = useState(0); + const [actionBusy, setActionBusy] = useState(false); + const [actionError, setActionError] = useState(null); + const [viewerUrl, setViewerUrl] = useState(null); + const [viewerBusy, setViewerBusy] = useState(false); + const sandbox = props.sandbox; + const humanController = sandbox?.controller.kind === "human" ? sandbox.controller : null; + const desktopReady = sandbox?.desktop.status === "ready"; + + const fullscreen = () => void frameRef.current?.requestFullscreen(); + const requestViewer = async () => { + if (!desktopReady || viewerBusy) return; + setViewerBusy(true); + setActionError(null); + try { + setViewerUrl(await props.onRequestViewerUrl()); + setReconnectGeneration((current) => current + 1); + } catch (cause) { + setViewerUrl(null); + setActionError( + cause instanceof Error ? cause.message : "Could not connect to the desktop viewer.", + ); + } finally { + setViewerBusy(false); + } + }; + const reconnect = () => { + void requestViewer(); + props.onReconnect(); + }; + useEffect(() => { + setViewerUrl(null); + if (desktopReady) void requestViewer(); + // A new desktop session always requires a fresh one-time ticket. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [desktopReady, sandbox?.desktop.sessionId]); + const busy = props.busy === true || actionBusy; + const runAction = async (action: () => Promise<{ readonly _tag?: string }>) => { + if (busy) return; + setActionBusy(true); + setActionError(null); + try { + const result = await action(); + if (result._tag === "Failure") + setActionError("The sandbox action failed. Refresh and try again."); + } catch (cause) { + setActionError(cause instanceof Error ? cause.message : "The sandbox action failed."); + } finally { + setActionBusy(false); + } + }; + + return ( +
+
+ +
+
Isolated desktop
+
+ {sandbox === null + ? "Starts automatically on first use" + : `${titleCase(sandbox.lifecycle)} · ${sandbox.branch.branchName}`} +
+
+ {desktopReady ? ( + <> + + + + ) : null} + {sandbox === null ? ( + + ) : humanController ? ( + + ) : sandbox.lifecycle === "ready" ? ( + + ) : null} + {sandbox && !["stopped", "expired", "deleted"].includes(sandbox.lifecycle) ? ( + + ) : null} + {sandbox ? ( + + ) : null} +
+ + {humanController ? ( +
+ You control this desktop. Agent commands remain paused until you explicitly resume. +
+ ) : null} + {actionError ? ( +
+ {actionError} +
+ ) : null} + +
+ {desktopReady && viewerUrl ? ( +