diff --git a/.changeset/inherit-agent-manager-sandbox.md b/.changeset/inherit-agent-manager-sandbox.md new file mode 100644 index 0000000000..7ecb13576d --- /dev/null +++ b/.changeset/inherit-agent-manager-sandbox.md @@ -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. diff --git a/bun.lock b/bun.lock index bcb9d8fc10..e0f443bced 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 7a1526c06c..7b3fd994d6 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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 { - 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 - 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 { + 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 { 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), diff --git a/packages/kilo-vscode/src/agent-manager/tool-start.ts b/packages/kilo-vscode/src/agent-manager/tool-start.ts index 9a9f22c313..927a649537 100644 --- a/packages/kilo-vscode/src/agent-manager/tool-start.ts +++ b/packages/kilo-vscode/src/agent-manager/tool-start.ts @@ -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 result: CreateWorktreeResult @@ -47,7 +52,7 @@ export interface ToolDeps { claimRequest?: (requestID: string) => boolean cleanupWorktree: (wid: string, dir: string) => Promise setup: (dir: string, branch?: string, id?: string) => Promise - createSessionInWorktree: (dir: string, branch: string, id?: string) => Promise + createSessionInWorktree: (dir: string, branch: string, id?: string, source?: ToolSource) => Promise sessionMetadata: (client: KiloClient, dir: string) => Promise> 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 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 WorktreeManager | undefined + getStateManager: () => WorktreeStateManager | undefined + postToWebview: (message: AgentManagerOutMessage) => void + capture: (event: string, properties?: Record) => 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 { + 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 { + 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() +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts index ddba635ed7..5db5002a5d 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts @@ -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( diff --git a/packages/opencode/src/kilocode/agent-manager/event.ts b/packages/opencode/src/kilocode/agent-manager/event.ts index 8a1c2b14a4..9015c39d19 100644 --- a/packages/opencode/src/kilocode/agent-manager/event.ts +++ b/packages/opencode/src/kilocode/agent-manager/event.ts @@ -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)), diff --git a/packages/opencode/src/kilocode/sandbox/inheritance.ts b/packages/opencode/src/kilocode/sandbox/inheritance.ts new file mode 100644 index 0000000000..6601f6e6f8 --- /dev/null +++ b/packages/opencode/src/kilocode/sandbox/inheritance.ts @@ -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() + +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 } +} diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index fb5bbdf192..77999ca260 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -364,12 +364,14 @@ export const inherit = Effect.fn("SandboxPolicy.inherit")(function* ( parentID: SessionID, sessionID: SessionID, fallback?: Omit, + 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 diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index b042e26ee3..642a6ceb2a 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -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, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 8462922644..bb0702b2b9 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -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> @@ -490,6 +494,7 @@ export type NotFound = NotFoundError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect + // kilocode_change start - session create metadata and sandbox inheritance extensions readonly listGlobal: (input?: GlobalListInput) => Effect.Effect 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 + // kilocode_change end readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect readonly get: (id: SessionID) => Effect.Effect @@ -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 }) diff --git a/packages/opencode/test/kilocode/sandbox/session.test.ts b/packages/opencode/test/kilocode/sandbox/session.test.ts index 85b735c303..07c87781f2 100644 --- a/packages/opencode/test/kilocode/sandbox/session.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session.test.ts @@ -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 diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 086b49ae22..37b4622632 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4134,6 +4134,7 @@ export class Session2 extends HeyApiClient { permission?: PermissionRuleset platform?: string workspaceID?: string + sandboxInheritanceToken?: string }, options?: Options, ) { @@ -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" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 122d5e87e8..7196dba704 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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?: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index f049600ebe..78478a8b38 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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"]