shell.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. import { Effect, Stream } from "effect"
  2. import os from "os"
  3. import { createWriteStream } from "node:fs"
  4. import * as Tool from "./tool"
  5. import path from "path"
  6. import { containsPath, type InstanceContext } from "../project/instance-context"
  7. import { InstanceState } from "@/effect/instance-state"
  8. import { lazy } from "@/util/lazy"
  9. import { Language, type Node } from "web-tree-sitter"
  10. import { FSUtil } from "@kirincode-ai/core/fs-util"
  11. import { fileURLToPath } from "url"
  12. import { Config } from "@/config/config"
  13. import { RuntimeFlags } from "@/effect/runtime-flags"
  14. import { Shell } from "@kirincode-ai/core/shell"
  15. import { ShellID } from "./shell/id"
  16. import * as Truncate from "./truncate"
  17. import { Plugin } from "@/plugin"
  18. import { ChildProcess } from "effect/unstable/process"
  19. import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
  20. import { ShellPrompt, type Parameters } from "./shell/prompt"
  21. import { BashArity } from "@/permission/arity"
  22. export { Parameters } from "./shell/prompt"
  23. const MAX_METADATA_LENGTH = 30_000
  24. const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
  25. const FILES = new Set([
  26. ...CWD,
  27. "rm",
  28. "cp",
  29. "mv",
  30. "mkdir",
  31. "touch",
  32. "chmod",
  33. "chown",
  34. "cat",
  35. // Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir
  36. // already hit the entries above, and alias normalization should happen in one
  37. // place later so we do not risk double-prompting.
  38. "get-content",
  39. "set-content",
  40. "add-content",
  41. "copy-item",
  42. "move-item",
  43. "remove-item",
  44. "new-item",
  45. "rename-item",
  46. ])
  47. const CMD_FILES = new Set([
  48. "copy",
  49. "del",
  50. "dir",
  51. "erase",
  52. "md",
  53. "mkdir",
  54. "move",
  55. "rd",
  56. "ren",
  57. "rename",
  58. "rmdir",
  59. "type",
  60. ])
  61. const FLAGS = new Set(["-destination", "-literalpath", "-path"])
  62. const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
  63. type Part = {
  64. type: string
  65. text: string
  66. }
  67. type Scan = {
  68. dirs: Set<string>
  69. patterns: Set<string>
  70. always: Set<string>
  71. }
  72. type Chunk = {
  73. text: string
  74. size: number
  75. }
  76. const resolveWasm = (asset: string) => {
  77. if (asset.startsWith("file://")) return fileURLToPath(asset)
  78. if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
  79. const url = new URL(asset, import.meta.url)
  80. return fileURLToPath(url)
  81. }
  82. function parts(node: Node) {
  83. const out: Part[] = []
  84. for (let i = 0; i < node.childCount; i++) {
  85. const child = node.child(i)
  86. if (!child) continue
  87. if (child.type === "command_elements") {
  88. for (let j = 0; j < child.childCount; j++) {
  89. const item = child.child(j)
  90. if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue
  91. out.push({ type: item.type, text: item.text })
  92. }
  93. continue
  94. }
  95. if (
  96. child.type !== "command_name" &&
  97. child.type !== "command_name_expr" &&
  98. child.type !== "word" &&
  99. child.type !== "string" &&
  100. child.type !== "raw_string" &&
  101. child.type !== "concatenation"
  102. ) {
  103. continue
  104. }
  105. out.push({ type: child.type, text: child.text })
  106. }
  107. return out
  108. }
  109. function source(node: Node) {
  110. return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim()
  111. }
  112. function commands(node: Node) {
  113. return node.descendantsOfType("command").filter((child): child is Node => Boolean(child))
  114. }
  115. function unquote(text: string) {
  116. if (text.length < 2) return text
  117. const first = text[0]
  118. const last = text[text.length - 1]
  119. if ((first === '"' || first === "'") && first === last) return text.slice(1, -1)
  120. return text
  121. }
  122. function home(text: string) {
  123. if (text === "~") return os.homedir()
  124. if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2))
  125. return text
  126. }
  127. function envValue(key: string) {
  128. if (process.platform !== "win32") return process.env[key]
  129. const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
  130. return name ? process.env[name] : undefined
  131. }
  132. function auto(key: string, cwd: string, shell: string) {
  133. const name = key.toUpperCase()
  134. if (name === "HOME") return os.homedir()
  135. if (name === "PWD") return cwd
  136. if (name === "PSHOME") return path.dirname(shell)
  137. }
  138. function expand(text: string, cwd: string, shell: string) {
  139. const out = unquote(text)
  140. .replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "")
  141. .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "")
  142. .replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "")
  143. return home(out)
  144. }
  145. function provider(text: string) {
  146. const match = text.match(/^([A-Za-z]+)::(.*)$/)
  147. if (match) {
  148. if (match[1].toLowerCase() !== "filesystem") return
  149. return match[2]
  150. }
  151. const prefix = text.match(/^([A-Za-z]+):(.*)$/)
  152. if (!prefix) return text
  153. if (prefix[1].length === 1) return text
  154. return
  155. }
  156. function dynamic(text: string, ps: boolean) {
  157. if (text.startsWith("(") || text.startsWith("@(")) return true
  158. if (text.includes("$(") || text.includes("${") || text.includes("`")) return true
  159. if (ps) return /\$(?!env:)/i.test(text)
  160. return text.includes("$")
  161. }
  162. function prefix(text: string) {
  163. const match = /[?*[]/.exec(text)
  164. if (!match) return text
  165. if (match.index === 0) return
  166. return text.slice(0, match.index)
  167. }
  168. function pathArgs(list: Part[], ps: boolean, cmd = false) {
  169. if (!ps) {
  170. return list
  171. .slice(1)
  172. .filter(
  173. (item) =>
  174. !item.text.startsWith("-") &&
  175. !(cmd && item.text.startsWith("/")) &&
  176. !(list[0]?.text === "chmod" && item.text.startsWith("+")),
  177. )
  178. .map((item) => item.text)
  179. }
  180. const out: string[] = []
  181. let want = false
  182. for (const item of list.slice(1)) {
  183. if (want) {
  184. out.push(item.text)
  185. want = false
  186. continue
  187. }
  188. if (item.type === "command_parameter") {
  189. const flag = item.text.toLowerCase()
  190. if (SWITCHES.has(flag)) continue
  191. want = FLAGS.has(flag)
  192. continue
  193. }
  194. out.push(item.text)
  195. }
  196. return out
  197. }
  198. function preview(text: string) {
  199. if (text.length <= MAX_METADATA_LENGTH) return text
  200. return "...\n\n" + text.slice(-MAX_METADATA_LENGTH)
  201. }
  202. function tail(text: string, maxLines: number, maxBytes: number) {
  203. const lines = text.split("\n")
  204. if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) {
  205. return {
  206. text,
  207. cut: false,
  208. }
  209. }
  210. const out: string[] = []
  211. let bytes = 0
  212. for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
  213. const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
  214. if (bytes + size > maxBytes) {
  215. if (out.length === 0) {
  216. const buf = Buffer.from(lines[i], "utf-8")
  217. let start = buf.length - maxBytes
  218. if (start < 0) start = 0
  219. while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++
  220. out.unshift(buf.subarray(start).toString("utf-8"))
  221. }
  222. break
  223. }
  224. out.unshift(lines[i])
  225. bytes += size
  226. }
  227. return {
  228. text: out.join("\n"),
  229. cut: true,
  230. }
  231. }
  232. const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) {
  233. const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))
  234. if (!tree) throw new Error("Failed to parse command")
  235. return tree
  236. })
  237. const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, input: { command: string }) {
  238. if (scan.dirs.size > 0) {
  239. const directories = Array.from(scan.dirs)
  240. const globs = directories.map((dir) => {
  241. if (process.platform === "win32") return FSUtil.normalizePathPattern(path.join(dir, "*"))
  242. return path.join(dir, "*")
  243. })
  244. yield* ctx.ask({
  245. permission: "external_directory",
  246. patterns: globs,
  247. always: globs,
  248. metadata: {
  249. command: input.command,
  250. directories,
  251. patterns: globs,
  252. },
  253. })
  254. }
  255. if (scan.patterns.size === 0) return
  256. yield* ctx.ask({
  257. permission: ShellID.ToolID,
  258. patterns: Array.from(scan.patterns),
  259. always: Array.from(scan.always),
  260. metadata: {
  261. command: input.command,
  262. },
  263. })
  264. })
  265. function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
  266. if (process.platform === "win32" && Shell.ps(shell)) {
  267. return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
  268. cwd,
  269. env,
  270. stdin: "ignore",
  271. detached: false,
  272. })
  273. }
  274. return ChildProcess.make(command, [], {
  275. shell,
  276. cwd,
  277. env,
  278. stdin: "ignore",
  279. detached: process.platform !== "win32",
  280. })
  281. }
  282. const parser = lazy(async () => {
  283. const { Parser } = await import("web-tree-sitter")
  284. const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
  285. with: { type: "wasm" },
  286. })
  287. const treePath = resolveWasm(treeWasm)
  288. await Parser.init({
  289. locateFile() {
  290. return treePath
  291. },
  292. })
  293. const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
  294. with: { type: "wasm" },
  295. })
  296. const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, {
  297. with: { type: "wasm" },
  298. })
  299. const bashPath = resolveWasm(bashWasm)
  300. const psPath = resolveWasm(psWasm)
  301. const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
  302. const bash = new Parser()
  303. bash.setLanguage(bashLanguage)
  304. const ps = new Parser()
  305. ps.setLanguage(psLanguage)
  306. return { bash, ps }
  307. })
  308. export const ShellTool = Tool.define(
  309. ShellID.ToolID,
  310. Effect.gen(function* () {
  311. const config = yield* Config.Service
  312. const spawner = yield* ChildProcessSpawner
  313. const fs = yield* FSUtil.Service
  314. const trunc = yield* Truncate.Service
  315. const plugin = yield* Plugin.Service
  316. const flags = yield* RuntimeFlags.Service
  317. const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
  318. const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
  319. const lines = yield* spawner
  320. .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
  321. .pipe(Effect.catch(() => Effect.succeed([] as string[])))
  322. const file = lines[0]?.trim()
  323. if (!file) return
  324. return FSUtil.normalizePath(file)
  325. })
  326. const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
  327. if (process.platform === "win32") {
  328. if (Shell.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) {
  329. const file = yield* cygpath(shell, text)
  330. if (file) return file
  331. }
  332. return FSUtil.normalizePath(path.resolve(root, FSUtil.windowsPath(text)))
  333. }
  334. return path.resolve(root, text)
  335. })
  336. const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
  337. const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
  338. const file = text && prefix(text)
  339. if (!file || dynamic(file, ps)) return
  340. const next = ps ? provider(file) : file
  341. if (!next) return
  342. return yield* resolvePath(next, cwd, shell)
  343. })
  344. const collect = Effect.fn("ShellTool.collect")(function* (
  345. root: Node,
  346. cwd: string,
  347. ps: boolean,
  348. shell: string,
  349. instance: InstanceContext,
  350. ) {
  351. const scan: Scan = {
  352. dirs: new Set<string>(),
  353. patterns: new Set<string>(),
  354. always: new Set<string>(),
  355. }
  356. const shellKind = ShellID.toKind(Shell.name(shell))
  357. for (const node of commands(root)) {
  358. const command = parts(node)
  359. const tokens = command.map((item) => item.text)
  360. const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0]
  361. if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) {
  362. for (const arg of pathArgs(command, ps, shellKind === "cmd")) {
  363. const resolved = yield* argPath(arg, cwd, ps, shell)
  364. yield* Effect.logInfo("resolved path", { arg, resolved })
  365. if (!resolved || containsPath(resolved, instance)) continue
  366. const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
  367. scan.dirs.add(dir)
  368. }
  369. }
  370. if (tokens.length && (!cmd || !CWD.has(cmd))) {
  371. scan.patterns.add(source(node))
  372. scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
  373. }
  374. }
  375. return scan
  376. })
  377. const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
  378. const extra = yield* plugin.trigger(
  379. "shell.env",
  380. { cwd, sessionID: ctx.sessionID, callID: ctx.callID },
  381. { env: {} },
  382. )
  383. return {
  384. ...process.env,
  385. ...extra.env,
  386. }
  387. })
  388. const run = Effect.fn("ShellTool.run")(function* (
  389. input: {
  390. shell: string
  391. command: string
  392. cwd: string
  393. env: NodeJS.ProcessEnv
  394. timeout: number
  395. },
  396. ctx: Tool.Context,
  397. ) {
  398. const limits = yield* trunc.limits()
  399. const keep = limits.maxBytes * 2
  400. let full = ""
  401. let last = ""
  402. const list: Chunk[] = []
  403. let used = 0
  404. let file = ""
  405. let sink: ReturnType<typeof createWriteStream> | undefined
  406. let cut = false
  407. let expired = false
  408. let aborted = false
  409. const closeSink = Effect.fnUntraced(function* () {
  410. const stream = sink
  411. if (!stream) return
  412. sink = undefined
  413. if (stream.destroyed || stream.closed) return
  414. yield* Effect.promise(
  415. () =>
  416. new Promise<void>((resolve) => {
  417. let settled = false
  418. const done = () => {
  419. if (settled) return
  420. settled = true
  421. stream.off("close", done)
  422. stream.off("error", done)
  423. stream.off("finish", done)
  424. resolve()
  425. }
  426. stream.once("close", done)
  427. stream.once("error", done)
  428. stream.once("finish", done)
  429. stream.end(done)
  430. }),
  431. ).pipe(Effect.catch(() => Effect.void))
  432. })
  433. yield* ctx.metadata({
  434. metadata: {
  435. output: "",
  436. },
  437. })
  438. const code: number | null = yield* Effect.scoped(
  439. Effect.gen(function* () {
  440. yield* Effect.addFinalizer(closeSink)
  441. const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
  442. yield* Effect.forkScoped(
  443. Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
  444. const size = Buffer.byteLength(chunk, "utf-8")
  445. list.push({ text: chunk, size })
  446. used += size
  447. while (used > keep && list.length > 1) {
  448. const item = list.shift()
  449. if (!item) break
  450. used -= item.size
  451. cut = true
  452. }
  453. last = preview(last + chunk)
  454. if (file) {
  455. sink?.write(chunk)
  456. } else {
  457. full += chunk
  458. if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) {
  459. return trunc.write(full).pipe(
  460. Effect.andThen((next) =>
  461. Effect.sync(() => {
  462. file = next
  463. cut = true
  464. sink = createWriteStream(next, { flags: "a" })
  465. full = ""
  466. }),
  467. ),
  468. Effect.andThen(
  469. ctx.metadata({
  470. metadata: {
  471. output: last,
  472. },
  473. }),
  474. ),
  475. )
  476. }
  477. }
  478. return ctx.metadata({
  479. metadata: {
  480. output: last,
  481. },
  482. })
  483. }),
  484. )
  485. const abort = Effect.callback<void>((resume) => {
  486. if (ctx.abort.aborted) return resume(Effect.void)
  487. const handler = () => resume(Effect.void)
  488. ctx.abort.addEventListener("abort", handler, { once: true })
  489. return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
  490. })
  491. const timeout = Effect.sleep(`${input.timeout + 100} millis`)
  492. const exit = yield* Effect.raceAll([
  493. handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
  494. abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
  495. timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
  496. ])
  497. if (exit.kind === "abort") {
  498. aborted = true
  499. yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
  500. }
  501. if (exit.kind === "timeout") {
  502. expired = true
  503. yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
  504. }
  505. return exit.kind === "exit" ? exit.code : null
  506. }),
  507. ).pipe(Effect.orDie)
  508. const meta: string[] = []
  509. if (expired) {
  510. meta.push(
  511. `shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`,
  512. )
  513. }
  514. if (aborted) meta.push("User aborted the command")
  515. const raw = list.map((item) => item.text).join("")
  516. const end = tail(raw, limits.maxLines, limits.maxBytes)
  517. if (end.cut) cut = true
  518. if (!file && end.cut) {
  519. file = yield* trunc.write(raw)
  520. }
  521. let output = end.text
  522. if (!output) output = "(no output)"
  523. if (cut && file) {
  524. output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output
  525. }
  526. if (meta.length > 0) {
  527. output += "\n\n<shell_metadata>\n" + meta.join("\n") + "\n</shell_metadata>"
  528. }
  529. return {
  530. title: input.command,
  531. metadata: {
  532. output: last || preview(output),
  533. exit: code,
  534. truncated: cut,
  535. ...(cut && file ? { outputPath: file } : {}),
  536. },
  537. output,
  538. }
  539. })
  540. return () =>
  541. Effect.gen(function* () {
  542. const cfg = yield* config.get()
  543. const shell = Shell.acceptable(cfg.shell)
  544. const name = Shell.name(shell)
  545. const limits = yield* trunc.limits()
  546. const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs)
  547. yield* Effect.logInfo("shell tool using shell", { shell })
  548. return {
  549. description: prompt.description,
  550. parameters: prompt.parameters,
  551. execute: (params: Parameters, ctx: Tool.Context) =>
  552. Effect.gen(function* () {
  553. const instanceCtx = yield* InstanceState.context
  554. const cwd = params.workdir
  555. ? yield* resolvePath(params.workdir, instanceCtx.directory, shell)
  556. : instanceCtx.directory
  557. if (params.timeout !== undefined && params.timeout < 0) {
  558. throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
  559. }
  560. const timeout = params.timeout ?? defaultTimeoutMs
  561. const ps = Shell.ps(shell)
  562. yield* Effect.scoped(
  563. Effect.gen(function* () {
  564. const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
  565. Effect.sync(() => tree.delete()),
  566. )
  567. const scan = yield* collect(tree.rootNode, cwd, ps, shell, instanceCtx)
  568. if (!containsPath(cwd, instanceCtx)) scan.dirs.add(cwd)
  569. yield* ask(ctx, scan, params)
  570. }),
  571. )
  572. return yield* run(
  573. {
  574. shell,
  575. command: params.command,
  576. cwd,
  577. env: yield* shellEnv(ctx, cwd),
  578. timeout,
  579. },
  580. ctx,
  581. )
  582. }),
  583. }
  584. })
  585. }),
  586. )