mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
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:
@@ -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
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user