Merge pull request #11783 from Kilo-Org/bittersweet-height

fix(agent-manager): inherit sandbox for tool-started sessions
This commit is contained in:
Marius
2026-07-14 09:53:55 +02:00
committed by GitHub
15 changed files with 350 additions and 122 deletions
@@ -0,0 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/sdk": patch
"kilo-code": patch
---
Inherit sandbox state when a sandboxed agent starts new Agent Manager sessions.
+7 -7
View File
@@ -802,20 +802,20 @@
},
},
"trustedDependencies": [
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"web-tree-sitter",
"tree-sitter-bash",
"esbuild",
"protobufjs",
],
"patchedDependencies": {
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
"@ai-sdk/xai@3.0.92": "patches/@ai-sdk%2Fxai@3.0.92.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
},
"overrides": {
"@effect/platform-node-shared": "4.0.0-beta.74",
@@ -4754,6 +4754,8 @@
"@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"@anthropic-ai/sandbox-runtime/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="],
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="],
@@ -4764,8 +4766,6 @@
"@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="],
"@anthropic-ai/sandbox-runtime/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="],
@@ -9,13 +9,12 @@ import { isAbsolutePath } from "../path-utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager"
import { handleSection } from "./section-handler"
import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch"
import { normalizeBaseBranch } from "./base-branch"
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
import { PRStatusBridge } from "./pr-status-bridge"
import { GitOps } from "./GitOps"
import { versionedName } from "./branch-name"
import { BranchNamingController } from "./branch-naming"
import { classifyWorktreeError } from "./git-import"
import { SetupScriptService } from "./SetupScriptService"
import { SetupScriptRunner } from "./SetupScriptRunner"
import { copyEnvFiles } from "./env-copy"
@@ -31,6 +30,11 @@ import { AgentManagerVisiblePresence } from "./am-visible-presence"
import { continueInWorktree } from "./continue-in-worktree"
import { WorktreeDiffController } from "./worktree-diff-controller"
import { WorktreeImporter } from "./worktree-importer"
import {
createWorktreeOnDisk,
type CreateWorktreeOnDiskOptions,
type CreateWorktreeOnDiskResult,
} from "./worktree-create"
import { recordPromotionHandoff } from "./promotion-handoff"
import { restoreWorktrees } from "./state-recovery"
import { createLocalDiff, diffSummary as localDiffSummary } from "./local-diff"
@@ -737,107 +741,19 @@ export class AgentManagerProvider implements Disposable {
// Shared helpers
// ---------------------------------------------------------------------------
/** Resolve the effective base branch using the configured default, explicit override, and existence check. */
private async resolveBaseBranch(
manager: WorktreeManager,
state: WorktreeStateManager,
explicit?: string,
): Promise<string | undefined> {
const configured = state.getDefaultBaseBranch()
if (!configured && !explicit) return undefined
const configuredExists = configured ? await manager.branchExists(configured) : false
const result = chooseBaseBranch({ explicit, configured, configuredExists })
if (result.stale) {
this.clearStaleDefaultBaseBranch(state, result.stale)
}
return result.branch
}
/** Reset a stale default base branch and notify the webview. */
private clearStaleDefaultBaseBranch(state: WorktreeStateManager, stale: string): void {
this.log(`Default base branch "${stale}" no longer exists, clearing`)
state.setDefaultBaseBranch(undefined)
this.pushState()
}
/** Create a git worktree on disk and register it in state. Returns null on failure. */
private async createWorktreeOnDisk(opts?: {
groupId?: string
baseBranch?: string
branchName?: string
existingBranch?: string
name?: string
label?: string
}): Promise<{
worktree: ReturnType<WorktreeStateManager["addWorktree"]>
result: CreateWorktreeResult
} | null> {
const manager = this.getWorktreeManager()
const state = this.getStateManager()
if (!manager || !state) {
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "error",
message: "Open a folder that contains a git repository to use worktrees",
errorCode: "not_git_repo",
})
return null
}
this.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Creating git worktree..." })
// Resolve effective base branch using configured default
const effectiveBase = opts?.existingBranch
? undefined
: await this.resolveBaseBranch(manager, state, opts?.baseBranch)
let result: CreateWorktreeResult
try {
result = await manager.createWorktree({
prompt: opts?.name || "kilo",
baseBranch: effectiveBase ?? opts?.baseBranch,
branchName: opts?.branchName,
existingBranch: opts?.existingBranch,
})
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "error",
message: msg,
errorCode: classifyWorktreeError(msg),
})
this.host.capture("Agent Manager Session Error", {
source: PLATFORM,
error: msg,
context: "createWorktree",
})
return null
}
const worktree = state.addWorktree({
branch: result.branch,
path: result.path,
parentBranch: result.parentBranch,
remote: result.remote,
groupId: opts?.groupId,
label: opts?.label,
branchOwned: !opts?.existingBranch,
})
// Push state immediately so the sidebar shows the new worktree with a loading indicator
this.pushState()
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Setting up worktree...",
branch: result.branch,
worktreeId: worktree.id,
})
return { worktree, result }
private async createWorktreeOnDisk(opts?: CreateWorktreeOnDiskOptions): Promise<CreateWorktreeOnDiskResult | null> {
return createWorktreeOnDisk(
{
getWorktreeManager: () => this.getWorktreeManager(),
getStateManager: () => this.getStateManager(),
postToWebview: (message) => this.postToWebview(message),
capture: (event, properties) => this.host.capture(event, properties),
pushState: () => this.pushState(),
log: (...args) => this.log(...args),
},
opts,
)
}
/** Create a CLI session in a worktree directory. Returns null on failure. */
@@ -845,6 +761,7 @@ export class AgentManagerProvider implements Disposable {
worktreePath: string,
branch: string,
worktreeId?: string,
source?: { sandboxInheritanceToken?: string },
): Promise<Session | null> {
let client: KiloClient
try {
@@ -878,7 +795,16 @@ export class AgentManagerProvider implements Disposable {
const { data: session } = await startSession(
client,
worktreePath,
() => client.session.create({ directory: worktreePath, platform: PLATFORM, metadata }, { throwOnError: true }),
() =>
client.session.create(
{
directory: worktreePath,
platform: PLATFORM,
metadata,
...(source?.sandboxInheritanceToken ? { sandboxInheritanceToken: source.sandboxInheritanceToken } : {}),
},
{ throwOnError: true },
),
(...args) => this.log(...args),
)
return session
@@ -985,7 +911,9 @@ export class AgentManagerProvider implements Disposable {
const properties = (event as { properties?: unknown }).properties
const req = parseToolRequest(properties)
if (!req) return
if (directory) req.directory = directory
if (directory) {
req.directory = directory
}
void this.startToolRequest(req)
}
@@ -1012,7 +940,7 @@ export class AgentManagerProvider implements Disposable {
this.pushState()
},
setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id),
createSessionInWorktree: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id),
createSessionInWorktree: (dir, branch, id, source) => this.createSessionInWorktree(dir, branch, id, source),
sessionMetadata: (client, dir) => sandboxSessionMetadata(this.connectionService.sandboxPreference, client, dir),
registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir),
notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid),
@@ -21,11 +21,16 @@ export interface ToolRequest {
requestID: string
sessionID?: string
directory?: string
sandboxInheritanceToken?: string
mode: "worktree" | "local"
versions?: boolean
tasks: ToolTask[]
}
export interface ToolSource {
sandboxInheritanceToken?: string
}
interface WorktreeCreated {
worktree: ReturnType<WorktreeStateManager["addWorktree"]>
result: CreateWorktreeResult
@@ -47,7 +52,7 @@ export interface ToolDeps {
claimRequest?: (requestID: string) => boolean
cleanupWorktree: (wid: string, dir: string) => Promise<void>
setup: (dir: string, branch?: string, id?: string) => Promise<void>
createSessionInWorktree: (dir: string, branch: string, id?: string) => Promise<Session | null>
createSessionInWorktree: (dir: string, branch: string, id?: string, source?: ToolSource) => Promise<Session | null>
sessionMetadata: (client: KiloClient, dir: string) => Promise<Record<string, unknown>>
registerWorktreeSession: (sid: string, dir: string) => void
notifyReady: (sid: string, result: CreateWorktreeResult, wid?: string) => void
@@ -116,7 +121,7 @@ async function prompt(client: KiloClient, sid: string, dir: string, task: ToolTa
)
}
async function local(deps: ToolDeps, client: KiloClient, task: ToolTask, directory?: string) {
async function local(deps: ToolDeps, client: KiloClient, task: ToolTask, directory?: string, source?: ToolSource) {
const root = deps.getRoot()
const state = deps.getState()
if (!root || !state) return false
@@ -135,7 +140,12 @@ async function local(deps: ToolDeps, client: KiloClient, task: ToolTask, directo
const target = wt?.path ?? root
const metadata = await deps.sessionMetadata(client, target)
const { data } = await client.session.create(
{ directory: target, platform: PLATFORM, metadata },
{
directory: target,
platform: PLATFORM,
metadata,
...(source?.sandboxInheritanceToken ? { sandboxInheritanceToken: source.sandboxInheritanceToken } : {}),
},
{ throwOnError: true },
)
const session = data
@@ -163,6 +173,7 @@ async function worktree(
total: number,
groupId?: string,
versions?: boolean,
source?: ToolSource,
) {
const baseBranch = branch(task.branchName) ?? branch(task.name)
const baseLabel = label(task.name) ?? label(task.branchName) ?? label(task.prompt)
@@ -176,7 +187,12 @@ async function worktree(
if (!created) return false
await deps.setup(created.result.path, created.result.branch, created.worktree.id)
const session = await deps.createSessionInWorktree(created.result.path, created.result.branch, created.worktree.id)
const session = await deps.createSessionInWorktree(
created.result.path,
created.result.branch,
created.worktree.id,
source,
)
if (!session) {
await deps.cleanupWorktree(created.worktree.id, created.result.path)
return false
@@ -216,6 +232,7 @@ export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise<v
const versions = req.mode === "worktree" && req.versions === true && total > 1
const groupId = versions ? `grp-${Date.now()}` : undefined
const state = { ok: 0 }
const source = { sandboxInheritanceToken: req.sandboxInheritanceToken }
deps.post({ type: "agentManager.multiVersionProgress", status: "creating", total, completed: 0, groupId })
for (let i = 0; i < req.tasks.length; i++) {
@@ -223,8 +240,8 @@ export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise<v
try {
const done =
req.mode === "local"
? await local(deps, client, task, req.directory)
: await worktree(deps, client, task, i, total, groupId, versions)
? await local(deps, client, task, req.directory, source)
: await worktree(deps, client, task, i, total, groupId, versions, source)
if (done) state.ok++
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
@@ -284,6 +301,8 @@ export function parseToolRequest(value: unknown): ToolRequest | undefined {
requestID: typeof value.requestID === "string" ? value.requestID : `am-${Date.now()}`,
sessionID: typeof value.sessionID === "string" ? value.sessionID : undefined,
directory: typeof value.directory === "string" ? value.directory : undefined,
sandboxInheritanceToken:
typeof value.sandboxInheritanceToken === "string" ? value.sandboxInheritanceToken : undefined,
mode,
versions: typeof value.versions === "boolean" ? value.versions : undefined,
tasks: parsed,
@@ -0,0 +1,132 @@
import type { Worktree, WorktreeStateManager } from "./WorktreeStateManager"
import type { WorktreeManager, CreateWorktreeResult } from "./WorktreeManager"
import { chooseBaseBranch } from "./base-branch"
import { classifyWorktreeError } from "./git-import"
import { PLATFORM } from "./constants"
import type { AgentManagerOutMessage } from "./types"
export type CreateWorktreeOnDiskOptions = {
groupId?: string
baseBranch?: string
branchName?: string
existingBranch?: string
name?: string
label?: string
}
export type CreateWorktreeOnDiskResult = {
worktree: Worktree
result: CreateWorktreeResult
}
export interface CreateWorktreeOnDiskContext {
getWorktreeManager: () => WorktreeManager | undefined
getStateManager: () => WorktreeStateManager | undefined
postToWebview: (message: AgentManagerOutMessage) => void
capture: (event: string, properties?: Record<string, unknown>) => void
pushState: () => void
log: (...args: unknown[]) => void
}
/**
* Create a git worktree on disk and register it in state. Returns null on failure.
*
* Pure orchestration — no vscode imports.
*/
export async function createWorktreeOnDisk(
ctx: CreateWorktreeOnDiskContext,
opts?: CreateWorktreeOnDiskOptions,
): Promise<CreateWorktreeOnDiskResult | null> {
const manager = ctx.getWorktreeManager()
const state = ctx.getStateManager()
if (!manager || !state) {
ctx.postToWebview({
type: "agentManager.worktreeSetup",
status: "error",
message: "Open a folder that contains a git repository to use worktrees",
errorCode: "not_git_repo",
})
return null
}
ctx.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Creating git worktree..." })
// Resolve effective base branch using configured default
const effectiveBase = opts?.existingBranch
? undefined
: await resolveBaseBranch(ctx, manager, state, opts?.baseBranch)
let result: CreateWorktreeResult
try {
result = await manager.createWorktree({
prompt: opts?.name || "kilo",
baseBranch: effectiveBase ?? opts?.baseBranch,
branchName: opts?.branchName,
existingBranch: opts?.existingBranch,
})
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
ctx.postToWebview({
type: "agentManager.worktreeSetup",
status: "error",
message: msg,
errorCode: classifyWorktreeError(msg),
})
ctx.capture("Agent Manager Session Error", {
source: PLATFORM,
error: msg,
context: "createWorktree",
})
return null
}
const worktree = state.addWorktree({
branch: result.branch,
path: result.path,
parentBranch: result.parentBranch,
remote: result.remote,
groupId: opts?.groupId,
label: opts?.label,
branchOwned: !opts?.existingBranch,
})
// Push state immediately so the sidebar shows the new worktree with a loading indicator
ctx.pushState()
ctx.postToWebview({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Setting up worktree...",
branch: result.branch,
worktreeId: worktree.id,
})
return { worktree, result }
}
/** Resolve the effective base branch using the configured default, explicit override, and existence check. */
async function resolveBaseBranch(
ctx: CreateWorktreeOnDiskContext,
manager: WorktreeManager,
state: WorktreeStateManager,
explicit?: string,
): Promise<string | undefined> {
const configured = state.getDefaultBaseBranch()
if (!configured && !explicit) return undefined
const configuredExists = configured ? await manager.branchExists(configured) : false
const result = chooseBaseBranch({ explicit, configured, configuredExists })
if (result.stale) clearStaleDefaultBaseBranch(ctx, state, result.stale)
return result.branch
}
/** Reset a stale default base branch and notify the webview. */
function clearStaleDefaultBaseBranch(
ctx: CreateWorktreeOnDiskContext,
state: WorktreeStateManager,
stale: string,
): void {
ctx.log(`Default base branch "${stale}" no longer exists, clearing`)
state.setDefaultBaseBranch(undefined)
ctx.pushState()
}
@@ -144,6 +144,29 @@ describe("agent manager tool start", () => {
)
})
it("passes sandbox inheritance token to local sessions", async () => {
const client = {
session: {
create: mock(async () => ({ data: session("s-local") })),
promptAsync: mock(async () => ({})),
},
}
const c = deps({ getClient: () => client as never })
await startFromTool(c, {
requestID: "am-local-source",
sessionID: "s-parent",
sandboxInheritanceToken: "si-token",
mode: "local",
tasks: [{ prompt: "Do work" }],
})
expect(client.session.create).toHaveBeenCalledWith(
expect.objectContaining({ sandboxInheritanceToken: "si-token" }),
{ throwOnError: true },
)
})
it("starts local sessions when Windows drive-letter casing differs", async () => {
setPlatform("win32")
const client = {
@@ -191,6 +214,8 @@ describe("agent manager tool start", () => {
const c = deps({ getClient: () => client as never })
await startFromTool(c, {
requestID: "am-2",
sessionID: "s-parent",
sandboxInheritanceToken: "si-token",
mode: "worktree",
tasks: [
{
@@ -206,7 +231,9 @@ describe("agent manager tool start", () => {
expect.objectContaining({ branchName: "fix-one", name: "fix-one", label: "one" }),
)
expect(c.setup).toHaveBeenCalled()
expect(c.createSessionInWorktree).toHaveBeenCalled()
expect(c.createSessionInWorktree).toHaveBeenCalledWith("/repo/.kilo/worktrees/wt-1", "kilo/test", "wt-1", {
sandboxInheritanceToken: "si-token",
})
expect(c.registerWorktreeSession).toHaveBeenCalledWith("s-wt", "/repo/.kilo/worktrees/wt-1")
expect(c.notifyReady).toHaveBeenCalled()
expect(client.session.promptAsync).toHaveBeenCalledWith(
@@ -24,6 +24,7 @@ export const AgentManagerMode = Schema.Literals(["worktree", "local"])
export const AgentManagerStart = Schema.Struct({
requestID: Schema.String,
sessionID: SessionID,
sandboxInheritanceToken: Schema.optional(Schema.String),
mode: AgentManagerMode,
versions: Schema.optional(Schema.Boolean),
tasks: Schema.Array(AgentManagerTask).check(Schema.isMinLength(1), Schema.isMaxLength(20)),
@@ -0,0 +1,40 @@
import { randomUUID } from "node:crypto"
import type { SessionID } from "@/session/schema"
interface Grant {
sessionID: SessionID
directory: string
expires: number
remaining: number
}
const ttl = 24 * 60 * 60 * 1000
const grants = new Map<string, Grant>()
function cleanup(now = Date.now()) {
for (const [token, grant] of grants) {
if (grant.expires <= now || grant.remaining <= 0) grants.delete(token)
}
}
export function issue(input: { sessionID: SessionID; directory: string; count: number }) {
cleanup()
const token = `si-${randomUUID()}`
grants.set(token, {
sessionID: input.sessionID,
directory: input.directory,
expires: Date.now() + ttl,
remaining: Math.max(1, input.count),
})
return token
}
export function consume(token: string | undefined) {
if (!token) return undefined
cleanup()
const grant = grants.get(token)
if (!grant) return undefined
grant.remaining--
if (grant.remaining <= 0) grants.delete(token)
return { sessionID: grant.sessionID, directory: grant.directory }
}
@@ -364,12 +364,14 @@ export const inherit = Effect.fn("SandboxPolicy.inherit")(function* (
parentID: SessionID,
sessionID: SessionID,
fallback?: Omit<Snapshot, "version">,
sourceDirectory?: string,
) {
const directory = yield* InstanceState.directory
const source = sourceDirectory ?? directory
yield* locked(
parentID,
Effect.gen(function* () {
const stored = yield* read(directory, parentID)
const stored = yield* read(source, parentID)
const parent: Snapshot | undefined = stored ?? (fallback && { ...fallback, version: 0 })
if (!parent) return
// Only persist the parent snapshot when it actually belongs to this directory. A fallback
@@ -1,6 +1,8 @@
// kilocode_change - new file
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { AgentManagerEvent, type AgentManagerTask } from "@/kilocode/agent-manager/event"
import * as SandboxInheritance from "@/kilocode/sandbox/inheritance"
import { KiloSessionMessageOrder } from "@/kilocode/session/message-order"
import { Provider } from "@/provider/provider"
import { Tool } from "@/tool/tool"
@@ -237,9 +239,16 @@ export const AgentManagerTool = Tool.define<
})
const requestID = `am-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const directory = yield* InstanceState.directory
const sandboxInheritanceToken = SandboxInheritance.issue({
sessionID: ctx.sessionID,
directory,
count: params.tasks.length,
})
yield* bus.publish(AgentManagerEvent.Start, {
requestID,
sessionID: ctx.sessionID,
sandboxInheritanceToken,
mode: params.mode,
versions: params.versions,
tasks,
+20 -1
View File
@@ -32,6 +32,7 @@ import { Permission } from "@/permission"
import { Global } from "@opencode-ai/core/global"
// kilocode_change start - Kilo session behavior extensions
import { BackgroundProcess } from "@/kilocode/background-process"
import * as SandboxInheritance from "@/kilocode/sandbox/inheritance"
import { InteractiveTerminal } from "@/kilocode/interactive-terminal"
import { KiloSession } from "@/kilocode/session"
import { kiloSessionFork } from "@/kilocode/session/fork-command"
@@ -259,7 +260,10 @@ export const CreateInput = Schema.optional(
metadata: Schema.optional(Metadata),
permission: Schema.optional(PermissionV1.Ruleset),
platform: Schema.optional(Schema.String), // kilocode_change - per-session platform override for telemetry attribution
// kilocode_change start - server-issued sandbox inheritance grant
workspaceID: Schema.optional(WorkspaceV2.ID),
sandboxInheritanceToken: Schema.optional(Schema.String),
// kilocode_change end
}),
)
export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>>
@@ -490,6 +494,7 @@ export type NotFound = NotFoundError
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
// kilocode_change start - session create metadata and sandbox inheritance extensions
readonly listGlobal: (input?: GlobalListInput) => Effect.Effect<GlobalInfo[]>
readonly create: (input?: {
parentID?: SessionID
@@ -500,7 +505,9 @@ export interface Interface {
permission?: PermissionV1.Ruleset
platform?: string // kilocode_change - per-session platform override for telemetry attribution
workspaceID?: WorkspaceV2.ID
sandboxInheritanceToken?: string
}) => Effect.Effect<Info>
// kilocode_change end
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
readonly get: (id: SessionID) => Effect.Effect<Info, NotFound>
@@ -569,6 +576,7 @@ export const layer: Layer.Layer<
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
// kilocode_change start - inherited sandbox policy source
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
title?: string
@@ -582,6 +590,7 @@ export const layer: Layer.Layer<
permission?: PermissionV1.Ruleset
platform?: string // kilocode_change - per-session platform override for telemetry attribution
sourceID?: SessionID // kilocode_change - inherited sandbox policy source
sourceDirectory?: string
sandboxFallback?: SandboxPolicy.Snapshot // kilocode_change - confinement to seed when source state lives in another directory
}) {
const ctx = yield* InstanceState.context
@@ -607,6 +616,7 @@ export const layer: Layer.Layer<
},
}
log.info("created", result)
// kilocode_change end
// kilocode_change start - legacy sessions must satisfy the upstream project foreign key
yield* db
@@ -627,7 +637,7 @@ export const layer: Layer.Layer<
// kilocode_change start - initialize inherited state before session.created subscribers run
KiloSession.register({ id: result.id, parentID: result.parentID, platform: input.platform })
const source = input.sourceID ?? result.parentID
if (source) yield* SandboxPolicy.inherit(source, result.id, input.sandboxFallback)
if (source) yield* SandboxPolicy.inherit(source, result.id, input.sandboxFallback, input.sourceDirectory)
// kilocode_change end
yield* events.publish(SessionV1.Event.Created, { sessionID: result.id, info: result })
@@ -769,6 +779,7 @@ export const layer: Layer.Layer<
} as SessionV1.Part
})
// kilocode_change start - session create metadata and sandbox inheritance extensions
const create = Effect.fn("Session.create")(function* (input?: {
parentID?: SessionID
title?: string
@@ -778,9 +789,14 @@ export const layer: Layer.Layer<
permission?: PermissionV1.Ruleset
platform?: string // kilocode_change - per-session platform override for telemetry attribution
workspaceID?: WorkspaceV2.ID
sandboxInheritanceToken?: string
}) {
const ctx = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
const grant = SandboxInheritance.consume(input?.sandboxInheritanceToken)
if (input?.sandboxInheritanceToken && !grant) yield* Effect.die(new Error("Invalid sandbox inheritance token"))
// kilocode_change end
// kilocode_change start - propagate trusted sandbox inheritance grant
const session = yield* createNext({
parentID: input?.parentID,
directory: ctx.directory,
@@ -791,8 +807,11 @@ export const layer: Layer.Layer<
metadata: input?.metadata,
permission: input?.permission,
platform: input?.platform, // kilocode_change
sourceID: grant?.sessionID, // kilocode_change
sourceDirectory: grant?.directory, // kilocode_change
workspaceID: input?.workspaceID ?? workspace,
})
// kilocode_change end
return session
})
@@ -1,7 +1,7 @@
import fs from "node:fs/promises"
import path from "node:path"
import { $ } from "bun"
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Deferred, Effect, Exit, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Database } from "@opencode-ai/core/database/database"
@@ -14,8 +14,10 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { BackgroundProcess } from "@/kilocode/background-process"
import { Notebook } from "@/kilocode/notebook/service"
import * as SandboxActivation from "@/kilocode/sandbox/activation"
import * as SandboxInheritance from "@/kilocode/sandbox/inheritance"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
import { SandboxStore } from "@/kilocode/sandbox/store"
import type { SessionID } from "@/session/schema"
import { Session } from "@/session/session"
import { SessionStatus } from "@/session/status"
import { Shell } from "@/shell/shell"
@@ -95,6 +97,19 @@ function activate(sessionID: Session.Info["id"]) {
}
describe("sandbox session cleanup", () => {
test("keeps inheritance grants valid across slow worktree setup", () => {
const now = Date.now
try {
Date.now = () => 1_700_000_000_000
const sid = "session" as SessionID
const token = SandboxInheritance.issue({ sessionID: sid, directory: "/repo", count: 1 })
Date.now = () => 1_700_000_000_000 + 6 * 60 * 1000
expect(SandboxInheritance.consume(token)).toEqual({ sessionID: sid, directory: "/repo" })
} finally {
Date.now = now
}
})
it.live("forks inherit the source session snapshot", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
@@ -112,6 +127,25 @@ describe("sandbox session cleanup", () => {
}),
)
it.live("created sessions inherit the source snapshot across directories", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const dir = yield* tmpdirScoped({ git: true, config: { sandbox: { enabled: true } } })
const worktree = yield* tmpdirScoped({ git: true })
const source = yield* provideInstance(dir)(sessions.create({ title: "sandbox-source" }))
const status = yield* provideInstance(dir)(SandboxPolicy.status(source.id))
if (!status.available) return
const token = SandboxInheritance.issue({ sessionID: source.id, directory: dir, count: 1 })
const child = yield* provideInstance(worktree)(sessions.create({ title: "sandbox-child", sandboxInheritanceToken: token }))
expect((yield* provideInstance(worktree)(SandboxPolicy.status(child.id))).enabled).toBe(true)
yield* provideInstance(dir)(SandboxPolicy.toggle(source.id))
expect((yield* provideInstance(dir)(SandboxPolicy.status(source.id))).enabled).toBe(false)
expect((yield* provideInstance(worktree)(SandboxPolicy.status(child.id))).enabled).toBe(true)
}),
)
it.live("forks into another directory carry the source confinement", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
+2
View File
@@ -4134,6 +4134,7 @@ export class Session2 extends HeyApiClient {
permission?: PermissionRuleset
platform?: string
workspaceID?: string
sandboxInheritanceToken?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -4152,6 +4153,7 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "permission" },
{ in: "body", key: "platform" },
{ in: "body", key: "workspaceID" },
{ in: "body", key: "sandboxInheritanceToken" },
],
},
],
+2
View File
@@ -3538,6 +3538,7 @@ export type EventKilocodeAgentManagerStart = {
properties: {
requestID: string
sessionID: string
sandboxInheritanceToken?: string
mode: "worktree" | "local"
versions?: boolean
tasks: Array<{
@@ -8796,6 +8797,7 @@ export type SessionCreateData = {
permission?: PermissionRuleset
platform?: string
workspaceID?: string
sandboxInheritanceToken?: string
}
path?: never
query?: {
+6
View File
@@ -6143,6 +6143,9 @@
"workspaceID": {
"type": "string",
"pattern": "^wrk"
},
"sandboxInheritanceToken": {
"type": "string"
}
},
"additionalProperties": false
@@ -32398,6 +32401,9 @@
"type": "string",
"pattern": "^ses"
},
"sandboxInheritanceToken": {
"type": "string"
},
"mode": {
"type": "string",
"enum": ["worktree", "local"]