Merge remote-tracking branch 'origin/johnnyeric/kilo-opencode-v1.18.0' into johnnyeric/kilo-opencode-v1.18.13

This commit is contained in:
Johnny Eric Amancio
2026-08-07 16:12:20 +02:00
136 changed files with 3582 additions and 1513 deletions
+44 -4
View File
@@ -51,9 +51,46 @@ async function copyTreeSitterWasms(outputDir: string) {
console.log(`copied ${languageWasmFiles.length + 1} tree-sitter wasm files to ${targetDir}`)
}
// kilocode_change start
async function isKiloConsoleUpToDate(app: string, out: string) {
const indexHtml = path.join(out, "index.html")
if (!fs.existsSync(indexHtml)) return false
const outStat = await fs.promises.stat(indexHtml)
const inputs = [
path.join(app, "src"),
path.join(app, "package.json"),
path.join(app, "vite.config.ts"),
path.join(app, "index.html"),
path.resolve(dir, "../kilo-web-ui/src"),
path.resolve(dir, "../kilo-indexing/src"),
path.resolve(dir, "../kilo-ui/src"),
path.resolve(dir, "../ui/src"),
path.resolve(dir, "../sdk/js/src"),
path.resolve(dir, "../../bun.lock"),
]
for (const p of inputs) {
if (!fs.existsSync(p)) continue
const st = await fs.promises.stat(p)
if (st.isDirectory()) {
const glob = new Bun.Glob("**/*")
for await (const file of glob.scan({ cwd: p })) {
const fileStat = await fs.promises.stat(path.join(p, file))
if (fileStat.mtimeMs > outStat.mtimeMs) return false
}
} else if (st.mtimeMs > outStat.mtimeMs) {
return false
}
}
return true
}
async function buildKiloConsole() {
const app = path.resolve(dir, "../kilo-console")
const out = path.join(app, "dist")
if (await isKiloConsoleUpToDate(app, out)) {
console.log(`reusing existing Kilo Console build at ${out}`)
return out
}
console.log("building Kilo Console")
const proc = Bun.spawn([process.execPath, "run", "build"], {
cwd: app,
@@ -66,6 +103,7 @@ async function buildKiloConsole() {
if (code !== 0) throw new Error(`Kilo Console build failed with exit code ${code}`)
return out
}
// kilocode_change end
async function copyKiloConsole(input: string, outputDir: string) {
const target = path.join(outputDir, "console")
@@ -219,11 +257,13 @@ const targets = singleFlag
})
: allTargets
await $`rm -rf dist`
// kilocode_change start
const kiloConsoleDist = await buildKiloConsole()
const kiloSandboxWorker = await KiloSandboxWorker.bundle()
const kiloSandboxNetwork = await KiloSandboxNetwork.bundle()
await $`rm -rf dist`
const [kiloConsoleDist, kiloSandboxWorker, kiloSandboxNetwork] = await Promise.all([
buildKiloConsole(),
KiloSandboxWorker.bundle(),
KiloSandboxNetwork.bundle(),
])
// kilocode_change end
const binaries: Record<string, string> = {}
+35 -3
View File
@@ -10,9 +10,41 @@ process.chdir(dir)
const modelsUrl = process.env.KILO_MODELS_URL || "https://models.dev"
// kilocode_change start
const raw = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
const cacheFile = path.resolve(dir, "node_modules/.cache/models-dev-api.json")
const raw = await (async () => {
if (process.env.MODELS_DEV_API_JSON) {
return await Bun.file(process.env.MODELS_DEV_API_JSON).text()
}
const cached = Bun.file(cacheFile)
try {
if (await cached.exists()) {
const st = await cached.stat()
if (st && Date.now() - st.mtimeMs < 6 * 3600 * 1000) {
return await cached.text()
}
}
} catch (err) {
console.warn("[generate] cache read check failed, fetching live", err)
}
try {
const res = await fetch(`${modelsUrl}/api.json`, { signal: AbortSignal.timeout(5000) })
if (!res.ok) throw new Error(`Failed to fetch models.dev snapshot: HTTP ${res.status}`)
const text = await res.text()
try {
await Bun.write(cacheFile, text)
} catch (err) {
console.warn("[generate] cache write failed", err)
}
return text
} catch (err) {
try {
if (await cached.exists()) return await cached.text()
} catch (fallbackErr) {
console.warn("[generate] cache fallback read failed", fallbackErr)
}
throw err
}
})()
export const modelsData = JSON.stringify(parseModelsSnapshot(raw).data)
// kilocode_change end
console.log("Loaded models.dev snapshot")
+83 -4
View File
@@ -1,16 +1,77 @@
// kilocode_change - new file
import path from "path"
import fs from "fs/promises"
import fsSync from "fs"
import crypto from "crypto"
export namespace TestCli {
export const ENV = "KILO_TEST_CLI_PATH"
export async function build(root: string, dir: string) {
async function fingerprint(root: string): Promise<string> {
const hash = crypto.createHash("sha256")
const repo = path.resolve(root, "../..")
const pkgs = path.join(repo, "packages")
const lock = path.join(repo, "bun.lock")
if (fsSync.existsSync(lock)) {
const st = fsSync.statSync(lock)
hash.update("bun.lock").update(String(st.mtimeMs)).update(String(st.size))
}
const ignored = new Set(["kilo-vscode", "kilo-jetbrains", "kilo-docs"])
const entries = fsSync.readdirSync(pkgs, { withFileTypes: true })
for (const ent of entries) {
if (!ent.isDirectory() || ignored.has(ent.name)) continue
const dir = path.join(pkgs, ent.name)
const targets =
ent.name === "sdk"
? [path.join(dir, "js", "src"), path.join(dir, "js", "package.json")]
: [path.join(dir, "src"), path.join(dir, "migration"), path.join(dir, "package.json")]
for (const target of targets) {
if (!fsSync.existsSync(target)) continue
const st = fsSync.statSync(target)
if (st.isDirectory()) {
const glob = new Bun.Glob("**/*.{ts,tsx,sql,json,txt}")
for await (const file of glob.scan({ cwd: target })) {
const p = path.join(target, file)
const fst = fsSync.statSync(p)
hash.update(`${ent.name}:${file}`).update(String(fst.mtimeMs)).update(String(fst.size))
}
} else {
hash.update(`${ent.name}:pkg`).update(String(st.mtimeMs)).update(String(st.size))
}
}
}
return hash.digest("hex")
}
export async function build(root: string, targetDir?: string) {
if (path.resolve(process.cwd()) !== path.resolve(root)) {
throw new Error(`CLI test bundle must be built from ${root}`)
}
const dir = targetDir ?? path.join(root, ".artifacts", "test-cli-cached")
const out = path.join(dir, "src/storage")
const bin = path.join(out, "cli.js")
const hashFile = path.join(dir, ".hash")
if (!targetDir) {
const currentHash = await fingerprint(root)
try {
if (fsSync.existsSync(bin) && fsSync.existsSync(hashFile)) {
const storedHash = fsSync.readFileSync(hashFile, "utf8").trim()
if (storedHash === currentHash) {
return bin
}
}
} catch {}
}
const { createSolidTransformPlugin } = await import("@opentui/solid/bun-plugin")
const entry = "./src/index.ts"
const out = path.join(dir, "src/storage")
const result = await Bun.build({
entrypoints: [entry],
outdir: out,
@@ -41,8 +102,26 @@ export namespace TestCli {
return
}
})()
if (target) await fs.symlink(target, path.join(scope, name.replace("@opentui/", "")), kind)
if (target) {
const link = path.join(scope, name.replace("@opentui/", ""))
try {
await fs.rm(link, { recursive: true, force: true })
await fs.symlink(target, link, kind)
} catch (err) {
console.warn(`[test-cli] failed to link native variant ${name}:`, err)
}
}
}
return path.join(out, "cli.js")
if (!targetDir) {
try {
const currentHash = await fingerprint(root)
await fs.writeFile(hashFile, currentHash)
} catch (err) {
console.warn("[test-cli] failed to save fingerprint hash:", err)
}
}
return bin
}
}
+3 -11
View File
@@ -187,25 +187,17 @@ type Proc = ReturnType<typeof Bun.spawn>
const xmldir = ci ? path.join(os.tmpdir(), `opencode-junit-${process.pid}`) : ""
if (ci) await fs.mkdir(xmldir, { recursive: true })
// kilocode_change start
const supplied = process.env[TestCli.ENV]
const binprefix = path.join(root, ".artifacts", "test-cli-")
const built = supplied
? { binary: supplied, dir: undefined }
: await (async () => {
await fs.mkdir(path.dirname(binprefix), { recursive: true })
const dir = await fs.mkdtemp(binprefix)
return { binary: await TestCli.build(root, dir), dir }
})()
: { binary: await TestCli.build(root), dir: undefined }
async function cleanBinary() {
if (!built.dir) return
const expected = path.dirname(binprefix)
const valid =
path.dirname(built.dir) === expected && path.basename(built.dir).startsWith(path.basename(binprefix))
if (!valid) throw new Error(`Refusing to remove unexpected test CLI directory: ${built.dir}`)
// The generated directory contains the bundle, emitted assets, and copied migrations.
await fs.rm(built.dir, { recursive: true, force: true })
}
// kilocode_change end
const counter = { done: 0 }
const pad = String(files.length).length
-2
View File
@@ -115,8 +115,6 @@ const layer = Layer.effect(
}
// kilocode_change start
commands[Default.REVIEW] = reviewCommand()
commands["local-review"] = legacyReviewCommand("local-review")!
commands["local-review-uncommitted"] = legacyReviewCommand("local-review-uncommitted")!
commands["resume-claude"] = SessionResume.resumeClaude
commands["resume-codex"] = SessionResume.resumeCodex
// kilocode_change end
+4 -3
View File
@@ -1020,7 +1020,7 @@ const layer = Layer.effect(
directory: ctx.directory,
payload: {
type: Event.ConfigUpdated.type,
properties: {},
properties: { sandbox: Object.hasOwn(config, "sandbox") },
},
}),
)
@@ -1075,6 +1075,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
const next = result.next
const changed = result.changed
const sandboxChanged = changed && Object.hasOwn(config, "sandbox")
// kilocode_change end
// kilocode_change start - skip dispose when caller opts out
@@ -1086,7 +1087,7 @@ const layer = Layer.effect(
directory: "global",
payload: {
type: Event.ConfigUpdated.type,
properties: {},
properties: { sandbox: sandboxChanged },
},
}),
).pipe(Effect.catchCause(() => Effect.void))
@@ -1103,7 +1104,7 @@ const layer = Layer.effect(
directory: "global",
payload: {
type: Event.ConfigUpdated.type,
properties: {},
properties: { sandbox: sandboxChanged },
},
}),
).pipe(Effect.catchCause(() => Effect.void))
+3 -2
View File
@@ -9,6 +9,7 @@ import { mergeDeep } from "remeda"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { errorMessage } from "@/util/error"
import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change
import * as Formatter from "./formatter"
export const Status = Schema.Struct({
@@ -85,8 +86,8 @@ const layer = Layer.effect(
.run(
ChildProcess.make(replaced[0]!, replaced.slice(1), {
cwd: dir,
env: item.environment,
extendEnv: true,
env: modelEnv(item.environment), // kilocode_change - formatters must not inherit backend credentials
extendEnv: false, // kilocode_change
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
@@ -53,7 +53,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@kilocode/AgentManager") {}
export function layer(timeout: Duration.Input = "10 seconds") {
export function layer(timeout: Duration.Input = "60 seconds") {
return Layer.effect(
Service,
Effect.gen(function* () {
@@ -5,6 +5,7 @@ import { makeRuntime } from "@/effect/run-service"
import { Identifier } from "@/id/id"
import { Instance, type InstanceContext } from "@/kilocode/instance"
import { KiloShutdown } from "@/kilocode/cli/shutdown"
import { model as modelEnv } from "@/kilocode/process/env"
import { SessionID } from "@/session/schema"
import { Shell } from "@opencode-ai/core/shell"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -574,14 +575,11 @@ export namespace BackgroundProcess {
}
function env(id?: ID, token?: string) {
const result: NodeJS.ProcessEnv = {
...process.env,
const result: NodeJS.ProcessEnv = modelEnv({
TERM: "dumb",
...(id ? { KILO_BACKGROUND_PROCESS_ID: id } : {}),
...(token ? { KILO_BACKGROUND_PROCESS_TOKEN: token } : {}),
}
delete result.KILO_SERVER_PASSWORD
delete result.KILO_SERVER_USERNAME
})
delete result.KILO_BACKGROUND_PROCESS_PORTS
return result
}
@@ -15,7 +15,12 @@ export namespace KilocodeConfigWriter {
target: KilocodeConfigOverlay.Target
}
export type Result = { ok: true; target: KilocodeConfigOverlay.Target } | Conflict
export type Result = {
ok: true
target: KilocodeConfigOverlay.Target
changed: boolean
sandboxChanged: boolean
} | Conflict
export async function write(input: {
directory: string
@@ -45,7 +50,7 @@ export namespace KilocodeConfigWriter {
}
const patch = KilocodeConfigOverlay.patch({ scope: input.scope, set: input.set, unset: input.unset })
if (Object.keys(patch).length === 0) return { ok: true, target }
if (Object.keys(patch).length === 0) return { ok: true, target, changed: false, sandboxChanged: false }
await mkdir(path.dirname(target.path), { recursive: true })
await input.beforeWrite?.()
const checked = await KilocodeConfigOverlay.target(input)
@@ -77,7 +82,12 @@ export namespace KilocodeConfigWriter {
? 0o600
: undefined
if (updated !== before) await (input.write ?? Filesystem.write)(checked.path, updated, mode)
return { ok: true, target: await KilocodeConfigOverlay.target(input) }
return {
ok: true,
target: await KilocodeConfigOverlay.target(input),
changed: updated !== before,
sandboxChanged: updated !== before && Object.hasOwn(patch, "sandbox"),
}
}
function patchJsonc(input: string, patch: unknown, parts: string[] = []): string {
@@ -3,6 +3,7 @@ import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output"
import { model as modelEnv } from "@/kilocode/process/env"
import { Identifier } from "@/id/id"
import { Instance, type InstanceContext } from "@/kilocode/instance"
import { SessionID } from "@/session/schema"
@@ -220,14 +221,10 @@ export namespace InteractiveTerminal {
}
function environment(input: NodeJS.ProcessEnv) {
const env = Object.fromEntries(
Object.entries(input).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
const env = modelEnv(input)
env.TERM = "xterm-256color"
env.KILO_TERMINAL = "1"
env.KILO_INTERACTIVE_TERMINAL = "1"
delete env.KILO_SERVER_PASSWORD
delete env.KILO_SERVER_USERNAME
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
@@ -0,0 +1,13 @@
export function model(extra?: NodeJS.ProcessEnv | null): Record<string, string> {
const env = Object.fromEntries(
Object.entries({ ...process.env, ...(extra ?? {}) }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
delete env.KILO_SERVER_PASSWORD
delete env.KILO_SERVER_USERNAME
delete env.KILO_CONFIG
delete env.KILO_CONFIG_CONTENT
delete env.KILO_CONFIG_DIR
return env
}
@@ -30,7 +30,7 @@ export function parseReviewCommand(prompt: string | undefined): ReviewCommand |
export function reviewCommand(): Command.Info {
return {
name: "review",
description: "review changes [uncommitted|commit|branch|pr]",
description: "review changes [uncommitted|staged|unpushed|branch|commit|pr]",
template: REVIEW,
hints: ["$ARGUMENTS"],
}
@@ -1,6 +1,6 @@
You are Kilo Code, an expert code reviewer focused on high-confidence security, performance, business logic, deploy safety, duplication, and dead-code findings. During the initial review phase, your role is advisory: provide clear, actionable feedback but DO NOT modify any files. Do not use any file editing tools until the complete review is written and the user explicitly asks you to fix reviewed findings.
You are performing a code review with `/review`. It supports uncommitted working-tree changes, a specific commit, the current branch against a base ref, or a GitHub pull request.
You are performing a code review with `/review`. It supports uncommitted working-tree changes, staged changes, unpushed commits, a specific commit, the current branch against a base ref, or a GitHub pull request.
---
@@ -12,18 +12,20 @@ $ARGUMENTS
## Interpreting User Input
Treat the user input above as the literal free-form text the user typed after `/review`. It can be empty, review guidance, an explicit local scope, a commit hash, a branch or base ref, or a pull request URL or number.
Treat the user input above as the literal free-form text the user typed after `/review`. It can be empty, review guidance, an explicit local scope (`uncommitted`, `staged`, `unpushed`, `branch`), effort flags (`quick`, `--quick`, `-q`, `deep`, `--deep`, `-d`, `--effort <1-10>`), a commit hash, a branch or base ref, or a pull request URL or number.
Choose exactly one review scope in this order:
1. **Explicit uncommitted scope** - `/review uncommitted [guidance]` reviews staged, unstaged, and untracked changes. Phrases that clearly request working-tree, staged, unstaged, uncommitted, or untracked changes select the same scope.
2. **Explicit branch scope** - `/review branch [base] [guidance]` reviews the current branch against the provided base, or against the default base when none is provided. After `branch`, treat a token as the base only when it resolves as a git ref or is identified with syntax such as `base=<ref>`, `base <ref>`, `against <ref>`, `compare to <ref>`, or `vs <ref>`; otherwise treat it as guidance. Phrases that clearly request branch, committed, or PR-ready changes select branch scope.
3. **Commit** - a 7-40 character hexadecimal token that resolves as a commit selects commit review. Treat remaining text as guidance.
4. **Pull request** - input that starts with a GitHub pull request URL or a positive PR number selects pull request review. Treat remaining text as guidance.
5. **Branch or base ref** - a token that resolves as a local or remote git ref, or a clearly named base such as `base main`, `against origin/dev`, `compare to develop`, or `vs release/next`, selects branch review. Treat remaining text as guidance.
6. **Empty or guidance-only input** - choose uncommitted review. Bare `/review` always defaults to uncommitted changes, even when the working tree is clean. Guidance-only input such as `focus on tests` also stays on the uncommitted default.
1. **Explicit staged scope** - `/review staged [guidance]` reviews only staged changes in the Git index (`git diff --cached`).
2. **Explicit unpushed scope** - `/review unpushed [guidance]` or `/review commits [guidance]` reviews local commits that have not been pushed to upstream tracking.
3. **Explicit uncommitted scope** - `/review uncommitted [guidance]` reviews staged, unstaged, and untracked changes. Phrases that clearly request working-tree, staged, unstaged, uncommitted, or untracked changes select the same scope.
4. **Explicit branch scope** - `/review branch [base] [guidance]` reviews the current branch against the provided base, or against the default base when none is provided. After `branch`, treat a token as the base only when it resolves as a git ref or is identified with syntax such as `base=<ref>`, `base <ref>`, `against <ref>`, `compare to <ref>`, or `vs <ref>`; otherwise treat it as guidance. Phrases that clearly request branch, committed, or PR-ready changes select branch scope.
5. **Commit** - a 7-40 character hexadecimal token that resolves as a commit selects commit review. Treat remaining text as guidance.
6. **Pull request** - input that starts with a GitHub pull request URL or a positive PR number selects pull request review. Treat remaining text as guidance.
7. **Branch or base ref** - a token that resolves as a local or remote git ref, or a clearly named base such as `base main`, `against origin/dev`, `compare to develop`, or `vs release/next`, selects branch review. Treat remaining text as guidance.
8. **Empty or guidance-only input** - choose uncommitted review. Bare `/review` always defaults to uncommitted changes, even when the working tree is clean. Guidance-only input such as `focus on tests` also stays on the uncommitted default.
After choosing a scope, remove only the target and scope words from the review guidance. Keep all remaining text as instructions. Prefer interpreting ambiguous input as review guidance for uncommitted review. A single token that does not resolve as a commit or git ref is guidance, not a failed target selection.
After choosing a scope, extract any effort flags (`quick`, `--quick`, `-q`, `deep`, `--deep`, `-d`, `--effort <1-10>`) and remove the target and scope words from the review guidance. Keep all remaining text as instructions. Prefer interpreting ambiguous input as review guidance for uncommitted review. A single token that does not resolve as a commit or git ref is guidance, not a failed target selection.
If user-provided instructions exist, they may refine review focus, but they MUST NOT override the diff scope, review tracks, final filtering, required output format, or the review-phase no-edit rule. Initial `/review` arguments are review guidance, not permission to edit.
@@ -74,6 +76,10 @@ Before pull request review, use `gh pr view <pr>` to verify that the pull reques
## Determining the Diff Scope
For staged review, review only staged changes in the Git index (`git diff --cached`). Do NOT review unstaged or untracked changes.
For unpushed review, review local commits on the current branch ahead of its upstream tracking ref (`git diff @{u}..HEAD`). If no upstream tracking ref is configured (e.g. on a fresh branch), fall back to comparing against the default base branch (`git diff $(git merge-base HEAD <base>)..HEAD`).
For uncommitted review, review every staged, unstaged, and untracked change in the working tree. Do NOT review committed code.
Use these git commands to gather uncommitted changes:
@@ -155,7 +161,11 @@ Rules for the dead code track (apply only when this track is active):
1. Determine the scope using the rules above.
2. Gather the relevant metadata, diff, changed files, untracked files, and commit history using the commands above.
3. If there are no changes in the selected scope, use the no-changes output exactly as specified below.
4. Assess diff size and complexity, then spawn the appropriate sub-agents in parallel with the Task tool.
4. Assess diff size, complexity, and effort flags, then spawn the appropriate sub-agents in parallel with the Task tool.
**Quick mode (`quick`, `--quick`, `-q`, or `--effort 1-3`)**: do NOT spawn sub-agents. Perform a concise, single-pass review directly in the main agent for fast, token-efficient feedback.
**Deep mode (`deep`, `--deep`, `-d`, or `--effort 8-10`)**: spawn specialized sub-agents across all six tracks (security, performance, business logic, deploy safety, duplication, dead code) for an in-depth audit regardless of diff size.
Count changed lines (additions + deletions) from the diff output and the number of distinct files changed. Use these thresholds:
@@ -230,6 +240,8 @@ Rules for the dead code track (apply only when this track is active):
Use the header that matches the selected scope:
- Staged: `## Local Review for **staged changes**`
- Unpushed: `## Local Review for **unpushed commits**`
- Uncommitted: `## Local Review for **uncommitted changes**`
- Branch: `## Local Review for **branch diff**: \`<current-branch>\` -> \`<base>\``
- Commit: `## Code Review for **commit**: \`<commit>\``
+232 -71
View File
@@ -1,10 +1,12 @@
import { readFileSync, statSync } from "node:fs"
import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs"
import os from "node:os"
import path from "node:path"
import { Effect, Semaphore } from "effect"
import { Global } from "@opencode-ai/core/global"
import { Database } from "@opencode-ai/core/database/database"
import { backendSupport, run as runSandbox, unrestricted, type Profile } from "@kilocode/sandbox"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Instance } from "@/kilocode/instance"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
@@ -21,14 +23,62 @@ export type Snapshot = SandboxStore.Snapshot
export type Target = { id: SessionID; directory: string }
const snapshots = new Map<string, Snapshot>()
const synced = new Map<string, number>()
const locks = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
const refreshes = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
const gates = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
const permits = 1_000_000
let revision = 0
GlobalBus.on("event", (event) => {
if (event.payload?.type === "global.config.updated" && event.payload.properties?.sandbox === true) revision++
})
function key(directory: string, sessionID: SessionID) {
return directory + "\0" + sessionID
}
function limits(fallback: ReturnType<typeof SandboxConfig.resolve>) {
return {
mode: fallback.mode,
allowedHosts: fallback.allowedHosts,
writablePaths: fallback.writablePaths.map((value) =>
value.startsWith("~") ? path.join(os.homedir(), value.slice(1)) : value,
),
}
}
function apply(current: Snapshot, fallback: ReturnType<typeof SandboxConfig.resolve>) {
return { ...current, ...limits(fallback) }
}
function matches(current: Snapshot, next: Snapshot) {
return (
current.mode === next.mode &&
current.allowedHosts.join("\0") === next.allowedHosts.join("\0") &&
current.writablePaths.join("\0") === next.writablePaths.join("\0")
)
}
function changed(sessionID: SessionID, directory: string, next: Snapshot) {
const support = backendSupport({ mode: next.mode, allowedHosts: next.allowedHosts })
GlobalBus.emit("event", {
directory,
payload: {
id: Bus.createID(),
type: Changed.type,
properties: {
sessionID,
directory,
enabled: next.enabled && support.available,
available: support.available,
reason: support.reason,
version: next.version,
},
},
})
}
function initial(
chosen: boolean | undefined,
pref: boolean | undefined,
@@ -36,11 +86,7 @@ function initial(
fallback: ReturnType<typeof SandboxConfig.resolve>,
): Snapshot {
const state = {
mode: fallback.mode,
allowedHosts: fallback.allowedHosts,
writablePaths: fallback.writablePaths.map((value) =>
value.startsWith("~") ? path.join(os.homedir(), value.slice(1)) : value,
),
...limits(fallback),
version: 0,
}
if (chosen !== undefined) return { ...state, enabled: chosen }
@@ -76,6 +122,23 @@ function lockedAll<A, E, R>(sessions: readonly SessionID[], effect: Effect.Effec
return [...new Set(sessions)].reduceRight((next, sessionID) => locked(sessionID, next), effect)
}
function refreshing<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const entry = refreshes.get(sessionID) ?? { semaphore: Semaphore.makeUnsafe(1), refs: 0 }
entry.refs++
refreshes.set(sessionID, entry)
return entry
}),
(entry) => entry.semaphore.withPermits(1)(effect),
(entry) =>
Effect.sync(() => {
entry.refs--
if (entry.refs === 0 && refreshes.get(sessionID) === entry) refreshes.delete(sessionID)
}),
)
}
function gated<A, E, R>(sessionID: SessionID, count: number, effect: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
@@ -131,6 +194,37 @@ function isolated(ctx: InstanceContext) {
return linked(path.resolve(ctx.directory), path.resolve(ctx.worktree))
}
function canonical(dir: string) {
try {
return realpathSync.native(dir)
} catch {
return path.resolve(dir)
}
}
function ancestor(value: string, target: string) {
const relative = path.relative(canonical(value), canonical(target))
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
}
function accessible(dir: string) {
try {
accessSync(dir, constants.R_OK)
return true
} catch {
return false
}
}
function filterWritable(ctx: InstanceContext, values: readonly string[]) {
const list = values.filter(accessible)
if (!isolated(ctx)) return list
// A nested macOS sandbox cannot reliably canonicalize an inherited writable
// ancestor of the linked worktree. The active worktree is already writable;
// keep unrelated explicit paths, but do not widen it back to the repository.
return list.filter((value) => !ancestor(value, ctx.directory))
}
export function profile(
ctx: InstanceContext,
mode: Profile["network"]["mode"] = "deny",
@@ -152,7 +246,7 @@ export function profile(
Global.Path.bin,
Global.Path.log,
Global.Path.repos,
...(extraWritable ?? []),
...filterWritable(ctx, extraWritable ?? []),
].map(root)
return {
filesystem: {
@@ -166,13 +260,7 @@ export function profile(
allowedHosts,
},
environment: {
deny: [
"KILO_CONFIG",
"KILO_CONFIG_CONTENT",
"KILO_CONFIG_DIR",
"KILO_SERVER_PASSWORD",
"KILO_SERVER_USERNAME",
],
deny: ["KILO_CONFIG", "KILO_CONFIG_CONTENT", "KILO_CONFIG_DIR", "KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"],
set: {
TMPDIR: Global.Path.tmp,
TMP: Global.Path.tmp,
@@ -205,14 +293,31 @@ const snapshot = Effect.fn("SandboxPolicy.snapshot")(function* (sessionID: Sessi
// session moved or created with an explicit choice keeps that choice instead of resetting. The
// persisted per-directory preference (last toggled state) is the next precedence, so new sessions
// inherit the last /sandbox choice. The config default applies when neither is present.
const version = revision
const next = yield* resolveInitial(directory, sessionID)
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
snapshots.set(key(directory, sessionID), next)
const id = key(directory, sessionID)
snapshots.set(id, next)
synced.set(id, version)
return { directory, state: next }
}),
)
})
function current(
sessionID: SessionID,
inside = false,
): Effect.Effect<{ directory: string; state: Snapshot }, never, Config.Service | Database.Service> {
return Effect.gen(function* () {
const expected = revision
const state = yield* snapshot(sessionID)
const id = key(state.directory, sessionID)
if (synced.get(id) !== expected) yield* inside ? reconcile(sessionID, expected) : refresh(sessionID, expected)
if (revision !== expected) return yield* current(sessionID, inside)
return yield* snapshot(sessionID)
})
}
export const configuredSupport = Effect.fn("SandboxPolicy.configuredSupport")(function* () {
const cfg = yield* (yield* Config.Service).get()
const state = SandboxConfig.resolve(cfg)
@@ -224,22 +329,53 @@ export function fallback(config: Config.Info) {
}
export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) {
const current = yield* snapshot(sessionID)
const support = backendSupport({ mode: current.state.mode, allowedHosts: current.state.allowedHosts })
const active = yield* current(sessionID)
const support = backendSupport({ mode: active.state.mode, allowedHosts: active.state.allowedHosts })
return {
directory: current.directory,
enabled: current.state.enabled && support.available,
directory: active.directory,
enabled: active.state.enabled && support.available,
available: support.available,
reason: support.reason,
version: current.state.version,
version: active.state.version,
}
})
export const networkRestricted = Effect.fn("SandboxPolicy.networkRestricted")(function* (sessionID: SessionID) {
const current = yield* snapshot(sessionID)
return current.state.enabled && current.state.mode !== "allow"
const active = yield* current(sessionID)
return active.state.enabled && active.state.mode !== "allow"
})
const reconcile = Effect.fn("SandboxPolicy.reconcile")(function* (sessionID: SessionID, version = revision) {
const directory = yield* InstanceState.directory
return yield* refreshing(
sessionID,
Effect.gen(function* () {
const id = key(directory, sessionID)
if (synced.get(id) === version) return false
const current = yield* read(directory, sessionID)
if (!current) return false
const config = yield* (yield* Config.Service).get()
const next: Snapshot = {
...apply(current, SandboxConfig.resolve(config)),
version: current.version + 1,
}
if (matches(current, next)) {
synced.set(id, version)
return false
}
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
snapshots.set(id, next)
synced.set(id, version)
yield* Effect.sync(() => changed(sessionID, directory, next))
return true
}),
)
})
export const refresh = Effect.fn("SandboxPolicy.refresh")((sessionID: SessionID, version = revision) =>
locked(sessionID, reconcile(sessionID, version)),
)
function change<E, R, F = never, Q = never, P = never, S = never>(
sessionID: SessionID,
guard:
@@ -255,7 +391,10 @@ function change<E, R, F = never, Q = never, P = never, S = never>(
Effect.gen(function* () {
const stored = yield* read(directory, sessionID)
const current = stored ?? (yield* resolveInitial(directory, sessionID))
const support = backendSupport({ mode: current.mode, allowedHosts: current.allowedHosts })
const enabling = !current.enabled
const version = revision
const base = enabling ? apply(current, SandboxConfig.resolve(yield* (yield* Config.Service).get())) : current
const support = backendSupport({ mode: base.mode, allowedHosts: base.allowedHosts })
const status = {
directory,
enabled: current.enabled && support.available,
@@ -263,34 +402,40 @@ function change<E, R, F = never, Q = never, P = never, S = never>(
reason: support.reason,
version: current.version,
}
const enabling = !current.enabled
if (enabling && !status.available) return status
const targets = enabling && family ? yield* family : [{ id: sessionID, directory }]
const sessions = targets.map((target) => target.id)
const update = Effect.gen(function* () {
yield* typeof guard === "function" ? guard(enabling, targets) : guard
const next: Snapshot = { ...current, enabled: enabling, version: status.version + 1 }
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
snapshots.set(key(directory, sessionID), next)
if (enabling) {
yield* Effect.forEach(
targets,
(target) =>
target.id === sessionID ? Effect.void : inheritSnapshot(target.directory, next, target.id),
{ discard: true },
const update = refreshing(
sessionID,
Effect.gen(function* () {
yield* typeof guard === "function" ? guard(enabling, targets) : guard
const next: Snapshot = { ...base, enabled: enabling, version: status.version + 1 }
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
const id = key(directory, sessionID)
snapshots.set(id, next)
synced.set(id, version)
if (enabling) {
yield* Effect.forEach(
targets,
(target) =>
target.id === sessionID
? Effect.void
: refreshing(target.id, inheritSnapshot(target.directory, next, target.id)),
{ discard: true },
)
}
// The per-session SandboxStore is the authoritative state; the per-directory
// preference only seeds future sessions. A preference write failure must not
// fail the toggle or desync the in-memory cache from the persisted snapshot.
yield* Effect.promise(() => SandboxPreference.write(directory, next.enabled)).pipe(
Effect.catch(() => Effect.void),
)
}
// The per-session SandboxStore is the authoritative state; the per-directory
// preference only seeds future sessions. A preference write failure must not
// fail the toggle or desync the in-memory cache from the persisted snapshot.
yield* Effect.promise(() => SandboxPreference.write(directory, next.enabled)).pipe(
Effect.catch(() => Effect.void),
)
const value = { ...status, enabled: next.enabled && support.available, version: next.version }
// Publish through the standalone Bus facade so HTTP handlers do not need Bus.Service.
yield* Effect.promise(() => Bus.publish(Instance.current, Changed, { sessionID, ...value }))
return value
})
const value = { ...status, enabled: next.enabled && support.available, version: next.version }
// Publish through the standalone Bus facade so HTTP handlers do not need Bus.Service.
yield* Effect.promise(() => Bus.publish(Instance.current, Changed, { sessionID, ...value }))
return value
}),
)
if (enabling) {
const children = sessions.filter((id) => id !== sessionID)
return yield* lockedAll(
@@ -339,6 +484,7 @@ const inheritSnapshot = Effect.fn("SandboxPolicy.inheritSnapshot")(function* (
directory: string,
parent: Snapshot,
sessionID: SessionID,
version = revision,
) {
const child = yield* read(directory, sessionID)
const next: Snapshot = child
@@ -357,7 +503,10 @@ const inheritSnapshot = Effect.fn("SandboxPolicy.inheritSnapshot")(function* (
)
return
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
snapshots.set(key(directory, sessionID), next)
const id = key(directory, sessionID)
snapshots.set(id, next)
synced.set(id, version)
yield* Effect.sync(() => changed(sessionID, directory, next))
})
export const inherit = Effect.fn("SandboxPolicy.inherit")(function* (
@@ -368,7 +517,7 @@ export const inherit = Effect.fn("SandboxPolicy.inherit")(function* (
) {
const directory = yield* InstanceState.directory
const source = sourceDirectory ?? directory
yield* locked(
yield* refreshing(
parentID,
Effect.gen(function* () {
const stored = yield* read(source, parentID)
@@ -377,9 +526,9 @@ export const inherit = Effect.fn("SandboxPolicy.inherit")(function* (
// Only persist the parent snapshot when it actually belongs to this directory. A fallback
// carries confinement from another directory (e.g. forking into a worktree) and must not be
// written back under the parent's key here, or it leaks a phantom parent record.
yield* locked(
yield* refreshing(
sessionID,
inheritSnapshot(directory, parent, sessionID),
inheritSnapshot(directory, parent, sessionID, synced.get(key(source, parentID)) ?? -1),
)
}),
)
@@ -404,10 +553,17 @@ export function retire<A, E, R>(
return locked(
sessionID,
Effect.gen(function* () {
const result = yield* effect
yield* Effect.promise(() => SandboxStore.remove(directory, sessionID))
snapshots.delete(key(directory, sessionID))
return result
return yield* refreshing(
sessionID,
Effect.gen(function* () {
const result = yield* effect
yield* Effect.promise(() => SandboxStore.remove(directory, sessionID))
const id = key(directory, sessionID)
snapshots.delete(id)
synced.delete(id)
return result
}),
)
}),
)
}
@@ -416,13 +572,20 @@ export function dispose<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A,
return locked(
sessionID,
Effect.gen(function* () {
const result = yield* effect
yield* Effect.promise(() => SandboxStore.dispose(sessionID))
const suffix = "\0" + sessionID
for (const id of snapshots.keys()) {
if (id.endsWith(suffix)) snapshots.delete(id)
}
return result
return yield* refreshing(
sessionID,
Effect.gen(function* () {
const result = yield* effect
yield* Effect.promise(() => SandboxStore.dispose(sessionID))
const suffix = "\0" + sessionID
for (const id of snapshots.keys()) {
if (!id.endsWith(suffix)) continue
snapshots.delete(id)
synced.delete(id)
}
return result
}),
)
}),
)
}
@@ -436,20 +599,18 @@ function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>)
sessionID,
1,
Effect.gen(function* () {
const current = yield* snapshot(sessionID)
if (!current.state.enabled) return yield* unrestricted(effect)
const support = backendSupport({ mode: current.state.mode, allowedHosts: current.state.allowedHosts })
const active = yield* current(sessionID, true)
if (!active.state.enabled) return yield* unrestricted(effect)
const support = backendSupport({ mode: active.state.mode, allowedHosts: active.state.allowedHosts })
if (!support.available) {
return yield* Effect.fail(
new Error(support.reason ?? "The configured sandbox backend is unavailable"),
)
return yield* Effect.fail(new Error(support.reason ?? "The configured sandbox backend is unavailable"))
}
return yield* runSandbox(
profile(
yield* InstanceState.context,
current.state.mode,
current.state.writablePaths,
current.state.allowedHosts,
active.state.mode,
active.state.writablePaths,
active.state.allowedHosts,
),
effect,
)
@@ -75,10 +75,10 @@ export const clear = Effect.fn("SandboxState.clear")(function* (sessionID: Sessi
.transaction((tx) =>
Effect.gen(function* () {
const row = yield* tx
.select({ metadata: SessionTable.metadata })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.select({ metadata: SessionTable.metadata })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
if (!row) return
yield* tx
.update(SessionTable)
@@ -7,7 +7,7 @@ import type { Profile } from "@kilocode/sandbox"
import type { SessionID } from "@/session/schema"
export namespace SandboxStore {
/** Session confinement authority captured independently from later configuration reloads. */
/** Persisted session confinement authority, refreshed from trusted settings between tool executions. */
export type Snapshot = {
enabled: boolean
mode: Profile["network"]["mode"]
@@ -42,7 +42,8 @@ export namespace SandboxStore {
if (state.allowedHosts !== undefined && !Array.isArray(state.allowedHosts)) return false
if (state.writablePaths !== undefined && !Array.isArray(state.writablePaths)) return false
if (Array.isArray(state.allowedHosts) && state.allowedHosts.some((value) => typeof value !== "string")) return false
if (Array.isArray(state.writablePaths) && state.writablePaths.some((value) => typeof value !== "string")) return false
if (Array.isArray(state.writablePaths) && state.writablePaths.some((value) => typeof value !== "string"))
return false
if (state.mode === "proxy" && (!Array.isArray(state.allowedHosts) || state.allowedHosts.length === 0)) return false
return true
}
@@ -1,5 +1,6 @@
import { Account } from "@/account/account"
import { Auth } from "@/auth"
import { GlobalBus } from "@/bus/global"
import { Config } from "@/config/config"
import * as InstanceState from "@/effect/instance-state"
import { KilocodeConfigOverlay } from "@/kilocode/config/overlay"
@@ -10,6 +11,7 @@ import { ConfigRules } from "@/kilocode/server/routes/config-rules"
import { KilocodeKeybinds } from "@/kilocode/tui/keybinds"
import { KilocodeTuiConfig } from "@/kilocode/tui/config"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { Event } from "@/server/event"
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import { markInstanceForDisposal } from "@/server/routes/instance/httpapi/lifecycle"
import { InvalidRequestError } from "@/server/routes/instance/httpapi/errors"
@@ -106,8 +108,30 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf
const hot = body.scope === "global" && Object.keys(patch).every((key) => key === "console")
if (body.scope === "global") {
yield* config.invalidate()
if (result.changed) {
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.ConfigUpdated.type,
properties: { sandbox: result.sandboxChanged },
},
}),
).pipe(Effect.catchCause(() => Effect.void))
}
} else {
yield* config.update({})
if (result.sandboxChanged) {
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: instance.directory,
payload: {
type: Event.ConfigUpdated.type,
properties: { sandbox: true },
},
}),
).pipe(Effect.catchCause(() => Effect.void))
}
yield* markInstanceForDisposal(instance)
}
const all = yield* auth.all().pipe(Effect.orElseSucceed(() => ({})))
@@ -140,7 +164,7 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf
sources: sources.sources,
}),
)
if (body.scope === "global" && !hot) {
if (body.scope === "global" && result.changed && !hot) {
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }).pipe(
Effect.catchCause(() => Effect.void),
)
@@ -26,5 +26,6 @@ Do NOT suggest a review when:
Choosing the right review prompt for the action prompt:
- Use `/review uncommitted` as the action prompt for uncommitted working-tree changes (staged, unstaged, and untracked files)
- Use `/review branch` as the action prompt for committed branch-level changes
- Use `/review unpushed` as the action prompt for committed changes ahead of upstream
- Use `/review branch` as the action prompt for branch-level changes against base
- Prefer `/review uncommitted` when the work you just did has not been committed yet
+3
View File
@@ -1,5 +1,6 @@
import type { ChildProcessWithoutNullStreams } from "child_process"
import { Process } from "@/util/process"
import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change
type Child = Process.Child & ChildProcessWithoutNullStreams
@@ -10,6 +11,8 @@ export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts
const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts
const proc = Process.spawn([cmd, ...args], {
...cfg,
env: modelEnv(cfg?.env), // kilocode_change - language servers must not inherit backend credentials
extendEnv: false, // kilocode_change
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
+5 -3
View File
@@ -37,6 +37,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { TuiEvent } from "@/server/tui-event"
import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change
import { InstanceState } from "@/effect/instance-state"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -375,11 +376,12 @@ const layer = Layer.effect(
command: cmd,
args: finalArgs, // kilocode_change
cwd,
env: {
...process.env,
// kilocode_change start - local MCPs must not inherit backend credentials
env: modelEnv({
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
},
}),
// kilocode_change end
})
// kilocode_change start - a piped stderr stream must be consumed or verbose MCP servers can block
transport.stderr?.on("data", (chunk: Buffer) => {
+2 -4
View File
@@ -12,6 +12,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change
import { Shell } from "@opencode-ai/core/shell"
import { ShellID } from "./shell/id"
@@ -522,10 +523,7 @@ export const ShellTool = Tool.define(
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
{ env: {} },
)
return {
...process.env,
...extra.env,
}
return modelEnv(extra.env) // kilocode_change - model shells must not inherit backend credentials
})
const run = Effect.fn("ShellTool.run")(function* (
+3 -1
View File
@@ -10,6 +10,7 @@ export type Shell = boolean | string
export interface Options {
cwd?: string
env?: NodeJS.ProcessEnv | null
extendEnv?: boolean // kilocode_change - allow a complete sanitized environment
stdin?: Stdio
stdout?: Stdio
stderr?: Stdio
@@ -63,7 +64,7 @@ export function spawn(cmd: string[], opts: Options = {}): Child {
const proc = launch(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
shell: opts.shell,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
env: opts.env === null ? {} : opts.env ? (opts.extendEnv === false ? opts.env : { ...process.env, ...opts.env }) : undefined, // kilocode_change
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
windowsHide: process.platform === "win32",
})
@@ -115,6 +116,7 @@ export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result>
const proc = spawn(cmd, {
cwd: opts.cwd,
env: opts.env,
extendEnv: opts.extendEnv, // kilocode_change
stdin: opts.stdin,
shell: opts.shell,
abort: opts.abort,
@@ -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()
@@ -22,6 +22,10 @@ describe("review command parsing", () => {
expect(parseReviewCommand("/review")).toBe("review")
expect(parseReviewCommand("/review focus on tests")).toBe("review")
expect(parseReviewCommand("/review uncommitted focus on tests")).toBe("review")
expect(parseReviewCommand("/review staged")).toBe("review")
expect(parseReviewCommand("/review unpushed")).toBe("review")
expect(parseReviewCommand("/review quick")).toBe("review")
expect(parseReviewCommand("/review --quick")).toBe("review")
expect(parseReviewCommand("/review branch origin/main focus on auth")).toBe("review")
expect(parseReviewCommand("/review a1b2c3d")).toBe("review")
expect(parseReviewCommand("/review https://github.com/Kilo-Org/kilocode/pull/11084")).toBe("review")
@@ -60,6 +64,14 @@ describe("review command", () => {
expect(text).toContain("git ls-files --others --exclude-standard")
})
test("documents explicit staged and unpushed review", () => {
const text = cmd.template as string
expect(text).toContain("`/review staged [guidance]`")
expect(text).toContain("`/review unpushed [guidance]`")
expect(text).toContain("For staged review")
expect(text).toContain("For unpushed review")
})
test("documents explicit and ref-based branch review", () => {
const text = cmd.template as string
expect(text).toContain("`/review branch [base] [guidance]`")
@@ -133,6 +145,7 @@ describe("review command", () => {
test("applies adaptive parallel review tracks", () => {
const text = cmd.template as string
expect(text).toContain("Quick mode (`quick`, `--quick`, `-q`, or `--effort 1-3`)")
expect(text).toContain("spawn the appropriate sub-agents in parallel")
expect(text).toContain("do NOT spawn sub-agents")
expect(text).toContain("spawn a single security sub-agent")
@@ -144,7 +157,7 @@ describe("review command", () => {
expect(text).toContain("NO_FINDINGS")
})
it.live("lists review and deprecated review aliases", () =>
it.live("resolves review and deprecated review aliases", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
@@ -156,8 +169,8 @@ describe("review command", () => {
const uncommitted = yield* command.get("local-review-uncommitted")
expect(names).toContain("review")
expect(names).toContain("local-review")
expect(names).toContain("local-review-uncommitted")
expect(names).not.toContain("local-review")
expect(names).not.toContain("local-review-uncommitted")
expect(review?.name).toBe("review")
expect(branch?.description).toBe("deprecated; use /review branch")
expect(branch?.template).toBe(legacyReviewMessage("local-review"))
@@ -130,6 +130,17 @@ describe("sandbox policy", () => {
expect(actual).not.toContain(dirs.b)
})
test("drops inherited writable ancestors for a managed worktree", async () => {
await using tmp = await fixture()
const dirs = tmp.extra
const policy = profile(context(dirs.a, dirs.main, dirs), "deny", [dirs.main, dirs.approved])
const paths = policy.filesystem.allowWrite.map((rule) => rule.path)
expect(paths).not.toContain(dirs.main)
expect(paths).toContain(dirs.approved)
expect(paths).toContain(dirs.a)
})
posix("fails closed when a worktree marker cannot be resolved", async () => {
await using tmp = await fixture()
const dirs = tmp.extra
@@ -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) {
@@ -81,11 +81,18 @@ describe("pty HttpApi bridge", () => {
expect(list.status).toBe(200)
expect(await list.json()).toEqual([])
// kilocode_change start - test initial spawn dimensions
const created = await app().request(PtyPaths.create, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"], title: "demo" }),
body: JSON.stringify({
command: "/usr/bin/env",
args: ["sh", "-c", "sleep 5"],
title: "demo",
size: { cols: 50, rows: 20 },
}),
})
// kilocode_change end
expect(created.status).toBe(200)
const info = await created.json()
@@ -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