fix: remember sandbox state per session

This commit is contained in:
marius-kilocode
2026-06-25 16:03:17 +02:00
parent 6e9276d1af
commit be3ae82962
25 changed files with 1078 additions and 202 deletions
+7
View File
@@ -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.
+292
View File
@@ -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.
+114 -2
View File
@@ -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<void> | 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<void> {
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<void> {
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<void> {
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()
@@ -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
@@ -45,6 +45,7 @@ export interface ToolDeps {
cleanupWorktree: (wid: string, dir: string) => Promise<void>
setup: (dir: string, branch?: string, id?: string) => Promise<void>
createSessionInWorktree: (dir: string, branch: string, id?: string) => Promise<Session | null>
sessionMetadata: (client: KiloClient, dir: string) => Promise<Record<string, unknown>>
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)
@@ -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: <T>(_key: string, fallback?: T) => fallback,
update: async () => undefined,
} satisfies Pick<vscode.Memento, "get" | "update">)
this.sandboxPreference = new SandboxPreference(state)
this.serverManager = new ServerManager(context, (code) => this.handleServerExit(code))
}
@@ -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<vscode.Memento, "get" | "update">
export class SandboxPreference {
private value: boolean | undefined
private revision = 0
private pending = Promise.resolve()
private readonly listeners = new Set<Listener>()
constructor(private readonly state: Store) {
this.value = state.get<boolean>(KEY)
}
explicit(): boolean | undefined {
return this.value
}
resolve(fallback: boolean): boolean {
return this.value ?? fallback
}
getRevision(): number {
return this.revision
}
wait(): Promise<void> {
return this.pending
}
set(enabled: boolean, validate?: () => Promise<void>): Promise<void> {
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)
}
}
@@ -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<string, unknown>) {
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<string, unknown>,
) {
return sandboxMetadata(await sandboxDefault(preference, client, directory), metadata)
}
@@ -34,6 +34,7 @@ function deps(overrides: Partial<ToolDeps> = {}): 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(
@@ -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<void>
}) {
@@ -117,6 +118,7 @@ function createClient(options?: {
},
},
sandbox: {
support: async () => options?.supportDeferred?.promise ?? { data: { available: true } },
toggle: async (params: Record<string, unknown>) => {
sandboxed.push(params)
options?.sandboxStarted?.resolve(undefined)
@@ -145,7 +147,25 @@ function createClient(options?: {
}
function createConnection(client: ReturnType<typeof createClient>) {
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<void>) => {
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<void>
handleRevertSession: (sid: string, messageID: string) => Promise<void>
handleSendMessage: (text: string, messageID?: string, sessionID?: string, draftID?: string) => Promise<void>
handleToggleSandbox: (input: { sessionID?: string; draftID?: string; requestID: string }) => Promise<void>
handleSetSandboxDefault: (enabled: boolean, requestID: string) => Promise<void>
handleToggleSandbox: (input: { sessionID: string; requestID: string }) => Promise<void>
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
handleDeleteSession: (sid: string) => Promise<void>
}
@@ -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<void>()
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<void>()
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<void>()
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<void>()
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)
})
})
@@ -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")
@@ -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<string, unknown>()
if (initial !== undefined) values.set("kilo.sandbox.newSessionDefault", initial)
return {
get<T>(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<void>()
const second = Promise.withResolvers<void>()
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 },
])
})
})
@@ -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<PromptInputProps> = (props) => {
const [enhancing, setEnhancing] = createSignal(false)
const [autoApprove, setAutoApprove] = createSignal(false)
const [sandboxState, setSandboxState] = createSignal<SandboxState>()
const [sandboxDefault, setSandboxDefault] = createSignal<SandboxDefaultState>()
const [sandboxRequest, setSandboxRequest] = createSignal<string>()
const [sandboxTarget, setSandboxTarget] = createSignal<string | null>()
let sandboxRetry: ReturnType<typeof setTimeout> | undefined
@@ -208,15 +210,21 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
<Show when={sandboxVisible()}>
<Tooltip
value={
sandbox()?.available === false ? (
(sandbox()?.reason ?? language.t("common.requestFailed"))
!sandboxAvailable() && sandboxReady() ? (
(sandboxReason() ?? language.t("common.requestFailed"))
) : (
<SandboxTooltipContent enabled={sandboxEnabled()} network={sandboxNetworkEnabled()} />
)
@@ -1,3 +1,11 @@
export type SandboxDefaultState = {
desired: boolean
enabled: boolean
available: boolean
reason?: string
revision: number
}
export type SandboxState = {
sessionID: string
enabled: boolean
@@ -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
@@ -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
@@ -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<string, { enabled: boolean; version: number }>()
const locks = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
function key(directory: string, sessionID: SessionID) {
return directory + "\0" + sessionID
}
function locked<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
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<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>) {
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<E, R> = (value: State.Value) => Effect.Effect<unknown, E, R>
function change<GE, GR, SE, SR>(sessionID: SessionID, guard: Effect.Effect<unknown, GE, GR>, store: Store<SE, SR>) {
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<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>) {
return change(sessionID, guard)
export function toggleGuarded<GE, GR, SE, SR>(
sessionID: SessionID,
guard: Effect.Effect<unknown, GE, GR>,
store: Store<SE, SR>,
) {
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<A, E, R>(
sessionID: SessionID,
directory: string,
_directory: string,
effect: Effect.Effect<A, E, R>,
): Effect.Effect<A, E, R> {
return locked(
sessionID,
Effect.gen(function* () {
overrides.delete(key(directory, sessionID))
return yield* effect
}),
)
return locked(sessionID, effect)
}
export function dispose<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
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<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
@@ -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<string, unknown> | 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<string, unknown> | null | undefined, value: Value) {
return { ...metadata, [key]: value }
}
export function remove(metadata: Record<string, unknown> | 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()
}),
),
)
@@ -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.",
}),
),
)
@@ -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),
})
}),
),
)
}),
)
@@ -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)
@@ -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<string, unknown>) {
return (yield* (yield* Session.Service).create({ title: "sandbox-test", metadata })).id
})
function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
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<void>()
const release = yield* Deferred.make<void>()
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")
@@ -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" }))
+33 -1
View File
@@ -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<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<SandboxSupportResponses, SandboxSupportErrors, ThrowOnError>({
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<ThrowOnError extends boolean = false>(
parameters: {
+31
View File
@@ -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: {