mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
+25







![opencode-agent[bot]](/assets/img/avatar_default.png)

Johnny Eric Amancio
opencode-agent[bot]
LukeParkerDev
Shoubhit Dash
Simon Klee
Brendan Allan
Brendan Allan
starptech
Cortex Code
Dax
Dax Raad
Frank
opencode-agent[bot]
Aiden Cline
Michael Hart
smagnuso
Orca丶
Adam
Aarav Sareen
Kit Langton
James Long
Dustin Deus
Ulises Jeremias
Jack
Jérôme Benoit
Ariane Emory
LIU Xinyu
Colin McDonnell
Sebastian
opencode
Kamesh Sampath
pcadena-lila
weiconghe
alberto
kilo-maintainer[bot]
ef6b152ff8
* feat(worktree): add managed workspace cloning (#30117) * test(tui): skip crashing keymap textarea renderer * fix(core): allow skipping migration execution * fix(opencode): remove automatic full session diffs (#30127) * chore: generate * refactor(worktree): move project out of repository * zen: deepseek flash * fix(tui): remount session view on session switch (#30129) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * go: minimax m3 * refactor(opencode): inline local provider helpers (#30169) * refactor(opencode): simplify provider setup flow (#30173) * fix(app): show project sessions before path sync resolves (#30167) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * fix(core): preserve session metadata migration identity (#30176) * refactor(session): align namespace imports and inline trivial helpers (#30180) * opencode(run): add queued prompt management (#30103) Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn. * chore: generate * fix(acp): honor session/cancel by aborting the running turn (#30145) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(tui): prevent prompt corruption when pasting near wide characters (#29710) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> * fix(opencode): avoid nullable webfetch format schema (#30215) * chore: generate * fix(core): contain lsp warmup defects (#30226) * add run --replay mode (#30239) * chore: generate * chore: update nix node_modules hashes * fix(stats): restore leaderboard spacing * fix(stats): center top models dot grid * fix(stats): stabilize top models hover * fix(stats): align big-pickle provider resolution (#30274) * feat(app): v2 desktop UI improvements (#29689) Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> * chore: generate * fix(tui): clarify inline subagent rows (#30051) * fix(tui): handle events across workspaces (#30281) * feat(core): update Copilot for token-based billing (#30181) * fix(tui): keep background marker with subagent label (#30271) * fix(tui): keep retry attempt before message (#30275) * chore: generate * fix(opencode): enforce storage path invariants (#29666) * chore: generate * feat(core): add location-based permission service (#30287) * chore: generate * fix(tui): preserve live parts during session hydration (#30300) * fix(app): restore deferred MCP status updates (#30220) * fix: export v2 stylesheets and declare core node types (#30312) * chore: update nix node_modules hashes * fix(app): avoid suspending on pending child path (#30314) * fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316) * chore: generate * feat(core): expose session location * chore: generate * fix(opencode): preserve websocket api errors (#30321) * refactor(core): simplify session pagination * feat(core): add location filesystem contract * feat(core): add dummy location filesystem layer * chore: generate * feat(opencode): add filesystem read and list routes * chore: generate * infra: stats * sync * feat(app): inset new layout session panels (#30342) * fix(app): tab title truncation and close button positioning (#30349) * tui: show model context in run footer (#30380) * tui: revert OpenTUI upgrade to 0.2.16 (#30383) * chore: update nix node_modules hashes * feat(core): add managed repository cache (#30408) * chore: generate * chore: generate * sync * feat(stats): add cache ratio section * feat(core): add flagged project references (#30414) * chore: generate * feat(core): support named migrations (#30418) * fix(stats): clean retired provider rows during sync (#30420) * fix(stats): mention opencode go in top models copy * feat(core): expose project reference filesystem access (#30423) * chore: generate * sync * fix(tui): scope diff viewer to session directory (#30426) * test: widen provider header timeout margin (#30427) * fix(plugin): restore private git install fallback (#30430) * fix(stats): remove leaderboard nav link * chore(opencode): remove scout agent (#30435) * chore: generate * feat(stats): improve cache ratio chart * chore: generate * fix(effect-drizzle-sqlite): preserve transaction begin errors (#30448) * chore: bump effect beta to 74 (#30449) * Revert "tui: revert OpenTUI upgrade to 0.2.16 (#30383)" (#30452) * chore: update nix node_modules hashes * refactor(opencode): improve startup time by 38% (#30453) Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> * chore: generate * fix(opencode): patch empty Gemini replay messages (#30463) * chore: generate * refactor(core): consolidate filesystem services (#30447) * chore: generate * run: enable interactive replay by default (#30465) * chore: update nix node_modules hashes * refactor(opencode): remove JSON storage migration (#30461) * chore: generate * chore: update nix node_modules hashes * fix(tui): stop idle background task spinner (#30484) * refactor(core): move v1 schemas into core (#30473) * chore: generate * fix: task id passed to background job for continuation (#30485) * chore: generate * feat(core): project copying and tracking directories (#30139) * chore: generate * fix(opencode): preserve signed thinking during anthropic reorder (#30182) * Revert "fix(opencode): preserve signed thinking during anthropic reorder" (#30502) * fix: rm tool reorder logic from old bug (#30483) * chore: generate * feat(app): polish home projects list UI (#30436) * feat(app): polish select-v2 component (#30446) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(github): enforce existing git author identity (#30507) * feat(app): new update button (#30460) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(opencode): fallback to sh for curl upgrade (#30499) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(ui): render whole-file patches as complete diffs (#30516) * chore: generate * feat(app): add servers tab to settings dialog (#29675) * refactor(core): consolidate pty service (#30537) * chore: generate * tui: truncate sidebar file paths (#30531) * chore: update nix node_modules hashes * feat(stats): add geo breakdown (#30456) * chore: generate * chore: update nix node_modules hashes * fix(acp): classify apply_patch as edit (#30564) * fix(acp): classify task as think (#30565) * fix(acp): include external directory permission context (#30567) * fix(acp): clean read tool display content (#30569) * fix(tui): route question responses by session directory (#30578) * fix(stats): serve stats og image from banner * docs(go): add Qwen3.7 Plus model (#30594) * fix(openai): preserve websocket idle state (#30586) * refactor(core): remove ai sdk option fields (#30581) * chore: generate * test(core): cover v1 provider option lowering (#30599) * chore: generate * refactor(core): nest model api id (#30603) * fix(core): expose azure openai xhigh efforts (#30620) * feat(core): add skill registry and file agent loading (#30617) * chore: generate * chore: update nix node_modules hashes * fix(stats): count all go usage * chore: remove zed extension and automation (#30628) * fix(opencode): preserve variant for delegated tasks (#30630) * zen: update nvidia tos * fix(opencode): route SAP AI Core reasoning variants through modelParams (#30482) * chore: generate * fix(app): hide unavailable titlebar update (#30642) * feat(app): v2 thinking level selector (#30646) * fix(app,ui): session review reactivity and VCS query cache (#30660) * feat(core): add embedded v2 session runtime and tool foundation (#30632) * chore: generate * chore: update nix node_modules hashes * docs: correct compaction prune default (#30670) * fix(opencode): avoid shell cancel race (#30641) * feat: bump bedrock and add proper mantle support for openai models through aws bedrock (#30464) * test: wait for shell truncation readiness (#30679) * chore: update nix node_modules hashes * refactor(opencode): clean up task tool prompts (#30687) * feat(core): add command registry (#30624) * chore: generate * fix(acp): replay loaded session transcript (#30645) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): reset pre-launch session projections (#30728) * feat(tui): improve experimental session switcher (#30738) * fix(opencode): respect disabled auto compaction on overflow (#30749) * zen: nemotron 3 ultra * fix(enterprise): install hono standard validator peer (#30740) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix build * chore: update nix node_modules hashes * make scripts executable * fix(tui): show toast when variant_list keybind used with no variants (#30724) * fix(opencode): `ACP.loadSession` should replay all messages (#30761) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(opencode): attribute task child agent on creation (#30786) * fix(tui): add Vue syntax highlighting (#30802) * fix: bump @openrouter/ai-sdk-provider to 2.9.0 (#30800) * feat(core): moving sessions (#30640) * chore: generate * tweak: background agent prompting to avoid polling issues (#30790) * upgrade opentui to 0.3.2 (#30748) * chore: update nix node_modules hashes * feat(desktop): surface local server startup failures (#30822) * ci: publish * refactor(core): make v2 session inputs event sourced (#30785) * chore: generate * fix(llm): normalize OpenAI function tool schemas * chore: generate * feat(stats): refresh stats routes and homepage (#30419) * fix(stats): sort metric charts by top usage * feat(core): add public native API (#30828) * chore: generate * feat(app): color themes (#30824) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * chore: generate * sync release versions for v1.16.0 * feat(core): attach global native tools (#30832) * chore: generate * feat(core): add Snowflake Cortex provider (#29901) Co-authored-by: Cortex Code <noreply@snowflake.com> * chore: generate * feat(core): persist v2 session context epochs (#30789) * chore: generate * feat(tui): allow backgrounding synchronous subagents (#30488) * fix(app): improve tab handling (#30669) * chore: generate * fix(tui): prioritize models slash autocomplete (#30848) * fix(tui): route permission replies to session directory (#30851) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix(cli): harden daemon lifecycle (#30844) * chore: generate * feat(app): improve desktop multi-server support (#30678) Co-authored-by: Brendan Allan <git@brendonovich.dev> * chore: generate * fix(app): handle tab overflow and scrolling in titlebar (#30886) * fix(app): tab overflow (#30894) * tui: guard path formatting inputs (#30469) Fixes #27726, #25216, #24856, #24294, #17071, #29164, #24837, #16865, #14279, #29895 * opencode/run: refresh themes after terminal reloads (#30917) * chore: generate * fix(tui): fall back to local cwd when editor spawns in attach mode (#30583) * docs: update Go Qwen tiered pricing (#30936) * chore: generate * feat(tui): add diff hunk navigation (#30935) * chore: rm fuzzy search on references (#30931) * fix: use mapError instead of orDie for context snapshot decoding (#30905) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): recover corrupted models cache (#30947) * chore: bun install (#30968) * fix(opencode): resolve Bedrock hang by using node build conditions (#30873) * fix(workflows): retry nix-hashes compute-hash on transient failure (#30743) * fix(stats): scroll model charts to latest on mobile * fix(opencode): prevent destructive edit matches (#30932) * chore: generate * fix(core): respect v2 default agents (#30969) * chore: generate * test(opencode): remove disposal event wait race (#30971) * test(opencode): remove shell timeout output race (#30974) * fix(opencode): gate reasoning summaries by provider (#30973) * feat(core): admit v2 skill guidance (#30843) * fix(workflows): serialize desktop release uploads (#30978) * fix(stats): add mobile chart end spacing * release: v1.16.2 * refactor: kilo compat for v1.16.2 * fix(opencode): address v1.16.2 merge regressions * chore: update kilo-vscode visual regression baselines * fix(opencode): restore Kilo behavior after v1.16.2 merge * fix(opencode): retry Windows migration cleanup * test(opencode): restore clone and macOS watcher coverage * fix(opencode): address second-pass review for #12099 Preserve imported usage and retry partial JSON migrations. Refresh active dependency patches, remove the obsolete GCP patch, and regenerate Kilo HttpApi branding. --------- Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Michael Hart <mhart@cloudflare.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: smagnuso <smagnuso@gmail.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Orca丶 <93272799+dauphinYan@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Ulises Jeremias <ulisescf.24@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Co-authored-by: LIU Xinyu <contact@lxy.cc> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com> Co-authored-by: Cortex Code <noreply@snowflake.com> Co-authored-by: pcadena-lila <pcadena@lila.ai> Co-authored-by: weiconghe <46336277+weiconghe@users.noreply.github.com> Co-authored-by: alberto <914199+alblez@users.noreply.github.com> Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
1242 lines
42 KiB
TypeScript
1242 lines
42 KiB
TypeScript
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
|
import { describe, expect } from "bun:test"
|
|
import { Cause, Effect, Exit, Layer } from "effect"
|
|
import type * as Scope from "effect/Scope"
|
|
import os from "os"
|
|
import path from "path"
|
|
import { Config } from "@/config/config"
|
|
import { Shell } from "../../src/shell/shell"
|
|
import { ShellTool } from "../../src/tool/shell"
|
|
import { Filesystem } from "@/util/filesystem"
|
|
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
|
|
import type { Permission } from "../../src/permission"
|
|
import { Agent } from "../../src/agent/agent"
|
|
import { Truncate } from "@/tool/truncate"
|
|
import { SessionID, MessageID } from "../../src/session/schema"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
import { Plugin } from "../../src/plugin"
|
|
import { testEffect } from "../lib/effect"
|
|
import { Tool } from "@/tool/tool"
|
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
|
import { InstanceStore } from "@/project/instance-store"
|
|
|
|
const shellLayer = Layer.mergeAll(
|
|
CrossSpawnSpawner.defaultLayer,
|
|
FSUtil.defaultLayer,
|
|
Plugin.defaultLayer,
|
|
Truncate.defaultLayer,
|
|
Config.defaultLayer,
|
|
Agent.defaultLayer,
|
|
RuntimeFlags.defaultLayer,
|
|
testInstanceStoreLayer,
|
|
)
|
|
const it = testEffect(shellLayer)
|
|
type ShellTestServices =
|
|
| (typeof shellLayer extends Layer.Layer<infer ROut, infer _E, infer _RIn> ? ROut : never)
|
|
| InstanceStore.Service
|
|
| Scope.Scope
|
|
|
|
const initShell = Effect.fn("ShellToolTest.init")(function* () {
|
|
const info = yield* ShellTool
|
|
return yield* info.init()
|
|
})
|
|
|
|
const initBash = initShell
|
|
|
|
const run = Effect.fn("ShellToolTest.run")(function* (
|
|
args: Tool.InferParameters<typeof ShellTool>,
|
|
next: Tool.Context = ctx,
|
|
) {
|
|
const bash = yield* initShell()
|
|
return yield* bash.execute(args, next)
|
|
})
|
|
|
|
const runIn = <A, E, R>(directory: string, self: Effect.Effect<A, E, R>) => self.pipe(provideInstance(directory))
|
|
|
|
const fail = Effect.fn("ShellToolTest.fail")(function* (
|
|
args: Tool.InferParameters<typeof ShellTool>,
|
|
next: Tool.Context = ctx,
|
|
) {
|
|
const exit = yield* run(args, next).pipe(Effect.exit)
|
|
if (Exit.isFailure(exit)) {
|
|
const err = Cause.squash(exit.cause)
|
|
return err instanceof Error ? err : new Error(String(err))
|
|
}
|
|
throw new Error("expected command to fail")
|
|
})
|
|
|
|
const ctx = {
|
|
sessionID: SessionID.make("ses_test"),
|
|
messageID: MessageID.make("msg_test"),
|
|
callID: "",
|
|
agent: "code", // kilocode_change
|
|
abort: AbortSignal.any([]),
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
}
|
|
|
|
Shell.acceptable.reset()
|
|
const quote = (text: string) => `"${text}"`
|
|
const squote = (text: string) => `'${text}'`
|
|
const projectRoot = path.join(__dirname, "../..")
|
|
const bin = quote(process.execPath.replaceAll("\\", "/"))
|
|
const bash = (() => {
|
|
const shell = Shell.acceptable()
|
|
if (Shell.name(shell) === "bash") return shell
|
|
return Shell.gitbash()
|
|
})()
|
|
const shells = (() => {
|
|
if (process.platform !== "win32") {
|
|
const shell = Shell.acceptable()
|
|
return [{ label: Shell.name(shell), shell }]
|
|
}
|
|
|
|
const list = [bash, Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || Bun.which("cmd.exe")]
|
|
.filter((shell): shell is string => Boolean(shell))
|
|
.map((shell) => ({ label: Shell.name(shell), shell }))
|
|
|
|
return list.filter(
|
|
(item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i,
|
|
)
|
|
})()
|
|
const PS = new Set(["pwsh", "powershell"])
|
|
const ps = shells.filter((item) => PS.has(item.label))
|
|
const cmdShell = shells.find((item) => item.label === "cmd")
|
|
|
|
const sh = () => Shell.name(Shell.acceptable())
|
|
const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text))
|
|
|
|
const fill = (mode: "lines" | "bytes", n: number) => {
|
|
const code =
|
|
mode === "lines"
|
|
? "console.log(Array.from({length:Number(Bun.argv[1])},(_,i)=>i+1).join(String.fromCharCode(10)))"
|
|
: "process.stdout.write(String.fromCharCode(97).repeat(Number(Bun.argv[1])))"
|
|
const text = `${bin} -e ${evalarg(code)} ${n}`
|
|
if (PS.has(sh())) return `& ${text}`
|
|
return text
|
|
}
|
|
const glob = (p: string) =>
|
|
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
|
|
|
|
const forms = (dir: string) => {
|
|
if (process.platform !== "win32") return [dir]
|
|
const full = Filesystem.normalizePath(dir)
|
|
const slash = full.replaceAll("\\", "/")
|
|
const root = slash.replace(/^[A-Za-z]:/, "")
|
|
return Array.from(new Set([full, slash, root, root.toLowerCase()]))
|
|
}
|
|
|
|
const withShell = <A, E, R>(item: { label: string; shell: string }, self: Effect.Effect<A, E, R>) =>
|
|
Effect.acquireUseRelease(
|
|
Effect.sync(() => {
|
|
const prev = process.env.SHELL
|
|
process.env.SHELL = item.shell
|
|
Shell.acceptable.reset()
|
|
Shell.preferred.reset()
|
|
return prev
|
|
}),
|
|
() => self,
|
|
(prev) =>
|
|
Effect.sync(() => {
|
|
if (prev === undefined) delete process.env.SHELL
|
|
else process.env.SHELL = prev
|
|
Shell.acceptable.reset()
|
|
Shell.preferred.reset()
|
|
}),
|
|
)
|
|
|
|
const each = (
|
|
name: string,
|
|
fn: (item: { label: string; shell: string }) => Effect.Effect<void, unknown, ShellTestServices>,
|
|
) => {
|
|
for (const item of shells) {
|
|
it.live(`${name} [${item.label}]`, () => withShell(item, fn(item)))
|
|
}
|
|
}
|
|
|
|
const capture = (requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
|
|
...ctx,
|
|
ask: (req: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">) =>
|
|
Effect.sync(() => {
|
|
requests.push(req)
|
|
if (stop) throw stop
|
|
}),
|
|
})
|
|
|
|
const mustTruncate = (result: {
|
|
metadata: { truncated?: boolean; exit?: number | null } & Record<string, unknown>
|
|
output: string
|
|
}) => {
|
|
if (result.metadata.truncated) return
|
|
throw new Error(
|
|
[`shell: ${process.env.SHELL || ""}`, `exit: ${String(result.metadata.exit)}`, "output:", result.output].join("\n"),
|
|
)
|
|
}
|
|
|
|
describe("tool.shell", () => {
|
|
each("basic", () =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const result = yield* run({
|
|
command: "echo test",
|
|
description: "Echo test message",
|
|
})
|
|
expect(result.metadata.exit).toBe(0)
|
|
expect(result.metadata.output).toContain("test")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("falls back from terminal-only configured shell", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped({ config: { shell: "fish" } })
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const bash = yield* initBash()
|
|
const fallback = Shell.name(Shell.acceptable("fish"))
|
|
expect(fallback).not.toBe("fish")
|
|
expect(bash.description).toContain(fallback)
|
|
|
|
const result = yield* bash.execute(
|
|
{
|
|
command: "echo fallback",
|
|
description: "Echo fallback text",
|
|
},
|
|
ctx,
|
|
)
|
|
expect(result.metadata.exit).toBe(0)
|
|
expect(result.output).toContain("fallback")
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
|
|
describe("tool.shell permissions", () => {
|
|
each("asks for bash permission with correct pattern", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: "echo hello",
|
|
description: "Echo hello",
|
|
},
|
|
capture(requests),
|
|
)
|
|
expect(requests.length).toBe(1)
|
|
expect(requests[0].permission).toBe("bash")
|
|
expect(requests[0].patterns).toContain("echo hello")
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
each("asks for bash permission with multiple commands", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: "echo foo && echo bar",
|
|
description: "Echo twice",
|
|
},
|
|
capture(requests),
|
|
)
|
|
expect(requests.length).toBe(1)
|
|
expect(requests[0].permission).toBe("bash")
|
|
expect(requests[0].patterns).toContain("echo foo")
|
|
expect(requests[0].patterns).toContain("echo bar")
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
for (const item of ps) {
|
|
it.live(`parses PowerShell conditionals for permission prompts [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: "Write-Host foo; if ($?) { Write-Host bar }",
|
|
description: "Check PowerShell conditional",
|
|
},
|
|
capture(requests),
|
|
)
|
|
const bashReq = requests.find((r) => r.permission === "bash")
|
|
expect(bashReq).toBeDefined()
|
|
expect(bashReq!.patterns).toContain("Write-Host foo")
|
|
expect(bashReq!.patterns).toContain("Write-Host bar")
|
|
expect(bashReq!.always).toContain("Write-Host *")
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: "Remove-Item -Recurse tmp",
|
|
description: "Remove a temp directory",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
const bashReq = requests.find((r) => r.permission === "bash")
|
|
expect(bashReq).toBeDefined()
|
|
expect(bashReq!.always).toContain("Remove-Item *")
|
|
expect(bashReq!.always).not.toContain("Remove-Item -Recurse *")
|
|
}),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
}
|
|
|
|
each("asks for external_directory permission for wildcard external paths", () =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
|
|
const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: `cat ${file}`,
|
|
description: "Read wildcard path",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
expect(extDirReq).toBeDefined()
|
|
expect(extDirReq!.patterns).toContain(want)
|
|
}),
|
|
),
|
|
)
|
|
|
|
if (process.platform === "win32") {
|
|
if (bash) {
|
|
it.live("asks for nested bash command permissions [bash]", () =>
|
|
withShell(
|
|
{ label: "bash", shell: bash },
|
|
Effect.gen(function* () {
|
|
const outerTmp = yield* tmpdirScoped()
|
|
yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x"))
|
|
yield* runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const file = path.join(outerTmp, "outside.txt").replaceAll("\\", "/")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: `echo $(cat "${file}")`,
|
|
description: "Read nested bash file",
|
|
},
|
|
capture(requests),
|
|
)
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
const bashReq = requests.find((r) => r.permission === "bash")
|
|
expect(extDirReq).toBeDefined()
|
|
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp, "*")))
|
|
expect(bashReq).toBeDefined()
|
|
expect(bashReq!.patterns).toContain(`cat "${file}"`)
|
|
}),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`asks for external_directory permission for PowerShell paths after switches [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
|
|
description: "Copy Windows ini",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
expect(extDirReq).toBeDefined()
|
|
expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*")))
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`asks for nested PowerShell command permissions [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
|
|
yield* run(
|
|
{
|
|
command: `Write-Output $(Get-Content ${file})`,
|
|
description: "Read nested PowerShell file",
|
|
},
|
|
capture(requests),
|
|
)
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
const bashReq = requests.find((r) => r.permission === "bash")
|
|
expect(extDirReq).toBeDefined()
|
|
expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*")))
|
|
expect(bashReq).toBeDefined()
|
|
expect(bashReq!.patterns).toContain(`Get-Content ${file}`)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: 'Get-Content "C:../outside.txt"',
|
|
description: "Read drive-relative file",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
expect(requests[0]?.permission).toBe("external_directory")
|
|
if (requests[0]?.permission !== "external_directory") return
|
|
expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp), "*")))
|
|
}),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: 'Get-Content "$HOME/.ssh/config"',
|
|
description: "Read home config",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
expect(requests[0]?.permission).toBe("external_directory")
|
|
if (requests[0]?.permission !== "external_directory") return
|
|
expect(requests[0].patterns).toContain(glob(path.join(os.homedir(), ".ssh", "*")))
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: 'Get-Content "$PWD/../outside.txt"',
|
|
description: "Read pwd-relative file",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
expect(requests[0]?.permission).toBe("external_directory")
|
|
if (requests[0]?.permission !== "external_directory") return
|
|
expect(requests[0].patterns).toContain(glob(path.join(path.dirname(tmp), "*")))
|
|
}),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: 'Get-Content "$PSHOME/outside.txt"',
|
|
description: "Read pshome file",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
expect(requests[0]?.permission).toBe("external_directory")
|
|
if (requests[0]?.permission !== "external_directory") return
|
|
expect(requests[0].patterns).toContain(glob(path.join(path.dirname(item.shell), "*")))
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`asks for external_directory permission for missing PowerShell env paths [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
Effect.acquireUseRelease(
|
|
Effect.sync(() => {
|
|
const key = "KILO_TEST_MISSING"
|
|
const prev = process.env[key]
|
|
delete process.env[key]
|
|
return { key, prev }
|
|
}),
|
|
({ key }) =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
|
|
description: "Read Windows ini with missing env",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
expect(extDirReq).toBeDefined()
|
|
expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*")))
|
|
}),
|
|
),
|
|
({ key, prev }) =>
|
|
Effect.sync(() => {
|
|
if (prev === undefined) delete process.env[key]
|
|
else process.env[key] = prev
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`asks for external_directory permission for PowerShell env paths [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: "Get-Content $env:WINDIR/win.ini",
|
|
description: "Read Windows ini from env",
|
|
},
|
|
capture(requests),
|
|
)
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
expect(extDirReq).toBeDefined()
|
|
expect(extDirReq!.patterns).toContain(
|
|
Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`,
|
|
description: "Read Windows ini from FileSystem provider",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
expect(requests[0]?.permission).toBe("external_directory")
|
|
if (requests[0]?.permission !== "external_directory") return
|
|
expect(requests[0].patterns).toContain(
|
|
Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`asks for external_directory permission for braced PowerShell env paths [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: "Get-Content ${env:WINDIR}/win.ini",
|
|
description: "Read Windows ini from braced env",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
expect(requests[0]?.permission).toBe("external_directory")
|
|
if (requests[0]?.permission !== "external_directory") return
|
|
expect(requests[0].patterns).toContain(
|
|
Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`treats Set-Location like cd for permissions [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: "Set-Location C:/Windows",
|
|
description: "Change location",
|
|
},
|
|
capture(requests),
|
|
)
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
const bashReq = requests.find((r) => r.permission === "bash")
|
|
expect(extDirReq).toBeDefined()
|
|
expect(extDirReq!.patterns).toContain(
|
|
Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
|
|
)
|
|
expect(bashReq).toBeUndefined()
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
for (const item of ps) {
|
|
it.live(`does not add nested PowerShell expressions to permission prompts [${item.label}]`, () =>
|
|
withShell(
|
|
item,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: "Write-Output ('a' * 3)",
|
|
description: "Write repeated text",
|
|
},
|
|
capture(requests),
|
|
)
|
|
const bashReq = requests.find((r) => r.permission === "bash")
|
|
expect(bashReq).toBeDefined()
|
|
expect(bashReq!.patterns).not.toContain("a * 3")
|
|
expect(bashReq!.always).not.toContain("a *")
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
if (process.platform === "win32" && cmdShell) {
|
|
it.live("asks for external_directory permission for cmd file commands [cmd]", () =>
|
|
withShell(
|
|
cmdShell,
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`,
|
|
description: "Read Windows ini with cmd",
|
|
},
|
|
capture(requests),
|
|
)
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
expect(extDirReq).toBeDefined()
|
|
expect(extDirReq!.patterns).toContain(Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")))
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
each("asks for external_directory permission when cd to parent", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: "cd ../",
|
|
description: "Change to parent directory",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
expect(extDirReq).toBeDefined()
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
each("asks for external_directory permission when workdir is outside project", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: "echo ok",
|
|
workdir: os.tmpdir(),
|
|
description: "Echo from temp dir",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
expect(extDirReq).toBeDefined()
|
|
expect(extDirReq!.patterns).toContain(glob(path.join(os.tmpdir(), "*")))
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
if (process.platform === "win32") {
|
|
it.live("normalizes external_directory workdir variants on Windows", () =>
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const outerTmp = yield* tmpdirScoped()
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const want = Filesystem.normalizePathPattern(path.join(outerTmp, "*"))
|
|
|
|
for (const dir of forms(outerTmp)) {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: "echo ok",
|
|
workdir: dir,
|
|
description: "Echo from external dir",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({
|
|
dir,
|
|
patterns: [want],
|
|
always: [want],
|
|
})
|
|
}
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
if (bash) {
|
|
it.live("uses Git Bash /tmp semantics for external workdir", () =>
|
|
withShell(
|
|
{ label: "bash", shell: bash },
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
const want = glob(path.join(os.tmpdir(), "*"))
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: "echo ok",
|
|
workdir: "/tmp",
|
|
description: "Echo from Git Bash tmp",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
expect(requests[0]).toMatchObject({
|
|
permission: "external_directory",
|
|
patterns: [want],
|
|
always: [want],
|
|
})
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.live("uses Git Bash /tmp semantics for external file paths", () =>
|
|
withShell(
|
|
{ label: "bash", shell: bash },
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
const want = glob(path.join(os.tmpdir(), "*"))
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: "cat /tmp/opencode-does-not-exist",
|
|
description: "Read Git Bash tmp file",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
expect(requests[0]).toMatchObject({
|
|
permission: "external_directory",
|
|
patterns: [want],
|
|
always: [want],
|
|
})
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
each("asks for external_directory permission when file arg is outside project", () =>
|
|
Effect.gen(function* () {
|
|
const outerTmp = yield* tmpdirScoped()
|
|
yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x"))
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
const filepath = path.join(outerTmp, "outside.txt")
|
|
expect(
|
|
yield* fail(
|
|
{
|
|
command: `cat ${filepath}`,
|
|
description: "Read external file",
|
|
},
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
const expected = glob(path.join(outerTmp, "*"))
|
|
expect(extDirReq).toBeDefined()
|
|
expect(extDirReq!.patterns).toContain(expected)
|
|
expect(extDirReq!.always).toContain(expected)
|
|
expect(extDirReq!.metadata).toMatchObject({
|
|
command: `cat ${filepath}`,
|
|
description: "Read external file",
|
|
directories: [outerTmp],
|
|
patterns: [expected],
|
|
})
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
each("does not ask for external_directory permission when rm inside project", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* Effect.promise(() => Bun.write(path.join(tmp, "tmpfile"), "x"))
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: `rm -rf ${path.join(tmp, "nested")}`,
|
|
description: "Remove nested dir",
|
|
},
|
|
capture(requests),
|
|
)
|
|
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
|
expect(extDirReq).toBeUndefined()
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
each("includes always patterns for auto-approval", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: "git log --oneline -5",
|
|
description: "Git log",
|
|
},
|
|
capture(requests),
|
|
)
|
|
expect(requests.length).toBe(1)
|
|
expect(requests[0].always.length).toBeGreaterThan(0)
|
|
expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true)
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
each("does not ask for bash permission when command is cd only", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run(
|
|
{
|
|
command: "cd .",
|
|
description: "Stay in current directory",
|
|
},
|
|
capture(requests),
|
|
)
|
|
const bashReq = requests.find((r) => r.permission === "bash")
|
|
expect(bashReq).toBeUndefined()
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
each("matches redirects in permission pattern", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const err = new Error("stop after permission")
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
expect(
|
|
yield* fail(
|
|
{ command: "echo test > output.txt", description: "Redirect test output" },
|
|
capture(requests, err),
|
|
),
|
|
).toMatchObject({ message: err.message })
|
|
const bashReq = requests.find((r) => r.permission === "bash")
|
|
expect(bashReq).toBeDefined()
|
|
expect(bashReq!.patterns).toContain("echo test > output.txt")
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
each("always pattern has space before wildcard to not include different commands", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
yield* runIn(
|
|
tmp,
|
|
Effect.gen(function* () {
|
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
|
yield* run({ command: "ls -la", description: "List" }, capture(requests))
|
|
const bashReq = requests.find((r) => r.permission === "bash")
|
|
expect(bashReq).toBeDefined()
|
|
// kilocode_change start — arity prefix produces "ls *" with space before wildcard
|
|
expect(bashReq!.always).toContain("ls *")
|
|
expect(bashReq!.patterns).toContain("ls -la")
|
|
// kilocode_change end
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
|
|
describe("tool.shell abort", () => {
|
|
it.live(
|
|
"preserves output when aborted",
|
|
() =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const controller = new AbortController()
|
|
const collected: string[] = []
|
|
const res = yield* run(
|
|
{
|
|
command: `echo before && sleep 30`,
|
|
description: "Long running command",
|
|
},
|
|
{
|
|
...ctx,
|
|
abort: controller.signal,
|
|
metadata: (input) =>
|
|
Effect.sync(() => {
|
|
const output = (input.metadata as { output?: string })?.output
|
|
if (output && output.includes("before") && !controller.signal.aborted) {
|
|
collected.push(output)
|
|
controller.abort()
|
|
}
|
|
}),
|
|
},
|
|
)
|
|
expect(res.output).toContain("before")
|
|
expect(res.output).toContain("User aborted the command")
|
|
expect(collected.length).toBeGreaterThan(0)
|
|
}),
|
|
),
|
|
15_000,
|
|
)
|
|
|
|
it.live(
|
|
"terminates command on timeout",
|
|
() =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const result = yield* run({
|
|
command: `sleep 60`,
|
|
description: "Timeout test",
|
|
timeout: 500,
|
|
})
|
|
expect(result.output).toContain("shell tool terminated command after exceeding timeout")
|
|
expect(result.output).toContain("retry with a larger timeout value in milliseconds")
|
|
}),
|
|
),
|
|
15_000,
|
|
)
|
|
|
|
it.live(
|
|
"uses RuntimeFlags bashDefaultTimeoutMs when timeout is omitted",
|
|
() =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const tool = yield* initShell()
|
|
expect(tool.description).toContain("commands will time out after 500ms")
|
|
const result = yield* tool.execute(
|
|
{
|
|
command: `sleep 60`,
|
|
description: "Default timeout test",
|
|
},
|
|
ctx,
|
|
)
|
|
expect(result.output).toContain("exceeding timeout 500 ms")
|
|
}),
|
|
).pipe(Effect.provide(RuntimeFlags.layer({ bashDefaultTimeoutMs: 500 }))),
|
|
15_000,
|
|
)
|
|
|
|
if (process.platform !== "win32") {
|
|
it.live("captures stderr in output", () =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const result = yield* run({
|
|
command: `echo stdout_msg && echo stderr_msg >&2`,
|
|
description: "Stderr test",
|
|
})
|
|
expect(result.output).toContain("stdout_msg")
|
|
expect(result.output).toContain("stderr_msg")
|
|
expect(result.metadata.exit).toBe(0)
|
|
}),
|
|
),
|
|
)
|
|
}
|
|
|
|
it.live("returns non-zero exit code", () =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const result = yield* run({
|
|
command: `exit 42`,
|
|
description: "Non-zero exit",
|
|
})
|
|
expect(result.metadata.exit).toBe(42)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("streams metadata updates progressively", () =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const updates: string[] = []
|
|
const result = yield* run(
|
|
{
|
|
command: `echo first && sleep 0.1 && echo second`,
|
|
description: "Streaming test",
|
|
},
|
|
{
|
|
...ctx,
|
|
metadata: (input) =>
|
|
Effect.sync(() => {
|
|
const output = (input.metadata as { output?: string })?.output
|
|
if (output) updates.push(output)
|
|
}),
|
|
},
|
|
)
|
|
expect(result.output).toContain("first")
|
|
expect(result.output).toContain("second")
|
|
expect(updates.length).toBeGreaterThan(1)
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
describe("tool.shell truncation", () => {
|
|
it.live("truncates output exceeding line limit", () =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const lineCount = Truncate.MAX_LINES + 500
|
|
const result = yield* run({
|
|
command: fill("lines", lineCount),
|
|
description: "Generate lines exceeding limit",
|
|
})
|
|
mustTruncate(result)
|
|
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
|
|
expect(result.output).toMatch(/Full output saved to:\s+\S+/)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("truncates output exceeding byte limit", () =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const byteCount = Truncate.MAX_BYTES + 10000
|
|
const result = yield* run({
|
|
command: fill("bytes", byteCount),
|
|
description: "Generate bytes exceeding limit",
|
|
})
|
|
mustTruncate(result)
|
|
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
|
|
expect(result.output).toMatch(/Full output saved to:\s+\S+/)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("does not truncate small output", () =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const result = yield* run({
|
|
command: fill("lines", 1),
|
|
description: "Generate one line",
|
|
})
|
|
expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
|
|
expect(result.output).toContain("1")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("full output is saved to file when truncated", () =>
|
|
runIn(
|
|
projectRoot,
|
|
Effect.gen(function* () {
|
|
const lineCount = Truncate.MAX_LINES + 100
|
|
const result = yield* run({
|
|
command: fill("lines", lineCount),
|
|
description: "Generate lines for file check",
|
|
})
|
|
mustTruncate(result)
|
|
|
|
const filepath = (result.metadata as { outputPath?: string }).outputPath
|
|
expect(filepath).toBeTruthy()
|
|
|
|
const saved = yield* (yield* FSUtil.Service).readFileString(filepath!)
|
|
const lines = saved.trim().split(/\r?\n/)
|
|
expect(lines.length).toBe(lineCount)
|
|
expect(lines[0]).toBe("1")
|
|
expect(lines[lineCount - 1]).toBe(String(lineCount))
|
|
}),
|
|
),
|
|
)
|
|
})
|