fix: preserve sandbox state across session scopes

This commit is contained in:
marius-kilocode
2026-06-25 19:05:32 +02:00
parent be3ae82962
commit 7c65a3313d
15 changed files with 181 additions and 26 deletions
+4 -3
View File
@@ -17,7 +17,7 @@ Auto Approve scope is intentionally excluded and tracked separately in https://g
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.
2. **New-session default**: Store the most recently selected state in the VS Code workspace state. Fall back to `experimental.sandbox` until the user explicitly selects a state.
The effective backend state is:
@@ -53,11 +53,12 @@ Fork B -> fork enabled
| 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. |
| Task child | Copy the parent session's explicit sandbox state, then keep the child independent. |
| 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. |
| VS Code reload | Reload the sticky default from workspace state 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. |
@@ -105,7 +106,7 @@ 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`.
Add a small extension service, for example `packages/kilo-vscode/src/services/sandbox-preference.ts`, backed by `ExtensionContext.workspaceState`.
The service should:
+10 -8
View File
@@ -1105,10 +1105,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await this.fetchAndSendSandboxStatus(message.sessionID)
break
case "requestSandboxDefault":
await this.fetchAndSendSandboxDefault()
await this.fetchAndSendSandboxDefault(message.contextDirectory)
break
case "setSandboxDefault":
await this.handleSetSandboxDefault(message.enabled, message.requestID)
await this.handleSetSandboxDefault(message.enabled, message.requestID, message.contextDirectory)
break
case "toggleSandbox":
await this.handleToggleSandbox(message)
@@ -2494,14 +2494,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage(getWorkStylePayload())
}
private async fetchAndSendSandboxDefault(requestID?: string): Promise<void> {
private async fetchAndSendSandboxDefault(directory = this.getContextDirectory(), 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 }),
@@ -2532,20 +2531,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private async handleSetSandboxDefault(enabled: boolean, requestID: string): Promise<void> {
private async handleSetSandboxDefault(
enabled: boolean,
requestID: string,
directory = this.getContextDirectory(),
): Promise<void> {
const client = this.client
const sandbox = sandboxClient(client)
if (!client || !sandbox || this.connectionState !== "connected") {
await this.fetchAndSendSandboxDefault(requestID)
await this.fetchAndSendSandboxDefault(directory, 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)
await this.fetchAndSendSandboxDefault(directory, requestID)
vscode.window.showInformationMessage(
enabled ? "Sandbox enabled for new sessions" : "Sandbox disabled for new sessions",
)
@@ -440,11 +440,15 @@ export class AgentManagerProvider implements Disposable {
return null
}
if ((m.type === "sendMessage" || m.type === "sendCommand" || m.type === "toggleSandbox") && !m.sessionID) {
if (
m.type === "requestSandboxDefault" ||
m.type === "setSandboxDefault" ||
((m.type === "sendMessage" || m.type === "sendCommand" || m.type === "toggleSandbox") && !m.sessionID)
) {
const ctx = typeof m.agentManagerContext === "string" ? m.agentManagerContext : undefined
const worktree = ctx && ctx !== "local" ? this.getStateManager()?.getWorktree(ctx) : undefined
if (worktree) {
if (m.draftID) this.activeSessionId = m.draftID
if ("draftID" in m && m.draftID) this.activeSessionId = m.draftID
return { ...msg, contextDirectory: worktree.path }
}
}
@@ -229,6 +229,26 @@ describe("AgentManagerProvider worktree creation", () => {
})
})
it.each([{ type: "requestSandboxDefault" }, { type: "setSandboxDefault", enabled: false, requestID: "request-1" }])(
"routes $type to the selected worktree directory",
async (message) => {
const manager = createHarness()
const state = {
getWorktree: vi.fn().mockReturnValue({ id: "wt-1", path: "/repo/.kilo/worktrees/wt-1" }),
}
manager.getStateManager.mockReturnValue(state)
manager.contextTarget.mockResolvedValue(undefined)
const result = await manager.onMessage({ ...message, agentManagerContext: "wt-1" })
expect(result).toEqual({
...message,
agentManagerContext: "wt-1",
contextDirectory: "/repo/.kilo/worktrees/wt-1",
})
},
)
it("resolves new sandbox toggles to the selected worktree directory", async () => {
const manager = createHarness()
const state = {
@@ -639,6 +639,20 @@ interface SendCommandIn {
contextDirectory?: string
}
interface RequestSandboxDefaultIn {
type: "requestSandboxDefault"
agentManagerContext?: string
contextDirectory?: string
}
interface SetSandboxDefaultIn {
type: "setSandboxDefault"
enabled: boolean
requestID: string
agentManagerContext?: string
contextDirectory?: string
}
interface ToggleSandboxIn {
type: "toggleSandbox"
sessionID?: string
@@ -792,6 +806,8 @@ export type AgentManagerInMessage =
| LoadMessagesIn
| SendMessageIn
| SendCommandIn
| RequestSandboxDefaultIn
| SetSandboxDefaultIn
| ToggleSandboxIn
| RequestTerminalContextIn
| ClearSessionIn
@@ -1,6 +1,24 @@
import { describe, expect, test } from "bun:test"
import { KiloConnectionService } from "./connection-service"
function state(value: boolean) {
return {
get: <T>() => value as T,
update: async () => undefined,
}
}
describe("KiloConnectionService sandbox preference", () => {
test("uses workspace state instead of extension-global state", () => {
const service = new KiloConnectionService({
workspaceState: state(false),
globalState: state(true),
} as any)
expect(service.sandboxPreference.resolve(true)).toBe(false)
})
})
describe("KiloConnectionService viewed sessions", () => {
test("keeps Agent Manager sessions when sidebar focus changes during a flush", async () => {
const service = new KiloConnectionService({} as any)
@@ -93,7 +93,7 @@ export class KiloConnectionService {
constructor(context: vscode.ExtensionContext) {
const state =
context.globalState ??
context.workspaceState ??
({
get: <T>(_key: string, fallback?: T) => fallback,
update: async () => undefined,
@@ -74,6 +74,8 @@ function createClient(options?: {
const reverted: Array<Record<string, unknown>> = []
const created: Array<Record<string, unknown>> = []
const sandboxed: Array<Record<string, unknown>> = []
const sandboxSupport: Array<Record<string, unknown>> = []
const configReads: Array<Record<string, unknown>> = []
return {
calls,
stopped,
@@ -82,6 +84,8 @@ function createClient(options?: {
reverted,
created,
sandboxed,
sandboxSupport,
configReads,
session: {
list: async () => ({ data: [] }),
create: async (params: Record<string, unknown>) => {
@@ -118,7 +122,10 @@ function createClient(options?: {
},
},
sandbox: {
support: async () => options?.supportDeferred?.promise ?? { data: { available: true } },
support: async (params: Record<string, unknown>) => {
sandboxSupport.push(params)
return options?.supportDeferred?.promise ?? { data: { available: true } }
},
toggle: async (params: Record<string, unknown>) => {
sandboxed.push(params)
options?.sandboxStarted?.resolve(undefined)
@@ -137,7 +144,12 @@ function createClient(options?: {
},
provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) },
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} }) },
config: {
get: async (params: Record<string, unknown>) => {
configReads.push(params)
return { data: {} }
},
},
kilo: {
notifications: async () => ({ data: [] }),
profile: async () => ({ data: {} }),
@@ -207,7 +219,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>
handleSetSandboxDefault: (enabled: boolean, requestID: string) => Promise<void>
fetchAndSendSandboxDefault: (directory?: string, requestID?: string) => Promise<void>
handleSetSandboxDefault: (enabled: boolean, requestID: string, directory?: 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>
@@ -334,6 +347,16 @@ describe("KiloProvider sandbox toggle", () => {
notice.mockRestore()
})
it("resolves a blank worktree default against the routed directory", async () => {
const client = createClient()
const { internal } = makeProvider(client)
await internal.fetchAndSendSandboxDefault("/repo/.kilo/worktrees/wt-1")
expect(client.configReads).toEqual([{ directory: "/repo/.kilo/worktrees/wt-1" }])
expect(client.sandboxSupport).toEqual([{ directory: "/repo/.kilo/worktrees/wt-1" }])
})
it("waits for a blank toggle before creating the first prompt session", async () => {
const support = defer<{ data: { available: boolean } }>()
const client = createClient({ supportDeferred: support })
@@ -35,6 +35,7 @@ describe("PromptInput sandbox toggle", () => {
expect(toggle).toContain('type: "toggleSandbox"')
expect(toggle).toContain('type: "setSandboxDefault"')
expect(toggle).toContain("enabled: !sandboxDefault()!.desired")
expect(toggle).toContain("agentManagerContext: ctx()")
expect(toggle).toContain("sessionID,")
expect(toggle).toContain("requestID,")
expect(toggle).not.toContain("draftID:")
@@ -57,10 +58,11 @@ describe("PromptInput sandbox toggle", () => {
expect(move).toBeGreaterThan(save)
})
it("requires the enabled experiment for visibility and uses effective runtime state for the button", () => {
it("keeps persisted sandbox state visible independently of the configured default", () => {
expect(src).toContain(
'return features().sandboxControls && config().experimental?.sandbox === true && !id?.startsWith("cloud:")',
'const sandboxVisible = () => features().sandboxControls && !session.currentSessionID()?.startsWith("cloud:")',
)
expect(src).not.toContain("config().experimental?.sandbox === true")
expect(src).toContain("<Show when={sandboxVisible()}>")
expect(src).toContain("{ action: toggleSandbox, enabled: () => sandboxVisible() && !sandboxDisabled() }")
expect(src).toContain('if (!sandboxVisible()) hidden.add("sandbox")')
@@ -72,7 +74,7 @@ describe("PromptInput sandbox toggle", () => {
expect(src).toContain("const target = untrack(sandboxTarget)")
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('type: "requestSandboxDefault", agentManagerContext: ctx()')
expect(src).toContain("aria-pressed={sandboxEnabled()}")
expect(src).toContain("!sandboxReady()")
expect(src).toContain("if (sandboxRequest() && target === null) return")
@@ -202,10 +202,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const id = session.currentSessionID()
return id?.startsWith("cloud:") ? undefined : id
}
const sandboxVisible = () => {
const id = session.currentSessionID()
return features().sandboxControls && config().experimental?.sandbox === true && !id?.startsWith("cloud:")
}
const sandboxVisible = () => features().sandboxControls && !session.currentSessionID()?.startsWith("cloud:")
const sandbox = () => {
const state = sandboxState()
return state?.sessionID === sandboxID() ? state : undefined
@@ -224,7 +221,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
vscode.postMessage({ type: "requestSandboxStatus", sessionID })
return
}
vscode.postMessage({ type: "requestSandboxDefault" })
vscode.postMessage({ type: "requestSandboxDefault", agentManagerContext: ctx() })
}
const toggleSandbox = () => {
const sessionID = sandboxID()
@@ -234,7 +231,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setSandboxRequest(requestID)
setSandboxTarget(sessionID ?? null)
if (!sessionID) {
vscode.postMessage({ type: "setSandboxDefault", enabled: !sandboxDefault()!.desired, requestID })
vscode.postMessage({
type: "setSandboxDefault",
enabled: !sandboxDefault()!.desired,
requestID,
agentManagerContext: ctx(),
})
return
}
vscode.postMessage({
@@ -931,12 +931,16 @@ export interface RequestSandboxStatusMessage {
export interface RequestSandboxDefaultMessage {
type: "requestSandboxDefault"
agentManagerContext?: string
contextDirectory?: string
}
export interface SetSandboxDefaultMessage {
type: "setSandboxDefault"
enabled: boolean
requestID: string
agentManagerContext?: string
contextDirectory?: string
}
export interface ToggleSandboxMessage {
@@ -24,6 +24,12 @@ export function merge(metadata: Record<string, unknown> | null | undefined, valu
return { ...metadata, [key]: value }
}
export function inherit(metadata: Record<string, unknown> | null | undefined) {
const value = parse(metadata)
if (!value) return
return merge(undefined, { enabled: value.enabled, version: 0 })
}
export function remove(metadata: Record<string, unknown> | null | undefined) {
if (!metadata || !(key in metadata)) return metadata
const next = { ...metadata }
+2
View File
@@ -14,6 +14,7 @@ import { KiloTask } from "../kilocode/tool/task" // kilocode_change
import { KiloCostPropagation } from "../kilocode/session/cost-propagation" // kilocode_change
import { KiloSessionProcessor } from "../kilocode/session/processor" // kilocode_change
import { KiloSession } from "../kilocode/session" // kilocode_change
import * as SandboxState from "../kilocode/sandbox/state" // kilocode_change
import { errorMessage } from "@/util/error" // kilocode_change
import { Cause, Effect, Exit, Schema, Scope } from "effect"
import { EffectBridge } from "@/effect/bridge"
@@ -181,6 +182,7 @@ export const TaskTool = Tool.define(
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
platform, // kilocode_change
metadata: SandboxState.inherit(parent.metadata), // kilocode_change - preserve explicit parent sandbox state
// kilocode_change start - dedupe inherited restrictions before child prompt toggles persist
permission: KiloTask.merge(
deriveSubagentSessionPermission({
@@ -112,6 +112,21 @@ it.instance(
{ config: { experimental: { sandbox: true } } },
)
it.instance("persists metadata without requiring sandbox backend support", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const id = yield* create({ source: "test" })
yield* SandboxState.write(id, { enabled: false, version: 7 })
expect(yield* SandboxState.read(id)).toEqual({ enabled: false, version: 7 })
expect((yield* sessions.get(id)).metadata).toEqual({
source: "test",
[SandboxState.key]: { enabled: false, version: 7 },
})
}),
)
it.instance("preserves unrelated metadata through the production persistence callback", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
@@ -17,6 +17,7 @@ import { Provider } from "../../src/provider/provider"
import { Permission } from "../../src/permission"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
import * as SandboxState from "../../src/kilocode/sandbox/state"
import { Truncate } from "../../src/tool/truncate"
import { ToolRegistry } from "../../src/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
@@ -155,6 +156,45 @@ describe("Kilo task nesting", () => {
),
)
it.live("inherits the parent's explicit sandbox state", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
yield* sessions.setMetadata({
sessionID: chat.id,
metadata: SandboxState.merge(chat.metadata, { enabled: false, version: 3 }),
})
const tool = yield* TaskTool
const def = yield* tool.init()
const result = yield* def.execute(
{
description: "inspect sandbox",
prompt: "check the child sandbox state",
subagent_type: "explore",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps: stubOps() },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const child = yield* sessions.get(result.metadata.sessionId)
expect(SandboxState.parse(child.metadata)).toEqual({ enabled: false, version: 0 })
expect((yield* (yield* Config.Service).get()).experimental?.sandbox).toBe(true)
}),
{ config: { experimental: { sandbox: true } } },
),
)
it.live("disables nested task and question tools even when global permissions allow them", () =>
provideTmpdirInstance(
() =>