mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/johnnyeric/kilo-opencode-v1.18.0' into johnnyeric/kilo-opencode-v1.18.13
This commit is contained in:
@@ -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> = {}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>\``
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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* (
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user