From 4b0ba8094832d75aad042ef91aa3730d2086c9b3 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:19:34 -0400 Subject: [PATCH 1/4] feat(sandbox): add per-session host sandbox toggle --- docs/configuration.md | 1 + src/hooks/plan-approval.ts | 13 +- src/hooks/sandbox-tools.ts | 18 +- src/hooks/shell-env.ts | 33 +- src/index.ts | 184 +- src/sandbox/manager.ts | 17 +- src/sandbox/session-controller.ts | 995 ++++++++ src/services/unified-sandbox-resolver.ts | 126 + src/storage/index.ts | 3 + .../repos/session-sandbox-preferences-repo.ts | 147 ++ src/tui.tsx | 251 +- src/tui/session-sandbox-store.ts | 253 ++ src/types-bun.d.ts | 2 +- src/utils/logger.ts | 9 +- src/utils/tui-client.ts | 48 +- test/__shims__/bun-sqlite.mjs | 19 +- test/hooks/shell-env.test.ts | 78 +- test/parent-session-lookup.test.ts | 51 + test/plugin.test.ts | 385 +++- test/sandbox-manager.test.ts | 4 +- test/sandbox-tools.test.ts | 61 +- test/sandbox/manager-env-passthrough.test.ts | 32 +- test/sandbox/manager-reliability.test.ts | 19 + test/sandbox/session-controller.test.ts | 2046 +++++++++++++++++ test/session-sandbox-preferences-repo.test.ts | 196 ++ test/tui/session-sandbox-store.test.ts | 512 +++++ test/unified-sandbox-resolver.test.ts | 260 +++ 27 files changed, 5635 insertions(+), 128 deletions(-) create mode 100644 src/sandbox/session-controller.ts create mode 100644 src/services/unified-sandbox-resolver.ts create mode 100644 src/storage/repos/session-sandbox-preferences-repo.ts create mode 100644 src/tui/session-sandbox-store.ts create mode 100644 test/sandbox/session-controller.test.ts create mode 100644 test/session-sandbox-preferences-repo.test.ts create mode 100644 test/tui/session-sandbox-store.test.ts create mode 100644 test/unified-sandbox-resolver.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 9bcdc9e391..566526f2f0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -152,6 +152,7 @@ Notes: | `tui.showVersion` | `true` | Show the Forge version in the sidebar title. | | `tui.keybinds.executePlan` | `"f"` | Open the execution dialog. Avoid `e`, which conflicts with opencode's built-in `editor_open`. | | `tui.keybinds.dashboard` | `""` | Optional keybind for opening the dashboard. Empty registers the command without a default binding. | +| `tui.keybinds.toggleHostSandbox` | `""` | Optional keybind for `Toggle host sandbox`, which runs the current session inside a sandbox container. Empty registers the command without a default binding. Requires `sandbox.enabled`. | ## Dashboard diff --git a/src/hooks/plan-approval.ts b/src/hooks/plan-approval.ts index 4496a7f2a8..0a7ae343fe 100644 --- a/src/hooks/plan-approval.ts +++ b/src/hooks/plan-approval.ts @@ -137,9 +137,13 @@ export function createToolExecuteBeforeHook(ctx: ToolContext, deps: LoopToolBloc input: { tool: string; sessionID: string; callID: string }, _output: { args: unknown } ) => { + // Only tools that can be blocked during a loop require loop-state resolution. Resolving the + // ancestor chain for every tool lets a transient session lookup failure reject host-side + // native tools (read/edit/write, etc.) that never consult loop state. Non-blocked tools + // return before any resolution. + if (!(input.tool in LOOP_BLOCKED_TOOLS)) return const state = await resolveBlockedLoopToolState(loop, input.sessionID, deps) if (!state?.active) return - if (!(input.tool in LOOP_BLOCKED_TOOLS)) return logger.log(`Loop: blocking ${input.tool} tool before execution in ${state.phase} phase for session ${input.sessionID}`) @@ -155,7 +159,12 @@ export function createToolExecuteAfterHook(ctx: ToolContext, deps: LoopToolBlock input: { tool: string; sessionID: string; callID: string; args: unknown }, output: { title: string; output: string; metadata: unknown } ) => { - const blockedState = await resolveBlockedLoopToolState(loop, input.sessionID, deps) + // Resolve loop state only for tools that can be blocked during a loop, so a transient session + // lookup failure never rejects host-side native tools that ignore loop state. + let blockedState: { active?: boolean; loopName?: string; phase?: string } | null = null + if (input.tool in LOOP_BLOCKED_TOOLS) { + blockedState = await resolveBlockedLoopToolState(loop, input.sessionID, deps) + } if (blockedState?.active && input.tool in LOOP_BLOCKED_TOOLS) { logger.log(`Loop: blocked ${input.tool} tool in ${blockedState.phase} phase for session ${input.sessionID}`) output.title = 'Tool blocked' diff --git a/src/hooks/sandbox-tools.ts b/src/hooks/sandbox-tools.ts index afa6724013..7bedace910 100644 --- a/src/hooks/sandbox-tools.ts +++ b/src/hooks/sandbox-tools.ts @@ -6,7 +6,7 @@ import { executeSandboxGlob, executeSandboxGrep } from '../sandbox/exec-fs' import { isInsideAnyMount } from '../sandbox/path' interface SandboxToolHookDeps { - resolveSandboxForSession: (sessionID: string) => Promise + resolveSandboxForSession: (sessionID: string, opts?: { throwOnRestoreError?: boolean }) => Promise logger: Logger } @@ -20,7 +20,15 @@ export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['t // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches upstream Hooks type output: { args: any }, ) => { - const sandbox = await deps.resolveSandboxForSession(input.sessionID) + // This hook only intercepts search tools. Return before any resolution so a fail-closed + // resolver error can never block native file or management tools (`read`, `edit`, `write`, + // bash, etc.), preserving the shell + search isolation scope. + if (input.tool !== 'glob' && input.tool !== 'grep') return + + // Request fail-closed resolution exactly as bash does: when an acknowledged sandbox cannot be + // restored (or the selected session's start failed), the resolver throws and the tool call + // fails rather than silently searching the host checkout. + const sandbox = await deps.resolveSandboxForSession(input.sessionID, { throwOnRestoreError: true }) if (!sandbox) { deps.logger.debug(`[sandbox-hook] no sandbox for session ${input.sessionID} tool=${input.tool}`) return @@ -35,8 +43,10 @@ export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['t isAbsolute(requestedPath) && !isInsideAnyMount(requestedPath, mounts) ) { - deps.logger.debug(`[sandbox-hook] ${input.tool} path '${requestedPath}' is outside the workspace mount; deferring to host execution`) - return + // Fail closed: an absolute search path outside the sandbox mounts must not silently fall + // back to host execution, which would violate the shell + search isolation scope. Throwing + // blocks the search rather than running it on the host. + throw new Error(`Refusing to run ${input.tool} outside the sandbox workspace mount: ${requestedPath}`) } if (input.tool === 'glob') { diff --git a/src/hooks/shell-env.ts b/src/hooks/shell-env.ts index 8169d9a34e..4ba72d6ea2 100644 --- a/src/hooks/shell-env.ts +++ b/src/hooks/shell-env.ts @@ -1,39 +1,32 @@ import type { Hooks } from '@opencode-ai/plugin' import type { Logger } from '../types' -import { resolveSandboxContextForLoop, type SandboxContextManager, type SandboxLoopContextState } from '../sandbox/context' +import type { SandboxContext } from '../sandbox/context' import { SHIM_ENV_CONTAINER, SHIM_ENV_ENV_FILE, SHIM_ENV_HOST_SHELL } from '../sandbox/shell-shim' export interface ShellEnvHookDeps { - resolveActiveLoopForSession: (sessionID: string) => Promise - sandboxManager: SandboxContextManager | null + /** Resolves the sandbox context for a session through the unified loop-first resolver. */ + resolveSandboxForSession: (sessionID: string, opts?: { throwOnRestoreError?: boolean }) => Promise /** The shell the user had configured in opencode before forge pointed `shell` at the shim. */ getUserConfiguredShell: () => string | undefined logger: Logger } /** - * Feeds the sandbox shell shim: for sessions that belong to an active sandbox loop, injects the - * container name (and env-file path) so the shim routes the command into the loop microVM via - * `sbx exec`. Every other session gets no container env, so the shim falls through to the host - * shell — restoring the user's own configured shell when they had one. + * Feeds the sandbox shell shim: for sessions that resolve to a sandbox context (a loop sandbox or + * an acknowledged host-session sandbox), injects the container name (and env-file path) so the shim + * routes the command into the microVM via `sbx exec`. Every other session gets no container env, so + * the shim falls through to the host shell — restoring the user's own configured shell when they had + * one. * - * Fail-closed: when the session belongs to an active sandbox loop but the container cannot be - * resolved or restarted, this throws (failing the bash call) rather than letting the command - * silently run on the host. + * Fail-closed: resolution is requested with `{ throwOnRestoreError: true }`, so when an expected + * sandbox cannot be resolved or restarted the resolver throws (failing the bash call) rather than + * letting the command silently run on the host. */ export function createShellEnvHook(deps: ShellEnvHookDeps): NonNullable { return async (input, output) => { if (input.sessionID) { - const resolved = await deps.resolveActiveLoopForSession(input.sessionID) - if (resolved?.active && resolved.sandbox) { - const sandbox = await resolveSandboxContextForLoop(deps.sandboxManager, resolved, deps.logger, { - throwOnRestoreError: true, - }) - if (!sandbox) { - throw new Error( - `Sandbox container for loop "${resolved.loopName}" is unavailable; refusing to run the command on the host.`, - ) - } + const sandbox = await deps.resolveSandboxForSession(input.sessionID, { throwOnRestoreError: true }) + if (sandbox) { output.env[SHIM_ENV_CONTAINER] = sandbox.containerName if (sandbox.envFile) output.env[SHIM_ENV_ENV_FILE] = sandbox.envFile return diff --git a/src/index.ts b/src/index.ts index 9d626d6e52..6f64cdb15f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,11 @@ import type { Plugin, PluginInput, Hooks } from '@opencode-ai/plugin' import { join } from 'path' import type { ForgeClient, SessionGetParams } from './client/port' +import { ForgeClientError } from './client/port' import { buildAgents } from './agents' import { createConfigHandler } from './config' import { createSessionHooks, createLoopEventHandler } from './hooks' -import { initializeDatabase, resolveDataDir, resolveOpencodeToolOutputDir, closeDatabase, createLoopsRepo, createPlansRepo, createReviewFindingsRepo, createSectionPlansRepo, createLoopSessionUsageRepo, createFeatureGroupsRepo, createLoopTransitionsRepo, createPlanAmendmentsRepo } from './storage' +import { initializeDatabase, resolveDataDir, resolveOpencodeToolOutputDir, closeDatabase, createLoopsRepo, createPlansRepo, createReviewFindingsRepo, createSectionPlansRepo, createLoopSessionUsageRepo, createFeatureGroupsRepo, createLoopTransitionsRepo, createPlanAmendmentsRepo, createSessionSandboxPreferencesRepo } from './storage' import type { LoopChangeNotifier } from './loop' import { loadPluginConfig, resolveBundledContainerDir, resolvePromptsDir } from './setup' import { resolveLogPath } from './storage' @@ -21,6 +22,7 @@ import { emitLoopPermissionConfigWarnings } from './utils/loop-permission-warnin import { publishToast } from './utils/toast' import { mkdirSync } from 'fs' import { createSandboxManager } from './sandbox/manager' +import { createSessionSandboxController, createUnavailableSandboxLifecycleManager, type SessionSandboxController } from './sandbox/session-controller' import type { PluginConfig, CompactionConfig } from './types' import { createTools } from './tools' import { createToolExecuteBeforeHook, createToolExecuteAfterHook, createPlanApprovalEventHook } from './hooks' @@ -32,6 +34,7 @@ import { createForgeClientFromPluginInput } from './client/sdk-adapter' import { LRUCache } from './utils/lru-cache' import { createSessionLoopResolver } from './services/session-loop-resolver' +import { createUnifiedSandboxResolver } from './services/unified-sandbox-resolver' import { createPlanCaptureEventHook } from './hooks/plan-capture' import { createForgeSessionAttachHook, createForgeSessionMessageAttachHook } from './hooks/forge-session-attach' import { createLoopPermissionPatcher } from './hooks/loop-permission' @@ -119,7 +122,14 @@ export function createParentSessionLookup({ } failures.push(`${attempt.label}[${attempt.directory ?? 'none'}]:empty`) } catch (err) { - failures.push(`${attempt.label}[${attempt.directory ?? 'none'}]:${err instanceof Error ? err.message : String(err)}`) + // Only definitive absence (a not-found response) is treated as a negative + // result. Transient failures (connection/unavailable/request) propagate so + // sandbox routing fails closed instead of caching a false "no parent". + if (err instanceof ForgeClientError && err.kind === 'not-found') { + failures.push(`${attempt.label}[${attempt.directory ?? 'none'}]:not-found`) + continue + } + throw err } } @@ -196,6 +206,65 @@ export function createSessionDirectoryLookup({ } +/** + * Process-wide registry of host-session sandbox controllers, keyed by project id. + * + * OpenCode can instantiate this plugin more than once for the same directory in a single process, + * and every instance builds its own database handle, sandbox manager and controller. Two + * controllers reconciling the same per-project preference row race on one container: one creates + * while the other force-deletes underneath it, which surfaces as `operation in progress`, + * `already exists`, `failed to run sandbox container`, or an acknowledgement timeout. The + * container and the preference row are both per project, so exactly one reconciler may exist per + * project per process; additional instances share it and release it by reference count. + */ +type SharedSessionSandboxController = { + controller: SessionSandboxController + started: Promise + refs: number + close: () => void +} + +const sharedSessionSandboxControllers = new Map() + +/** + * Returns the process-wide controller for `projectId`, creating and starting it on first use. + * The returned `started` promise is shared, so every caller awaits the same initial reconcile + * rather than triggering a second one. + */ +function acquireSessionSandboxController( + projectId: string, + create: () => { controller: SessionSandboxController; close: () => void }, +): SharedSessionSandboxController { + const existing = sharedSessionSandboxControllers.get(projectId) + if (existing) { + existing.refs += 1 + return existing + } + const { controller, close } = create() + const entry: SharedSessionSandboxController = { controller, started: controller.start(), refs: 1, close } + sharedSessionSandboxControllers.set(projectId, entry) + return entry +} + +/** + * Drops one reference and disposes the controller once the last instance releases it. The + * controller owns a dedicated database handle, closed here after disposal, so it can outlive the + * instance that happened to create it: instances release before closing their own handles, and a + * borrowed handle would otherwise be closed while other instances still hold a reference. + */ +async function releaseSessionSandboxController(projectId: string): Promise { + const entry = sharedSessionSandboxControllers.get(projectId) + if (!entry) return + entry.refs -= 1 + if (entry.refs > 0) return + sharedSessionSandboxControllers.delete(projectId) + try { + await entry.controller.dispose() + } finally { + entry.close() + } +} + /** * Creates an OpenCode plugin instance with loop management and sandboxing. * @@ -410,6 +479,11 @@ export function createForgePlugin(config: PluginConfig): Plugin { let cleanupPromise: Promise | null = null + // Host-session sandbox controller: reconciles the acknowledged host sandbox preference for + // sessions outside any loop. Assigned once a sandbox manager exists; disposed in cleanup. + let sessionSandboxController: SessionSandboxController | null = null + let sessionSandboxProjectId: string | null = null + const cleanup = (): Promise => { if (cleanupPromise) { return cleanupPromise @@ -425,16 +499,36 @@ export function createForgePlugin(config: PluginConfig): Plugin { logger.log('Loop: active loops preserved during plugin cleanup') loopHandler.clearAllRetryTimeouts() - - closeDatabase(db) - logger.log('Plugin cleanup complete') + + // Disposal and DB close must both be exception-safe: a rejected controller disposal (e.g. + // a failed container removal or acknowledgement persistence) must never prevent the SQLite + // handle from closing. The error is logged and swallowed so cleanup completes and the + // idempotent cleanupPromise still resolves. + try { + // Release rather than dispose: the controller is shared by every plugin instance in this + // process for this project, and only the last release may tear it down. + if (sessionSandboxProjectId) { + await releaseSessionSandboxController(sessionSandboxProjectId) + } + } catch (err) { + logger.error('Error during session sandbox controller disposal', err) + } finally { + closeDatabase(db) + logger.log('Plugin cleanup complete') + } })() return cleanupPromise } - const handleExit = cleanup const handleSigint = cleanup const handleSigterm = cleanup + // The `exit` event fires once the event loop has drained and cannot await asynchronous work, + // so it must never run the async disposal (container removal, applied-OFF persistence) — that + // work would be cut off mid-flight. The awaited shutdown runs through the + // `server.instance.disposed` event and the SIGINT/SIGTERM handlers (which keep the process + // alive while their async cleanup completes). This listener is registered so shutdown + // bookkeeping is explicit and cleaned up consistently with the other signals. + const handleExit = () => {} process.once('exit', handleExit) process.once('SIGINT', handleSigint) @@ -499,14 +593,58 @@ export function createForgePlugin(config: PluginConfig): Plugin { sessionLoopResolver, logger, }) - // Resolves sandbox context for a session by following parent hops until an - // active sandbox loop is found. Returns null if no sandbox is active for - // the session or its ancestor. - async function resolveSandboxForSession(sessionID: string) { - const resolved = await sessionLoopResolver.resolveActiveLoopForSession(sessionID) - return resolveSandboxContextForLoop(sandboxManager, resolved, logger) + + // Host-session sandbox controller: reconciles the acknowledged host sandbox preference for + // sessions outside any loop. Always constructed — even when sandbox routing is unavailable + // (sandbox disabled, manager init failure, or no shell shim) — so a requested ON is + // acknowledged as OFF-with-error and the selected session is blocked fail-closed instead of + // silently executing on the host. Its initial reconcile runs before hooks are returned so + // acknowledged state is live at startup. + // Shared per project across every plugin instance in this process: a second reconciler would + // race this one on the same container. Only the first instance constructs and starts one, and + // it gets its own database handle so it never depends on that instance's lifetime. + const sharedSessionSandbox = acquireSessionSandboxController(projectId, () => { + const controllerDb = initializeDatabase(dataDir, { completedLoopTtlMs: config.completedLoopTtlMs }) + return { + close: () => closeDatabase(controllerDb), + controller: createSessionSandboxController({ + projectId, + directory, + preferences: createSessionSandboxPreferencesRepo(controllerDb), + sandboxManager: sandboxManager ?? createUnavailableSandboxLifecycleManager(runtime), + getParentSessionId: parentSessionLookup, + getSessionDirectory: sessionDirectoryLookup, + resolveActiveLoopForSession: sessionLoopResolver.resolveActiveLoopForSession, + logger, + }), + } + }) + sessionSandboxController = sharedSessionSandbox.controller + sessionSandboxProjectId = projectId + try { + await sharedSessionSandbox.started + } catch (err) { + // Startup must be exception-safe: a rejected controller start (e.g. the initial reconcile + // fails on a persistence or session-lookup error) must not leave SQLite or the process + // listeners open. Run the idempotent cleanup (which stops the controller and closes the DB) + // before rethrowing so the plugin fails closed without leaking resources. + logger.error('Session sandbox controller failed to start; cleaning up', err) + await cleanup() + throw err } + // Unified, loop-first sandbox resolver. Loop resolution always takes precedence: an active + // sandbox loop owns its sessions; an active non-sandbox loop forces host (a host preference + // cannot override loop/worktree behavior); only sessions with no active loop consult the + // acknowledged host-session sandbox. Callers opt into fail-closed behavior via + // { throwOnRestoreError: true }. + const resolveSandboxForSession = createUnifiedSandboxResolver({ + resolveActiveLoopForSession: sessionLoopResolver.resolveActiveLoopForSession, + resolveLoopSandbox: (resolved, opts) => resolveSandboxContextForLoop(sandboxManager, resolved, logger, opts), + resolveHostSandbox: (sessionID, opts) => + sessionSandboxController ? sessionSandboxController.resolveSandboxForSession(sessionID, opts) : Promise.resolve(null), + }) + // Spawns an isolated agent session (splitter/architect) seeded with a single text prompt, // using the configured auditor model. Single source of truth for group agent bring-up. async function spawnAgentSession(title: string, text: string, agent: string): Promise<{ sessionId: string }> { @@ -729,8 +867,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { } })(), 'shell.env': createShellEnvHook({ - resolveActiveLoopForSession: sessionLoopResolver.resolveActiveLoopForSession, - sandboxManager, + resolveSandboxForSession, getUserConfiguredShell: () => userConfiguredShell, logger, }), @@ -764,7 +901,17 @@ export function createForgePlugin(config: PluginConfig): Plugin { await planApprovalEventHook(eventInput) }, 'tool.execute.before': async (input, output) => { - const resolved = await sessionLoopResolver.resolveActiveLoopForSession(input.sessionID) + // Loop bookkeeping (activity recording, permission patching) is best-effort for tool + // routing: a transient ancestry/session lookup failure must never reject native file and + // management tools (`read`, `edit`, `write`, ...), which are host-side by design. + // Sandbox-routed tools (bash/glob/grep) still fail closed through their own resolver + // calls, so shell + search isolation is not weakened. + let resolved: Awaited> | null = null + try { + resolved = await sessionLoopResolver.resolveActiveLoopForSession(input.sessionID) + } catch (err) { + logger.debug(`[tool-before] loop resolution failed for session ${input.sessionID}: ${err instanceof Error ? err.message : String(err)}`) + } if (resolved) { logger.log(`[tool-before] ${input.tool} callID=${input.callID} session=${input.sessionID} loop=${resolved.loopName} sandbox=${resolved.sandbox ? 'yes' : 'no'}`) if (resolved.active) { @@ -776,7 +923,12 @@ export function createForgePlugin(config: PluginConfig): Plugin { await sandboxBeforeHook!(input, output) }, 'tool.execute.after': async (input, output) => { - const resolved = await sessionLoopResolver.resolveActiveLoopForSession(input.sessionID) + let resolved: Awaited> | null = null + try { + resolved = await sessionLoopResolver.resolveActiveLoopForSession(input.sessionID) + } catch (err) { + logger.debug(`[tool-after] loop resolution failed for session ${input.sessionID}: ${err instanceof Error ? err.message : String(err)}`) + } if (resolved) { logger.log(`[tool-after] ${input.tool} callID=${input.callID} output=${output.output?.slice(0, 200)}`) if (resolved.active) { diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index 95d23cbed6..6935dc7717 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -382,16 +382,31 @@ export function createSandboxManager( const active = activeSandboxes.get(worktreeName) const containerName = active?.containerName || runtime.sandboxContainerName(worktreeName) + // Cleanup (env file, in-memory map entry) always runs; the removal failure is rethrown so + // callers that own the container lifecycle (e.g. the session-sandbox controller) can observe + // that the container may still be live instead of recording a successful stop. + let removalError: unknown = null try { await runtime.removeSandbox(containerName) logger.log(`Sandbox ${containerName} removed`) } catch (err) { + removalError = err const errMsg = err instanceof Error ? err.message : String(err) logger.log(`Sandbox ${containerName} removal: ${errMsg}`) } finally { - if (active?.envFile) rmSync(active.envFile, { force: true }) + // Cleanup of the in-memory map entry must never be skipped: an env-file deletion failure + // must not leave stale manager state that would trigger indefinite fail-closed retries for a + // container that was already removed. + if (active?.envFile) { + try { + rmSync(active.envFile, { force: true }) + } catch (err) { + logger.log(`Sandbox: failed to remove env file ${active.envFile}: ${err instanceof Error ? err.message : String(err)}`) + } + } activeSandboxes.delete(worktreeName) } + if (removalError) throw removalError } function getActive(worktreeName: string): ActiveSandbox | null { diff --git a/src/sandbox/session-controller.ts b/src/sandbox/session-controller.ts new file mode 100644 index 0000000000..7a38d50b76 --- /dev/null +++ b/src/sandbox/session-controller.ts @@ -0,0 +1,995 @@ +import { createHash, randomUUID } from 'node:crypto' +import { resolve } from 'path' +import type { Logger } from '../types' +import type { SessionSandboxAppliedState, SessionSandboxDesiredState, SessionSandboxPreferencesRepo } from '../storage' +import type { SandboxContext } from './context' +import type { SandboxRuntime } from './sbx' +import type { ActiveSandbox } from './manager' + +export const DEFAULT_POLL_INTERVAL_MS = 500 + +/** Error recorded on the applied row when a host sandbox is refused for an active loop session. */ +export const LOOP_SESSION_REFUSED_ERROR = 'host sandbox cannot be enabled for an active loop session' + +/** + * Error used when the host-session sandbox runtime is unavailable (sandbox disabled, manager + * initialization failed, or no shell shim). A requested ON is acknowledged as OFF with this + * error and the selected session is blocked fail-closed rather than running on the host. + */ +export const UNAVAILABLE_SANDBOX_ERROR = 'host-session sandbox is unavailable (sandbox runtime not initialized)' + +/** Error recorded on the applied row when an ON request carries no session to bind. */ +export const MISSING_SESSION_ERROR = 'host sandbox cannot be enabled without a session' + +/** + * Minimum surface of `SandboxManager` the controller relies on. Kept narrow so the + * reconciler is decoupled from the full manager and easy to fake in tests. + */ +export interface SessionSandboxLifecycleManager { + runtime: SandboxRuntime + ensureRunning(worktreeName: string, projectDir: string, startedAt?: string): Promise + stop(worktreeName: string): Promise + getActive(worktreeName: string): ActiveSandbox | null +} + +export type ResolveActiveLoopForSession = (sessionId: string) => Promise<{ active: boolean; sandbox?: boolean } | null> + +/** + * Fail-closed lifecycle manager used when no real sandbox manager exists (sandbox disabled, + * manager/shims unavailable). Every start attempt fails so a requested ON is acknowledged as + * OFF with an error and the selected session is blocked from host fallback; stop is a no-op + * because no container was ever started. + */ +export function createUnavailableSandboxLifecycleManager(runtime: SandboxRuntime): SessionSandboxLifecycleManager { + return { + runtime, + async ensureRunning() { + throw new Error(UNAVAILABLE_SANDBOX_ERROR) + }, + async stop() {}, + getActive() { + return null + }, + } +} + +export interface SessionSandboxControllerDeps { + projectId: string + directory: string + preferences: SessionSandboxPreferencesRepo + sandboxManager: SessionSandboxLifecycleManager + getParentSessionId(sessionId: string): Promise + /** + * Resolves the directory owning a session. Used to gate reconciliation so only the plugin + * instance that owns the requested session acts on the shared preference rows (loop-worktree + * child instances share the same project DB but different directories). Optional: when absent + * every session is treated as owned. + */ + getSessionDirectory?(sessionId: string): Promise + /** + * Resolves whether a session belongs to an active loop. Used to refuse binding a host sandbox + * to a loop session (loop-first resolution ignores the host binding). Optional: when absent + * loop refusal is skipped. + */ + resolveActiveLoopForSession?: ResolveActiveLoopForSession + logger: Logger + pollIntervalMs?: number +} + +export interface ResolveSandboxSessionOpts { + throwOnRestoreError?: boolean +} + +export interface SessionSandboxController { + start(): Promise + resolveSandboxForSession(sessionId: string, opts?: ResolveSandboxSessionOpts): Promise + getState(): SessionSandboxAppliedState | null + dispose(): Promise +} + +/** + * Maximum number of ancestor hops to walk when matching a session to the acknowledged + * root session, mirroring `session-loop-resolver` so deeply nested sub-agents resolve. + */ +const MAX_PARENT_DEPTH = 10 + +/** Cap on reconcile re-runs within a single tick when the desired revision keeps moving. */ +const MAX_SUPERSEDE_ITERATIONS = 8 + +/** + * Derives the logical manager key for a project. This is a stable, non-final key passed to + * `SandboxManager.ensureRunning`/`stop`; `sbx.sandboxContainerName` remains the only place the + * `forge-` prefix is added. Keyed by project id to match the granularity of the desired/applied + * preference rows, which are stored per project: one project row therefore maps to exactly one + * host container even when the project spans several checkout directories. Deterministic so a + * clean restart resolves to the same underlying container. + */ +export function deriveManagerKey(projectId: string): string { + const digest = createHash('sha256').update(projectId).digest('hex') + return `host-session-${digest.slice(0, 12)}` +} + +function freshRevision(): string { + return randomUUID() +} + +/** + * Owns persisted reconciliation, in-memory acknowledged binding, host-container lifecycle, + * and descendant matching for one project directory's session sandbox. + */ +export function createSessionSandboxController(deps: SessionSandboxControllerDeps): SessionSandboxController { + const { projectId, directory, preferences, sandboxManager, logger } = deps + const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS + const managerKey = deriveManagerKey(projectId) + + let acknowledgedSessionId: string | null = null + /** + * The applied revision at which `acknowledgedSessionId` was bound ON. Kept alongside the + * in-memory root so a restore failure is attributed to this binding's own revision, never to the + * current shared applied row (which may have since moved to another session's acknowledgement). + */ + let acknowledgedRevision: string | null = null + let hostActive = false + let lastApplied: SessionSandboxAppliedState | null = null + /** + * The selected session whose host sandbox failed to start (or was refused). Kept distinct from + * the acknowledged binding so resolution fails closed for that session and its descendants + * instead of returning null (which integrated hooks would treat as permission for host execution). + */ + let failedSelection: { sessionId: string; error: string } | null = null + /** + * The applied ON revision whose runtime validation (ensureRunning) has already been performed and + * bound in memory. Lets an already-applied successful ON revision skip container work on idle + * reconcile ticks, while a fresh instance (null) still validates the runtime on startup. + */ + let lastValidatedRevision: string | null = null + /** + * True while restoring a persisted successful ON (the trusted-ON reconcile branch) before the + * container lifecycle is confirmed (`hostActive`) or a rollback completes. After an unclean + * restart a live container may already exist for this manager key, so a startup that aborts + * (reconcile throws) while this is set must still tear the manager key down during disposal + * even though `hostActive` was never established. + */ + let restoringPersistedOn = false + /** + * True when a cleanup stop failed and the host container may still be live. Pending cleanup is + * retried before any further start attempt, so a partially-initialized or orphaned container is + * never adopted and acknowledged ON. Cleared only once removal is confirmed. + */ + let pendingCleanup = false + /** + * The desired revision whose failed ON start is deferred by `pendingCleanup`. Lets the reconcile + * settle that specific failure OFF-with-error once removal succeeds, while a superseding desired + * revision is still processed normally. + */ + let pendingCleanupRevision: string | null = null + let intervalId: ReturnType | null = null + let reconciling = false + let disposed = false + let startPromise: Promise | null = null + let disposePromise: Promise | null = null + + /** + * Single serialization point for every lifecycle mutation: reconciliation, disposal, and the + * container-restore half of resolution. Guarantees these critical sections never interleave, so + * an in-flight start/stop can never be overridden by a concurrent one. + */ + let lifecycleTail: Promise = Promise.resolve() + function serialized(fn: () => Promise): Promise { + const run = lifecycleTail.then(fn, fn) + lifecycleTail = run.then( + () => undefined, + () => undefined, + ) + return run + } + + function bind(sessionId: string | null, revision: string | null = null): void { + acknowledgedSessionId = sessionId + hostActive = sessionId !== null + acknowledgedRevision = sessionId !== null ? revision : null + } + + function writeApplied(state: SessionSandboxAppliedState): void { + preferences.setApplied(projectId, state) + lastApplied = state + } + + function restoreFromApplied(applied: SessionSandboxAppliedState, desired: SessionSandboxDesiredState): void { + lastApplied = applied + // Success is signalled by an exact `null` error, not a falsy one: an empty-string error (or + // any non-null error) records a failed start and must remain fail-closed after a restart. + if (applied.enabled && applied.error === null) { + bind(applied.sessionId, applied.revision) + failedSelection = null + return + } + acknowledgedSessionId = null + hostActive = false + acknowledgedRevision = null + // A desired-ON that never successfully applied (failed start or loop refusal) must keep the + // selected session blocked from host fallback after a restart, where the persisted applied + // row is the only record of the failure. + const sid = applied.sessionId ?? desired.sessionId + failedSelection = desired.enabled && applied.error !== null && sid + ? { sessionId: sid, error: applied.error } + : null + } + + /** + * Classifies ownership of `sessionId` for this instance: confirmed local, confirmed foreign, or + * uncertain. Without a `getSessionDirectory` dep every session is local. A session whose + * directory cannot be resolved (lookup returns null or throws) is `uncertain`, not foreign: in a + * loop-worktree child instance the directory-scoped lookup returns null for a root session it + * cannot see, so claiming local ownership would start the wrong sandbox and overwrite the shared + * acknowledgement — but treating it as confirmed foreign would leave a matching ON row untouched + * while resolution returns null (host fallback). Uncertain ownership therefore fails closed. + */ + async function resolveOwnership(sessionId: string | null): Promise<'local' | 'foreign' | 'uncertain'> { + if (sessionId == null || !deps.getSessionDirectory) return 'local' + let dir: string | null + try { + dir = await deps.getSessionDirectory(sessionId) + } catch { + return 'uncertain' + } + if (!dir) return 'uncertain' + return resolve(dir) === resolve(directory) ? 'local' : 'foreign' + } + + /** + * True when `sessionId` equals `root` or is an ancestor-chain descendant of it. Mirrors the + * depth cap and cycle guard used by `session-loop-resolver`. + */ + async function isWithinSession(root: string, sessionId: string): Promise { + if (!root || !sessionId) return false + if (sessionId === root) return true + const seen = new Set([sessionId]) + let current = sessionId + for (let depth = 0; depth < MAX_PARENT_DEPTH; depth++) { + const parent = await deps.getParentSessionId(current) + if (!parent || seen.has(parent)) break + seen.add(parent) + if (parent === root) return true + current = parent + } + return false + } + + /** + * Best-effort removal of the host container. Returns true when removal is confirmed; false when + * it failed and the container may still be live. On false the caller must retain `hostActive` + * so the next reconcile tick retries the removal rather than orphaning a live container. + */ + async function bestEffortStop(): Promise { + try { + await sandboxManager.stop(managerKey) + // A confirmed successful removal resolves any pending cleanup. + pendingCleanup = false + return true + } catch (err) { + logger.log(`[session-sandbox] best-effort stop failed: ${err instanceof Error ? err.message : String(err)}`) + return false + } + } + + /** + * Fails closed when the given session is the acknowledged failed selection or a descendant. + * Revalidates semantically (by the selected session, not object identity): reconciliation + * recreates the identical failed-selection object each time it re-records the same failure, so + * identity changes spuriously during slow parent lookups and would exhaust the retry cap, + * letting a descendant fall through to host execution. If the selection moves to a different + * session mid-lookup the match is recomputed; on retry exhaustion the call fails closed. + */ + async function blockIfFailedSelection(sessionId: string): Promise { + for (let i = 0; i < MAX_SUPERSEDE_ITERATIONS; i++) { + const selection = failedSelection + if (!selection) return + const matched = await isWithinSession(selection.sessionId, sessionId) + const current = failedSelection + if (!current) return + if (current.sessionId === selection.sessionId) { + if (matched) { + throw new Error(`Host sandbox unavailable for the selected session: ${current.error}`) + } + return + } + } + throw new Error(`Host sandbox unavailable for the selected session: ${failedSelection?.error ?? 'unknown'}`) + } + + /** + * Handles a failed ON start uniformly: clears the binding, records the failure, best-effort- + * removes any partially-started (or previously running) container, and acknowledges applied + * OFF-with-error, retaining retryable ownership when the removal fails. Shared by fresh starts + * and persisted-ON restores so a partial creation is never leaked and a transient removal + * failure is never acknowledged settled. + */ + async function handleFailedOnStart( + desired: SessionSandboxDesiredState | null, + sessionId: string, + msg: string, + ): Promise { + bind(null) + lastValidatedRevision = null + failedSelection = { sessionId, error: msg } + // A failed start may leave a partially-started (or previously running) container, even on a + // first start: ensureRunning can create the container and then fail (e.g. env-file + // generation). Always attempt deterministic-key cleanup; if it fails, retain retryable + // ownership so the next reconcile tick retries the removal rather than acknowledging the + // failure settled while a container is still live. + const stopped = await bestEffortStop() + if (!stopped) { + pendingCleanup = true + pendingCleanupRevision = desired?.revision ?? null + hostActive = true + return + } + hostActive = false + restoringPersistedOn = false + if (desired) { + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, + sessionId, + error: msg, + appliedAt: Date.now(), + }) + } + } + + /** + * Tears down a binding whose container recovery failed after the desired selection had already + * moved to a newer binding (e.g. ON(A) superseded by ON(B) while A's container dies). The failed + * binding's container is removed but no acknowledgement is written: recording this failure at + * A's (now old) revision would overwrite the newer applied acknowledgement. The caller re-runs + * reconciliation so the current desired revision is applied. + */ + async function handleSupersededRestoreFailure(): Promise { + bind(null) + lastValidatedRevision = null + failedSelection = null + restoringPersistedOn = false + const stopped = await bestEffortStop() + if (!stopped) { + // The failed binding's container could not be removed; it may still be live. Retain retryable + // ownership so no superseding start adopts or acknowledges it until removal succeeds. + pendingCleanup = true + hostActive = true + return + } + hostActive = false + } + + /** + * Acts on a single desired/applied pair. Returns the desired revision processed, or null + * when there is no desired state (controller remains off). Actual SBX start/stop always + * completes before the matching applied row is written. + */ + async function reconcilePair( + desired: SessionSandboxDesiredState | null, + applied: SessionSandboxAppliedState | null, + ): Promise { + if (!desired) { + if (hostActive) { + const stopped = await bestEffortStop() + if (!stopped) { + // Removal failed; the container may still be live. Retain hostActive so the next + // reconcile tick retries the removal instead of orphaning a live container. + acknowledgedSessionId = null + failedSelection = null + return null + } + hostActive = false + } + acknowledgedSessionId = null + failedSelection = null + return null + } + + const appliedAtDesiredRevision = applied != null && applied.revision === desired.revision + const trustedOn = + appliedAtDesiredRevision && + desired.enabled && + applied.enabled && + applied.error === null && + desired.sessionId != null && + applied.sessionId != null && + desired.sessionId === applied.sessionId + + // Only the plugin instance owning the requested session may act on the shared preference rows + // or the shared host container. A loop-worktree child instance (same project DB, different + // directory) must never acknowledge, stop, or supersede the root instance's sandbox, and vice + // versa. Ownership is resolved before `restoringPersistedOn` is set because that flag makes + // disposal stop the manager key, which only the owner may do. + const ownership = await resolveOwnership(desired.sessionId) + if (ownership !== 'local') { + // Only remove a container this instance actually started. The manager key is derived from + // the project id, so every instance of this project resolves the same container: a + // pre-existing container for that key belongs to whichever instance owns the session, and + // stopping it here would tear down the owner's sandbox. The owner's own reconcile adopts or + // removes it instead. A failed stop must not clear lifecycle tracking: the container may + // still be live, so retain retryable ownership (hostActive true) and let the next reconcile + // tick retry removal rather than orphaning a container the new owner will never see. The + // foreign acknowledgement is never overwritten. + if (hostActive) { + try { + await sandboxManager.stop(managerKey) + } catch (err) { + logger.log( + `[session-sandbox] stop failed during ownership transfer: ${err instanceof Error ? err.message : String(err)}`, + ) + // A failed stop means the container may still be live. Retain retryable ownership and + // record pending cleanup so no superseding start can adopt or acknowledge it until + // removal succeeds. + hostActive = true + pendingCleanup = true + lastValidatedRevision = null + acknowledgedSessionId = null + acknowledgedRevision = null + failedSelection = null + return desired.revision + } + hostActive = false + lastValidatedRevision = null + } + bind(null) + // Uncertain ownership with an ON request must fail closed: this instance could not confirm it + // owns the selected session (e.g. a transient directory-lookup failure), so blocking host + // fallback is safer than running tools on the host while the shared ON row is left untouched. + // Re-evaluated on the next reconcile tick once ownership can be confirmed. + if (ownership === 'uncertain' && desired.enabled && desired.sessionId) { + failedSelection = { + sessionId: desired.sessionId, + error: 'Host sandbox ownership could not be confirmed for the selected session', + } + } else { + failedSelection = null + } + return desired.revision + } + + // Ownership is confirmed local from here. A matching successful persisted ON (applied at the + // desired revision) implies a container may already be running for the project's deterministic + // manager key (e.g. an unclean restart). Track it as potentially owning this key BEFORE the + // remaining fallible work: if this reconcile returns before the lifecycle is confirmed, + // disposal must still tear the key down rather than leak a pre-existing container. + if (trustedOn) { + restoringPersistedOn = true + } + + // A previous start/stop left a container that could not be removed. Retry removal before any + // start attempt so a partially-initialized or orphaned container is never adopted and + // acknowledged ON. Only once removal is confirmed do we fall through to act on the desired + // state. Returning the current revision (desired unchanged) makes the next reconcile tick + // retry the removal. + if (pendingCleanup) { + const stopped = await bestEffortStop() + if (!stopped) return desired.revision + pendingCleanup = false + hostActive = false + lastValidatedRevision = null + // This pending cleanup is a deferred failed-ON-start acknowledgement (a start failed and the + // follow-up removal also failed). Now that removal has succeeded, settle that specific + // failure OFF-with-error rather than falling through to retry the ON start, which could adopt + // the orphaned container and wrongly acknowledge it ON. Only when the desired intent has since + // moved to a superseding revision do we fall through to act on the newest state. + if (desired.enabled && pendingCleanupRevision !== null && desired.revision === pendingCleanupRevision && failedSelection) { + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, + sessionId: failedSelection.sessionId, + error: failedSelection.error, + appliedAt: Date.now(), + }) + pendingCleanupRevision = null + return desired.revision + } + pendingCleanupRevision = null + } + + // Already applied at this exact revision. A matching revision only proves the applied row + // corresponds to the same requested intent, not that it is safe to restore: an inconsistent + // pair (desired OFF but applied ON, a null or mismatched session, or an error under an ON + // intent) must never start or restore the wrong sandbox, so fall through to re-act the + // desired state whenever the applied row is not trustworthy. + if (applied && applied.revision === desired.revision) { + const trustedOn = + desired.enabled && + applied.enabled && + applied.error === null && + desired.sessionId != null && + applied.sessionId != null && + desired.sessionId === applied.sessionId + + if (trustedOn) { + // `restoringPersistedOn` was already set before the ownership check, so a restore that + // aborts before `hostActive` is confirmed still tears the manager key down on disposal. + // Recheck that the selected session has not since entered an active loop. Loop-first + // resolution ignores the host binding, so a session that started a loop while host SBX was + // ON must have its host sandbox stopped and the acknowledgement flipped to OFF-with-error; + // otherwise the container keeps running and the sidebar stays ON while the loop actually + // runs unsandboxed. This runs on every trusted-ON tick (the cheap lookup, not container + // work), so a loop membership change is always caught even though the desired revision is + // unchanged. + if (deps.resolveActiveLoopForSession) { + const inLoop = await deps.resolveActiveLoopForSession(desired.sessionId!) + if (inLoop?.active) { + const stopped = await bestEffortStop() + if (!stopped) { + // Removal failed; the container may still be live. Retain ownership (hostActive stays + // true) and block the selected session fail-closed so the next tick retries the + // removal before the refusal is acknowledged settled. + failedSelection = { sessionId: desired.sessionId!, error: LOOP_SESSION_REFUSED_ERROR } + return desired.revision + } + bind(null) + lastValidatedRevision = null + restoringPersistedOn = false + failedSelection = { sessionId: desired.sessionId!, error: LOOP_SESSION_REFUSED_ERROR } + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, + sessionId: desired.sessionId, + error: LOOP_SESSION_REFUSED_ERROR, + appliedAt: Date.now(), + }) + return desired.revision + } + } + // Restoring a persisted successful ON must confirm the current lifecycle manager can + // actually provide the sandbox. After an unclean restart the manager may be unavailable + // (initialization failed) even though the applied row still records a matching ON; trusting + // that row blindly would expose a false ON sidebar while tool restoration fails. Validate + // the runtime and, when unavailable, acknowledge OFF-with-error and block the session. + // Validation runs only once per ON revision: an already-validated idle sandbox must not + // call ensureRunning on every reconcile tick. + if (lastValidatedRevision !== desired.revision) { + try { + await sandboxManager.ensureRunning(managerKey, directory) + lastValidatedRevision = desired.revision + } catch (err) { + // A persisted-ON restore that partially creates the container and then fails must run + // deterministic-key cleanup and retry a transient removal failure before the OFF + // acknowledgement is settled, exactly like a fresh start. + await handleFailedOnStart(desired, desired.sessionId!, err instanceof Error ? err.message : String(err)) + return desired.revision + } + } + restoreFromApplied(applied, desired) + restoringPersistedOn = false + return desired.revision + } + + // Desired ON already acknowledged OFF-with-error at this revision (failed start or loop + // refusal): never hot-retry the failed start; restore the fail-closed failure state. + if (desired.enabled && applied.enabled === false && applied.error !== null) { + restoreFromApplied(applied, desired) + return desired.revision + } + + // Desired OFF acknowledged OFF-with-error: a prior stop failed and the container may + // still be live. Retry removal until it succeeds; a matching OFF with no error is settled, + // and an OFF-with-error under a desired ON is a failed start (handled above, never hot-retried). + if (!desired.enabled && applied.enabled === false && applied.error !== null) { + try { + await sandboxManager.stop(managerKey) + } catch (err) { + // Preserve retryable ownership: the container may still be live, so keep hostActive true + // and the next reconcile tick retries the removal. The applied OFF-with-error row is left + // as-is (it already records the failure). + acknowledgedSessionId = null + hostActive = true + failedSelection = desired.sessionId ? { sessionId: desired.sessionId, error: String(err) } : null + return desired.revision + } + hostActive = false + acknowledgedSessionId = null + failedSelection = null + lastValidatedRevision = null + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, + sessionId: desired.sessionId, + error: null, + appliedAt: Date.now(), + }) + return desired.revision + } + + // Desired OFF settled (applied OFF, no error): restore the settled state and return. + if (!desired.enabled && applied.enabled === false && applied.error === null) { + restoreFromApplied(applied, desired) + return desired.revision + } + + // Any other matching-revision pair is inconsistent (e.g. desired OFF but applied ON, or an + // ON intent with a null/mismatched session): fall through to re-act the desired state so the + // persisted acknowledgement is corrected and no wrong sandbox is started or restored. + } + + if (desired.enabled) { + // Reject an ON request that carries no session to bind: starting a container for a null + // session would orphan it (bind(null) clears ownership, so it could never be used or + // cleaned up). Acknowledge OFF-with-error instead of starting SBX. + if (!desired.sessionId) { + // No container is ever started for a null-session request; only stop a live container from + // a prior binding (hostActive) to avoid leaking it when the selection becomes session-less. + if (hostActive) { + const stopped = await bestEffortStop() + if (!stopped) { + // Removal failed; the container may still be live. Retain hostActive so the next tick + // retries the removal before the null-session state is acknowledged settled. + acknowledgedSessionId = null + failedSelection = null + return desired.revision + } + hostActive = false + lastValidatedRevision = null + } + bind(null) + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, + sessionId: null, + error: MISSING_SESSION_ERROR, + appliedAt: Date.now(), + }) + return desired.revision + } + // Refuse to bind a host sandbox to an active loop session: loop-first resolution ignores + // the host binding, so acknowledging ON here would report an SBX state that is never used. + if (deps.resolveActiveLoopForSession) { + const inLoop = await deps.resolveActiveLoopForSession(desired.sessionId) + if (inLoop?.active) { + acknowledgedSessionId = null + lastValidatedRevision = null + // Retain the refused session as a failed selection so it and its descendants remain + // blocked fail-closed even after the loop terminates (before the next reconciliation + // tick). Clearing it here would create a window where the loop-first resolver sees no + // active loop and returns host fallback for a session the user requested be sandboxed. + failedSelection = { sessionId: desired.sessionId, error: LOOP_SESSION_REFUSED_ERROR } + // Stop the deterministic key even when hostActive is false: after an unclean restart a + // stale container for this key can still be live (a prior session's ON survived the + // crash), and a refused loop session must not leave it running. + const stopped = await bestEffortStop() + if (!stopped) { + // Removal failed; the container may still be live. Retain retryable ownership so the + // next tick retries before the refusal is acknowledged settled. + hostActive = true + return desired.revision + } + hostActive = false + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, + sessionId: desired.sessionId, + error: LOOP_SESSION_REFUSED_ERROR, + appliedAt: Date.now(), + }) + return desired.revision + } + } + try { + await sandboxManager.ensureRunning(managerKey, directory) + } catch (err) { + await handleFailedOnStart(desired, desired.sessionId, err instanceof Error ? err.message : String(err)) + return desired.revision + } + // Persist the applied-ON acknowledgement BEFORE committing the in-memory binding. The + // resolution matching phase runs off the lifecycle lock, so a binding set before the write + // would let a concurrent resolution expose an acknowledged root whose ON row is not yet (and + // may never be) persisted. If the write fails, roll back through handleFailedOnStart, which + // clears the binding and stops the just-started container, so no unacknowledged sandbox is + // used and none leaks. + failedSelection = null + try { + writeApplied({ + version: 1, + revision: desired.revision, + enabled: true, + sessionId: desired.sessionId, + error: null, + appliedAt: Date.now(), + }) + } catch (err) { + await handleFailedOnStart(desired, desired.sessionId, err instanceof Error ? err.message : String(err)) + return desired.revision + } + bind(desired.sessionId, desired.revision) + lastValidatedRevision = desired.revision + } else { + // A failed stop may leave the container live. Acknowledge OFF with the error so the next + // startup never believes the container is stopped, and so the selected session stays + // fail-closed while the removal is retried on the next reconcile tick. Preserve hostActive + // so the retry actually happens (the matching-revision branch re-attempts the stop). + try { + await sandboxManager.stop(managerKey) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + acknowledgedSessionId = null + hostActive = true + lastValidatedRevision = null + failedSelection = desired.sessionId ? { sessionId: desired.sessionId, error: msg } : null + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, + sessionId: desired.sessionId, + error: msg, + appliedAt: Date.now(), + }) + return desired.revision + } + hostActive = false + acknowledgedSessionId = null + failedSelection = null + lastValidatedRevision = null + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, + sessionId: desired.sessionId, + error: null, + appliedAt: Date.now(), + }) + } + return desired.revision + } + + /** + * Full reconciliation: acts on the current desired revision, then re-reads and keeps acting + * if the desired moved on during an in-flight operation, so the newest revision always wins. + * Writing the older applied revision in the interim is safe because this loop re-runs. + */ + async function reconcile(): Promise { + for (let i = 0; i < MAX_SUPERSEDE_ITERATIONS; i++) { + const desired = preferences.getDesired(projectId) + const applied = preferences.getApplied(projectId) + const actedRevision = await reconcilePair(desired, applied) + if (actedRevision === null) return + const latest = preferences.getDesired(projectId) + if (!latest || latest.revision === actedRevision) return + } + } + + async function tick(): Promise { + if (disposed || reconciling) return + reconciling = true + try { + await serialized(() => reconcile()) + } catch (err) { + logger.error(`[session-sandbox] reconcile failed: ${err instanceof Error ? err.message : String(err)}`) + } finally { + reconciling = false + } + } + + async function resolveSandboxForSession( + sessionId: string, + opts?: ResolveSandboxSessionOpts, + ): Promise { + if (disposed) return null + + // Fail closed: a selected session (or its descendants) whose host sandbox failed to start + // must never fall through to host execution. Throwing blocks the tool call rather than + // returning null, which integrated hooks interpret as permission to run on the host. + // `revalidateFailedSelection` re-checks before each null return because a concurrent + // reconciliation can record a failure and clear the binding while this resolution is in + // flight; without it a session whose sandbox start just failed would fall through to host. + const revalidateFailedSelection = (): Promise => blockIfFailedSelection(sessionId) + + await blockIfFailedSelection(sessionId) + + const root = acknowledgedSessionId + // The applied revision at which `root` was acknowledged ON, kept on the controller's own + // binding (not re-read from the shared applied row). An ON(A)->ON(B) rebind that lands during + // a restore must not attribute A's failure to B's revision, which would overwrite B's + // acknowledgement and orphan B's container. + const rootAppliedRevision = acknowledgedRevision + if (!root) { + await revalidateFailedSelection() + return null + } + + // Read-only matching phase, kept off the lifecycle lock so it never stalls reconciliation. + const matched = await isWithinSession(root, sessionId) + if (!matched) { + await revalidateFailedSelection() + return null + } + if (disposed) return null + + // Container restore and return are serialized with reconciliation/disposal and revalidate that + // the acknowledged root is still the root this session matched against, so an OFF, a disposal, + // or an ON(A)->ON(B) transition that wins during the async work makes this return null without + // exposing another root's sandbox. + return serialized(async () => { + if (disposed) return null + if (acknowledgedSessionId !== root) { + await revalidateFailedSelection() + return null + } + try { + await sandboxManager.ensureRunning(managerKey, directory) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + logger.log(`[session-sandbox] ensureRunning failed during restore: ${msg}`) + // A container-restore failure (e.g. env-file generation) can leave a partially-created or + // orphaned container and a stale applied-ON row. Route through the same cleanup and + // OFF-with-error transition as a failed start so the live container is removed, the stale + // acknowledgement is corrected, and the selected session stays fail-closed. Attribute the + // failure to the binding being recovered (root, at its own applied revision) so a + // superseding desired revision is processed normally instead of being marked failed. But + // only when the current desired revision still belongs to this binding: if the selection + // has since moved on (ON(A) superseded by ON(B) while A's recovery fails), recording this + // failure at A's old revision would overwrite B's newer applied acknowledgement. + const desiredNow = preferences.getDesired(projectId) + const stillCurrent = desiredNow != null && desiredNow.revision === rootAppliedRevision + if (stillCurrent) { + await handleFailedOnStart( + { + version: 1, + revision: rootAppliedRevision ?? freshRevision(), + enabled: true, + sessionId: root, + requestedAt: Date.now(), + }, + root, + msg, + ) + } else { + // The selection moved on while this binding's recovery failed. Tear this binding's + // container down without overwriting the newer applied acknowledgement, then re-run + // reconciliation so the current desired revision is applied. The failed binding's + // session is no longer selected, so it is not recorded as a failed selection. + await handleSupersededRestoreFailure() + try { + await reconcile() + } catch (reconcileErr) { + logger.log( + `[session-sandbox] reconcile after superseded restore failure failed: ${reconcileErr instanceof Error ? reconcileErr.message : String(reconcileErr)}`, + ) + } + } + if (opts?.throwOnRestoreError) throw err + return null + } + if (disposed) return null + if (acknowledgedSessionId !== root) { + await revalidateFailedSelection() + return null + } + const active = sandboxManager.getActive(managerKey) + if (!active) return null + return { + runtime: sandboxManager.runtime, + containerName: active.containerName, + hostDir: active.projectDir, + mounts: active.mounts ?? [{ hostDir: active.projectDir, containerDir: active.projectDir }], + envFile: active.envFile, + } + }) + } + + return { + async start(): Promise { + // Single-flight: concurrent or repeated calls share one start, so exactly one interval is + // ever installed and every caller waits for the initial reconciliation to complete. + if (startPromise) return startPromise + startPromise = (async () => { + // Startup reconciliation must not swallow errors: if persisted desired state cannot be + // reconciled (e.g. a transient DB or session-lookup failure), the caller (plugin startup) + // fails closed rather than returning with an ON indicator but no restored runtime binding, + // which would leave selected tools executing host-side. Steady-state ticks below swallow + // errors and retry on the next interval. + if (!disposed) await serialized(() => reconcile()) + if (disposed) return + if (intervalId === null) { + intervalId = setInterval(() => { + void tick() + }, pollIntervalMs) + } + })() + return startPromise + }, + + resolveSandboxForSession, + + getState(): SessionSandboxAppliedState | null { + return lastApplied + }, + + async dispose(): Promise { + // Single-flight: concurrent shutdown paths all await the same cleanup, so the sandbox is + // stopped and applied OFF is persisted before any caller resolves. + if (disposePromise) return disposePromise + // Mark disposed eagerly (before the serialized body) so any in-flight resolution or + // reconciliation revalidates against it and returns null rather than restoring a container. + disposed = true + if (intervalId !== null) { + clearInterval(intervalId) + intervalId = null + } + disposePromise = serialized(async () => { + // Stop this controller's own container before any fallible bookkeeping, so a transient DB + // or ownership-lookup failure can never skip container removal. The selection may have + // rebounded to another instance's session before this instance reconciled; disposal must + // still tear down the container it started rather than leaking it. `restoringPersistedOn` + // covers a startup that aborted mid-restore of a persisted ON, where an unclean restart may + // have left a live container even though `hostActive` was never set. + if (hostActive || restoringPersistedOn) { + try { + await sandboxManager.stop(managerKey) + hostActive = false + restoringPersistedOn = false + lastValidatedRevision = null + } catch (err) { + // A failed stop means the container may still be live. Record the failure so cleanup + // never falsely acknowledges completion: applied OFF with a null error would tell the + // next startup the sandbox is stopped when it is not. Only write when this instance + // owns the current desired session so a non-owner cannot overwrite another instance's + // shared acknowledgement; the actual owner observes the live container on its own poll. + const msg = err instanceof Error ? err.message : String(err) + let desired: SessionSandboxDesiredState | null = null + let owned = false + try { + desired = preferences.getDesired(projectId) + owned = desired != null && (await resolveOwnership(desired.sessionId)) === 'local' + } catch { + // A failed read must not mask the stop failure; we simply skip the failure write. + } + if (owned && desired) { + writeApplied({ + version: 1, + revision: desired.enabled ? freshRevision() : desired.revision, + enabled: false, + sessionId: desired.sessionId, + error: msg, + appliedAt: Date.now(), + }) + } + bind(null) + failedSelection = null + return + } + } + // Write OFF at the current desired revision so a TUI request still awaiting its own + // acknowledgement observes the OFF result (a fresh revision would be ignored as stale). + // Desired is left ON, so the next startup re-applies it: a matching-revision desired ON with + // a settled applied OFF is re-acted to start the container and acknowledge ON again. Skip + // the write when this instance does not own the requested session so it cannot overwrite + // another instance's acknowledgement for a shared project DB. + let desired: SessionSandboxDesiredState | null = null + let owned = false + try { + desired = preferences.getDesired(projectId) + owned = desired != null && (await resolveOwnership(desired.sessionId)) === 'local' + } catch { + // A transient bookkeeping failure after the container is confirmed stopped must not + // abort disposal; the container is already removed, so the applied row is left as-is. + } + if (owned && desired) { + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, + sessionId: desired.sessionId, + error: null, + appliedAt: Date.now(), + }) + } + bind(null) + failedSelection = null + }).then(() => undefined) + return disposePromise + }, + } +} + diff --git a/src/services/unified-sandbox-resolver.ts b/src/services/unified-sandbox-resolver.ts new file mode 100644 index 0000000000..e7da45f76c --- /dev/null +++ b/src/services/unified-sandbox-resolver.ts @@ -0,0 +1,126 @@ +import type { SandboxContext } from '../sandbox/context' +import type { ResolvedLoop } from './session-loop-resolver' + +export interface ResolveSandboxForSessionOpts { + throwOnRestoreError?: boolean +} + +export interface UnifiedSandboxResolverDeps { + resolveActiveLoopForSession(sessionID: string): Promise + /** Resolves the loop-owned sandbox context for an active sandbox loop. */ + resolveLoopSandbox(resolved: ResolvedLoop, opts?: ResolveSandboxForSessionOpts): Promise + /** Resolves the acknowledged host-session sandbox for a session outside any loop. */ + resolveHostSandbox(sessionID: string, opts?: ResolveSandboxForSessionOpts): Promise +} + +function unavailableLoopError(loopName: string): Error { + return new Error(`Sandbox container for loop "${loopName}" is unavailable; refusing to run the command on the host.`) +} + +/** + * Bounded revalidation retries. After an asynchronous loop sandbox restoration a loop may have + * terminated, changed mode, or been replaced; loop membership is re-checked and re-routed up to + * this many times so a stale loop context is never returned. A loop that keeps changing identity + * past this cap falls back to the most recently resolved loop context rather than looping forever. + */ +const MAX_REVALIDATION_RETRIES = 4 + +/** + * The single loop-first sandbox resolver feeding bash, glob, and grep. Loop resolution always + * takes precedence: an active sandbox loop owns its sessions; an active non-sandbox loop forces + * host (a host preference cannot override loop/worktree behavior); only sessions with no active + * loop consult the acknowledged host-session sandbox. + * + * The host fallback is computed asynchronously (parent lookups, container restore), during which + * a loop can start. Loop membership is therefore revalidated after the deferred host resolution + * so a command never receives the host-session sandbox for a session that has just joined a loop. + * Likewise, loop membership is revalidated after every asynchronous loop sandbox restoration so a + * loop that terminates, changes mode, or is replaced while `ensureRunning` is pending never + * returns (or recreates) a stale loop container. + */ +export function createUnifiedSandboxResolver( + deps: UnifiedSandboxResolverDeps, +): (sessionID: string, opts?: ResolveSandboxForSessionOpts) => Promise { + /** + * Resolves and returns the sandbox context for an active sandbox loop, revalidating loop + * membership after the asynchronous restoration so a stale loop context is never returned. + */ + async function resolveLoop( + sessionID: string, + resolved: ResolvedLoop, + opts: ResolveSandboxForSessionOpts | undefined, + depth: number, + ): Promise { + let sandbox: SandboxContext | null = null + let error: unknown + try { + sandbox = await deps.resolveLoopSandbox(resolved, opts) + } catch (err) { + error = err + } + // Revalidate loop membership after the asynchronous restore for every outcome (restored, null, + // or rejected). The loop may have terminated, changed mode, or been replaced while + // `ensureRunning` was pending; loop-first precedence must reflect the current state, never the + // stale loop captured before the restore. + const now = await deps.resolveActiveLoopForSession(sessionID) + if (now?.active && now.sandbox && now.loopName === resolved.loopName) { + // The same loop is still active: its restored context (or failure) is authoritative. + if (error !== undefined) throw error + if (!sandbox && opts?.throwOnRestoreError) throw unavailableLoopError(resolved.loopName) + return sandbox + } + // Loop membership changed (or was lost) during the restore. Re-route to the current state while + // the retry budget lasts; on exhaustion fail closed rather than return (or recreate) a stale + // loop container. + if (depth <= 0) { + if (now?.active && now.sandbox) throw unavailableLoopError(now.loopName) + if (error !== undefined) throw error + throw unavailableLoopError(resolved.loopName) + } + if (now?.active && now.sandbox) return resolveLoop(sessionID, now, opts, depth - 1) + if (now?.active) return null + return resolveSession(sessionID, opts, depth - 1) + } + + /** + * Full loop-first resolution for a session, re-running with a bounded depth when loop membership + * moves during an asynchronous step. + */ + async function resolveSession( + sessionID: string, + opts: ResolveSandboxForSessionOpts | undefined, + depth: number, + ): Promise { + const resolved = await deps.resolveActiveLoopForSession(sessionID) + if (resolved?.active && resolved.sandbox) { + return resolveLoop(sessionID, resolved, opts, depth) + } + if (resolved?.active) return null + + // Resolve the host sandbox, then revalidate loop membership whether the host path succeeds or + // rejects. A loop may start during the asynchronous host resolution (ensureRunning / parent + // lookups); loop-first precedence must win even for a host fallback computed before the loop + // existed, and even when the host resolution itself failed (a stale host error must never + // override loop-first routing). + let context: SandboxContext | null + try { + context = await deps.resolveHostSandbox(sessionID, opts) + } catch (err) { + const now = await deps.resolveActiveLoopForSession(sessionID) + if (now?.active && now.sandbox) { + return resolveLoop(sessionID, now, opts, depth) + } + if (now?.active) return null + throw err + } + + const now = await deps.resolveActiveLoopForSession(sessionID) + if (now?.active && now.sandbox) { + return resolveLoop(sessionID, now, opts, depth) + } + if (now?.active) return null + return context + } + + return (sessionID, opts) => resolveSession(sessionID, opts, MAX_REVALIDATION_RETRIES) +} diff --git a/src/storage/index.ts b/src/storage/index.ts index 65dd674c29..d2644ac128 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -23,3 +23,6 @@ export type { PlanRow } from './repos/plans-repo' export { createFeatureGroupsRepo } from './repos/feature-groups-repo' export type { FeatureGroupRow, GroupFeatureRow } from './repos/feature-groups-repo' + +export { createSessionSandboxPreferencesRepo, SESSION_SANDBOX_DESIRED_KEY, SESSION_SANDBOX_APPLIED_KEY } from './repos/session-sandbox-preferences-repo' +export type { SessionSandboxDesiredState, SessionSandboxAppliedState, SessionSandboxPreferencesRepo } from './repos/session-sandbox-preferences-repo' diff --git a/src/storage/repos/session-sandbox-preferences-repo.ts b/src/storage/repos/session-sandbox-preferences-repo.ts new file mode 100644 index 0000000000..6c7fd8f2a4 --- /dev/null +++ b/src/storage/repos/session-sandbox-preferences-repo.ts @@ -0,0 +1,147 @@ +import type { Database } from 'bun:sqlite' + +export const SESSION_SANDBOX_DESIRED_KEY = 'session-sandbox.desired' +export const SESSION_SANDBOX_APPLIED_KEY = 'session-sandbox.applied' + +export interface SessionSandboxDesiredState { + version: 1 + revision: string + enabled: boolean + sessionId: string | null + requestedAt: number +} + +export interface SessionSandboxAppliedState { + version: 1 + revision: string + enabled: boolean + sessionId: string | null + error: string | null + appliedAt: number +} + +export interface SessionSandboxPreferencesRepo { + getDesired(projectId: string): SessionSandboxDesiredState | null + setDesired(projectId: string, state: SessionSandboxDesiredState): void + getApplied(projectId: string): SessionSandboxAppliedState | null + setApplied(projectId: string, state: SessionSandboxAppliedState): void + getPair(projectId: string): SessionSandboxPreferencePair +} + +export interface SessionSandboxPreferencePair { + desired: SessionSandboxDesiredState | null + applied: SessionSandboxAppliedState | null +} + +function parseDesired(data: unknown): SessionSandboxDesiredState | null { + if (typeof data !== 'object' || data === null) return null + const o = data as Record + if (o.version !== 1) return null + if (typeof o.revision !== 'string' || o.revision.trim() === '') return null + if (typeof o.enabled !== 'boolean') return null + if (o.sessionId !== null && (typeof o.sessionId !== 'string' || o.sessionId.trim() === '')) return null + if (typeof o.requestedAt !== 'number' || !Number.isFinite(o.requestedAt)) return null + return { + version: 1, + revision: o.revision, + enabled: o.enabled, + sessionId: o.sessionId as string | null, + requestedAt: o.requestedAt, + } +} + +function parseApplied(data: unknown): SessionSandboxAppliedState | null { + if (typeof data !== 'object' || data === null) return null + const o = data as Record + if (o.version !== 1) return null + if (typeof o.revision !== 'string' || o.revision.trim() === '') return null + if (typeof o.enabled !== 'boolean') return null + if (o.sessionId !== null && (typeof o.sessionId !== 'string' || o.sessionId.trim() === '')) return null + if (o.error !== null && typeof o.error !== 'string') return null + if (typeof o.appliedAt !== 'number' || !Number.isFinite(o.appliedAt)) return null + return { + version: 1, + revision: o.revision, + enabled: o.enabled, + sessionId: o.sessionId as string | null, + error: o.error as string | null, + appliedAt: o.appliedAt, + } +} + +interface PreferenceRow { + data: string +} + +export function createSessionSandboxPreferencesRepo(db: Database): SessionSandboxPreferencesRepo { + const getDesiredStmt = db.prepare(` + SELECT data FROM tui_preferences + WHERE project_id = ? AND key = ? + `) + + const getAppliedStmt = db.prepare(` + SELECT data FROM tui_preferences + WHERE project_id = ? AND key = ? + `) + + const upsertStmt = db.prepare(` + INSERT INTO tui_preferences (project_id, key, data, expires_at, updated_at) + VALUES (?, ?, ?, NULL, ?) + ON CONFLICT(project_id, key) DO UPDATE SET + data = excluded.data, + expires_at = NULL, + updated_at = excluded.updated_at + `) + + const now = () => Date.now() + + function readDesired(projectId: string): SessionSandboxDesiredState | null { + const row = getDesiredStmt.get(projectId, SESSION_SANDBOX_DESIRED_KEY) as PreferenceRow | null + if (!row) return null + let parsed: unknown + try { + parsed = JSON.parse(row.data) + } catch { + return null + } + return parseDesired(parsed) + } + + function readApplied(projectId: string): SessionSandboxAppliedState | null { + const row = getAppliedStmt.get(projectId, SESSION_SANDBOX_APPLIED_KEY) as PreferenceRow | null + if (!row) return null + let parsed: unknown + try { + parsed = JSON.parse(row.data) + } catch { + return null + } + return parseApplied(parsed) + } + + return { + getDesired: readDesired, + + setDesired(projectId: string, state: SessionSandboxDesiredState): void { + const ts = now() + upsertStmt.run(projectId, SESSION_SANDBOX_DESIRED_KEY, JSON.stringify(state), ts) + }, + + getApplied: readApplied, + + setApplied(projectId: string, state: SessionSandboxAppliedState): void { + const ts = now() + upsertStmt.run(projectId, SESSION_SANDBOX_APPLIED_KEY, JSON.stringify(state), ts) + }, + + getPair(projectId: string): SessionSandboxPreferencePair { + // Both reads run inside one transaction so they observe a single SQLite + // snapshot. Without this, a concurrent desired write between the two + // autocommit reads could assemble revisions from different snapshots and + // briefly trust a superseded ON state. + return db.transaction(() => { + return { desired: readDesired(projectId), applied: readApplied(projectId) } + })() + }, + } +} diff --git a/src/tui.tsx b/src/tui.tsx index 095d3edbff..2315ef1ee4 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -10,11 +10,23 @@ import type { PluginConfig } from './types' import { createSbxRuntime } from './sandbox/sbx' import { buildAndLoadSandboxTemplate } from './sandbox/template' import { runCommand } from './sandbox/process' +import { isSandboxConfigEnabled } from './sandbox/context' import { tmpdir } from 'os' +import { existsSync } from 'fs' import { resolveLoopPermissionOptions } from './constants/loop' import { emitLoopPermissionConfigWarnings } from './utils/loop-permission-warnings' -import { connectForgeProject, type ForgeProjectClient } from './utils/tui-client' +import { connectForgeProject, resolveTuiProjectId, type ForgeProjectClient } from './utils/tui-client' import { ExecutePlanPanel, type ExecutePlanPanelProps } from './tui/execute-plan-panel' +import { + awaitSessionSandboxState, + beginSessionSandboxStateRequest, + deriveSessionSandboxAcknowledged, + hostSandboxToggleBlocked, + isSessionSandboxPreferenceSettled, + readSessionSandboxPreference, +} from './tui/session-sandbox-store' +import type { SessionSandboxPreference } from './tui/session-sandbox-store' +import type { SessionSandboxAppliedState } from './storage' import { attachLoopSessionFollower, getCurrentRouteSessionId } from './tui/session-follow' import { openInBrowser, startDashboardServer, type DashboardServerHandle } from './dashboard/launch' import { describeDashboardBinding } from './dashboard/config' @@ -23,11 +35,13 @@ import { normalizePastedPlanText } from './utils/marked-plan-parser' type TuiKeybinds = { executePlan: string dashboard: string + toggleHostSandbox: string } const DEFAULT_KEYBINDS: TuiKeybinds = { executePlan: 'f', dashboard: '', + toggleHostSandbox: '', } type TuiOptions = { @@ -38,7 +52,22 @@ type TuiOptions = { type ForgeConnectionStatus = 'connecting' | 'connected' | 'unavailable' -function ForgeSidebarStatus(props: { api: TuiPluginApi; opts: TuiOptions; status: () => ForgeConnectionStatus }) { +function SandboxStatusText(props: { api: TuiPluginApi; applied: () => SessionSandboxAppliedState | null; sessionId?: string }) { + const theme = () => props.api.theme.current + const on = createMemo(() => { + const applied = props.applied() + return !!applied && applied.enabled === true && applied.error == null && applied.sessionId === props.sessionId + }) + return · SBX = {on() ? 'on' : 'off'} +} + +function ForgeSidebarStatus(props: { + api: TuiPluginApi + opts: TuiOptions + status: () => ForgeConnectionStatus + applied: () => SessionSandboxAppliedState | null + sessionId?: string +}) { const theme = () => props.api.theme.current const title = createMemo(() => props.opts.showVersion ? `Forge v${VERSION}` : 'Forge') const statusText = createMemo(() => props.status() === 'connecting' ? 'connecting' : 'RPC unavailable') @@ -50,6 +79,7 @@ function ForgeSidebarStatus(props: { api: TuiPluginApi; opts: TuiOptions; status {title()} + · {statusText()} @@ -64,6 +94,7 @@ function SidebarContainer(props: { pluginConfig: PluginConfig opts: TuiOptions status: () => ForgeConnectionStatus + applied: () => SessionSandboxAppliedState | null sessionId?: string }) { const currentClient = createMemo(() => props.client()) @@ -71,9 +102,9 @@ function SidebarContainer(props: { return ( } + fallback={} > - {(client) => } + {(client) => } ) } @@ -84,6 +115,7 @@ function Sidebar(props: { cache: () => ExecutionContextCache | null pluginConfig: PluginConfig opts: TuiOptions + applied: () => SessionSandboxAppliedState | null sessionId?: string }) { const theme = () => props.api.theme.current @@ -99,6 +131,7 @@ function Sidebar(props: { {title()} + @@ -280,6 +313,194 @@ const tui: TuiPlugin = async (api) => { }) }) + // Shared disposal flag for the sidebar client and the sandbox preference + // init/toggle paths. Registered here so it also guards work that runs with + // the sidebar disabled. + let disposed = false + let retryTimer: ReturnType | null = null + let sandboxPollTimer: ReturnType | null = null + let sandboxInitTimer: ReturnType | null = null + api.lifecycle.onDispose(() => { + disposed = true + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + if (sandboxPollTimer) { + clearTimeout(sandboxPollTimer) + sandboxPollTimer = null + } + if (sandboxInitTimer) { + clearTimeout(sandboxInitTimer) + sandboxInitTimer = null + } + }) + + // Host-sandbox acknowledgement state for the current project. Initialized + // once `api.state.ready` so the toggle command works with the sidebar + // disabled. ON is trusted only when the desired/applied revisions match, + // both target the same session, and applied carries no error. + const [sandboxProjectId, setSandboxProjectId] = createSignal(null) + const [sandboxApplied, setSandboxApplied] = createSignal(null) + let sandboxInitStarted = false + + const refreshSandboxAcknowledgement = (projectId: string): SessionSandboxPreference | null => { + if (disposed) return null + // When sandboxing is disabled by configuration the server never constructs a + // reconciler or uses that sandbox, so any persisted ON must not be displayed. + if (!isSandboxConfigEnabled(pluginConfig)) { + if (!disposed) setSandboxApplied(null) + return null + } + const pref = readSessionSandboxPreference(projectId, forgeDbPath) + if (!disposed) setSandboxApplied(deriveSessionSandboxAcknowledged(pref)) + return pref + } + + // Shared acknowledgement-following loop. Whenever a new desired revision is + // written (initial restore or a toggle) it keeps polling the local preference + // pair until it settles, updating the acknowledged signal each step. This runs + // independently of the command timeout so a late acknowledgement — one the + // server applies after the toggle's 15s wait expires — still reaches the + // sidebar instead of leaving it stale until restart. + const ensureSandboxPolling = (projectId: string): void => { + if (disposed || sandboxPollTimer) return + const step = (): void => { + if (disposed) return + sandboxPollTimer = null + if (!isSandboxConfigEnabled(pluginConfig)) return + const pref = refreshSandboxAcknowledgement(projectId) + if (!pref) return + // Keep polling even after the pair settles at a low frequency so a later server + // acknowledgement — e.g. a periodic restore that fails and flips ON into OFF-with-error — + // is eventually reflected in the sidebar without a toggle or restart. Poll faster while + // unsettled or the local DB is not yet available so a matching acknowledgement is displayed + // promptly. + const settled = !pref.unavailable && isSessionSandboxPreferenceSettled(pref) + sandboxPollTimer = setTimeout(step, settled ? 5000 : 1500) + } + step() + } + + createEffect(() => { + if (!api.state.ready || sandboxInitStarted) return + sandboxInitStarted = true + void (async () => { + // Retry transient project discovery with bounded, disposal-aware polling so a temporarily + // failing lookup (or a forge.db that is not yet available) does not permanently leave the + // acknowledged state OFF for this process. Polling below also retries unavailable DB reads. + let projectId: string | null = null + while (!disposed && !projectId) { + projectId = await resolveTuiProjectId(api, directory) + if (disposed || projectId) break + await new Promise((resolve) => { + sandboxInitTimer = setTimeout(() => { + sandboxInitTimer = null + resolve() + }, 1500) + }) + } + if (disposed) return + setSandboxProjectId(projectId) + if (!projectId) return + // Poll until the preference pair settles. On a clean restart the applied + // row can lag the persisted desired state while the server reconciles, so + // a single read would leave ON invisible forever. ensureSandboxPolling + // reuses the store reader and stops as soon as the pair is settled. + ensureSandboxPolling(projectId) + })() + }) + + const runToggleHostSandbox = async () => { + const toggleBlocked = hostSandboxToggleBlocked(isSandboxConfigEnabled(pluginConfig)) + if (toggleBlocked) { + api.ui.toast({ message: toggleBlocked, variant: 'warning', duration: 5000 }) + return + } + const sessionId = getCurrentRouteSessionId(api) + if (!sessionId) { + api.ui.toast({ message: 'Open a session first', variant: 'info', duration: 3000 }) + return + } + // Re-resolve the project ID lazily when a prior attempt failed so a transient + // discovery failure does not permanently disable the toggle for this process. + let projectId = sandboxProjectId() + if (!projectId) { + projectId = await resolveTuiProjectId(api, directory) + if (disposed) return + setSandboxProjectId(projectId) + } + // Each failure below reports a distinct cause. The TUI has no usable log sink (console output + // corrupts the rendered screen, which is why the sbx runtime here is given a no-op logger), so + // the reason has to travel in the toast or it is lost entirely. + if (!projectId) { + api.ui.toast({ message: 'Sandbox toggle unavailable: could not resolve this project', variant: 'warning', duration: 5000 }) + return + } + if (!existsSync(forgeDbPath)) { + api.ui.toast({ message: `Sandbox toggle unavailable: no Forge database at ${forgeDbPath}`, variant: 'warning', duration: 5000 }) + return + } + const pref = readSessionSandboxPreference(projectId, forgeDbPath) + // A snapshot flagged as unavailable (missing/uninitialized table) must not be treated as "no + // persisted state": deriving from null here could issue an ON request that the server never + // acknowledges, or misreport the current state. Reject it before deriving or writing. + if (pref.unavailable) { + const reason = pref.unavailableReason ?? 'unknown reason' + api.ui.toast({ message: `Sandbox toggle unavailable: Forge preferences unreadable (${reason})`, variant: 'warning', duration: 5000 }) + return + } + const { desired } = pref + const turningOff = desired?.enabled === true && desired.sessionId === sessionId + const nextEnabled = !turningOff + let revision: string | null = null + try { + revision = beginSessionSandboxStateRequest(projectId, forgeDbPath, { + sessionId, + enabled: nextEnabled, + }) + if (disposed) return + // Immediately re-derive the acknowledged state from the authoritative + // desired/applied pair. The new desired revision supersedes the prior + // applied acknowledgement, so a stale ON (previous revision/session) is + // cleared before the request even resolves. + refreshSandboxAcknowledgement(projectId) + // Follow this desired revision to its acknowledgement independently of the + // command timeout, so a late server apply still reaches the sidebar. + ensureSandboxPolling(projectId) + const applied = await awaitSessionSandboxState(projectId, forgeDbPath, revision, { + timeoutMs: 15_000, + pollMs: 250, + signal: api.lifecycle.signal, + }) + if (disposed) return + // Re-read the authoritative desired/applied pair before publishing state or + // success. A superseded acknowledgement (a newer toggle already moved the + // desired revision) must not render stale ON; only a current revision still + // warrants a success toast. + const pref = refreshSandboxAcknowledgement(projectId) + if (pref?.desired && pref.desired.revision === applied.revision) { + api.ui.toast({ + message: `Host sandbox ${applied.enabled ? 'enabled' : 'disabled'} for this session`, + variant: 'success', + duration: 4000, + }) + } + } catch (err) { + if (disposed) return + // The failed request may have superseded a prior acknowledged state with a + // new desired revision that never got applied. Re-read the authoritative + // pair and re-derive so a stale ON for the previous session is cleared. + const pref = refreshSandboxAcknowledgement(projectId) + // Suppress errors for superseded requests: when a newer toggle already + // moved the desired revision, this waiter is stale and must not report a + // false failure long after the latest request succeeded. + if (pref?.desired && revision && pref.desired.revision !== revision) return + const message = err instanceof Error ? err.message : String(err) + api.ui.toast({ message: `Sandbox toggle failed: ${message}`, variant: 'error', duration: 6000 }) + } + } + // Auto-follow loop session rotations. Runs independently of the sidebar // option so users with the sidebar disabled still get follow-on-rotation. const detachSessionFollower = attachLoopSessionFollower(api) @@ -363,11 +584,22 @@ const tui: TuiPlugin = async (api) => { namespace: 'palette', run: () => { runBuildSandboxImage() }, }, + { + name: 'forge.sandbox.toggleHost', + title: 'Toggle host sandbox', + desc: 'Enable or disable the host sandbox for the current session', + category: 'Forge', + namespace: 'palette', + run: () => { void runToggleHostSandbox() }, + }, ], bindings: [ ...(opts.keybinds.dashboard ? [{ key: opts.keybinds.dashboard, cmd: 'forge.dashboard' as const }] : []), + ...(opts.keybinds.toggleHostSandbox + ? [{ key: opts.keybinds.toggleHostSandbox, cmd: 'forge.sandbox.toggleHost' as const }] + : []), ], }) @@ -377,17 +609,7 @@ const tui: TuiPlugin = async (api) => { const [connectionStatus, setConnectionStatus] = createSignal('connecting') const [executionContextCache, setExecutionContextCache] = createSignal(null) let connectPromise: Promise | null = null - let disposed = false let unavailableToastShown = false - let retryTimer: ReturnType | null = null - - api.lifecycle.onDispose(() => { - disposed = true - if (retryTimer) { - clearTimeout(retryTimer) - retryTimer = null - } - }) const showUnavailableToast = () => { if (unavailableToastShown) return @@ -561,6 +783,7 @@ const tui: TuiPlugin = async (api) => { pluginConfig={pluginConfig} opts={opts} status={connectionStatus} + applied={sandboxApplied} sessionId={slotProps.session_id} /> }, diff --git a/src/tui/session-sandbox-store.ts b/src/tui/session-sandbox-store.ts new file mode 100644 index 0000000000..d32e985ed8 --- /dev/null +++ b/src/tui/session-sandbox-store.ts @@ -0,0 +1,253 @@ +import { Database } from 'bun:sqlite' +import { existsSync } from 'fs' +import { randomUUID } from 'node:crypto' +import { resolveForgeDbPath } from '../storage' +import { createSessionSandboxPreferencesRepo } from '../storage/repos/session-sandbox-preferences-repo' +import type { SessionSandboxAppliedState, SessionSandboxDesiredState } from '../storage/repos/session-sandbox-preferences-repo' + +/** + * Opens the local forge database for a bounded TUI operation. Returns null when + * the file is missing so an uninitialized instance is never implicitly created + * as a second, empty database. The server owns the schema (WAL, migrations, + * integrity recovery); the TUI only applies `busy_timeout` and never runs + * migrations or bootstrap. + */ +function openForgeDb(dbPathOverride?: string): Database | null { + const dbPath = dbPathOverride || resolveForgeDbPath() + if (!existsSync(dbPath)) return null + // `readwrite` must be set explicitly: bun:sqlite derives its open flags from these options, and + // `{ create: false }` alone yields neither READONLY nor READWRITE, which SQLite rejects outright. + // The store also writes desired state, so readonly is not sufficient. + const db = new Database(dbPath, { readwrite: true, create: false }) + try { + db.run('PRAGMA busy_timeout=5000') + } catch (err) { + db.close() + throw err + } + return db +} + +export interface SessionSandboxPreference { + desired: SessionSandboxDesiredState | null + applied: SessionSandboxAppliedState | null + /** + * True when the read could not reach an initialized `tui_preferences` table for the project + * (missing database file, uninitialized table, or unreadable/corrupt file). This lets callers + * distinguish "no persisted state" from "the local DB is not available yet" so they can retry + * instead of permanently treating a transient startup failure as OFF. + */ + unavailable?: boolean + /** + * Why the read was unavailable, for logging. Distinguishes a missing database file from an + * unreadable or uninitialized one so the failure is diagnosable instead of silently opaque. + */ + unavailableReason?: string +} + +/** + * Returns a blocking reason when the toggle must not write desired state, or + * null to proceed. When sandboxing is disabled by configuration the server + * never constructs a reconciler, so a persisted request could never be + * acknowledged and would linger until it is unexpectedly applied after + * sandboxing is re-enabled. + */ +export function hostSandboxToggleBlocked(configEnabled: boolean): string | null { + if (!configEnabled) return 'Host sandbox is disabled by config (sandbox.enabled: false)' + return null +} + +/** + * Returns the trusted applied state for a preference pair, or null. ON is trusted + * only when the desired and applied revisions match, both target the same session, + * desired is enabled, and the applied row carries no error. Stale or mismatched + * revisions always derive to null so a late or superseded acknowledgement never + * falsely reports ON. + */ +export function deriveSessionSandboxAcknowledged( + pref: SessionSandboxPreference, +): SessionSandboxAppliedState | null { + const { desired, applied } = pref + if ( + desired && + applied && + desired.revision === applied.revision && + desired.enabled && + applied.enabled && + desired.sessionId === applied.sessionId && + applied.error == null + ) { + return applied + } + return null +} + +/** + * Returns true when the preference pair has reached a terminal state and no + * further polling is needed: either no desired state is persisted, or the + * applied row carries the desired revision (regardless of enabled/error). A + * pair is pending only while a desired state awaits its matching applied + * acknowledgement. + */ +export function isSessionSandboxPreferenceSettled(pref: SessionSandboxPreference): boolean { + const { desired, applied } = pref + if (!desired) return true + if (!applied) return false + return applied.revision === desired.revision +} + +/** + * Reads the desired and applied sandbox rows for a project from the local forge + * database. Falls back to both null when the database or table is unavailable. + */ +export function readSessionSandboxPreference(projectId: string, dbPath?: string): SessionSandboxPreference { + let db: Database | null = null + try { + db = openForgeDb(dbPath) + if (!db) { + return { desired: null, applied: null, unavailable: true, unavailableReason: 'database file not found' } + } + const repo = createSessionSandboxPreferencesRepo(db) + return { ...repo.getPair(projectId), unavailable: false } + } catch (err) { + const reason = err instanceof Error ? err.message : String(err) + return { desired: null, applied: null, unavailable: true, unavailableReason: reason } + } finally { + try { + db?.close() + } catch { + // ignore close errors + } + } +} + +/** + * Persists a desired sandbox state through the repository's single atomic + * upsert. Propagates write errors (missing table, locked, etc.) to the caller. + */ +export function writeSessionSandboxDesired( + projectId: string, + dbPath: string | undefined, + state: SessionSandboxDesiredState, +): void { + let db: Database | null = null + try { + db = openForgeDb(dbPath) + if (!db) throw new Error('Forge database unavailable for local sandbox control') + createSessionSandboxPreferencesRepo(db).setDesired(projectId, state) + } finally { + try { + db?.close() + } catch { + // ignore close errors + } + } +} + +export interface RequestSessionSandboxStateOptions { + projectId: string + dbPath?: string + sessionId: string + enabled: boolean + timeoutMs: number + pollMs: number + signal?: AbortSignal +} + +function createRevision(): string { + return randomUUID() +} + +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Sandbox state request cancelled')) + return + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, ms) + const onAbort = (): void => { + clearTimeout(timer) + reject(new Error('Sandbox state request cancelled')) + } + signal?.addEventListener('abort', onAbort) + }) +} + +/** + * Writes a fresh desired revision synchronously and returns it. Callers that + * need to re-derive acknowledged state immediately (before the matching applied + * acknowledgement arrives) use this to split request creation from awaiting it. + */ +export function beginSessionSandboxStateRequest( + projectId: string, + dbPath: string | undefined, + opts: { sessionId: string; enabled: boolean }, +): string { + const revision = createRevision() + const desired: SessionSandboxDesiredState = { + version: 1, + revision, + enabled: opts.enabled, + sessionId: opts.sessionId, + requestedAt: Date.now(), + } + writeSessionSandboxDesired(projectId, dbPath, desired) + return revision +} + +/** + * Polls the applied row until the matching revision arrives. Returns the applied + * state. Throws on a matching `error`, on timeout, or when cancelled via + * `signal`. Stale applied revisions are ignored. + */ +export async function awaitSessionSandboxState( + projectId: string, + dbPath: string | undefined, + revision: string, + opts: { timeoutMs: number; pollMs: number; signal?: AbortSignal }, +): Promise { + const start = Date.now() + while (true) { + // Check cancellation before each read so an already-aborted waiter never returns an existing + // acknowledgement; cancellation is only meaningful at read boundaries, not only while sleeping. + if (opts.signal?.aborted) throw new Error('Sandbox state request cancelled') + const { applied } = readSessionSandboxPreference(projectId, dbPath) + if (applied && applied.revision === revision) { + // A non-null error — including an empty string — rejects the request. + if (applied.error !== null) throw new Error(applied.error) + return applied + } + // Read before declaring timeout so any matching acknowledgement present by the deadline + // (including one that arrives during the final sleep) resolves successfully. + const elapsed = Date.now() - start + if (elapsed >= opts.timeoutMs) break + await abortableSleep(Math.min(opts.pollMs, opts.timeoutMs - elapsed), opts.signal) + } + throw new Error(`Timed out waiting for sandbox acknowledgement after ${opts.timeoutMs}ms`) +} + +/** + * Writes a fresh desired revision and polls the applied row until the matching + * revision arrives. Returns the applied state. Throws on a matching `error`, on + * timeout, or when cancelled via `signal`. Stale applied revisions are ignored. + */ +export async function requestSessionSandboxState( + opts: RequestSessionSandboxStateOptions, +): Promise { + // Check cancellation before writing a new desired revision so an already-aborted request never + // persists desired state the server may still apply. Otherwise a pre-cancelled request would + // reject as cancelled yet leave an orphaned desired row. + if (opts.signal?.aborted) throw new Error('Sandbox state request cancelled') + const revision = beginSessionSandboxStateRequest(opts.projectId, opts.dbPath, { + sessionId: opts.sessionId, + enabled: opts.enabled, + }) + return awaitSessionSandboxState(opts.projectId, opts.dbPath, revision, { + timeoutMs: opts.timeoutMs, + pollMs: opts.pollMs, + signal: opts.signal, + }) +} diff --git a/src/types-bun.d.ts b/src/types-bun.d.ts index 8c8048dbf7..05ce59ed6a 100644 --- a/src/types-bun.d.ts +++ b/src/types-bun.d.ts @@ -2,7 +2,7 @@ declare module 'bun:sqlite' { export class Database { - constructor(path: string, options?: { create?: boolean; readonly?: boolean }) + constructor(path: string, options?: { create?: boolean; readonly?: boolean; readwrite?: boolean }) run(sql: string, ...params: unknown[]): void prepare(sql: string): Statement close(): void diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 6659db6e11..e141e0120f 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -34,6 +34,10 @@ export function createLogger(config: LoggingConfig, options?: { clearOnInit?: bo const isEnabled = config.enabled const isDebug = config.debug ?? false const clearOnInit = options?.clearOnInit ?? true + // Distinguishes concurrent logger instances inside a single process. Without it, two plugin + // instances in the same pid are indistinguishable from one instance repeating work, and + // clearOnInit erases the earlier instance's init line so the duplication is invisible. + const instanceId = Math.random().toString(36).slice(2, 8) if (!isEnabled) { return { @@ -74,7 +78,10 @@ export function createLogger(config: LoggingConfig, options?: { clearOnInit?: bo const timestamp = new Date().toISOString() const formattedArgs = args.length > 0 ? ' ' + args.map(formatArg).join(' ') : '' - const line = `${timestamp} ${level} ${PREFIX} ${message}${formattedArgs}\n` + // Every opencode instance on this machine shares one log file, so lines are only attributable + // to a process if each one carries its pid. Without it, concurrent work by two instances is + // indistinguishable from one instance repeating itself. + const line = `${timestamp} ${level} ${PREFIX}[${process.pid}:${instanceId}] ${message}${formattedArgs}\n` try { appendFileSync(filePath, line, 'utf-8') diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index d3cac3b35f..9a7db740c1 100644 --- a/src/utils/tui-client.ts +++ b/src/utils/tui-client.ts @@ -407,26 +407,18 @@ export async function selectTuiSession(api: TuiPluginApi, client: ForgeClient, s } } -export async function connectForgeProject( - api: TuiPluginApi, - directory?: string, - permissionOptions?: LoopPermissionRulesetOptions, - dbPath?: string, -): Promise { - tuiDebug(`connect start directory=${directory ?? 'none'}`) - - // Single client path: every SDK call in this project client goes through the - // typed ForgeClient port wrapping the TUI's v2 client. +/** + * Single shared project ID discovery used by {@link connectForgeProject} and the + * sandbox TUI initialization. Prefers OpenCode's directory-scoped + * `project.current` (which handles multi-checkout repos where extra checkouts + * live in `sandboxes` and an exact `worktree === dir` list match would fail), + * falling back to `project.list`. + */ +export async function resolveTuiProjectId(api: TuiPluginApi, directory?: string): Promise { const client = createForgeClient(api.client) - let projectId: string | null = null - + let projectId: string | null try { - // Prefer OpenCode's own directory-scoped resolution. project.current handles - // multi-checkout repos (same project id, different worktree paths): the - // project row keeps only the first-registered checkout in `worktree` while - // additional checkouts land in `sandboxes`, so an exact `worktree === dir` - // match on the list silently fails for the secondary checkout. const current = await client.project.current(directory ? { directory } : undefined) projectId = current?.id ?? null } catch { @@ -445,6 +437,28 @@ export async function connectForgeProject( } } + return projectId +} + +export async function connectForgeProject( + api: TuiPluginApi, + directory?: string, + permissionOptions?: LoopPermissionRulesetOptions, + dbPath?: string, +): Promise { + tuiDebug(`connect start directory=${directory ?? 'none'}`) + + // Single client path: every SDK call in this project client goes through the + // typed ForgeClient port wrapping the TUI's v2 client. + const client = createForgeClient(api.client) + + let projectId: string | null = null + try { + projectId = await resolveTuiProjectId(api, directory) + } catch { + projectId = null + } + if (!projectId) { tuiDebug(`discovery failed; continuing with cwd routing directory=${directory ?? 'none'}`) } else { diff --git a/test/__shims__/bun-sqlite.mjs b/test/__shims__/bun-sqlite.mjs index 372d2e34c2..f1491f4792 100644 --- a/test/__shims__/bun-sqlite.mjs +++ b/test/__shims__/bun-sqlite.mjs @@ -3,11 +3,24 @@ import BetterSqlite3 from 'better-sqlite3' class Database extends BetterSqlite3 { /** * @param {string | Buffer} pathOrHandle Database file path. - * @param {{ readonly?: boolean } | undefined} options - * Bun-compatible options object (readonly is forwarded to - * better-sqlite3). + * @param {{ readonly?: boolean, create?: boolean, readwrite?: boolean } | undefined} options + * Bun-compatible options object, translated to better-sqlite3 semantics. + * + * bun:sqlite derives SQLite open flags from these options, so an options object that implies + * neither READONLY nor READWRITE (for example `{ create: false }`) is rejected at runtime. + * better-sqlite3 has unrelated option semantics and would silently accept it, so the same + * validation is reproduced here; otherwise a call that always throws under Bun passes in tests. */ constructor(pathOrHandle, options) { + if (options !== null && typeof options === 'object') { + const readonly = options.readonly === true + const readwrite = options.readwrite === true || options.create === true + if (!readonly && !readwrite) { + throw new Error('flags must include SQLITE_OPEN_READONLY or SQLITE_OPEN_READWRITE') + } + super(pathOrHandle, { readonly, fileMustExist: options.create !== true }) + return + } super(pathOrHandle, options) } diff --git a/test/hooks/shell-env.test.ts b/test/hooks/shell-env.test.ts index 0941e735e0..748cbb6dbc 100644 --- a/test/hooks/shell-env.test.ts +++ b/test/hooks/shell-env.test.ts @@ -2,33 +2,30 @@ import { describe, test, expect, vi } from 'vitest' import { createShellEnvHook } from '../../src/hooks/shell-env' import { SHIM_ENV_CONTAINER, SHIM_ENV_ENV_FILE, SHIM_ENV_HOST_SHELL } from '../../src/sandbox/shell-shim' import type { Logger } from '../../src/types' +import type { SandboxContext } from '../../src/sandbox/context' const logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as Logger -function makeSandboxManager( - active: { containerName: string; projectDir: string; envFile?: string } | null, - opts?: { ensureRunningError?: Error }, -) { +function makeSandboxContext(overrides: Partial = {}): SandboxContext { return { - docker: {} as never, - restore: vi.fn(async () => {}), - getActive: vi.fn(() => (active ? { ...active, mounts: [] } : null)), - ensureRunning: vi.fn(async () => { - if (opts?.ensureRunningError) throw opts.ensureRunningError - return active?.containerName ?? '' - }), + runtime: {} as never, + containerName: 'forge-loop-a', + hostDir: '/wt', + mounts: [], + ...overrides, } } describe('createShellEnvHook', () => { - test('injects container and env file for an active sandbox loop session', async () => { + test('injects container and env file when a sandbox context is resolved', async () => { const hook = createShellEnvHook({ - resolveActiveLoopForSession: vi.fn(async () => ({ loopName: 'loop-a', active: true, sandbox: true, worktreeDir: '/wt' })), - sandboxManager: makeSandboxManager({ - containerName: 'forge-loop-a', - projectDir: '/wt', - envFile: '/data/forge/sandbox-env/forge-loop-a.env', - }), + resolveSandboxForSession: vi.fn(async () => + makeSandboxContext({ + containerName: 'forge-loop-a', + hostDir: '/wt', + envFile: '/data/forge/sandbox-env/forge-loop-a.env', + }), + ), getUserConfiguredShell: () => undefined, logger, }) @@ -43,8 +40,7 @@ describe('createShellEnvHook', () => { test('injects container without an env-file variable when the sandbox has none', async () => { const hook = createShellEnvHook({ - resolveActiveLoopForSession: vi.fn(async () => ({ loopName: 'loop-a', active: true, sandbox: true, worktreeDir: '/wt' })), - sandboxManager: makeSandboxManager({ containerName: 'forge-loop-a', projectDir: '/wt' }), + resolveSandboxForSession: vi.fn(async () => makeSandboxContext({ containerName: 'forge-loop-a', hostDir: '/wt' })), getUserConfiguredShell: () => undefined, logger, }) @@ -56,10 +52,9 @@ describe('createShellEnvHook', () => { expect(output.env[SHIM_ENV_ENV_FILE]).toBeUndefined() }) - test('injects nothing container-related for a non-loop session', async () => { + test('injects nothing container-related when no sandbox is resolved', async () => { const hook = createShellEnvHook({ - resolveActiveLoopForSession: vi.fn(async () => null), - sandboxManager: makeSandboxManager({ containerName: 'forge-x', projectDir: '/wt' }), + resolveSandboxForSession: vi.fn(async () => null), getUserConfiguredShell: () => undefined, logger, }) @@ -72,8 +67,7 @@ describe('createShellEnvHook', () => { test('restores the user-configured shell for non-sandbox sessions', async () => { const hook = createShellEnvHook({ - resolveActiveLoopForSession: vi.fn(async () => null), - sandboxManager: null, + resolveSandboxForSession: vi.fn(async () => null), getUserConfiguredShell: () => '/opt/homebrew/bin/fish', logger, }) @@ -87,8 +81,7 @@ describe('createShellEnvHook', () => { test('worktree-only loop sessions fall through to the host shell branch', async () => { const hook = createShellEnvHook({ - resolveActiveLoopForSession: vi.fn(async () => ({ loopName: 'loop-b', active: true, sandbox: false })), - sandboxManager: makeSandboxManager(null), + resolveSandboxForSession: vi.fn(async () => null), getUserConfiguredShell: () => undefined, logger, }) @@ -99,10 +92,11 @@ describe('createShellEnvHook', () => { expect(output.env).toEqual({}) }) - test('fails closed when the sandbox container cannot be resolved for an active sandbox loop', async () => { + test('propagates a resolver rejection when the expected sandbox cannot be resolved', async () => { const hook = createShellEnvHook({ - resolveActiveLoopForSession: vi.fn(async () => ({ loopName: 'loop-c', active: true, sandbox: true, worktreeDir: '/wt' })), - sandboxManager: makeSandboxManager(null), + resolveSandboxForSession: vi.fn(async () => { + throw new Error('Sandbox container for loop "loop-c" is unavailable; refusing to run the command on the host.') + }), getUserConfiguredShell: () => '/bin/zsh', logger, }) @@ -112,10 +106,11 @@ describe('createShellEnvHook', () => { expect(output.env).toEqual({}) }) - test('fails closed when container restore throws', async () => { + test('propagates a resolver rejection when container restore throws', async () => { const hook = createShellEnvHook({ - resolveActiveLoopForSession: vi.fn(async () => ({ loopName: 'loop-d', active: true, sandbox: true, worktreeDir: '/wt' })), - sandboxManager: makeSandboxManager({ containerName: 'forge-loop-d', projectDir: '/wt' }, { ensureRunningError: new Error('docker down') }), + resolveSandboxForSession: vi.fn(async () => { + throw new Error('docker down') + }), getUserConfiguredShell: () => undefined, logger, }) @@ -125,11 +120,24 @@ describe('createShellEnvHook', () => { expect(output.env).toEqual({}) }) + test('requests fail-closed resolution with throwOnRestoreError', async () => { + const resolve = vi.fn(async () => null) + const hook = createShellEnvHook({ + resolveSandboxForSession: resolve, + getUserConfiguredShell: () => undefined, + logger, + }) + const output = { env: {} as Record } + + await hook({ cwd: '/wt', sessionID: 'ses_5' }, output) + + expect(resolve).toHaveBeenCalledWith('ses_5', { throwOnRestoreError: true }) + }) + test('no sessionID falls through to host shell handling', async () => { const resolve = vi.fn(async () => null) const hook = createShellEnvHook({ - resolveActiveLoopForSession: resolve, - sandboxManager: null, + resolveSandboxForSession: resolve, getUserConfiguredShell: () => '/bin/bash', logger, }) diff --git a/test/parent-session-lookup.test.ts b/test/parent-session-lookup.test.ts index 703b33d41e..709a7371dd 100644 --- a/test/parent-session-lookup.test.ts +++ b/test/parent-session-lookup.test.ts @@ -95,6 +95,57 @@ describe('createParentSessionLookup', () => { await new Promise((resolve) => setTimeout(resolve, 60)) }) + test('transient session.get failures propagate instead of being cached as absence', async () => { + const sessionId = 'session-transient' + const transient = new ForgeClientError({ kind: 'connection', method: 'session.get', message: 'Unable to connect' }) + const { client } = createFakeForgeClient({ + session: { + get: async () => { throw transient }, + }, + }) + const loop = createMockLoop([]) + + const lookup = createParentSessionLookup({ + client, + directory: '/host', + loop: loop as any, + logger: mockLogger, + negativeTtlMs: 1000, + }) + + // A transient failure is not a definitive absence: it must reject so sandbox routing + // fails closed rather than caching a false "no parent" for the negative TTL. + await expect(lookup(sessionId)).rejects.toThrow(/Unable to connect/) + await expect(lookup(sessionId)).rejects.toThrow(/Unable to connect/) + }) + + test('transient failure is not negative-cached: recovery resolves once the host recovers', async () => { + const sessionId = 'session-recover' + let calls = 0 + const { client } = createFakeForgeClient({ + session: { + get: async () => { + calls++ + if (calls === 1) throw new ForgeClientError({ kind: 'unavailable', method: 'session.get', message: 'host unavailable' }) + return { parentID: 'parent-x' } + }, + }, + }) + const loop = createMockLoop([]) + + const lookup = createParentSessionLookup({ + client, + directory: '/host', + loop: loop as any, + logger: mockLogger, + negativeTtlMs: 100000, + }) + + await expect(lookup(sessionId)).rejects.toThrow(/host unavailable/) + // The failed attempt was not negative-cached, so the very next call retries and succeeds. + expect(await lookup(sessionId)).toBe('parent-x') + }) + test('listActive dirs contribute attempts in order', async () => { const sessionId = 'session-dir-test' const parentId = 'parent-from-worktree' diff --git a/test/plugin.test.ts b/test/plugin.test.ts index bbee0f7f3a..7870679ecf 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -4,12 +4,37 @@ import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from 'fs' import { join } from 'path' import type { PluginConfig } from '../src/types' import type { PluginInput } from '@opencode-ai/plugin' -import { initializeDatabase, closeDatabase, createLoopsRepo, createPlansRepo, createFeatureGroupsRepo } from '../src/storage' +import { initializeDatabase, closeDatabase, createLoopsRepo, createPlansRepo, createFeatureGroupsRepo, createSessionSandboxPreferencesRepo } from '../src/storage' const TEST_DIR = '/tmp/opencode-manager-memory-test-' + Date.now() const TEST_PROJECT_ID = 'test-proj-id-' + Date.now() +/** + * Builds a plugin `client` whose HTTP transport resolves `session.get` to a session whose + * directory is `dir`. The session directory lookup must positively prove ownership before the + * controller acts on a shared preference row; a client that cannot resolve a session means the + * instance is not its owner. See `createSessionDirectoryLookup` in `src/index.ts`. + */ +function sessionResolvingClient(dir: string) { + const mockFetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === 'string' ? input : (input as Request).url + const m = url.match(/\/session\/([^/?]+)/) + if (m) { + const sessionID = decodeURIComponent(m[1]) + return new Response(JSON.stringify({ id: sessionID, directory: dir, parentID: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return { _client: { getConfig: () => ({ fetch: mockFetch }) } } +} + describe('createForgePlugin', () => { let testDir: string let currentHooks: { getCleanup?: () => Promise } | null @@ -615,6 +640,364 @@ describe('createForgePlugin', () => { expect(logContents).toContain('loop.permissions.deny entry "*" is ignored') }) + test('host session sandbox controller is started on init and disposed before DB close', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + // Sandbox routing is disabled so the deterministic unavailable manager is used: ensureRunning + // fails closed and stop is a no-op. This keeps the test independent of whether the `sbx` CLI + // is installed on the host, while still verifying startup reconcile is awaited before hooks + // return and cleanup disposes before the DB closes. + sandbox: { mode: 'sbx', enabled: false }, + } + + // Persist a desired ON so the startup reconciliation has something to act on. + const setupDb = initializeDatabase(config.dataDir!) + createSessionSandboxPreferencesRepo(setupDb).setDesired(TEST_PROJECT_ID, { + version: 1, + revision: 'r-init', + enabled: true, + sessionId: 'ses-root', + requestedAt: Date.now(), + }) + closeDatabase(setupDb) + + const plugin = createForgePlugin(config) + const mockInput = { + directory: testDir, + worktree: testDir, + // The instance must positively prove it owns 'ses-root' (its directory resolves to this + // instance's directory) before it may act on the shared preference row. + client: sessionResolvingClient(testDir) as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + const hooks = await plugin(mockInput as unknown as PluginInput) + currentHooks = hooks as { getCleanup?: () => Promise } + + // Startup reconciliation ran and was awaited before hooks returned: the applied row now + // records the desired revision (the container start itself fails closed here since sbx is + // unavailable, but the revision still advances, proving start's reconcile completed). + let db = initializeDatabase(config.dataDir!) + let appliedAfterStart = createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID) + expect(appliedAfterStart).not.toBeNull() + expect(appliedAfterStart!.revision).toBe('r-init') + closeDatabase(db) + + await currentHooks.getCleanup!() + + // Dispose ran before the DB closed: applied OFF is persisted at the desired revision (so a + // pending TUI request observes its own acknowledgement), clearing the start-time failure error + // to a confirmed-stopped OFF (error: null) which proves dispose actually executed. + db = initializeDatabase(config.dataDir!) + const appliedAfterCleanup = createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID) + expect(appliedAfterCleanup).not.toBeNull() + expect(appliedAfterCleanup!.enabled).toBe(false) + expect(appliedAfterCleanup!.error).toBeNull() + expect(appliedAfterCleanup!.revision).toBe('r-init') + closeDatabase(db) + }) + + test('a transient ancestry lookup failure does not block native tools in tool.execute.before', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + sandbox: { mode: 'sbx' }, + } + + const plugin = createForgePlugin(config) + // A client whose session.get throws a transient (non-not-found) error makes the loop resolver's + // ancestor walk fail, simulating a temporary network/DB failure during tool routing. + const failingFetch = async (_input: RequestInfo | URL): Promise => { + throw new Error('connection refused') + } + const mockInput = { + directory: testDir, + worktree: testDir, + client: { _client: { getConfig: () => ({ fetch: failingFetch }) } } as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + const hooks = await plugin(mockInput as unknown as PluginInput) + currentHooks = hooks as { getCleanup?: () => Promise } + + const beforeHook = hooks['tool.execute.before'] as ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: unknown }, + ) => Promise + + // A native host-side tool must not be rejected by a transient loop-ancestry lookup failure. + await expect( + beforeHook({ tool: 'read', sessionID: 'ses-native', callID: 'c1' }, { args: {} }), + ).resolves.toBeUndefined() + await expect( + beforeHook({ tool: 'edit', sessionID: 'ses-native', callID: 'c2' }, { args: {} }), + ).resolves.toBeUndefined() + await expect( + beforeHook({ tool: 'write', sessionID: 'ses-native', callID: 'c3' }, { args: {} }), + ).resolves.toBeUndefined() + + await currentHooks.getCleanup!() + }) + + test('shell.env retains host behavior for sessions with no sandbox via the unified resolver', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + sandbox: { mode: 'sbx' }, + } + + const plugin = createForgePlugin(config) + const mockInput = { + directory: testDir, + worktree: testDir, + // The plugin's forged client must resolve session.get as a definitive absence (no parent) + // so the unified resolver's ancestor walk stays quiet and does not hit the network. + client: sessionResolvingClient(testDir) as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + const hooks = await plugin(mockInput as unknown as PluginInput) + currentHooks = hooks as { getCleanup?: () => Promise } + + const shellEnv = hooks['shell.env'] as ( + input: { sessionID?: string; cwd?: string }, + output: { env: Record }, + ) => Promise + + // No active loop and no acknowledged host sandbox: the unified resolver returns null and no + // container env is injected (no user shell configured either), so the shim falls through. + const output = { env: {} as Record } + await shellEnv({ sessionID: 'ses-unrelated', cwd: testDir }, output) + expect(output.env).toEqual({}) + }) + + test('a failed host-sandbox start makes the selected session fail closed while others stay host', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + sandbox: { mode: 'sbx' }, + } + + // Persist a desired ON for a selected session. Sandbox routing stays enabled so this exercises + // a genuine container-start failure rather than the unavailable-runtime path; `sbx` is forced + // off PATH below so the start fails whether or not the CLI is installed on the host. + const setupDb = initializeDatabase(config.dataDir!) + createSessionSandboxPreferencesRepo(setupDb).setDesired(TEST_PROJECT_ID, { + version: 1, + revision: 'r-fail', + enabled: true, + sessionId: 'ses-selected', + requestedAt: Date.now(), + }) + closeDatabase(setupDb) + + const plugin = createForgePlugin(config) + const mockInput = { + directory: testDir, + worktree: testDir, + // The session directory lookup must positively prove this instance owns the selected + // session (its directory resolves to this instance's directory) for the fail-closed path to + // engage. With a directory-scoped lookup that returns null, the instance is not the owner and + // must not act on the shared preference row at all. + client: sessionResolvingClient(testDir) as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + const originalPath = process.env.PATH + process.env.PATH = join(testDir, 'no-such-bin') + try { + const hooks = await plugin(mockInput as unknown as PluginInput) + currentHooks = hooks as { getCleanup?: () => Promise } + + const shellEnv = hooks['shell.env'] as ( + input: { sessionID?: string; cwd?: string }, + output: { env: Record }, + ) => Promise + + // The selected session's start failed, so bash for it must fail closed (throw) rather than + // fall through to the host shell. + await expect(shellEnv({ sessionID: 'ses-selected', cwd: testDir }, { env: {} })).rejects.toThrow() + + // An unrelated host session is unaffected and falls through to the host shell. + const output = { env: {} as Record } + await shellEnv({ sessionID: 'ses-unrelated', cwd: testDir }, output) + expect(output.env).toEqual({}) + + await currentHooks.getCleanup!() + } finally { + if (originalPath === undefined) delete process.env.PATH + else process.env.PATH = originalPath + } + }) + + test('two plugin instances for one project share a single refcounted sandbox controller', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + sandbox: { mode: 'sbx', enabled: false }, + } + + const setupDb = initializeDatabase(config.dataDir!) + createSessionSandboxPreferencesRepo(setupDb).setDesired(TEST_PROJECT_ID, { + version: 1, + revision: 'r-shared', + enabled: true, + sessionId: 'ses-root', + requestedAt: Date.now(), + }) + closeDatabase(setupDb) + + const mockInput = { + directory: testDir, + worktree: testDir, + client: sessionResolvingClient(testDir) as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + // OpenCode can instantiate the plugin more than once for the same directory in one process. + // A second reconciler would race the first on the same container, so both instances must + // resolve to one shared controller. + const hooksA = await createForgePlugin(config)(mockInput as unknown as PluginInput) + const hooksB = await createForgePlugin(config)(mockInput as unknown as PluginInput) + const cleanupA = (hooksA as unknown as { getCleanup: () => Promise }).getCleanup + const cleanupB = (hooksB as unknown as { getCleanup: () => Promise }).getCleanup + + // The fail-closed start recorded an error; disposal is what clears it to a confirmed OFF. + let db = initializeDatabase(config.dataDir!) + expect(createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID)?.error).toBeTruthy() + closeDatabase(db) + + // Releasing the first instance must not dispose the shared controller while the second still + // holds a reference: the acknowledgement stays at the start-time failure. + await cleanupA() + db = initializeDatabase(config.dataDir!) + expect(createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID)?.error).toBeTruthy() + closeDatabase(db) + + // The last release disposes it, clearing the error to a confirmed-stopped OFF. + await cleanupB() + db = initializeDatabase(config.dataDir!) + const applied = createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeNull() + closeDatabase(db) + }) + + test('unavailable sandbox runtime acknowledges a requested ON as OFF-with-error and blocks the selected session', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + sandbox: { mode: 'sbx', enabled: false }, + } + + // Persist a desired ON for a selected session. Sandbox routing is unavailable (disabled), so + // startup reconciliation must still create a controller that acknowledges the request as + // OFF-with-error at the matching revision and blocks the selected session fail-closed. + const setupDb = initializeDatabase(config.dataDir!) + createSessionSandboxPreferencesRepo(setupDb).setDesired(TEST_PROJECT_ID, { + version: 1, + revision: 'r-unavail', + enabled: true, + sessionId: 'ses-selected', + requestedAt: Date.now(), + }) + closeDatabase(setupDb) + + const plugin = createForgePlugin(config) + const mockInput = { + directory: testDir, + worktree: testDir, + client: sessionResolvingClient(testDir) as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + const hooks = await plugin(mockInput as unknown as PluginInput) + currentHooks = hooks as { getCleanup?: () => Promise } + + // The unavailable runtime acknowledged the requested ON at the matching revision as OFF with + // an error, so the TUI sees a definitive server answer rather than a silent host fallback. + let db = initializeDatabase(config.dataDir!) + const applied = createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID) + expect(applied).not.toBeNull() + expect(applied!.revision).toBe('r-unavail') + expect(applied!.enabled).toBe(false) + expect(applied!.error).toBeTruthy() + closeDatabase(db) + + const shellEnv = hooks['shell.env'] as ( + input: { sessionID?: string; cwd?: string }, + output: { env: Record }, + ) => Promise + + // The selected session fails closed (throws) rather than executing on the host. + await expect(shellEnv({ sessionID: 'ses-selected', cwd: testDir }, { env: {} })).rejects.toThrow(/unavailable/) + + // An unrelated host session is unaffected and falls through to the host shell. + const output = { env: {} as Record } + await shellEnv({ sessionID: 'ses-unrelated', cwd: testDir }, output) + expect(output.env).toEqual({}) + + await currentHooks.getCleanup!() + }) + + test('manager initialization failure still acknowledges a requested ON as OFF-with-error', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + // Sandbox routing is disabled so the deterministic unavailable manager is used (ensureRunning + // fails closed, stop is a no-op). This makes the OFF-with-error acknowledgement independent of + // whether the `sbx` CLI is installed on the host, exercising the same fail-closed surface as a + // manager that fails to initialize. + sandbox: { mode: 'sbx', enabled: false }, + } + + const setupDb = initializeDatabase(config.dataDir!) + createSessionSandboxPreferencesRepo(setupDb).setDesired(TEST_PROJECT_ID, { + version: 1, + revision: 'r-manager-fail', + enabled: true, + sessionId: 'ses-selected', + requestedAt: Date.now(), + }) + closeDatabase(setupDb) + + const plugin = createForgePlugin(config) + const mockInput = { + directory: testDir, + worktree: testDir, + client: sessionResolvingClient(testDir) as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + const hooks = await plugin(mockInput as unknown as PluginInput) + currentHooks = hooks as { getCleanup?: () => Promise } + + // Regardless of how the manager/shims became unavailable, the requested ON is acknowledged as + // OFF-with-error at the matching revision (fail closed). + let db = initializeDatabase(config.dataDir!) + const applied = createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID) + expect(applied).not.toBeNull() + expect(applied!.revision).toBe('r-manager-fail') + expect(applied!.enabled).toBe(false) + expect(applied!.error).toBeTruthy() + closeDatabase(db) + + const shellEnv = hooks['shell.env'] as ( + input: { sessionID?: string; cwd?: string }, + output: { env: Record }, + ) => Promise + await expect(shellEnv({ sessionID: 'ses-selected', cwd: testDir }, { env: {} })).rejects.toThrow(/unavailable/) + await currentHooks.getCleanup!() + }) + }) describe('PluginConfig', () => { diff --git a/test/sandbox-manager.test.ts b/test/sandbox-manager.test.ts index cb43ec411a..a516c8406b 100644 --- a/test/sandbox-manager.test.ts +++ b/test/sandbox-manager.test.ts @@ -305,7 +305,9 @@ describe('SandboxManager', () => { ) await manager.start('test', '/path') - await manager.stop('test') + // Removal failure is surfaced (the container may still be live) so lifecycle owners can + // record it, while cleanup still clears the stale in-memory map entry. + await expect(manager.stop('test')).rejects.toThrow(/Failed to remove sandbox/) expect(manager.isActive('test')).toBe(false) }) diff --git a/test/sandbox-tools.test.ts b/test/sandbox-tools.test.ts index 5c6b675ded..510ef8273b 100644 --- a/test/sandbox-tools.test.ts +++ b/test/sandbox-tools.test.ts @@ -281,8 +281,8 @@ describe('sandbox tool hooks', () => { }) }) - describe('host fallback for absolute out-of-mount paths', () => { - test('glob with absolute path outside mount is not intercepted (host fallback)', async () => { + describe('fail-closed for absolute out-of-mount paths', () => { + test('glob with absolute path outside mount fails closed instead of running on the host', async () => { const input = { tool: 'glob', sessionID: TEST_SESSION_ID, @@ -298,13 +298,10 @@ describe('sandbox tool hooks', () => { metadata: undefined, } - await beforeHook(input as never, output as never) - await afterHook({ ...input, args: output.args } as never, output as never) - - expect(output.output).toBe('HOST_NATIVE') + await expect(beforeHook(input as never, output as never)).rejects.toThrow(/outside the sandbox workspace mount/) }) - test('grep with absolute path outside mount is not intercepted (host fallback)', async () => { + test('grep with absolute path outside mount fails closed instead of running on the host', async () => { const input = { tool: 'grep', sessionID: TEST_SESSION_ID, @@ -320,10 +317,7 @@ describe('sandbox tool hooks', () => { metadata: undefined, } - await beforeHook(input as never, output as never) - await afterHook({ ...input, args: output.args } as never, output as never) - - expect(output.output).toBe('HOST_NATIVE') + await expect(beforeHook(input as never, output as never)).rejects.toThrow(/outside the sandbox workspace mount/) }) test('grep with relative path is still intercepted', async () => { @@ -395,4 +389,49 @@ describe('sandbox tool hooks', () => { expect(output.args.command).toBe('echo hi') }) }) + + describe('fail-closed search restoration', () => { + test('resolver errors do not block unrelated (non-glob/grep) tools', async () => { + const hook = createSandboxToolBeforeHook({ + resolveSandboxForSession: async () => { + throw new Error('sandbox unavailable') + }, + logger: mockLogger, + }) + // The resolver would reject, but this hook only handles glob/grep. Native file and + // management tools must pass through untouched, never blocked by a restoration failure. + for (const tool of ['read', 'edit', 'write', 'bash']) { + const input = { tool, sessionID: TEST_SESSION_ID, callID: `${tool}-1` } + const output = { args: { filePath: '/tmp/x' } } + await expect(hook(input as never, output as never)).resolves.toBeUndefined() + expect(output.args.filePath).toBe('/tmp/x') + } + }) + + test('glob fails closed when the sandbox resolver rejects', async () => { + const hook = createSandboxToolBeforeHook({ + resolveSandboxForSession: async () => { + throw new Error('sandbox unavailable') + }, + logger: mockLogger, + }) + const input = { tool: 'glob', sessionID: TEST_SESSION_ID, callID: 'glob-failclosed-1' } + const output = { args: { pattern: '*.ts', path: `${TEST_HOST_DIR}/src` } } + + await expect(hook(input as never, output as never)).rejects.toThrow('sandbox unavailable') + }) + + test('grep fails closed when the sandbox resolver rejects', async () => { + const hook = createSandboxToolBeforeHook({ + resolveSandboxForSession: async () => { + throw new Error('sandbox unavailable') + }, + logger: mockLogger, + }) + const input = { tool: 'grep', sessionID: TEST_SESSION_ID, callID: 'grep-failclosed-1' } + const output = { args: { pattern: 'console.log', path: `${TEST_HOST_DIR}/src` } } + + await expect(hook(input as never, output as never)).rejects.toThrow('sandbox unavailable') + }) + }) }) diff --git a/test/sandbox/manager-env-passthrough.test.ts b/test/sandbox/manager-env-passthrough.test.ts index 2698d955a9..cef9709caa 100644 --- a/test/sandbox/manager-env-passthrough.test.ts +++ b/test/sandbox/manager-env-passthrough.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, afterEach } from 'vitest' -import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, readdirSync } from 'fs' +import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, readdirSync, mkdirSync, writeFileSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { createSandboxManager, type SandboxManagerConfig } from '../../src/sandbox/manager' @@ -125,4 +125,34 @@ describe('SandboxManager env passthrough file lifecycle', () => { expect(existsSync(envFile)).toBe(false) expect(readdirSync(join(dataDir, 'sandbox-env'))).toHaveLength(0) }) + + test('stop clears the active map entry even when the env file cannot be removed', async () => { + setEnv('FORGE_TEST_TOKEN', 'abc123') + const dataDir = createTempDataDir() + + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + dataDir, + network: { env: ['FORGE_TEST_TOKEN'] }, + } + + const manager = createSandboxManager(runtime, config, logger) + await manager.start('test', '/home/user/worktrees/feature') + const envFile = manager.getActive('test')?.envFile! + expect(existsSync(envFile)).toBe(true) + + // Replace the env file with a non-empty directory so its deletion throws (filesystem access), + // while the container removal itself succeeds. + rmSync(envFile) + mkdirSync(envFile) + writeFileSync(join(envFile, 'block'), 'x') + + await expect(manager.stop('test')).resolves.toBeUndefined() + + // The container was removed and the stale in-memory entry is gone despite the env-file failure, + // so no fail-closed retries are triggered for an already-removed container. + expect(manager.getActive('test')).toBeNull() + }) }) diff --git a/test/sandbox/manager-reliability.test.ts b/test/sandbox/manager-reliability.test.ts index 758f944734..3007a4fab7 100644 --- a/test/sandbox/manager-reliability.test.ts +++ b/test/sandbox/manager-reliability.test.ts @@ -173,4 +173,23 @@ describe('SandboxManager.ensureRunning', () => { // createSandbox should NOT have been called again expect(mockRuntime.createSandbox).toHaveBeenCalledTimes(1) }) + + it('stop rethrows a removal failure but still clears the active map entry', async () => { + const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest' } + const manager = createSandboxManager(mockRuntime, config, mockLogger) + + mockRuntime.isRunning = vi.fn(async () => false) + await manager.ensureRunning('test-wt', '/tmp/project') + expect(manager.isActive('test-wt')).toBe(true) + + // Runtime removal fails: the container may still be live, so stop() must surface the failure + // (callers that own the lifecycle can record it) while still cleaning up the in-memory entry. + mockRuntime.removeSandbox = vi.fn(async () => { + throw new Error('container removal failed') + }) + + await expect(manager.stop('test-wt')).rejects.toThrow(/container removal failed/) + // Cleanup is preserved: the stale map entry is gone even though removal failed. + expect(manager.isActive('test-wt')).toBe(false) + }) }) diff --git a/test/sandbox/session-controller.test.ts b/test/sandbox/session-controller.test.ts new file mode 100644 index 0000000000..64f1abcf77 --- /dev/null +++ b/test/sandbox/session-controller.test.ts @@ -0,0 +1,2046 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { + createSessionSandboxController, + createUnavailableSandboxLifecycleManager, + deriveManagerKey, + DEFAULT_POLL_INTERVAL_MS, + type SessionSandboxLifecycleManager, +} from '../../src/sandbox/session-controller' +import { createSessionSandboxPreferencesRepo } from '../../src/storage' +import type { SessionSandboxAppliedState, SessionSandboxDesiredState, SessionSandboxPreferencesRepo } from '../../src/storage' +import type { ActiveSandbox } from '../../src/sandbox/manager' +import { createMockSandboxRuntime, createMockLogger } from '../helpers/sandbox-mocks' +import { setupLoopsTestDb } from '../helpers/loops-test-db' + +const PROJECT = 'project-a' +const DIRECTORY = '/abs/path/to/worktree' +const ROOT_SESSION = 'session-root' +const MANAGER_KEY = deriveManagerKey(PROJECT) + +function makeDesired(overrides: Partial = {}): SessionSandboxDesiredState { + return { version: 1, revision: 'rev-1', enabled: true, sessionId: ROOT_SESSION, requestedAt: 1000, ...overrides } +} + +function deferred() { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +interface FakeManager extends SessionSandboxLifecycleManager { + ensureRunningCalls: string[] + stopCalls: string[] + active: ActiveSandbox | null + setEnsureRunningImpl(fn: (key: string, dir: string) => Promise): void + setActive(active: ActiveSandbox | null): void +} + +function createFakeManager(): FakeManager { + const runtime = createMockSandboxRuntime() + const manager: FakeManager = { + runtime, + ensureRunningCalls: [], + stopCalls: [], + active: null, + ensureRunning: async () => '', + stop: async (key: string) => { + manager.stopCalls.push(key) + manager.active = null + }, + getActive: () => manager.active, + setEnsureRunningImpl(fn) { + manager.ensureRunning = async (key: string, dir: string) => { + manager.ensureRunningCalls.push(key) + const name = await fn(key, dir) + manager.active = { containerName: name, projectDir: dir, startedAt: new Date().toISOString(), mounts: [] } + return name + } + }, + setActive(active) { + manager.active = active + }, + } + manager.setEnsureRunningImpl(async (key: string) => `forge-${key}`) + return manager +} + +describe('SessionSandboxController', () => { + let db: Database + let repo: ReturnType + let tempDir: string + let manager: FakeManager + let logger: ReturnType + + function createController(overrides: { + pollIntervalMs?: number + directory?: string + preferences?: SessionSandboxPreferencesRepo + getSessionDirectory?: (sid: string) => Promise + resolveActiveLoopForSession?: (sid: string) => Promise<{ active: boolean; sandbox?: boolean } | null> + getParentSessionId?: (sid: string) => Promise + } = {}) { + return createSessionSandboxController({ + projectId: PROJECT, + directory: overrides.directory ?? DIRECTORY, + preferences: overrides.preferences ?? repo, + sandboxManager: manager, + getParentSessionId: overrides.getParentSessionId ?? (async () => null), + ...(overrides.getSessionDirectory ? { getSessionDirectory: overrides.getSessionDirectory } : {}), + ...(overrides.resolveActiveLoopForSession ? { resolveActiveLoopForSession: overrides.resolveActiveLoopForSession } : {}), + logger, + ...(overrides.pollIntervalMs ? { pollIntervalMs: overrides.pollIntervalMs } : {}), + }) + } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'session-sandbox-controller-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + repo = createSessionSandboxPreferencesRepo(db) + manager = createFakeManager() + logger = createMockLogger() + }) + + afterEach(() => { + db.close() + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + }) + + test('start with no desired state stays off and writes nothing', async () => { + const controller = createController() + await controller.start() + expect(manager.ensureRunningCalls).toEqual([]) + expect(manager.stopCalls).toEqual([]) + expect(repo.getApplied(PROJECT)).toBeNull() + expect(controller.getState()).toBeNull() + await controller.dispose() + }) + + test('persisted desired ON starts the host and writes applied ON', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-on' })) + const controller = createController() + await controller.start() + + expect(manager.ensureRunningCalls).toEqual([MANAGER_KEY]) + expect(manager.ensureRunningCalls[0]).not.toMatch(/^forge-/) + expect(manager.ensureRunningCalls[0]).toMatch(/^host-session-/) + expect(manager.stopCalls).toEqual([]) + + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-on') + expect(applied?.enabled).toBe(true) + expect(applied?.sessionId).toBe(ROOT_SESSION) + expect(applied?.error).toBeNull() + expect(controller.getState()).toEqual(applied) + await controller.dispose() + }) + + test('desired OFF stops the host and writes applied OFF without error', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-off', enabled: false })) + const controller = createController() + await controller.start() + + expect(manager.stopCalls).toEqual([MANAGER_KEY]) + expect(manager.ensureRunningCalls).toEqual([]) + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-off') + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeNull() + await controller.dispose() + }) + + test('failed start is acknowledged as OFF with an error and exposes no host fallback', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-fail' })) + manager.setEnsureRunningImpl(async () => { + throw new Error('sbx daemon is not running') + }) + const controller = createController() + await controller.start() + + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-fail') + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/sbx daemon is not running/) + + // A failed start must never expose a host fallback for the selected session: resolution + // fails closed (throws) rather than returning null (which hooks treat as host permission). + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).rejects.toThrow(/sbx daemon is not running/) + await controller.dispose() + }) + + test('an applied-ON write failure rolls back the binding and stops the started container', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-write-fail' })) + // The container starts successfully, but persisting the applied-ON acknowledgement fails (e.g. + // SQLite is locked). The controller must roll back the in-memory binding and stop the started + // container so no unacknowledged sandbox is used or leaked, and it must fail closed. + const wrappedRepo: SessionSandboxPreferencesRepo = { + getDesired: (p) => repo.getDesired(p), + setDesired: (p, s) => repo.setDesired(p, s), + getApplied: (p) => repo.getApplied(p), + setApplied: (p, s) => { + if (s.enabled) throw new Error('SQLITE_BUSY: database is locked') + repo.setApplied(p, s) + }, + getPair: (p) => repo.getPair(p), + } + const controller = createController({ preferences: wrappedRepo }) + await controller.start() + + // The container was started but then stopped; nothing remains live. + expect(manager.ensureRunningCalls).toEqual([MANAGER_KEY]) + expect(manager.stopCalls).toEqual([MANAGER_KEY]) + + // No sandbox resolves after the failed write: the selected session fails closed rather than + // falling through to host execution or exposing an unacknowledged container. + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).rejects.toThrow(/SQLITE_BUSY/) + await controller.dispose() + }) + + test('startup restoration reapplies a persisted desired ON across a fresh instance', async () => { + // First server run: apply desired ON, then shut down (dispose writes applied OFF). + repo.setDesired(PROJECT, makeDesired({ revision: 'r-persist' })) + const first = createController() + await first.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + await first.dispose() + expect(repo.getApplied(PROJECT)?.enabled).toBe(false) + // dispose acknowledges OFF at the desired revision so a pending TUI request observes it; desired + // stays ON so the next startup re-applies it. + expect(repo.getApplied(PROJECT)?.revision).toBe('r-persist') + + // New server instance reading the same persisted rows re-applies desired ON. + const second = createController() + await second.start() + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-persist') + expect(applied?.enabled).toBe(true) + expect(manager.ensureRunningCalls).toContain(MANAGER_KEY) + await second.dispose() + }) + + test('an already-applied successful ON revision is restored after validating the runtime', async () => { + // Persisted applied ON already matches desired (e.g. prior run that was not disposed). + const applied: SessionSandboxAppliedState = { + version: 1, + revision: 'r-match', + enabled: true, + sessionId: ROOT_SESSION, + error: null, + appliedAt: Date.now(), + } + repo.setDesired(PROJECT, makeDesired({ revision: 'r-match' })) + repo.setApplied(PROJECT, applied) + const controller = createController() + await controller.start() + + // The matching-revision restore validates the runtime before trusting the persisted ON; with a + // healthy manager this is a cheap ensureRunning (not a full restart). + expect(manager.ensureRunningCalls).toEqual([MANAGER_KEY]) + expect(manager.stopCalls).toEqual([]) + expect(controller.getState()).toEqual(applied) + + // Binding is restored, so the acknowledged root resolves to a context. + const ctx = await controller.resolveSandboxForSession(ROOT_SESSION) + expect(ctx).not.toBeNull() + expect(ctx?.containerName).toBe(`forge-${MANAGER_KEY}`) + await controller.dispose() + }) + + test('matching persisted ON is not trusted when the lifecycle manager is unavailable', async () => { + // A prior run left applied ON at the same revision as desired, but after an unclean restart + // the lifecycle manager is unavailable (initialization failed). The persisted ON must not be + // restored: startup acknowledges OFF-with-error and the selected session fails closed. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-unavail' })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-unavail', + enabled: true, + sessionId: ROOT_SESSION, + error: null, + appliedAt: Date.now(), + }) + const unavailable = createUnavailableSandboxLifecycleManager(createMockSandboxRuntime()) + const controller = createSessionSandboxController({ + projectId: PROJECT, + directory: DIRECTORY, + preferences: repo, + sandboxManager: unavailable, + getParentSessionId: async () => null, + logger, + }) + await controller.start() + + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-unavail') + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeTruthy() + // Startup never exposes applied ON for an unavailable runtime. + expect(controller.getState()?.enabled).toBe(false) + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).rejects.toThrow() + await controller.dispose() + }) + + test('persisted-ON restore that partially creates the container cleans up before acknowledging OFF', async () => { + // A prior run left applied ON at the same revision as desired. On restart the restore + // validation must run deterministic-key cleanup if ensureRunning creates the container and + // then fails (e.g. env-file generation), so the partially-created container is not leaked + // while OFF-with-error is acknowledged. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-restore-partial' })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-restore-partial', + enabled: true, + sessionId: ROOT_SESSION, + error: null, + appliedAt: Date.now(), + }) + manager.setEnsureRunningImpl(async (key, dir) => { + manager.active = { containerName: `forge-${key}`, projectDir: dir, startedAt: new Date().toISOString(), mounts: [] } + throw new Error('env file generation failed during restore') + }) + const controller = createController() + await controller.start() + + expect(manager.stopCalls).toEqual([MANAGER_KEY]) + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-restore-partial') + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/env file generation failed during restore/) + // No live container remains to expose. + expect(manager.active).toBeNull() + await controller.dispose() + }) + + test('persisted-ON restore cleanup is retried when the removal fails transiently', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-restore-retry' })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-restore-retry', + enabled: true, + sessionId: ROOT_SESSION, + error: null, + appliedAt: Date.now(), + }) + manager.setEnsureRunningImpl(async (key, dir) => { + manager.active = { containerName: `forge-${key}`, projectDir: dir, startedAt: new Date().toISOString(), mounts: [] } + throw new Error('start failed after creation during restore') + }) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + + // First cleanup attempt fails; ownership is retained and no settled OFF-with-error is written + // (the persisted ON row is left untouched until the removal succeeds). + expect(manager.stopCalls).toHaveLength(1) + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + stopFails = false + await vi.advanceTimersByTimeAsync(20) + // The pending cleanup resolves (stop 2) and the failure is settled OFF-with-error; the + // still-ON desired is never re-attempted, so the failed start is not retried. + expect(manager.stopCalls).toHaveLength(2) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/start failed after creation during restore/) + expect(manager.active).toBeNull() + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('an inconsistent desired-OFF / applied-ON pair at the same revision is never restored', async () => { + // Desired says OFF but a (schema-valid) applied row still records ON at the same revision. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-inconsistent', enabled: false, sessionId: ROOT_SESSION })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-inconsistent', + enabled: true, + sessionId: ROOT_SESSION, + error: null, + appliedAt: Date.now(), + }) + const controller = createController() + await controller.start() + + // The inconsistent ON is not restored or started; the desired OFF is re-acted (stopped). + expect(manager.ensureRunningCalls).toEqual([]) + expect(manager.stopCalls).toEqual([MANAGER_KEY]) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeNull() + expect(controller.getState()?.enabled).toBe(false) + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).resolves.toBeNull() + await controller.dispose() + }) + + test('an inconsistent applied-ON with a null session is never restored as a bound sandbox', async () => { + // Desired is a valid ON for ROOT_SESSION but the applied row records a null session. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-null-applied', sessionId: ROOT_SESSION })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-null-applied', + enabled: true, + sessionId: null, + error: null, + appliedAt: Date.now(), + }) + const controller = createController() + await controller.start() + + // The mismatched row is never trusted; desired is re-acted and bound to the real session. + expect(repo.getApplied(PROJECT)?.sessionId).toBe(ROOT_SESSION) + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + await controller.dispose() + }) + + test('an inconsistent applied-ON for a different session is corrected, not restored as the wrong sandbox', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-mismatch', sessionId: ROOT_SESSION })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-mismatch', + enabled: true, + sessionId: 'session-other', + error: null, + appliedAt: Date.now(), + }) + const controller = createController() + await controller.start() + + // The wrong-session applied row is never trusted; desired is re-acted for ROOT_SESSION. + expect(repo.getApplied(PROJECT)?.sessionId).toBe(ROOT_SESSION) + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + await controller.dispose() + }) + + test('superseding revisions: newest desired wins while an older operation is in flight', async () => { + const gate = deferred() + manager.setEnsureRunningImpl(() => gate.promise) + + repo.setDesired(PROJECT, makeDesired({ revision: 'r1', enabled: true })) + const controller = createController() + const starting = controller.start() + + // Desired moves to OFF while the r1 start is still in flight. + repo.setDesired(PROJECT, makeDesired({ revision: 'r2', enabled: false })) + gate.resolve(`forge-${MANAGER_KEY}`) + await starting + + // The newest revision wins: applied records r2 OFF. + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r2') + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeNull() + expect(manager.stopCalls).toContain(MANAGER_KEY) + await controller.dispose() + }) + + test('resolveSandboxForSession matches the acknowledged root or a descendant', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-desc', sessionId: ROOT_SESSION })) + const parents: Record = { + 'session-sub': ROOT_SESSION, + 'session-deep': 'session-sub', + } + const controller = createSessionSandboxController({ + projectId: PROJECT, + directory: DIRECTORY, + preferences: repo, + sandboxManager: manager, + getParentSessionId: async (sid: string) => parents[sid] ?? null, + logger, + pollIntervalMs: DEFAULT_POLL_INTERVAL_MS, + }) + await controller.start() + + // Root session resolves. + const rootCtx = await controller.resolveSandboxForSession(ROOT_SESSION) + expect(rootCtx).not.toBeNull() + expect(rootCtx?.containerName).toBe(`forge-${MANAGER_KEY}`) + + // Direct descendant and a multi-hop descendant resolve. + expect(await controller.resolveSandboxForSession('session-sub')).not.toBeNull() + expect(await controller.resolveSandboxForSession('session-deep')).not.toBeNull() + + // An unrelated session (no ancestor chain to the root) does not resolve. + await expect(controller.resolveSandboxForSession('session-unrelated')).resolves.toBeNull() + await controller.dispose() + }) + + test('resolveSandboxForSession returns null when the sandbox is off', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-off', enabled: false })) + const controller = createController() + await controller.start() + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).resolves.toBeNull() + await controller.dispose() + }) + + test('throwOnRestoreError surfaces an ensureRunning failure for a bound session', async () => { + // Bring the sandbox up and bind the acknowledged root with a working manager. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-restore-fail' })) + const controller = createController() + await controller.start() + expect(await controller.resolveSandboxForSession(ROOT_SESSION)).not.toBeNull() + + // The acknowledged container dies; recovery now fails. + manager.setEnsureRunningImpl(async () => { + throw new Error('cannot recover container') + }) + await expect( + controller.resolveSandboxForSession(ROOT_SESSION, { throwOnRestoreError: true }), + ).rejects.toThrow('cannot recover container') + await controller.dispose() + }) + + test('polling is non-overlapping', async () => { + vi.useFakeTimers() + try { + const gate = deferred() + manager.setEnsureRunningImpl(() => gate.promise) + + // Start with no desired so the initial reconcile resolves quickly and the + // interval is installed. + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + + repo.setDesired(PROJECT, makeDesired({ revision: 'r-nonoverlap' })) + + // First interval tick starts a slow reconcile. + await vi.advanceTimersByTimeAsync(20) + expect(manager.ensureRunningCalls).toHaveLength(1) + + // Subsequent ticks while the first is in flight must not start a second reconcile. + await vi.advanceTimersByTimeAsync(200) + expect(manager.ensureRunningCalls).toHaveLength(1) + + gate.resolve(`forge-${MANAGER_KEY}`) + await vi.advanceTimersByTimeAsync(1) + + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + expect(manager.ensureRunningCalls).toHaveLength(1) + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('dispose is idempotent, stops the container, and acknowledges OFF', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-idem' })) + const controller = createController() + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + await controller.dispose() + await controller.dispose() + + expect(manager.stopCalls).toEqual([MANAGER_KEY]) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeNull() + expect(applied?.revision).toBe('r-idem') + expect(controller.getState()).toEqual(applied) + }) + + test('dispose acknowledges OFF at the desired revision so a pending TUI request observes it', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-pending-on' })) + const controller = createController() + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + await controller.dispose() + + // The applied OFF is written at the desired revision, so a TUI still waiting on that exact + // revision observes the OFF acknowledgement instead of timing out on an unrelated revision. + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeNull() + expect(applied?.revision).toBe('r-pending-on') + + // Desired is left ON; a fresh instance re-applies it on startup. + const second = createController() + await second.start() + const reapplied = repo.getApplied(PROJECT) + expect(reapplied?.enabled).toBe(true) + expect(reapplied?.revision).toBe('r-pending-on') + await second.dispose() + }) + + test('dispose does not record successful applied OFF when stopping fails', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-dispose-fail' })) + const controller = createController() + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // Container removal fails during cleanup; the sandbox may still be live. + manager.stop = async () => { + throw new Error('container removal failed') + } + await controller.dispose() + + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/container removal failed/) + // A successful OFF (error: null) must never be recorded after a failed stop, so the next + // startup cannot falsely believe the container is stopped. + expect(applied?.error).not.toBeNull() + }) + + test('polling is fully stopped during disposal', async () => { + vi.useFakeTimers() + try { + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + + await controller.dispose() + + // A new desired ON must not be acted on after disposal: interval is cleared. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-after-dispose' })) + await vi.advanceTimersByTimeAsync(500) + expect(manager.ensureRunningCalls).toEqual([]) + expect(repo.getApplied(PROJECT)).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + test('dispose waits for in-flight reconciliation and cannot be overridden by a late ensureRunning', async () => { + const gate = deferred() + manager.setEnsureRunningImpl(() => gate.promise) + repo.setDesired(PROJECT, makeDesired({ revision: 'r-race-dispose', enabled: true })) + const controller = createController() + + const starting = controller.start() + const disposing = controller.dispose() + gate.resolve(`forge-${MANAGER_KEY}`) + await starting + await disposing + + // After dispose returns the pending start cannot leave the sandbox ON. + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeNull() + expect(manager.stopCalls).toContain(MANAGER_KEY) + expect(controller.getState()?.enabled).toBe(false) + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).resolves.toBeNull() + }) + + test('resolve returns null when disposal clears the binding during a deferred parent lookup', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-race-parent', sessionId: ROOT_SESSION })) + const parentGate = deferred() + const controller = createSessionSandboxController({ + projectId: PROJECT, + directory: DIRECTORY, + preferences: repo, + sandboxManager: manager, + getParentSessionId: async () => parentGate.promise, + logger, + pollIntervalMs: DEFAULT_POLL_INTERVAL_MS, + }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + const resolving = controller.resolveSandboxForSession('session-sub') + await controller.dispose() + parentGate.resolve(ROOT_SESSION) + await expect(resolving).resolves.toBeNull() + + // Disposal won; no extra container restore and the sandbox stays off. + expect(repo.getApplied(PROJECT)?.enabled).toBe(false) + expect(manager.ensureRunningCalls).toHaveLength(1) + }) + + test('resolve does not leave a live sandbox when disposal wins during a deferred restore', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-race-restore', sessionId: ROOT_SESSION })) + const gate = deferred() + let calls = 0 + manager.setEnsureRunningImpl(async () => { + calls++ + if (calls === 1) return `forge-${MANAGER_KEY}` + return gate.promise + }) + const controller = createController() + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + const resolving = controller.resolveSandboxForSession(ROOT_SESSION) + await controller.dispose() + gate.resolve(`forge-${MANAGER_KEY}`) + await expect(resolving).resolves.toBeNull() + + // The deferred restore returned null and disposal finalized OFF with no live sandbox. + expect(repo.getApplied(PROJECT)?.enabled).toBe(false) + expect(manager.stopCalls).toContain(MANAGER_KEY) + }) + + test('ON(A)->ON(B) transition never exposes B sandbox to an A descendant during a deferred parent lookup', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: 'session-A' })) + const parentGate = deferred() + const controller = createSessionSandboxController({ + projectId: PROJECT, + directory: DIRECTORY, + preferences: repo, + sandboxManager: manager, + getParentSessionId: async () => parentGate.promise, + logger, + pollIntervalMs: 20, + }) + await controller.start() + expect(repo.getApplied(PROJECT)?.sessionId).toBe('session-A') + + // A descendant of A begins resolving; its parent lookup is deferred. + const resolving = controller.resolveSandboxForSession('descendant-of-A') + + // Reconciliation moves the selected root from A to B while the lookup is in flight. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: 'session-B' })) + await vi.advanceTimersByTimeAsync(20) + expect(repo.getApplied(PROJECT)?.sessionId).toBe('session-B') + + parentGate.resolve('session-A') + // A's descendant must not receive B's sandbox: root no longer matches the acknowledged root. + // The two starts are the legitimate ON(A) and ON(B) reconciliations; the deferred descendant + // resolution adds no third restore. + await expect(resolving).resolves.toBeNull() + expect(manager.ensureRunningCalls).toHaveLength(2) + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('repeated concurrent start installs one interval and every caller awaits initial reconciliation', async () => { + vi.useFakeTimers() + try { + const gate = deferred() + manager.setEnsureRunningImpl(() => gate.promise) + repo.setDesired(PROJECT, makeDesired({ revision: 'r-single-start' })) + const controller = createController({ pollIntervalMs: 20 }) + + const s1 = controller.start() + const s2 = controller.start() + const s3 = controller.start() + + let resolved = false + s1.then(() => { + resolved = true + }) + s2.then(() => { + resolved = true + }) + s3.then(() => { + resolved = true + }) + + // No caller resolves until the initial (gated) reconciliation completes; only one starts. + await vi.advanceTimersByTimeAsync(0) + expect(resolved).toBe(false) + expect(manager.ensureRunningCalls).toHaveLength(1) + + gate.resolve(`forge-${MANAGER_KEY}`) + await Promise.all([s1, s2, s3]) + expect(resolved).toBe(true) + expect(manager.ensureRunningCalls).toHaveLength(1) + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('concurrent dispose calls await the same cleanup and stop the container once', async () => { + const gate = deferred() + manager.setEnsureRunningImpl(() => gate.promise) + repo.setDesired(PROJECT, makeDesired({ revision: 'r-conc-dispose', enabled: true })) + const controller = createController() + + const starting = controller.start() + const d1 = controller.dispose() + const d2 = controller.dispose() + const d3 = controller.dispose() + gate.resolve(`forge-${MANAGER_KEY}`) + await starting + await Promise.all([d1, d2, d3]) + + // Single cleanup: one stop, applied OFF persisted before any dispose caller resolves. + expect(manager.stopCalls).toEqual([MANAGER_KEY]) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(controller.getState()?.enabled).toBe(false) + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).resolves.toBeNull() + }) + + test('a failed selection blocks descendants too but leaves unrelated sessions unaffected', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-fail-desc', sessionId: ROOT_SESSION })) + manager.setEnsureRunningImpl(async () => { + throw new Error('startup failed') + }) + const parents: Record = { 'session-sub': ROOT_SESSION } + const controller = createController({ getParentSessionId: async (sid) => parents[sid] ?? null }) + await controller.start() + + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).rejects.toThrow(/startup failed/) + await expect(controller.resolveSandboxForSession('session-sub')).rejects.toThrow(/startup failed/) + // An unrelated session has no acknowledged binding and is not blocked: host fallback is allowed. + await expect(controller.resolveSandboxForSession('session-unrelated')).resolves.toBeNull() + await controller.dispose() + }) + + test('failed selection blocking is restored across a fresh instance from the persisted error row', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-persist-fail', sessionId: ROOT_SESSION })) + // A prior failed start already acknowledged as OFF with an error at the same revision. + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-persist-fail', + enabled: false, + sessionId: ROOT_SESSION, + error: 'container died', + appliedAt: Date.now(), + }) + const controller = createController() + await controller.start() + // Desired is still ON and never successfully applied: the selected session stays blocked. + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).rejects.toThrow(/container died/) + await controller.dispose() + }) + + test('an empty-string error on a persisted applied row remains fail-closed after restart', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-empty-err', sessionId: ROOT_SESSION })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-empty-err', + enabled: false, + sessionId: ROOT_SESSION, + error: '', + appliedAt: Date.now(), + }) + const controller = createController() + await controller.start() + // An empty-string error still records a failed start: the selected session must not run on host. + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).rejects.toThrow(/unavailable/) + await controller.dispose() + }) + + test('concurrent failed-selection replacement never returns host fallback for the newly selected root or descendants', async () => { + vi.useFakeTimers() + try { + manager.setEnsureRunningImpl(async () => { + throw new Error('start failed') + }) + const gates: Array>> = [] + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: 'session-A' })) + const controller = createSessionSandboxController({ + projectId: PROJECT, + directory: DIRECTORY, + preferences: repo, + sandboxManager: manager, + getParentSessionId: async () => { + const g = deferred() + gates.push(g) + return g.promise + }, + logger, + pollIntervalMs: 20, + }) + await controller.start() + // Desired ON for A failed at startup: failedSelection = { session-A }. + await expect(controller.resolveSandboxForSession('session-A')).rejects.toThrow(/start failed/) + + // A descendant of A begins resolving; its parent lookup is deferred. + const resolving = controller.resolveSandboxForSession('desc-A') + await vi.advanceTimersByTimeAsync(0) + expect(gates).toHaveLength(1) + + // A superseding request for B fails while the descendant lookup is in flight. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: 'session-B' })) + await vi.advanceTimersByTimeAsync(20) + expect(repo.getApplied(PROJECT)?.revision).toBe('r-b') + + // Resolve the descendant's first parent lookup to the old failed root (A): the retry must + // re-match against the NEW failed selection (B) so the descendant still fails closed. + gates[0].resolve('session-A') + await vi.advanceTimersByTimeAsync(0) + expect(gates.length).toBeGreaterThanOrEqual(2) + gates[1].resolve('session-B') + await expect(resolving).rejects.toThrow(/start failed/) + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('an identical recreated failed selection does not exhaust the retry cap during a slow lookup', async () => { + vi.useFakeTimers() + try { + manager.setEnsureRunningImpl(async () => { + throw new Error('start failed') + }) + repo.setDesired(PROJECT, makeDesired({ revision: 'r-fail', sessionId: 'session-X' })) + const gates: Array>> = [] + const controller = createSessionSandboxController({ + projectId: PROJECT, + directory: DIRECTORY, + preferences: repo, + sandboxManager: manager, + getParentSessionId: async () => { + const g = deferred() + gates.push(g) + return g.promise + }, + logger, + pollIntervalMs: 20, + }) + await controller.start() + // Start failed: failedSelection = session-X (applied OFF-with-error). + await expect(controller.resolveSandboxForSession('session-X')).rejects.toThrow(/start failed/) + + // A descendant's parent lookup is slow and gated. + const resolving = controller.resolveSandboxForSession('desc-X') + await vi.advanceTimersByTimeAsync(0) + expect(gates).toHaveLength(1) + + // Reconcile keeps re-recording the IDENTICAL failure (new object identity, same session) on + // idle ticks while the descendant lookup is in flight. + await vi.advanceTimersByTimeAsync(100) + + // The lookup resolves to the same failed root: the selection did not change, so the session + // fails closed immediately without re-matching through (and exhausting) the retry cap. + gates[0].resolve('session-X') + await expect(resolving).rejects.toThrow(/start failed/) + expect(gates).toHaveLength(1) + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('a deferred restore failure is attributed to the recovered binding, not a superseding desired revision', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: 'session-A' })) + const controller = createController({ + pollIntervalMs: 20, + getParentSessionId: async (sid) => (sid === 'desc-A' ? 'session-A' : null), + }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // The acknowledged container dies; the first restore attempt is gated and then fails. + const recoveryGate = deferred() + let gateFirst = true + manager.setEnsureRunningImpl(async (key, dir) => { + if (gateFirst) { + gateFirst = false + await recoveryGate.promise + throw new Error('restore failed') + } + return `forge-${key}` + }) + + // A descendant of A triggers a container restore; its ensureRunning await is gated, holding + // the lifecycle lock so the pending rebind's reconcile is blocked. + const resolving = controller.resolveSandboxForSession('desc-A', { throwOnRestoreError: true }) + await vi.advanceTimersByTimeAsync(0) + + // The user rebinds to B while A's recovery is in flight (desired moves to a new revision). + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: 'session-B' })) + + // A's recovery fails. + recoveryGate.resolve('') + await expect(resolving).rejects.toThrow(/restore failed/) + + // The failure was attributed to A (the binding being recovered), never to B: once the blocked + // reconcile runs, B is attempted and acknowledged ON instead of being marked failed. + await vi.advanceTimersByTimeAsync(20) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(true) + expect(applied?.sessionId).toBe('session-B') + expect(applied?.revision).toBe('r-b') + expect(applied?.error).toBeNull() + await expect(controller.resolveSandboxForSession('session-B')).resolves.not.toBeNull() + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('an instance that does not own the requested session neither starts nor acknowledges it', async () => { + const OTHER_DIR = '/abs/path/other-worktree' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-other', sessionId: 'session-other' })) + const nonOwner = createController({ + getSessionDirectory: async (sid) => (sid === 'session-other' ? OTHER_DIR : DIRECTORY), + }) + await nonOwner.start() + expect(manager.ensureRunningCalls).toEqual([]) + expect(repo.getApplied(PROJECT)).toBeNull() + // Disposal must not overwrite a shared acknowledgement for a session it does not own. + await nonOwner.dispose() + expect(repo.getApplied(PROJECT)).toBeNull() + }) + + test('an instance whose directory lookup cannot resolve a session does not claim it', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-unresolved', sessionId: 'session-unresolved' })) + // The directory-scoped lookup returns null because this instance cannot see the session (e.g. + // a loop-worktree child resolving a root session). It must be treated as not owned: no sandbox + // is started and no acknowledgement is written. + const controller = createController({ + getSessionDirectory: async () => null, + }) + await controller.start() + expect(manager.ensureRunningCalls).toEqual([]) + expect(manager.stopCalls).toEqual([]) + expect(repo.getApplied(PROJECT)).toBeNull() + await controller.dispose() + expect(repo.getApplied(PROJECT)).toBeNull() + }) + + test('a previous owner stops and clears its binding when the selection moves to another instance', async () => { + vi.useFakeTimers() + try { + const ROOT_DIR = DIRECTORY + const WORKTREE_DIR = '/abs/path/loop-worktree' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: 'session-root' })) + const ownerA = createController({ + pollIntervalMs: 20, + getSessionDirectory: async (sid) => (sid === 'session-root' ? ROOT_DIR : WORKTREE_DIR), + }) + await ownerA.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + expect(await ownerA.resolveSandboxForSession('session-root')).not.toBeNull() + + // Selection rebinds to a session owned by another instance (in a different directory). + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: 'session-wt' })) + + // ownerA's next poll reconciles the moved selection: it must NOT acknowledge r-b (it does not + // own session-wt), but must stop its own container and clear its binding so only one active + // binding remains once the selection moves away. + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toContain(MANAGER_KEY) + expect(repo.getApplied(PROJECT)?.revision).toBe('r-a') + // The previously selected root session no longer resolves. + await expect(ownerA.resolveSandboxForSession('session-root')).resolves.toBeNull() + await ownerA.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('two instances sharing a project DB cannot acknowledge or overwrite each other\'s sandbox', async () => { + const ROOT_DIR = DIRECTORY + const WORKTREE_DIR = '/abs/path/loop-worktree' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-root', sessionId: 'session-root' })) + + // The root instance owns the root session and starts its own directory-derived sandbox. + const root = createController({ + directory: ROOT_DIR, + getSessionDirectory: async (sid) => (sid === 'session-root' ? ROOT_DIR : WORKTREE_DIR), + }) + await root.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // A worktree child instance shares the same preference DB but a different directory. It must + // not start its own sandbox or overwrite the root's acknowledgement for the root session. + const child = createController({ + directory: WORKTREE_DIR, + getSessionDirectory: async (sid) => (sid === 'session-root' ? ROOT_DIR : WORKTREE_DIR), + }) + await child.start() + expect(manager.ensureRunningCalls).toEqual([MANAGER_KEY]) + + // Child disposal must not stop the root's acknowledged sandbox or overwrite its applied row. + await child.dispose() + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(true) + expect(applied?.sessionId).toBe('session-root') + expect(applied?.revision).toBe('r-root') + await root.dispose() + }) + + test('an active sandbox loop session cannot receive a host-sandbox ON acknowledgement', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-loop-sandbox', sessionId: 'session-loop' })) + const controller = createController({ + resolveActiveLoopForSession: async (sid) => (sid === 'session-loop' ? { active: true, sandbox: true } : null), + }) + await controller.start() + expect(manager.ensureRunningCalls).toEqual([]) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.sessionId).toBe('session-loop') + expect(applied?.error).toMatch(/active loop session/) + await controller.dispose() + }) + + test('an active worktree-only loop session cannot receive a host-sandbox ON acknowledgement', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-loop-worktree', sessionId: 'session-wt' })) + const controller = createController({ + resolveActiveLoopForSession: async (sid) => (sid === 'session-wt' ? { active: true, worktree: true } : null), + }) + await controller.start() + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/active loop session/) + await controller.dispose() + }) + + test('a loop-refused session stays blocked after the loop terminates before the next tick', async () => { + vi.useFakeTimers() + try { + let inLoop = true + const parents: Record = { 'session-sub': ROOT_SESSION } + repo.setDesired(PROJECT, makeDesired({ revision: 'r-refuse', sessionId: ROOT_SESSION })) + const controller = createController({ + pollIntervalMs: 500, + getParentSessionId: async (sid) => parents[sid] ?? null, + resolveActiveLoopForSession: async () => (inLoop ? { active: true, sandbox: true } : null), + }) + await controller.start() + + // Refusal acknowledged OFF with the loop error. + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/active loop session/) + + // The loop terminates before the next 500ms reconciliation tick. The unified resolver now + // sees no active loop and falls through to the host controller; the refused session and its + // descendants must still be blocked fail-closed rather than returning host fallback. + inLoop = false + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).rejects.toThrow(/active loop session/) + await expect(controller.resolveSandboxForSession('session-sub')).rejects.toThrow(/active loop session/) + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('dispose stops its active container even when desired rebinds to a non-owned session', async () => { + const OTHER_DIR = '/abs/path/other-worktree' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: ROOT_SESSION })) + const controller = createController({ + getSessionDirectory: async (sid) => (sid === ROOT_SESSION ? DIRECTORY : OTHER_DIR), + }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + expect(manager.stopCalls).toEqual([]) + + // Desired rebinds to a session owned by another instance before this instance reconciles. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: 'session-other' })) + + // Disposal must still tear down the container this controller started (no leak), but must not + // write an applied acknowledgement for the non-owned desired session. + await controller.dispose() + expect(manager.stopCalls).toContain(MANAGER_KEY) + expect(repo.getApplied(PROJECT)?.revision).toBe('r-a') + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + }) + + test('resolution fails closed when a failure is recorded mid-resolution', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: ROOT_SESSION })) + const parentGate = deferred() + const controller = createSessionSandboxController({ + projectId: PROJECT, + directory: DIRECTORY, + preferences: repo, + sandboxManager: manager, + getParentSessionId: async (sid) => (sid === 'session-sub' ? parentGate.promise : null), + logger, + pollIntervalMs: 20, + }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // A descendant of the acknowledged root begins resolving; its parent lookup is deferred. + const resolving = controller.resolveSandboxForSession('session-sub') + + // While the lookup is in flight, a reconciliation records a failed start for the root session + // (a superseded desired ON fails ensureRunning): the binding is cleared and the root becomes + // null, so the in-flight resolution would otherwise fall through to host execution. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-fail', sessionId: ROOT_SESSION })) + manager.setEnsureRunningImpl(async () => { + throw new Error('start failed') + }) + await vi.advanceTimersByTimeAsync(20) + expect(repo.getApplied(PROJECT)?.enabled).toBe(false) + expect(repo.getApplied(PROJECT)?.error).toMatch(/start failed/) + + parentGate.resolve(ROOT_SESSION) + // The descendant must not fall through to host (null); it must fail closed instead. + await expect(resolving).rejects.toThrow(/start failed/) + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('a transient parent lookup failure rejects resolution instead of falling back to host', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-on', sessionId: ROOT_SESSION })) + const controller = createController({ + pollIntervalMs: 500, + // The acknowledged root resolves fine, but the subagent's parent lookup hits a transient + // failure. This mirrors createParentSessionLookup propagating a connection error rather than + // caching a false "not a descendant". + getParentSessionId: async (sid) => { + if (sid === ROOT_SESSION) return null + throw new Error('Unable to connect') + }, + }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // The transient failure must reject (fail closed) so bash/glob/grep do not run host-side for + // the descendant, rather than returning null which hooks treat as host permission. + await expect(controller.resolveSandboxForSession('session-sub')).rejects.toThrow(/Unable to connect/) + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).resolves.not.toBeNull() + await controller.dispose() + }) + + test('a failed stop is retried on the next reconcile until removal succeeds', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-off-up', enabled: true, sessionId: ROOT_SESSION })) + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // Desired moves OFF and the stop fails transiently. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-off-retry', enabled: false, sessionId: ROOT_SESSION })) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + await vi.advanceTimersByTimeAsync(20) + + // The failed stop is acknowledged OFF-with-error and ownership is preserved for retry. + let applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-off-retry') + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/transient removal failure/) + + // The removal now succeeds; the next reconcile retries and settles OFF without error. + stopFails = false + await vi.advanceTimersByTimeAsync(20) + applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-off-retry') + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeNull() + expect(manager.stopCalls).toHaveLength(2) + + await controller.dispose() + expect(manager.stopCalls).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + test('a failed stop during absent-desired teardown is retried until removal succeeds', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-absent-up', sessionId: ROOT_SESSION })) + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // The desired row is removed entirely; the teardown stop fails transiently. + db.run('DELETE FROM tui_preferences WHERE project_id = ? AND key = ?', [PROJECT, 'session-sandbox.desired']) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + await vi.advanceTimersByTimeAsync(20) + // The failed teardown stop retains ownership: it is retried, never settled while live. + expect(manager.stopCalls).toHaveLength(1) + + stopFails = false + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(2) + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).resolves.toBeNull() + + await controller.dispose() + expect(manager.stopCalls).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + test('a failed stop during a null-session transition is retried until removal succeeds', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-ns-up', sessionId: ROOT_SESSION })) + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // Desired rebinds to a null session; the transition stop fails transiently. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-ns', enabled: true, sessionId: null })) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(1) + + stopFails = false + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(2) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/without a session/) + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('a failed stop during a loop-refusal transition is retried until removal succeeds', async () => { + vi.useFakeTimers() + try { + let inLoop = false + repo.setDesired(PROJECT, makeDesired({ revision: 'r-loop-up', sessionId: ROOT_SESSION })) + const controller = createController({ + pollIntervalMs: 20, + resolveActiveLoopForSession: async () => ({ active: inLoop, sandbox: true }), + }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // The session becomes part of an active loop; the refusal stop fails transiently. + inLoop = true + repo.setDesired(PROJECT, makeDesired({ revision: 'r-loop', sessionId: ROOT_SESSION })) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(1) + + stopFails = false + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(2) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/active loop session/) + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('a failed stop during failed-start cleanup is retried until removal succeeds', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-fs-up', sessionId: ROOT_SESSION })) + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // A new start attempt fails and the cleanup stop fails transiently. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-fs', sessionId: ROOT_SESSION })) + manager.setEnsureRunningImpl(async () => { + throw new Error('start failed') + }) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(1) + + stopFails = false + await vi.advanceTimersByTimeAsync(20) + // Pending cleanup resolves (stop 2) and the failure is settled OFF-with-error; the still-ON + // desired is never re-attempted, so the failed start is not retried. + expect(manager.stopCalls).toHaveLength(2) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/start failed/) + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('a failed stop during ownership transfer is retried until removal succeeds', async () => { + vi.useFakeTimers() + try { + const ROOT_DIR = DIRECTORY + const WORKTREE_DIR = '/abs/path/loop-worktree' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: 'session-root' })) + const ownerA = createController({ + pollIntervalMs: 20, + getSessionDirectory: async (sid) => (sid === 'session-root' ? ROOT_DIR : WORKTREE_DIR), + }) + await ownerA.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // Selection rebinds to a session owned by another instance; the transfer stop fails. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: 'session-wt' })) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transfer removal failed') + manager.active = null + } + await vi.advanceTimersByTimeAsync(20) + // The non-owner never acknowledges r-b, and a failed stop preserves ownership for retry. + expect(repo.getApplied(PROJECT)?.revision).toBe('r-a') + expect(manager.stopCalls).toHaveLength(1) + + // The removal now succeeds; the transfer completes without leaking the container. + stopFails = false + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(2) + await expect(ownerA.resolveSandboxForSession('session-root')).resolves.toBeNull() + + await ownerA.dispose() + expect(manager.stopCalls).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + test('a restarted instance leaves the shared container to its owner when ownership is foreign', async () => { + vi.useFakeTimers() + try { + const WORKTREE_DIR = '/abs/path/loop-worktree' + // Instance A binds ON to a local session and starts its container, then "crashes" without + // disposing, leaving the container live at the deterministic manager key. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: 'session-root' })) + const first = createController({ + pollIntervalMs: 20, + getSessionDirectory: async (sid) => (sid === 'session-root' ? DIRECTORY : WORKTREE_DIR), + }) + await first.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + expect(manager.stopCalls).toEqual([]) + expect(manager.getActive(MANAGER_KEY)).not.toBeNull() + + // The preference is rebound to a session another instance owns before A restarts. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: 'session-wt' })) + + // A restarts: the desired session is foreign to its directory and hostActive is false after + // restart. The manager key is derived from the project id and is therefore shared by every + // instance of this project, so the live container now belongs to whichever instance owns the + // selected session. A must leave it alone: stopping it here would tear down the owner's + // sandbox. It is not leaked, because the owner reconciles the very same key. + const restarted = createController({ + pollIntervalMs: 20, + getSessionDirectory: async (sid) => (sid === 'session-root' ? DIRECTORY : WORKTREE_DIR), + }) + await restarted.start() + expect(manager.stopCalls).not.toContain(MANAGER_KEY) + expect(manager.getActive(MANAGER_KEY)).not.toBeNull() + // The foreign acknowledgement is never overwritten. + expect(repo.getApplied(PROJECT)?.revision).toBe('r-a') + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + await restarted.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('the container key follows the project id, not the instance directory', async () => { + // The desired/applied preference rows are stored per project, so the container must be keyed at + // the same granularity. A second checkout of the same project has to resolve the very same + // container instead of starting a competing one against the single shared preference row. + const OTHER_CHECKOUT = '/abs/path/to/another-checkout' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-key', sessionId: ROOT_SESSION })) + const controller = createController({ + directory: OTHER_CHECKOUT, + getSessionDirectory: async () => OTHER_CHECKOUT, + }) + await controller.start() + + expect(manager.ensureRunningCalls).toEqual([MANAGER_KEY]) + expect(MANAGER_KEY).toBe(deriveManagerKey(PROJECT)) + expect(MANAGER_KEY).not.toBe(deriveManagerKey(OTHER_CHECKOUT)) + + await controller.dispose() + }) + + test('an idle acknowledged sandbox does not re-run ensureRunning on every poll', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-idle', sessionId: ROOT_SESSION })) + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + // Initial reconcile validated the runtime exactly once. + expect(manager.ensureRunningCalls).toEqual([MANAGER_KEY]) + + // Many idle polls must not call ensureRunning again (no repeated work for the applied revision). + await vi.advanceTimersByTimeAsync(1000) + expect(manager.ensureRunningCalls).toEqual([MANAGER_KEY]) + + // Resolution still returns the acknowledged sandbox; its liveness restore is separate and + // legitimate (one extra ensureRunning for the explicit tool-path recovery). + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).resolves.not.toBeNull() + expect(manager.ensureRunningCalls).toHaveLength(2) + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('an ON request without a session is acknowledged OFF-with-error and never starts SBX', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-null-session', enabled: true, sessionId: null })) + const controller = createController() + await controller.start() + + expect(manager.ensureRunningCalls).toEqual([]) + expect(manager.stopCalls).toEqual([]) + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-null-session') + expect(applied?.enabled).toBe(false) + expect(applied?.sessionId).toBeNull() + expect(applied?.error).toMatch(/without a session/) + await controller.dispose() + }) + + test('a persisted null-session ON request is refused again on a fresh instance', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-null-persist', enabled: true, sessionId: null })) + const first = createController() + await first.start() + expect(repo.getApplied(PROJECT)?.error).toMatch(/without a session/) + await first.dispose() + + // A fresh instance re-reads the same persisted desired ON (with no session) and refuses it + // again rather than starting an orphaned container. + const second = createController() + await second.start() + expect(manager.ensureRunningCalls).toEqual([]) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/without a session/) + await second.dispose() + }) + + test('a fresh-start partial-creation failure always cleans up the container it created', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-partial', sessionId: ROOT_SESSION })) + // ensureRunning creates the container (env file) and then fails: a first start with no prior + // binding must still run deterministic-key cleanup so the created container is not leaked. + manager.setEnsureRunningImpl(async (key, dir) => { + manager.active = { containerName: `forge-${key}`, projectDir: dir, startedAt: new Date().toISOString(), mounts: [] } + throw new Error('env file generation failed') + }) + const controller = createController() + await controller.start() + + expect(manager.stopCalls).toEqual([MANAGER_KEY]) + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-partial') + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/env file generation failed/) + // No live container remains to expose. + expect(manager.active).toBeNull() + await controller.dispose() + }) + + test('fresh-start partial-creation cleanup is retried when the cleanup stop fails transiently', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-partial-retry', sessionId: ROOT_SESSION })) + manager.setEnsureRunningImpl(async (key, dir) => { + manager.active = { containerName: `forge-${key}`, projectDir: dir, startedAt: new Date().toISOString(), mounts: [] } + throw new Error('start failed after creation') + }) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + + // First cleanup attempt fails; ownership is retained and no settled OFF-with-error is written. + expect(manager.stopCalls).toHaveLength(1) + expect(repo.getApplied(PROJECT)).toBeNull() + + stopFails = false + await vi.advanceTimersByTimeAsync(20) + // Pending cleanup resolves (stop 2) and the failure is settled OFF-with-error; the still-ON + // start is never re-attempted, so no extra cleanup runs. + expect(manager.stopCalls).toHaveLength(2) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/start failed after creation/) + expect(manager.active).toBeNull() + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('a failed-start cleanup stop is retried before any start can adopt the live partial container', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-adopt', sessionId: ROOT_SESSION })) + // The first start creates the container and then fails (env-file setup). A subsequent + // ensureRunning would now SUCCEED: without a pending-cleanup guard the next reconcile tick + // would adopt the partially-initialized container and wrongly acknowledge ON. + let startCount = 0 + manager.setEnsureRunningImpl(async (key, dir) => { + startCount++ + if (startCount === 1) { + manager.active = { containerName: `forge-${key}`, projectDir: dir, startedAt: new Date().toISOString(), mounts: [] } + throw new Error('start failed after creation') + } + return `forge-${key}` + }) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + + // First cleanup attempt fails; ownership is retained and no applied row is written yet. + expect(manager.stopCalls).toHaveLength(1) + expect(repo.getApplied(PROJECT)).toBeNull() + + // While removal keeps failing, the retry must retry REMOVAL, not start: even though a fresh + // ensureRunning would succeed, the live partial container must not be adopted and + // acknowledged ON. + await vi.advanceTimersByTimeAsync(60) + expect(manager.stopCalls).toHaveLength(4) + expect(startCount).toBe(1) + expect(repo.getApplied(PROJECT)).toBeNull() + + // Removal now succeeds; the failed start is settled OFF-with-error rather than re-attempting + // the ON start, so the live partial container is never adopted. + stopFails = false + await vi.advanceTimersByTimeAsync(20) + expect(startCount).toBe(1) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/start failed after creation/) + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('tool-path restore failure stops the container and replaces stale applied ON with OFF-with-error', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-restore-fail2', sessionId: ROOT_SESSION })) + const controller = createController() + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + expect(await controller.resolveSandboxForSession(ROOT_SESSION)).not.toBeNull() + + // The acknowledged container dies; recovery recreates it but fails during env-file setup, + // leaving a partially-created container and a stale applied-ON row. + manager.setEnsureRunningImpl(async (key, dir) => { + manager.active = { containerName: `forge-${key}`, projectDir: dir, startedAt: new Date().toISOString(), mounts: [] } + throw new Error('env setup failed during restore') + }) + await expect( + controller.resolveSandboxForSession(ROOT_SESSION, { throwOnRestoreError: true }), + ).rejects.toThrow(/env setup failed during restore/) + + // The stale applied-ON is replaced with OFF-with-error and the live container is removed. + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/env setup failed during restore/) + expect(manager.active).toBeNull() + // The selected session stays fail-closed rather than falling back to the host. + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).rejects.toThrow(/env setup failed during restore/) + await controller.dispose() + }) + + test('a session that enters an active loop after ON is settled has its host sandbox stopped and OFF acknowledged', async () => { + vi.useFakeTimers() + try { + let inLoop = false + repo.setDesired(PROJECT, makeDesired({ revision: 'r-loop-later', sessionId: ROOT_SESSION })) + const controller = createController({ + pollIntervalMs: 20, + resolveActiveLoopForSession: async () => ({ active: inLoop, sandbox: true }), + }) + await controller.start() + // Not in a loop yet: the host sandbox is ON and acknowledged, and nothing was stopped. + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + expect(manager.stopCalls).toEqual([]) + + // The selected session enters an active loop WITHOUT changing the desired revision. + inLoop = true + await vi.advanceTimersByTimeAsync(20) + + // The host sandbox is stopped and the acknowledgement is flipped to OFF-with-error for the + // same revision, so the sidebar no longer reports ON and no container is leaked. + expect(manager.stopCalls).toContain(MANAGER_KEY) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.revision).toBe('r-loop-later') + expect(applied?.error).toMatch(/active loop session/) + expect(controller.getState()?.enabled).toBe(false) + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('a session that enters an active loop stays fail-closed until its removal succeeds', async () => { + vi.useFakeTimers() + try { + let inLoop = false + repo.setDesired(PROJECT, makeDesired({ revision: 'r-loop-later-retry', sessionId: ROOT_SESSION })) + const controller = createController({ + pollIntervalMs: 20, + resolveActiveLoopForSession: async () => ({ active: inLoop, sandbox: true }), + }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + inLoop = true + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + await vi.advanceTimersByTimeAsync(20) + // A failed removal retains ownership: no settled OFF-with-error is written yet. + expect(manager.stopCalls).toHaveLength(1) + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + stopFails = false + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(2) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/active loop session/) + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('start rejects when the initial reconciliation fails, failing closed instead of restoring stale ON', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-start-fail' })) + const controller = createController({ + resolveActiveLoopForSession: async () => { + throw new Error('session lookup failed') + }, + }) + // Startup reconciliation must not swallow errors: a transient session-lookup failure must fail + // the plugin startup rather than return with an ON indicator and no restored runtime binding. + await expect(controller.start()).rejects.toThrow(/session lookup failed/) + expect(manager.ensureRunningCalls).toEqual([]) + }) + + test('dispose tears down a pre-existing container when startup restore aborts on a lookup failure', async () => { + // An unclean restart left the container running and the applied row ON at the desired revision. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-restore-abort' })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-restore-abort', + enabled: true, + sessionId: ROOT_SESSION, + error: null, + appliedAt: Date.now(), + }) + manager.setActive({ containerName: `forge-${MANAGER_KEY}`, projectDir: DIRECTORY, startedAt: new Date().toISOString(), mounts: [] }) + const controller = createController({ + resolveActiveLoopForSession: async () => { + throw new Error('session lookup failed') + }, + }) + // Startup fails closed: the persisted-ON restore cannot validate the session membership. + await expect(controller.start()).rejects.toThrow(/session lookup failed/) + expect(manager.ensureRunningCalls).toEqual([]) + + // Cleanup must still tear down the pre-existing container even though hostActive was never set. + await controller.dispose() + expect(manager.stopCalls).toContain(MANAGER_KEY) + expect(manager.active).toBeNull() + }) + + test('uncertain ownership of an ON request fails closed instead of exposing host fallback', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-uncertain', sessionId: 'session-x' })) + // The directory-scoped lookup cannot resolve the session: ownership is uncertain, not confirmed + // foreign, so the controller must neither claim it nor leave host fallback exposed. + const controller = createController({ + getSessionDirectory: async () => null, + }) + await controller.start() + expect(manager.ensureRunningCalls).toEqual([]) + // No acknowledgement is written (ownership cannot be confirmed), but the selected session must + // still be blocked fail-closed rather than falling through to host execution. + expect(repo.getApplied(PROJECT)).toBeNull() + await expect( + controller.resolveSandboxForSession('session-x', { throwOnRestoreError: true }), + ).rejects.toThrow(/ownership could not be confirmed/) + await controller.dispose() + }) + + test('dispose leaves a pre-existing container to its owner when ownership is uncertain for a persisted ON', async () => { + // An unclean restart left the container running and the applied row ON at the desired revision, + // but the directory-scoped ownership lookup cannot resolve the session (uncertain, not foreign), + // so reconciliation returns before confirming the container lifecycle. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-uncertain-on', sessionId: 'session-x' })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-uncertain-on', + enabled: true, + sessionId: 'session-x', + error: null, + appliedAt: Date.now(), + }) + manager.setActive({ containerName: `forge-${MANAGER_KEY}`, projectDir: DIRECTORY, startedAt: new Date().toISOString(), mounts: [] }) + const controller = createController({ + getSessionDirectory: async () => null, + }) + // Reconciliation does not confirm ownership, so it never confirms (or starts) the container. + await controller.start() + expect(manager.stopCalls).toEqual([]) + // The manager key is derived from the project id and is shared by every instance of this + // project. Uncertain ownership cannot confirm this instance owns the selected session, and + // hostActive was never set, so this instance never started the container. Stopping it on + // disposal could tear down another instance's live sandbox, so it is left for its owner to + // reconcile. + await controller.dispose() + expect(manager.stopCalls).not.toContain(MANAGER_KEY) + expect(manager.active).not.toBeNull() + }) + + test('dispose stops the container even when preference bookkeeping fails', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-dispose-book' })) + const controller = createController() + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // A transient bookkeeping failure (applied-row read throws) after the container is stopped must + // not abort disposal before container removal. + const getDesiredSpy = vi.spyOn(repo, 'getDesired').mockImplementation(() => { + throw new Error('sqlite read failed') + }) + await controller.dispose() + getDesiredSpy.mockRestore() + + // The container must still have been stopped even though the OFF bookkeeping failed. + expect(manager.stopCalls).toContain(MANAGER_KEY) + }) + + test("a superseded restore failure does not overwrite the newer applied acknowledgement", async () => { + const OTHER_SESSION = 'session-b' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: ROOT_SESSION })) + const controller = createController({ pollIntervalMs: 60_000 }) + await controller.start() + expect(await controller.resolveSandboxForSession(ROOT_SESSION)).not.toBeNull() + expect(repo.getApplied(PROJECT)?.revision).toBe('r-a') + + // Desired supersedes to B while A is still the acknowledged root. B's applied acknowledgement + // is already recorded as the newer shared state this controller must not clobber. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: OTHER_SESSION })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-b', + enabled: true, + sessionId: OTHER_SESSION, + error: null, + appliedAt: 5000, + }) + + // A's container dies and the restore now fails (only the first recovery attempt fails). + let failFirst = true + manager.setEnsureRunningImpl(async (key, dir) => { + if (failFirst) { + failFirst = false + throw new Error('cannot recover container') + } + return `forge-${key}` + }) + + await expect( + controller.resolveSandboxForSession(ROOT_SESSION, { throwOnRestoreError: true }), + ).rejects.toThrow('cannot recover container') + + // A's failure must not overwrite B's newer applied acknowledgement: B's revision stays applied + // and the reconcile after the superseded failure re-validates B instead of recording A's error. + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-b') + expect(applied?.enabled).toBe(true) + expect(applied?.error).toBeNull() + await controller.dispose() + }) + + test('startup loop refusal stops a stale deterministic-key container left by a crashed ON', async () => { + vi.useFakeTimers() + try { + // A prior run left the container live (unclean crash) and the applied row ON at an OLD + // revision, while desired has since moved to a NEW loop-refused revision. On restart the + // fresh instance has hostActive=false, yet the refusal must still stop the stale container. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-loop-new', sessionId: ROOT_SESSION })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-old', + enabled: true, + sessionId: ROOT_SESSION, + error: null, + appliedAt: Date.now(), + }) + manager.setActive({ + containerName: `forge-${MANAGER_KEY}`, + projectDir: DIRECTORY, + startedAt: new Date().toISOString(), + mounts: [], + }) + const controller = createController({ + pollIntervalMs: 20, + resolveActiveLoopForSession: async () => ({ active: true, sandbox: true }), + }) + await controller.start() + // The stale container is removed before the refusal is acknowledged OFF, even though + // hostActive was false after the fresh restart. + expect(manager.stopCalls).toContain(MANAGER_KEY) + expect(manager.active).toBeNull() + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-loop-new') + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/active loop session/) + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('a failed ownership-transfer stop blocks a superseding local start until removal succeeds', async () => { + vi.useFakeTimers() + try { + const ROOT_DIR = DIRECTORY + const WORKTREE_DIR = '/abs/path/loop-worktree' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: 'session-root' })) + const ownerA = createController({ + pollIntervalMs: 20, + getSessionDirectory: async (sid) => (sid === 'session-root' ? ROOT_DIR : WORKTREE_DIR), + }) + await ownerA.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // Selection rebinds to a foreign session; the transfer stop fails, leaving the container live. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: 'session-wt' })) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transfer removal failed') + manager.active = null + } + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(1) + + // Selection rebinds back to the local root session while the container is still live. The + // failed transfer must not be forgotten: no superseding start adopts the live container. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-c', sessionId: 'session-root' })) + await vi.advanceTimersByTimeAsync(20) + expect(manager.ensureRunningCalls).toHaveLength(1) + expect(manager.stopCalls).toHaveLength(2) + + // Removal now succeeds; the next reconcile retries the local start and acknowledges ON. + stopFails = false + await vi.advanceTimersByTimeAsync(20) + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-c') + expect(applied?.enabled).toBe(true) + expect(applied?.sessionId).toBe('session-root') + await ownerA.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('a superseded restore failure with a failed cleanup never adopts the superseding container', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: 'session-A' })) + const controller = createController({ + pollIntervalMs: 20, + getParentSessionId: async (sid) => (sid === 'desc-A' ? 'session-A' : null), + }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // Desired supersedes to B (applied ON already recorded) while A is still the acknowledged root. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: 'session-B' })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-b', + enabled: true, + sessionId: 'session-B', + error: null, + appliedAt: Date.now(), + }) + + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('cleanup removal failed') + manager.active = null + } + manager.setEnsureRunningImpl(async () => { + throw new Error('cannot recover A container') + }) + // A's container recovery fails and is superseded by B; the follow-up cleanup also fails. + await expect( + controller.resolveSandboxForSession('desc-A', { throwOnRestoreError: true }), + ).rejects.toThrow(/cannot recover A container/) + + // The failed cleanup retained pending ownership: while removal keeps failing the blocked + // reconcile retries removal and never starts/adopts the superseding container. + const startsBefore = manager.ensureRunningCalls.length + await vi.advanceTimersByTimeAsync(40) + expect(manager.ensureRunningCalls.length).toBe(startsBefore) + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + // Removal now succeeds; the blocked reconcile retries and acknowledges B ON. + stopFails = false + manager.setEnsureRunningImpl(async () => `forge-${MANAGER_KEY}`) + await vi.advanceTimersByTimeAsync(20) + const applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-b') + expect(applied?.enabled).toBe(true) + expect(applied?.sessionId).toBe('session-B') + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/test/session-sandbox-preferences-repo.test.ts b/test/session-sandbox-preferences-repo.test.ts new file mode 100644 index 0000000000..088cc3c98e --- /dev/null +++ b/test/session-sandbox-preferences-repo.test.ts @@ -0,0 +1,196 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { + createSessionSandboxPreferencesRepo, + SESSION_SANDBOX_DESIRED_KEY, + SESSION_SANDBOX_APPLIED_KEY, +} from '../src/storage' +import { setupLoopsTestDb } from './helpers/loops-test-db' + +const PROJECT_A = 'project-a' +const PROJECT_B = 'project-b' + +function makeDesired(overrides: Partial = {}): import('../src/storage').SessionSandboxDesiredState { + return { version: 1, revision: 'rev-1', enabled: true, sessionId: 'sess-1', requestedAt: 1000, ...overrides } +} + +function makeApplied(overrides: Partial = {}): import('../src/storage').SessionSandboxAppliedState { + return { version: 1, revision: 'rev-1', enabled: true, sessionId: 'sess-1', error: null, appliedAt: 2000, ...overrides } +} + +describe('SessionSandboxPreferencesRepo', () => { + let db: Database + let repo: ReturnType + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'session-sandbox-preferences-repo-test-')) + const dbPath = join(tempDir, 'test.db') + db = new Database(dbPath) + setupLoopsTestDb(db) + repo = createSessionSandboxPreferencesRepo(db) + }) + + afterEach(() => { + db.close() + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + }) + + describe('desired round-trip', () => { + test('returns null when nothing stored', () => { + expect(repo.getDesired(PROJECT_A)).toBeNull() + }) + + test('round-trips a full desired state', () => { + const state = makeDesired({ revision: 'abc', enabled: false, sessionId: 'sess-2', requestedAt: 999 }) + repo.setDesired(PROJECT_A, state) + expect(repo.getDesired(PROJECT_A)).toEqual(state) + }) + + test('round-trips nullable sessionId', () => { + const state = makeDesired({ sessionId: null }) + repo.setDesired(PROJECT_A, state) + expect(repo.getDesired(PROJECT_A)).toEqual(state) + }) + + test('replacement overwrites prior value under the same key', () => { + repo.setDesired(PROJECT_A, makeDesired({ revision: 'v1' })) + repo.setDesired(PROJECT_A, makeDesired({ revision: 'v2', enabled: false })) + expect(repo.getDesired(PROJECT_A)).toEqual(makeDesired({ revision: 'v2', enabled: false })) + }) + }) + + describe('applied round-trip', () => { + test('returns null when nothing stored', () => { + expect(repo.getApplied(PROJECT_A)).toBeNull() + }) + + test('round-trips a full applied state', () => { + const state = makeApplied({ revision: 'abc', enabled: false, sessionId: null, error: 'boom', appliedAt: 555 }) + repo.setApplied(PROJECT_A, state) + expect(repo.getApplied(PROJECT_A)).toEqual(state) + }) + + test('round-trips null error and nullable sessionId', () => { + const state = makeApplied({ sessionId: 'sess-x', error: null }) + repo.setApplied(PROJECT_A, state) + expect(repo.getApplied(PROJECT_A)).toEqual(state) + }) + + test('replacement overwrites prior value under the same key', () => { + repo.setApplied(PROJECT_A, makeApplied({ revision: 'v1', error: 'old' })) + repo.setApplied(PROJECT_A, makeApplied({ revision: 'v2', error: null })) + expect(repo.getApplied(PROJECT_A)).toEqual(makeApplied({ revision: 'v2', error: null })) + }) + }) + + describe('key independence', () => { + test('desired and applied do not overwrite each other', () => { + const desired = makeDesired({ revision: 'd1' }) + const applied = makeApplied({ revision: 'a1', error: 'err' }) + repo.setDesired(PROJECT_A, desired) + repo.setApplied(PROJECT_A, applied) + + expect(repo.getDesired(PROJECT_A)).toEqual(desired) + expect(repo.getApplied(PROJECT_A)).toEqual(applied) + + // Update one, the other stays intact + repo.setDesired(PROJECT_A, makeDesired({ revision: 'd2' })) + expect(repo.getDesired(PROJECT_A)).toEqual(makeDesired({ revision: 'd2' })) + expect(repo.getApplied(PROJECT_A)).toEqual(applied) + }) + }) + + describe('project isolation', () => { + test('states under different project ids are independent', () => { + const desiredA = makeDesired({ revision: 'a' }) + const desiredB = makeDesired({ revision: 'b' }) + repo.setDesired(PROJECT_A, desiredA) + repo.setDesired(PROJECT_B, desiredB) + + expect(repo.getDesired(PROJECT_A)).toEqual(desiredA) + expect(repo.getDesired(PROJECT_B)).toEqual(desiredB) + + const appliedA = makeApplied({ revision: 'a' }) + repo.setApplied(PROJECT_A, appliedA) + expect(repo.getApplied(PROJECT_A)).toEqual(appliedA) + expect(repo.getApplied(PROJECT_B)).toBeNull() + }) + }) + + describe('malformed-row handling', () => { + function writeRaw(projectId: string, key: string, data: string): void { + db.run( + 'INSERT OR REPLACE INTO tui_preferences (project_id, key, data, expires_at, updated_at) VALUES (?, ?, ?, NULL, ?)', + projectId, + key, + data, + Date.now(), + ) + } + + test('treats invalid JSON as absent', () => { + writeRaw(PROJECT_A, SESSION_SANDBOX_DESIRED_KEY, '{not json') + writeRaw(PROJECT_A, SESSION_SANDBOX_APPLIED_KEY, 'nope') + expect(repo.getDesired(PROJECT_A)).toBeNull() + expect(repo.getApplied(PROJECT_A)).toBeNull() + }) + + test('treats wrong version as absent', () => { + writeRaw(PROJECT_A, SESSION_SANDBOX_DESIRED_KEY, JSON.stringify(makeDesired({ version: 2 as never }))) + writeRaw(PROJECT_A, SESSION_SANDBOX_APPLIED_KEY, JSON.stringify(makeApplied({ version: 0 as never }))) + expect(repo.getDesired(PROJECT_A)).toBeNull() + expect(repo.getApplied(PROJECT_A)).toBeNull() + }) + + test('treats non-boolean enabled as absent', () => { + writeRaw(PROJECT_A, SESSION_SANDBOX_DESIRED_KEY, JSON.stringify({ ...makeDesired(), enabled: 'yes' })) + writeRaw(PROJECT_A, SESSION_SANDBOX_APPLIED_KEY, JSON.stringify({ ...makeApplied(), enabled: 1 })) + expect(repo.getDesired(PROJECT_A)).toBeNull() + expect(repo.getApplied(PROJECT_A)).toBeNull() + }) + + test('treats non-finite timestamp as absent', () => { + writeRaw(PROJECT_A, SESSION_SANDBOX_DESIRED_KEY, JSON.stringify({ ...makeDesired(), requestedAt: Number.NaN })) + writeRaw(PROJECT_A, SESSION_SANDBOX_APPLIED_KEY, JSON.stringify({ ...makeApplied(), appliedAt: Infinity })) + expect(repo.getDesired(PROJECT_A)).toBeNull() + expect(repo.getApplied(PROJECT_A)).toBeNull() + }) + + test('treats wrong-type nullable fields as absent', () => { + writeRaw(PROJECT_A, SESSION_SANDBOX_DESIRED_KEY, JSON.stringify({ ...makeDesired(), sessionId: 42 })) + writeRaw(PROJECT_A, SESSION_SANDBOX_APPLIED_KEY, JSON.stringify({ ...makeApplied(), error: 42 })) + expect(repo.getDesired(PROJECT_A)).toBeNull() + expect(repo.getApplied(PROJECT_A)).toBeNull() + }) + + test('treats empty or whitespace-only revision as absent', () => { + writeRaw(PROJECT_A, SESSION_SANDBOX_DESIRED_KEY, JSON.stringify({ ...makeDesired(), revision: '' })) + writeRaw(PROJECT_A, SESSION_SANDBOX_APPLIED_KEY, JSON.stringify({ ...makeApplied(), revision: ' ' })) + expect(repo.getDesired(PROJECT_A)).toBeNull() + expect(repo.getApplied(PROJECT_A)).toBeNull() + }) + + test('treats empty or whitespace-only non-null session identifier as absent', () => { + writeRaw(PROJECT_A, SESSION_SANDBOX_DESIRED_KEY, JSON.stringify({ ...makeDesired(), sessionId: '' })) + writeRaw(PROJECT_A, SESSION_SANDBOX_APPLIED_KEY, JSON.stringify({ ...makeApplied(), sessionId: ' ' })) + expect(repo.getDesired(PROJECT_A)).toBeNull() + expect(repo.getApplied(PROJECT_A)).toBeNull() + }) + + test('a malformed desired row does not mask a valid applied row', () => { + writeRaw(PROJECT_A, SESSION_SANDBOX_DESIRED_KEY, 'bad') + const applied = makeApplied() + repo.setApplied(PROJECT_A, applied) + expect(repo.getDesired(PROJECT_A)).toBeNull() + expect(repo.getApplied(PROJECT_A)).toEqual(applied) + }) + }) +}) diff --git a/test/tui/session-sandbox-store.test.ts b/test/tui/session-sandbox-store.test.ts new file mode 100644 index 0000000000..0f846023c0 --- /dev/null +++ b/test/tui/session-sandbox-store.test.ts @@ -0,0 +1,512 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { Worker } from 'worker_threads' +import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { + awaitSessionSandboxState, + beginSessionSandboxStateRequest, + deriveSessionSandboxAcknowledged, + hostSandboxToggleBlocked, + isSessionSandboxPreferenceSettled, + readSessionSandboxPreference, + writeSessionSandboxDesired, + requestSessionSandboxState, +} from '../../src/tui/session-sandbox-store' +import { createSessionSandboxPreferencesRepo } from '../../src/storage' +import type { SessionSandboxAppliedState, SessionSandboxDesiredState } from '../../src/storage' +import { setupLoopsTestDb } from '../helpers/loops-test-db' + +const PROJECT_A = 'project-a' + +describe('session-sandbox-store (TUI bridge)', () => { + let tempDir: string + let dbPath: string + let db: Database + let repo: ReturnType + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'session-sandbox-store-test-')) + dbPath = join(tempDir, 'forge.db') + db = new Database(dbPath) + db.exec('PRAGMA journal_mode = WAL') + setupLoopsTestDb(db) + repo = createSessionSandboxPreferencesRepo(db) + }) + + afterEach(() => { + db.close() + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + }) + + function writeApplied(overrides: Partial = {}): SessionSandboxAppliedState { + const applied: SessionSandboxAppliedState = { + version: 1, + revision: 'rev-applied', + enabled: true, + sessionId: 'sess-1', + error: null, + appliedAt: Date.now(), + ...overrides, + } + repo.setApplied(PROJECT_A, applied) + return applied + } + + describe('hostSandboxToggleBlocked', () => { + test('blocks the toggle when sandboxing is disabled by config', () => { + expect(hostSandboxToggleBlocked(false)).toBe('Host sandbox is disabled by config (sandbox.enabled: false)') + }) + + test('allows the toggle when sandboxing is enabled by config', () => { + expect(hostSandboxToggleBlocked(true)).toBeNull() + }) + }) + + describe('readSessionSandboxPreference', () => { + test('returns both null and unavailable when the database file is missing', () => { + const missing = join(tempDir, 'does-not-exist.db') + expect(readSessionSandboxPreference(PROJECT_A, missing)).toEqual({ + desired: null, + applied: null, + unavailable: true, + unavailableReason: 'database file not found', + }) + }) + + test('returns both null and unavailable when the table is uninitialized', () => { + const emptyDbPath = join(tempDir, 'empty.db') + const empty = new Database(emptyDbPath) + empty.close() + const pref = readSessionSandboxPreference(PROJECT_A, emptyDbPath) + expect(pref).toMatchObject({ desired: null, applied: null, unavailable: true }) + // The reason must name the missing table so a misresolved database path is diagnosable + // rather than surfacing as an opaque "unavailable". + expect(pref.unavailableReason).toMatch(/tui_preferences/) + }) + + test('reads persisted desired and applied rows', () => { + const desired = { version: 1 as const, revision: 'r1', enabled: true, sessionId: 'sess-1', requestedAt: 100 } + const applied = writeApplied({ revision: 'r1' }) + repo.setDesired(PROJECT_A, desired) + + const pref = readSessionSandboxPreference(PROJECT_A, dbPath) + expect(pref.desired).toEqual(desired) + expect(pref.applied).toEqual(applied) + }) + + test('reads rows for the correct project only', () => { + repo.setDesired(PROJECT_A, { version: 1 as const, revision: 'r1', enabled: true, sessionId: 'sess-1', requestedAt: 1 }) + expect(readSessionSandboxPreference('project-b', dbPath)).toEqual({ desired: null, applied: null, unavailable: false }) + }) + + test('does not create a missing database file', () => { + const missing = join(tempDir, 'never-created.db') + expect(readSessionSandboxPreference(PROJECT_A, missing)).toEqual({ + desired: null, + applied: null, + unavailable: true, + unavailableReason: 'database file not found', + }) + expect(existsSync(missing)).toBe(false) + }) + + test('returns nulls when the file exists but is unreadable', () => { + const corruptPath = join(tempDir, 'corrupt.db') + writeFileSync(corruptPath, 'this is not a sqlite database at all, definitely not') + // Opening succeeds lazily; the first query fails and is swallowed, and the + // locally opened handle is closed in the finally block. + const pref = readSessionSandboxPreference(PROJECT_A, corruptPath) + expect(pref).toMatchObject({ desired: null, applied: null, unavailable: true }) + expect(pref.unavailableReason).toBeTruthy() + }) + + test('reports unavailable until the table exists, then available once initialized', () => { + const path = join(tempDir, 'delayed.db') + // A transiently unavailable read (missing DB) must be distinguishable from "no persisted + // state" so the TUI keeps polling instead of permanently showing OFF. + expect(readSessionSandboxPreference(PROJECT_A, path)).toEqual({ + desired: null, + applied: null, + unavailable: true, + unavailableReason: 'database file not found', + }) + + // Server initializes the DB and table; the same read now reports available. + const initialized = new Database(path) + setupLoopsTestDb(initialized) + initialized.close() + expect(readSessionSandboxPreference(PROJECT_A, path)).toEqual({ desired: null, applied: null, unavailable: false }) + }) + + test('assembles desired and applied from one snapshot despite an intervening desired write', async () => { + // A concurrent writer commits atomic (desired, applied) pairs whose + // revisions always match. Any reader must therefore see either the + // pre-commit or post-commit pair, never a mix across commit boundaries. + // The pre-fix code read the two keys as separate autocommit queries, so a + // commit landing between them could yield mismatched revisions and briefly + // trust a superseded ON state. + const workerCode = ` + const { parentPort, workerData } = require('worker_threads') + const Database = require('better-sqlite3') + const db = new Database(workerData.dbPath) + db.pragma('journal_mode = WAL') + db.pragma('busy_timeout = 5000') + const upsert = db.prepare( + 'INSERT INTO tui_preferences (project_id, key, data, expires_at, updated_at) ' + + 'VALUES (?, ?, ?, NULL, ?) ' + + 'ON CONFLICT(project_id, key) DO UPDATE SET data = excluded.data, expires_at = NULL, updated_at = excluded.updated_at' + ) + const writePair = db.transaction((rev) => { + const now = Date.now() + upsert.run(workerData.projectId, workerData.desiredKey, + JSON.stringify({ version: 1, revision: rev, enabled: true, sessionId: 'sess-1', requestedAt: now }), now) + upsert.run(workerData.projectId, workerData.appliedKey, + JSON.stringify({ version: 1, revision: rev, enabled: true, sessionId: 'sess-1', error: null, appliedAt: now }), now) + }) + const start = Date.now() + let i = 0 + while (Date.now() - start < workerData.durationMs) { + i++ + writePair('r' + i) + } + parentPort.postMessage({ writes: i }) + ` + const worker = new Worker(workerCode, { + eval: true, + workerData: { + dbPath, + projectId: PROJECT_A, + desiredKey: 'session-sandbox.desired', + appliedKey: 'session-sandbox.applied', + durationMs: 500, + }, + }) + const done = new Promise<{ writes: number }>((resolve) => worker.once('message', resolve)) + + let reads = 0 + let violation: string | null = null + const end = Date.now() + 500 + while (Date.now() < end) { + const pref = readSessionSandboxPreference(PROJECT_A, dbPath) + reads++ + if (pref.desired && pref.applied && pref.desired.revision !== pref.applied.revision) { + violation = `desired=${pref.desired.revision} applied=${pref.applied.revision}` + break + } + } + const { writes } = await done + await worker.terminate() + + expect(writes).toBeGreaterThan(0) + expect(reads).toBeGreaterThan(0) + expect(violation).toBeNull() + }) + }) + + describe('writeSessionSandboxDesired', () => { + test('persists desired and surfaces it on read', () => { + const desired = { version: 1 as const, revision: 'r9', enabled: false, sessionId: 'sess-2', requestedAt: 42 } + writeSessionSandboxDesired(PROJECT_A, dbPath, desired) + expect(readSessionSandboxPreference(PROJECT_A, dbPath).desired).toEqual(desired) + }) + + test('throws when the database file is missing', () => { + const missing = join(tempDir, 'does-not-exist.db') + expect(() => + writeSessionSandboxDesired(PROJECT_A, missing, { + version: 1, + revision: 'r1', + enabled: true, + sessionId: 'sess-1', + requestedAt: 1, + }), + ).toThrow() + expect(existsSync(missing)).toBe(false) + }) + + test('throws when the table is uninitialized instead of creating a schema', () => { + const emptyDbPath = join(tempDir, 'empty.db') + const empty = new Database(emptyDbPath) + empty.close() + expect(() => + writeSessionSandboxDesired(PROJECT_A, emptyDbPath, { + version: 1, + revision: 'r1', + enabled: true, + sessionId: 'sess-1', + requestedAt: 1, + }), + ).toThrow() + }) + }) + + describe('deriveSessionSandboxAcknowledged', () => { + function desired(overrides: Partial = {}) { + return { version: 1 as const, revision: 'r1', enabled: true, sessionId: 'sess-1', requestedAt: 1, ...overrides } + } + + test('derives ON only for a matching, error-free applied row', () => { + const applied = writeApplied({ revision: 'r1', enabled: true, error: null }) + const pref = { desired: desired(), applied } + expect(deriveSessionSandboxAcknowledged(pref)).toEqual(applied) + }) + + test('derives OFF while the matching applied row has not arrived yet', () => { + // A late acknowledgement: the TUI reads before server reconciliation + // writes the applied row, so it must not report ON prematurely. + expect(deriveSessionSandboxAcknowledged({ desired: desired(), applied: null })).toBeNull() + }) + + test('derives the matching applied row when it arrives after an earlier read', () => { + // The polling refresh re-reads after the initial snapshot, so a matching + // applied ON written after initialization must transition to ON. + const before = deriveSessionSandboxAcknowledged({ desired: desired(), applied: null }) + expect(before).toBeNull() + const applied = writeApplied({ revision: 'r1', enabled: true, error: null }) + const after = deriveSessionSandboxAcknowledged({ desired: desired(), applied }) + expect(after).toEqual(applied) + }) + + test('derives OFF for a mismatched or stale applied revision', () => { + const stale = writeApplied({ revision: 'old-rev', enabled: true, error: null }) + expect(deriveSessionSandboxAcknowledged({ desired: desired(), applied: stale })).toBeNull() + const wrongSession = writeApplied({ revision: 'r1', enabled: true, sessionId: 'sess-other', error: null }) + expect(deriveSessionSandboxAcknowledged({ desired: desired(), applied: wrongSession })).toBeNull() + }) + + test('derives OFF for a matching revision carrying an error or disabled desired', () => { + const errored = writeApplied({ revision: 'r1', enabled: true, error: 'sbx failed' }) + expect(deriveSessionSandboxAcknowledged({ desired: desired(), applied: errored })).toBeNull() + const disabledDesired = desired({ enabled: false }) + expect(deriveSessionSandboxAcknowledged({ desired: disabledDesired, applied: errored })).toBeNull() + }) + + test('derives OFF when an ON acknowledgement is superseded by a newer desired revision', () => { + // An ON request resolves at revision r1, but a subsequent toggle already moved + // the desired revision to r2 (still pending application). Deriving from the + // authoritative pair must not publish the stale ON. + const staleOn = writeApplied({ revision: 'r1', enabled: true, sessionId: 'sess-1', error: null }) + const superseded = desired({ revision: 'r2', enabled: false, sessionId: 'sess-1' }) + expect(deriveSessionSandboxAcknowledged({ desired: superseded, applied: staleOn })).toBeNull() + }) + + test('an unavailable read (missing DB/table) is never derived as ON', () => { + // Even if a previous snapshot looked like ON, an unavailable read has no rows to trust and + // must derive OFF so a transient startup failure never flashes a false ON. + expect( + deriveSessionSandboxAcknowledged({ desired: null, applied: null, unavailable: true }), + ).toBeNull() + }) + }) + + describe('isSessionSandboxPreferenceSettled', () => { + function desired(overrides: Partial = {}) { + return { version: 1 as const, revision: 'r1', enabled: true, sessionId: 'sess-1', requestedAt: 1, ...overrides } + } + + test('is settled with no persisted desired state', () => { + expect(isSessionSandboxPreferenceSettled({ desired: null, applied: null })).toBe(true) + }) + + test('is pending while a desired state awaits its matching applied revision', () => { + expect(isSessionSandboxPreferenceSettled({ desired: desired(), applied: null })).toBe(false) + }) + + test('is pending while the applied row carries a stale revision', () => { + const stale = writeApplied({ revision: 'old-rev', enabled: true, error: null }) + expect(isSessionSandboxPreferenceSettled({ desired: desired(), applied: stale })).toBe(false) + }) + + test('is settled once the applied revision matches, including OFF and error', () => { + const off = writeApplied({ revision: 'r1', enabled: false, error: null }) + expect(isSessionSandboxPreferenceSettled({ desired: desired(), applied: off })).toBe(true) + const errored = writeApplied({ revision: 'r1', enabled: false, error: 'sbx failed to start' }) + expect(isSessionSandboxPreferenceSettled({ desired: desired(), applied: errored })).toBe(true) + }) + }) + + describe('requestSessionSandboxState', () => { + test('writes desired and resolves on the matching applied revision', async () => { + const promise = requestSessionSandboxState({ + projectId: PROJECT_A, + dbPath, + sessionId: 'sess-1', + enabled: true, + timeoutMs: 2000, + pollMs: 10, + }) + + const { desired } = readSessionSandboxPreference(PROJECT_A, dbPath) + expect(desired).not.toBeNull() + expect(desired!.enabled).toBe(true) + expect(desired!.sessionId).toBe('sess-1') + + writeApplied({ revision: desired!.revision, enabled: true, error: null }) + + const applied = await promise + expect(applied.revision).toBe(desired!.revision) + expect(applied.enabled).toBe(true) + expect(applied.error).toBeNull() + }) + + test('ignores a stale applied revision and only resolves on the matching one', async () => { + // A stale ON acknowledgement for a different revision must not falsely resolve. + writeApplied({ revision: 'stale-rev', enabled: true, error: null }) + + const promise = requestSessionSandboxState({ + projectId: PROJECT_A, + dbPath, + sessionId: 'sess-1', + enabled: true, + timeoutMs: 2000, + pollMs: 10, + }) + + const { desired } = readSessionSandboxPreference(PROJECT_A, dbPath) + writeApplied({ revision: desired!.revision, enabled: true, error: null }) + + const applied = await promise + expect(applied.revision).toBe(desired!.revision) + }) + + test('throws the server error when a matching applied row carries an error', async () => { + const promise = requestSessionSandboxState({ + projectId: PROJECT_A, + dbPath, + sessionId: 'sess-1', + enabled: true, + timeoutMs: 2000, + pollMs: 10, + }) + + const { desired } = readSessionSandboxPreference(PROJECT_A, dbPath) + writeApplied({ revision: desired!.revision, enabled: false, error: 'sbx failed to start' }) + + await expect(promise).rejects.toThrow('sbx failed to start') + }) + + test('throws on timeout when no matching applied row arrives', async () => { + await expect( + requestSessionSandboxState({ + projectId: PROJECT_A, + dbPath, + sessionId: 'sess-1', + enabled: true, + timeoutMs: 60, + pollMs: 10, + }), + ).rejects.toThrow(/Timed out/) + }) + + test('resolves an acknowledgement that arrives during the final poll sleep', async () => { + // pollMs >= timeoutMs: a single bounded sleep spans the whole window, and the + // acknowledgement lands mid-sleep. The waiter must still read it before declaring timeout. + const promise = requestSessionSandboxState({ + projectId: PROJECT_A, + dbPath, + sessionId: 'sess-1', + enabled: true, + timeoutMs: 100, + pollMs: 10_000, + }) + const { desired } = readSessionSandboxPreference(PROJECT_A, dbPath) + setTimeout(() => { + writeApplied({ revision: desired!.revision, enabled: true, error: null }) + }, 50) + const applied = await promise + expect(applied.revision).toBe(desired!.revision) + expect(applied.enabled).toBe(true) + }) + + test('rejects a matching applied row carrying an empty-string error', async () => { + // `error: ''` is a valid non-null error; it must reject rather than report success. + const promise = requestSessionSandboxState({ + projectId: PROJECT_A, + dbPath, + sessionId: 'sess-1', + enabled: true, + timeoutMs: 2000, + pollMs: 10, + }) + const { desired } = readSessionSandboxPreference(PROJECT_A, dbPath) + writeApplied({ revision: desired!.revision, enabled: false, error: '' }) + await expect(promise).rejects.toThrow() + }) + + test('rejects immediately when the signal is already aborted before any read', async () => { + const controller = new AbortController() + controller.abort() + await expect( + requestSessionSandboxState({ + projectId: PROJECT_A, + dbPath, + sessionId: 'sess-1', + enabled: true, + timeoutMs: 2000, + pollMs: 10, + signal: controller.signal, + }), + ).rejects.toThrow(/cancelled/i) + // A pre-cancelled request must not persist a desired revision the server could still apply. + expect(readSessionSandboxPreference(PROJECT_A, dbPath).desired).toBeNull() + }) + + test('caps each poll sleep to the remaining deadline when pollMs exceeds timeoutMs', async () => { + const start = Date.now() + await expect( + requestSessionSandboxState({ + projectId: PROJECT_A, + dbPath, + sessionId: 'sess-1', + enabled: true, + timeoutMs: 100, + pollMs: 10_000, + }), + ).rejects.toThrow(/Timed out/) + expect(Date.now() - start).toBeLessThan(1000) + }) + + test('throws when the poll is cancelled via signal', async () => { + const controller = new AbortController() + const promise = requestSessionSandboxState({ + projectId: PROJECT_A, + dbPath, + sessionId: 'sess-1', + enabled: true, + timeoutMs: 2000, + pollMs: 10, + signal: controller.signal, + }) + controller.abort() + await expect(promise).rejects.toThrow(/cancelled/i) + }) + }) + + describe('beginSessionSandboxStateRequest + awaitSessionSandboxState', () => { + test('persists the desired revision synchronously and clears a prior ON before the applied row arrives', async () => { + // Acknowledged ON at revision r1. + const on = writeApplied({ revision: 'r1', enabled: true, sessionId: 'sess-1', error: null }) + repo.setDesired(PROJECT_A, { version: 1 as const, revision: 'r1', enabled: true, sessionId: 'sess-1', requestedAt: 1 }) + expect(deriveSessionSandboxAcknowledged(readSessionSandboxPreference(PROJECT_A, dbPath))).toEqual(on) + + // Toggle OFF writes revision r2 synchronously. The authoritative pair now + // has a mismatched revision, so the sidebar must not keep reporting ON even + // though the r2 applied row has not arrived yet. + const revision = beginSessionSandboxStateRequest(PROJECT_A, dbPath, { sessionId: 'sess-1', enabled: false }) + expect(deriveSessionSandboxAcknowledged(readSessionSandboxPreference(PROJECT_A, dbPath))).toBeNull() + + // The pending request then resolves once the matching OFF is applied. + const promise = awaitSessionSandboxState(PROJECT_A, dbPath, revision, { timeoutMs: 2000, pollMs: 10 }) + writeApplied({ revision, enabled: false, error: null }) + const applied = await promise + expect(applied.revision).toBe(revision) + expect(applied.enabled).toBe(false) + }) + }) +}) diff --git a/test/unified-sandbox-resolver.test.ts b/test/unified-sandbox-resolver.test.ts new file mode 100644 index 0000000000..533783cbef --- /dev/null +++ b/test/unified-sandbox-resolver.test.ts @@ -0,0 +1,260 @@ +import { describe, test, expect } from 'vitest' +import { createUnifiedSandboxResolver, type UnifiedSandboxResolverDeps } from '../src/services/unified-sandbox-resolver' +import type { SandboxContext } from '../src/sandbox/context' + +function makeHostContainer(name: string): SandboxContext { + return { runtime: {} as SandboxContext['runtime'], containerName: name, hostDir: '/work', mounts: [] } +} + +function makeDeps(overrides: Partial = {}): UnifiedSandboxResolverDeps { + return { + resolveActiveLoopForSession: async () => null, + resolveLoopSandbox: async (resolved) => (resolved.sandbox ? makeHostContainer(`loop-${resolved.loopName}`) : null), + resolveHostSandbox: async () => makeHostContainer('host-container'), + ...overrides, + } +} + +describe('createUnifiedSandboxResolver', () => { + test('an active sandbox loop takes precedence and the host sandbox is never consulted', async () => { + let hostCalled = 0 + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => ({ loopName: 'loop-x', active: true, sandbox: true }), + resolveHostSandbox: async () => { + hostCalled++ + return makeHostContainer('host-container') + }, + }), + ) + const ctx = await resolver('ses-1') + expect(ctx?.containerName).toBe('loop-loop-x') + expect(hostCalled).toBe(0) + }) + + test('an active non-sandbox loop forces host (returns null) and never consults the host sandbox', async () => { + let hostCalled = 0 + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => ({ loopName: 'loop-wt', active: true, worktree: true }), + resolveHostSandbox: async () => { + hostCalled++ + return makeHostContainer('host-container') + }, + }), + ) + const ctx = await resolver('ses-1') + expect(ctx).toBeNull() + expect(hostCalled).toBe(0) + }) + + test('a sandbox loop that starts during deferred host resolution wins over the host sandbox', async () => { + let hostCalled = 0 + let loopStarted = false + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => { + // No loop on the first (pre-host) check; a sandbox loop exists once the host restore runs. + if (hostCalled === 0) return null + return loopStarted ? { loopName: 'loop-new', active: true, sandbox: true } : null + }, + resolveHostSandbox: async () => { + hostCalled++ + // The asynchronous host restore completes after a sandbox loop has started. + loopStarted = true + return makeHostContainer('host-container') + }, + }), + ) + const ctx = await resolver('ses-1') + // Loop-first precedence wins: the newly active loop's sandbox is returned, not the host sandbox. + expect(ctx?.containerName).toBe('loop-loop-new') + }) + + test('a non-sandbox loop that starts during deferred host resolution forces host (returns null)', async () => { + let hostCalled = 0 + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => (hostCalled === 0 ? null : { loopName: 'loop-wt', active: true, worktree: true }), + resolveHostSandbox: async () => { + hostCalled++ + return makeHostContainer('host-container') + }, + }), + ) + const ctx = await resolver('ses-1') + expect(ctx).toBeNull() + }) + + test('a sandbox loop that starts while the host sandbox rejects wins over the stale host error', async () => { + let hostCalled = 0 + let loopStarted = false + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => { + if (hostCalled === 0) return null + return loopStarted ? { loopName: 'loop-new', active: true, sandbox: true } : null + }, + resolveHostSandbox: async () => { + hostCalled++ + loopStarted = true + throw new Error('host restore failed') + }, + }), + ) + // Loop-first precedence wins even when the host path rejected: the stale host error must not + // override the newly active sandbox loop. + const ctx = await resolver('ses-1', { throwOnRestoreError: true }) + expect(ctx?.containerName).toBe('loop-loop-new') + }) + + test('a non-sandbox loop that starts while the host sandbox rejects forces host (returns null)', async () => { + let hostCalled = 0 + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => (hostCalled === 0 ? null : { loopName: 'loop-wt', active: true, worktree: true }), + resolveHostSandbox: async () => { + hostCalled++ + throw new Error('host restore failed') + }, + }), + ) + const ctx = await resolver('ses-1', { throwOnRestoreError: true }) + expect(ctx).toBeNull() + }) + + test('a host resolution rejection with no concurrent loop propagates the host error', async () => { + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveHostSandbox: async () => { + throw new Error('host restore failed') + }, + }), + ) + await expect(resolver('ses-1', { throwOnRestoreError: true })).rejects.toThrow(/host restore failed/) + }) + + test('no loop anywhere returns the host sandbox context', async () => { + const resolver = createUnifiedSandboxResolver(makeDeps()) + const ctx = await resolver('ses-1') + expect(ctx?.containerName).toBe('host-container') + }) + + test('an unavailable loop sandbox fails closed when throwOnRestoreError is set', async () => { + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => ({ loopName: 'loop-x', active: true, sandbox: true }), + resolveLoopSandbox: async () => null, + }), + ) + await expect(resolver('ses-1', { throwOnRestoreError: true })).rejects.toThrow(/loop "loop-x" is unavailable/) + await expect(resolver('ses-1')).resolves.toBeNull() + }) + + test('revalidates after restoration and follows a loop replaced by another sandbox loop', async () => { + let call = 0 + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => { + call++ + if (call === 1) return { loopName: 'loop-a', active: true, sandbox: true } + return { loopName: 'loop-b', active: true, sandbox: true } + }, + }), + ) + // The loop captured before restoration was A; after restoration the session belongs to B, so the + // resolver must follow B rather than returning A's stale container. + const ctx = await resolver('ses-1') + expect(ctx?.containerName).toBe('loop-loop-b') + }) + + test('revalidates after restoration and returns null when the loop becomes non-sandbox', async () => { + let call = 0 + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => { + call++ + if (call === 1) return { loopName: 'loop-a', active: true, sandbox: true } + return { loopName: 'loop-wt', active: true, worktree: true } + }, + }), + ) + // The session was in a sandbox loop when resolution started but that loop became a non-sandbox + // loop during restoration: the stale sandbox container must not be returned. + const ctx = await resolver('ses-1') + expect(ctx).toBeNull() + }) + + test('revalidates after restoration and falls back to the host when the loop terminates', async () => { + let call = 0 + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => { + call++ + if (call === 1) return { loopName: 'loop-a', active: true, sandbox: true } + return null + }, + }), + ) + // The sandbox loop terminated during restoration; with no active loop remaining the host-session + // path applies rather than returning (or recreating) the terminated loop's container. + const ctx = await resolver('ses-1') + expect(ctx?.containerName).toBe('host-container') + }) + + test('revalidates after restoration when the restore rejects and the loop changed', async () => { + let call = 0 + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => { + call++ + if (call === 1) return { loopName: 'loop-a', active: true, sandbox: true } + return { loopName: 'loop-b', active: true, sandbox: true } + }, + resolveLoopSandbox: async (resolved) => { + if (resolved.loopName === 'loop-a') throw new Error('loop-a restore failed') + return makeHostContainer('loop-loop-b') + }, + }), + ) + // loop-a's restoration rejects, but by the time it rejects the session belongs to loop-b: the + // stale loop-a error must not determine routing; the resolver follows the current loop-b. + const ctx = await resolver('ses-1', { throwOnRestoreError: true }) + expect(ctx?.containerName).toBe('loop-loop-b') + }) + + test('revalidates after restoration when the restore returns null and the loop changed', async () => { + let call = 0 + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => { + call++ + if (call === 1) return { loopName: 'loop-a', active: true, sandbox: true } + return { loopName: 'loop-b', active: true, sandbox: true } + }, + resolveLoopSandbox: async (resolved) => + resolved.loopName === 'loop-a' ? null : makeHostContainer('loop-loop-b'), + }), + ) + // loop-a's restoration returned no sandbox, but the session now belongs to loop-b: routing must + // follow the current loop-b rather than returning a stale null for the replaced loop. + const ctx = await resolver('ses-1') + expect(ctx?.containerName).toBe('loop-loop-b') + }) + + test('fails closed when a loop keeps changing past the revalidation retry cap', async () => { + let call = 0 + const resolver = createUnifiedSandboxResolver( + makeDeps({ + resolveActiveLoopForSession: async () => { + call++ + return { loopName: `loop-${call}`, active: true, sandbox: true } + }, + resolveLoopSandbox: async (resolved) => makeHostContainer(`loop-${resolved.loopName}`), + }), + ) + // Every revalidation reports a NEW loop name, so the resolver never settles on a stable loop and + // exhausts its retry budget. It must fail closed (throw) rather than return a stale loop context. + await expect(resolver('ses-1')).rejects.toThrow(/unavailable/) + }) +}) From dd5c986a41dc334f7692b0b03ed30c04406824ba Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:37:49 -0400 Subject: [PATCH 2/4] fix(sandbox): bound startup ownership lookup so a hung session lookup cannot stall the plugin A persisted desired ON names a session from the previous run, so after a restart the ownership lookup targets a session the freshly-booting server may not answer for. The startup reconcile is awaited before the plugin returns its hooks, so an unbounded lookup blocked initialization forever and the TUI never rendered. Bound a single lookup at 5s; on timeout ownership resolves to 'uncertain', which already fails closed (nothing starts and the session is blocked rather than run host-side). The TUI sandbox indicator also drops the equals sign and reads SBX enabled/disabled, using the secondary colour while an acknowledged sandbox is active. --- src/sandbox/session-controller.ts | 35 ++++++++++++++++++++- src/tui.tsx | 4 ++- test/sandbox/session-controller.test.ts | 41 +++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/sandbox/session-controller.ts b/src/sandbox/session-controller.ts index 7a38d50b76..01d99cece4 100644 --- a/src/sandbox/session-controller.ts +++ b/src/sandbox/session-controller.ts @@ -8,6 +8,17 @@ import type { ActiveSandbox } from './manager' export const DEFAULT_POLL_INTERVAL_MS = 500 +/** + * Cap on a single session-directory lookup during ownership resolution. + * + * The lookup is an HTTP call to the OpenCode server, and the startup reconcile that performs it is + * awaited before the plugin returns its hooks. A persisted desired ON names a session from the + * previous run, so after a restart the lookup targets a session the freshly-booting server may not + * answer for; without a bound, plugin initialization blocks forever and the TUI never renders. + * Exceeding this resolves to `uncertain`, which already fails closed. + */ +export const OWNERSHIP_LOOKUP_TIMEOUT_MS = 5_000 + /** Error recorded on the applied row when a host sandbox is refused for an active loop session. */ export const LOOP_SESSION_REFUSED_ERROR = 'host sandbox cannot be enabled for an active loop session' @@ -113,6 +124,25 @@ function freshRevision(): string { return randomUUID() } +/** + * Rejects when `run` has not settled within {@link OWNERSHIP_LOOKUP_TIMEOUT_MS}. The underlying + * request is not cancellable, so this only stops waiting on it; the timer is always cleared so a + * pending timeout cannot keep the process alive. + */ +async function withOwnershipLookupTimeout(run: () => Promise): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + run(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('session directory lookup timed out')), OWNERSHIP_LOOKUP_TIMEOUT_MS) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + /** * Owns persisted reconciliation, in-memory acknowledged binding, host-container lifecycle, * and descendant matching for one project directory's session sandbox. @@ -227,9 +257,12 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep */ async function resolveOwnership(sessionId: string | null): Promise<'local' | 'foreign' | 'uncertain'> { if (sessionId == null || !deps.getSessionDirectory) return 'local' + const lookup = deps.getSessionDirectory let dir: string | null try { - dir = await deps.getSessionDirectory(sessionId) + // Bounded: this runs inside the startup reconcile that plugin initialization awaits, so a + // lookup that never settles would hang the whole plugin rather than just this decision. + dir = await withOwnershipLookupTimeout(() => lookup(sessionId)) } catch { return 'uncertain' } diff --git a/src/tui.tsx b/src/tui.tsx index 2315ef1ee4..fc4382af5c 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -58,7 +58,9 @@ function SandboxStatusText(props: { api: TuiPluginApi; applied: () => SessionSan const applied = props.applied() return !!applied && applied.enabled === true && applied.error == null && applied.sessionId === props.sessionId }) - return · SBX = {on() ? 'on' : 'off'} + // Secondary while the sandbox is actually acknowledged ON, so an active sandbox stands out + // against the muted status line instead of reading as ordinary chrome. + return · SBX {on() ? 'enabled' : 'disabled'} } function ForgeSidebarStatus(props: { diff --git a/test/sandbox/session-controller.test.ts b/test/sandbox/session-controller.test.ts index 64f1abcf77..cf983e503c 100644 --- a/test/sandbox/session-controller.test.ts +++ b/test/sandbox/session-controller.test.ts @@ -8,6 +8,7 @@ import { createUnavailableSandboxLifecycleManager, deriveManagerKey, DEFAULT_POLL_INTERVAL_MS, + OWNERSHIP_LOOKUP_TIMEOUT_MS, type SessionSandboxLifecycleManager, } from '../../src/sandbox/session-controller' import { createSessionSandboxPreferencesRepo } from '../../src/storage' @@ -1812,6 +1813,46 @@ describe('SessionSandboxController', () => { await controller.dispose() }) + test('a session lookup that never settles cannot hang startup', async () => { + vi.useFakeTimers() + try { + // Plugin initialization awaits start(), and a persisted desired ON names a session from the + // previous run. If the lookup for that session never answers, an unbounded await would block + // the plugin forever and the TUI would never render. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-hang', sessionId: 'session-gone' })) + const controller = createController({ + // Polling is pushed out of the way so this exercises the startup reconcile alone; steady + // state ticks would each open another bounded lookup. + pollIntervalMs: OWNERSHIP_LOOKUP_TIMEOUT_MS * 1000, + getSessionDirectory: () => new Promise(() => {}), + }) + + let settled = false + const started = controller.start().then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(OWNERSHIP_LOOKUP_TIMEOUT_MS - 1) + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(2) + await started + expect(settled).toBe(true) + + // The timed-out lookup is uncertain ownership, which fails closed: nothing was started, and + // the selected session is recorded as a failed selection rather than left to run host-side. + expect(manager.ensureRunningCalls).toEqual([]) + expect(repo.getApplied(PROJECT)).toBeNull() + + // Not disposed: disposal serializes behind the reconcile chain, and the never-settling lookup + // would need further timer advancement to drain. Nothing was started, and restoring real + // timers discards the poll interval, so there is no resource to release. + expect(manager.stopCalls).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + test('dispose leaves a pre-existing container to its owner when ownership is uncertain for a persisted ON', async () => { // An unclean restart left the container running and the applied row ON at the desired revision, // but the directory-scoped ownership lookup cannot resolve the session (uncertain, not foreign), From bae347e91d162356f5d88217fa4b5258e5c999ac Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:25:33 -0400 Subject: [PATCH 3/4] feat(sandbox): run session sandbox controller startup in background --- src/index.ts | 26 ++--- src/sandbox/session-controller.ts | 50 +++++++--- src/storage/index.ts | 4 +- .../repos/session-sandbox-preferences-repo.ts | 49 +++++++++- src/tui.tsx | 65 +++++++----- src/tui/session-sandbox-store.ts | 25 ++++- test/plugin.test.ts | 98 +++++++++++++------ test/sandbox/session-controller.test.ts | 46 ++++++++- test/session-sandbox-preferences-repo.test.ts | 19 ++++ test/tui/session-sandbox-store.test.ts | 53 +++++++++- 10 files changed, 334 insertions(+), 101 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6f64cdb15f..e670fb384a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -479,9 +479,6 @@ export function createForgePlugin(config: PluginConfig): Plugin { let cleanupPromise: Promise | null = null - // Host-session sandbox controller: reconciles the acknowledged host sandbox preference for - // sessions outside any loop. Assigned once a sandbox manager exists; disposed in cleanup. - let sessionSandboxController: SessionSandboxController | null = null let sessionSandboxProjectId: string | null = null const cleanup = (): Promise => { @@ -598,8 +595,8 @@ export function createForgePlugin(config: PluginConfig): Plugin { // sessions outside any loop. Always constructed — even when sandbox routing is unavailable // (sandbox disabled, manager init failure, or no shell shim) — so a requested ON is // acknowledged as OFF-with-error and the selected session is blocked fail-closed instead of - // silently executing on the host. Its initial reconcile runs before hooks are returned so - // acknowledged state is live at startup. + // silently executing on the host. Its initial reconcile starts in the background; host sandbox + // resolution waits for it before deciding whether a session may run on the host. // Shared per project across every plugin instance in this process: a second reconciler would // race this one on the same container. Only the first instance constructs and starts one, and // it gets its own database handle so it never depends on that instance's lifetime. @@ -619,19 +616,8 @@ export function createForgePlugin(config: PluginConfig): Plugin { }), } }) - sessionSandboxController = sharedSessionSandbox.controller sessionSandboxProjectId = projectId - try { - await sharedSessionSandbox.started - } catch (err) { - // Startup must be exception-safe: a rejected controller start (e.g. the initial reconcile - // fails on a persistence or session-lookup error) must not leave SQLite or the process - // listeners open. Run the idempotent cleanup (which stops the controller and closes the DB) - // before rethrowing so the plugin fails closed without leaking resources. - logger.error('Session sandbox controller failed to start; cleaning up', err) - await cleanup() - throw err - } + void sharedSessionSandbox.started.catch((err) => logger.error('Session sandbox controller failed to start', err)) // Unified, loop-first sandbox resolver. Loop resolution always takes precedence: an active // sandbox loop owns its sessions; an active non-sandbox loop forces host (a host preference @@ -641,8 +627,10 @@ export function createForgePlugin(config: PluginConfig): Plugin { const resolveSandboxForSession = createUnifiedSandboxResolver({ resolveActiveLoopForSession: sessionLoopResolver.resolveActiveLoopForSession, resolveLoopSandbox: (resolved, opts) => resolveSandboxContextForLoop(sandboxManager, resolved, logger, opts), - resolveHostSandbox: (sessionID, opts) => - sessionSandboxController ? sessionSandboxController.resolveSandboxForSession(sessionID, opts) : Promise.resolve(null), + resolveHostSandbox: async (sessionID, opts) => { + await sharedSessionSandbox.controller.start() + return sharedSessionSandbox.controller.resolveSandboxForSession(sessionID, opts) + }, }) // Spawns an isolated agent session (splitter/architect) seeded with a single text prompt, diff --git a/src/sandbox/session-controller.ts b/src/sandbox/session-controller.ts index 01d99cece4..2f79296e9f 100644 --- a/src/sandbox/session-controller.ts +++ b/src/sandbox/session-controller.ts @@ -11,11 +11,10 @@ export const DEFAULT_POLL_INTERVAL_MS = 500 /** * Cap on a single session-directory lookup during ownership resolution. * - * The lookup is an HTTP call to the OpenCode server, and the startup reconcile that performs it is - * awaited before the plugin returns its hooks. A persisted desired ON names a session from the - * previous run, so after a restart the lookup targets a session the freshly-booting server may not - * answer for; without a bound, plugin initialization blocks forever and the TUI never renders. - * Exceeding this resolves to `uncertain`, which already fails closed. + * The lookup is an HTTP call to the OpenCode server. A persisted desired ON names a session from + * the previous run, so after a restart the lookup targets a session the freshly-booting server may + * not answer for; without a bound, host sandbox resolution blocks forever. Exceeding this resolves + * to `uncertain`, which already fails closed. */ export const OWNERSHIP_LOOKUP_TIMEOUT_MS = 5_000 @@ -214,6 +213,16 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep return run } + function writeControllerState(phase: 'loading' | 'ready' | 'failed'): void { + const desired = preferences.getDesired(projectId) + preferences.setControllerState(projectId, { + version: 1, + phase, + revision: desired?.revision ?? null, + sessionId: desired?.sessionId ?? null, + }) + } + function bind(sessionId: string | null, revision: string | null = null): void { acknowledgedSessionId = sessionId hostActive = sessionId !== null @@ -260,8 +269,8 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep const lookup = deps.getSessionDirectory let dir: string | null try { - // Bounded: this runs inside the startup reconcile that plugin initialization awaits, so a - // lookup that never settles would hang the whole plugin rather than just this decision. + // Bounded so a lookup that never settles cannot indefinitely block controller readiness and + // every host sandbox resolution waiting on it. dir = await withOwnershipLookupTimeout(() => lookup(sessionId)) } catch { return 'uncertain' @@ -916,21 +925,31 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep // Single-flight: concurrent or repeated calls share one start, so exactly one interval is // ever installed and every caller waits for the initial reconciliation to complete. if (startPromise) return startPromise - startPromise = (async () => { - // Startup reconciliation must not swallow errors: if persisted desired state cannot be - // reconciled (e.g. a transient DB or session-lookup failure), the caller (plugin startup) - // fails closed rather than returning with an ON indicator but no restored runtime binding, - // which would leave selected tools executing host-side. Steady-state ticks below swallow - // errors and retry on the next interval. - if (!disposed) await serialized(() => reconcile()) + const starting = (async () => { + try { + writeControllerState('loading') + if (!disposed) await serialized(() => reconcile()) + } catch (err) { + try { + writeControllerState('failed') + } catch (stateErr) { + logger.log(`[session-sandbox] failed to persist controller failure state: ${stateErr instanceof Error ? stateErr.message : String(stateErr)}`) + } + throw err + } if (disposed) return + writeControllerState('ready') if (intervalId === null) { intervalId = setInterval(() => { void tick() }, pollIntervalMs) } })() - return startPromise + startPromise = starting + void starting.catch(() => { + if (startPromise === starting) startPromise = null + }) + return starting }, resolveSandboxForSession, @@ -1025,4 +1044,3 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep }, } } - diff --git a/src/storage/index.ts b/src/storage/index.ts index d2644ac128..48b4cebd1e 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -24,5 +24,5 @@ export type { PlanRow } from './repos/plans-repo' export { createFeatureGroupsRepo } from './repos/feature-groups-repo' export type { FeatureGroupRow, GroupFeatureRow } from './repos/feature-groups-repo' -export { createSessionSandboxPreferencesRepo, SESSION_SANDBOX_DESIRED_KEY, SESSION_SANDBOX_APPLIED_KEY } from './repos/session-sandbox-preferences-repo' -export type { SessionSandboxDesiredState, SessionSandboxAppliedState, SessionSandboxPreferencesRepo } from './repos/session-sandbox-preferences-repo' +export { createSessionSandboxPreferencesRepo, SESSION_SANDBOX_DESIRED_KEY, SESSION_SANDBOX_APPLIED_KEY, SESSION_SANDBOX_CONTROLLER_KEY } from './repos/session-sandbox-preferences-repo' +export type { SessionSandboxDesiredState, SessionSandboxAppliedState, SessionSandboxControllerState, SessionSandboxPreferencesRepo } from './repos/session-sandbox-preferences-repo' diff --git a/src/storage/repos/session-sandbox-preferences-repo.ts b/src/storage/repos/session-sandbox-preferences-repo.ts index 6c7fd8f2a4..dbb8e3f60b 100644 --- a/src/storage/repos/session-sandbox-preferences-repo.ts +++ b/src/storage/repos/session-sandbox-preferences-repo.ts @@ -2,6 +2,7 @@ import type { Database } from 'bun:sqlite' export const SESSION_SANDBOX_DESIRED_KEY = 'session-sandbox.desired' export const SESSION_SANDBOX_APPLIED_KEY = 'session-sandbox.applied' +export const SESSION_SANDBOX_CONTROLLER_KEY = 'session-sandbox.controller' export interface SessionSandboxDesiredState { version: 1 @@ -20,17 +21,27 @@ export interface SessionSandboxAppliedState { appliedAt: number } +export interface SessionSandboxControllerState { + version: 1 + phase: 'loading' | 'ready' | 'failed' + revision: string | null + sessionId: string | null +} + export interface SessionSandboxPreferencesRepo { getDesired(projectId: string): SessionSandboxDesiredState | null setDesired(projectId: string, state: SessionSandboxDesiredState): void getApplied(projectId: string): SessionSandboxAppliedState | null setApplied(projectId: string, state: SessionSandboxAppliedState): void + getControllerState(projectId: string): SessionSandboxControllerState | null + setControllerState(projectId: string, state: SessionSandboxControllerState): void getPair(projectId: string): SessionSandboxPreferencePair } export interface SessionSandboxPreferencePair { desired: SessionSandboxDesiredState | null applied: SessionSandboxAppliedState | null + controller: SessionSandboxControllerState | null } function parseDesired(data: unknown): SessionSandboxDesiredState | null { @@ -69,6 +80,21 @@ function parseApplied(data: unknown): SessionSandboxAppliedState | null { } } +function parseControllerState(data: unknown): SessionSandboxControllerState | null { + if (typeof data !== 'object' || data === null) return null + const o = data as Record + if (o.version !== 1) return null + if (o.phase !== 'loading' && o.phase !== 'ready' && o.phase !== 'failed') return null + if (o.revision !== null && (typeof o.revision !== 'string' || o.revision.trim() === '')) return null + if (o.sessionId !== null && (typeof o.sessionId !== 'string' || o.sessionId.trim() === '')) return null + return { + version: 1, + phase: o.phase, + revision: o.revision as string | null, + sessionId: o.sessionId as string | null, + } +} + interface PreferenceRow { data: string } @@ -119,6 +145,18 @@ export function createSessionSandboxPreferencesRepo(db: Database): SessionSandbo return parseApplied(parsed) } + function readControllerState(projectId: string): SessionSandboxControllerState | null { + const row = getAppliedStmt.get(projectId, SESSION_SANDBOX_CONTROLLER_KEY) as PreferenceRow | null + if (!row) return null + let parsed: unknown + try { + parsed = JSON.parse(row.data) + } catch { + return null + } + return parseControllerState(parsed) + } + return { getDesired: readDesired, @@ -134,13 +172,20 @@ export function createSessionSandboxPreferencesRepo(db: Database): SessionSandbo upsertStmt.run(projectId, SESSION_SANDBOX_APPLIED_KEY, JSON.stringify(state), ts) }, + getControllerState: readControllerState, + + setControllerState(projectId: string, state: SessionSandboxControllerState): void { + const ts = now() + upsertStmt.run(projectId, SESSION_SANDBOX_CONTROLLER_KEY, JSON.stringify(state), ts) + }, + getPair(projectId: string): SessionSandboxPreferencePair { - // Both reads run inside one transaction so they observe a single SQLite + // Reads run inside one transaction so they observe a single SQLite // snapshot. Without this, a concurrent desired write between the two // autocommit reads could assemble revisions from different snapshots and // briefly trust a superseded ON state. return db.transaction(() => { - return { desired: readDesired(projectId), applied: readApplied(projectId) } + return { desired: readDesired(projectId), applied: readApplied(projectId), controller: readControllerState(projectId) } })() }, } diff --git a/src/tui.tsx b/src/tui.tsx index fc4382af5c..d547bd8f4b 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui' -import { createEffect, createMemo, createSignal, Show, untrack } from 'solid-js' +import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from 'solid-js' import { VERSION } from './version' import { loadPluginConfig, resolveBundledContainerDir } from './setup' import { resolveForgeDbPath, resolveDataDir } from './storage' @@ -20,13 +20,12 @@ import { ExecutePlanPanel, type ExecutePlanPanelProps } from './tui/execute-plan import { awaitSessionSandboxState, beginSessionSandboxStateRequest, - deriveSessionSandboxAcknowledged, + deriveSessionSandboxDisplayStatus, hostSandboxToggleBlocked, isSessionSandboxPreferenceSettled, readSessionSandboxPreference, } from './tui/session-sandbox-store' import type { SessionSandboxPreference } from './tui/session-sandbox-store' -import type { SessionSandboxAppliedState } from './storage' import { attachLoopSessionFollower, getCurrentRouteSessionId } from './tui/session-follow' import { openInBrowser, startDashboardServer, type DashboardServerHandle } from './dashboard/launch' import { describeDashboardBinding } from './dashboard/config' @@ -52,22 +51,44 @@ type TuiOptions = { type ForgeConnectionStatus = 'connecting' | 'connected' | 'unavailable' -function SandboxStatusText(props: { api: TuiPluginApi; applied: () => SessionSandboxAppliedState | null; sessionId?: string }) { - const theme = () => props.api.theme.current - const on = createMemo(() => { - const applied = props.applied() - return !!applied && applied.enabled === true && applied.error == null && applied.sessionId === props.sessionId +const SBX_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +function SandboxLoadingSpinner(props: { api: TuiPluginApi }) { + const [frame, setFrame] = createSignal(0) + const animationsEnabled = () => props.api.kv.get('animations_enabled', true) + + createEffect(() => { + if (!animationsEnabled()) return + const timer = setInterval(() => setFrame((current) => (current + 1) % SBX_SPINNER_FRAMES.length), 80) + onCleanup(() => clearInterval(timer)) }) + + return {animationsEnabled() ? SBX_SPINNER_FRAMES[frame()] : '⋯'} +} + +function SandboxStatusText(props: { api: TuiPluginApi; preference: () => SessionSandboxPreference | null; sessionId?: string }) { + const theme = () => props.api.theme.current + const status = createMemo(() => deriveSessionSandboxDisplayStatus(props.preference(), props.sessionId)) // Secondary while the sandbox is actually acknowledged ON, so an active sandbox stands out // against the muted status line instead of reading as ordinary chrome. - return · SBX {on() ? 'enabled' : 'disabled'} + return ( + · SBX {status()}} + > + + · SBX + + + + ) } function ForgeSidebarStatus(props: { api: TuiPluginApi opts: TuiOptions status: () => ForgeConnectionStatus - applied: () => SessionSandboxAppliedState | null + preference: () => SessionSandboxPreference | null sessionId?: string }) { const theme = () => props.api.theme.current @@ -81,7 +102,7 @@ function ForgeSidebarStatus(props: { {title()} - + · {statusText()} @@ -96,7 +117,7 @@ function SidebarContainer(props: { pluginConfig: PluginConfig opts: TuiOptions status: () => ForgeConnectionStatus - applied: () => SessionSandboxAppliedState | null + preference: () => SessionSandboxPreference | null sessionId?: string }) { const currentClient = createMemo(() => props.client()) @@ -104,9 +125,9 @@ function SidebarContainer(props: { return ( } + fallback={} > - {(client) => } + {(client) => } ) } @@ -117,7 +138,7 @@ function Sidebar(props: { cache: () => ExecutionContextCache | null pluginConfig: PluginConfig opts: TuiOptions - applied: () => SessionSandboxAppliedState | null + preference: () => SessionSandboxPreference | null sessionId?: string }) { const theme = () => props.api.theme.current @@ -133,7 +154,7 @@ function Sidebar(props: { {title()} - + @@ -338,12 +359,8 @@ const tui: TuiPlugin = async (api) => { } }) - // Host-sandbox acknowledgement state for the current project. Initialized - // once `api.state.ready` so the toggle command works with the sidebar - // disabled. ON is trusted only when the desired/applied revisions match, - // both target the same session, and applied carries no error. const [sandboxProjectId, setSandboxProjectId] = createSignal(null) - const [sandboxApplied, setSandboxApplied] = createSignal(null) + const [sandboxPreference, setSandboxPreference] = createSignal(null) let sandboxInitStarted = false const refreshSandboxAcknowledgement = (projectId: string): SessionSandboxPreference | null => { @@ -351,11 +368,11 @@ const tui: TuiPlugin = async (api) => { // When sandboxing is disabled by configuration the server never constructs a // reconciler or uses that sandbox, so any persisted ON must not be displayed. if (!isSandboxConfigEnabled(pluginConfig)) { - if (!disposed) setSandboxApplied(null) + if (!disposed) setSandboxPreference(null) return null } const pref = readSessionSandboxPreference(projectId, forgeDbPath) - if (!disposed) setSandboxApplied(deriveSessionSandboxAcknowledged(pref)) + if (!disposed) setSandboxPreference(pref) return pref } @@ -785,7 +802,7 @@ const tui: TuiPlugin = async (api) => { pluginConfig={pluginConfig} opts={opts} status={connectionStatus} - applied={sandboxApplied} + preference={sandboxPreference} sessionId={slotProps.session_id} /> }, diff --git a/src/tui/session-sandbox-store.ts b/src/tui/session-sandbox-store.ts index d32e985ed8..64a0f825c2 100644 --- a/src/tui/session-sandbox-store.ts +++ b/src/tui/session-sandbox-store.ts @@ -3,7 +3,7 @@ import { existsSync } from 'fs' import { randomUUID } from 'node:crypto' import { resolveForgeDbPath } from '../storage' import { createSessionSandboxPreferencesRepo } from '../storage/repos/session-sandbox-preferences-repo' -import type { SessionSandboxAppliedState, SessionSandboxDesiredState } from '../storage/repos/session-sandbox-preferences-repo' +import type { SessionSandboxAppliedState, SessionSandboxControllerState, SessionSandboxDesiredState } from '../storage/repos/session-sandbox-preferences-repo' /** * Opens the local forge database for a bounded TUI operation. Returns null when @@ -31,6 +31,7 @@ function openForgeDb(dbPathOverride?: string): Database | null { export interface SessionSandboxPreference { desired: SessionSandboxDesiredState | null applied: SessionSandboxAppliedState | null + controller?: SessionSandboxControllerState | null /** * True when the read could not reach an initialized `tui_preferences` table for the project * (missing database file, uninitialized table, or unreadable/corrupt file). This lets callers @@ -96,6 +97,28 @@ export function isSessionSandboxPreferenceSettled(pref: SessionSandboxPreference return applied.revision === desired.revision } +export type SessionSandboxDisplayStatus = 'enabled' | 'disabled' | 'loading' + +export function deriveSessionSandboxDisplayStatus( + pref: SessionSandboxPreference | null, + sessionId?: string, +): SessionSandboxDisplayStatus { + if (!pref || !sessionId) return 'disabled' + const controller = pref.controller + if ( + controller && + controller.revision === pref.desired?.revision && + controller.sessionId === sessionId + ) { + if (controller.phase === 'loading' && pref.desired?.enabled) return 'loading' + if (controller.phase === 'failed') return 'disabled' + } + const acknowledged = deriveSessionSandboxAcknowledged(pref) + if (acknowledged?.sessionId === sessionId) return 'enabled' + if (pref.desired?.sessionId === sessionId && !isSessionSandboxPreferenceSettled(pref)) return 'loading' + return 'disabled' +} + /** * Reads the desired and applied sandbox rows for a project from the local forge * database. Falls back to both null when the database or table is unavailable. diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 7870679ecf..d82564b6a1 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -640,17 +640,12 @@ describe('createForgePlugin', () => { expect(logContents).toContain('loop.permissions.deny entry "*" is ignored') }) - test('host session sandbox controller is started on init and disposed before DB close', async () => { + test('host session sandbox startup does not block init and routing waits fail-closed', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - // Sandbox routing is disabled so the deterministic unavailable manager is used: ensureRunning - // fails closed and stop is a no-op. This keeps the test independent of whether the `sbx` CLI - // is installed on the host, while still verifying startup reconcile is awaited before hooks - // return and cleanup disposes before the DB closes. sandbox: { mode: 'sbx', enabled: false }, } - // Persist a desired ON so the startup reconciliation has something to act on. const setupDb = initializeDatabase(config.dataDir!) createSessionSandboxPreferencesRepo(setupDb).setDesired(TEST_PROJECT_ID, { version: 1, @@ -661,13 +656,31 @@ describe('createForgePlugin', () => { }) closeDatabase(setupDb) + let releaseFirstLookup!: () => void + let lookupCount = 0 + const firstLookup = new Promise((resolve) => { + releaseFirstLookup = () => resolve(new Response(JSON.stringify({ id: 'ses-root', directory: testDir, parentID: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + }) + const mockFetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === 'string' ? input : (input as Request).url + const match = url.match(/\/session\/([^/?]+)/) + if (!match) return new Response(JSON.stringify({}), { status: 200 }) + lookupCount += 1 + if (lookupCount === 1) return firstLookup + const sessionID = decodeURIComponent(match[1]!) + return new Response(JSON.stringify({ id: sessionID, directory: testDir, parentID: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } const plugin = createForgePlugin(config) const mockInput = { directory: testDir, worktree: testDir, - // The instance must positively prove it owns 'ses-root' (its directory resolves to this - // instance's directory) before it may act on the shared preference row. - client: sessionResolvingClient(testDir) as never, + client: { _client: { getConfig: () => ({ fetch: mockFetch }) } } as never, project: { id: TEST_PROJECT_ID } as never, serverUrl: new URL('http://localhost:5551'), $: {} as never, @@ -676,20 +689,38 @@ describe('createForgePlugin', () => { const hooks = await plugin(mockInput as unknown as PluginInput) currentHooks = hooks as { getCleanup?: () => Promise } - // Startup reconciliation ran and was awaited before hooks returned: the applied row now - // records the desired revision (the container start itself fails closed here since sbx is - // unavailable, but the revision still advances, proving start's reconcile completed). let db = initializeDatabase(config.dataDir!) - let appliedAfterStart = createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID) - expect(appliedAfterStart).not.toBeNull() - expect(appliedAfterStart!.revision).toBe('r-init') + let preferences = createSessionSandboxPreferencesRepo(db) + expect(preferences.getApplied(TEST_PROJECT_ID)).toBeNull() + expect(preferences.getControllerState(TEST_PROJECT_ID)?.phase).toBe('loading') + closeDatabase(db) + + const shellEnv = hooks['shell.env'] as ( + input: { sessionID?: string; cwd?: string }, + output: { env: Record }, + ) => Promise + let routingSettled = false + const routing = shellEnv({ sessionID: 'ses-root', cwd: testDir }, { env: {} }).then( + () => null, + (err: unknown) => err, + ).finally(() => { + routingSettled = true + }) + await Promise.resolve() + expect(routingSettled).toBe(false) + + releaseFirstLookup() + expect(await routing).toBeInstanceOf(Error) + + db = initializeDatabase(config.dataDir!) + preferences = createSessionSandboxPreferencesRepo(db) + const appliedAfterStart = preferences.getApplied(TEST_PROJECT_ID) + expect(appliedAfterStart?.revision).toBe('r-init') + expect(preferences.getControllerState(TEST_PROJECT_ID)?.phase).toBe('ready') closeDatabase(db) await currentHooks.getCleanup!() - // Dispose ran before the DB closed: applied OFF is persisted at the desired revision (so a - // pending TUI request observes its own acknowledgement), clearing the start-time failure error - // to a confirmed-stopped OFF (error: null) which proves dispose actually executed. db = initializeDatabase(config.dataDir!) const appliedAfterCleanup = createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID) expect(appliedAfterCleanup).not.toBeNull() @@ -868,6 +899,12 @@ describe('createForgePlugin', () => { const cleanupA = (hooksA as unknown as { getCleanup: () => Promise }).getCleanup const cleanupB = (hooksB as unknown as { getCleanup: () => Promise }).getCleanup + const shellEnv = hooksB['shell.env'] as ( + input: { sessionID?: string; cwd?: string }, + output: { env: Record }, + ) => Promise + await expect(shellEnv({ sessionID: 'ses-root', cwd: testDir }, { env: {} })).rejects.toThrow(/unavailable/) + // The fail-closed start recorded an error; disposal is what clears it to a confirmed OFF. let db = initializeDatabase(config.dataDir!) expect(createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID)?.error).toBeTruthy() @@ -921,6 +958,12 @@ describe('createForgePlugin', () => { const hooks = await plugin(mockInput as unknown as PluginInput) currentHooks = hooks as { getCleanup?: () => Promise } + const shellEnv = hooks['shell.env'] as ( + input: { sessionID?: string; cwd?: string }, + output: { env: Record }, + ) => Promise + await expect(shellEnv({ sessionID: 'ses-selected', cwd: testDir }, { env: {} })).rejects.toThrow(/unavailable/) + // The unavailable runtime acknowledged the requested ON at the matching revision as OFF with // an error, so the TUI sees a definitive server answer rather than a silent host fallback. let db = initializeDatabase(config.dataDir!) @@ -931,14 +974,6 @@ describe('createForgePlugin', () => { expect(applied!.error).toBeTruthy() closeDatabase(db) - const shellEnv = hooks['shell.env'] as ( - input: { sessionID?: string; cwd?: string }, - output: { env: Record }, - ) => Promise - - // The selected session fails closed (throws) rather than executing on the host. - await expect(shellEnv({ sessionID: 'ses-selected', cwd: testDir }, { env: {} })).rejects.toThrow(/unavailable/) - // An unrelated host session is unaffected and falls through to the host shell. const output = { env: {} as Record } await shellEnv({ sessionID: 'ses-unrelated', cwd: testDir }, output) @@ -980,6 +1015,12 @@ describe('createForgePlugin', () => { const hooks = await plugin(mockInput as unknown as PluginInput) currentHooks = hooks as { getCleanup?: () => Promise } + const shellEnv = hooks['shell.env'] as ( + input: { sessionID?: string; cwd?: string }, + output: { env: Record }, + ) => Promise + await expect(shellEnv({ sessionID: 'ses-selected', cwd: testDir }, { env: {} })).rejects.toThrow(/unavailable/) + // Regardless of how the manager/shims became unavailable, the requested ON is acknowledged as // OFF-with-error at the matching revision (fail closed). let db = initializeDatabase(config.dataDir!) @@ -990,11 +1031,6 @@ describe('createForgePlugin', () => { expect(applied!.error).toBeTruthy() closeDatabase(db) - const shellEnv = hooks['shell.env'] as ( - input: { sessionID?: string; cwd?: string }, - output: { env: Record }, - ) => Promise - await expect(shellEnv({ sessionID: 'ses-selected', cwd: testDir }, { env: {} })).rejects.toThrow(/unavailable/) await currentHooks.getCleanup!() }) diff --git a/test/sandbox/session-controller.test.ts b/test/sandbox/session-controller.test.ts index cf983e503c..a3bc31974c 100644 --- a/test/sandbox/session-controller.test.ts +++ b/test/sandbox/session-controller.test.ts @@ -194,6 +194,8 @@ describe('SessionSandboxController', () => { if (s.enabled) throw new Error('SQLITE_BUSY: database is locked') repo.setApplied(p, s) }, + getControllerState: (p) => repo.getControllerState(p), + setControllerState: (p, s) => repo.setControllerState(p, s), getPair: (p) => repo.getPair(p), } const controller = createController({ preferences: wrappedRepo }) @@ -757,12 +759,19 @@ describe('SessionSandboxController', () => { await vi.advanceTimersByTimeAsync(0) expect(resolved).toBe(false) expect(manager.ensureRunningCalls).toHaveLength(1) + expect(repo.getControllerState(PROJECT)).toEqual({ + version: 1, + phase: 'loading', + revision: 'r-single-start', + sessionId: ROOT_SESSION, + }) gate.resolve(`forge-${MANAGER_KEY}`) await Promise.all([s1, s2, s3]) expect(resolved).toBe(true) expect(manager.ensureRunningCalls).toHaveLength(1) expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + expect(repo.getControllerState(PROJECT)?.phase).toBe('ready') await controller.dispose() } finally { @@ -770,6 +779,36 @@ describe('SessionSandboxController', () => { } }) + test('a rejected start can be retried without creating a second controller', async () => { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-retry-start' })) + let failLoadingWrite = true + const wrappedRepo: SessionSandboxPreferencesRepo = { + getDesired: (p) => repo.getDesired(p), + setDesired: (p, s) => repo.setDesired(p, s), + getApplied: (p) => repo.getApplied(p), + setApplied: (p, s) => repo.setApplied(p, s), + getControllerState: (p) => repo.getControllerState(p), + setControllerState: (p, s) => { + if (s.phase === 'loading' && failLoadingWrite) { + failLoadingWrite = false + throw new Error('SQLITE_BUSY: database is locked') + } + repo.setControllerState(p, s) + }, + getPair: (p) => repo.getPair(p), + } + const controller = createController({ preferences: wrappedRepo }) + + await expect(controller.start()).rejects.toThrow(/SQLITE_BUSY/) + expect(repo.getControllerState(PROJECT)?.phase).toBe('failed') + + await expect(controller.start()).resolves.toBeUndefined() + expect(repo.getControllerState(PROJECT)?.phase).toBe('ready') + expect(manager.ensureRunningCalls).toEqual([MANAGER_KEY]) + + await controller.dispose() + }) + test('concurrent dispose calls await the same cleanup and stop the container once', async () => { const gate = deferred() manager.setEnsureRunningImpl(() => gate.promise) @@ -1813,12 +1852,11 @@ describe('SessionSandboxController', () => { await controller.dispose() }) - test('a session lookup that never settles cannot hang startup', async () => { + test('a session lookup that never settles cannot hang controller readiness', async () => { vi.useFakeTimers() try { - // Plugin initialization awaits start(), and a persisted desired ON names a session from the - // previous run. If the lookup for that session never answers, an unbounded await would block - // the plugin forever and the TUI would never render. + // A persisted desired ON names a session from the previous run. If the lookup for that + // session never answers, an unbounded await would block every host sandbox resolution. repo.setDesired(PROJECT, makeDesired({ revision: 'r-hang', sessionId: 'session-gone' })) const controller = createController({ // Polling is pushed out of the way so this exercises the startup reconcile alone; steady diff --git a/test/session-sandbox-preferences-repo.test.ts b/test/session-sandbox-preferences-repo.test.ts index 088cc3c98e..f9fc077b8b 100644 --- a/test/session-sandbox-preferences-repo.test.ts +++ b/test/session-sandbox-preferences-repo.test.ts @@ -7,6 +7,7 @@ import { createSessionSandboxPreferencesRepo, SESSION_SANDBOX_DESIRED_KEY, SESSION_SANDBOX_APPLIED_KEY, + SESSION_SANDBOX_CONTROLLER_KEY, } from '../src/storage' import { setupLoopsTestDb } from './helpers/loops-test-db' @@ -91,6 +92,19 @@ describe('SessionSandboxPreferencesRepo', () => { }) }) + describe('controller state round-trip', () => { + test('returns null when nothing stored', () => { + expect(repo.getControllerState(PROJECT_A)).toBeNull() + }) + + test('round-trips controller readiness', () => { + const state = { version: 1 as const, phase: 'loading' as const, revision: 'rev-1', sessionId: 'sess-1' } + repo.setControllerState(PROJECT_A, state) + expect(repo.getControllerState(PROJECT_A)).toEqual(state) + expect(repo.getPair(PROJECT_A).controller).toEqual(state) + }) + }) + describe('key independence', () => { test('desired and applied do not overwrite each other', () => { const desired = makeDesired({ revision: 'd1' }) @@ -143,6 +157,11 @@ describe('SessionSandboxPreferencesRepo', () => { expect(repo.getApplied(PROJECT_A)).toBeNull() }) + test('rejects malformed controller state', () => { + writeRaw(PROJECT_A, SESSION_SANDBOX_CONTROLLER_KEY, JSON.stringify({ version: 1, phase: 'unknown', revision: 'rev-1', sessionId: 'sess-1' })) + expect(repo.getControllerState(PROJECT_A)).toBeNull() + }) + test('treats wrong version as absent', () => { writeRaw(PROJECT_A, SESSION_SANDBOX_DESIRED_KEY, JSON.stringify(makeDesired({ version: 2 as never }))) writeRaw(PROJECT_A, SESSION_SANDBOX_APPLIED_KEY, JSON.stringify(makeApplied({ version: 0 as never }))) diff --git a/test/tui/session-sandbox-store.test.ts b/test/tui/session-sandbox-store.test.ts index 0f846023c0..ccc869bbfc 100644 --- a/test/tui/session-sandbox-store.test.ts +++ b/test/tui/session-sandbox-store.test.ts @@ -8,6 +8,7 @@ import { awaitSessionSandboxState, beginSessionSandboxStateRequest, deriveSessionSandboxAcknowledged, + deriveSessionSandboxDisplayStatus, hostSandboxToggleBlocked, isSessionSandboxPreferenceSettled, readSessionSandboxPreference, @@ -102,7 +103,7 @@ describe('session-sandbox-store (TUI bridge)', () => { test('reads rows for the correct project only', () => { repo.setDesired(PROJECT_A, { version: 1 as const, revision: 'r1', enabled: true, sessionId: 'sess-1', requestedAt: 1 }) - expect(readSessionSandboxPreference('project-b', dbPath)).toEqual({ desired: null, applied: null, unavailable: false }) + expect(readSessionSandboxPreference('project-b', dbPath)).toEqual({ desired: null, applied: null, controller: null, unavailable: false }) }) test('does not create a missing database file', () => { @@ -141,7 +142,7 @@ describe('session-sandbox-store (TUI bridge)', () => { const initialized = new Database(path) setupLoopsTestDb(initialized) initialized.close() - expect(readSessionSandboxPreference(PROJECT_A, path)).toEqual({ desired: null, applied: null, unavailable: false }) + expect(readSessionSandboxPreference(PROJECT_A, path)).toEqual({ desired: null, applied: null, controller: null, unavailable: false }) }) test('assembles desired and applied from one snapshot despite an intervening desired write', async () => { @@ -331,6 +332,54 @@ describe('session-sandbox-store (TUI bridge)', () => { }) }) + describe('deriveSessionSandboxDisplayStatus', () => { + const desired = (overrides: Partial = {}) => ({ + version: 1 as const, + revision: 'r1', + enabled: true, + sessionId: 'sess-1', + requestedAt: 1, + ...overrides, + }) + + test('shows loading only for the selected session while acknowledgement is pending', () => { + const pref = { desired: desired(), applied: null } + expect(deriveSessionSandboxDisplayStatus(pref, 'sess-1')).toBe('loading') + expect(deriveSessionSandboxDisplayStatus(pref, 'sess-other')).toBe('disabled') + }) + + test('shows enabled only for a matching acknowledged session', () => { + const applied = writeApplied({ revision: 'r1', enabled: true, sessionId: 'sess-1', error: null }) + const pref = { desired: desired(), applied } + expect(deriveSessionSandboxDisplayStatus(pref, 'sess-1')).toBe('enabled') + expect(deriveSessionSandboxDisplayStatus(pref, 'sess-other')).toBe('disabled') + }) + + test('shows loading while startup revalidates a previously acknowledged sandbox', () => { + const applied = writeApplied({ revision: 'r1', enabled: true, sessionId: 'sess-1', error: null }) + const pref = { + desired: desired(), + applied, + controller: { version: 1 as const, phase: 'loading' as const, revision: 'r1', sessionId: 'sess-1' }, + } + expect(deriveSessionSandboxDisplayStatus(pref, 'sess-1')).toBe('loading') + expect(deriveSessionSandboxDisplayStatus(pref, 'sess-other')).toBe('disabled') + expect(deriveSessionSandboxDisplayStatus({ ...pref, controller: { ...pref.controller, phase: 'ready' } }, 'sess-1')).toBe('enabled') + }) + + test('shows loading while a newer disable request supersedes acknowledged ON', () => { + const staleOn = writeApplied({ revision: 'r1', enabled: true, sessionId: 'sess-1', error: null }) + const pref = { desired: desired({ revision: 'r2', enabled: false }), applied: staleOn } + expect(deriveSessionSandboxDisplayStatus(pref, 'sess-1')).toBe('loading') + }) + + test('shows disabled after acknowledgement fails or turns the sandbox off', () => { + const errored = writeApplied({ revision: 'r1', enabled: false, sessionId: 'sess-1', error: 'unavailable' }) + expect(deriveSessionSandboxDisplayStatus({ desired: desired(), applied: errored }, 'sess-1')).toBe('disabled') + expect(deriveSessionSandboxDisplayStatus(null, 'sess-1')).toBe('disabled') + }) + }) + describe('requestSessionSandboxState', () => { test('writes desired and resolves on the matching applied revision', async () => { const promise = requestSessionSandboxState({ From 6c411b156594c6da5def4dc4ab592285fb8e9172 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:55:22 -0400 Subject: [PATCH 4/4] refactor(session-sandbox): consolidate ownership resolution, polling backoff, and TUI single-flight --- docs/api/_media/configuration.md | 3 + docs/api/_media/sandbox.md | 14 +- docs/api/functions/createForgePlugin.md | 2 +- .../functions/createParentSessionLookup.md | 2 +- .../functions/createSessionDirectoryLookup.md | 2 +- docs/api/interfaces/CompactionConfig.md | 6 +- .../CreateParentSessionLookupOptions.md | 12 +- .../CreateSessionDirectoryLookupOptions.md | 16 +- docs/api/interfaces/DashboardConfig.md | 6 +- docs/api/interfaces/PluginConfig.md | 36 +-- docs/api/variables/VERSION.md | 4 +- docs/api/variables/default.md | 2 +- docs/configuration.md | 4 +- docs/sandbox.md | 14 +- src/hooks/loop-permission.ts | 22 +- src/index.ts | 245 ++++++++++-------- src/loop/runtime.ts | 35 +-- src/sandbox/manager.ts | 8 +- src/sandbox/session-controller.ts | 189 +++++++++----- src/services/session-loop-resolver.ts | 22 +- src/services/unified-sandbox-resolver.ts | 2 +- src/storage/index.ts | 2 +- .../repos/session-sandbox-preferences-repo.ts | 39 +-- src/tui.tsx | 100 ++++--- src/tui/session-sandbox-store.ts | 50 ++-- src/utils/session-ancestry.ts | 19 ++ src/utils/tui-client.ts | 21 +- test/index/session-lookup.test.ts | 25 ++ test/loop-permission-ruleset.test.ts | 55 ++++ test/loop/runtime.test.ts | 57 ++++ test/plugin.test.ts | 207 ++++++++++++++- test/sandbox/manager-custom-mounts.test.ts | 6 +- test/sandbox/manager-project-mount.test.ts | 34 ++- .../sandbox/manager-tool-output-mount.test.ts | 4 +- test/sandbox/session-controller.test.ts | 175 +++++++++++++ test/tui/session-sandbox-store.test.ts | 231 ++++++----------- test/tui/tui-client-discovery.test.ts | 62 +++++ 37 files changed, 1208 insertions(+), 525 deletions(-) create mode 100644 src/utils/session-ancestry.ts create mode 100644 test/tui/tui-client-discovery.test.ts diff --git a/docs/api/_media/configuration.md b/docs/api/_media/configuration.md index 9bcdc9e391..a0335aa833 100644 --- a/docs/api/_media/configuration.md +++ b/docs/api/_media/configuration.md @@ -152,6 +152,9 @@ Notes: | `tui.showVersion` | `true` | Show the Forge version in the sidebar title. | | `tui.keybinds.executePlan` | `"f"` | Open the execution dialog. Avoid `e`, which conflicts with opencode's built-in `editor_open`. | | `tui.keybinds.dashboard` | `""` | Optional keybind for opening the dashboard. Empty registers the command without a default binding. | +| `tui.keybinds.toggleHostSandbox` | `""` | Optional keybind for `Toggle host sandbox`, which enables or disables the project host-session sandbox for the current session. Empty registers the command without a default binding. Requires `sandbox.enabled`. | + +The host-session sandbox applies only to sessions outside active loops. Its desired and applied state is stored per project, and one selected session (including its descendants) can use it at a time. `bash`, `glob`, and `grep` route through the sandbox; file tools remain host-side. A failed enable request blocks those routed tools rather than falling back to the host until the request is disabled or succeeds on retry. ## Dashboard diff --git a/docs/api/_media/sandbox.md b/docs/api/_media/sandbox.md index 9f3ed32aa8..c8e1bab809 100644 --- a/docs/api/_media/sandbox.md +++ b/docs/api/_media/sandbox.md @@ -1,6 +1,6 @@ # Sandbox -Forge can run loop iterations inside an isolated `sbx` sandbox while keeping the loop worktree mounted at its identical host path for fast host/sandbox file sharing. +Forge can run loop iterations or one selected host session inside an isolated `sbx` sandbox while keeping the active project directory mounted at its identical host path for fast host/sandbox file sharing. See also: [Configuration](configuration.md), [Tools](tools.md), [Loop System](loop-system.md). @@ -23,9 +23,9 @@ The image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a ## How It Works -1. Forge creates an isolated git worktree for the loop. -2. If sandboxing is enabled and the `sbx` daemon is available, Forge creates one sandbox for that loop. -3. The worktree and the read-only source project (when `sandbox.mountProjectReadonly` is enabled) are each mounted at their identical host path, so absolute paths resolve the same on both sides. There is no `/workspace` or `/project` container path. +1. A sandbox loop uses its isolated git worktree. A host-session sandbox instead uses the project root selected from the TUI. +2. Forge creates one sandbox per loop, or one project-scoped host-session sandbox shared by plugin instances in the process. +3. The active directory and the read-only source project (when `sandbox.mountProjectReadonly` is enabled) are mounted at their identical host paths, so absolute paths resolve the same on both sides. There is no `/workspace` or `/project` container path. 4. Shell commands and search tools execute inside the sandbox; file tools stay on the host, so LSP and editor integration continue to work. The read-only project mount is dropped whenever the worktree's git directories live inside the source project (the default forge layout), so `sandbox.mountProjectReadonly` is effectively inert there. @@ -37,14 +37,14 @@ Sandbox loops use opencode's native `bash` tool — streaming output, truncation > Requires opencode >= 1.15.5 (the session-aware `shell.env` plugin hook). Enforced via the `engines.opencode` field in Forge's package.json: older opencode versions refuse to load the plugin instead of silently running sandbox loop commands on the host. 1. Forge points opencode's `shell` config at a generated shim (`/forge-shell`). -2. On every bash tool call, Forge's `shell.env` hook resolves the session to its loop. Sessions belonging to an active sandbox loop (including Task-tool subagents) get `FORGE_SANDBOX_CONTAINER` injected; the shim then runs the command via `sbx exec -w "$PWD" bash`. -3. All other sessions get no container env, and the shim execs the host shell unchanged (respecting a user-configured `shell` via `FORGE_HOST_SHELL`). +2. On every bash tool call, Forge's `shell.env` hook resolves the session. Sessions belonging to an active sandbox loop, or to the acknowledged host-session selection, get `FORGE_SANDBOX_CONTAINER` injected; descendants such as Task-tool subagents inherit the same routing. The shim then runs the command via `sbx exec -w "$PWD" bash`. +3. Sessions with no expected sandbox get no container env, and the shim execs the host shell unchanged (respecting a user-configured `shell` via `FORGE_HOST_SHELL`). Active loop routing always takes precedence over host-session preference. The shim fails closed: if the sandbox is expected but `sbx exec` fails (or the loop sandbox cannot be restored), the command errors — it never silently runs on the host. ## Tool Behavior -| Tool category | Behavior in sandbox loop | +| Tool category | Behavior in a sandboxed session | |---|---| | Shell | Native `bash` tool, executed inside the loop sandbox via the shell shim. | | Search tools | `glob` and `grep` route through the `sbx exec` execution hooks. | diff --git a/docs/api/functions/createForgePlugin.md b/docs/api/functions/createForgePlugin.md index 5bfb1707be..21f773a14d 100644 --- a/docs/api/functions/createForgePlugin.md +++ b/docs/api/functions/createForgePlugin.md @@ -8,7 +8,7 @@ > **createForgePlugin**(`config`): `Plugin` -Defined in: [index.ts:205](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L205) +Defined in: [index.ts:276](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L276) Creates an OpenCode plugin instance with loop management and sandboxing. diff --git a/docs/api/functions/createParentSessionLookup.md b/docs/api/functions/createParentSessionLookup.md index d21be7d7a5..a5cb985af2 100644 --- a/docs/api/functions/createParentSessionLookup.md +++ b/docs/api/functions/createParentSessionLookup.md @@ -8,7 +8,7 @@ > **createParentSessionLookup**(`__namedParameters`): (`sessionId`) => `Promise`\<`string` \| `null`\> -Defined in: [index.ts:59](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L59) +Defined in: [index.ts:93](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L93) ## Parameters diff --git a/docs/api/functions/createSessionDirectoryLookup.md b/docs/api/functions/createSessionDirectoryLookup.md index 2488932600..92a13190a0 100644 --- a/docs/api/functions/createSessionDirectoryLookup.md +++ b/docs/api/functions/createSessionDirectoryLookup.md @@ -8,7 +8,7 @@ > **createSessionDirectoryLookup**(`__namedParameters`): (`sessionId`) => `Promise`\<`string` \| `null`\> -Defined in: [index.ts:140](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L140) +Defined in: [index.ts:154](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L154) ## Parameters diff --git a/docs/api/interfaces/CompactionConfig.md b/docs/api/interfaces/CompactionConfig.md index 57b45ebc51..746d049e9e 100644 --- a/docs/api/interfaces/CompactionConfig.md +++ b/docs/api/interfaces/CompactionConfig.md @@ -6,7 +6,7 @@ # Interface: CompactionConfig -Defined in: [types.ts:164](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L164) +Defined in: [types.ts:165](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L165) Configuration for session compaction behavior. @@ -16,7 +16,7 @@ Configuration for session compaction behavior. > `optional` **customPrompt?**: `boolean` -Defined in: [types.ts:166](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L166) +Defined in: [types.ts:167](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L167) Use a custom compaction prompt. @@ -26,6 +26,6 @@ Use a custom compaction prompt. > `optional` **maxContextTokens?**: `number` -Defined in: [types.ts:168](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L168) +Defined in: [types.ts:169](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L169) Maximum context tokens for compaction. diff --git a/docs/api/interfaces/CreateParentSessionLookupOptions.md b/docs/api/interfaces/CreateParentSessionLookupOptions.md index 300b18bfcc..9db632667a 100644 --- a/docs/api/interfaces/CreateParentSessionLookupOptions.md +++ b/docs/api/interfaces/CreateParentSessionLookupOptions.md @@ -6,7 +6,7 @@ # Interface: CreateParentSessionLookupOptions -Defined in: [index.ts:49](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L49) +Defined in: [index.ts:52](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L52) ## Properties @@ -14,7 +14,7 @@ Defined in: [index.ts:49](https://github.com/chriswritescode-dev/opencode-forge/ > **client**: `ForgeClient` -Defined in: [index.ts:50](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L50) +Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L53) *** @@ -22,7 +22,7 @@ Defined in: [index.ts:50](https://github.com/chriswritescode-dev/opencode-forge/ > **directory**: `string` -Defined in: [index.ts:51](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L51) +Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L54) *** @@ -30,7 +30,7 @@ Defined in: [index.ts:51](https://github.com/chriswritescode-dev/opencode-forge/ > **logger**: `object` -Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L53) +Defined in: [index.ts:56](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L56) #### debug @@ -92,7 +92,7 @@ Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/ > **loop**: `Loop` -Defined in: [index.ts:52](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L52) +Defined in: [index.ts:55](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L55) *** @@ -100,4 +100,4 @@ Defined in: [index.ts:52](https://github.com/chriswritescode-dev/opencode-forge/ > `optional` **negativeTtlMs?**: `number` -Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L54) +Defined in: [index.ts:57](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L57) diff --git a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md index a5f01097ca..ec778ed4dd 100644 --- a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md +++ b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md @@ -6,7 +6,7 @@ # Interface: CreateSessionDirectoryLookupOptions -Defined in: [index.ts:134](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L134) +Defined in: [index.ts:147](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L147) ## Properties @@ -14,7 +14,7 @@ Defined in: [index.ts:134](https://github.com/chriswritescode-dev/opencode-forge > **client**: `ForgeClient` -Defined in: [index.ts:135](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L135) +Defined in: [index.ts:148](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L148) *** @@ -22,7 +22,7 @@ Defined in: [index.ts:135](https://github.com/chriswritescode-dev/opencode-forge > **directory**: `string` -Defined in: [index.ts:136](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L136) +Defined in: [index.ts:149](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L149) *** @@ -30,4 +30,12 @@ Defined in: [index.ts:136](https://github.com/chriswritescode-dev/opencode-forge > **loop**: `Loop` -Defined in: [index.ts:137](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L137) +Defined in: [index.ts:150](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L150) + +*** + +### negativeTtlMs? + +> `optional` **negativeTtlMs?**: `number` + +Defined in: [index.ts:151](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L151) diff --git a/docs/api/interfaces/DashboardConfig.md b/docs/api/interfaces/DashboardConfig.md index 76b2df5dc1..483d70d145 100644 --- a/docs/api/interfaces/DashboardConfig.md +++ b/docs/api/interfaces/DashboardConfig.md @@ -6,7 +6,7 @@ # Interface: DashboardConfig -Defined in: [types.ts:200](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L200) +Defined in: [types.ts:201](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L201) Configuration for the read-only observability dashboard HTTP server. The dashboard is unauthenticated: binding to a non-loopback address exposes @@ -20,7 +20,7 @@ for the canonical warning text rendered by launch surfaces. > `optional` **host?**: `string` -Defined in: [types.ts:202](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L202) +Defined in: [types.ts:203](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L203) Bind hostname or IP. Defaults to "localhost". Use "0.0.0.0" to listen on all interfaces. @@ -30,6 +30,6 @@ Bind hostname or IP. Defaults to "localhost". Use "0.0.0.0" to listen on all int > `optional` **port?**: `number` -Defined in: [types.ts:204](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L204) +Defined in: [types.ts:205](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L205) Base bind port. Defaults to 4747. Consecutive ports are tried when busy. diff --git a/docs/api/interfaces/PluginConfig.md b/docs/api/interfaces/PluginConfig.md index dccd202bec..6d429f6e2f 100644 --- a/docs/api/interfaces/PluginConfig.md +++ b/docs/api/interfaces/PluginConfig.md @@ -6,7 +6,7 @@ # Interface: PluginConfig -Defined in: [types.ts:254](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L254) +Defined in: [types.ts:255](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L255) Complete plugin configuration for opencode-forge. @@ -16,7 +16,7 @@ Complete plugin configuration for opencode-forge. > `optional` **agents?**: `Record`\<`string`, `AgentOverrideConfig`\> -Defined in: [types.ts:286](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L286) +Defined in: [types.ts:287](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L287) Per-agent configuration overrides. @@ -26,7 +26,7 @@ Per-agent configuration overrides. > `optional` **auditorFallbackModels?**: (`string` \| `AuditorFallbackModel`)[] -Defined in: [types.ts:272](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L272) +Defined in: [types.ts:273](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L273) Ordered entries tried, in order, when the current auditor model hits a provider usage/auth limit mid-loop. Use a `"provider/model"` string, or `{ model, variant }` to pin a variant to that fallback; the primary `auditorVariant` is **not** inherited by fallback entries. @@ -36,7 +36,7 @@ Ordered entries tried, in order, when the current auditor model hits a provider > `optional` **auditorModel?**: `string` -Defined in: [types.ts:266](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L266) +Defined in: [types.ts:267](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L267) Model to use for code auditing. @@ -46,7 +46,7 @@ Model to use for code auditing. > `optional` **auditorVariant?**: `string` -Defined in: [types.ts:270](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L270) +Defined in: [types.ts:271](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L271) Default reasoning/thinking variant for the auditor model. @@ -56,7 +56,7 @@ Default reasoning/thinking variant for the auditor model. > `optional` **compaction?**: [`CompactionConfig`](CompactionConfig.md) -Defined in: [types.ts:260](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L260) +Defined in: [types.ts:261](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L261) Compaction behavior configuration. @@ -66,7 +66,7 @@ Compaction behavior configuration. > `optional` **completedLoopTtlMs?**: `number` -Defined in: [types.ts:280](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L280) +Defined in: [types.ts:281](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L281) TTL for completed/cancelled/errored/stalled loops before sweep. Default 7 days. @@ -76,7 +76,7 @@ TTL for completed/cancelled/errored/stalled loops before sweep. Default 7 days. > `optional` **dashboard?**: [`DashboardConfig`](DashboardConfig.md) -Defined in: [types.ts:284](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L284) +Defined in: [types.ts:285](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L285) Dashboard HTTP server bind configuration. @@ -86,7 +86,7 @@ Dashboard HTTP server bind configuration. > `optional` **dataDir?**: `string` -Defined in: [types.ts:256](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L256) +Defined in: [types.ts:257](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L257) Custom data directory for plugin storage. Defaults to platform data dir. @@ -96,7 +96,7 @@ Custom data directory for plugin storage. Defaults to platform data dir. > `optional` **executionModel?**: `string` -Defined in: [types.ts:264](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L264) +Defined in: [types.ts:265](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L265) Model to use for code execution. @@ -106,7 +106,7 @@ Model to use for code execution. > `optional` **executionVariant?**: `string` -Defined in: [types.ts:268](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L268) +Defined in: [types.ts:269](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L269) Default reasoning/thinking variant for the execution model. @@ -116,7 +116,7 @@ Default reasoning/thinking variant for the execution model. > `optional` **groupLaunch?**: `GroupLaunchConfig` -Defined in: [types.ts:276](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L276) +Defined in: [types.ts:277](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L277) Group launch configuration. @@ -126,7 +126,7 @@ Group launch configuration. > `optional` **logging?**: `LoggingConfig` -Defined in: [types.ts:258](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L258) +Defined in: [types.ts:259](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L259) Logging configuration. @@ -136,7 +136,7 @@ Logging configuration. > `optional` **loop?**: `LoopConfig` -Defined in: [types.ts:274](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L274) +Defined in: [types.ts:275](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L275) Loop behavior configuration. @@ -146,7 +146,7 @@ Loop behavior configuration. > `optional` **messagesTransform?**: `MessagesTransformConfig` -Defined in: [types.ts:262](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L262) +Defined in: [types.ts:263](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L263) Message transformation for architect agent. @@ -156,7 +156,7 @@ Message transformation for architect agent. > `optional` **remotes?**: `RemoteServerConfig`[] -Defined in: [types.ts:278](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L278) +Defined in: [types.ts:279](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L279) Remote opencode servers available as loop launch targets. @@ -166,7 +166,7 @@ Remote opencode servers available as loop launch targets. > `optional` **sandbox?**: `SandboxConfig` -Defined in: [types.ts:288](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L288) +Defined in: [types.ts:289](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L289) Sandbox execution configuration. @@ -176,6 +176,6 @@ Sandbox execution configuration. > `optional` **tui?**: `TuiConfig` -Defined in: [types.ts:282](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/types.ts#L282) +Defined in: [types.ts:283](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L283) TUI display configuration. diff --git a/docs/api/variables/VERSION.md b/docs/api/variables/VERSION.md index 185743fd5b..328c4bc27a 100644 --- a/docs/api/variables/VERSION.md +++ b/docs/api/variables/VERSION.md @@ -6,6 +6,6 @@ # Variable: VERSION -> `const` **VERSION**: `"0.7.9"` = `'0.7.9'` +> `const` **VERSION**: `"0.8.0"` = `'0.8.0'` -Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/version.ts#L1) +Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/version.ts#L1) diff --git a/docs/api/variables/default.md b/docs/api/variables/default.md index 92eecf3117..d360f6e483 100644 --- a/docs/api/variables/default.md +++ b/docs/api/variables/default.md @@ -8,7 +8,7 @@ > `const` **default**: `object` -Defined in: [index.ts:840](https://github.com/chriswritescode-dev/opencode-forge/blob/4781cfd6d6b1994ce6d5de8e795c35f0499fd5fe/src/index.ts#L840) +Defined in: [index.ts:991](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L991) ## Type Declaration diff --git a/docs/configuration.md b/docs/configuration.md index 566526f2f0..a0335aa833 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -152,7 +152,9 @@ Notes: | `tui.showVersion` | `true` | Show the Forge version in the sidebar title. | | `tui.keybinds.executePlan` | `"f"` | Open the execution dialog. Avoid `e`, which conflicts with opencode's built-in `editor_open`. | | `tui.keybinds.dashboard` | `""` | Optional keybind for opening the dashboard. Empty registers the command without a default binding. | -| `tui.keybinds.toggleHostSandbox` | `""` | Optional keybind for `Toggle host sandbox`, which runs the current session inside a sandbox container. Empty registers the command without a default binding. Requires `sandbox.enabled`. | +| `tui.keybinds.toggleHostSandbox` | `""` | Optional keybind for `Toggle host sandbox`, which enables or disables the project host-session sandbox for the current session. Empty registers the command without a default binding. Requires `sandbox.enabled`. | + +The host-session sandbox applies only to sessions outside active loops. Its desired and applied state is stored per project, and one selected session (including its descendants) can use it at a time. `bash`, `glob`, and `grep` route through the sandbox; file tools remain host-side. A failed enable request blocks those routed tools rather than falling back to the host until the request is disabled or succeeds on retry. ## Dashboard diff --git a/docs/sandbox.md b/docs/sandbox.md index 9f3ed32aa8..c8e1bab809 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -1,6 +1,6 @@ # Sandbox -Forge can run loop iterations inside an isolated `sbx` sandbox while keeping the loop worktree mounted at its identical host path for fast host/sandbox file sharing. +Forge can run loop iterations or one selected host session inside an isolated `sbx` sandbox while keeping the active project directory mounted at its identical host path for fast host/sandbox file sharing. See also: [Configuration](configuration.md), [Tools](tools.md), [Loop System](loop-system.md). @@ -23,9 +23,9 @@ The image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a ## How It Works -1. Forge creates an isolated git worktree for the loop. -2. If sandboxing is enabled and the `sbx` daemon is available, Forge creates one sandbox for that loop. -3. The worktree and the read-only source project (when `sandbox.mountProjectReadonly` is enabled) are each mounted at their identical host path, so absolute paths resolve the same on both sides. There is no `/workspace` or `/project` container path. +1. A sandbox loop uses its isolated git worktree. A host-session sandbox instead uses the project root selected from the TUI. +2. Forge creates one sandbox per loop, or one project-scoped host-session sandbox shared by plugin instances in the process. +3. The active directory and the read-only source project (when `sandbox.mountProjectReadonly` is enabled) are mounted at their identical host paths, so absolute paths resolve the same on both sides. There is no `/workspace` or `/project` container path. 4. Shell commands and search tools execute inside the sandbox; file tools stay on the host, so LSP and editor integration continue to work. The read-only project mount is dropped whenever the worktree's git directories live inside the source project (the default forge layout), so `sandbox.mountProjectReadonly` is effectively inert there. @@ -37,14 +37,14 @@ Sandbox loops use opencode's native `bash` tool — streaming output, truncation > Requires opencode >= 1.15.5 (the session-aware `shell.env` plugin hook). Enforced via the `engines.opencode` field in Forge's package.json: older opencode versions refuse to load the plugin instead of silently running sandbox loop commands on the host. 1. Forge points opencode's `shell` config at a generated shim (`/forge-shell`). -2. On every bash tool call, Forge's `shell.env` hook resolves the session to its loop. Sessions belonging to an active sandbox loop (including Task-tool subagents) get `FORGE_SANDBOX_CONTAINER` injected; the shim then runs the command via `sbx exec -w "$PWD" bash`. -3. All other sessions get no container env, and the shim execs the host shell unchanged (respecting a user-configured `shell` via `FORGE_HOST_SHELL`). +2. On every bash tool call, Forge's `shell.env` hook resolves the session. Sessions belonging to an active sandbox loop, or to the acknowledged host-session selection, get `FORGE_SANDBOX_CONTAINER` injected; descendants such as Task-tool subagents inherit the same routing. The shim then runs the command via `sbx exec -w "$PWD" bash`. +3. Sessions with no expected sandbox get no container env, and the shim execs the host shell unchanged (respecting a user-configured `shell` via `FORGE_HOST_SHELL`). Active loop routing always takes precedence over host-session preference. The shim fails closed: if the sandbox is expected but `sbx exec` fails (or the loop sandbox cannot be restored), the command errors — it never silently runs on the host. ## Tool Behavior -| Tool category | Behavior in sandbox loop | +| Tool category | Behavior in a sandboxed session | |---|---| | Shell | Native `bash` tool, executed inside the loop sandbox via the shell shim. | | Search tools | `glob` and `grep` route through the `sbx exec` execution hooks. | diff --git a/src/hooks/loop-permission.ts b/src/hooks/loop-permission.ts index da508847fc..4d0f9f44c9 100644 --- a/src/hooks/loop-permission.ts +++ b/src/hooks/loop-permission.ts @@ -130,7 +130,13 @@ export function createLoopPermissionPatcher(deps: CreateLoopPermissionPatcherDep const parentID = info?.parentID if (!sessionID || !parentID) return - const resolved = await sessionLoopResolver.resolveActiveLoopForSession(sessionID) + let resolved: ResolvedLoop | null + try { + resolved = await sessionLoopResolver.resolveActiveLoopForSession(sessionID) + } catch (err) { + logger.error(`[loop-permission] ancestry lookup failed for ${sessionID}, skipping patch`, err) + return + } if (!resolved?.active) return if (PATCHED_SESSIONS.has(sessionID)) { @@ -153,9 +159,17 @@ export function createLoopPermissionPatcher(deps: CreateLoopPermissionPatcherDep const { sessionID } = input if (!sessionID || PATCHED_SESSIONS.has(sessionID)) return - const resolved = input.resolved !== undefined - ? input.resolved - : await sessionLoopResolver.resolveActiveLoopForSession(sessionID) + let resolved: ResolvedLoop | null + if (input.resolved !== undefined) { + resolved = input.resolved + } else { + try { + resolved = await sessionLoopResolver.resolveActiveLoopForSession(sessionID) + } catch (err) { + logger.error(`[loop-permission] ancestry lookup failed for ${sessionID}, skipping fallback patch`, err) + return + } + } if (!resolved?.active) return const targetDirectory = resolved.worktreeDir ?? directory diff --git a/src/index.ts b/src/index.ts index e670fb384a..fe213d671e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,7 @@ import { emitLoopPermissionConfigWarnings } from './utils/loop-permission-warnin import { publishToast } from './utils/toast' import { mkdirSync } from 'fs' import { createSandboxManager } from './sandbox/manager' -import { createSessionSandboxController, createUnavailableSandboxLifecycleManager, type SessionSandboxController } from './sandbox/session-controller' +import { createSessionSandboxController, createUnavailableSandboxLifecycleManager, type ResolveActiveLoopForSession, type SessionSandboxController } from './sandbox/session-controller' import type { PluginConfig, CompactionConfig } from './types' import { createTools } from './tools' import { createToolExecuteBeforeHook, createToolExecuteAfterHook, createPlanApprovalEventHook } from './hooks' @@ -59,6 +59,37 @@ export interface CreateParentSessionLookupOptions { const PARENT_LOOKUP_NEGATIVE_TTL_MS = 15000 +type SessionLookupAttempt = { label: string; directory?: string; input: Record } + +function buildSessionLookupAttempts( + sessionId: string, + directory: string, + loop: import('./loop').Loop, +): SessionLookupAttempt[] { + const attempts: SessionLookupAttempt[] = [] + const seenDirectories = new Set() + for (const state of loop.listActive()) { + if (!state.worktreeDir || seenDirectories.has(state.worktreeDir)) continue + seenDirectories.add(state.worktreeDir) + const workspaceParam = state.workspaceId ? { workspace: state.workspaceId } : {} + attempts.push({ + label: `loop:${state.loopName}`, + directory: state.worktreeDir, + input: { sessionID: sessionId, directory: state.worktreeDir, ...workspaceParam }, + }) + if (state.workspaceId) { + attempts.push({ + label: `loop-ws:${state.loopName}`, + input: { sessionID: sessionId, workspace: state.workspaceId }, + }) + } + } + if (!seenDirectories.has(directory)) { + attempts.push({ label: 'host', directory, input: { sessionID: sessionId, directory } }) + } + return attempts +} + export function createParentSessionLookup({ client, directory, @@ -80,35 +111,7 @@ export function createParentSessionLookup({ negativeCache.delete(sessionId) } - const attempts: Array<{ label: string; directory?: string; input: Record }> = [] - - const seenDirectories = new Set() - const activeLoops = loop.listActive() - - for (const state of activeLoops) { - if (!state.worktreeDir || seenDirectories.has(state.worktreeDir)) continue - seenDirectories.add(state.worktreeDir) - const workspaceParam = state.workspaceId ? { workspace: state.workspaceId } : {} - attempts.push({ - label: `loop:${state.loopName}`, - directory: state.worktreeDir, - input: { sessionID: sessionId, directory: state.worktreeDir, ...workspaceParam }, - }) - if (state.workspaceId) { - attempts.push({ - label: `loop-ws:${state.loopName}`, - input: { sessionID: sessionId, workspace: state.workspaceId }, - }) - } - } - - if (!seenDirectories.has(directory)) { - attempts.push({ - label: 'host', - directory, - input: { sessionID: sessionId, directory }, - }) - } + const attempts = buildSessionLookupAttempts(sessionId, directory, loop) const failures: string[] = [] @@ -145,123 +148,142 @@ export interface CreateSessionDirectoryLookupOptions { client: ForgeClient directory: string loop: import('./loop').Loop + negativeTtlMs?: number } -export function createSessionDirectoryLookup({ +interface SessionSandboxIdentity { + projectId: string + directory: string +} + +function createSessionIdentityLookup({ client, directory, loop, -}: CreateSessionDirectoryLookupOptions): (sessionId: string) => Promise { - const cache = new LRUCache(500) + negativeTtlMs = PARENT_LOOKUP_NEGATIVE_TTL_MS, +}: CreateSessionDirectoryLookupOptions, requireProjectId = true): (sessionId: string) => Promise { + const cache = new LRUCache(500) + const negativeCache = new Map() - return async (sessionId: string): Promise => { + return async (sessionId: string): Promise => { if (cache.has(sessionId)) { return cache.get(sessionId) ?? null } - const attempts: Array<{ label: string; directory?: string; input: Record }> = [] - - const seenDirectories = new Set() - const activeLoops = loop.listActive() - - for (const state of activeLoops) { - if (!state.worktreeDir || seenDirectories.has(state.worktreeDir)) continue - seenDirectories.add(state.worktreeDir) - const workspaceParam = state.workspaceId ? { workspace: state.workspaceId } : {} - attempts.push({ - label: `loop:${state.loopName}`, - directory: state.worktreeDir, - input: { sessionID: sessionId, directory: state.worktreeDir, ...workspaceParam }, - }) - if (state.workspaceId) { - attempts.push({ - label: `loop-ws:${state.loopName}`, - input: { sessionID: sessionId, workspace: state.workspaceId }, - }) - } - } - - if (!seenDirectories.has(directory)) { - attempts.push({ - label: 'host', - directory, - input: { sessionID: sessionId, directory }, - }) + const negExpiry = negativeCache.get(sessionId) + if (negExpiry !== undefined) { + if (negExpiry > Date.now()) return null + negativeCache.delete(sessionId) } + const attempts = buildSessionLookupAttempts(sessionId, directory, loop) for (const attempt of attempts) { try { const session = await client.session.get(attempt.input as SessionGetParams) - if (session && session.directory) { - cache.set(sessionId, session.directory) - return session.directory + if (session?.directory && (!requireProjectId || session.projectID)) { + const identity = { projectId: session.projectID ?? '', directory: session.directory } + negativeCache.delete(sessionId) + cache.set(sessionId, identity) + return identity } } catch { // fall through to next attempt } } + negativeCache.set(sessionId, Date.now() + negativeTtlMs) return null } } +export function createSessionDirectoryLookup( + options: CreateSessionDirectoryLookupOptions, +): (sessionId: string) => Promise { + const lookup = createSessionIdentityLookup(options, false) + return async (sessionId) => (await lookup(sessionId))?.directory ?? null +} + + +type SessionSandboxProvider = { + worktree: boolean + getParentSessionId: (sessionId: string) => Promise + getSessionDirectory: (sessionId: string) => Promise + getSessionIdentity: (sessionId: string) => Promise + resolveActiveLoopForSession: ResolveActiveLoopForSession +} -/** - * Process-wide registry of host-session sandbox controllers, keyed by project id. - * - * OpenCode can instantiate this plugin more than once for the same directory in a single process, - * and every instance builds its own database handle, sandbox manager and controller. Two - * controllers reconciling the same per-project preference row race on one container: one creates - * while the other force-deletes underneath it, which surfaces as `operation in progress`, - * `already exists`, `failed to run sandbox container`, or an acknowledgement timeout. The - * container and the preference row are both per project, so exactly one reconciler may exist per - * project per process; additional instances share it and release it by reference count. - */ type SharedSessionSandboxController = { controller: SessionSandboxController started: Promise - refs: number + providerState: { + providers: Set + current: SessionSandboxProvider + } close: () => void } const sharedSessionSandboxControllers = new Map() -/** - * Returns the process-wide controller for `projectId`, creating and starting it on first use. - * The returned `started` promise is shared, so every caller awaits the same initial reconcile - * rather than triggering a second one. - */ +function preferredSessionSandboxProvider(providers: Set): SessionSandboxProvider { + return [...providers].find((provider) => !provider.worktree) ?? providers.values().next().value! +} + +function sessionSandboxProviderForwarding( + getCurrent: () => SessionSandboxProvider, +): Pick { + return { + getParentSessionId: (sessionId) => getCurrent().getParentSessionId(sessionId), + getSessionDirectory: (sessionId) => getCurrent().getSessionDirectory(sessionId), + getSessionIdentity: (sessionId) => getCurrent().getSessionIdentity(sessionId), + resolveActiveLoopForSession: (sessionId) => getCurrent().resolveActiveLoopForSession(sessionId), + } +} + function acquireSessionSandboxController( projectId: string, - create: () => { controller: SessionSandboxController; close: () => void }, + provider: SessionSandboxProvider, + create: (getCurrent: () => SessionSandboxProvider) => { controller: SessionSandboxController; close: () => void }, ): SharedSessionSandboxController { const existing = sharedSessionSandboxControllers.get(projectId) if (existing) { - existing.refs += 1 + existing.providerState.providers.add(provider) + existing.providerState.current = preferredSessionSandboxProvider(existing.providerState.providers) return existing } - const { controller, close } = create() - const entry: SharedSessionSandboxController = { controller, started: controller.start(), refs: 1, close } + const providerState = { + providers: new Set([provider]), + current: provider, + } + const { controller, close } = create(() => providerState.current) + const entry: SharedSessionSandboxController = { + controller, + started: controller.start(), + providerState, + close, + } sharedSessionSandboxControllers.set(projectId, entry) return entry } -/** - * Drops one reference and disposes the controller once the last instance releases it. The - * controller owns a dedicated database handle, closed here after disposal, so it can outlive the - * instance that happened to create it: instances release before closing their own handles, and a - * borrowed handle would otherwise be closed while other instances still hold a reference. - */ -async function releaseSessionSandboxController(projectId: string): Promise { +async function releaseSessionSandboxController( + projectId: string, + provider: SessionSandboxProvider, +): Promise { const entry = sharedSessionSandboxControllers.get(projectId) - if (!entry) return - entry.refs -= 1 - if (entry.refs > 0) return + if (!entry || !entry.providerState.providers.has(provider)) return + if (entry.providerState.providers.size > 1) { + entry.providerState.providers.delete(provider) + if (entry.providerState.current === provider) { + entry.providerState.current = preferredSessionSandboxProvider(entry.providerState.providers) + } + return + } sharedSessionSandboxControllers.delete(projectId) try { await entry.controller.dispose() } finally { entry.close() + entry.providerState.providers.clear() } } @@ -275,6 +297,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { return async (input: PluginInput): Promise => { const { directory, project } = input const projectId = project.id + const projectRoot = project.worktree ?? directory const loggingConfig = config.logging const logger = createLogger({ @@ -327,7 +350,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { dataDir, toolOutputDir: resolveOpencodeToolOutputDir(), tmpDir: forgeTempDir, - sourceProjectDir: directory, + sourceProjectDir: projectRoot, mountProjectReadonly: config.sandbox?.mountProjectReadonly, ...(config.sandbox?.mounts ? { customMounts: config.sandbox.mounts } : {}), ...(config.sandbox?.network ? { network: config.sandbox.network } : {}), @@ -480,6 +503,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { let cleanupPromise: Promise | null = null let sessionSandboxProjectId: string | null = null + let sessionSandboxProvider: SessionSandboxProvider | null = null const cleanup = (): Promise => { if (cleanupPromise) { @@ -504,8 +528,8 @@ export function createForgePlugin(config: PluginConfig): Plugin { try { // Release rather than dispose: the controller is shared by every plugin instance in this // process for this project, and only the last release may tear it down. - if (sessionSandboxProjectId) { - await releaseSessionSandboxController(sessionSandboxProjectId) + if (sessionSandboxProjectId && sessionSandboxProvider) { + await releaseSessionSandboxController(sessionSandboxProjectId, sessionSandboxProvider) } } catch (err) { logger.error('Error during session sandbox controller disposal', err) @@ -572,7 +596,8 @@ export function createForgePlugin(config: PluginConfig): Plugin { const parentSessionLookup = createParentSessionLookup({ client: forgeClient, directory, loop: loopHandler.loop, logger }) loopHandler.loop.setParentSessionLookup(parentSessionLookup) - const sessionDirectoryLookup = createSessionDirectoryLookup({ client: forgeClient, directory, loop: loopHandler.loop }) + const sessionIdentityLookup = createSessionIdentityLookup({ client: forgeClient, directory, loop: loopHandler.loop }) + const sessionDirectoryLookup = async (sessionId: string) => (await sessionIdentityLookup(sessionId))?.directory ?? null const sessionLoopResolver = createSessionLoopResolver({ loop: loopHandler.loop, getParentSessionId: parentSessionLookup, @@ -600,23 +625,33 @@ export function createForgePlugin(config: PluginConfig): Plugin { // Shared per project across every plugin instance in this process: a second reconciler would // race this one on the same container. Only the first instance constructs and starts one, and // it gets its own database handle so it never depends on that instance's lifetime. - const sharedSessionSandbox = acquireSessionSandboxController(projectId, () => { + const hostSessionProvider: SessionSandboxProvider = { + worktree: isForgeWorktreeDir(dataDir, directory), + getParentSessionId: parentSessionLookup, + getSessionDirectory: sessionDirectoryLookup, + getSessionIdentity: sessionIdentityLookup, + resolveActiveLoopForSession: sessionLoopResolver.resolveActiveLoopForSession, + } + const sharedSessionSandbox = acquireSessionSandboxController(projectId, hostSessionProvider, (getCurrent) => { + const forwarding = sessionSandboxProviderForwarding(getCurrent) const controllerDb = initializeDatabase(dataDir, { completedLoopTtlMs: config.completedLoopTtlMs }) return { close: () => closeDatabase(controllerDb), controller: createSessionSandboxController({ projectId, - directory, + directory: projectRoot, preferences: createSessionSandboxPreferencesRepo(controllerDb), sandboxManager: sandboxManager ?? createUnavailableSandboxLifecycleManager(runtime), - getParentSessionId: parentSessionLookup, - getSessionDirectory: sessionDirectoryLookup, - resolveActiveLoopForSession: sessionLoopResolver.resolveActiveLoopForSession, + getParentSessionId: forwarding.getParentSessionId, + getSessionDirectory: forwarding.getSessionDirectory, + getSessionIdentity: forwarding.getSessionIdentity, + resolveActiveLoopForSession: forwarding.resolveActiveLoopForSession, logger, }), } }) sessionSandboxProjectId = projectId + sessionSandboxProvider = hostSessionProvider void sharedSessionSandbox.started.catch((err) => logger.error('Session sandbox controller failed to start', err)) // Unified, loop-first sandbox resolver. Loop resolution always takes precedence: an active diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index 2800067908..b26a0a60b6 100644 --- a/src/loop/runtime.ts +++ b/src/loop/runtime.ts @@ -38,6 +38,7 @@ import { createPromptDispatch } from './runtime-prompt' import { createWorkspaceLifecycle, isWorkspaceNotFoundError } from './runtime-workspace' import { loopRegistry } from '../utils/loop-registry' import { selectSessionBestEffort } from '../utils/tui-navigation' +import { findSessionAncestor } from '../utils/session-ancestry' import { classifyProviderLimit, extractErrorSignal } from './provider-limit' import { parseCoderDecisions } from '../utils/coder-decisions' @@ -317,23 +318,11 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { if (!getParentSessionId) return null - const seen = new Set([sessionId]) - let current = sessionId - for (let depth = 0; depth < 10; depth++) { - const parentId = await getParentSessionId(current) - if (!parentId || seen.has(parentId)) break - seen.add(parentId) - + return findSessionAncestor(sessionId, getParentSessionId, (parentId) => { const parentLoop = loopService.resolveLoopName(parentId) if (parentLoop) return parentLoop - - const parentReverse = sessionToLoop.get(parentId) - if (parentReverse) return parentReverse - - current = parentId - } - - return null + return sessionToLoop.get(parentId) ?? null + }) } const { detachFromWorkspace, recoverFromMissingWorkspace, ensureWorkspaceForLoop } = createWorkspaceLifecycle({ client, logger, loopService }) @@ -2354,7 +2343,13 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { return } - const loopName = await resolveSessionLoopName(eventSessionId) + let loopName: string | null = null + try { + loopName = await resolveSessionLoopName(eventSessionId) + } catch (err) { + logger.error(`Loop: ancestry lookup failed for error event session=${eventSessionId}, ignoring event`, err) + return + } if (!loopName) return await withStateLock(loopName, async () => { const state = loopService.getActiveState(loopName) @@ -2469,7 +2464,13 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { } if (status?.type === 'retry') { - const loopName = await resolveSessionLoopName(sessionId) + let loopName: string | null = null + try { + loopName = await resolveSessionLoopName(sessionId) + } catch (err) { + logger.error(`Loop: ancestry lookup failed for retry status session=${sessionId}, ignoring event`, err) + return + } if (!loopName) return const limitReason = classifyProviderLimit({ message: status.message }) if (!limitReason) { diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index 6935dc7717..8e1cebd961 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -191,11 +191,13 @@ export function createSandboxManager( const orderedGitMounts = [...gitMounts].sort((a, b) => a.hostDir.length - b.hostDir.length) const sourceProjectDir = config.sourceProjectDir + const resolvedSourceProjectDir = sourceProjectDir ? resolve(sourceProjectDir) : undefined const hasProjectMount = config.mountProjectReadonly !== false - && !!sourceProjectDir - && resolve(sourceProjectDir) !== absolute + && !!resolvedSourceProjectDir + && resolvedSourceProjectDir !== absolute + && existsSync(resolvedSourceProjectDir) const projectMount: SandboxMount | undefined = hasProjectMount - ? { hostDir: resolve(sourceProjectDir!), containerDir: resolve(sourceProjectDir!), readOnly: true } + ? { hostDir: resolvedSourceProjectDir, containerDir: resolvedSourceProjectDir, readOnly: true } : undefined const toolOutputMount = resolveToolOutputMount(absolute) diff --git a/src/sandbox/session-controller.ts b/src/sandbox/session-controller.ts index 2f79296e9f..1707f3b471 100644 --- a/src/sandbox/session-controller.ts +++ b/src/sandbox/session-controller.ts @@ -1,10 +1,11 @@ import { createHash, randomUUID } from 'node:crypto' -import { resolve } from 'path' +import { isAbsolute, relative, resolve, sep } from 'path' import type { Logger } from '../types' import type { SessionSandboxAppliedState, SessionSandboxDesiredState, SessionSandboxPreferencesRepo } from '../storage' import type { SandboxContext } from './context' import type { SandboxRuntime } from './sbx' import type { ActiveSandbox } from './manager' +import { findSessionAncestor } from '../utils/session-ancestry' export const DEFAULT_POLL_INTERVAL_MS = 500 @@ -19,17 +20,17 @@ export const DEFAULT_POLL_INTERVAL_MS = 500 export const OWNERSHIP_LOOKUP_TIMEOUT_MS = 5_000 /** Error recorded on the applied row when a host sandbox is refused for an active loop session. */ -export const LOOP_SESSION_REFUSED_ERROR = 'host sandbox cannot be enabled for an active loop session' +const LOOP_SESSION_REFUSED_ERROR = 'host sandbox cannot be enabled for an active loop session' /** * Error used when the host-session sandbox runtime is unavailable (sandbox disabled, manager * initialization failed, or no shell shim). A requested ON is acknowledged as OFF with this * error and the selected session is blocked fail-closed rather than running on the host. */ -export const UNAVAILABLE_SANDBOX_ERROR = 'host-session sandbox is unavailable (sandbox runtime not initialized)' +const UNAVAILABLE_SANDBOX_ERROR = 'host-session sandbox is unavailable (sandbox runtime not initialized)' /** Error recorded on the applied row when an ON request carries no session to bind. */ -export const MISSING_SESSION_ERROR = 'host sandbox cannot be enabled without a session' +const MISSING_SESSION_ERROR = 'host sandbox cannot be enabled without a session' /** * Minimum surface of `SandboxManager` the controller relies on. Kept narrow so the @@ -69,6 +70,7 @@ export interface SessionSandboxControllerDeps { preferences: SessionSandboxPreferencesRepo sandboxManager: SessionSandboxLifecycleManager getParentSessionId(sessionId: string): Promise + getSessionIdentity?(sessionId: string): Promise<{ projectId: string; directory: string } | null> /** * Resolves the directory owning a session. Used to gate reconciliation so only the plugin * instance that owns the requested session acts on the shared preference rows (loop-worktree @@ -97,14 +99,9 @@ export interface SessionSandboxController { dispose(): Promise } -/** - * Maximum number of ancestor hops to walk when matching a session to the acknowledged - * root session, mirroring `session-loop-resolver` so deeply nested sub-agents resolve. - */ -const MAX_PARENT_DEPTH = 10 - /** Cap on reconcile re-runs within a single tick when the desired revision keeps moving. */ const MAX_SUPERSEDE_ITERATIONS = 8 +const MAX_FAST_IDLE_POLLS = 4 /** * Derives the logical manager key for a project. This is a stable, non-final key passed to @@ -192,7 +189,9 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep * revision is still processed normally. */ let pendingCleanupRevision: string | null = null - let intervalId: ReturnType | null = null + let selectedProjectDirectory: string | null = null + let pollTimer: ReturnType | null = null + let idlePolls = 0 let reconciling = false let disposed = false let startPromise: Promise | null = null @@ -265,7 +264,19 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep * while resolution returns null (host fallback). Uncertain ownership therefore fails closed. */ async function resolveOwnership(sessionId: string | null): Promise<'local' | 'foreign' | 'uncertain'> { - if (sessionId == null || !deps.getSessionDirectory) return 'local' + if (sessionId == null) return 'local' + if (deps.getSessionIdentity) { + try { + const identity = await withOwnershipLookupTimeout(() => deps.getSessionIdentity!(sessionId)) + if (!identity) return 'uncertain' + if (identity.projectId !== projectId) return 'foreign' + selectedProjectDirectory = identity.directory + return 'local' + } catch { + return 'uncertain' + } + } + if (!deps.getSessionDirectory) return 'local' const lookup = deps.getSessionDirectory let dir: string | null try { @@ -276,7 +287,21 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep return 'uncertain' } if (!dir) return 'uncertain' - return resolve(dir) === resolve(directory) ? 'local' : 'foreign' + const relativeDirectory = relative(resolve(directory), resolve(dir)) + const local = relativeDirectory !== '..' && + !relativeDirectory.startsWith(`..${sep}`) && + !isAbsolute(relativeDirectory) + if (local) selectedProjectDirectory = dir + return local ? 'local' : 'foreign' + } + + async function readLocallyOwnedDesired(): Promise { + try { + const desired = preferences.getDesired(projectId) + return desired && (await resolveOwnership(desired.sessionId)) === 'local' ? desired : null + } catch { + return null + } } /** @@ -286,16 +311,9 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep async function isWithinSession(root: string, sessionId: string): Promise { if (!root || !sessionId) return false if (sessionId === root) return true - const seen = new Set([sessionId]) - let current = sessionId - for (let depth = 0; depth < MAX_PARENT_DEPTH; depth++) { - const parent = await deps.getParentSessionId(current) - if (!parent || seen.has(parent)) break - seen.add(parent) - if (parent === root) return true - current = parent - } - return false + return (await findSessionAncestor(sessionId, deps.getParentSessionId, (parentId) => ( + parentId === root ? true : null + ))) ?? false } /** @@ -447,6 +465,31 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep // disposal stop the manager key, which only the owner may do. const ownership = await resolveOwnership(desired.sessionId) if (ownership !== 'local') { + if (deps.getSessionIdentity) { + const stopped = await bestEffortStop() + hostActive = !stopped + pendingCleanup = !stopped + pendingCleanupRevision = !stopped ? desired.revision : null + lastValidatedRevision = null + acknowledgedSessionId = null + acknowledgedRevision = null + const ownershipError = ownership === 'foreign' + ? 'Host sandbox session belongs to a different project' + : 'Host sandbox session could not be resolved for this project' + const error = desired.enabled || !stopped ? ownershipError : null + failedSelection = desired.enabled && desired.sessionId + ? { sessionId: desired.sessionId, error: ownershipError } + : null + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, + sessionId: desired.sessionId, + error, + appliedAt: Date.now(), + }) + return desired.revision + } // Only remove a container this instance actually started. The manager key is derived from // the project id, so every instance of this project resolves the same container: a // pre-existing container for that key belongs to whichever instance owns the session, and @@ -455,6 +498,13 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep // still be live, so retain retryable ownership (hostActive true) and let the next reconcile // tick retry removal rather than orphaning a container the new owner will never see. The // foreign acknowledgement is never overwritten. + const uncertainFailedSelection = + ownership === 'uncertain' && desired.enabled && desired.sessionId + ? { + sessionId: desired.sessionId, + error: 'Host sandbox ownership could not be confirmed for the selected session', + } + : null if (hostActive) { try { await sandboxManager.stop(managerKey) @@ -470,7 +520,7 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep lastValidatedRevision = null acknowledgedSessionId = null acknowledgedRevision = null - failedSelection = null + failedSelection = uncertainFailedSelection return desired.revision } hostActive = false @@ -481,15 +531,37 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep // owns the selected session (e.g. a transient directory-lookup failure), so blocking host // fallback is safer than running tools on the host while the shared ON row is left untouched. // Re-evaluated on the next reconcile tick once ownership can be confirmed. - if (ownership === 'uncertain' && desired.enabled && desired.sessionId) { - failedSelection = { + failedSelection = uncertainFailedSelection + return desired.revision + } + + if ( + desired.enabled && + desired.sessionId != null && + applied?.enabled && + applied.sessionId != null && + applied.sessionId !== desired.sessionId + ) { + const stopped = await bestEffortStop() + hostActive = !stopped + pendingCleanup = !stopped + pendingCleanupRevision = !stopped ? desired.revision : null + lastValidatedRevision = null + acknowledgedSessionId = null + acknowledgedRevision = null + if (!stopped) { + const error = 'Failed to remove the previous host sandbox before switching sessions' + failedSelection = desired.sessionId ? { sessionId: desired.sessionId, error } : null + writeApplied({ + version: 1, + revision: desired.revision, + enabled: false, sessionId: desired.sessionId, - error: 'Host sandbox ownership could not be confirmed for the selected session', - } - } else { - failedSelection = null + error, + appliedAt: Date.now(), + }) + return desired.revision } - return desired.revision } // Ownership is confirmed local from here. A matching successful persisted ON (applied at the @@ -591,7 +663,7 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep // call ensureRunning on every reconcile tick. if (lastValidatedRevision !== desired.revision) { try { - await sandboxManager.ensureRunning(managerKey, directory) + await sandboxManager.ensureRunning(managerKey, selectedProjectDirectory ?? directory) lastValidatedRevision = desired.revision } catch (err) { // A persisted-ON restore that partially creates the container and then fails must run @@ -719,7 +791,7 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep } } try { - await sandboxManager.ensureRunning(managerKey, directory) + await sandboxManager.ensureRunning(managerKey, selectedProjectDirectory ?? directory) } catch (err) { await handleFailedOnStart(desired, desired.sessionId, err instanceof Error ? err.message : String(err)) return desired.revision @@ -810,9 +882,23 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep logger.error(`[session-sandbox] reconcile failed: ${err instanceof Error ? err.message : String(err)}`) } finally { reconciling = false + schedulePoll() } } + function schedulePoll(): void { + if (disposed || pollTimer !== null) return + const desired = preferences.getDesired(projectId) + const active = hostActive || pendingCleanup || (desired?.enabled === true && failedSelection === null) + if (active) idlePolls = 0 + const fast = active || idlePolls < MAX_FAST_IDLE_POLLS + if (!active) idlePolls += 1 + pollTimer = setTimeout(() => { + pollTimer = null + void tick() + }, fast ? pollIntervalMs : pollIntervalMs * 10) + } + async function resolveSandboxForSession( sessionId: string, opts?: ResolveSandboxSessionOpts, @@ -859,7 +945,7 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep return null } try { - await sandboxManager.ensureRunning(managerKey, directory) + await sandboxManager.ensureRunning(managerKey, selectedProjectDirectory ?? directory) } catch (err) { const msg = err instanceof Error ? err.message : String(err) logger.log(`[session-sandbox] ensureRunning failed during restore: ${msg}`) @@ -939,11 +1025,7 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep } if (disposed) return writeControllerState('ready') - if (intervalId === null) { - intervalId = setInterval(() => { - void tick() - }, pollIntervalMs) - } + schedulePoll() })() startPromise = starting void starting.catch(() => { @@ -965,9 +1047,9 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep // Mark disposed eagerly (before the serialized body) so any in-flight resolution or // reconciliation revalidates against it and returns null rather than restoring a container. disposed = true - if (intervalId !== null) { - clearInterval(intervalId) - intervalId = null + if (pollTimer !== null) { + clearTimeout(pollTimer) + pollTimer = null } disposePromise = serialized(async () => { // Stop this controller's own container before any fallible bookkeeping, so a transient DB @@ -989,15 +1071,8 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep // owns the current desired session so a non-owner cannot overwrite another instance's // shared acknowledgement; the actual owner observes the live container on its own poll. const msg = err instanceof Error ? err.message : String(err) - let desired: SessionSandboxDesiredState | null = null - let owned = false - try { - desired = preferences.getDesired(projectId) - owned = desired != null && (await resolveOwnership(desired.sessionId)) === 'local' - } catch { - // A failed read must not mask the stop failure; we simply skip the failure write. - } - if (owned && desired) { + const desired = await readLocallyOwnedDesired() + if (desired) { writeApplied({ version: 1, revision: desired.enabled ? freshRevision() : desired.revision, @@ -1018,16 +1093,8 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep // a settled applied OFF is re-acted to start the container and acknowledge ON again. Skip // the write when this instance does not own the requested session so it cannot overwrite // another instance's acknowledgement for a shared project DB. - let desired: SessionSandboxDesiredState | null = null - let owned = false - try { - desired = preferences.getDesired(projectId) - owned = desired != null && (await resolveOwnership(desired.sessionId)) === 'local' - } catch { - // A transient bookkeeping failure after the container is confirmed stopped must not - // abort disposal; the container is already removed, so the applied row is left as-is. - } - if (owned && desired) { + const desired = await readLocallyOwnedDesired() + if (desired) { writeApplied({ version: 1, revision: desired.revision, diff --git a/src/services/session-loop-resolver.ts b/src/services/session-loop-resolver.ts index 9f0a526e8e..c496374dd5 100644 --- a/src/services/session-loop-resolver.ts +++ b/src/services/session-loop-resolver.ts @@ -1,6 +1,7 @@ import type { Logger } from '../types' import type { LoopService } from '../loop/service' import { resolve } from 'path' +import { findSessionAncestor } from '../utils/session-ancestry' export interface SessionLoopResolverDeps { loop: { @@ -21,14 +22,6 @@ export interface ResolvedLoop { workspaceId?: string } -/** - * Maximum number of ancestor hops to walk when resolving a session to its loop. - * Sub-agents can spawn further sub-agents (e.g. a post-action `pr-review` skill - * launching change-agents), producing a chain several levels deep. The cap plus - * the cycle guard bound the work and prevent runaway lookups. - */ -const MAX_PARENT_DEPTH = 10 - export function createSessionLoopResolver(deps: SessionLoopResolverDeps): { resolveActiveLoopForSession(sessionId: string): Promise } { @@ -48,13 +41,8 @@ export function createSessionLoopResolver(deps: SessionLoopResolverDeps): { // session at the top of their chain. The immediate parent of such a // session is itself a sub-agent with no loop name, so a single hop is not // enough. - const seen = new Set([sessionId]) let firstParentId: string | null = null - let current = sessionId - for (let depth = 0; depth < MAX_PARENT_DEPTH; depth++) { - const parentId = await deps.getParentSessionId(current) - if (!parentId || seen.has(parentId)) break - seen.add(parentId) + const ancestorState = await findSessionAncestor(sessionId, deps.getParentSessionId, (parentId, depth) => { if (depth === 0) firstParentId = parentId deps.logger.debug( @@ -67,9 +55,9 @@ export function createSessionLoopResolver(deps: SessionLoopResolverDeps): { deps.logger.log(`[session-resolver] session=${sessionId} resolved via ancestor=${parentId} depth=${depth} loop=${parentState.loopName}`) return parentState } - - current = parentId - } + return null + }) + if (ancestorState) return ancestorState if (firstParentId && deps.getSessionDirectory) { const dir = await deps.getSessionDirectory(sessionId) diff --git a/src/services/unified-sandbox-resolver.ts b/src/services/unified-sandbox-resolver.ts index e7da45f76c..008611cc23 100644 --- a/src/services/unified-sandbox-resolver.ts +++ b/src/services/unified-sandbox-resolver.ts @@ -21,7 +21,7 @@ function unavailableLoopError(loopName: string): Error { * Bounded revalidation retries. After an asynchronous loop sandbox restoration a loop may have * terminated, changed mode, or been replaced; loop membership is re-checked and re-routed up to * this many times so a stale loop context is never returned. A loop that keeps changing identity - * past this cap falls back to the most recently resolved loop context rather than looping forever. + * past this cap fails closed instead of returning a potentially stale loop context. */ const MAX_REVALIDATION_RETRIES = 4 diff --git a/src/storage/index.ts b/src/storage/index.ts index 48b4cebd1e..db86d03264 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -25,4 +25,4 @@ export { createFeatureGroupsRepo } from './repos/feature-groups-repo' export type { FeatureGroupRow, GroupFeatureRow } from './repos/feature-groups-repo' export { createSessionSandboxPreferencesRepo, SESSION_SANDBOX_DESIRED_KEY, SESSION_SANDBOX_APPLIED_KEY, SESSION_SANDBOX_CONTROLLER_KEY } from './repos/session-sandbox-preferences-repo' -export type { SessionSandboxDesiredState, SessionSandboxAppliedState, SessionSandboxControllerState, SessionSandboxPreferencesRepo } from './repos/session-sandbox-preferences-repo' +export type { SessionSandboxDesiredState, SessionSandboxAppliedState, SessionSandboxPreferencesRepo } from './repos/session-sandbox-preferences-repo' diff --git a/src/storage/repos/session-sandbox-preferences-repo.ts b/src/storage/repos/session-sandbox-preferences-repo.ts index dbb8e3f60b..3e3f3ad1e2 100644 --- a/src/storage/repos/session-sandbox-preferences-repo.ts +++ b/src/storage/repos/session-sandbox-preferences-repo.ts @@ -100,12 +100,7 @@ interface PreferenceRow { } export function createSessionSandboxPreferencesRepo(db: Database): SessionSandboxPreferencesRepo { - const getDesiredStmt = db.prepare(` - SELECT data FROM tui_preferences - WHERE project_id = ? AND key = ? - `) - - const getAppliedStmt = db.prepare(` + const getStmt = db.prepare(` SELECT data FROM tui_preferences WHERE project_id = ? AND key = ? `) @@ -121,40 +116,26 @@ export function createSessionSandboxPreferencesRepo(db: Database): SessionSandbo const now = () => Date.now() - function readDesired(projectId: string): SessionSandboxDesiredState | null { - const row = getDesiredStmt.get(projectId, SESSION_SANDBOX_DESIRED_KEY) as PreferenceRow | null + function readState(projectId: string, key: string, parse: (value: unknown) => T | null): T | null { + const row = getStmt.get(projectId, key) as PreferenceRow | null if (!row) return null - let parsed: unknown try { - parsed = JSON.parse(row.data) + return parse(JSON.parse(row.data)) } catch { return null } - return parseDesired(parsed) + } + + function readDesired(projectId: string): SessionSandboxDesiredState | null { + return readState(projectId, SESSION_SANDBOX_DESIRED_KEY, parseDesired) } function readApplied(projectId: string): SessionSandboxAppliedState | null { - const row = getAppliedStmt.get(projectId, SESSION_SANDBOX_APPLIED_KEY) as PreferenceRow | null - if (!row) return null - let parsed: unknown - try { - parsed = JSON.parse(row.data) - } catch { - return null - } - return parseApplied(parsed) + return readState(projectId, SESSION_SANDBOX_APPLIED_KEY, parseApplied) } function readControllerState(projectId: string): SessionSandboxControllerState | null { - const row = getAppliedStmt.get(projectId, SESSION_SANDBOX_CONTROLLER_KEY) as PreferenceRow | null - if (!row) return null - let parsed: unknown - try { - parsed = JSON.parse(row.data) - } catch { - return null - } - return parseControllerState(parsed) + return readState(projectId, SESSION_SANDBOX_CONTROLLER_KEY, parseControllerState) } return { diff --git a/src/tui.tsx b/src/tui.tsx index d547bd8f4b..18c8052976 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -15,14 +15,14 @@ import { tmpdir } from 'os' import { existsSync } from 'fs' import { resolveLoopPermissionOptions } from './constants/loop' import { emitLoopPermissionConfigWarnings } from './utils/loop-permission-warnings' -import { connectForgeProject, resolveTuiProjectId, type ForgeProjectClient } from './utils/tui-client' +import { connectForgeProject, resolveTuiProjectIdOnce, type ForgeProjectClient } from './utils/tui-client' import { ExecutePlanPanel, type ExecutePlanPanelProps } from './tui/execute-plan-panel' import { awaitSessionSandboxState, beginSessionSandboxStateRequest, + deriveSandboxPollDelayMs, deriveSessionSandboxDisplayStatus, hostSandboxToggleBlocked, - isSessionSandboxPreferenceSettled, readSessionSandboxPreference, } from './tui/session-sandbox-store' import type { SessionSandboxPreference } from './tui/session-sandbox-store' @@ -69,12 +69,18 @@ function SandboxLoadingSpinner(props: { api: TuiPluginApi }) { function SandboxStatusText(props: { api: TuiPluginApi; preference: () => SessionSandboxPreference | null; sessionId?: string }) { const theme = () => props.api.theme.current const status = createMemo(() => deriveSessionSandboxDisplayStatus(props.preference(), props.sessionId)) + const statusColor = () => { + const current = status() + if (current === 'enabled') return theme().secondary + if (current === 'failed') return theme().error + return theme().textMuted + } // Secondary while the sandbox is actually acknowledged ON, so an active sandbox stands out // against the muted status line instead of reading as ordinary chrome. return ( · SBX {status()}} + fallback={· SBX {status()}} > · SBX @@ -342,7 +348,7 @@ const tui: TuiPlugin = async (api) => { let disposed = false let retryTimer: ReturnType | null = null let sandboxPollTimer: ReturnType | null = null - let sandboxInitTimer: ReturnType | null = null + let toggleWaiterController: AbortController | null = null api.lifecycle.onDispose(() => { disposed = true if (retryTimer) { @@ -353,16 +359,46 @@ const tui: TuiPlugin = async (api) => { clearTimeout(sandboxPollTimer) sandboxPollTimer = null } - if (sandboxInitTimer) { - clearTimeout(sandboxInitTimer) - sandboxInitTimer = null + if (toggleWaiterController) { + toggleWaiterController.abort() + toggleWaiterController = null } }) + const sleepAbortable = (ms: number, signal: AbortSignal): Promise => + new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + const onAbort = (): void => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal.addEventListener('abort', onAbort, { once: true }) + }) + const [sandboxProjectId, setSandboxProjectId] = createSignal(null) const [sandboxPreference, setSandboxPreference] = createSignal(null) let sandboxInitStarted = false + const preferenceFieldsEqual = ( + a: SessionSandboxPreference | null, + b: SessionSandboxPreference | null, + ): boolean => { + if (a === b) return true + if (!a || !b) return false + return ( + JSON.stringify(a.desired) === JSON.stringify(b.desired) && + JSON.stringify(a.applied) === JSON.stringify(b.applied) && + JSON.stringify(a.controller) === JSON.stringify(b.controller) + ) + } + const refreshSandboxAcknowledgement = (projectId: string): SessionSandboxPreference | null => { if (disposed) return null // When sandboxing is disabled by configuration the server never constructs a @@ -372,7 +408,7 @@ const tui: TuiPlugin = async (api) => { return null } const pref = readSessionSandboxPreference(projectId, forgeDbPath) - if (!disposed) setSandboxPreference(pref) + if (!disposed && !preferenceFieldsEqual(sandboxPreference(), pref)) setSandboxPreference(pref) return pref } @@ -383,20 +419,14 @@ const tui: TuiPlugin = async (api) => { // server applies after the toggle's 15s wait expires — still reaches the // sidebar instead of leaving it stale until restart. const ensureSandboxPolling = (projectId: string): void => { - if (disposed || sandboxPollTimer) return + if (disposed || sandboxPollTimer || !opts.sidebar) return const step = (): void => { - if (disposed) return + if (disposed || !opts.sidebar) return sandboxPollTimer = null if (!isSandboxConfigEnabled(pluginConfig)) return const pref = refreshSandboxAcknowledgement(projectId) if (!pref) return - // Keep polling even after the pair settles at a low frequency so a later server - // acknowledgement — e.g. a periodic restore that fails and flips ON into OFF-with-error — - // is eventually reflected in the sidebar without a toggle or restart. Poll faster while - // unsettled or the local DB is not yet available so a matching acknowledgement is displayed - // promptly. - const settled = !pref.unavailable && isSessionSandboxPreferenceSettled(pref) - sandboxPollTimer = setTimeout(step, settled ? 5000 : 1500) + sandboxPollTimer = setTimeout(step, deriveSandboxPollDelayMs(pref)) } step() } @@ -405,19 +435,13 @@ const tui: TuiPlugin = async (api) => { if (!api.state.ready || sandboxInitStarted) return sandboxInitStarted = true void (async () => { - // Retry transient project discovery with bounded, disposal-aware polling so a temporarily - // failing lookup (or a forge.db that is not yet available) does not permanently leave the - // acknowledged state OFF for this process. Polling below also retries unavailable DB reads. let projectId: string | null = null - while (!disposed && !projectId) { - projectId = await resolveTuiProjectId(api, directory) - if (disposed || projectId) break - await new Promise((resolve) => { - sandboxInitTimer = setTimeout(() => { - sandboxInitTimer = null - resolve() - }, 1500) - }) + let delayMs = 1000 + for (let attempt = 1; attempt <= 4 && !disposed && !projectId; attempt++) { + projectId = await resolveTuiProjectIdOnce(api, directory) + if (disposed || projectId || attempt === 4) break + await sleepAbortable(delayMs, api.lifecycle.signal) + delayMs *= 2 } if (disposed) return setSandboxProjectId(projectId) @@ -445,7 +469,7 @@ const tui: TuiPlugin = async (api) => { // discovery failure does not permanently disable the toggle for this process. let projectId = sandboxProjectId() if (!projectId) { - projectId = await resolveTuiProjectId(api, directory) + projectId = await resolveTuiProjectIdOnce(api, directory) if (disposed) return setSandboxProjectId(projectId) } @@ -473,6 +497,7 @@ const tui: TuiPlugin = async (api) => { const turningOff = desired?.enabled === true && desired.sessionId === sessionId const nextEnabled = !turningOff let revision: string | null = null + let waiterController: AbortController | null = null try { revision = beginSessionSandboxStateRequest(projectId, forgeDbPath, { sessionId, @@ -487,11 +512,16 @@ const tui: TuiPlugin = async (api) => { // Follow this desired revision to its acknowledgement independently of the // command timeout, so a late server apply still reaches the sidebar. ensureSandboxPolling(projectId) + if (toggleWaiterController) toggleWaiterController.abort() + waiterController = new AbortController() + toggleWaiterController = waiterController + const signal = AbortSignal.any([waiterController.signal, api.lifecycle.signal]) const applied = await awaitSessionSandboxState(projectId, forgeDbPath, revision, { timeoutMs: 15_000, pollMs: 250, - signal: api.lifecycle.signal, + signal, }) + if (toggleWaiterController === waiterController) toggleWaiterController = null if (disposed) return // Re-read the authoritative desired/applied pair before publishing state or // success. A superseded acknowledgement (a newer toggle already moved the @@ -506,6 +536,7 @@ const tui: TuiPlugin = async (api) => { }) } } catch (err) { + if (waiterController && toggleWaiterController === waiterController) toggleWaiterController = null if (disposed) return // The failed request may have superseded a prior acknowledged state with a // new desired revision that never got applied. Re-read the authoritative @@ -516,7 +547,12 @@ const tui: TuiPlugin = async (api) => { // false failure long after the latest request succeeded. if (pref?.desired && revision && pref.desired.revision !== revision) return const message = err instanceof Error ? err.message : String(err) - api.ui.toast({ message: `Sandbox toggle failed: ${message}`, variant: 'error', duration: 6000 }) + const guidance = nextEnabled ? 'Toggle off, then on to retry.' : 'Toggle again to retry disabling.' + api.ui.toast({ + message: `Sandbox toggle failed: ${message}. ${guidance}`, + variant: 'error', + duration: 6000, + }) } } diff --git a/src/tui/session-sandbox-store.ts b/src/tui/session-sandbox-store.ts index 64a0f825c2..52ce66d1bd 100644 --- a/src/tui/session-sandbox-store.ts +++ b/src/tui/session-sandbox-store.ts @@ -97,7 +97,13 @@ export function isSessionSandboxPreferenceSettled(pref: SessionSandboxPreference return applied.revision === desired.revision } -export type SessionSandboxDisplayStatus = 'enabled' | 'disabled' | 'loading' +export type SessionSandboxDisplayStatus = 'enabled' | 'disabled' | 'loading' | 'failed' + +export function deriveSandboxPollDelayMs(pref: SessionSandboxPreference): number { + if (pref.unavailable) return 5000 + if (!isSessionSandboxPreferenceSettled(pref)) return 1500 + return pref.desired ? 10_000 : 30_000 +} export function deriveSessionSandboxDisplayStatus( pref: SessionSandboxPreference | null, @@ -111,10 +117,17 @@ export function deriveSessionSandboxDisplayStatus( controller.sessionId === sessionId ) { if (controller.phase === 'loading' && pref.desired?.enabled) return 'loading' - if (controller.phase === 'failed') return 'disabled' + if (controller.phase === 'failed') return 'failed' } const acknowledged = deriveSessionSandboxAcknowledged(pref) if (acknowledged?.sessionId === sessionId) return 'enabled' + if ( + pref.desired?.sessionId === sessionId && + pref.desired.enabled && + isSessionSandboxPreferenceSettled(pref) + ) { + return 'failed' + } if (pref.desired?.sessionId === sessionId && !isSessionSandboxPreferenceSettled(pref)) return 'loading' return 'disabled' } @@ -167,16 +180,6 @@ export function writeSessionSandboxDesired( } } -export interface RequestSessionSandboxStateOptions { - projectId: string - dbPath?: string - sessionId: string - enabled: boolean - timeoutMs: number - pollMs: number - signal?: AbortSignal -} - function createRevision(): string { return randomUUID() } @@ -251,26 +254,3 @@ export async function awaitSessionSandboxState( } throw new Error(`Timed out waiting for sandbox acknowledgement after ${opts.timeoutMs}ms`) } - -/** - * Writes a fresh desired revision and polls the applied row until the matching - * revision arrives. Returns the applied state. Throws on a matching `error`, on - * timeout, or when cancelled via `signal`. Stale applied revisions are ignored. - */ -export async function requestSessionSandboxState( - opts: RequestSessionSandboxStateOptions, -): Promise { - // Check cancellation before writing a new desired revision so an already-aborted request never - // persists desired state the server may still apply. Otherwise a pre-cancelled request would - // reject as cancelled yet leave an orphaned desired row. - if (opts.signal?.aborted) throw new Error('Sandbox state request cancelled') - const revision = beginSessionSandboxStateRequest(opts.projectId, opts.dbPath, { - sessionId: opts.sessionId, - enabled: opts.enabled, - }) - return awaitSessionSandboxState(opts.projectId, opts.dbPath, revision, { - timeoutMs: opts.timeoutMs, - pollMs: opts.pollMs, - signal: opts.signal, - }) -} diff --git a/src/utils/session-ancestry.ts b/src/utils/session-ancestry.ts new file mode 100644 index 0000000000..893059a27a --- /dev/null +++ b/src/utils/session-ancestry.ts @@ -0,0 +1,19 @@ +export const MAX_SESSION_ANCESTOR_DEPTH = 10 + +export async function findSessionAncestor( + sessionId: string, + getParentSessionId: (sessionId: string) => Promise, + match: (ancestorId: string, depth: number) => T | null | Promise, +): Promise { + const seen = new Set([sessionId]) + let current = sessionId + for (let depth = 0; depth < MAX_SESSION_ANCESTOR_DEPTH; depth++) { + const parentId = await getParentSessionId(current) + if (!parentId || seen.has(parentId)) return null + seen.add(parentId) + const result = await match(parentId, depth) + if (result !== null) return result + current = parentId + } + return null +} diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index 9a7db740c1..52751733f0 100644 --- a/src/utils/tui-client.ts +++ b/src/utils/tui-client.ts @@ -440,6 +440,25 @@ export async function resolveTuiProjectId(api: TuiPluginApi, directory?: string) return projectId } +const projectIdFlights = new WeakMap>>() + +export function resolveTuiProjectIdOnce(api: TuiPluginApi, directory?: string): Promise { + const key = directory ?? '' + let flights = projectIdFlights.get(api) + if (!flights) { + flights = new Map() + projectIdFlights.set(api, flights) + } + const existing = flights.get(key) + if (existing) return existing + const flight = resolveTuiProjectId(api, directory).finally(() => { + flights.delete(key) + if (flights.size === 0) projectIdFlights.delete(api) + }) + flights.set(key, flight) + return flight +} + export async function connectForgeProject( api: TuiPluginApi, directory?: string, @@ -454,7 +473,7 @@ export async function connectForgeProject( let projectId: string | null = null try { - projectId = await resolveTuiProjectId(api, directory) + projectId = await resolveTuiProjectIdOnce(api, directory) } catch { projectId = null } diff --git a/test/index/session-lookup.test.ts b/test/index/session-lookup.test.ts index 02b122d137..4a4f9154ad 100644 --- a/test/index/session-lookup.test.ts +++ b/test/index/session-lookup.test.ts @@ -386,4 +386,29 @@ describe('createSessionDirectoryLookup', () => { expect(client.session.get).toHaveBeenCalledTimes(1) }) + + it('negative result is cached for the configured TTL', async () => { + vi.useFakeTimers() + try { + const { client } = createFakeForgeClient({ + session: { get: async () => { throw notFoundErr() } }, + }) + const lookup = createSessionDirectoryLookup({ + client, + directory: '/host', + loop: createMockLoop([]) as any, + negativeTtlMs: 100, + }) + + await expect(lookup('ses-missing')).resolves.toBeNull() + await expect(lookup('ses-missing')).resolves.toBeNull() + expect(client.session.get).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(101) + await expect(lookup('ses-missing')).resolves.toBeNull() + expect(client.session.get).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/test/loop-permission-ruleset.test.ts b/test/loop-permission-ruleset.test.ts index a2685b04d8..21214cd514 100644 --- a/test/loop-permission-ruleset.test.ts +++ b/test/loop-permission-ruleset.test.ts @@ -466,6 +466,37 @@ describe('createLoopPermissionPatcher (session.created path)', () => { expect(mockUpdate).toHaveBeenCalledTimes(1) }) + + test('transient ancestry lookup rejection does not reject onSessionCreated and skips patching', async () => { + const mockGet = vi.fn(async () => ({})) + const mockUpdate = vi.fn(async () => {}) + const logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as Logger + + const hook = createLoopPermissionPatcher({ + client: { session: { get: mockGet, update: mockUpdate } } as any, + sessionLoopResolver: { + resolveActiveLoopForSession: vi.fn(async () => { throw new Error('transient ancestry failure') }), + } as any, + directory: '/repo', + logger, + }) + + await expect( + hook.onSessionCreated({ + event: { + type: 'session.created', + properties: { info: { id: 'child-session', parentID: 'parent-session' } }, + }, + }), + ).resolves.toBeUndefined() + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('ancestry lookup failed'), + expect.any(Error), + ) + expect(mockGet).not.toHaveBeenCalled() + expect(mockUpdate).not.toHaveBeenCalled() + }) }) describe('createLoopPermissionPatcher.ensurePatched (fallback path)', () => { @@ -656,4 +687,28 @@ describe('createLoopPermissionPatcher.ensurePatched (fallback path)', () => { const updateArgs = (mockUpdate as any).mock.calls[0][0] expect(updateArgs.permission).toContainEqual(portableRule) }) + + test('transient ancestry lookup rejection does not reject ensurePatched and skips patching', async () => { + const mockGet = vi.fn(async () => ({})) + const mockUpdate = vi.fn(async () => {}) + const logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as Logger + + const patcher = createLoopPermissionPatcher({ + client: { session: { get: mockGet, update: mockUpdate } } as any, + sessionLoopResolver: { + resolveActiveLoopForSession: vi.fn(async () => { throw new Error('transient ancestry failure') }), + } as any, + directory: '/repo', + logger, + }) + + await expect(patcher.ensurePatched({ sessionID: 'child-session' })).resolves.toBeUndefined() + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('ancestry lookup failed'), + expect.any(Error), + ) + expect(mockGet).not.toHaveBeenCalled() + expect(mockUpdate).not.toHaveBeenCalled() + }) }) diff --git a/test/loop/runtime.test.ts b/test/loop/runtime.test.ts index 40415cee89..6b437ef6d0 100644 --- a/test/loop/runtime.test.ts +++ b/test/loop/runtime.test.ts @@ -2935,6 +2935,35 @@ describe('stall handling terminates with stall timeout when configured cap is re expect(abortCalls.length).toBe(0) }) + test('transient ancestry lookup rejection does not reject retry status handling and leaves loop running', async () => { + const fake = createFakeForgeClient() + const { loop, calls, logs } = createRuntime({ + client: fake.client, + getParentSessionId: async () => { throw new Error('transient ancestry failure') }, + }) + const codingSessionId = 'coding-session-id' + const state = makeState({ sessionId: codingSessionId, phase: 'coding' }) + loopService.setState(state.loopName, state) + + await expect( + loop.tick({ + type: 'session.status', + properties: { + sessionID: 'child-session-id', + status: { type: 'retry', attempt: 1, message: 'You have reached your usage limit', next: 60000 }, + }, + }), + ).resolves.toBeUndefined() + + const afterState = loopService.getActiveState(state.loopName) + expect(afterState).not.toBeNull() + expect(afterState!.active).toBe(true) + + const abortCalls = calls.filter(c => c.method === 'session.abort') + expect(abortCalls.length).toBe(0) + expect(logs.some(l => l.level === 'error' && l.message.includes('ancestry lookup failed'))).toBe(true) + }) + test('persisted assistant error with usage-limit text terminates as provider_limit', async () => { const { client, calls } = createFakeForgeClient({ session: { @@ -4240,6 +4269,34 @@ describe('stall handling terminates with stall timeout when configured cap is re const abortCalls = calls.filter(c => c.method === 'session.abort') expect(abortCalls.length).toBe(0) }) + + test('transient ancestry lookup rejection does not reject session.error handling and leaves loop running', async () => { + const fake = createFakeForgeClient() + const { loop, calls, logs } = createRuntime({ + client: fake.client, + getParentSessionId: async () => { throw new Error('transient ancestry failure') }, + }) + const state = makeState({ sessionId: 'coding-session-id', phase: 'coding' }) + loopService.setState(state.loopName, state) + + await expect( + loop.tick({ + type: 'session.error', + properties: { + sessionID: 'child-session-id', + error: { name: 'ProviderAuthError', data: { message: 'You have reached your usage limit' } }, + }, + }), + ).resolves.toBeUndefined() + + const afterState = loopService.getActiveState(state.loopName) + expect(afterState).not.toBeNull() + expect(afterState!.active).toBe(true) + + const abortCalls = calls.filter(c => c.method === 'session.abort') + expect(abortCalls.length).toBe(0) + expect(logs.some(l => l.level === 'error' && l.message.includes('ancestry lookup failed'))).toBe(true) + }) }) describe('reverse index lifecycle', () => { diff --git a/test/plugin.test.ts b/test/plugin.test.ts index d82564b6a1..c1c7e53e3b 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -22,7 +22,30 @@ function sessionResolvingClient(dir: string) { const m = url.match(/\/session\/([^/?]+)/) if (m) { const sessionID = decodeURIComponent(m[1]) - return new Response(JSON.stringify({ id: sessionID, directory: dir, parentID: null }), { + return new Response(JSON.stringify({ id: sessionID, projectID: TEST_PROJECT_ID, directory: dir, parentID: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return { _client: { getConfig: () => ({ fetch: mockFetch }) } } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function nonResolvingClient() { + const mockFetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === 'string' ? input : (input as Request).url + const m = url.match(/\/session\/([^/?]+)/) + if (m) { + const sessionID = decodeURIComponent(m[1]) + return new Response(JSON.stringify({ id: sessionID, directory: null, parentID: null }), { status: 200, headers: { 'Content-Type': 'application/json' }, }) @@ -659,7 +682,7 @@ describe('createForgePlugin', () => { let releaseFirstLookup!: () => void let lookupCount = 0 const firstLookup = new Promise((resolve) => { - releaseFirstLookup = () => resolve(new Response(JSON.stringify({ id: 'ses-root', directory: testDir, parentID: null }), { + releaseFirstLookup = () => resolve(new Response(JSON.stringify({ id: 'ses-root', projectID: TEST_PROJECT_ID, directory: testDir, parentID: null }), { status: 200, headers: { 'Content-Type': 'application/json' }, })) @@ -671,7 +694,7 @@ describe('createForgePlugin', () => { lookupCount += 1 if (lookupCount === 1) return firstLookup const sessionID = decodeURIComponent(match[1]!) - return new Response(JSON.stringify({ id: sessionID, directory: testDir, parentID: null }), { + return new Response(JSON.stringify({ id: sessionID, projectID: TEST_PROJECT_ID, directory: testDir, parentID: null }), { status: 200, headers: { 'Content-Type': 'application/json' }, }) @@ -926,6 +949,184 @@ describe('createForgePlugin', () => { closeDatabase(db) }) + test('a forge worktree instance initializing first still reconciles the root session via project.worktree', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + sandbox: { mode: 'sbx', enabled: false }, + } + const projectRoot = join(testDir, 'root') + const worktreeDir = join(testDir, 'worktree') + mkdirSync(projectRoot, { recursive: true }) + mkdirSync(worktreeDir, { recursive: true }) + + const setupDb = initializeDatabase(config.dataDir!) + createSessionSandboxPreferencesRepo(setupDb).setDesired(TEST_PROJECT_ID, { + version: 1, + revision: 'r-root', + enabled: true, + sessionId: 'ses-root', + requestedAt: Date.now(), + }) + closeDatabase(setupDb) + + const worktreeHooks = await createForgePlugin(config)({ + directory: worktreeDir, + worktree: projectRoot, + client: sessionResolvingClient(projectRoot) as never, + project: { id: TEST_PROJECT_ID, worktree: projectRoot } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } as unknown as PluginInput) + const rootHooks = await createForgePlugin(config)({ + directory: projectRoot, + worktree: projectRoot, + client: sessionResolvingClient(projectRoot) as never, + project: { id: TEST_PROJECT_ID, worktree: projectRoot } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } as unknown as PluginInput) + currentHooks = rootHooks as { getCleanup?: () => Promise } + const cleanupWorktree = (worktreeHooks as unknown as { getCleanup: () => Promise }).getCleanup + + const shellEnv = rootHooks['shell.env'] as ( + input: { sessionID?: string; cwd?: string }, + output: { env: Record }, + ) => Promise + + await expect(shellEnv({ sessionID: 'ses-root', cwd: projectRoot }, { env: {} })).rejects.toThrow(/unavailable/) + + let db = initializeDatabase(config.dataDir!) + const applied = createSessionSandboxPreferencesRepo(db).getApplied(TEST_PROJECT_ID) + closeDatabase(db) + expect(applied?.revision).toBe('r-root') + expect(applied?.error).toBeTruthy() + + await cleanupWorktree() + }) + + test('a later forge worktree instance cannot leave a root-session toggle pending', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + sandbox: { mode: 'sbx', enabled: false }, + } + const projectRoot = join(testDir, 'root') + const worktreeDir = join(testDir, 'worktree') + mkdirSync(projectRoot, { recursive: true }) + mkdirSync(worktreeDir, { recursive: true }) + + const rootHooks = await createForgePlugin(config)({ + directory: projectRoot, + worktree: projectRoot, + client: sessionResolvingClient(projectRoot) as never, + project: { id: TEST_PROJECT_ID, worktree: projectRoot } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } as unknown as PluginInput) + const worktreeHooks = await createForgePlugin(config)({ + directory: worktreeDir, + worktree: projectRoot, + client: nonResolvingClient() as never, + project: { id: TEST_PROJECT_ID, worktree: projectRoot } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } as unknown as PluginInput) + const cleanupRoot = (rootHooks as unknown as { getCleanup: () => Promise }).getCleanup + const cleanupWorktree = (worktreeHooks as unknown as { getCleanup: () => Promise }).getCleanup + currentHooks = null + + try { + const writerDb = initializeDatabase(config.dataDir!) + createSessionSandboxPreferencesRepo(writerDb).setDesired(TEST_PROJECT_ID, { + version: 1, + revision: 'r-root-after-worktree', + enabled: true, + sessionId: 'ses-root', + requestedAt: Date.now(), + }) + closeDatabase(writerDb) + + await sleep(1200) + + const readerDb = initializeDatabase(config.dataDir!) + const applied = createSessionSandboxPreferencesRepo(readerDb).getApplied(TEST_PROJECT_ID) + closeDatabase(readerDb) + expect(applied?.revision).toBe('r-root-after-worktree') + expect(applied?.error).toBeTruthy() + } finally { + await cleanupWorktree() + await cleanupRoot() + } + }) + + test('after the creating instance is disposed, a survivor processes a new desired revision without closed-db callback failure', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + sandbox: { mode: 'sbx', enabled: false }, + } + + const setupDb = initializeDatabase(config.dataDir!) + createSessionSandboxPreferencesRepo(setupDb).setDesired(TEST_PROJECT_ID, { + version: 1, + revision: 'r1', + enabled: true, + sessionId: 'ses-1', + requestedAt: Date.now(), + }) + closeDatabase(setupDb) + + const hooksA = await createForgePlugin(config)({ + directory: testDir, + worktree: testDir, + client: nonResolvingClient() as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } as unknown as PluginInput) + const hooksB = await createForgePlugin(config)({ + directory: testDir, + worktree: testDir, + client: sessionResolvingClient(testDir) as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } as unknown as PluginInput) + const cleanupA = (hooksA as unknown as { getCleanup: () => Promise }).getCleanup + currentHooks = hooksB as unknown as { getCleanup?: () => Promise } + + const shellEnvB = hooksB['shell.env'] as ( + input: { sessionID?: string; cwd?: string }, + output: { env: Record }, + ) => Promise + await expect(shellEnvB({ sessionID: 'ses-1', cwd: testDir }, { env: {} })).rejects.toThrow(/unavailable/) + + await cleanupA() + + const writerDb = initializeDatabase(config.dataDir!) + createSessionSandboxPreferencesRepo(writerDb).setDesired(TEST_PROJECT_ID, { + version: 1, + revision: 'r2', + enabled: true, + sessionId: 'ses-2', + requestedAt: Date.now(), + }) + closeDatabase(writerDb) + + let applied: { revision: string | null; error: string | null } | null = null + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + const pollDb = initializeDatabase(config.dataDir!) + const row = createSessionSandboxPreferencesRepo(pollDb).getApplied(TEST_PROJECT_ID) + closeDatabase(pollDb) + if (row?.revision === 'r2') { + applied = { revision: row.revision, error: row.error } + break + } + await sleep(50) + } + expect(applied?.revision).toBe('r2') + expect(applied?.error).toBeTruthy() + }) + test('unavailable sandbox runtime acknowledges a requested ON as OFF-with-error and blocks the selected session', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, diff --git a/test/sandbox/manager-custom-mounts.test.ts b/test/sandbox/manager-custom-mounts.test.ts index ae73a8ecdd..5c51fec630 100644 --- a/test/sandbox/manager-custom-mounts.test.ts +++ b/test/sandbox/manager-custom-mounts.test.ts @@ -157,7 +157,7 @@ describe('SandboxManager custom mounts', () => { const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - sourceProjectDir: '/main-project', + sourceProjectDir: '/tmp', customMounts: [{ host: tmpCustom, readonly: false }], } @@ -167,11 +167,11 @@ describe('SandboxManager custom mounts', () => { const active = manager.getActive('test') expect(active?.mounts).toHaveLength(3) expect(active?.mounts[0]).toEqual({ hostDir: '/home/user/worktrees/feature', containerDir: '/home/user/worktrees/feature' }) - expect(active?.mounts[1]).toEqual({ hostDir: '/main-project', containerDir: '/main-project', readOnly: true }) + expect(active?.mounts[1]).toEqual({ hostDir: '/tmp', containerDir: '/tmp', readOnly: true }) expect(active?.mounts[2]).toEqual({ hostDir: resolve(tmpCustom), containerDir: resolve(tmpCustom), readOnly: false }) const workspaces = runtime.getCreateSandboxCalls()[0][1] - expect(workspaces).toContainEqual({ hostDir: '/main-project', readOnly: true }) + expect(workspaces).toContainEqual({ hostDir: '/tmp', readOnly: true }) expect(workspaces).toContainEqual({ hostDir: resolve(tmpCustom), readOnly: false }) }) }) diff --git a/test/sandbox/manager-project-mount.test.ts b/test/sandbox/manager-project-mount.test.ts index 8fa1e8f5d5..6774ee576a 100644 --- a/test/sandbox/manager-project-mount.test.ts +++ b/test/sandbox/manager-project-mount.test.ts @@ -8,7 +8,7 @@ describe('SandboxManager project mount', () => { const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - sourceProjectDir: '/home/user/main-project', + sourceProjectDir: '/tmp', mountProjectReadonly: true, } @@ -18,7 +18,7 @@ describe('SandboxManager project mount', () => { const calls = runtime.getCreateSandboxCalls() expect(calls.length).toBe(1) const workspaces = calls[0][1] - expect(workspaces).toContainEqual({ hostDir: '/home/user/main-project', readOnly: true }) + expect(workspaces).toContainEqual({ hostDir: '/tmp', readOnly: true }) }) test('does not add project mount when mountProjectReadonly is false', async () => { @@ -69,12 +69,28 @@ describe('SandboxManager project mount', () => { expect(workspaces).toHaveLength(1) }) + test('does not pass a stale source project directory to sbx', async () => { + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + const manager = createSandboxManager(runtime, { + image: 'oc-forge-sandbox:latest', + sourceProjectDir: '/definitely/missing/source-project', + mountProjectReadonly: true, + }, logger) + + await manager.start('test', '/home/user/worktrees/feature') + + expect(runtime.getCreateSandboxCalls()[0][1]).toEqual([ + { hostDir: '/home/user/worktrees/feature', readOnly: undefined }, + ]) + }) + test('mounts list on active sandbox includes both worktree and project mounts', async () => { const runtime = createMockSandboxRuntime() const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - sourceProjectDir: '/main-project', + sourceProjectDir: '/tmp', mountProjectReadonly: true, } @@ -84,7 +100,7 @@ describe('SandboxManager project mount', () => { const active = manager.getActive('test') expect(active?.mounts).toHaveLength(2) expect(active?.mounts[0]).toEqual({ hostDir: '/home/user/worktrees/feature', containerDir: '/home/user/worktrees/feature' }) - expect(active?.mounts[1]).toEqual({ hostDir: '/main-project', containerDir: '/main-project', readOnly: true }) + expect(active?.mounts[1]).toEqual({ hostDir: '/tmp', containerDir: '/tmp', readOnly: true }) }) test('mounts list only has worktree mount when project mount is disabled', async () => { @@ -110,7 +126,7 @@ describe('SandboxManager project mount', () => { const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - sourceProjectDir: '/main-project', + sourceProjectDir: '/tmp', mountProjectReadonly: true, } @@ -129,7 +145,7 @@ describe('SandboxManager project mount', () => { const active = manager.getActive('test') expect(active?.mounts).toHaveLength(2) expect(active?.mounts[0]).toEqual({ hostDir: '/home/user/worktrees/feature', containerDir: '/home/user/worktrees/feature' }) - expect(active?.mounts[1]).toEqual({ hostDir: '/main-project', containerDir: '/main-project', readOnly: true }) + expect(active?.mounts[1]).toEqual({ hostDir: '/tmp', containerDir: '/tmp', readOnly: true }) }) test('start() with already-running sandbox does not add project mount when disabled', async () => { @@ -137,7 +153,7 @@ describe('SandboxManager project mount', () => { const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - sourceProjectDir: '/main-project', + sourceProjectDir: '/tmp', mountProjectReadonly: false, } @@ -156,7 +172,7 @@ describe('SandboxManager project mount', () => { const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - sourceProjectDir: '/main-project', + sourceProjectDir: '/tmp', mountProjectReadonly: true, } @@ -172,7 +188,7 @@ describe('SandboxManager project mount', () => { const active = manager.getActive('foo') expect(active?.mounts).toHaveLength(2) expect(active?.mounts[0]).toEqual({ hostDir: '/home/user/worktrees/feature', containerDir: '/home/user/worktrees/feature' }) - expect(active?.mounts[1]).toEqual({ hostDir: '/main-project', containerDir: '/main-project', readOnly: true }) + expect(active?.mounts[1]).toEqual({ hostDir: '/tmp', containerDir: '/tmp', readOnly: true }) }) test('restore() with already-running sandbox does not add project mount when disabled', async () => { diff --git a/test/sandbox/manager-tool-output-mount.test.ts b/test/sandbox/manager-tool-output-mount.test.ts index 7881106003..48134102c2 100644 --- a/test/sandbox/manager-tool-output-mount.test.ts +++ b/test/sandbox/manager-tool-output-mount.test.ts @@ -101,7 +101,7 @@ describe('SandboxManager tool-output mount', () => { const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - sourceProjectDir: '/main-project', + sourceProjectDir: '/tmp', toolOutputDir, } @@ -112,7 +112,7 @@ describe('SandboxManager tool-output mount', () => { const active = manager.getActive('test') expect(active?.mounts).toHaveLength(3) expect(active?.mounts[0]).toEqual({ hostDir: '/home/user/worktrees/feature', containerDir: '/home/user/worktrees/feature' }) - expect(active?.mounts[1]).toEqual({ hostDir: '/main-project', containerDir: '/main-project', readOnly: true }) + expect(active?.mounts[1]).toEqual({ hostDir: '/tmp', containerDir: '/tmp', readOnly: true }) expect(active?.mounts[2]).toEqual({ hostDir: resolved, containerDir: resolved, readOnly: true }) }) }) diff --git a/test/sandbox/session-controller.test.ts b/test/sandbox/session-controller.test.ts index a3bc31974c..b4e89b11df 100644 --- a/test/sandbox/session-controller.test.ts +++ b/test/sandbox/session-controller.test.ts @@ -85,6 +85,7 @@ describe('SessionSandboxController', () => { directory?: string preferences?: SessionSandboxPreferencesRepo getSessionDirectory?: (sid: string) => Promise + getSessionIdentity?: (sid: string) => Promise<{ projectId: string; directory: string } | null> resolveActiveLoopForSession?: (sid: string) => Promise<{ active: boolean; sandbox?: boolean } | null> getParentSessionId?: (sid: string) => Promise } = {}) { @@ -95,6 +96,7 @@ describe('SessionSandboxController', () => { sandboxManager: manager, getParentSessionId: overrides.getParentSessionId ?? (async () => null), ...(overrides.getSessionDirectory ? { getSessionDirectory: overrides.getSessionDirectory } : {}), + ...(overrides.getSessionIdentity ? { getSessionIdentity: overrides.getSessionIdentity } : {}), ...(overrides.resolveActiveLoopForSession ? { resolveActiveLoopForSession: overrides.resolveActiveLoopForSession } : {}), logger, ...(overrides.pollIntervalMs ? { pollIntervalMs: overrides.pollIntervalMs } : {}), @@ -546,6 +548,26 @@ describe('SessionSandboxController', () => { } }) + test('polling backs off after repeated idle reconciliations and resumes when desired state appears', async () => { + vi.useFakeTimers() + try { + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + + await vi.advanceTimersByTimeAsync(80) + repo.setDesired(PROJECT, makeDesired({ revision: 'r-after-idle' })) + await vi.advanceTimersByTimeAsync(199) + expect(manager.ensureRunningCalls).toHaveLength(0) + await vi.advanceTimersByTimeAsync(1) + expect(manager.ensureRunningCalls).toHaveLength(1) + expect(repo.getApplied(PROJECT)?.revision).toBe('r-after-idle') + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + test('dispose is idempotent, stops the container, and acknowledges OFF', async () => { repo.setDesired(PROJECT, makeDesired({ revision: 'r-idem' })) const controller = createController() @@ -1039,6 +1061,122 @@ describe('SessionSandboxController', () => { expect(repo.getApplied(PROJECT)).toBeNull() }) + test('a session in a nested directory under the project root is locally owned', async () => { + const sessionId = 'session-nested-project' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-nested', sessionId })) + const controller = createController({ + getSessionDirectory: async () => `${DIRECTORY}/new-hollywood/nh-app`, + }) + + await controller.start() + + expect(repo.getApplied(PROJECT)).toMatchObject({ + revision: 'r-nested', + enabled: true, + sessionId, + error: null, + }) + await controller.dispose() + }) + + test('an owned secondary checkout is mounted from the selected session directory', async () => { + const sessionId = 'session-secondary-checkout' + const secondaryDirectory = '/abs/path/to/secondary-checkout' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-secondary', sessionId })) + const controller = createController({ + getSessionIdentity: async () => ({ projectId: PROJECT, directory: secondaryDirectory }), + }) + + await controller.start() + + expect(repo.getApplied(PROJECT)).toMatchObject({ + revision: 'r-secondary', + enabled: true, + sessionId, + error: null, + }) + expect(manager.active?.projectDir).toBe(secondaryDirectory) + await controller.dispose() + }) + + test('an unresolved project session is cleaned up and acknowledged off', async () => { + const sessionId = 'session-stale' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-stale', sessionId })) + manager.setActive({ + containerName: 'forge-stale', + projectDir: DIRECTORY, + startedAt: new Date().toISOString(), + mounts: [], + }) + const controller = createController({ getSessionIdentity: async () => null }) + + await controller.start() + + expect(manager.stopCalls).toHaveLength(1) + expect(repo.getApplied(PROJECT)).toMatchObject({ + revision: 'r-stale', + enabled: false, + sessionId, + error: expect.stringContaining('could not be resolved'), + }) + await controller.dispose() + }) + + test('a session from another project is acknowledged off', async () => { + const sessionId = 'session-other-project' + repo.setDesired(PROJECT, makeDesired({ revision: 'r-other-project', sessionId })) + const controller = createController({ + getSessionIdentity: async () => ({ projectId: 'different-project', directory: DIRECTORY }), + }) + + await controller.start() + + expect(manager.ensureRunningCalls).toEqual([]) + expect(repo.getApplied(PROJECT)).toMatchObject({ + revision: 'r-other-project', + enabled: false, + sessionId, + error: expect.stringContaining('different project'), + }) + await controller.dispose() + }) + + test('switching selected sessions recreates the sandbox with the new checkout', async () => { + const oldSessionId = 'session-old' + const newSessionId = 'session-new' + const newDirectory = '/abs/path/to/new-checkout' + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-old', + enabled: true, + sessionId: oldSessionId, + error: null, + appliedAt: Date.now(), + }) + repo.setDesired(PROJECT, makeDesired({ revision: 'r-new', sessionId: newSessionId })) + manager.setActive({ + containerName: 'forge-old', + projectDir: DIRECTORY, + startedAt: new Date().toISOString(), + mounts: [], + }) + const controller = createController({ + getSessionIdentity: async () => ({ projectId: PROJECT, directory: newDirectory }), + }) + + await controller.start() + + expect(manager.stopCalls).toHaveLength(1) + expect(manager.active?.projectDir).toBe(newDirectory) + expect(repo.getApplied(PROJECT)).toMatchObject({ + revision: 'r-new', + enabled: true, + sessionId: newSessionId, + error: null, + }) + await controller.dispose() + }) + test('an instance whose directory lookup cannot resolve a session does not claim it', async () => { repo.setDesired(PROJECT, makeDesired({ revision: 'r-unresolved', sessionId: 'session-unresolved' })) // The directory-scoped lookup returns null because this instance cannot see the session (e.g. @@ -1852,6 +1990,43 @@ describe('SessionSandboxController', () => { await controller.dispose() }) + test('a failed stop during transfer to an uncertain session keeps it blocked fail-closed', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-a', sessionId: ROOT_SESSION })) + const controller = createController({ + pollIntervalMs: 20, + getSessionDirectory: async (sid) => (sid === ROOT_SESSION ? DIRECTORY : null), + }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + repo.setDesired(PROJECT, makeDesired({ revision: 'r-b', sessionId: 'session-x' })) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transfer removal failed') + manager.active = null + } + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(1) + expect(repo.getApplied(PROJECT)?.revision).toBe('r-a') + await expect( + controller.resolveSandboxForSession('session-x', { throwOnRestoreError: true }), + ).rejects.toThrow(/ownership could not be confirmed/) + + stopFails = false + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(2) + expect(repo.getApplied(PROJECT)?.revision).toBe('r-a') + + await controller.dispose() + expect(manager.stopCalls).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + test('a session lookup that never settles cannot hang controller readiness', async () => { vi.useFakeTimers() try { diff --git a/test/tui/session-sandbox-store.test.ts b/test/tui/session-sandbox-store.test.ts index ccc869bbfc..08aa0424f3 100644 --- a/test/tui/session-sandbox-store.test.ts +++ b/test/tui/session-sandbox-store.test.ts @@ -7,13 +7,13 @@ import { tmpdir } from 'os' import { awaitSessionSandboxState, beginSessionSandboxStateRequest, + deriveSandboxPollDelayMs, deriveSessionSandboxAcknowledged, deriveSessionSandboxDisplayStatus, hostSandboxToggleBlocked, isSessionSandboxPreferenceSettled, readSessionSandboxPreference, writeSessionSandboxDesired, - requestSessionSandboxState, } from '../../src/tui/session-sandbox-store' import { createSessionSandboxPreferencesRepo } from '../../src/storage' import type { SessionSandboxAppliedState, SessionSandboxDesiredState } from '../../src/storage' @@ -373,161 +373,120 @@ describe('session-sandbox-store (TUI bridge)', () => { expect(deriveSessionSandboxDisplayStatus(pref, 'sess-1')).toBe('loading') }) - test('shows disabled after acknowledgement fails or turns the sandbox off', () => { + test('shows failed for a settled desired ON that was applied OFF or with an error', () => { + const off = writeApplied({ revision: 'r1', enabled: false, sessionId: 'sess-1', error: null }) + expect(deriveSessionSandboxDisplayStatus({ desired: desired(), applied: off }, 'sess-1')).toBe('failed') const errored = writeApplied({ revision: 'r1', enabled: false, sessionId: 'sess-1', error: 'unavailable' }) - expect(deriveSessionSandboxDisplayStatus({ desired: desired(), applied: errored }, 'sess-1')).toBe('disabled') + expect(deriveSessionSandboxDisplayStatus({ desired: desired(), applied: errored }, 'sess-1')).toBe('failed') + expect(deriveSessionSandboxDisplayStatus({ desired: desired(), applied: off }, 'sess-other')).toBe('disabled') + }) + + test('shows failed for a matching failed controller state', () => { + const errored = writeApplied({ revision: 'r1', enabled: false, sessionId: 'sess-1', error: 'unavailable' }) + const pref = { + desired: desired(), + applied: errored, + controller: { version: 1 as const, phase: 'failed' as const, revision: 'r1', sessionId: 'sess-1' }, + } + expect(deriveSessionSandboxDisplayStatus(pref, 'sess-1')).toBe('failed') + expect(deriveSessionSandboxDisplayStatus(pref, 'sess-other')).toBe('disabled') + }) + + test('shows disabled for a clean settled OFF and for no persisted state', () => { + const off = writeApplied({ revision: 'r1', enabled: false, sessionId: 'sess-1', error: null }) + expect(deriveSessionSandboxDisplayStatus({ desired: desired({ enabled: false }), applied: off }, 'sess-1')).toBe('disabled') expect(deriveSessionSandboxDisplayStatus(null, 'sess-1')).toBe('disabled') }) }) - describe('requestSessionSandboxState', () => { - test('writes desired and resolves on the matching applied revision', async () => { - const promise = requestSessionSandboxState({ - projectId: PROJECT_A, - dbPath, - sessionId: 'sess-1', - enabled: true, - timeoutMs: 2000, - pollMs: 10, - }) - - const { desired } = readSessionSandboxPreference(PROJECT_A, dbPath) - expect(desired).not.toBeNull() - expect(desired!.enabled).toBe(true) - expect(desired!.sessionId).toBe('sess-1') + describe('deriveSandboxPollDelayMs', () => { + const desired = (overrides: Partial = {}) => ({ + version: 1 as const, + revision: 'r1', + enabled: true, + sessionId: 'sess-1', + requestedAt: 1, + ...overrides, + }) - writeApplied({ revision: desired!.revision, enabled: true, error: null }) + test('polls promptly while a desired revision is pending', () => { + expect(deriveSandboxPollDelayMs({ desired: desired(), applied: null })).toBe(1500) + }) - const applied = await promise - expect(applied.revision).toBe(desired!.revision) - expect(applied.enabled).toBe(true) - expect(applied.error).toBeNull() + test('backs off once the pair settles', () => { + const settled = writeApplied({ revision: 'r1', enabled: true, error: null }) + expect(deriveSandboxPollDelayMs({ desired: desired(), applied: settled })).toBe(10_000) }) - test('ignores a stale applied revision and only resolves on the matching one', async () => { - // A stale ON acknowledgement for a different revision must not falsely resolve. - writeApplied({ revision: 'stale-rev', enabled: true, error: null }) + test('retries an unavailable local DB and backs off when no preference exists', () => { + expect(deriveSandboxPollDelayMs({ desired: null, applied: null, unavailable: true })).toBe(5000) + expect(deriveSandboxPollDelayMs({ desired: null, applied: null })).toBe(30_000) + }) + }) - const promise = requestSessionSandboxState({ - projectId: PROJECT_A, - dbPath, - sessionId: 'sess-1', - enabled: true, - timeoutMs: 2000, - pollMs: 10, - }) + describe('beginSessionSandboxStateRequest + awaitSessionSandboxState', () => { + test('persists the desired revision synchronously and clears a prior ON before the applied row arrives', async () => { + // Acknowledged ON at revision r1. + const on = writeApplied({ revision: 'r1', enabled: true, sessionId: 'sess-1', error: null }) + repo.setDesired(PROJECT_A, { version: 1 as const, revision: 'r1', enabled: true, sessionId: 'sess-1', requestedAt: 1 }) + expect(deriveSessionSandboxAcknowledged(readSessionSandboxPreference(PROJECT_A, dbPath))).toEqual(on) - const { desired } = readSessionSandboxPreference(PROJECT_A, dbPath) - writeApplied({ revision: desired!.revision, enabled: true, error: null }) + // Toggle OFF writes revision r2 synchronously. The authoritative pair now + // has a mismatched revision, so the sidebar must not keep reporting ON even + // though the r2 applied row has not arrived yet. + const revision = beginSessionSandboxStateRequest(PROJECT_A, dbPath, { sessionId: 'sess-1', enabled: false }) + expect(deriveSessionSandboxAcknowledged(readSessionSandboxPreference(PROJECT_A, dbPath))).toBeNull() + // The pending request then resolves once the matching OFF is applied. + const promise = awaitSessionSandboxState(PROJECT_A, dbPath, revision, { timeoutMs: 2000, pollMs: 10 }) + writeApplied({ revision, enabled: false, error: null }) const applied = await promise - expect(applied.revision).toBe(desired!.revision) + expect(applied.revision).toBe(revision) + expect(applied.enabled).toBe(false) }) - test('throws the server error when a matching applied row carries an error', async () => { - const promise = requestSessionSandboxState({ - projectId: PROJECT_A, - dbPath, - sessionId: 'sess-1', - enabled: true, - timeoutMs: 2000, - pollMs: 10, - }) - - const { desired } = readSessionSandboxPreference(PROJECT_A, dbPath) - writeApplied({ revision: desired!.revision, enabled: false, error: 'sbx failed to start' }) + test('ignores stale applied state until the requested revision arrives', async () => { + writeApplied({ revision: 'stale', enabled: true, error: null }) + const revision = beginSessionSandboxStateRequest(PROJECT_A, dbPath, { sessionId: 'sess-1', enabled: true }) + const promise = awaitSessionSandboxState(PROJECT_A, dbPath, revision, { timeoutMs: 2000, pollMs: 10 }) + writeApplied({ revision, enabled: true, error: null }) + await expect(promise).resolves.toMatchObject({ revision, enabled: true }) + }) - await expect(promise).rejects.toThrow('sbx failed to start') + test('rejects matching applied errors including an empty string', async () => { + const revision = beginSessionSandboxStateRequest(PROJECT_A, dbPath, { sessionId: 'sess-1', enabled: true }) + const promise = awaitSessionSandboxState(PROJECT_A, dbPath, revision, { timeoutMs: 2000, pollMs: 10 }) + writeApplied({ revision, enabled: false, error: '' }) + await expect(promise).rejects.toThrow() }) - test('throws on timeout when no matching applied row arrives', async () => { + test('times out when no matching applied revision arrives', async () => { + const revision = beginSessionSandboxStateRequest(PROJECT_A, dbPath, { sessionId: 'sess-1', enabled: true }) await expect( - requestSessionSandboxState({ - projectId: PROJECT_A, - dbPath, - sessionId: 'sess-1', - enabled: true, - timeoutMs: 60, - pollMs: 10, - }), + awaitSessionSandboxState(PROJECT_A, dbPath, revision, { timeoutMs: 60, pollMs: 10 }), ).rejects.toThrow(/Timed out/) }) - test('resolves an acknowledgement that arrives during the final poll sleep', async () => { - // pollMs >= timeoutMs: a single bounded sleep spans the whole window, and the - // acknowledgement lands mid-sleep. The waiter must still read it before declaring timeout. - const promise = requestSessionSandboxState({ - projectId: PROJECT_A, - dbPath, - sessionId: 'sess-1', - enabled: true, - timeoutMs: 100, - pollMs: 10_000, - }) - const { desired } = readSessionSandboxPreference(PROJECT_A, dbPath) - setTimeout(() => { - writeApplied({ revision: desired!.revision, enabled: true, error: null }) - }, 50) - const applied = await promise - expect(applied.revision).toBe(desired!.revision) - expect(applied.enabled).toBe(true) - }) - - test('rejects a matching applied row carrying an empty-string error', async () => { - // `error: ''` is a valid non-null error; it must reject rather than report success. - const promise = requestSessionSandboxState({ - projectId: PROJECT_A, - dbPath, - sessionId: 'sess-1', - enabled: true, - timeoutMs: 2000, - pollMs: 10, - }) - const { desired } = readSessionSandboxPreference(PROJECT_A, dbPath) - writeApplied({ revision: desired!.revision, enabled: false, error: '' }) - await expect(promise).rejects.toThrow() - }) - - test('rejects immediately when the signal is already aborted before any read', async () => { - const controller = new AbortController() - controller.abort() - await expect( - requestSessionSandboxState({ - projectId: PROJECT_A, - dbPath, - sessionId: 'sess-1', - enabled: true, - timeoutMs: 2000, - pollMs: 10, - signal: controller.signal, - }), - ).rejects.toThrow(/cancelled/i) - // A pre-cancelled request must not persist a desired revision the server could still apply. - expect(readSessionSandboxPreference(PROJECT_A, dbPath).desired).toBeNull() + test('reads an acknowledgement that arrives during the final bounded sleep', async () => { + const revision = beginSessionSandboxStateRequest(PROJECT_A, dbPath, { sessionId: 'sess-1', enabled: true }) + const promise = awaitSessionSandboxState(PROJECT_A, dbPath, revision, { timeoutMs: 100, pollMs: 10_000 }) + setTimeout(() => writeApplied({ revision, enabled: true, error: null }), 50) + await expect(promise).resolves.toMatchObject({ revision, enabled: true }) }) - test('caps each poll sleep to the remaining deadline when pollMs exceeds timeoutMs', async () => { + test('caps poll sleep to the remaining timeout', async () => { + const revision = beginSessionSandboxStateRequest(PROJECT_A, dbPath, { sessionId: 'sess-1', enabled: true }) const start = Date.now() await expect( - requestSessionSandboxState({ - projectId: PROJECT_A, - dbPath, - sessionId: 'sess-1', - enabled: true, - timeoutMs: 100, - pollMs: 10_000, - }), + awaitSessionSandboxState(PROJECT_A, dbPath, revision, { timeoutMs: 100, pollMs: 10_000 }), ).rejects.toThrow(/Timed out/) expect(Date.now() - start).toBeLessThan(1000) }) - test('throws when the poll is cancelled via signal', async () => { + test('rejects when polling is cancelled', async () => { + const revision = beginSessionSandboxStateRequest(PROJECT_A, dbPath, { sessionId: 'sess-1', enabled: true }) const controller = new AbortController() - const promise = requestSessionSandboxState({ - projectId: PROJECT_A, - dbPath, - sessionId: 'sess-1', - enabled: true, + const promise = awaitSessionSandboxState(PROJECT_A, dbPath, revision, { timeoutMs: 2000, pollMs: 10, signal: controller.signal, @@ -536,26 +495,4 @@ describe('session-sandbox-store (TUI bridge)', () => { await expect(promise).rejects.toThrow(/cancelled/i) }) }) - - describe('beginSessionSandboxStateRequest + awaitSessionSandboxState', () => { - test('persists the desired revision synchronously and clears a prior ON before the applied row arrives', async () => { - // Acknowledged ON at revision r1. - const on = writeApplied({ revision: 'r1', enabled: true, sessionId: 'sess-1', error: null }) - repo.setDesired(PROJECT_A, { version: 1 as const, revision: 'r1', enabled: true, sessionId: 'sess-1', requestedAt: 1 }) - expect(deriveSessionSandboxAcknowledged(readSessionSandboxPreference(PROJECT_A, dbPath))).toEqual(on) - - // Toggle OFF writes revision r2 synchronously. The authoritative pair now - // has a mismatched revision, so the sidebar must not keep reporting ON even - // though the r2 applied row has not arrived yet. - const revision = beginSessionSandboxStateRequest(PROJECT_A, dbPath, { sessionId: 'sess-1', enabled: false }) - expect(deriveSessionSandboxAcknowledged(readSessionSandboxPreference(PROJECT_A, dbPath))).toBeNull() - - // The pending request then resolves once the matching OFF is applied. - const promise = awaitSessionSandboxState(PROJECT_A, dbPath, revision, { timeoutMs: 2000, pollMs: 10 }) - writeApplied({ revision, enabled: false, error: null }) - const applied = await promise - expect(applied.revision).toBe(revision) - expect(applied.enabled).toBe(false) - }) - }) }) diff --git a/test/tui/tui-client-discovery.test.ts b/test/tui/tui-client-discovery.test.ts new file mode 100644 index 0000000000..fd15be9842 --- /dev/null +++ b/test/tui/tui-client-discovery.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect, vi } from 'vitest' +import type { TuiPluginApi } from '@opencode-ai/plugin/tui' +import { resolveTuiProjectIdOnce } from '../../src/utils/tui-client' + +function createMockApi(overrides?: { current?: () => Promise<{ data: { id: string }; error?: unknown }> }): TuiPluginApi { + return { + state: { + config: { provider: {} }, + path: { directory: '/test/project' }, + }, + client: { + project: { + current: vi.fn(overrides?.current ?? (async () => ({ data: { id: 'proj-1' }, error: undefined }))), + list: vi.fn(async () => ({ data: [], error: undefined })), + }, + } as any, + } as unknown as TuiPluginApi +} + +describe('resolveTuiProjectIdOnce single-flight', () => { + test('shares one in-flight discovery across concurrent calls for the same API', async () => { + const api = createMockApi() + const [a, b, c] = await Promise.all([ + resolveTuiProjectIdOnce(api, '/test/project'), + resolveTuiProjectIdOnce(api, '/test/project'), + resolveTuiProjectIdOnce(api, '/test/project'), + ]) + expect(a).toBe('proj-1') + expect(b).toBe('proj-1') + expect(c).toBe('proj-1') + expect(api.client.project.current).toHaveBeenCalledTimes(1) + }) + + test('runs a fresh discovery for a later call after the flight settles', async () => { + const api = createMockApi() + await resolveTuiProjectIdOnce(api, '/test/project') + await resolveTuiProjectIdOnce(api, '/test/project') + expect(api.client.project.current).toHaveBeenCalledTimes(2) + }) + + test('does not share flights across different directories', async () => { + const api = createMockApi() + await Promise.all([ + resolveTuiProjectIdOnce(api, '/test/a'), + resolveTuiProjectIdOnce(api, '/test/b'), + ]) + expect(api.client.project.current).toHaveBeenCalledTimes(2) + }) + + test('does not cache a null result so a later retry can re-discover', async () => { + let calls = 0 + const api = createMockApi({ + current: async () => { + calls += 1 + if (calls === 1) throw new Error('not ready') + return { data: { id: 'proj-2' }, error: undefined } + }, + }) + expect(await resolveTuiProjectIdOnce(api, '/test/project')).toBeNull() + expect(await resolveTuiProjectIdOnce(api, '/test/project')).toBe('proj-2') + }) +})