fix(cli): apply saved sandbox settings to existing sessions (#12600)

* fix(cli): apply saved sandbox settings to existing sessions

* fix(cli): emit config update event on global overlay saves

* fix(cli): scope live sandbox policy refresh

* fix(cli): refresh project sandbox policies

* test(cli): use external path for sandbox overlay
This commit is contained in:
Marius
2026-08-07 13:37:06 +02:00
committed by GitHub
parent a22d5c02ba
commit 4e36297668
43 changed files with 930 additions and 151 deletions
@@ -12,6 +12,7 @@ import { Npm } from "@opencode-ai/core/npm"
import { HttpClient } from "effect/unstable/http"
import { Account } from "../../../src/account/account"
import { Auth } from "../../../src/auth"
import { GlobalBus } from "../../../src/bus/global"
import { Config } from "../../../src/config/config"
import { ConfigMarkdown } from "../../../src/config/markdown"
import { ConfigParse } from "../../../src/config/parse"
@@ -110,6 +111,45 @@ describe("markdown substitutions", () => {
})
describe("global config updates", () => {
test("marks only sandbox updates for live policy refresh", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir()
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear()
await disposeAllInstances()
const events: Array<{ payload?: { type?: string; properties?: { sandbox?: boolean } } }> = []
const listener = (event: (typeof events)[number]) => events.push(event)
GlobalBus.on("event", listener)
try {
await provideTestInstance({
directory: tmp.path,
fn: async () => {
await Effect.runPromise(
Config.Service.use((svc) =>
Effect.all([
svc.updateGlobal({ permission: { edit: "ask" } }, { dispose: false }),
svc.updateGlobal({ sandbox: { network: "deny" } }, { dispose: false }),
]),
).pipe(Effect.scoped, Effect.provide(layer)),
)
},
})
expect(
events
.filter((event) => event.payload?.type === "global.config.updated")
.map((event) => event.payload?.properties?.sandbox),
).toEqual([false, true])
} finally {
GlobalBus.off("event", listener)
;(Global.Path as { config: string }).config = prev
await clear()
await disposeAllInstances()
}
})
test("preserves concurrent permission updates", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir()
@@ -10,6 +10,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { Database } from "@opencode-ai/core/database/database"
import { assertNetwork, assertWrite, enabled as sandboxed } from "@kilocode/sandbox"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Config } from "@/config/config"
import * as Network from "@/kilocode/sandbox/network"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
@@ -34,7 +35,7 @@ function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>)
return SandboxPolicy.executeTool(sessionID, tool, effect)
}
test("restores the session snapshot after a backend restart", async () => {
test("refreshes the session snapshot after a backend restart", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-sandbox-restart-"))
const directory = path.join(root, "project")
await fs.mkdir(directory)
@@ -98,7 +99,13 @@ test("restores the session snapshot after a backend restart", async () => {
const restored = run({
sandbox: { enabled: false, network: "deny", allowed_hosts: ["evil.example"], writable_paths: ["/tmp/evil"] },
})
expect(restored.state).toEqual(initial.state)
expect(restored.state).toEqual({
enabled: true,
mode: "proxy",
allowedHosts: ["evil.example:443"],
writablePaths: ["/tmp/evil"],
version: 1,
})
expect(restored.status.enabled).toBe(restored.status.available)
} finally {
await fs.rm(root, { recursive: true, force: true })
@@ -178,7 +185,7 @@ linux("reports configured network namespace availability", async () => {
}
})
it.instance("snapshots the primary kilo config for the session lifetime", () =>
it.instance("does not let project config weaken an initialized policy", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const password = Flag.KILO_SERVER_PASSWORD
@@ -208,6 +215,7 @@ it.instance("snapshots the primary kilo config for the session lifetime", () =>
expect((yield* SandboxPolicy.status(id)).enabled).toBe(true)
expect(yield* execute(id, sandboxed)).toBe(true)
expect(Exit.isFailure(yield* execute(id, assertNetwork("https://example.com").pipe(Effect.exit)))).toBe(true)
expect(yield* SandboxPolicy.peek(test.directory, id)).toMatchObject({ mode: "deny", version: 0 })
const next = SessionID.make("ses_sandbox_config_next")
expect((yield* SandboxPolicy.status(next)).enabled).toBe(false)
@@ -265,6 +273,182 @@ it.instance("applies configured writable paths during tool execution", () =>
}),
)
it.instance("refreshes an initialized policy from current settings", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const id = SessionID.make("ses_sandbox_refresh")
yield* Effect.promise(() =>
SandboxStore.write(test.directory, id, {
enabled: false,
mode: "deny",
allowedHosts: [],
writablePaths: [],
version: 0,
}),
)
yield* SandboxPolicy.peek(test.directory, id)
const changed = yield* SandboxPolicy.refresh(id).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () =>
Effect.succeed({
sandbox: { network: "allow", writable_paths: ["~/sandbox-refresh"] },
}),
}),
),
)
expect(changed).toBe(true)
expect(yield* SandboxPolicy.peek(test.directory, id)).toEqual({
enabled: false,
mode: "allow",
allowedHosts: [],
writablePaths: [path.join(os.homedir(), "sandbox-refresh")],
version: 1,
})
}),
)
it.instance("uses current settings when enabling an initialized policy", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const id = SessionID.make("ses_sandbox_enable_refresh")
yield* Effect.promise(() =>
SandboxStore.write(test.directory, id, {
enabled: false,
mode: "deny",
allowedHosts: [],
writablePaths: [],
version: 0,
}),
)
const status = yield* SandboxPolicy.toggle(id).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () =>
Effect.succeed({
sandbox: { enabled: true, network: "allow", writable_paths: ["/sandbox-enable-refresh"] },
}),
}),
),
)
if (!status.available) {
expect(status.enabled).toBe(false)
expect(status.version).toBe(0)
expect(yield* SandboxPolicy.peek(test.directory, id)).toEqual({
enabled: false,
mode: "deny",
allowedHosts: [],
writablePaths: [],
version: 0,
})
return
}
expect(status.enabled).toBe(true)
expect(status.version).toBe(1)
expect(yield* SandboxPolicy.peek(test.directory, id)).toEqual({
enabled: true,
mode: "allow",
allowedHosts: [],
writablePaths: ["/sandbox-enable-refresh"],
version: 1,
})
}),
)
it.instance("applies trusted settings to inherited sessions", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const parent = SessionID.make("ses_sandbox_refresh_parent")
const child = SessionID.make("ses_sandbox_refresh_child")
yield* Effect.promise(() =>
SandboxStore.write(test.directory, parent, {
enabled: true,
mode: "deny",
allowedHosts: [],
writablePaths: ["/shared"],
version: 0,
}),
)
yield* Effect.promise(() =>
SandboxStore.write(test.directory, child, {
enabled: false,
mode: "deny",
allowedHosts: [],
writablePaths: ["/shared"],
version: 0,
}),
)
yield* SandboxPolicy.peek(test.directory, parent)
yield* SandboxPolicy.peek(test.directory, child)
const config = Layer.mock(Config.Service, {
get: () =>
Effect.succeed({
sandbox: { network: "allow", writable_paths: ["/shared", "/new"] },
}),
})
yield* SandboxPolicy.refresh(parent).pipe(Effect.provide(config))
yield* SandboxPolicy.refresh(child).pipe(Effect.provide(config))
expect(yield* SandboxPolicy.peek(test.directory, parent)).toMatchObject({
enabled: true,
mode: "allow",
writablePaths: ["/shared", "/new"],
})
expect(yield* SandboxPolicy.peek(test.directory, child)).toEqual({
enabled: false,
mode: "allow",
allowedHosts: [],
writablePaths: ["/shared", "/new"],
version: 1,
})
}),
)
it.instance("emits a sandbox status event after refreshing policy", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const id = SessionID.make("ses_sandbox_refresh_event")
yield* Effect.promise(() =>
SandboxStore.write(test.directory, id, {
enabled: true,
mode: "deny",
allowedHosts: [],
writablePaths: [],
version: 0,
}),
)
const events: Array<{ directory?: string; payload: { type?: string; properties?: { sessionID?: string } } }> = []
const listener = (event: (typeof events)[number]) => events.push(event)
GlobalBus.on("event", listener)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener)))
yield* SandboxPolicy.refresh(id).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () => Effect.succeed({ sandbox: { network: "allow" } }),
}),
),
)
expect(events).toContainEqual(
expect.objectContaining({
directory: test.directory,
payload: expect.objectContaining({
id: expect.any(String),
type: "sandbox.status.changed",
properties: expect.objectContaining({ sessionID: id }),
}),
}),
)
}),
)
it.instance(
"runs sandboxed when config is on and no override exists",
() =>
@@ -412,6 +596,45 @@ it.instance("serializes activation with unrestricted tool start", () =>
}),
)
it.instance("refreshes queued tools after config changes", () =>
(() => {
const config = { sandbox: { enabled: true, network: "allow" as "allow" | "deny" } }
return Effect.gen(function* () {
const id = SessionID.make("ses_sandbox_queued_refresh")
if (!(yield* SandboxPolicy.status(id)).available) return
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const running = yield* execute(
id,
Effect.gen(function* () {
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
return false
}),
).pipe(Effect.forkChild)
yield* Deferred.await(entered)
const queued = yield* execute(id, assertNetwork("https://example.com").pipe(Effect.exit)).pipe(Effect.forkChild)
config.sandbox.network = "deny"
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.config.updated", properties: { sandbox: true } },
})
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(running)
expect(Exit.isFailure(yield* Fiber.join(queued))).toBe(true)
expect(yield* SandboxPolicy.peek((yield* TestInstance).directory, id)).toMatchObject({ mode: "deny" })
}).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () => Effect.succeed(config),
}),
),
)
})(),
)
it.instance("prevents a queued toggle from restoring a retired override", () =>
Effect.gen(function* () {
const test = yield* TestInstance
@@ -501,6 +724,56 @@ it.instance("intersects inherited network and write authority", () =>
}),
)
it.instance("refreshes a child inherited while its parent policy is stale", () =>
(() => {
const config = { sandbox: { enabled: true, network: "allow" as "allow" | "deny" } }
return Effect.gen(function* () {
const parent = SessionID.make("ses_sandbox_stale_parent")
const child = SessionID.make("ses_sandbox_stale_child")
yield* SandboxPolicy.status(parent)
config.sandbox.network = "deny"
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.config.updated", properties: { sandbox: true } },
})
yield* SandboxPolicy.inherit(parent, child)
yield* SandboxPolicy.status(child)
expect(yield* SandboxPolicy.peek((yield* TestInstance).directory, child)).toMatchObject({ mode: "deny" })
}).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () => Effect.succeed(config),
}),
),
)
})(),
)
it.instance("refreshes a cold child inherited from an untracked stored parent", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const parent = SessionID.make("ses_sandbox_cold_parent")
const child = SessionID.make("ses_sandbox_cold_child")
yield* Effect.promise(() =>
SandboxStore.write(test.directory, parent, {
enabled: true,
mode: "allow",
allowedHosts: [],
writablePaths: [],
version: 0,
}),
)
yield* SandboxPolicy.inherit(parent, child)
yield* SandboxPolicy.status(child)
expect(yield* SandboxPolicy.peek(test.directory, child)).toMatchObject({ mode: "deny" })
}),
{ config: { sandbox: { enabled: true, network: "deny" } } },
)
it.instance("enforces writes only while the macOS session override is active", () =>
Effect.gen(function* () {
if (process.platform !== "darwin") return
@@ -10,6 +10,9 @@ import { KilocodeConfigOverlay } from "../../../src/kilocode/config/overlay"
import { KilocodeConfigWriter } from "../../../src/kilocode/config/writer"
import { Permission } from "../../../src/permission"
import { PtyPaths } from "../../../src/server/routes/instance/httpapi/groups/pty"
import { SessionPaths } from "../../../src/server/routes/instance/httpapi/groups/session"
import { SandboxStore } from "../../../src/kilocode/sandbox/store"
import type { Session } from "../../../src/session/session"
import { Filesystem } from "../../../src/util/filesystem"
import { resetDatabase } from "../../fixture/db"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
@@ -673,30 +676,28 @@ describe("config overlay routes", () => {
expect(saved.mcp).toEqual({ shared: { enabled: false } })
})
test.serial(
"refreshes effective config after project permission update",
async () => {
await using global = await tmpdir()
await using project = await tmpdir()
await setGlobal(global.path, { permission: { edit: "allow" } })
test.serial("refreshes effective config after project permission update", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
await setGlobal(global.path, { permission: { edit: "allow" } })
const before = await json<Agent[]>(await req(project.path, "/agent"))
expect(
Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action,
).toBe("allow")
const before = await json<Agent[]>(await req(project.path, "/agent"))
expect(Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action).toBe(
"allow",
)
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }),
}),
)
const body = await json<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
await req(project.path, "/config/overlay?scope=project"),
)
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }),
}),
)
const body = await json<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
await req(project.path, "/config/overlay?scope=project"),
)
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
expect(typeof edit === "string" ? edit : edit?.["*"]).toBe("ask")
expect(
@@ -738,6 +739,121 @@ describe("config overlay routes", () => {
)
})
test.serial(
"applies saved global sandbox settings to initialized sessions",
async () => {
await using global = await tmpdir()
await using project = await tmpdir({ git: true })
await using writable = await tmpdir()
await setGlobal(global.path, { sandbox: { enabled: true, network: "deny" } })
const session = await json<Session.Info>(
await req(project.path, SessionPaths.create, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
)
await json(await req(project.path, `/session/${session.id}/sandbox`))
expect(await SandboxStore.read(project.path, session.id)).toMatchObject({ mode: "deny", version: 0 })
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
scope: "global",
set: { sandbox: { enabled: true, network: "allow", writable_paths: [writable.path] } },
}),
}),
)
// The global update disposes instances asynchronously. Poll the sandbox status
// until the reloaded instance applies the saved policy, mirroring how the
// extension re-checks status after saving settings.
for (let i = 0; i < 40; i++) {
await json(await req(project.path, `/session/${session.id}/sandbox`))
const snap = await SandboxStore.read(project.path, session.id)
if (snap && snap.mode === "allow" && snap.version === 1) break
await Bun.sleep(250)
}
expect(await SandboxStore.read(project.path, session.id)).toMatchObject({
enabled: true,
mode: "allow",
writablePaths: [writable.path],
version: 1,
})
},
20_000,
)
test.serial("applies saved project sandbox settings to initialized sessions", async () => {
await using global = await tmpdir()
await using project = await tmpdir({ git: true })
await setGlobal(global.path, { sandbox: { enabled: true, network: "allow" } })
const session = await json<Session.Info>(
await req(project.path, SessionPaths.create, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
)
await json(await req(project.path, `/session/${session.id}/sandbox`))
expect(await SandboxStore.read(project.path, session.id)).toMatchObject({ mode: "allow", version: 0 })
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "project", set: { sandbox: { enabled: true, network: "deny" } } }),
}),
)
await json(await req(project.path, `/session/${session.id}/sandbox`))
expect(await SandboxStore.read(project.path, session.id)).toMatchObject({ mode: "deny", version: 1 })
})
test.serial("does not relax inherited sandbox policy after unrelated global saves", async () => {
await using global = await tmpdir()
await using project = await tmpdir({ git: true })
await setGlobal(global.path, { sandbox: { enabled: true, network: "deny" } })
const parent = await json<Session.Info>(
await req(project.path, SessionPaths.create, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
)
await json(await req(project.path, `/session/${parent.id}/sandbox`))
const child = await json<Session.Info>(
await req(project.path, SessionPaths.create, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parentID: parent.id }),
}),
)
await json(await req(project.path, `/session/${child.id}/sandbox`))
expect(await SandboxStore.read(project.path, child.id)).toMatchObject({ mode: "deny" })
// Simulate config changing while the backend is unaware. The unrelated save below
// must not treat that wider policy as a trusted sandbox settings update.
await Bun.write(
path.join(global.path, "kilo.json"),
JSON.stringify({ sandbox: { enabled: true, network: "allow" } }, null, 2),
)
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "global", set: { permission: { edit: "ask" } } }),
}),
)
await json(await req(project.path, `/session/${child.id}/sandbox`))
expect(await SandboxStore.read(project.path, child.id)).toMatchObject({ mode: "deny" })
})
terminal("preserves active terminals after updating global console preferences", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
@@ -0,0 +1,98 @@
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import type * as Scope from "effect/Scope"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Plugin } from "@/plugin"
import { MessageID, SessionID } from "@/session/schema"
import { ShellTool } from "@/tool/shell"
import { Truncate } from "@/tool/truncate"
import type { Tool } from "@/tool/tool"
import { InstanceStore } from "@/project/instance-store"
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const layer = Layer.mergeAll(
AppNodeBuilder.build(CrossSpawnSpawner.node),
AppNodeBuilder.build(FSUtil.node),
AppNodeBuilder.build(Plugin.node),
AppNodeBuilder.build(Truncate.node),
AppNodeBuilder.build(Config.node),
AppNodeBuilder.build(Agent.node),
AppNodeBuilder.build(RuntimeFlags.node),
testInstanceStoreLayer,
)
const it = testEffect(layer)
type Services =
| (typeof layer extends Layer.Layer<infer ROut, infer _E, infer _RIn> ? ROut : never)
| InstanceStore.Service
| Scope.Scope
const ctx = {
sessionID: SessionID.make("ses_shell_env"),
messageID: MessageID.make("msg_shell_env"),
callID: "",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const run = Effect.fn("ShellEnvTest.run")(function* (args: Tool.InferParameters<typeof ShellTool>) {
const info = yield* ShellTool
const tool = yield* info.init()
return yield* tool.execute(args, ctx)
})
it.effect("does not expose backend credentials or config to model shell commands", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const values = {
password: process.env.KILO_SERVER_PASSWORD,
username: process.env.KILO_SERVER_USERNAME,
config: process.env.KILO_CONFIG,
content: process.env.KILO_CONFIG_CONTENT,
directory: process.env.KILO_CONFIG_DIR,
}
process.env.KILO_SERVER_PASSWORD = "secret"
process.env.KILO_SERVER_USERNAME = "kilo"
process.env.KILO_CONFIG = "/secret/config.json"
process.env.KILO_CONFIG_CONTENT = '{"provider":{"apiKey":"secret"}}'
process.env.KILO_CONFIG_DIR = "/secret/config"
return values
}),
() =>
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
provideInstance(tmp)(
run({
command:
process.platform === "win32"
? "if ($env:KILO_SERVER_PASSWORD -or $env:KILO_SERVER_USERNAME -or $env:KILO_CONFIG -or $env:KILO_CONFIG_CONTENT -or $env:KILO_CONFIG_DIR) { 'set' } else { 'unset' }"
: 'test -z "$KILO_SERVER_PASSWORD" && test -z "$KILO_SERVER_USERNAME" && test -z "$KILO_CONFIG" && test -z "$KILO_CONFIG_CONTENT" && test -z "$KILO_CONFIG_DIR" && printf unset',
description: "Check backend credential isolation",
}),
),
),
Effect.map((result) => expect(result.output.trim()).toBe("unset")),
) as Effect.Effect<void, never, Services>,
(values) =>
Effect.sync(() => {
if (values.password === undefined) delete process.env.KILO_SERVER_PASSWORD
else process.env.KILO_SERVER_PASSWORD = values.password
if (values.username === undefined) delete process.env.KILO_SERVER_USERNAME
else process.env.KILO_SERVER_USERNAME = values.username
if (values.config === undefined) delete process.env.KILO_CONFIG
else process.env.KILO_CONFIG = values.config
if (values.content === undefined) delete process.env.KILO_CONFIG_CONTENT
else process.env.KILO_CONFIG_CONTENT = values.content
if (values.directory === undefined) delete process.env.KILO_CONFIG_DIR
else process.env.KILO_CONFIG_DIR = values.directory
}),
),
)
+31
View File
@@ -5,6 +5,37 @@ import { spawn } from "../../src/lsp/launch"
import { tmpdir } from "../fixture/fixture"
describe("lsp.launch", () => {
// kilocode_change start
test("does not expose backend credentials or config", async () => {
const keys = [
"KILO_SERVER_PASSWORD",
"KILO_SERVER_USERNAME",
"KILO_CONFIG",
"KILO_CONFIG_CONTENT",
"KILO_CONFIG_DIR",
] as const
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
for (const key of keys) process.env[key] = "secret"
try {
const proc = spawn(process.execPath, ["-e", `console.log(${JSON.stringify(keys)}.some((key) => process.env[key]))`])
const output = await new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = []
proc.stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
proc.on("error", reject)
proc.on("close", () => resolve(Buffer.concat(chunks).toString().trim()))
})
expect(output).toBe("false")
} finally {
for (const key of keys) {
const value = saved[key]
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
})
// kilocode_change end
test("spawns cmd scripts with spaces on Windows", async () => {
if (process.platform !== "win32") return
@@ -45,9 +45,25 @@ type CachedApp = BackendApp & { readonly dispose: () => Promise<void> }
const appCache: Partial<Record<string, CachedApp>> = {}
export async function disposeApps() {
const apps = Object.values(appCache)
// kilocode_change start - an in-flight SSE fiber can leave the in-process router scope unable
// to close; bound disposal so a completed scenario run cannot wedge the exerciser or CI
const apps = Object.entries(appCache)
for (const key of Object.keys(appCache)) delete appCache[key]
await Promise.all(apps.flatMap((app) => (app === undefined ? [] : [app.dispose()])))
await Promise.all(
apps.flatMap(([key, app]) =>
app === undefined
? []
: [
Promise.race([
app.dispose(),
Bun.sleep(3_000).then(() => {
console.error(`httpapi-exercise: router dispose did not settle for ${JSON.stringify(key)} after 3s`)
}),
]),
],
),
)
// kilocode_change end
}
function app(modules: Runtime, options: CallOptions) {
@@ -77,6 +77,25 @@ describe("util.process", () => {
expect(out.stdout.toString()).toBe("set")
})
// kilocode_change start
test("can use a complete environment without inherited values", async () => {
const key = "KILO_TEST_INHERITED_ENV"
const saved = process.env[key]
process.env[key] = "secret"
try {
const out = await Process.run(node(`process.stdout.write(process.env.${key} ?? "unset")`), {
env: { PATH: process.env.PATH },
extendEnv: false,
})
expect(out.stdout.toString()).toBe("unset")
} finally {
if (saved === undefined) delete process.env[key]
else process.env[key] = saved
}
})
// kilocode_change end
test("uses shell in run on Windows", async () => {
if (process.platform !== "win32") return