From be3ae82962bff96b7caff4cc66424bcef3f41e84 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 25 Jun 2026 16:03:17 +0200 Subject: [PATCH] fix: remember sandbox state per session --- .changeset/remember-sandbox-sessions.md | 7 + .kilo/plans/sandbox-toggle-session-state.md | 292 ++++++++++++++++++ packages/kilo-vscode/src/KiloProvider.ts | 116 ++++++- .../src/agent-manager/AgentManagerProvider.ts | 8 +- .../src/agent-manager/tool-start.ts | 7 +- .../cli-backend/connection-service.ts | 9 + .../src/services/sandbox-preference.ts | 58 ++++ .../kilo-vscode/src/shared/sandbox-session.ts | 31 ++ .../unit/agent-manager-tool-start.test.ts | 7 +- .../unit/kilo-provider-load-messages.test.ts | 181 +++++------ .../prompt-input-connection-guard.test.ts | 11 +- .../tests/unit/sandbox-preference.test.ts | 72 +++++ .../src/components/chat/PromptInput.tsx | 58 +++- .../src/components/chat/prompt-input-utils.ts | 8 + .../src/types/messages/extension-messages.ts | 11 + .../src/types/messages/webview-messages.ts | 15 +- .../opencode/src/kilocode/sandbox/policy.ts | 103 +++--- .../opencode/src/kilocode/sandbox/state.ts | 77 +++++ .../kilocode/server/httpapi/groups/sandbox.ts | 17 +- .../server/httpapi/handlers/sandbox.ts | 12 +- .../test/kilocode/sandbox/session.test.ts | 9 +- .../test/kilocode/sandbox/state.test.ts | 102 ++++-- .../server/httpapi-exercise-scenarios.ts | 4 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 34 +- packages/sdk/js/src/v2/gen/types.gen.ts | 31 ++ 25 files changed, 1078 insertions(+), 202 deletions(-) create mode 100644 .changeset/remember-sandbox-sessions.md create mode 100644 .kilo/plans/sandbox-toggle-session-state.md create mode 100644 packages/kilo-vscode/src/services/sandbox-preference.ts create mode 100644 packages/kilo-vscode/src/shared/sandbox-session.ts create mode 100644 packages/kilo-vscode/tests/unit/sandbox-preference.test.ts create mode 100644 packages/opencode/src/kilocode/sandbox/state.ts diff --git a/.changeset/remember-sandbox-sessions.md b/.changeset/remember-sandbox-sessions.md new file mode 100644 index 0000000000..ec0c8a8833 --- /dev/null +++ b/.changeset/remember-sandbox-sessions.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/sdk": patch +"kilo-code": patch +--- + +Remember sandbox choices per session and start new sessions with the last selected sandbox state. diff --git a/.kilo/plans/sandbox-toggle-session-state.md b/.kilo/plans/sandbox-toggle-session-state.md new file mode 100644 index 0000000000..75d0e49218 --- /dev/null +++ b/.kilo/plans/sandbox-toggle-session-state.md @@ -0,0 +1,292 @@ +# Plan: Persist Sandbox State Per Session + +## Goal + +Make the sandbox toolbar control behave predictably across Agent Manager, the sidebar, and editor tabs: + +- Every existing session remembers its own last sandbox state across tab switches and restarts. +- The most recently selected sandbox state becomes the default for brand-new sessions. +- Changing the default never changes another existing session. +- A fork inherits the source session's sandbox state because it is a continuation, not a fresh session. +- Backend enforcement remains authoritative. The UI must never present sandboxing as active when the backend cannot enforce it. + +Auto Approve scope is intentionally excluded and tracked separately in https://github.com/Kilo-Org/kilocode/issues/11673. + +## Expected State Model + +Use two durable values with different responsibilities: + +1. **Session state**: Store the desired sandbox state in the existing session metadata under a Kilo-owned key such as `kilocode.sandbox`. +2. **New-session default**: Store the most recently selected state in a shared VS Code `globalState` preference. Fall back to `experimental.sandbox` until the user explicitly selects a state. + +The effective backend state is: + +```text +(session metadata value ?? configured default) && sandbox backend is available +``` + +A successful toggle in an existing session updates both that session and the new-session default. It does not update other existing sessions. + +Example: + +```text +Configured default: enabled +Create A -> A enabled +Disable sandbox in A -> A disabled, new-session default disabled +Create B -> B disabled +Enable sandbox in B -> B enabled, new-session default enabled +Switch back to A -> A remains disabled +Create C -> C enabled +Fork A -> fork disabled +Fork B -> fork enabled +``` + +## Behavior Matrix + +| Flow | Required behavior | +|---|---| +| Blank new prompt | Show the sticky new-session default. Toggling changes only the default and must not create an empty backend session. | +| First prompt send | Create the session with the currently displayed default already stored in metadata before any tool can execute. | +| Explicit new local session | Initialize metadata from the sticky default. | +| New Agent Manager worktree session | Initialize metadata from the sticky default in the worktree directory. | +| Existing session load | Read the persisted state from the backend. Use `experimental.sandbox` only for legacy sessions without metadata. | +| Session switch | Fetch the selected session's state and discard stale responses from the previously selected session. | +| Existing session toggle | Persist the selected state in that session, then update the sticky default. Do not alter any other session. | +| Session fork | Copy the source session metadata. Ignore the sticky default. Parent and child become independent after the fork. | +| Continue in Worktree | Preserve the source session state through the existing fork flow. | +| Move or promote a session | Preserve state because the same session is being moved or associated, not created. | +| Session deletion | Delete state with the session row and retain serialization against an in-flight toggle. | +| Backend restart | Reload existing session state from metadata rather than reverting to config. | +| VS Code reload | Reload the sticky default from `globalState` and existing state from backend metadata. | +| Sidebar and editor tabs | Use the same shared default service; session state remains backend-owned. | +| Cloud preview | Keep the control hidden for synthetic `cloud:` sessions. | +| Cloud continuation/import | Preserve imported session metadata. Do not overwrite it with the local new-session default. | +| Unsupported platform | Show the backend reason, disable the control, and never display sandbox as effectively enabled. Preserve desired metadata for portability. | +| Config change | Affect only legacy sessions without explicit metadata and future defaults when no sticky preference exists. Never overwrite explicit session state. | + +## Implementation + +### 1. Persist Session State in Kilo-Owned Metadata + +Add a Kilo-owned metadata helper under `packages/opencode/src/kilocode/sandbox/` that: + +- Validates and reads `{ enabled: boolean, version: number }` from `Session.Info.metadata["kilocode.sandbox"]`. +- Merges updates without replacing unrelated session metadata. +- Treats missing or malformed metadata as absent and falls back safely to `experimental.sandbox`. +- Stores desired state separately from effective availability. + +Update `packages/opencode/src/kilocode/sandbox/policy.ts`: + +- Replace the process-local `overrides` value map with metadata reads and writes. +- Keep per-session locking so concurrent toggles and deletion remain serialized. +- Key durable state by session ID, not by `(directory, session ID)`. +- Persist `version` so stale HTTP or SSE responses can still be rejected after a backend restart. +- Continue publishing the existing session-scoped sandbox change event. +- Continue checking effective state immediately before every tool and MCP execution. + +Use the existing session metadata column and APIs. Do not add a database migration or a new upstream session field. Keep Kilo behavior in `packages/opencode/src/kilocode/`; avoid new shared upstream changes. + +Update the sandbox HTTP handler to persist through the session service so normal session update and synchronization behavior is retained. Update the route description to remove the word `ephemeral`. + +### 2. Expose Sessionless Backend Support + +Add a Kilo-owned `GET /sandbox/support` endpoint returning: + +```ts +{ + available: boolean + reason?: string +} +``` + +A blank prompt has no session ID, so it cannot reliably infer support from a session status endpoint or `process.platform`. Linux support also depends on the Bubblewrap probe. + +Regenerate OpenAPI and `packages/sdk/js/` after adding the endpoint. + +### 3. Add a Shared Sticky Default Service + +Add a small extension service, for example `packages/kilo-vscode/src/services/sandbox-preference.ts`, backed by `ExtensionContext.globalState`. + +The service should: + +- Store a tri-state value: absent, enabled, or disabled. +- Resolve absent state from `experimental.sandbox`. +- Broadcast changes to the sidebar, editor tabs, and Agent Manager providers. +- Serialize writes and expose an awaitable pending update for first-prompt ordering. +- Remain machine-local and not opt into Settings Sync because backend support is machine-specific. + +Rules for updating it: + +- Blank-prompt toggle: update the default only. +- Existing-session toggle: update it only after the backend successfully persists the session state. +- Session load, switch, fork, import, or move: do not update it merely because a session became active. +- Failed or unavailable backend toggle: do not claim a new remembered state. + +### 4. Separate Blank Defaults From Session Status in the Webview Protocol + +Add explicit messages for requesting and updating the blank-prompt default instead of overloading a missing session ID: + +```ts +// webview -> extension +{ type: "requestSandboxDefault" } +{ type: "setSandboxDefault", enabled, requestID, draftID? } + +// extension -> webview +{ + type: "sandboxDefaultStatus" + desired: boolean + enabled: boolean + available: boolean + reason?: string + revision: number + requestID?: string +} +``` + +Update `PromptInput.tsx` so that: + +- A real session renders only matching backend session status. +- A blank or pending prompt renders only the shared default status. +- Switching sessions clears stale state before requesting the selected scope. +- Reconnect requests current support plus either the blank default or active session status. +- The button is disabled while a default or session update is pending. +- Unsupported state shows the backend reason. +- Cloud previews continue hiding the control. + +### 5. Snapshot the Default During Every Fresh Session Creation + +Create one extension helper that merges the resolved default into the session create payload: + +```ts +metadata: { + ...metadata, + "kilocode.sandbox": { + enabled: defaultValue, + version: 0, + }, +} +``` + +Use it for all fresh-session paths: + +- Sidebar and editor-tab first prompt. +- Explicit local session creation. +- Agent Manager pending local tab on first prompt. +- New worktree creation. +- Adding a new session to an existing worktree. +- Agent Manager tool-created local and worktree sessions. +- Imported existing branches/worktrees when they create a genuinely new session. + +Do not use the helper for: + +- `session.fork`. +- Continue in Worktree. +- Moving or promoting an existing session. +- Cloud import/continuation. + +Session creation must wait for any in-flight blank default update. The metadata must be present in the create request so the first prompt cannot execute tools under the wrong state. + +### 6. Preserve Fork Semantics + +The backend session fork already clones metadata. Add sandbox-specific tests to lock in these semantics: + +- A disabled parent creates a disabled fork even when the sticky default is enabled. +- An enabled parent creates an enabled fork even when the sticky default is disabled. +- Toggling the child does not change the parent. +- Toggling the parent does not change the child. +- Continue in Worktree preserves the source value across directory changes. +- Forking or selecting a fork does not itself change the sticky default. + +Legacy sessions without sandbox metadata continue using the configured fallback. Once a user toggles one, it receives explicit durable metadata. + +### 7. Retain Safety and Race Guarantees + +Preserve or strengthen the current guards: + +- Serialize toggle against session deletion. +- Reject stale status by session ID, directory, backend version, and provider revision. +- Do not let a first prompt overtake a pending default update. +- Do not mutate the sticky default if backend persistence fails. +- Do not report effective enabled state when the sandbox backend is unavailable. +- Keep sandbox enforcement independent of Auto Approve and permission responses. + +## Tests + +### Backend + +Update `packages/opencode/test/kilocode/sandbox/state.test.ts` and related Kilo-owned tests to cover: + +- Enabled and disabled values survive backend/database restart. +- Explicit session state overrides config in both directions. +- Legacy and malformed metadata safely fall back to config. +- Unrelated metadata is preserved. +- Version persists and increments. +- Two sessions remain isolated. +- The same session reports the same desired state across directory routing. +- Unsupported backend reports effective disabled without destroying desired state. +- Concurrent toggles remain serialized. +- Toggle versus deletion cannot recreate deleted state. +- Forks inherit state and become independent. + +Add API coverage for the support endpoint and persisted status/toggle responses. + +### VS Code Extension + +Add or update focused unit tests for: + +- Missing sticky preference falls back to config. +- Explicit false overrides config true, and explicit true overrides config false. +- Preference survives construction of a new provider/service. +- Blank toggle does not create a session. +- First send waits for the pending default update and creates exactly one session with matching metadata. +- Existing-session toggle updates that session plus the sticky default, but no other session. +- Switching A to B to A restores each session's backend state. +- Worktree and Agent Manager tool creation include default metadata. +- Fork and Continue in Worktree do not apply the new-session helper. +- Move, promote, and cloud import do not overwrite existing metadata. +- Multiple providers receive sticky default broadcasts while session events remain filtered by tracked session ID. +- Unsupported support response disables the blank control without creating a session or changing the preference. +- Reconnect ignores stale pre-reconnect responses. + +### Manual Test + +1. Start with configured sandbox enabled. Create A, disable it, open a new local tab, and confirm the blank toggle is disabled. +2. Send the first prompt in the new tab and confirm B remains disabled during its first tool execution. +3. Enable sandbox in B, switch back to A, and confirm A remains disabled. Switch to B and confirm enabled. +4. Create C and confirm it starts enabled from the latest selected default. +5. Fork A and B. Confirm each fork inherits its parent, then toggle a fork and verify its parent is unchanged. +6. Repeat creation and switching with an Agent Manager worktree session and Continue in Worktree. +7. Reload VS Code and restart the CLI backend. Confirm A, B, C, and the sticky blank default retain their states. +8. On an unsupported platform or forced unavailable backend, confirm the control is disabled with a reason and tools run without a false sandbox-enabled indication. + +## Verification + +Run the smallest relevant checks first, then the package guards affected by generated API and shared integration points: + +```bash +# From the repository root after changing server endpoints +./script/generate.ts +bun run script/check-opencode-annotations.ts +bun run script/check-opencode-promise-facades.ts + +# From packages/opencode +bun run typecheck +bun test test/kilocode/sandbox/state.test.ts + +# From packages/kilo-vscode +bun run typecheck +bun run lint +bun run test:unit +bun run knip +bun run check-kilocode-change +``` + +Run `bun run script/extract-source-links.ts` only if implementation changes or adds URLs in the guarded packages. Add a patch changeset describing that sandbox choices now persist per session and initialize new sessions from the last selected state. + +## Non-Goals + +- Changing granular permission rules. +- Changing Auto Approve scope or persistence. +- Applying a sandbox toggle retroactively to every open session. +- Storing sandbox state in `.kilo/agent-manager.json`. +- Adding a new database table or modifying shared upstream session schemas. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 118ae7243e..2564ade9fb 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -151,6 +151,7 @@ import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" import { stopSessionProcesses } from "./kilo-provider/background-process" +import { sandboxDefault, sandboxSessionMetadata } from "./shared/sandbox-session" import { buildIndexingSettingsMessage, validIndexingSetting, @@ -159,6 +160,18 @@ import { type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" type ContextMessage = { contextDirectory?: unknown } +type SandboxSupportClient = { + support: ( + parameters: { directory?: string }, + options: { throwOnError: true }, + ) => Promise<{ data: { available: boolean; reason?: string } }> +} + +function sandboxClient(client: KiloClient | null) { + const sandbox = client?.sandbox + return sandbox as (typeof sandbox & SandboxSupportClient) | undefined +} + // Helper to map agent data to the subset of fields sent to the webview const mapAgent = (a: Agent) => ({ name: a.name, @@ -366,6 +379,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private unsubscribeMigrationComplete: (() => void) | null = null // legacy-migration private unsubscribeClearPendingPrompts: (() => void) | null = null private unsubscribeDirectoryProvider: (() => void) | null = null + private unsubscribeSandboxPreference: (() => void) | null = null private initConnectionPromise: Promise | null = null private webviewMessageDisposable: vscode.Disposable | null = null private autocompleteConfigDisposable: vscode.Disposable | null = null @@ -409,6 +423,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) { this.projectDirectory = opts.projectDirectory this.slimEditMetadata = opts.slimEditMetadata ?? true + this.unsubscribeSandboxPreference = this.connectionService.sandboxPreference?.onChange(() => { + if (this.connectionState === "connected") void this.fetchAndSendSandboxDefault() + }) TelemetryProxy.getInstance().setProvider(this) } @@ -1087,6 +1104,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "requestSandboxStatus": await this.fetchAndSendSandboxStatus(message.sessionID) break + case "requestSandboxDefault": + await this.fetchAndSendSandboxDefault() + break + case "setSandboxDefault": + await this.handleSetSandboxDefault(message.enabled, message.requestID) + break case "toggleSandbox": await this.handleToggleSandbox(message) break @@ -1548,8 +1571,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getContextDirectory() + const metadata = await sandboxSessionMetadata(this.connectionService.sandboxPreference, this.client, workspaceDir) const { data: session } = await this.client.session.create( - { directory: workspaceDir, platform: this.opts.platform }, + { directory: workspaceDir, platform: this.opts.platform, metadata }, { throwOnError: true }, ) this.stopCurrentSessionProcesses(session.id) @@ -2470,6 +2494,74 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(getWorkStylePayload()) } + private async fetchAndSendSandboxDefault(requestID?: string): Promise { + const revision = ++this.sandboxRevision + const generation = this.connectionGeneration + const client = this.client + const sandbox = sandboxClient(client) + if (!client || !sandbox || this.connectionState !== "connected") return + try { + const directory = this.getContextDirectory() + const [desired, result] = await Promise.all([ + sandboxDefault(this.connectionService.sandboxPreference, client, directory), + sandbox.support({ directory }, { throwOnError: true }), + ]) + if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client) + return + this.postMessage({ + type: "sandboxDefaultStatus", + desired, + enabled: desired && result.data.available, + available: result.data.available, + reason: result.data.reason, + revision, + requestID, + }) + } catch (error) { + if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client) + return + this.postMessage({ + type: "sandboxDefaultStatus", + desired: false, + enabled: false, + available: false, + reason: getErrorMessage(error) || "Failed to load sandbox default", + revision, + requestID, + }) + } + } + + private async handleSetSandboxDefault(enabled: boolean, requestID: string): Promise { + const client = this.client + const sandbox = sandboxClient(client) + if (!client || !sandbox || this.connectionState !== "connected") { + await this.fetchAndSendSandboxDefault(requestID) + return + } + const directory = this.getContextDirectory() + try { + await this.connectionService.sandboxPreference.set(enabled, async () => { + const { data } = await sandbox.support({ directory }, { throwOnError: true }) + if (!data.available) throw new Error(data.reason ?? "Sandbox backend is unavailable") + }) + await this.fetchAndSendSandboxDefault(requestID) + vscode.window.showInformationMessage( + enabled ? "Sandbox enabled for new sessions" : "Sandbox disabled for new sessions", + ) + } catch (error) { + this.postMessage({ + type: "sandboxDefaultStatus", + desired: this.connectionService.sandboxPreference.resolve(false), + enabled: false, + available: false, + reason: getErrorMessage(error) || "Failed to update sandbox default", + revision: ++this.sandboxRevision, + requestID, + }) + } + } + private postSandboxError(sessionID: string, error: unknown, revision: number, requestID?: string): void { this.postMessage({ type: "sandboxStatusError", @@ -2551,6 +2643,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper key: string, ): Promise { const revision = ++this.sandboxRevision + if (!input.sessionID) { + const error = new Error("Sandbox session is required") + this.postSandboxError("", error, revision, input.requestID) + throw error + } const generation = this.connectionGeneration const client = this.client const sandbox = client?.sandbox @@ -2590,6 +2687,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!sameDirectory(data.directory, this.getWorkspaceDirectory(resolved.sid))) { throw new Error("Session directory changed during sandbox toggle") } + const remembered = await this.connectionService.sandboxPreference + .set(data.enabled) + .then(() => true) + .catch((error) => { + console.error("[Kilo New] Failed to persist sandbox default:", error) + return false + }) this.postMessage({ type: "sandboxStatus", sessionID: resolved.sid, @@ -2597,6 +2701,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ...data, requestID: input.requestID, }) + if (!remembered) { + vscode.window.showWarningMessage( + `Sandbox ${data.enabled ? "enabled" : "disabled"} for this session, but the new-session default could not be saved`, + ) + return + } vscode.window.showInformationMessage(data.enabled ? "Sandbox enabled" : "Sandbox disabled") } catch (error) { if (this.connectionState === "connected" && this.connectionGeneration === generation && this.client === client) { @@ -2725,8 +2835,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const pending = this.sessionCreations.get(key) if (pending) return pending const creation = (async () => { + const metadata = await sandboxSessionMetadata(this.connectionService.sandboxPreference, this.client!, dir) const { data: session } = await this.client!.session.create( - { directory: dir, platform: this.opts.platform }, + { directory: dir, platform: this.opts.platform, metadata }, { throwOnError: true }, ) this.stopCurrentSessionProcesses(session.id) @@ -3841,6 +3952,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.unsubscribeMigrationComplete?.() this.unsubscribeClearPendingPrompts?.() this.unsubscribeDirectoryProvider?.() + this.unsubscribeSandboxPreference?.() this.viewStateDisposable?.dispose() this.visibilityDisposable?.dispose() this.webviewMessageDisposable?.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 3e2b59e592..b5553776e3 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -34,6 +34,7 @@ import { restoreWorktrees } from "./state-recovery" import { createLocalDiff, diffSummary as localDiffSummary } from "./local-diff" import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start" import { stopSessionProcesses } from "../kilo-provider/background-process" +import { sandboxSessionMetadata } from "../shared/sandbox-session" import { startSession } from "./mcp-warmup" import { readTerminalFont, watchTerminalFont } from "./terminal-font" @@ -826,10 +827,11 @@ export class AgentManagerProvider implements Disposable { }) try { + const metadata = await sandboxSessionMetadata(this.connectionService.sandboxPreference, client, worktreePath) const { data: session } = await startSession( client, worktreePath, - () => client.session.create({ directory: worktreePath, platform: PLATFORM }, { throwOnError: true }), + () => client.session.create({ directory: worktreePath, platform: PLATFORM, metadata }, { throwOnError: true }), (...args) => this.log(...args), ) return session @@ -942,6 +944,7 @@ export class AgentManagerProvider implements Disposable { }, setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id), createSessionInWorktree: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id), + sessionMetadata: (client, dir) => sandboxSessionMetadata(this.connectionService.sandboxPreference, client, dir), registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir), notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid), push: () => this.pushState(), @@ -1133,8 +1136,9 @@ export class AgentManagerProvider implements Disposable { let session: Session try { + const metadata = await sandboxSessionMetadata(this.connectionService.sandboxPreference, client, worktree.path) const { data } = await client.session.create( - { directory: worktree.path, platform: PLATFORM }, + { directory: worktree.path, platform: PLATFORM, metadata }, { throwOnError: true }, ) session = data diff --git a/packages/kilo-vscode/src/agent-manager/tool-start.ts b/packages/kilo-vscode/src/agent-manager/tool-start.ts index 5c7fbb51ca..c68f6c71a4 100644 --- a/packages/kilo-vscode/src/agent-manager/tool-start.ts +++ b/packages/kilo-vscode/src/agent-manager/tool-start.ts @@ -45,6 +45,7 @@ export interface ToolDeps { cleanupWorktree: (wid: string, dir: string) => Promise setup: (dir: string, branch?: string, id?: string) => Promise createSessionInWorktree: (dir: string, branch: string, id?: string) => Promise + sessionMetadata: (client: KiloClient, dir: string) => Promise> registerWorktreeSession: (sid: string, dir: string) => void notifyReady: (sid: string, result: CreateWorktreeResult, wid?: string) => void push: () => void @@ -126,7 +127,11 @@ async function local(deps: ToolDeps, client: KiloClient, task: ToolTask, directo return false } const target = wt?.path ?? root - const { data } = await client.session.create({ directory: target, platform: PLATFORM }, { throwOnError: true }) + const metadata = await deps.sessionMetadata(client, target) + const { data } = await client.session.create( + { directory: target, platform: PLATFORM, metadata }, + { throwOnError: true }, + ) const session = data state.addSession(session.id, wt?.id ?? null) if (wt) deps.registerWorktreeSession(session.id, wt.path) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index eff52fb64c..1ccf76026f 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -4,6 +4,7 @@ import { createKiloClient, type KiloClient } from "@kilocode/sdk/v2/client" import { SdkSSEAdapter, type SSEPayload } from "./sdk-sse-adapter" import type { ServerConfig } from "./types" import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" +import { SandboxPreference } from "../sandbox-preference" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" type SSEEventListener = (event: SSEPayload, directory?: string) => void @@ -50,6 +51,7 @@ async function drainNetworkWaits(client: KiloClient, dir: string) { * Multiple KiloProvider instances subscribe to it for SSE events and state changes. */ export class KiloConnectionService { + readonly sandboxPreference: SandboxPreference private readonly serverManager: ServerManager private client: KiloClient | null = null private sseClient: SdkSSEAdapter | null = null @@ -90,6 +92,13 @@ export class KiloConnectionService { private unsubRemote: (() => void) | null = null constructor(context: vscode.ExtensionContext) { + const state = + context.globalState ?? + ({ + get: (_key: string, fallback?: T) => fallback, + update: async () => undefined, + } satisfies Pick) + this.sandboxPreference = new SandboxPreference(state) this.serverManager = new ServerManager(context, (code) => this.handleServerExit(code)) } diff --git a/packages/kilo-vscode/src/services/sandbox-preference.ts b/packages/kilo-vscode/src/services/sandbox-preference.ts new file mode 100644 index 0000000000..6388395f02 --- /dev/null +++ b/packages/kilo-vscode/src/services/sandbox-preference.ts @@ -0,0 +1,58 @@ +import type * as vscode from "vscode" + +const KEY = "kilo.sandbox.newSessionDefault" + +type Listener = (enabled: boolean, revision: number) => void + +type Store = Pick + +export class SandboxPreference { + private value: boolean | undefined + private revision = 0 + private pending = Promise.resolve() + private readonly listeners = new Set() + + constructor(private readonly state: Store) { + this.value = state.get(KEY) + } + + explicit(): boolean | undefined { + return this.value + } + + resolve(fallback: boolean): boolean { + return this.value ?? fallback + } + + getRevision(): number { + return this.revision + } + + wait(): Promise { + return this.pending + } + + set(enabled: boolean, validate?: () => Promise): Promise { + const update = this.pending + .catch(() => undefined) + .then(async () => { + await validate?.() + await this.state.update(KEY, enabled) + this.value = enabled + this.revision += 1 + for (const listener of this.listeners) listener(enabled, this.revision) + }) + this.pending = update + void update + .catch(() => undefined) + .finally(() => { + if (this.pending === update) this.pending = Promise.resolve() + }) + return update + } + + onChange(listener: Listener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } +} diff --git a/packages/kilo-vscode/src/shared/sandbox-session.ts b/packages/kilo-vscode/src/shared/sandbox-session.ts new file mode 100644 index 0000000000..4b689d3253 --- /dev/null +++ b/packages/kilo-vscode/src/shared/sandbox-session.ts @@ -0,0 +1,31 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { SandboxPreference } from "../services/sandbox-preference" + +export const SANDBOX_METADATA_KEY = "kilocode.sandbox" + +export function sandboxMetadata(enabled: boolean, metadata?: Record) { + return { + ...metadata, + [SANDBOX_METADATA_KEY]: { + enabled, + version: 0, + }, + } +} + +export async function sandboxDefault(preference: SandboxPreference | undefined, client: KiloClient, directory: string) { + await preference?.wait() + const explicit = preference?.explicit() + if (explicit !== undefined) return explicit + const { data } = await client.config.get({ directory }, { throwOnError: true }) + return data.experimental?.sandbox === true +} + +export async function sandboxSessionMetadata( + preference: SandboxPreference | undefined, + client: KiloClient, + directory: string, + metadata?: Record, +) { + return sandboxMetadata(await sandboxDefault(preference, client, directory), metadata) +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts index 7eff66077c..2ed2dd5d57 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts @@ -34,6 +34,7 @@ function deps(overrides: Partial = {}): ToolDeps { cleanupWorktree: mock(async () => calls.push("cleanupWorktree")), setup: mock(async () => calls.push("setup")), createSessionInWorktree: mock(async () => session("s-wt")), + sessionMetadata: mock(async () => ({ "kilocode.sandbox": { enabled: true, version: 0 } })), registerWorktreeSession: mock(() => calls.push("registerWorktreeSession")), notifyReady: mock(() => calls.push("notifyReady")), push: mock(() => calls.push("push")), @@ -79,7 +80,11 @@ describe("agent manager tool start", () => { const panel = c.getPanel() expect(panel?.waitForReady).toHaveBeenCalled() expect(client.session.create).toHaveBeenCalledWith( - { directory: "/repo", platform: "agent-manager" }, + { + directory: "/repo", + platform: "agent-manager", + metadata: { "kilocode.sandbox": { enabled: true, version: 0 } }, + }, { throwOnError: true }, ) expect(client.session.promptAsync).toHaveBeenCalledWith( diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 2a6e8717f1..c31d5c9685 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -63,6 +63,7 @@ function createClient(options?: { sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }> abortFailures?: string[] createDeferred?: Deferred<{ data: unknown }> + supportDeferred?: Deferred<{ data: { available: boolean; reason?: string } }> sandboxDeferred?: Deferred<{ data: unknown }> sandboxStarted?: Deferred }) { @@ -117,6 +118,7 @@ function createClient(options?: { }, }, sandbox: { + support: async () => options?.supportDeferred?.promise ?? { data: { available: true } }, toggle: async (params: Record) => { sandboxed.push(params) options?.sandboxStarted?.resolve(undefined) @@ -145,7 +147,25 @@ function createClient(options?: { } function createConnection(client: ReturnType) { + const state = { value: undefined as boolean | undefined, revision: 0, pending: Promise.resolve() } return { + sandboxPreference: { + explicit: () => state.value, + resolve: (fallback: boolean) => state.value ?? fallback, + wait: () => state.pending, + set: (enabled: boolean, validate?: () => Promise) => { + const update = state.pending + .catch(() => undefined) + .then(async () => { + await validate?.() + state.value = enabled + state.revision += 1 + }) + state.pending = update + return update + }, + onChange: () => () => undefined, + }, connect: async () => {}, getClient: () => client, onEventFiltered: () => () => undefined, @@ -187,7 +207,8 @@ type ProviderInternals = { handleAbort: (sid?: string) => Promise handleRevertSession: (sid: string, messageID: string) => Promise handleSendMessage: (text: string, messageID?: string, sessionID?: string, draftID?: string) => Promise - handleToggleSandbox: (input: { sessionID?: string; draftID?: string; requestID: string }) => Promise + handleSetSandboxDefault: (enabled: boolean, requestID: string) => Promise + handleToggleSandbox: (input: { sessionID: string; requestID: string }) => Promise handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise handleDeleteSession: (sid: string) => Promise } @@ -292,31 +313,66 @@ describe("KiloProvider sandbox status", () => { }) describe("KiloProvider sandbox toggle", () => { - it("creates a session before toggling from the empty composer", async () => { + it("remembers a blank composer toggle without creating a session", async () => { const notice = spyOn(vscode.window, "showInformationMessage").mockResolvedValue(undefined) const client = createClient() const { internal, sent } = makeProvider(client) - await internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" }) + await internal.handleSetSandboxDefault(true, "sandbox-1") - expect(client.created).toEqual([expect.objectContaining({ directory: "/repo" })]) - expect(client.sandboxed).toEqual([{ sessionID: "s1", directory: "/repo" }]) - expect(sent.findIndex((message) => (message as { type?: string }).type === "sessionCreated")).toBeLessThan( - sent.findIndex((message) => (message as { type?: string }).type === "sandboxStatus"), - ) + expect(client.created).toHaveLength(0) + expect(client.sandboxed).toHaveLength(0) expect(sent).toContainEqual( expect.objectContaining({ - type: "sandboxStatus", - sessionID: "s1", + type: "sandboxDefaultStatus", requestID: "sandbox-1", + desired: true, enabled: true, }), ) - expect(notice).toHaveBeenCalledTimes(1) - expect(notice).toHaveBeenCalledWith("Sandbox enabled") + expect(notice).toHaveBeenCalledWith("Sandbox enabled for new sessions") notice.mockRestore() }) + it("waits for a blank toggle before creating the first prompt session", async () => { + const support = defer<{ data: { available: boolean } }>() + const client = createClient({ supportDeferred: support }) + const { internal } = makeProvider(client) + internal.gatherEditorContext = async () => ({}) + + const toggle = internal.handleSetSandboxDefault(true, "sandbox-1") + const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1") + await Promise.resolve() + expect(client.created).toHaveLength(0) + + support.resolve({ data: { available: true } }) + await Promise.all([toggle, send]) + expect(client.created).toEqual([ + expect.objectContaining({ metadata: { "kilocode.sandbox": { enabled: true, version: 0 } } }), + ]) + expect(client.prompted).toHaveLength(1) + }) + + it("does not create a first prompt session when the blank toggle fails", async () => { + const log = spyOn(console, "error").mockImplementation(() => {}) + const support = defer<{ data: { available: boolean; reason?: string } }>() + const client = createClient({ supportDeferred: support }) + const { internal, sent } = makeProvider(client) + internal.gatherEditorContext = async () => ({}) + + const toggle = internal.handleSetSandboxDefault(true, "sandbox-1") + const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1") + await Promise.resolve() + expect(client.created).toHaveLength(0) + support.resolve({ data: { available: false, reason: "unsupported" } }) + await Promise.all([toggle, send]) + + expect(client.created).toHaveLength(0) + expect(client.prompted).toHaveLength(0) + expect(sent).toContainEqual(expect.objectContaining({ type: "sendMessageFailed", messageID: "message-1" })) + log.mockRestore() + }) + it("reports the disabled state in a native notification", async () => { const notice = spyOn(vscode.window, "showInformationMessage").mockResolvedValue(undefined) const sandbox = defer<{ data: unknown }>() @@ -333,100 +389,21 @@ describe("KiloProvider sandbox toggle", () => { notice.mockRestore() }) - it("shares session creation and finishes the toggle before a prompt", async () => { - const create = defer<{ data: unknown }>() - const sandbox = defer<{ data: unknown }>() - const started = defer() - const client = createClient({ createDeferred: create, sandboxDeferred: sandbox, sandboxStarted: started }) + it("snapshots the remembered default before sending the first prompt", async () => { + const client = createClient() const { internal } = makeProvider(client) internal.gatherEditorContext = async () => ({}) - const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" }) - await Promise.resolve() - const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1") - await Promise.resolve() + await internal.handleSetSandboxDefault(true, "sandbox-1") + await internal.handleSendMessage("hello", "message-1", undefined, "draft-1") - expect(client.created).toHaveLength(1) - expect(client.prompted).toHaveLength(0) - - create.resolve({ data: mkSession() }) - await started.promise - expect(client.sandboxed).toHaveLength(1) - expect(client.prompted).toHaveLength(0) - - sandbox.resolve({ data: { directory: "/repo", enabled: true, available: true, version: 1 } }) - await Promise.all([toggle, send]) - expect(client.created).toHaveLength(1) - expect(client.prompted).toHaveLength(1) - }) - - it("does not send a queued prompt when the sandbox toggle fails", async () => { - const log = spyOn(console, "error").mockImplementation(() => {}) - const notice = spyOn(vscode.window, "showInformationMessage").mockResolvedValue(undefined) - const sandbox = defer<{ data: unknown }>() - const started = defer() - const client = createClient({ sandboxDeferred: sandbox, sandboxStarted: started }) - const { internal, sent } = makeProvider(client) - internal.gatherEditorContext = async () => ({}) - - const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" }) - await started.promise - const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1") - sandbox.reject(new Error("toggle failed")) - await Promise.all([toggle, send]) - - expect(client.prompted).toHaveLength(0) - expect(sent).toContainEqual(expect.objectContaining({ type: "sandboxStatusError", requestID: "sandbox-1" })) - expect(sent).toContainEqual( - expect.objectContaining({ type: "sendMessageFailed", sessionID: "s1", messageID: "message-1" }), - ) - expect(notice).not.toHaveBeenCalled() - notice.mockRestore() - log.mockRestore() - }) - - it("does not send a queued prompt when the sandbox backend is unavailable", async () => { - const log = spyOn(console, "error").mockImplementation(() => {}) - const notice = spyOn(vscode.window, "showInformationMessage").mockResolvedValue(undefined) - const sandbox = defer<{ data: unknown }>() - const started = defer() - const client = createClient({ sandboxDeferred: sandbox, sandboxStarted: started }) - const { internal, sent } = makeProvider(client) - internal.gatherEditorContext = async () => ({}) - - const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" }) - await started.promise - const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1") - sandbox.resolve({ - data: { directory: "/repo", enabled: false, available: false, reason: "unsupported", version: 0 }, - }) - await Promise.all([toggle, send]) - - expect(client.prompted).toHaveLength(0) - expect(sent).toContainEqual(expect.objectContaining({ type: "sandboxStatusError", message: "unsupported" })) - expect(sent).toContainEqual( - expect.objectContaining({ type: "sendMessageFailed", sessionID: "s1", messageID: "message-1" }), - ) - expect(notice).not.toHaveBeenCalled() - notice.mockRestore() - log.mockRestore() - }) - - it("keeps prompts queued after the draft is promoted", async () => { - const sandbox = defer<{ data: unknown }>() - const started = defer() - const client = createClient({ sandboxDeferred: sandbox, sandboxStarted: started }) - const { internal } = makeProvider(client) - internal.gatherEditorContext = async () => ({}) - - const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" }) - await started.promise - const send = internal.handleSendMessage("hello", "message-1", "s1", "draft-1") - await Promise.resolve() - expect(client.prompted).toHaveLength(0) - - sandbox.resolve({ data: { directory: "/repo", enabled: true, available: true, version: 1 } }) - await Promise.all([toggle, send]) + expect(client.created).toEqual([ + expect.objectContaining({ + directory: "/repo", + metadata: { "kilocode.sandbox": { enabled: true, version: 0 } }, + }), + ]) + expect(client.sandboxed).toHaveLength(0) expect(client.prompted).toHaveLength(1) }) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts index 0450a6996a..6ecdb8ea74 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts @@ -22,7 +22,7 @@ describe("PromptInput connection guard", () => { }) describe("PromptInput sandbox toggle", () => { - it("toggles or creates the runtime session instead of writing config", () => { + it("updates the default for drafts and toggles only existing sessions", () => { const start = src.indexOf("const toggleSandbox = () =>") const end = src.indexOf("let enhanceCounter", start) const toggle = src.slice(start, end) @@ -33,9 +33,11 @@ describe("PromptInput sandbox toggle", () => { expect(toggle).toContain("!sandboxVisible()") expect(toggle).toContain("if (!sessionID) saveDraft(draftKey(), text(), reviewComments(), imageAttach.images())") expect(toggle).toContain('type: "toggleSandbox"') + expect(toggle).toContain('type: "setSandboxDefault"') + expect(toggle).toContain("enabled: !sandboxDefault()!.desired") expect(toggle).toContain("sessionID,") - expect(toggle).toContain("draftID: props.pendingSessionID ?? session.draftSessionID()") expect(toggle).toContain("requestID,") + expect(toggle).not.toContain("draftID:") expect(toggle).toContain("setSandboxTarget(sessionID ?? null)") expect(toggle).not.toContain('type: "updateConfig"') }) @@ -68,8 +70,9 @@ describe("PromptInput sandbox toggle", () => { expect(src).toContain("setSandboxState(state)") expect(src).toContain("message.requestID === sandboxRequest()") expect(src).toContain("const target = untrack(sandboxTarget)") - expect(src).toContain("if (target && target !== sessionID) clearSandboxRequest()") - expect(src).toContain("sandbox()?.enabled ?? (!sandboxID() && config().experimental?.sandbox === true)") + expect(src).toContain("if (target !== undefined && target !== sessionID) clearSandboxRequest()") + expect(src).toContain("sandboxID() ? sandbox()?.enabled : sandboxDefault()?.enabled") + expect(src).toContain('type: "requestSandboxDefault"') expect(src).toContain("aria-pressed={sandboxEnabled()}") expect(src).toContain("!sandboxReady()") expect(src).toContain("if (sandboxRequest() && target === null) return") diff --git a/packages/kilo-vscode/tests/unit/sandbox-preference.test.ts b/packages/kilo-vscode/tests/unit/sandbox-preference.test.ts new file mode 100644 index 0000000000..f388333ade --- /dev/null +++ b/packages/kilo-vscode/tests/unit/sandbox-preference.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test" +import { SandboxPreference } from "../../src/services/sandbox-preference" + +function store(initial?: boolean) { + const values = new Map() + if (initial !== undefined) values.set("kilo.sandbox.newSessionDefault", initial) + return { + get(key: string, fallback?: T) { + return (values.has(key) ? values.get(key) : fallback) as T | undefined + }, + async update(key: string, value: unknown) { + values.set(key, value) + }, + } +} + +describe("SandboxPreference", () => { + it("falls back to config until the user selects a default", () => { + const preference = new SandboxPreference(store()) + expect(preference.resolve(true)).toBe(true) + expect(preference.resolve(false)).toBe(false) + }) + + it("persists explicit enabled and disabled defaults", async () => { + const state = store() + const first = new SandboxPreference(state) + await first.set(true) + expect(new SandboxPreference(state).resolve(false)).toBe(true) + await first.set(false) + expect(new SandboxPreference(state).resolve(true)).toBe(false) + }) + + it("keeps the prior value when persistence fails", async () => { + const state = store(true) + const preference = new SandboxPreference({ + get: state.get, + update: async () => { + throw new Error("storage unavailable") + }, + }) + await expect(preference.set(false)).rejects.toThrow("storage unavailable") + await Promise.resolve() + expect(preference.resolve(false)).toBe(true) + }) + + it("serializes validation and updates in user intent order", async () => { + const preference = new SandboxPreference(store()) + const first = Promise.withResolvers() + const second = Promise.withResolvers() + const firstUpdate = preference.set(true, () => first.promise) + const secondUpdate = preference.set(false, () => second.promise) + + second.resolve() + await Promise.resolve() + expect(preference.explicit()).toBeUndefined() + first.resolve() + await Promise.all([firstUpdate, secondUpdate]) + expect(preference.resolve(true)).toBe(false) + }) + + it("serializes updates and broadcasts revisions", async () => { + const preference = new SandboxPreference(store()) + const events: Array<{ enabled: boolean; revision: number }> = [] + preference.onChange((enabled, revision) => events.push({ enabled, revision })) + await Promise.all([preference.set(true), preference.set(false)]) + expect(preference.resolve(true)).toBe(false) + expect(events).toEqual([ + { enabled: true, revision: 1 }, + { enabled: false, revision: 2 }, + ]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 27e22d195e..f14a858eb6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -44,6 +44,7 @@ import { isPromptBusy, isPathMention, applySandboxState, + type SandboxDefaultState, type SandboxState, } from "./prompt-input-utils" import type { ExtensionMessage, ReviewComment, SendMessageFailedMessage, TextPart } from "../../types/messages" @@ -192,6 +193,7 @@ export const PromptInput: Component = (props) => { const [enhancing, setEnhancing] = createSignal(false) const [autoApprove, setAutoApprove] = createSignal(false) const [sandboxState, setSandboxState] = createSignal() + const [sandboxDefault, setSandboxDefault] = createSignal() const [sandboxRequest, setSandboxRequest] = createSignal() const [sandboxTarget, setSandboxTarget] = createSignal() let sandboxRetry: ReturnType | undefined @@ -208,15 +210,21 @@ export const PromptInput: Component = (props) => { const state = sandboxState() return state?.sessionID === sandboxID() ? state : undefined } - const sandboxEnabled = () => sandbox()?.enabled ?? (!sandboxID() && config().experimental?.sandbox === true) + const sandboxEnabled = () => (sandboxID() ? sandbox()?.enabled : sandboxDefault()?.enabled) ?? false + const sandboxAvailable = () => (sandboxID() ? sandbox()?.available : sandboxDefault()?.available) ?? false + const sandboxReason = () => (sandboxID() ? sandbox()?.reason : sandboxDefault()?.reason) + const sandboxReady = () => (sandboxID() ? sandbox() !== undefined : sandboxDefault() !== undefined) const sandboxNetworkEnabled = () => config().experimental?.sandbox_restrict_network !== false - const sandboxReady = () => !sandboxID() || sandbox() !== undefined const sandboxDisabled = () => - !server.isConnected() || !sandboxReady() || sandbox()?.available === false || sandboxRequest() !== undefined + !server.isConnected() || !sandboxReady() || !sandboxAvailable() || sandboxRequest() !== undefined const requestSandbox = () => { + if (server.connectionState() !== "connected") return const sessionID = sandboxID() - if (!sessionID || server.connectionState() !== "connected") return - vscode.postMessage({ type: "requestSandboxStatus", sessionID }) + if (sessionID) { + vscode.postMessage({ type: "requestSandboxStatus", sessionID }) + return + } + vscode.postMessage({ type: "requestSandboxDefault" }) } const toggleSandbox = () => { const sessionID = sandboxID() @@ -225,10 +233,13 @@ export const PromptInput: Component = (props) => { if (!sessionID) saveDraft(draftKey(), text(), reviewComments(), imageAttach.images()) setSandboxRequest(requestID) setSandboxTarget(sessionID ?? null) + if (!sessionID) { + vscode.postMessage({ type: "setSandboxDefault", enabled: !sandboxDefault()!.desired, requestID }) + return + } vscode.postMessage({ type: "toggleSandbox", sessionID, - draftID: props.pendingSessionID ?? session.draftSessionID(), requestID, agentManagerContext: ctx(), }) @@ -269,14 +280,16 @@ export const PromptInput: Component = (props) => { if (!connected) { clearSandboxRequest() setSandboxState(undefined) + setSandboxDefault(undefined) return } - if (target && target !== sessionID) clearSandboxRequest() + if (target !== undefined && target !== sessionID) clearSandboxRequest() if (!sessionID) { setSandboxState(undefined) + if (sandboxRequest() && target === null) return + requestSandbox() return } - if (sandboxRequest() && target === null) return requestSandbox() }) @@ -512,6 +525,31 @@ export const PromptInput: Component = (props) => { } const handleSandboxMessage = (message: ExtensionMessage) => { + if (message.type === "sandboxDefaultStatus") { + const matching = message.requestID !== undefined && message.requestID === sandboxRequest() + if (sandboxID() && !matching) return false + if (!server.isConnected()) return true + if (matching) clearSandboxRequest() + const current = sandboxDefault() + if (!current || current.revision <= message.revision) { + setSandboxDefault({ + desired: message.desired, + enabled: message.enabled, + available: message.available, + reason: message.reason, + revision: message.revision, + }) + } + if (matching && !message.available) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: message.reason, + }) + } + return true + } + if (message.type === "sandboxStatus") { const matching = message.requestID !== undefined && message.requestID === sandboxRequest() if (message.sessionID !== sandboxID() && !matching) return false @@ -1270,8 +1308,8 @@ export const PromptInput: Component = (props) => { ) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts index 6251efee29..a7c80455b9 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts @@ -1,3 +1,11 @@ +export type SandboxDefaultState = { + desired: boolean + enabled: boolean + available: boolean + reason?: string + revision: number +} + export type SandboxState = { sessionID: string enabled: boolean diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 330da8f633..0f3c88b3d5 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -656,6 +656,16 @@ export interface SandboxStatusMessage { requestID?: string } +export interface SandboxDefaultStatusMessage { + type: "sandboxDefaultStatus" + desired: boolean + enabled: boolean + available: boolean + reason?: string + revision: number + requestID?: string +} + export interface SandboxStatusErrorMessage { type: "sandboxStatusError" sessionID: string @@ -1067,6 +1077,7 @@ export type ExtensionMessage = | AgentManagerKeybindingsMessage | AutoApproveStateMessage | SandboxStatusMessage + | SandboxDefaultStatusMessage | SandboxStatusErrorMessage | AgentManagerMultiVersionProgressMessage | AgentManagerSetSessionModelMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 781df064c8..208548a912 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -929,10 +929,19 @@ export interface RequestSandboxStatusMessage { sessionID: string } +export interface RequestSandboxDefaultMessage { + type: "requestSandboxDefault" +} + +export interface SetSandboxDefaultMessage { + type: "setSandboxDefault" + enabled: boolean + requestID: string +} + export interface ToggleSandboxMessage { type: "toggleSandbox" - sessionID?: string - draftID?: string + sessionID: string requestID: string agentManagerContext?: string contextDirectory?: string @@ -1272,6 +1281,8 @@ export type WebviewMessage = | RequestAutoApproveStateMessage | ToggleAutoApproveMessage | RequestSandboxStatusMessage + | RequestSandboxDefaultMessage + | SetSandboxDefaultMessage | ToggleSandboxMessage | FetchMarketplaceDataMessage | FilterMarketplaceItemsMessage diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index 1dbc9a7c13..dd61d9a133 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -10,14 +10,10 @@ import type { InstanceContext } from "@/project/instance-context" import type { SessionID } from "@/session/schema" import { Changed } from "./event" import * as Network from "./network" +import * as State from "./state" -const overrides = new Map() const locks = new Map() -function key(directory: string, sessionID: SessionID) { - return directory + "\0" + sessionID -} - function locked(sessionID: SessionID, effect: Effect.Effect) { return Effect.acquireUseRelease( Effect.sync(() => { @@ -108,77 +104,78 @@ export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] = } } +export function support(mode: Profile["network"]["mode"] = "deny") { + return backendSupport({ mode, allowedHosts: [] }) +} + +export const configuredSupport = Effect.fn("SandboxPolicy.configuredSupport")(function* () { + const config = yield* Config.Service + const cfg = yield* config.get() + const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny" + return support(mode) +}) + export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) { const config = yield* Config.Service const cfg = yield* config.get() const directory = yield* InstanceState.directory - const override = overrides.get(key(directory, sessionID)) - const enabled = override?.enabled ?? cfg.experimental?.sandbox ?? false + const stored = yield* State.read(sessionID) + const desired = stored?.enabled ?? cfg.experimental?.sandbox ?? false const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny" - const support = backendSupport({ mode, allowedHosts: [] }) + const backend = support(mode) return { directory, - enabled: enabled && support.available, - available: support.available, - reason: support.reason, - version: override?.version ?? 0, + enabled: desired && backend.available, + available: backend.available, + reason: backend.reason, + version: stored?.version ?? 0, } }) -function change(sessionID: SessionID, guard: Effect.Effect) { - return Effect.gen(function* () { - const directory = yield* InstanceState.directory - const id = key(directory, sessionID) - return yield* locked( - sessionID, - Effect.gen(function* () { - yield* guard - const current = yield* status(sessionID) - if (!current.enabled && !current.available) return current - const value = { ...current, enabled: !current.enabled, version: current.version + 1 } - overrides.set(id, { enabled: value.enabled, version: value.version }) - yield* (yield* Bus.Service).publish(Changed, { sessionID, ...value }) - return value - }), - ) - }) +type Store = (value: State.Value) => Effect.Effect + +function change(sessionID: SessionID, guard: Effect.Effect, store: Store) { + return locked( + sessionID, + Effect.gen(function* () { + yield* guard + const current = yield* status(sessionID) + if (!current.enabled && !current.available) return current + const saved = { enabled: !current.enabled, version: current.version + 1 } + yield* store(saved) + const value = { ...current, ...saved } + yield* (yield* Bus.Service).publish(Changed, { sessionID, ...value }) + return value + }), + ) } -export const toggle = Effect.fn("SandboxPolicy.toggle")((sessionID: SessionID) => change(sessionID, Effect.void)) +export const toggle = Effect.fn("SandboxPolicy.toggle")((sessionID: SessionID) => + change(sessionID, Effect.void, (value) => State.write(sessionID, value)), +) -export function toggleGuarded(sessionID: SessionID, guard: Effect.Effect) { - return change(sessionID, guard) +export function toggleGuarded( + sessionID: SessionID, + guard: Effect.Effect, + store: Store, +) { + return change(sessionID, guard, store) } -export const clear = Effect.fn("SandboxPolicy.clear")(function* (sessionID: SessionID) { - yield* retire(sessionID, yield* InstanceState.directory, Effect.void) -}) +export const clear = Effect.fn("SandboxPolicy.clear")((sessionID: SessionID) => + locked(sessionID, State.clear(sessionID)), +) export function retire( sessionID: SessionID, - directory: string, + _directory: string, effect: Effect.Effect, ): Effect.Effect { - return locked( - sessionID, - Effect.gen(function* () { - overrides.delete(key(directory, sessionID)) - return yield* effect - }), - ) + return locked(sessionID, effect) } export function dispose(sessionID: SessionID, effect: Effect.Effect): Effect.Effect { - return locked( - sessionID, - Effect.gen(function* () { - const suffix = "\0" + sessionID - for (const id of overrides.keys()) { - if (id.endsWith(suffix)) overrides.delete(id) - } - return yield* effect - }), - ) + return locked(sessionID, effect) } function execute(sessionID: SessionID, effect: Effect.Effect) { diff --git a/packages/opencode/src/kilocode/sandbox/state.ts b/packages/opencode/src/kilocode/sandbox/state.ts new file mode 100644 index 0000000000..79a98b0744 --- /dev/null +++ b/packages/opencode/src/kilocode/sandbox/state.ts @@ -0,0 +1,77 @@ +import { eq } from "drizzle-orm" +import { Effect } from "effect" +import type { SessionID } from "@/session/schema" +import { SessionTable } from "@/session/session.sql" +import { Database } from "@/storage/db" + +export const key = "kilocode.sandbox" + +export type Value = { + enabled: boolean + version: number +} + +export function parse(metadata: Record | null | undefined): Value | undefined { + const value = metadata?.[key] + if (!value || typeof value !== "object" || Array.isArray(value)) return + const enabled = Reflect.get(value, "enabled") + const version = Reflect.get(value, "version") + if (typeof enabled !== "boolean" || !Number.isInteger(version) || (version as number) < 0) return + return { enabled, version: version as number } +} + +export function merge(metadata: Record | null | undefined, value: Value) { + return { ...metadata, [key]: value } +} + +export function remove(metadata: Record | null | undefined) { + if (!metadata || !(key in metadata)) return metadata + const next = { ...metadata } + delete next[key] + return next +} + +export const read = Effect.fn("SandboxState.read")((sessionID: SessionID) => + Effect.sync(() => + Database.use((db) => + parse( + db.select({ metadata: SessionTable.metadata }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get() + ?.metadata, + ), + ), + ), +) + +export const write = Effect.fn("SandboxState.write")((sessionID: SessionID, value: Value) => + Effect.sync(() => + Database.use((db) => { + const row = db + .select({ metadata: SessionTable.metadata }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + if (!row) return + db.update(SessionTable) + .set({ metadata: merge(row.metadata, value), time_updated: Date.now() }) + .where(eq(SessionTable.id, sessionID)) + .run() + }), + ), +) + +export const clear = Effect.fn("SandboxState.clear")((sessionID: SessionID) => + Effect.sync(() => + Database.use((db) => { + const row = db + .select({ metadata: SessionTable.metadata }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + if (!row) return + db.update(SessionTable) + .set({ metadata: remove(row.metadata), time_updated: Date.now() }) + .where(eq(SessionTable.id, sessionID)) + .run() + }), + ), +) diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/sandbox.ts b/packages/opencode/src/kilocode/server/httpapi/groups/sandbox.ts index 9a0571c899..dfe830388e 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/sandbox.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/sandbox.ts @@ -20,10 +20,25 @@ export const SandboxStatus = Schema.Struct({ version: Schema.Int, }) +export const SandboxSupport = Schema.Struct({ + available: Schema.Boolean, + reason: Schema.optional(Schema.String), +}) + export const SandboxApi = HttpApi.make("sandbox") .add( HttpApiGroup.make("sandbox") .add( + HttpApiEndpoint.get("support", "/sandbox/support", { + query: WorkspaceRoutingQuery, + success: described(SandboxSupport, "Sandbox backend support"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "sandbox.support", + summary: "Get sandbox backend support", + description: "Get sandbox backend availability without creating a session.", + }), + ), HttpApiEndpoint.get("status", root, { params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, @@ -45,7 +60,7 @@ export const SandboxApi = HttpApi.make("sandbox") OpenApi.annotations({ identifier: "sandbox.toggle", summary: "Toggle session sandbox", - description: "Toggle the ephemeral sandbox override for one session.", + description: "Toggle and persist the sandbox state for one session.", }), ), ) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts index dc756f2fdc..a606c032f8 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts @@ -1,6 +1,7 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as SandboxPolicy from "@/kilocode/sandbox/policy" +import * as SandboxState from "@/kilocode/sandbox/state" import { Session } from "@/session/session" import type { SessionID } from "@/session/schema" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" @@ -11,11 +12,20 @@ export const sandboxHandlers = HttpApiBuilder.group(InstanceHttpApi, "sandbox", const session = yield* Session.Service const exists = (sessionID: SessionID) => SessionError.mapStorageNotFound(session.get(sessionID)) return handlers + .handle("support", () => SandboxPolicy.configuredSupport()) .handle("status", (ctx: { params: { sessionID: SessionID } }) => exists(ctx.params.sessionID).pipe(Effect.andThen(SandboxPolicy.status(ctx.params.sessionID))), ) .handle("toggle", (ctx: { params: { sessionID: SessionID } }) => - SandboxPolicy.toggleGuarded(ctx.params.sessionID, exists(ctx.params.sessionID)), + SandboxPolicy.toggleGuarded(ctx.params.sessionID, exists(ctx.params.sessionID), (value) => + Effect.gen(function* () { + const info = yield* exists(ctx.params.sessionID) + yield* session.setMetadata({ + sessionID: info.id, + metadata: SandboxState.merge(info.metadata, value), + }) + }), + ), ) }), ) diff --git a/packages/opencode/test/kilocode/sandbox/session.test.ts b/packages/opencode/test/kilocode/sandbox/session.test.ts index d36f239955..a41008d570 100644 --- a/packages/opencode/test/kilocode/sandbox/session.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session.test.ts @@ -27,8 +27,8 @@ const it = testEffect( ), ) -describe("sandbox session cleanup", () => { - it.live("clears every directory override when removing outside instance context", () => +describe("sandbox session persistence", () => { + it.live("uses one persisted state across request directories", () => Effect.gen(function* () { const session = yield* Session.Service const dir = yield* tmpdirScoped({ git: true }) @@ -41,9 +41,10 @@ describe("sandbox session cleanup", () => { } yield* provideInstance(dir)(SandboxPolicy.toggle(info.id)) - yield* provideInstance(worktree)(SandboxPolicy.toggle(info.id)) - expect((yield* provideInstance(dir)(SandboxPolicy.status(info.id))).enabled).toBe(true) expect((yield* provideInstance(worktree)(SandboxPolicy.status(info.id))).enabled).toBe(true) + yield* provideInstance(worktree)(SandboxPolicy.toggle(info.id)) + expect((yield* provideInstance(dir)(SandboxPolicy.status(info.id))).enabled).toBe(false) + expect((yield* provideInstance(worktree)(SandboxPolicy.status(info.id))).enabled).toBe(false) yield* session.remove(info.id) expect((yield* provideInstance(dir)(SandboxPolicy.status(info.id))).enabled).toBe(false) expect((yield* provideInstance(worktree)(SandboxPolicy.status(info.id))).enabled).toBe(false) diff --git a/packages/opencode/test/kilocode/sandbox/state.test.ts b/packages/opencode/test/kilocode/sandbox/state.test.ts index 6bd3c7cbf2..05e0821219 100644 --- a/packages/opencode/test/kilocode/sandbox/state.test.ts +++ b/packages/opencode/test/kilocode/sandbox/state.test.ts @@ -5,18 +5,41 @@ import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { enabled as sandboxed } from "@kilocode/sandbox" +import { BackgroundJob } from "@/background/job" import { Bus } from "@/bus" import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" import * as Network from "@/kilocode/sandbox/network" import * as SandboxPolicy from "@/kilocode/sandbox/policy" +import * as SandboxState from "@/kilocode/sandbox/state" +import { Session } from "@/session/session" import { SessionID } from "@/session/schema" +import { Storage } from "@/storage/storage" +import { SyncEvent } from "@/sync" import { TestInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" -const it = testEffect(Layer.mergeAll(Bus.layer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + Layer.mergeAll( + Session.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), + Layer.provide(BackgroundJob.defaultLayer), + ), + Bus.layer, + Config.defaultLayer, + CrossSpawnSpawner.defaultLayer, + ), +) const linux = process.platform === "linux" ? test : test.skip const tool = Network.builtin({ id: "read" }) +const create = Effect.fn("SandboxTest.create")(function* (metadata?: Record) { + return (yield* (yield* Session.Service).create({ title: "sandbox-test", metadata })).id +}) + function execute(sessionID: SessionID, effect: Effect.Effect) { return SandboxPolicy.executeTool(sessionID, tool, effect) } @@ -65,10 +88,10 @@ linux("reports configured network namespace availability", async () => { }) it.instance( - "uses config as the default without persisting session toggles", + "persists session toggles without changing config", () => Effect.gen(function* () { - const id = SessionID.make("ses_sandbox_config") + const id = yield* create() const initial = yield* SandboxPolicy.status(id) expect(initial.enabled).toBe(initial.available) expect(initial.version).toBe(0) @@ -77,6 +100,10 @@ it.instance( const disabled = yield* SandboxPolicy.toggle(id) expect(disabled.enabled).toBe(false) expect(disabled.version).toBe(1) + expect(SandboxState.parse((yield* (yield* Session.Service).get(id)).metadata)).toEqual({ + enabled: false, + version: 1, + }) expect((yield* (yield* Config.Service).get()).experimental?.sandbox).toBe(true) yield* SandboxPolicy.clear(id) @@ -85,9 +112,29 @@ it.instance( { config: { experimental: { sandbox: true } } }, ) +it.instance("preserves unrelated metadata through the production persistence callback", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const id = yield* create({ source: "test", nested: { value: 1 } }) + if (!(yield* SandboxPolicy.status(id)).available) return + + yield* SandboxPolicy.toggleGuarded(id, sessions.get(id), (value) => + Effect.gen(function* () { + const info = yield* sessions.get(id) + yield* sessions.setMetadata({ sessionID: id, metadata: SandboxState.merge(info.metadata, value) }) + }), + ) + + const info = yield* sessions.get(id) + expect(info.metadata?.source).toBe("test") + expect(info.metadata?.nested).toEqual({ value: 1 }) + expect(SandboxState.parse(info.metadata)).toEqual({ enabled: true, version: 1 }) + }), +) + it.instance("runs unrestricted when config is off and no override exists", () => Effect.gen(function* () { - const id = SessionID.make("ses_sandbox_default_off") + const id = yield* create() expect((yield* SandboxPolicy.status(id)).enabled).toBe(false) expect(yield* execute(id, sandboxed)).toBe(false) }), @@ -97,7 +144,7 @@ it.instance( "runs sandboxed when config is on and no override exists", () => Effect.gen(function* () { - const id = SessionID.make("ses_sandbox_default_on") + const id = yield* create() const status = yield* SandboxPolicy.status(id) expect(status.enabled).toBe(status.available) expect(yield* execute(id, sandboxed)).toBe(status.available) @@ -109,8 +156,8 @@ it.instance( "overrides config off for only one session", () => Effect.gen(function* () { - const first = SessionID.make("ses_sandbox_override_off") - const second = SessionID.make("ses_sandbox_config_stays_on") + const first = yield* create() + const second = yield* create() if (!(yield* SandboxPolicy.status(first)).available) return expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false) @@ -122,8 +169,8 @@ it.instance( it.instance("overrides config off to sandbox only one session", () => Effect.gen(function* () { - const first = SessionID.make("ses_sandbox_override_on") - const second = SessionID.make("ses_sandbox_default_remains_off") + const first = yield* create() + const second = yield* create() if (!(yield* SandboxPolicy.status(first)).available) return expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(true) @@ -134,8 +181,8 @@ it.instance("overrides config off to sandbox only one session", () => it.instance("isolates concurrent session overrides and clears them", () => Effect.gen(function* () { - const first = SessionID.make("ses_sandbox_first") - const second = SessionID.make("ses_sandbox_second") + const first = yield* create() + const second = yield* create() const support = yield* SandboxPolicy.status(first) if (!support.available) { expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false) @@ -153,9 +200,26 @@ it.instance("isolates concurrent session overrides and clears them", () => }), ) +it.instance("inherits persisted state when forking and isolates later toggles", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const parent = yield* create() + if (!(yield* SandboxPolicy.status(parent)).available) return + + expect((yield* SandboxPolicy.toggle(parent)).enabled).toBe(true) + const child = yield* sessions.fork({ sessionID: parent }) + expect((yield* SandboxPolicy.status(child.id)).enabled).toBe(true) + expect(SandboxState.parse((yield* sessions.get(child.id)).metadata)).toEqual({ enabled: true, version: 1 }) + + expect((yield* SandboxPolicy.toggle(child.id)).enabled).toBe(false) + expect((yield* SandboxPolicy.status(parent)).enabled).toBe(true) + expect((yield* SandboxPolicy.status(child.id)).enabled).toBe(false) + }), +) + it.instance("does not activate an unavailable backend", () => Effect.gen(function* () { - const id = SessionID.make("ses_sandbox_support") + const id = yield* create() const result = yield* SandboxPolicy.toggle(id) if (result.available) return expect(result.enabled).toBe(false) @@ -165,7 +229,7 @@ it.instance("does not activate an unavailable backend", () => it.instance("serializes concurrent toggles for a session", () => Effect.gen(function* () { - const id = SessionID.make("ses_sandbox_concurrent") + const id = yield* create() if (!(yield* SandboxPolicy.status(id)).available) return yield* Effect.all([SandboxPolicy.toggle(id), SandboxPolicy.toggle(id)], { concurrency: "unbounded" }) expect((yield* SandboxPolicy.status(id)).enabled).toBe(false) @@ -175,7 +239,7 @@ it.instance("serializes concurrent toggles for a session", () => it.instance("prevents a queued toggle from restoring a retired override", () => Effect.gen(function* () { const test = yield* TestInstance - const id = SessionID.make("ses_sandbox_retire_race") + const id = yield* create() const entered = yield* Deferred.make() const release = yield* Deferred.make() const removal = yield* SandboxPolicy.retire( @@ -187,7 +251,9 @@ it.instance("prevents a queued toggle from restoring a retired override", () => }), ).pipe(Effect.forkChild) yield* Deferred.await(entered) - const pending = yield* SandboxPolicy.toggleGuarded(id, Effect.fail("deleted")).pipe(Effect.exit, Effect.forkChild) + const pending = yield* SandboxPolicy.toggleGuarded(id, Effect.fail("deleted"), (value) => + SandboxState.write(id, value), + ).pipe(Effect.exit, Effect.forkChild) yield* Deferred.succeed(release, undefined) yield* Fiber.join(removal) expect(Exit.isFailure(yield* Fiber.join(pending))).toBe(true) @@ -197,8 +263,8 @@ it.instance("prevents a queued toggle from restoring a retired override", () => it.instance("uses nested session state instead of inheriting a parent profile", () => Effect.gen(function* () { - const parent = SessionID.make("ses_sandbox_parent") - const child = SessionID.make("ses_sandbox_child") + const parent = yield* create() + const child = yield* create() if (!(yield* SandboxPolicy.status(parent)).available) return yield* SandboxPolicy.toggle(parent) expect(yield* execute(parent, execute(child, sandboxed))).toBe(false) @@ -210,7 +276,7 @@ it.instance("enforces writes only while the macOS session override is active", ( Effect.gen(function* () { if (process.platform !== "darwin") return const test = yield* TestInstance - const id = SessionID.make("ses_sandbox_process") + const id = yield* create() if (!(yield* SandboxPolicy.status(id)).available) return const outside = path.join(path.dirname(test.directory), `outside-${path.basename(test.directory)}`) const inside = path.join(test.directory, "allowed.txt") diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index c566e21a50..a5b76c1b80 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -189,6 +189,10 @@ export const kiloScenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, (body) => check(body === true, "missing network reject should remain a no-op success")), + http.protected.get("/sandbox/support", "sandbox.support").json(200, (body) => { + object(body) + check(typeof body.available === "boolean", "sandbox support should report backend availability") + }), http.protected .get("/session/{sessionID}/sandbox", "sandbox.status") .seeded((ctx) => ctx.session({ title: "Sandbox status" })) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6cb4f75f0d..15ba3467d4 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -259,6 +259,8 @@ import type { RemoteStatusResponses, SandboxStatusErrors, SandboxStatusResponses, + SandboxSupportErrors, + SandboxSupportResponses, SandboxToggleErrors, SandboxToggleResponses, SessionAbortErrors, @@ -7625,6 +7627,36 @@ export class Remote extends HeyApiClient { } export class Sandbox extends HeyApiClient { + /** + * Get sandbox backend support + * + * Get sandbox backend availability without creating a session. + */ + public support( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/sandbox/support", + ...options, + ...params, + }) + } + /** * Get session sandbox status * @@ -7660,7 +7692,7 @@ export class Sandbox extends HeyApiClient { /** * Toggle session sandbox * - * Toggle the ephemeral sandbox override for one session. + * Toggle and persist the sandbox state for one session. */ public toggle( parameters: { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3015f68a3e..d792b4096f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -10735,6 +10735,37 @@ export type RemoteStatusResponses = { export type RemoteStatusResponse = RemoteStatusResponses[keyof RemoteStatusResponses] +export type SandboxSupportData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sandbox/support" +} + +export type SandboxSupportErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SandboxSupportError = SandboxSupportErrors[keyof SandboxSupportErrors] + +export type SandboxSupportResponses = { + /** + * Sandbox backend support + */ + 200: { + available: boolean + reason?: string + } +} + +export type SandboxSupportResponse = SandboxSupportResponses[keyof SandboxSupportResponses] + export type SandboxStatusData = { body?: never path: {