mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge pull request #11628 from Kilo-Org/feat-tui-sandbox-toggle
feat(sandbox): add session sandbox controls
This commit is contained in:
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"]
|
||||
: []),
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user