mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix: remember sandbox state per session
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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" }))
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user