Merge pull request #11628 from Kilo-Org/feat-tui-sandbox-toggle

feat(sandbox): add session sandbox controls
This commit is contained in:
Marius
2026-06-24 14:30:21 +02:00
committed by GitHub
38 changed files with 1821 additions and 76 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@kilocode/cli": minor
"@kilocode/sdk": minor
"kilo-code": patch
---
Add session-local macOS sandbox controls, show the effective active state, and confirm toggles in the CLI and VS Code extension.
+4
View File
@@ -22,6 +22,10 @@ export function run<A, E, R>(
})
}
export function unrestricted<A, E, R>(effect: Effect.Effect<A, E, R>) {
return effect.pipe(Effect.provideService(CurrentProfile, undefined))
}
function denied(path: string, method: string) {
return PlatformError.systemError({
_tag: "PermissionDenied",
+2 -2
View File
@@ -1,7 +1,7 @@
export type { Profile } from "./profile"
export { assertWrite, enabled, run } from "./context"
export { assertWrite, enabled, run, unrestricted } from "./context"
export { decorateFileSystem, ensureDirectory } from "./filesystem"
export { assertNetwork, decorateHttpClient, httpLayer as networkHttpLayer } from "./network"
export { batchMutations, mutate, withRunner, type Runner as MutationRunner } from "./mutation"
export type { Request as MutationRequest } from "./mutation-protocol"
export { prepareCommand } from "./backend"
export { backendSupport, prepareCommand } from "./backend"
+186 -15
View File
@@ -294,6 +294,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
/** Remembers the last selected session so /new can stay in the same worktree after clearSession. */
private contextSessionID: string | undefined
private connectionState: "connecting" | "connected" | "disconnected" | "error" = "connecting"
private connectionGeneration = 0
private loginAttempt = 0
private isWebviewReady = false
private readonly extensionVersion =
@@ -310,6 +311,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private providersRefresh: Promise<void> | null = null
private providersQueued = false
private providersGeneration = 0
private sandboxRevision = 0
private cachedAgentsMessage: unknown = null
/** Cached skillsLoaded payload so requestSkills can be served before client is ready */
private cachedSkillsMessage: unknown = null
@@ -337,6 +339,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private trackedSessionIds: Set<string> = new Set()
private syncedChildSessions: Set<string> = new Set()
private readonly checkpoints = new Map<string, Promise<void>>()
private readonly sessionCreations = new Map<string, Promise<{ sid: string; dir: string }>>()
private readonly sandboxTransitions = new Map<string, Promise<void>>()
private readonly revisions = new Map<string, { id: string; seq: number }>()
private readonly refreshes = new Map<string, number>()
private sessionStatusMap = new Map<string, SessionStatus["type"]>() // Latest status used for destructive config warnings.
@@ -713,11 +717,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
public setSessionDirectory(sessionId: string, directory: string): void {
this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId))
this.sessionDirectories.set(sessionId, directory)
if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId)
}
public clearSessionDirectory(sessionId: string): void {
this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId))
this.sessionDirectories.delete(sessionId)
if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId)
}
/** Exposes the session→directory map so callers outside the webview can resolve worktree paths. */
@@ -1078,6 +1084,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.pendingFollowup = null
await handleQuestionReject(this.questionCtx, message.requestID, message.sessionID)
break
case "requestSandboxStatus":
await this.fetchAndSendSandboxStatus(message.sessionID)
break
case "toggleSandbox":
await this.handleToggleSandbox(message)
break
case "requestConfig":
this.fetchAndSendConfig().catch((e) => console.error("[Kilo New] fetchAndSendConfig failed:", e))
break
@@ -1338,6 +1350,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
console.log("[Kilo New] KiloProvider: 🔧 Starting initializeConnection...")
this.connectionState = "connecting"
this.connectionGeneration++
this.postMessage({ type: "connectionState", state: "connecting" })
// Clean up any existing subscriptions (e.g., sidebar re-shown)
@@ -1390,6 +1403,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Subscribe to connection state changes
this.unsubscribeState = this.connectionService.onStateChange(async (state, error) => {
if (this.connectionState !== state) this.connectionGeneration++
this.connectionState = state
this.postConnectionState(error)
@@ -2456,6 +2470,142 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage(getWorkStylePayload())
}
private postSandboxError(sessionID: string, error: unknown, revision: number, requestID?: string): void {
this.postMessage({
type: "sandboxStatusError",
sessionID,
directory: this.getWorkspaceDirectory(sessionID),
message: getErrorMessage(error) || "Failed to update sandbox",
requestID,
revision,
})
}
private async fetchAndSendSandboxStatus(sessionID: string, requestID?: string): Promise<void> {
const revision = ++this.sandboxRevision
const generation = this.connectionGeneration
const client = this.client
const sandbox = client?.sandbox
if (!sandbox?.status) return
if (this.connectionState !== "connected") {
this.postSandboxError(sessionID, "Not connected to CLI backend", revision, requestID)
return
}
try {
const directory = this.getWorkspaceDirectory(sessionID)
const { data } = await sandbox.status({ sessionID, directory }, { throwOnError: true })
if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client)
return
if (!sameDirectory(data.directory, this.getWorkspaceDirectory(sessionID))) {
if (requestID) void this.fetchAndSendSandboxStatus(sessionID, requestID)
return
}
this.postMessage({ type: "sandboxStatus", sessionID, revision, ...data, requestID })
} catch (error) {
if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client)
return
this.postSandboxError(sessionID, error, revision, requestID)
}
}
private sandboxKey(input: {
sessionID?: string
draftID?: string
agentManagerContext?: string
contextDirectory?: string
}): string {
if (input.sessionID) return `session:${input.sessionID}`
if (input.draftID) return `draft:${input.draftID}`
return `context:${input.agentManagerContext ?? ""}:${input.contextDirectory ?? this.getRootDirectory()}`
}
private handleToggleSandbox(input: {
sessionID?: string
draftID?: string
requestID: string
agentManagerContext?: string
contextDirectory?: string
}): Promise<void> {
const key = this.sandboxKey(input)
const pending = this.sandboxTransitions.get(key)
if (pending) return pending.catch(() => undefined)
const operation = this.runToggleSandbox(input, key)
this.sandboxTransitions.set(key, operation)
return operation
.catch(() => undefined)
.finally(() => {
for (const [id, active] of this.sandboxTransitions) {
if (active === operation) this.sandboxTransitions.delete(id)
}
})
}
private async runToggleSandbox(
input: {
sessionID?: string
draftID?: string
requestID: string
agentManagerContext?: string
contextDirectory?: string
},
key: string,
): Promise<void> {
const revision = ++this.sandboxRevision
const generation = this.connectionGeneration
const client = this.client
const sandbox = client?.sandbox
if (!sandbox?.toggle || this.connectionState !== "connected") {
const error = new Error("Not connected to CLI backend")
this.postSandboxError(input.sessionID ?? "", error, revision, input.requestID)
throw error
}
const resolved = await this.resolveSession(
input.sessionID,
input.draftID,
input.agentManagerContext,
input.contextDirectory,
).catch((error) => {
this.postSandboxError(input.sessionID ?? "", error, revision, input.requestID)
throw error
})
if (!resolved) {
const error = new Error("Failed to resolve sandbox session")
this.postSandboxError(input.sessionID ?? "", error, revision, input.requestID)
throw error
}
const operation = this.sandboxTransitions.get(key)
if (operation) this.sandboxTransitions.set(`session:${resolved.sid}`, operation)
if (this.connectionGeneration !== generation || this.client !== client) {
throw new Error("Sandbox connection changed")
}
try {
const { data } = await sandbox.toggle(
{ sessionID: resolved.sid, directory: resolved.dir },
{ throwOnError: true },
)
if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client) {
throw new Error("Sandbox connection changed")
}
if (!data.available) throw new Error(data.reason ?? "Sandbox backend is unavailable")
if (!sameDirectory(data.directory, this.getWorkspaceDirectory(resolved.sid))) {
throw new Error("Session directory changed during sandbox toggle")
}
this.postMessage({
type: "sandboxStatus",
sessionID: resolved.sid,
revision,
...data,
requestID: input.requestID,
})
} catch (error) {
if (this.connectionState === "connected" && this.connectionGeneration === generation && this.client === client) {
this.postSandboxError(resolved.sid, error, revision, input.requestID)
void this.fetchAndSendSandboxStatus(resolved.sid)
}
throw error
}
}
private async handleUpdateConfig(
partial: Partial<Config>,
project: Partial<Config> = {},
@@ -2570,21 +2720,29 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
if (!sessionID && !this.currentSession) {
const { data: session } = await this.client.session.create(
{ directory: dir, platform: this.opts.platform },
{ throwOnError: true },
)
this.stopCurrentSessionProcesses(session.id)
this.setCurrentSession(session)
this.contextSessionID = session.id
this.focusSession(session.id)
this.trackDirectory(session.id, dir)
this.trackedSessionIds.add(session.id)
this.postMessage({
type: "sessionCreated",
session: this.sessionToWebview(session),
draftID,
})
const key = `${draftID ?? context ?? "new"}\0${dir}`
const pending = this.sessionCreations.get(key)
if (pending) return pending
const creation = (async () => {
const { data: session } = await this.client!.session.create(
{ directory: dir, platform: this.opts.platform },
{ throwOnError: true },
)
this.stopCurrentSessionProcesses(session.id)
this.setCurrentSession(session)
this.contextSessionID = session.id
this.focusSession(session.id)
this.trackDirectory(session.id, dir)
this.trackedSessionIds.add(session.id)
this.postMessage({
type: "sessionCreated",
session: this.sessionToWebview(session),
draftID,
})
return { sid: session.id, dir }
})().finally(() => this.sessionCreations.delete(key))
this.sessionCreations.set(key, creation)
return creation
}
const sid = sessionID || this.currentSession?.id
@@ -2698,7 +2856,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
let resolved: { sid: string; dir: string } | undefined
try {
const sandbox = this.sandboxTransitions.get(
this.sandboxKey({ sessionID, draftID, agentManagerContext: context, contextDirectory }),
)
resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory)
if (sandbox) await sandbox
const parts: Array<TextPartInput | FilePartInput> = []
if (files) {
@@ -2779,7 +2941,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
let resolved: { sid: string; dir: string } | undefined
try {
const sandbox = this.sandboxTransitions.get(
this.sandboxKey({ sessionID, draftID, agentManagerContext: context, contextDirectory }),
)
resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory)
if (sandbox) await sandbox
if (messageID) {
this.connectionService.recordMessageSessionId(messageID, resolved!.sid)
@@ -3342,6 +3508,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return
}
const next = msg.type === "messageCreated" ? { ...msg, message: this.slimInfo(msg.message) } : msg
if (next.type === "sandboxStatus") {
if (!sameDirectory(next.directory, this.getWorkspaceDirectory(next.sessionID))) return
this.postMessage({ ...next, revision: ++this.sandboxRevision })
return
}
if (next.type === "indexingStatusLoaded") {
this.cachedIndexingStatusMessage = next
}
@@ -439,7 +439,7 @@ export class AgentManagerProvider implements Disposable {
return null
}
if ((m.type === "sendMessage" || m.type === "sendCommand") && !m.sessionID) {
if ((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) {
@@ -448,7 +448,11 @@ export class AgentManagerProvider implements Disposable {
}
}
if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) {
if (
(m.type === "sendMessage" || m.type === "sendCommand" || m.type === "toggleSandbox") &&
m.draftID &&
!m.sessionID
) {
this.activeSessionId = m.draftID
return msg
}
@@ -639,6 +639,15 @@ interface SendCommandIn {
contextDirectory?: string
}
interface ToggleSandboxIn {
type: "toggleSandbox"
sessionID?: string
draftID?: string
requestID: string
agentManagerContext?: string
contextDirectory?: string
}
interface RequestTerminalContextIn {
type: "requestTerminalContext"
requestId: string
@@ -783,6 +792,7 @@ export type AgentManagerInMessage =
| LoadMessagesIn
| SendMessageIn
| SendCommandIn
| ToggleSandboxIn
| RequestTerminalContextIn
| ClearSessionIn
| AbortIn
@@ -518,6 +518,15 @@ export type WebviewMessage =
| { type: "sessionDeleted"; sessionID: string }
| { type: "messageRemoved"; sessionID: string; messageID: string }
| { type: "sessionError"; sessionID?: string; error?: unknown }
| {
type: "sandboxStatus"
sessionID: string
directory: string
enabled: boolean
available: boolean
reason?: string
version: number
}
| null
type PartEvent =
@@ -554,6 +563,12 @@ function mapPartEvent(event: PartEvent, sessionID: string | undefined): WebviewM
}
}
function statusExtra(info: Extract<Event, { type: "session.status" }>["properties"]["status"]) {
if (info.type === "retry") return { attempt: info.attempt, message: info.message, next: info.next }
if (info.type === "offline") return { message: info.message }
return {}
}
export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: string | undefined): WebviewMessage {
if (event.type === "sync") {
switch (event.name) {
@@ -595,12 +610,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin
case "session.status": {
const info = event.properties.status
const status = info.type
const extra =
info.type === "retry"
? { attempt: info.attempt, message: info.message, next: info.next }
: info.type === "offline"
? { message: info.message }
: {}
const extra = statusExtra(info)
return {
type: "sessionStatus" as const,
sessionID: event.properties.sessionID,
@@ -681,6 +691,16 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin
error: event.properties.error,
}
}
case "sandbox.status.changed":
return {
type: "sandboxStatus",
sessionID: event.properties.sessionID,
directory: event.properties.directory,
enabled: event.properties.enabled,
available: event.properties.available,
reason: event.properties.reason,
version: event.properties.version,
}
case "indexing.status":
return {
type: "indexingStatusLoaded",
@@ -20,6 +20,7 @@ export function resolveEventSessionId(
}
void lookupMessageSessionId
if (event.type === "sandbox.status.changed") return event.properties.sessionID
return resolveTransientSessionId(event)
}
@@ -158,6 +158,16 @@ describe("resolveEventSessionId", () => {
expect(resolveEventSessionId(suggestion, noLookup)).toBe("s10")
})
it("routes sandbox status events", () => {
const event = {
id: "e12",
type: "sandbox.status.changed",
properties: { sessionID: "s11", directory: "/repo", enabled: true, available: true, version: 1 },
} satisfies Payload
expect(resolveEventSessionId(event, noLookup)).toBe("s11")
})
it("returns undefined for global events", () => {
const event = { id: "e12", type: "server.connected", properties: {} } satisfies Payload
@@ -61,20 +61,31 @@ function createClient(options?: {
sessionData?: unknown
sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }>
abortFailures?: string[]
createDeferred?: Deferred<{ data: unknown }>
sandboxDeferred?: Deferred<{ data: unknown }>
sandboxStarted?: Deferred<void>
}) {
const calls: { before?: string; limit?: number }[] = []
const stopped: { sessionID: string; directory?: string }[] = []
const aborted: { sessionID: string; directory?: string }[] = []
const prompted: Array<Record<string, unknown>> = []
const reverted: Array<Record<string, unknown>> = []
const created: Array<Record<string, unknown>> = []
const sandboxed: Array<Record<string, unknown>> = []
return {
calls,
stopped,
aborted,
prompted,
reverted,
created,
sandboxed,
session: {
list: async () => ({ data: [] }),
create: async (params: Record<string, unknown>) => {
created.push(params)
return options?.createDeferred?.promise ?? { data: mkSession() }
},
get: async (params: { sessionID: string; directory?: string }) => {
if (options?.sessionGet) return options.sessionGet(params)
return { data: options?.sessionData ?? null }
@@ -104,6 +115,17 @@ function createClient(options?: {
return { data: {} }
},
},
sandbox: {
toggle: async (params: Record<string, unknown>) => {
sandboxed.push(params)
options?.sandboxStarted?.resolve(undefined)
return (
options?.sandboxDeferred?.promise ?? {
data: { directory: "/repo", enabled: true, available: true, version: 1 },
}
)
},
},
backgroundProcess: {
stopSession: async (params: { sessionID: string; directory?: string }) => {
stopped.push(params)
@@ -163,7 +185,8 @@ type ProviderInternals = {
handleEvent: (event: unknown, directory?: string) => void
handleAbort: (sid?: string) => Promise<void>
handleRevertSession: (sid: string, messageID: string) => Promise<void>
handleSendMessage: (text: string, messageID?: string, sessionID?: string) => Promise<void>
handleSendMessage: (text: string, messageID?: string, sessionID?: string, draftID?: string) => Promise<void>
handleToggleSandbox: (input: { sessionID?: string; draftID?: string; requestID: string }) => Promise<void>
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
handleDeleteSession: (sid: string) => Promise<void>
}
@@ -246,6 +269,141 @@ describe("KiloProvider.handleAbort", () => {
})
})
describe("KiloProvider sandbox status", () => {
it("ignores events from another directory for the same session", () => {
const client = createClient()
const { internal, sent } = makeProvider(client)
internal.sessionDirectories.set("s1", "/repo")
internal.trackedSessionIds.add("s1")
internal.handleEvent({
type: "sandbox.status.changed",
properties: { sessionID: "s1", directory: "/other", enabled: true, available: true, version: 1 },
})
expect(sent.some((message) => (message as { type?: string }).type === "sandboxStatus")).toBe(false)
internal.handleEvent({
type: "sandbox.status.changed",
properties: { sessionID: "s1", directory: "/repo", enabled: true, available: true, version: 1 },
})
expect(sent).toContainEqual(expect.objectContaining({ type: "sandboxStatus", sessionID: "s1", directory: "/repo" }))
})
})
describe("KiloProvider sandbox toggle", () => {
it("creates a session before toggling from the empty composer", async () => {
const client = createClient()
const { internal, sent } = makeProvider(client)
await internal.handleToggleSandbox({ draftID: "draft-1", requestID: "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(sent).toContainEqual(
expect.objectContaining({
type: "sandboxStatus",
sessionID: "s1",
requestID: "sandbox-1",
enabled: true,
}),
)
})
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 })
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()
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 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" }),
)
log.mockRestore()
})
it("does not send a queued prompt when the sandbox backend is unavailable", async () => {
const log = spyOn(console, "error").mockImplementation(() => {})
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" }),
)
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.prompted).toHaveLength(1)
})
})
describe("KiloProvider revert ordering", () => {
it("unwraps the nested sync payload emitted by the live SSE endpoint", () => {
const event = unwrapSyncEvent({
@@ -24,6 +24,7 @@ import type {
SyncEventMessageUpdated,
EventSessionStatus,
EventSessionTurnClose,
EventSandboxStatusChanged,
EventPermissionAsked,
EventPermissionReplied,
EventTodoUpdated,
@@ -448,6 +449,23 @@ describe("mapSSEEventToWebviewMessage", () => {
}
})
it("maps sandbox status changes to effective button state", () => {
const event: EventSandboxStatusChanged = {
type: "sandbox.status.changed",
properties: { sessionID: "sess-1", directory: "/tmp", enabled: true, available: true, version: 3 },
}
expect(mapSSEEventToWebviewMessage(event, "sess-1")).toEqual({
type: "sandboxStatus",
sessionID: "sess-1",
directory: "/tmp",
enabled: true,
available: true,
reason: undefined,
version: 3,
})
})
it("maps session.turn.close to its terminal reason", () => {
const event: EventSessionTurnClose = {
id: "evt-turn",
@@ -20,17 +20,35 @@ describe("PromptInput connection guard", () => {
})
describe("PromptInput sandbox toggle", () => {
it("writes only the sandbox patch without saving pending settings drafts", () => {
const start = src.indexOf("<Show when={features().sandboxControls}>")
const end = src.indexOf("</Show>", start)
it("toggles or creates the runtime session instead of writing config", () => {
const start = src.indexOf("const toggleSandbox = () =>")
const end = src.indexOf("let enhanceCounter", start)
const toggle = src.slice(start, end)
expect(start).toBeGreaterThan(-1)
expect(end).toBeGreaterThan(start)
expect(toggle).toContain("vscode.postMessage({")
expect(toggle).toContain('type: "updateConfig"')
expect(toggle).toContain("config: { experimental: { sandbox: !sandbox() } }")
expect(toggle).not.toContain("saveConfig")
expect(toggle).not.toContain("updateConfig(")
expect(toggle).toContain("const sessionID = sandboxID()")
expect(toggle).toContain('type: "toggleSandbox"')
expect(toggle).toContain("sessionID,")
expect(toggle).toContain("draftID: props.pendingSessionID ?? session.draftSessionID()")
expect(toggle).toContain("requestID,")
expect(toggle).toContain("setSandboxTarget(sessionID ?? null)")
expect(toggle).not.toContain('type: "updateConfig"')
})
it("uses the internal flag for visibility and effective runtime state for the button", () => {
expect(src).toContain("features().sandboxControls")
expect(src).toContain("<Show when={sandboxVisible()}>")
expect(src).toContain('message.type === "sandboxStatus"')
expect(src).toContain("message.sessionID !== sandboxID() && !matching")
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("aria-pressed={sandboxEnabled()}")
expect(src).toContain("!sandboxReady()")
expect(src).toContain("if (sandboxRequest() && target === null) return")
expect(src).not.toContain("if (state === current) return true")
})
})
@@ -10,8 +10,41 @@ import {
isSuggesting,
isQuestioning,
isPathMention,
applySandboxState,
} from "../../webview-ui/src/components/chat/prompt-input-utils"
describe("applySandboxState", () => {
const state = (enabled: boolean, revision: number, sessionID = "ses_1", directory = "/repo") => ({
sessionID,
directory,
enabled,
available: true,
version: enabled ? 1 : 0,
revision,
})
it("ignores an HTTP response with an older backend version", () => {
const latest = { ...state(true, 1), version: 2 }
const stale = { ...state(false, 2), version: 1 }
expect(applySandboxState(latest, stale)).toEqual(latest)
})
it("uses provider revision to order equal backend versions", () => {
const current = { ...state(false, 2), version: 4 }
const older = { ...state(true, 1), version: 4 }
const newer = { ...state(true, 3), version: 4 }
expect(applySandboxState(current, older)).toEqual(current)
expect(applySandboxState(current, newer)).toEqual(newer)
})
it("keeps global provider ordering across sessions and directories", () => {
expect(applySandboxState(state(true, 5, "ses_1"), state(false, 1, "ses_2"))).toEqual(state(true, 5, "ses_1"))
expect(applySandboxState(state(true, 5), state(false, 6, "ses_1", "/worktree"))).toEqual(
state(false, 6, "ses_1", "/worktree"),
)
})
})
describe("fileName", () => {
it("extracts the last segment of a unix path", () => {
expect(fileName("src/components/chat/PromptInput.tsx")).toBe("PromptInput.tsx")
@@ -43,8 +43,10 @@ import {
insertSpacedText,
isPromptBusy,
isPathMention,
applySandboxState,
type SandboxState,
} from "./prompt-input-utils"
import type { ReviewComment, SendMessageFailedMessage, TextPart } from "../../types/messages"
import type { ExtensionMessage, ReviewComment, SendMessageFailedMessage, TextPart } from "../../types/messages"
import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown"
import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts"
import { ReviewComments } from "./ReviewComments"
@@ -143,10 +145,82 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [reviewComments, setReviewComments] = createSignal<ReviewComment[]>([])
const [enhancing, setEnhancing] = createSignal(false)
const [autoApprove, setAutoApprove] = createSignal(false)
const sandbox = () => config().experimental?.sandbox ?? false
const [sandboxState, setSandboxState] = createSignal<SandboxState>()
const [sandboxRequest, setSandboxRequest] = createSignal<string>()
const [sandboxTarget, setSandboxTarget] = createSignal<string | null>()
let sandboxRetry: ReturnType<typeof setTimeout> | undefined
let sandboxAttempts = 0
const sandboxID = () => {
const id = session.currentSessionID()
return id?.startsWith("cloud:") ? undefined : id
}
const sandboxVisible = () => {
const id = session.currentSessionID()
return features().sandboxControls && !id?.startsWith("cloud:")
}
const sandbox = () => {
const state = sandboxState()
return state?.sessionID === sandboxID() ? state : undefined
}
const sandboxEnabled = () => sandbox()?.enabled ?? (!sandboxID() && config().experimental?.sandbox === true)
const sandboxReady = () => !sandboxID() || sandbox() !== undefined
const requestSandbox = () => {
const sessionID = sandboxID()
if (!sessionID || server.connectionState() !== "connected") return
vscode.postMessage({ type: "requestSandboxStatus", sessionID })
}
const toggleSandbox = () => {
const sessionID = sandboxID()
const state = sandbox()
if ((sessionID && !state) || state?.available === false || sandboxRequest() || !server.isConnected()) return
const requestID = crypto.randomUUID()
setSandboxRequest(requestID)
setSandboxTarget(sessionID ?? null)
vscode.postMessage({
type: "toggleSandbox",
sessionID,
draftID: props.pendingSessionID ?? session.draftSessionID(),
requestID,
agentManagerContext: ctx(),
})
}
const clearSandboxRequest = () => {
setSandboxRequest(undefined)
setSandboxTarget(undefined)
}
const retrySandbox = (sessionID: string) => {
if (sandboxAttempts >= 2) return
sandboxAttempts++
if (sandboxRetry) clearTimeout(sandboxRetry)
sandboxRetry = setTimeout(() => {
sandboxRetry = undefined
if (sandboxID() === sessionID) requestSandbox()
}, 1000)
}
let enhanceCounter = 0
let preEnhanceText: string | null = null
createEffect(() => {
const sessionID = sandboxID()
const connected = server.connectionState() === "connected"
const target = untrack(sandboxTarget)
if (sandboxRetry) clearTimeout(sandboxRetry)
sandboxRetry = undefined
sandboxAttempts = 0
if (!connected) {
clearSandboxRequest()
setSandboxState(undefined)
return
}
if (target && target !== sessionID) clearSandboxRequest()
if (!sessionID) {
setSandboxState(undefined)
return
}
if (sandboxRequest() && target === null) return
requestSandbox()
})
const ghost = useGhostText(vscode, text, () => server.isConnected())
const speech = useSpeechToText(vscode, server, language)
@@ -378,7 +452,74 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
imageDrafts.set(target, images)
}
const handleSandboxMessage = (message: ExtensionMessage) => {
if (message.type === "sandboxStatus") {
const matching = message.requestID !== undefined && message.requestID === sandboxRequest()
if (message.sessionID !== sandboxID() && !matching) return false
if (!server.isConnected()) return true
const current = sandboxState()
if (matching) clearSandboxRequest()
const state = applySandboxState(current, message)
if (state !== current) setSandboxState(state)
sandboxAttempts = 0
if (sandboxRetry) clearTimeout(sandboxRetry)
sandboxRetry = undefined
if (matching) {
if (!state.available) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: state.reason,
})
return true
}
showToast({
variant: "success",
title: language.t("settings.experimental.sandbox.title"),
description: language.t(state.enabled ? "prompt.action.sandbox.enabled" : "prompt.action.sandbox.disabled"),
})
}
return true
}
if (message.type === "sandboxStatusError") {
const matching = message.requestID !== undefined && message.requestID === sandboxRequest()
if (message.sessionID !== sandboxID() && !matching) return false
if (!server.isConnected()) return true
const current = sandboxState()
if (matching) clearSandboxRequest()
if ((current?.revision ?? -1) > message.revision) return true
if (!message.requestID) {
const same = current?.sessionID === message.sessionID && current.directory === message.directory
setSandboxState({
sessionID: message.sessionID,
directory: message.directory,
enabled: same ? current.enabled : false,
available: false,
reason: message.message,
version: same ? current.version : 0,
revision: message.revision,
})
retrySandbox(message.sessionID)
}
if (matching) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: message.message,
})
}
return true
}
if (message.type !== "configUpdated") return false
requestSandbox()
return true
}
const unsubscribe = vscode.onMessage((message) => {
if (handleSandboxMessage(message)) return
if (message.type === "setChatBoxMessage") {
setText(message.text)
mention.seedFromText(message.text)
@@ -470,6 +611,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onCleanup(() => {
// Persist current draft before unmounting
saveDraft(draftKey(), text(), reviewComments(), imageAttach.images())
if (sandboxRetry) clearTimeout(sandboxRetry)
unsubAutoApprove()
unsubscribe()
})
@@ -1073,27 +1215,34 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Icon name="shield" size="small" />
</Button>
</Tooltip>
<Show when={features().sandboxControls}>
<Show when={sandboxVisible()}>
<Tooltip
value={
sandbox() ? language.t("prompt.action.sandbox.enabled") : language.t("prompt.action.sandbox.disabled")
sandbox()?.available === false
? (sandbox()?.reason ?? language.t("common.requestFailed"))
: sandboxEnabled()
? language.t("prompt.action.sandbox.enabled")
: language.t("prompt.action.sandbox.disabled")
}
placement="top"
>
<Button
variant="ghost"
size="small"
onClick={() =>
vscode.postMessage({
type: "updateConfig",
config: { experimental: { sandbox: !sandbox() } },
})
onClick={toggleSandbox}
disabled={
!server.isConnected() ||
!sandboxReady() ||
sandbox()?.available === false ||
sandboxRequest() !== undefined
}
aria-label={
sandbox() ? language.t("prompt.action.sandbox.disable") : language.t("prompt.action.sandbox.enable")
sandboxEnabled()
? language.t("prompt.action.sandbox.disable")
: language.t("prompt.action.sandbox.enable")
}
aria-pressed={sandbox()}
class={`prompt-status-button ${sandbox() ? "prompt-status-button--active" : ""}`}
aria-pressed={sandboxEnabled()}
class={`prompt-status-button ${sandboxEnabled() ? "prompt-status-button--active" : ""}`}
>
<Icon name="lock" size="small" />
</Button>
@@ -1,3 +1,22 @@
export type SandboxState = {
sessionID: string
enabled: boolean
available: boolean
reason?: string
version: number
directory: string
revision: number
}
export function applySandboxState(current: SandboxState | undefined, next: SandboxState) {
if (!current) return next
const same = current.sessionID === next.sessionID && current.directory === next.directory
if (same && current.version > next.version) return current
if (same && current.version === next.version && current.revision > next.revision) return current
if (!same && current.revision > next.revision) return current
return next
}
export function fileName(path: string): string {
const normalized = path.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").pop() ?? normalized
@@ -644,6 +644,27 @@ export interface AutoApproveStateMessage {
active: boolean
}
export interface SandboxStatusMessage {
type: "sandboxStatus"
sessionID: string
enabled: boolean
available: boolean
reason?: string
version: number
directory: string
revision: number
requestID?: string
}
export interface SandboxStatusErrorMessage {
type: "sandboxStatusError"
sessionID: string
directory: string
message: string
revision: number
requestID?: string
}
// Multi-version creation progress (extension → webview)
export interface AgentManagerMultiVersionProgressMessage {
type: "agentManager.multiVersionProgress"
@@ -1045,6 +1066,8 @@ export type ExtensionMessage =
| AgentManagerRunStatusMessage
| AgentManagerKeybindingsMessage
| AutoApproveStateMessage
| SandboxStatusMessage
| SandboxStatusErrorMessage
| AgentManagerMultiVersionProgressMessage
| AgentManagerSetSessionModelMessage
| AgentManagerSendInitialMessage
@@ -924,6 +924,20 @@ export interface ToggleAutoApproveMessage {
type: "toggleAutoApprove"
}
export interface RequestSandboxStatusMessage {
type: "requestSandboxStatus"
sessionID: string
}
export interface ToggleSandboxMessage {
type: "toggleSandbox"
sessionID?: string
draftID?: string
requestID: string
agentManagerContext?: string
contextDirectory?: string
}
export interface ToggleRemoteMessage {
type: "toggleRemote"
}
@@ -1257,6 +1271,8 @@ export type WebviewMessage =
| AgentManagerOpenSessionsMessage
| RequestAutoApproveStateMessage
| ToggleAutoApproveMessage
| RequestSandboxStatusMessage
| ToggleSandboxMessage
| FetchMarketplaceDataMessage
| FilterMarketplaceItemsMessage
| InstallMarketplaceItemMessage
@@ -10,6 +10,7 @@ import KiloSidebarBackgroundProcesses from "@/kilocode/plugins/sidebar-backgroun
import KiloSidebarIndexing from "@/kilocode/plugins/sidebar-indexing"
import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr"
import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage"
import KiloSandbox from "@/kilocode/plugins/sandbox"
// kilocode_change end
import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
@@ -44,6 +45,7 @@ export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalE
KiloSidebarIndexing, // kilocode_change
KiloSidebarPr, // kilocode_change
KiloSidebarUsage, // kilocode_change
KiloSandbox, // kilocode_change
HomeFooter,
HomeTips,
SidebarContext,
@@ -0,0 +1,131 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui"
import { createEffect, createSignal, on, type Accessor } from "solid-js"
const id = "internal:sandbox"
type Status = {
directory: string
enabled: boolean
available: boolean
reason?: string
version: number
}
export function indicator(status?: Status) {
return status?.enabled ? "◆ Sandbox on" : undefined
}
function session(api: TuiPluginApi) {
if (api.route.current.name !== "session") return
const sessionID = api.route.current.params?.sessionID
if (typeof sessionID !== "string") return
return sessionID
}
async function ensureSession(api: TuiPluginApi) {
const current = session(api)
if (current) return current
const result = await api.client.session.create({}, { throwOnError: true })
const sessionID = result.data?.id
if (sessionID) api.route.navigate("session", { sessionID })
return sessionID
}
function View(props: {
api: TuiPluginApi
sessionID: string
status: Accessor<ReadonlyMap<string, Status>>
load: (sessionID: string, force?: boolean) => Promise<void>
}) {
createEffect(
on(
() => props.api.state.config.experimental?.sandbox,
() => void props.load(props.sessionID, true),
),
)
return (
<box flexShrink={0}>
<text fg={props.api.theme.current.success}>{indicator(props.status().get(props.sessionID)) ?? ""}</text>
</box>
)
}
const tui: TuiPlugin = async (api) => {
const [status, setStatus] = createSignal<ReadonlyMap<string, Status>>(new Map())
const pending = new Set<string>()
const loads = new Map<string, symbol>()
const commit = (sessionID: string, value: Status) => {
const next = new Map(status())
next.set(sessionID, value)
setStatus(next)
}
const set = (sessionID: string, value: Status) => {
loads.delete(sessionID)
commit(sessionID, value)
}
const load = async (sessionID: string, force = false) => {
if (!force && status().has(sessionID)) return
const token = Symbol()
loads.set(sessionID, token)
try {
const result = await api.client.sandbox.status({ sessionID }, { throwOnError: true })
if (result.data && loads.get(sessionID) === token) commit(sessionID, result.data)
} catch (err) {
api.ui.toast({ message: String(err), variant: "error", duration: 5000 })
} finally {
if (loads.get(sessionID) === token) loads.delete(sessionID)
}
}
const changed = api.event.on("sandbox.status.changed", (event) => set(event.properties.sessionID, event.properties))
api.lifecycle.onDispose(changed)
api.keymap.registerLayer({
commands: [
{
namespace: "palette",
name: "session.toggle.sandbox",
title: "Toggle sandbox",
category: "Session",
slashName: "sandbox",
async run() {
const sessionID = await ensureSession(api)
if (!sessionID || pending.has(sessionID)) return
pending.add(sessionID)
try {
const result = await api.client.sandbox.toggle({ sessionID }, { throwOnError: true })
const value = result.data
if (!value) return
set(sessionID, value)
if (!value.enabled && !value.available) {
api.ui.toast({
message: value.reason ?? "Sandbox backend is unavailable",
variant: "error",
duration: 5000,
})
return
}
api.ui.toast({ message: `Sandbox ${value.enabled ? "enabled" : "disabled"}`, variant: "success" })
api.ui.dialog.clear()
} catch (err) {
api.ui.toast({ message: String(err), variant: "error", duration: 5000 })
} finally {
pending.delete(sessionID)
}
},
},
],
})
api.slots.register({
order: 50,
slots: {
session_prompt_right(_ctx, props) {
return <View api={api} sessionID={props.session_id} status={status} load={load} />
},
},
})
}
const plugin: TuiPluginModule & { id: string } = { id, tui }
export default plugin
@@ -0,0 +1,15 @@
import { Schema } from "effect"
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
export const Changed = BusEvent.define(
"sandbox.status.changed",
Schema.Struct({
sessionID: SessionID,
directory: Schema.String,
enabled: Schema.Boolean,
available: Schema.Boolean,
reason: Schema.optional(Schema.String),
version: Schema.Int,
}),
)
@@ -1,13 +1,40 @@
import { readFileSync, statSync } from "node:fs"
import path from "node:path"
import { Effect } from "effect"
import { Effect, Semaphore } from "effect"
import { Global } from "@opencode-ai/core/global"
import { run as runSandbox, type Profile } from "@kilocode/sandbox"
import { backendSupport, run as runSandbox, unrestricted, type Profile } from "@kilocode/sandbox"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import type { InstanceContext } from "@/project/instance-context"
import type { SessionID } from "@/session/schema"
import { Changed } from "./event"
import * as Network from "./network"
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(() => {
const entry = locks.get(sessionID) ?? { semaphore: Semaphore.makeUnsafe(1), refs: 0 }
entry.refs++
locks.set(sessionID, entry)
return entry
}),
(entry) => entry.semaphore.withPermits(1)(effect),
(entry) =>
Effect.sync(() => {
entry.refs--
if (entry.refs === 0 && locks.get(sessionID) === entry) locks.delete(sessionID)
}),
)
}
function root(path: string) {
return { path, kind: "subtree" as const }
}
@@ -81,20 +108,91 @@ export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] =
}
}
export function execute<A, E, R>(effect: Effect.Effect<A, E, R>) {
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
return {
directory,
enabled: enabled && backendSupport.available,
available: backendSupport.available,
reason: backendSupport.reason,
version: override?.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
}),
)
})
}
export const toggle = Effect.fn("SandboxPolicy.toggle")((sessionID: SessionID) => change(sessionID, Effect.void))
export function toggleGuarded<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>) {
return change(sessionID, guard)
}
export const clear = Effect.fn("SandboxPolicy.clear")(function* (sessionID: SessionID) {
yield* retire(sessionID, yield* InstanceState.directory, Effect.void)
})
export function retire<A, E, R>(
sessionID: SessionID,
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
}),
)
}
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
}),
)
}
function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
if (!(yield* status(sessionID)).enabled) return yield* unrestricted(effect)
const config = yield* Config.Service
const cfg = yield* config.get()
if (!cfg.experimental?.sandbox) return yield* effect
const mode = cfg.experimental.sandbox_restrict_network === false ? "allow" : "deny"
const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny"
return yield* runSandbox(profile(yield* InstanceState.context, mode), effect)
})
}
export function executeTool<A, E, R>(tool: { id: string }, effect: Effect.Effect<A, E, R>) {
return execute(Network.tool(tool, effect))
export function executeTool<A, E, R>(sessionID: SessionID, tool: { id: string }, effect: Effect.Effect<A, E, R>) {
return execute(sessionID, Network.tool(tool, effect))
}
export function executeMcp<A, E, R>(tool: object, effect: Effect.Effect<A, E, R>) {
return execute(Network.mcp(tool, effect))
export function executeMcp<A, E, R>(sessionID: SessionID, tool: object, effect: Effect.Effect<A, E, R>) {
return execute(sessionID, Network.mcp(tool, effect))
}
@@ -0,0 +1,63 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { SessionID } from "@/session/schema"
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
import { ApiNotFoundError } from "@/server/routes/instance/httpapi/errors"
const root = "/session/:sessionID/sandbox"
export const SandboxStatus = Schema.Struct({
directory: Schema.String,
enabled: Schema.Boolean,
available: Schema.Boolean,
reason: Schema.optional(Schema.String),
version: Schema.Int,
})
export const SandboxApi = HttpApi.make("sandbox")
.add(
HttpApiGroup.make("sandbox")
.add(
HttpApiEndpoint.get("status", root, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(SandboxStatus, "Session sandbox status"),
error: ApiNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "sandbox.status",
summary: "Get session sandbox status",
description: "Get the effective sandbox state for one session.",
}),
),
HttpApiEndpoint.post("toggle", `${root}/toggle`, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(SandboxStatus, "Updated session sandbox status"),
error: ApiNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "sandbox.toggle",
summary: "Toggle session sandbox",
description: "Toggle the ephemeral sandbox override for one session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "sandbox", description: "Kilo session sandbox routes." }))
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "kilo HttpApi",
version: "0.0.1",
description: "Kilo HttpApi surface.",
}),
)
@@ -0,0 +1,21 @@
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
import { Session } from "@/session/session"
import type { SessionID } from "@/session/schema"
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import * as SessionError from "@/server/routes/instance/httpapi/handlers/session-errors"
export const sandboxHandlers = HttpApiBuilder.group(InstanceHttpApi, "sandbox", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const exists = (sessionID: SessionID) => SessionError.mapStorageNotFound(session.get(sessionID))
return handlers
.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)),
)
}),
)
@@ -16,6 +16,7 @@ import { kiloGatewayHandlers } from "./handlers/kilo-gateway"
import { kilocodeHandlers } from "./handlers/kilocode"
import { networkHandlers } from "./handlers/network"
import { remoteHandlers } from "./handlers/remote"
import { sandboxHandlers } from "./handlers/sandbox"
import { sessionImportHandlers } from "./handlers/session-import"
import { suggestionHandlers } from "./handlers/suggestion"
import { telemetryHandlers } from "./handlers/telemetry"
@@ -31,6 +32,7 @@ export const provide = Layer.provide([
kilocodeHandlers,
networkHandlers,
remoteHandlers,
sandboxHandlers,
sessionImportHandlers,
suggestionHandlers,
telemetryHandlers,
@@ -31,6 +31,7 @@ import { KiloGatewayApi } from "@/kilocode/server/httpapi/groups/kilo-gateway"
import { KilocodeApi } from "@/kilocode/server/httpapi/groups/kilocode"
import { NetworkApi } from "@/kilocode/server/httpapi/groups/network"
import { RemoteApi } from "@/kilocode/server/httpapi/groups/remote"
import { SandboxApi } from "@/kilocode/server/httpapi/groups/sandbox"
import { SessionImportApi } from "@/kilocode/server/httpapi/groups/session-import"
import { SuggestionApi } from "@/kilocode/server/httpapi/groups/suggestion"
import { TelemetryApi } from "@/kilocode/server/httpapi/groups/telemetry"
@@ -75,6 +76,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(KilocodeApi)
.addHttpApi(NetworkApi)
.addHttpApi(RemoteApi)
.addHttpApi(SandboxApi)
.addHttpApi(SessionImportApi)
.addHttpApi(SuggestionApi)
.addHttpApi(TelemetryApi)
+21 -13
View File
@@ -33,6 +33,7 @@ import { Global } from "@opencode-ai/core/global"
import { BackgroundProcess } from "@/kilocode/background-process"
import { KiloSession, kiloSessionFork } from "@/kilocode/session"
import { SessionExport } from "@/kilocode/session-export"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
import { baseKey, cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff" // kilocode_change
// kilocode_change end
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
@@ -658,20 +659,27 @@ export const layer: Layer.Layer<
}
// kilocode_change start
yield* Effect.promise(() => KiloSession.removeSession(sessionID)).pipe(Effect.ignore)
KiloSession.clearPlatformOverride(sessionID)
if (hasInstance) {
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)).pipe(Effect.ignore)
void Promise.all([import("@/effect/app-runtime"), import("./run-state")]).then(([app, run]) =>
app.AppRuntime.runPromise(run.SessionRunState.Service.use((svc) => svc.cancel(sessionID))).catch(() => {}),
)
}
yield* SandboxPolicy.dispose(
sessionID,
Effect.gen(function* () {
yield* Effect.promise(() => KiloSession.removeSession(sessionID)).pipe(Effect.ignore)
KiloSession.clearPlatformOverride(sessionID)
if (hasInstance) {
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)).pipe(Effect.ignore)
void Promise.all([import("@/effect/app-runtime"), import("./run-state")]).then(([app, run]) =>
app.AppRuntime.runPromise(run.SessionRunState.Service.use((svc) => svc.cancel(sessionID))).catch(
() => {},
),
)
}
yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
// kilocode_change - capture final session-export workspace delta on close/delete
const workspaceKey = hasInstance ? yield* InstanceState.directory : undefined // kilocode_change
yield* Effect.promise(() => SessionExport.onSessionClose(sessionID, workspaceKey)) // kilocode_change
yield* sync.remove(sessionID)
}),
)
// kilocode_change end
yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
// kilocode_change - capture final session-export workspace delta on close/delete
const workspaceKey = hasInstance ? yield* InstanceState.directory : undefined // kilocode_change
yield* Effect.promise(() => SessionExport.onSessionClose(sessionID, workspaceKey)) // kilocode_change
yield* sync.remove(sessionID)
} catch (e) {
log.error(e)
}
+2 -1
View File
@@ -90,7 +90,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
{ args },
)
// kilocode_change start
const result = yield* SandboxPolicy.executeTool(item, item.execute(args, ctx))
const result = yield* SandboxPolicy.executeTool(ctx.sessionID, item, item.execute(args, ctx))
// kilocode_change end
const output = {
...result,
@@ -134,6 +134,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
)
// kilocode_change start
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* SandboxPolicy.executeMcp(
ctx.sessionID,
item,
Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
@@ -1,13 +1,17 @@
import { Cause, Effect, Exit, Layer } from "effect"
import { expect } from "bun:test"
import { HttpClient } from "effect/unstable/http"
import { backendSupport } from "@kilocode/sandbox"
import { ProjectID } from "@/project/schema"
import { InstanceRef } from "@/effect/instance-ref"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
import * as ToolNetwork from "@/kilocode/sandbox/network"
import { SessionID } from "@/session/schema"
import { TestConfig } from "../../fixture/config"
import { testEffect } from "../../lib/effect"
const sessionID = SessionID.make("ses_sandbox_config_network")
const tool = ToolNetwork.builtin({ id: "webfetch" })
const ctx = {
directory: process.cwd(),
worktree: process.cwd(),
@@ -51,14 +55,19 @@ function server() {
const restricted = testEffect(layer())
const open = testEffect(layer(false))
restricted.live("keeps network restriction enabled by default", () => {
restricted.live("keeps network restriction enabled by default when the sandbox is available", () => {
const target = server()
return Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const exit = yield* SandboxPolicy.execute(http.get(target.server.url)).pipe(
const exit = yield* SandboxPolicy.executeTool(sessionID, tool, http.get(target.server.url)).pipe(
Effect.provideService(InstanceRef, ctx),
Effect.exit,
)
if (!backendSupport.available) {
expect(Exit.isSuccess(exit)).toBe(true)
expect(target.requests()).toBe(1)
return
}
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Sandbox denied outbound network access")
expect(target.requests()).toBe(0)
@@ -69,7 +78,7 @@ open.live("allows tool network traffic when network restriction is disabled", ()
const target = server()
return Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const response = yield* SandboxPolicy.execute(http.get(target.server.url)).pipe(
const response = yield* SandboxPolicy.executeTool(sessionID, tool, http.get(target.server.url)).pipe(
Effect.provideService(InstanceRef, ctx),
)
expect(yield* response.text).toBe("sandbox-config-ok")
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test"
import type { Config as ConfigV1 } from "@kilocode/sdk"
import type { Config as ConfigV2 } from "@kilocode/sdk/v2"
const value = {
experimental: {
sandbox: true,
sandbox_restrict_network: false,
},
}
test("both public SDK Config types expose sandbox policy fields", () => {
const legacy = value satisfies ConfigV1
const current = value satisfies ConfigV2
expect(legacy.experimental).toEqual(current.experimental)
})
@@ -0,0 +1,52 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { BackgroundJob } from "@/background/job"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
import { Session } from "@/session/session"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "@/sync"
import { provideInstance, tmpdirScoped } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
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,
),
)
describe("sandbox session cleanup", () => {
it.live("clears every directory override when removing outside instance context", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const dir = yield* tmpdirScoped({ git: true })
const worktree = yield* tmpdirScoped({ git: true })
const info = yield* provideInstance(dir)(session.create({ title: "sandbox-cleanup" }))
const support = yield* provideInstance(dir)(SandboxPolicy.status(info.id))
if (!support.available) {
yield* session.remove(info.id)
return
}
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* 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)
}),
)
})
@@ -0,0 +1,190 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
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 { Bus } from "@/bus"
import { Config } from "@/config/config"
import * as Network from "@/kilocode/sandbox/network"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
import { SessionID } from "@/session/schema"
import { TestInstance } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const it = testEffect(Layer.mergeAll(Bus.layer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer))
const tool = Network.builtin({ id: "read" })
function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
return SandboxPolicy.executeTool(sessionID, tool, effect)
}
it.instance(
"uses config as the default without persisting session toggles",
() =>
Effect.gen(function* () {
const id = SessionID.make("ses_sandbox_config")
const initial = yield* SandboxPolicy.status(id)
expect(initial.enabled).toBe(initial.available)
expect(initial.version).toBe(0)
if (!initial.available) return
const disabled = yield* SandboxPolicy.toggle(id)
expect(disabled.enabled).toBe(false)
expect(disabled.version).toBe(1)
expect((yield* (yield* Config.Service).get()).experimental?.sandbox).toBe(true)
yield* SandboxPolicy.clear(id)
expect((yield* SandboxPolicy.status(id)).enabled).toBe(true)
}),
{ config: { experimental: { sandbox: true } } },
)
it.instance("runs unrestricted when config is off and no override exists", () =>
Effect.gen(function* () {
const id = SessionID.make("ses_sandbox_default_off")
expect((yield* SandboxPolicy.status(id)).enabled).toBe(false)
expect(yield* execute(id, sandboxed)).toBe(false)
}),
)
it.instance(
"runs sandboxed when config is on and no override exists",
() =>
Effect.gen(function* () {
const id = SessionID.make("ses_sandbox_default_on")
const status = yield* SandboxPolicy.status(id)
expect(status.enabled).toBe(status.available)
expect(yield* execute(id, sandboxed)).toBe(status.available)
}),
{ config: { experimental: { sandbox: true } } },
)
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")
if (!(yield* SandboxPolicy.status(first)).available) return
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false)
expect(yield* execute(first, sandboxed)).toBe(false)
expect(yield* execute(second, sandboxed)).toBe(true)
}),
{ config: { experimental: { sandbox: true } } },
)
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")
if (!(yield* SandboxPolicy.status(first)).available) return
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(true)
expect(yield* execute(first, sandboxed)).toBe(true)
expect(yield* execute(second, sandboxed)).toBe(false)
}),
)
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 support = yield* SandboxPolicy.status(first)
if (!support.available) {
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false)
return
}
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(true)
expect((yield* SandboxPolicy.status(second)).enabled).toBe(false)
expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(true)
expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(false)
expect((yield* SandboxPolicy.status(first)).enabled).toBe(true)
yield* SandboxPolicy.clear(first)
expect((yield* SandboxPolicy.status(first)).enabled).toBe(false)
expect((yield* SandboxPolicy.status(second)).enabled).toBe(false)
}),
)
it.instance("does not activate an unavailable backend", () =>
Effect.gen(function* () {
const id = SessionID.make("ses_sandbox_support")
const result = yield* SandboxPolicy.toggle(id)
if (result.available) return
expect(result.enabled).toBe(false)
expect(result.reason?.length).toBeGreaterThan(0)
}),
)
it.instance("serializes concurrent toggles for a session", () =>
Effect.gen(function* () {
const id = SessionID.make("ses_sandbox_concurrent")
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)
}),
)
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 entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const removal = yield* SandboxPolicy.retire(
id,
test.directory,
Effect.gen(function* () {
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}),
).pipe(Effect.forkChild)
yield* Deferred.await(entered)
const pending = yield* SandboxPolicy.toggleGuarded(id, Effect.fail("deleted")).pipe(Effect.exit, Effect.forkChild)
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(removal)
expect(Exit.isFailure(yield* Fiber.join(pending))).toBe(true)
expect((yield* SandboxPolicy.status(id)).enabled).toBe(false)
}),
)
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")
if (!(yield* SandboxPolicy.status(parent)).available) return
yield* SandboxPolicy.toggle(parent)
expect(yield* execute(parent, execute(child, sandboxed))).toBe(false)
expect(yield* execute(child, execute(parent, sandboxed))).toBe(true)
}),
)
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")
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")
const git = path.join(test.directory, ".git", "denied.txt")
const external = path.join(outside, "denied.txt")
yield* Effect.promise(() => fs.mkdir(path.dirname(git), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(outside, { recursive: true }))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(outside, { recursive: true, force: true })))
const run = (file: string) =>
ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.spawn(ChildProcess.make("/usr/bin/touch", [file])).pipe(Effect.flatMap((child) => child.exitCode)),
)
expect((yield* SandboxPolicy.toggle(id)).enabled).toBe(true)
expect(Number(yield* execute(id, run(inside)))).toBe(0)
expect(Number(yield* execute(id, run(external)))).not.toBe(0)
expect(Number(yield* execute(id, run(git)))).not.toBe(0)
expect((yield* SandboxPolicy.toggle(id)).enabled).toBe(false)
expect(Number(yield* execute(id, run(external)))).toBe(0)
}),
)
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs"
import path from "node:path"
import { indicator } from "@/kilocode/plugins/sandbox"
const source = path.resolve(import.meta.dir, "../../../src/kilocode/plugins/sandbox.tsx")
describe("sandbox TUI", () => {
test("shows an indicator only for an active available sandbox", () => {
expect(indicator({ directory: "/repo", enabled: true, available: true, version: 1 })).toBe("◆ Sandbox on")
expect(indicator({ directory: "/repo", enabled: false, available: true, version: 2 })).toBeUndefined()
expect(
indicator({ directory: "/repo", enabled: false, available: false, reason: "unavailable", version: 0 }),
).toBeUndefined()
})
test("keeps the prompt status contribution mounted while inactive", () => {
const content = fs.readFileSync(source, "utf8")
expect(content).toContain("<box flexShrink={0}>")
expect(content).toContain('indicator(props.status().get(props.sessionID)) ?? ""')
})
test("keeps the session override available independently of the persistent default", () => {
const content = fs.readFileSync(source, "utf8")
expect(content).not.toContain("enabled: () =>")
expect(content).toContain("await ensureSession(api)")
expect(content).toContain("api.client.session.create")
expect(content).toContain('api.route.navigate("session", { sessionID })')
expect(content).toContain("props.api.state.config.experimental?.sandbox")
expect(content).toContain("void props.load(props.sessionID, true)")
expect(content).toContain('api.event.on("sandbox.status.changed"')
})
})
@@ -189,6 +189,33 @@ 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("/session/{sessionID}/sandbox", "sandbox.status")
.seeded((ctx) => ctx.session({ title: "Sandbox status" }))
.at((ctx) => ({
path: route("/session/{sessionID}/sandbox", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, (body) => {
object(body)
check(typeof body.enabled === "boolean", "sandbox status should report enabled state")
check(typeof body.available === "boolean", "sandbox status should report backend availability")
check(typeof body.version === "number", "sandbox status should report its revision")
}),
http.protected
.post("/session/{sessionID}/sandbox/toggle", "sandbox.toggle")
.mutating()
.seeded((ctx) => ctx.session({ title: "Sandbox toggle" }))
.at((ctx) => ({
path: route("/session/{sessionID}/sandbox/toggle", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, (body) => {
object(body)
check(typeof body.enabled === "boolean", "sandbox toggle should report enabled state")
check(typeof body.available === "boolean", "sandbox toggle should report backend availability")
check(typeof body.version === "number", "sandbox toggle should report its revision")
}),
http.protected.get("/remote/status", "remote.status").json(200, (body) => {
object(body)
check(body.enabled === false && body.connected === false, "remote should start disabled")
+8
View File
@@ -1373,6 +1373,14 @@ export type Config = {
* Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)
*/
openTelemetry?: boolean
/**
* Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access
*/
sandbox?: boolean
/**
* Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)
*/
sandbox_restrict_network?: boolean
/**
* Tools that should only be available to primary agents.
*/
+75
View File
@@ -257,6 +257,10 @@ import type {
RemoteEnableResponses,
RemoteStatusErrors,
RemoteStatusResponses,
SandboxStatusErrors,
SandboxStatusResponses,
SandboxToggleErrors,
SandboxToggleResponses,
SessionAbortErrors,
SessionAbortResponses,
SessionChildrenErrors,
@@ -7620,6 +7624,72 @@ export class Remote extends HeyApiClient {
}
}
export class Sandbox extends HeyApiClient {
/**
* Get session sandbox status
*
* Get the effective sandbox state for one session.
*/
public status<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<SandboxStatusResponses, SandboxStatusErrors, ThrowOnError>({
url: "/session/{sessionID}/sandbox",
...options,
...params,
})
}
/**
* Toggle session sandbox
*
* Toggle the ephemeral sandbox override for one session.
*/
public toggle<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<SandboxToggleResponses, SandboxToggleErrors, ThrowOnError>({
url: "/session/{sessionID}/sandbox/toggle",
...options,
...params,
})
}
}
export class Suggestion extends HeyApiClient {
/**
* List pending suggestions
@@ -7993,6 +8063,11 @@ export class KiloClient extends HeyApiClient {
return (this._remote ??= new Remote({ client: this.client }))
}
private _sandbox?: Sandbox
get sandbox(): Sandbox {
return (this._sandbox ??= new Sandbox({ client: this.client }))
}
private _suggestion?: Suggestion
get suggestion(): Suggestion {
return (this._suggestion ??= new Suggestion({ client: this.client }))
+95
View File
@@ -12,6 +12,7 @@ export type Event =
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventSandboxStatusChanged
| EventKilocodeAgentManagerStart
| EventIndexingStatus
| EventIndexingWarning
@@ -930,6 +931,7 @@ export type GlobalEvent = {
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventSandboxStatusChanged
| EventKilocodeAgentManagerStart
| EventIndexingStatus
| EventIndexingWarning
@@ -2989,6 +2991,19 @@ export type EventGlobalConfigUpdated = {
}
}
export type EventSandboxStatusChanged = {
id: string
type: "sandbox.status.changed"
properties: {
sessionID: string
directory: string
enabled: boolean
available: boolean
reason?: string
version: number
}
}
export type EventKilocodeAgentManagerStart = {
id: string
type: "kilocode.agent_manager.start"
@@ -10720,6 +10735,86 @@ export type RemoteStatusResponses = {
export type RemoteStatusResponse = RemoteStatusResponses[keyof RemoteStatusResponses]
export type SandboxStatusData = {
body?: never
path: {
sessionID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/session/{sessionID}/sandbox"
}
export type SandboxStatusErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* NotFoundError
*/
404: NotFoundError
}
export type SandboxStatusError = SandboxStatusErrors[keyof SandboxStatusErrors]
export type SandboxStatusResponses = {
/**
* Session sandbox status
*/
200: {
directory: string
enabled: boolean
available: boolean
reason?: string
version: number
}
}
export type SandboxStatusResponse = SandboxStatusResponses[keyof SandboxStatusResponses]
export type SandboxToggleData = {
body?: never
path: {
sessionID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/session/{sessionID}/sandbox/toggle"
}
export type SandboxToggleErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* NotFoundError
*/
404: NotFoundError
}
export type SandboxToggleError = SandboxToggleErrors[keyof SandboxToggleErrors]
export type SandboxToggleResponses = {
/**
* Updated session sandbox status
*/
200: {
directory: string
enabled: boolean
available: boolean
reason?: string
version: number
}
}
export type SandboxToggleResponse = SandboxToggleResponses[keyof SandboxToggleResponses]
export type KilocodeSessionImportProjectData = {
body?: {
id: string
+236
View File
@@ -15179,6 +15179,192 @@
]
}
},
"/session/{sessionID}/sandbox": {
"get": {
"tags": ["sandbox"],
"operationId": "sandbox.status",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses.*"
},
"required": true
},
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Session sandbox status",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"available": {
"type": "boolean"
},
"reason": {
"type": "string"
},
"version": {
"type": "integer"
}
},
"required": ["directory", "enabled", "available", "version"],
"additionalProperties": false,
"description": "Session sandbox status"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
},
"404": {
"description": "NotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Get the effective sandbox state for one session.",
"summary": "Get session sandbox status",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.sandbox.status({\n ...\n})"
}
]
}
},
"/session/{sessionID}/sandbox/toggle": {
"post": {
"tags": ["sandbox"],
"operationId": "sandbox.toggle",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses.*"
},
"required": true
},
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Updated session sandbox status",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"available": {
"type": "boolean"
},
"reason": {
"type": "string"
},
"version": {
"type": "integer"
}
},
"required": ["directory", "enabled", "available", "version"],
"additionalProperties": false,
"description": "Updated session sandbox status"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
},
"404": {
"description": "NotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Toggle the ephemeral sandbox override for one session.",
"summary": "Toggle session sandbox",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.sandbox.toggle({\n ...\n})"
}
]
}
},
"/kilocode/session-import/project": {
"post": {
"tags": ["session-import"],
@@ -16488,6 +16674,9 @@
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventSandboxStatusChanged"
},
{
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
},
@@ -19271,6 +19460,9 @@
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventSandboxStatusChanged"
},
{
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
},
@@ -25601,6 +25793,46 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSandboxStatusChanged": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["sandbox.status.changed"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"directory": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"available": {
"type": "boolean"
},
"reason": {
"type": "string"
},
"version": {
"type": "integer"
}
},
"required": ["sessionID", "directory", "enabled", "available", "version"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventKilocodeAgent_managerStart": {
"type": "object",
"properties": {
@@ -30144,6 +30376,10 @@
"name": "remote",
"description": "Kilo remote connection routes."
},
{
"name": "sandbox",
"description": "Kilo session sandbox routes."
},
{
"name": "session-import",
"description": "Kilo legacy session import routes."
+4 -4
View File
@@ -112,14 +112,14 @@ const structure = [
...(!registry.includes("ToolNetwork.builtin(result)")
? [" tool/registry.ts must distinguish built-in tools from untrusted custom tools"]
: []),
...(!session.includes("SandboxPolicy.executeTool(item,")
? [" session/tools.ts must route built-in and custom tools through executeTool"]
...(!/SandboxPolicy\.executeTool\(\s*ctx\.sessionID,\s*item,/.test(session)
? [" session/tools.ts must route built-in and custom tools through session-aware executeTool"]
: []),
...(!mcp.includes("SandboxNetwork.remote(tool)")
? [" mcp/index.ts must classify remote MCP delegated authority"]
: []),
...(!session.includes("SandboxPolicy.executeMcp(")
? [" session/tools.ts must route MCP delegated authority through executeMcp"]
...(!/SandboxPolicy\.executeMcp\(\s*ctx\.sessionID,\s*item,/.test(session)
? [" session/tools.ts must route MCP delegated authority through session-aware executeMcp"]
: []),
]