@librechat/agents 3.1.77-dev.1 → 3.1.78-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/cjs/common/enum.cjs +54 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +148 -4
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
  6. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
  7. package/dist/cjs/llm/openai/index.cjs +317 -1
  8. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  9. package/dist/cjs/main.cjs +90 -0
  10. package/dist/cjs/main.cjs.map +1 -1
  11. package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
  12. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
  13. package/dist/cjs/messages/prune.cjs +27 -0
  14. package/dist/cjs/messages/prune.cjs.map +1 -1
  15. package/dist/cjs/messages/recency.cjs +99 -0
  16. package/dist/cjs/messages/recency.cjs.map +1 -0
  17. package/dist/cjs/run.cjs +30 -0
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/summarization/node.cjs +100 -6
  20. package/dist/cjs/summarization/node.cjs.map +1 -1
  21. package/dist/cjs/tools/ToolNode.cjs +635 -23
  22. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  23. package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
  24. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
  25. package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
  26. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
  27. package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
  28. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
  29. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
  30. package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
  31. package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
  32. package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
  33. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
  34. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
  35. package/dist/cjs/tools/local/attachments.cjs +183 -0
  36. package/dist/cjs/tools/local/attachments.cjs.map +1 -0
  37. package/dist/cjs/tools/local/bashAst.cjs +129 -0
  38. package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
  39. package/dist/cjs/tools/local/editStrategies.cjs +188 -0
  40. package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
  41. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
  42. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
  43. package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
  44. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
  45. package/dist/cjs/tools/local/textEncoding.cjs +30 -0
  46. package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
  47. package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
  48. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  50. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  51. package/dist/esm/common/enum.mjs +53 -1
  52. package/dist/esm/common/enum.mjs.map +1 -1
  53. package/dist/esm/graphs/Graph.mjs +149 -5
  54. package/dist/esm/graphs/Graph.mjs.map +1 -1
  55. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
  56. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
  57. package/dist/esm/llm/openai/index.mjs +318 -2
  58. package/dist/esm/llm/openai/index.mjs.map +1 -1
  59. package/dist/esm/main.mjs +17 -2
  60. package/dist/esm/main.mjs.map +1 -1
  61. package/dist/esm/messages/anthropicToolCache.mjs +99 -0
  62. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
  63. package/dist/esm/messages/prune.mjs +26 -1
  64. package/dist/esm/messages/prune.mjs.map +1 -1
  65. package/dist/esm/messages/recency.mjs +97 -0
  66. package/dist/esm/messages/recency.mjs.map +1 -0
  67. package/dist/esm/run.mjs +30 -0
  68. package/dist/esm/run.mjs.map +1 -1
  69. package/dist/esm/summarization/node.mjs +100 -6
  70. package/dist/esm/summarization/node.mjs.map +1 -1
  71. package/dist/esm/tools/ToolNode.mjs +635 -23
  72. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  73. package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
  74. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
  75. package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
  76. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
  77. package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
  78. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
  79. package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
  80. package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
  81. package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
  82. package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
  83. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
  84. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
  85. package/dist/esm/tools/local/attachments.mjs +180 -0
  86. package/dist/esm/tools/local/attachments.mjs.map +1 -0
  87. package/dist/esm/tools/local/bashAst.mjs +126 -0
  88. package/dist/esm/tools/local/bashAst.mjs.map +1 -0
  89. package/dist/esm/tools/local/editStrategies.mjs +185 -0
  90. package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
  91. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
  92. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
  93. package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
  94. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
  95. package/dist/esm/tools/local/textEncoding.mjs +27 -0
  96. package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
  97. package/dist/esm/tools/local/workspaceFS.mjs +49 -0
  98. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
  99. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  100. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  101. package/dist/types/common/enum.d.ts +39 -1
  102. package/dist/types/graphs/Graph.d.ts +34 -0
  103. package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
  104. package/dist/types/hooks/index.d.ts +2 -0
  105. package/dist/types/index.d.ts +1 -0
  106. package/dist/types/llm/openai/index.d.ts +17 -0
  107. package/dist/types/messages/anthropicToolCache.d.ts +51 -0
  108. package/dist/types/messages/index.d.ts +2 -0
  109. package/dist/types/messages/prune.d.ts +11 -0
  110. package/dist/types/messages/recency.d.ts +64 -0
  111. package/dist/types/run.d.ts +21 -0
  112. package/dist/types/tools/ToolNode.d.ts +145 -2
  113. package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
  114. package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
  115. package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
  116. package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
  117. package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
  118. package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
  119. package/dist/types/tools/local/attachments.d.ts +84 -0
  120. package/dist/types/tools/local/bashAst.d.ts +11 -0
  121. package/dist/types/tools/local/editStrategies.d.ts +28 -0
  122. package/dist/types/tools/local/index.d.ts +12 -0
  123. package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
  124. package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
  125. package/dist/types/tools/local/textEncoding.d.ts +21 -0
  126. package/dist/types/tools/local/workspaceFS.d.ts +49 -0
  127. package/dist/types/types/hitl.d.ts +56 -27
  128. package/dist/types/types/run.d.ts +8 -1
  129. package/dist/types/types/summarize.d.ts +30 -0
  130. package/dist/types/types/tools.d.ts +341 -6
  131. package/package.json +21 -2
  132. package/src/common/enum.ts +54 -0
  133. package/src/graphs/Graph.ts +164 -6
  134. package/src/hooks/__tests__/compactHooks.test.ts +38 -2
  135. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
  136. package/src/hooks/createWorkspacePolicyHook.ts +355 -0
  137. package/src/hooks/index.ts +6 -0
  138. package/src/index.ts +1 -0
  139. package/src/llm/openai/deepseek.test.ts +479 -0
  140. package/src/llm/openai/index.ts +484 -1
  141. package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
  142. package/src/messages/__tests__/recency.test.ts +267 -0
  143. package/src/messages/anthropicToolCache.ts +116 -0
  144. package/src/messages/index.ts +2 -0
  145. package/src/messages/prune.ts +27 -1
  146. package/src/messages/recency.ts +155 -0
  147. package/src/run.ts +31 -0
  148. package/src/scripts/compare_pi_vs_ours.ts +840 -0
  149. package/src/scripts/local_engine.ts +166 -0
  150. package/src/scripts/local_engine_checkpointer.ts +205 -0
  151. package/src/scripts/local_engine_compile.ts +263 -0
  152. package/src/scripts/local_engine_hooks.ts +226 -0
  153. package/src/scripts/local_engine_image.ts +201 -0
  154. package/src/scripts/local_engine_ptc.ts +151 -0
  155. package/src/scripts/local_engine_workspace.ts +258 -0
  156. package/src/scripts/summarization-recency.ts +462 -0
  157. package/src/specs/prune.test.ts +39 -0
  158. package/src/summarization/__tests__/node.test.ts +499 -3
  159. package/src/summarization/node.ts +124 -7
  160. package/src/tools/ToolNode.ts +769 -20
  161. package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
  162. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
  163. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
  164. package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
  165. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
  166. package/src/tools/__tests__/directToolHooks.test.ts +411 -0
  167. package/src/tools/__tests__/localToolNames.test.ts +73 -0
  168. package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
  169. package/src/tools/local/CompileCheckTool.ts +278 -0
  170. package/src/tools/local/FileCheckpointer.ts +93 -0
  171. package/src/tools/local/LocalCodingTools.ts +1342 -0
  172. package/src/tools/local/LocalExecutionEngine.ts +1329 -0
  173. package/src/tools/local/LocalExecutionTools.ts +167 -0
  174. package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
  175. package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
  176. package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
  177. package/src/tools/local/attachments.ts +251 -0
  178. package/src/tools/local/bashAst.ts +151 -0
  179. package/src/tools/local/editStrategies.ts +188 -0
  180. package/src/tools/local/index.ts +12 -0
  181. package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
  182. package/src/tools/local/syntaxCheck.ts +243 -0
  183. package/src/tools/local/textEncoding.ts +37 -0
  184. package/src/tools/local/workspaceFS.ts +89 -0
  185. package/src/types/hitl.ts +56 -27
  186. package/src/types/run.ts +12 -1
  187. package/src/types/summarize.ts +31 -0
  188. package/src/types/tools.ts +359 -7
@@ -0,0 +1,1329 @@
1
+ import { tmpdir } from 'os';
2
+ import { isAbsolute, relative, resolve } from 'path';
3
+ import { createHash, randomUUID } from 'crypto';
4
+ import { mkdir, realpath, rm, writeFile } from 'fs/promises';
5
+ import { createWriteStream } from 'fs';
6
+ import { spawn } from 'child_process';
7
+ import type { ChildProcess } from 'child_process';
8
+ import type { SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime';
9
+ import { runBashAstChecks, bashAstFindingsToErrors } from './bashAst';
10
+ import { nodeWorkspaceFS } from './workspaceFS';
11
+ import type { WorkspaceFS } from './workspaceFS';
12
+ import type * as t from '@/types';
13
+
14
+ const DEFAULT_TIMEOUT_MS = 60000;
15
+ const DEFAULT_MAX_OUTPUT_CHARS = 200000;
16
+ /**
17
+ * Hard cap on total stdout+stderr bytes a child process can stream
18
+ * before we kill its process tree. Independent from `maxOutputChars`
19
+ * (which only affects what the *model* sees) — this is the OOM
20
+ * backstop. Configurable via `local.maxSpawnedBytes`.
21
+ */
22
+ const DEFAULT_MAX_SPAWNED_BYTES = 50 * 1024 * 1024;
23
+ const DEFAULT_LOCAL_SESSION_ID = 'local';
24
+ const DEFAULT_SHELL = process.platform === 'win32' ? 'bash.exe' : 'bash';
25
+
26
+ // `(?:--\s+)?` before each destructive-target alternation: GNU/BSD
27
+ // utilities accept `--` as an end-of-options marker, so `rm -rf -- /`
28
+ // is identical in effect to `rm -rf /` but pre-fix it slipped past
29
+ // the guard because the regex required the path to follow option
30
+ // flags directly. Codex P1 #20.
31
+ // `DESTRUCTIVE_TARGET` is the canonical "protected location" pattern:
32
+ // matches `/`, `~`, `$HOME`, `${HOME}`, `.`, each optionally followed
33
+ // by a trailing-slash and/or wildcard glob suffix. The suffix matrix:
34
+ // '' — `$HOME` (round 14)
35
+ // '/' — `$HOME/` (round 14, Codex P1 [37])
36
+ // '*' — `$HOME*` (round 15, Codex P1 [42])
37
+ // '/*' — `$HOME/*` (round 15, Codex P1 [42])
38
+ // '.*' — `$HOME.*` (round 17, Codex P1 [47])
39
+ // '/.*' — `$HOME/.*` (round 17, Codex P1 [47]) — the
40
+ // dot-glob form deletes all dotfiles under the protected
41
+ // root, just as destructive as `/*` but the prior matrix
42
+ // missed it.
43
+ // Suffix expression: `(?:\/?\.?\*|\/)?` — one of:
44
+ // `\/?\.?\*` → `*`, `.*`, `/*`, `/.*`
45
+ // `\/` → `/`
46
+ // (empty) → bare base
47
+ const DESTRUCTIVE_TARGET = '(?:\\/|~|\\$\\{?HOME\\}?|\\.)(?:\\/?\\.?\\*|\\/)?';
48
+
49
+ const dangerousCommandPatterns: ReadonlyArray<RegExp> = [
50
+ new RegExp(
51
+ `\\brm\\s+(?:-[^\\s]*[rf][^\\s]*\\s+|-[^\\s]*[r][^\\s]*\\s+-[^\\s]*[f][^\\s]*\\s+)(?:--\\s+)?${DESTRUCTIVE_TARGET}\\s*(?:$|[;&|])`
52
+ ),
53
+ /\b(?:mkfs|mkswap|fdisk|parted|diskutil)\b/,
54
+ /\bdd\s+[^;&|]*\bof=\/dev\//,
55
+ new RegExp(
56
+ `\\bchmod\\s+-R\\s+(?:777|a\\+w)\\s+(?:--\\s+)?${DESTRUCTIVE_TARGET}(?:$|\\s|[;&|])`
57
+ ),
58
+ new RegExp(
59
+ `\\bchown\\s+-R\\s+[^;&|]+\\s+(?:--\\s+)?${DESTRUCTIVE_TARGET}(?:$|\\s|[;&|])`
60
+ ),
61
+ /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
62
+ ];
63
+
64
+ /**
65
+ * Companion patterns that look for destructive targets *inside*
66
+ * matching quote pairs. These are checked against the ORIGINAL
67
+ * command (not the post-quote-strip `normalized` form), because
68
+ * `stripQuotedContent` blanks the contents of quoted spans —
69
+ * which would otherwise let `rm -rf "/"` and friends slip past
70
+ * `dangerousCommandPatterns`.
71
+ *
72
+ * Kept as a separate list so we don't pay false-positive cost on
73
+ * benign uses like `echo "rm -rf /"` (the print case): each pattern
74
+ * here REQUIRES a quote *around the destructive path argument*, not
75
+ * just a quote *somewhere* in the command. `echo "rm -rf /"` has
76
+ * `/` outside of any quote-pair-around-the-path (the quotes wrap
77
+ * the whole `rm -rf /` text), so it doesn't match here either.
78
+ */
79
+ // Quoted variant uses the same DESTRUCTIVE_TARGET (which accepts an
80
+ // optional trailing slash) so `rm -rf "$HOME/"` and `rm -rf "~/"`
81
+ // don't slip past. Codex P1 #37.
82
+ const quotedDestructivePatterns: ReadonlyArray<RegExp> = [
83
+ new RegExp(
84
+ `\\brm\\s+(?:-[^\\s]*[rf][^\\s]*\\s+){1,3}(?:--\\s+)?["']${DESTRUCTIVE_TARGET}["']`
85
+ ),
86
+ new RegExp(
87
+ `\\bchmod\\s+-R\\s+(?:777|a\\+w)\\s+(?:--\\s+)?["']${DESTRUCTIVE_TARGET}["']`
88
+ ),
89
+ new RegExp(
90
+ `\\bchown\\s+-R\\s+[^;&|]+\\s+(?:--\\s+)?["']${DESTRUCTIVE_TARGET}["']`
91
+ ),
92
+ ];
93
+
94
+ /**
95
+ * Catches destructive operations smuggled inside a nested shell or
96
+ * `eval` call, e.g. `bash -lc "rm -rf $HOME"` — the outer command
97
+ * looks benign (`bash -lc "..."`) and the destructive `rm` lives
98
+ * inside the quoted payload that `stripQuotedContent` blanks out.
99
+ * Comprehensive review (manual finding C) flagged this as a real
100
+ * bypass of the otherwise-correct quote-strip-then-match approach.
101
+ *
102
+ * Run against the ORIGINAL command (quotes intact) so the inside of
103
+ * the nested-shell payload is visible. Conservative: matches only
104
+ * the same operation set as `dangerousCommandPatterns` (rm -rf,
105
+ * chmod -R 777, chown -R) when they appear inside a `<shell> -[l]?c
106
+ * "..."` or `eval "..."` payload.
107
+ */
108
+ const NESTED_SHELL_PREFIX = '(?:(?:ba|z|da|k)?sh|eval)\\s+(?:-l?c\\s+)?';
109
+ const nestedShellDestructivePatterns: ReadonlyArray<RegExp> = [
110
+ new RegExp(
111
+ NESTED_SHELL_PREFIX +
112
+ '["\'][^"\']*\\brm\\s+-[^\\s"\']*[rf][^\\s"\']*\\s+(?:--\\s+)?(?:\\/|~|\\$\\{?HOME\\}?|\\.)'
113
+ ),
114
+ new RegExp(
115
+ NESTED_SHELL_PREFIX +
116
+ '["\'][^"\']*\\bchmod\\s+-R\\s+(?:777|a\\+w)\\s+(?:--\\s+)?(?:\\/|~|\\$\\{?HOME\\}?|\\.)'
117
+ ),
118
+ new RegExp(
119
+ NESTED_SHELL_PREFIX +
120
+ '["\'][^"\']*\\bchown\\s+-R\\s+[^;&|]+\\s+(?:--\\s+)?(?:\\/|~|\\$\\{?HOME\\}?|\\.)'
121
+ ),
122
+ ];
123
+
124
+ const mutatingCommandPattern =
125
+ /\b(?:rm|mv|cp|touch|mkdir|rmdir|ln|truncate|tee|sed\s+-i|perl\s+-pi|python(?:3)?\s+-c|node\s+-e|npm\s+(?:install|ci|update|publish)|pnpm\s+(?:install|update|publish)|yarn\s+(?:install|add|publish)|git\s+(?:add|commit|checkout|switch|reset|clean|rebase|merge|push|pull|stash|tag|branch)|chmod|chown)\b|(?:^|[^<])>\s*[^&]|\bcat\s+[^|;&]*>\s*/;
126
+
127
+ type SpawnResult = {
128
+ stdout: string;
129
+ stderr: string;
130
+ exitCode: number | null;
131
+ timedOut: boolean;
132
+ /**
133
+ * True when the process was force-killed because total streamed bytes
134
+ * exceeded `maxSpawnedBytes`. Distinct from `timedOut`. Without this
135
+ * flag, callers (`bash_tool`, `execute_code`, etc.) would see a
136
+ * SIGKILL'd process with `exitCode: null` and treat it as success
137
+ * (Codex P1 — runaway commands like `yes` or noisy builds silently
138
+ * looked successful even though their output was truncated).
139
+ */
140
+ overflowKilled?: boolean;
141
+ /**
142
+ * Signal name (e.g. `'SIGKILL'`, `'SIGSEGV'`) when the process was
143
+ * terminated by a signal. Distinct from the overflow-kill path:
144
+ * this captures `kill -9 $$` from inside the script, native crashes,
145
+ * OS OOM killer, etc. Without this, signal-killed processes
146
+ * reported `exitCode: null` and looked like clean runs (Codex P2 —
147
+ * generalization of the overflow-kill fix). When present, the
148
+ * exitCode field is also synthesized to `128 + signum` per the
149
+ * POSIX convention so non-null-exit-code consumers see a failure.
150
+ */
151
+ signal?: string;
152
+ /** Path to the full untruncated stdout/stderr when output exceeded `maxOutputChars`. */
153
+ fullOutputPath?: string;
154
+ };
155
+
156
+ /**
157
+ * POSIX convention: `128 + signum` when a process is killed by a
158
+ * signal. Maps the common signals; unknown ones default to 1 so the
159
+ * caller still sees a non-zero (failed) exit. Only used when Node's
160
+ * `close` event reports `exitCode === null` (true signal kill).
161
+ */
162
+ const SIGNAL_TO_EXIT_CODE: Record<string, number> = {
163
+ SIGHUP: 129,
164
+ SIGINT: 130,
165
+ SIGQUIT: 131,
166
+ SIGILL: 132,
167
+ SIGTRAP: 133,
168
+ SIGABRT: 134,
169
+ SIGBUS: 135,
170
+ SIGFPE: 136,
171
+ SIGKILL: 137,
172
+ SIGUSR1: 138,
173
+ SIGSEGV: 139,
174
+ SIGUSR2: 140,
175
+ SIGPIPE: 141,
176
+ SIGALRM: 142,
177
+ SIGTERM: 143,
178
+ };
179
+ function exitCodeForSignal(signal: string | null): number {
180
+ if (signal == null) return 1;
181
+ return SIGNAL_TO_EXIT_CODE[signal] ?? 1;
182
+ }
183
+
184
+ type RuntimeCommand = {
185
+ command: string;
186
+ args: string[];
187
+ fileName: string;
188
+ source?: string;
189
+ };
190
+
191
+ type SandboxRuntimeModule = typeof import('@anthropic-ai/sandbox-runtime');
192
+ type SandboxManagerType = SandboxRuntimeModule['SandboxManager'];
193
+
194
+ let sandboxConfigKey: string | undefined;
195
+ let sandboxInitialized = false;
196
+ let sandboxRuntimePromise: Promise<SandboxRuntimeModule> | undefined;
197
+
198
+ export type BashValidationResult = {
199
+ valid: boolean;
200
+ errors: string[];
201
+ warnings: string[];
202
+ };
203
+
204
+ function isToolExecutionConfig(
205
+ config: t.ToolExecutionConfig | t.LocalExecutionConfig
206
+ ): config is t.ToolExecutionConfig {
207
+ return 'engine' in config || 'local' in config;
208
+ }
209
+
210
+ export function resolveLocalExecutionConfig(
211
+ config?: t.ToolExecutionConfig | t.LocalExecutionConfig
212
+ ): t.LocalExecutionConfig {
213
+ if (config != null && isToolExecutionConfig(config)) {
214
+ return config.local ?? {};
215
+ }
216
+ return config ?? {};
217
+ }
218
+
219
+ export function getLocalCwd(config?: t.LocalExecutionConfig): string {
220
+ return resolve(config?.workspace?.root ?? config?.cwd ?? process.cwd());
221
+ }
222
+
223
+ /**
224
+ * Resolves the effective workspace boundary: a list of absolute roots
225
+ * that file operations are allowed to touch. The first entry is always
226
+ * the canonical root (`getLocalCwd`); subsequent entries come from
227
+ * `workspace.additionalRoots` when provided.
228
+ *
229
+ * Returns plain absolute paths — callers symlink-resolve when they
230
+ * need realpath equality (see `resolveWorkspacePathSafe`).
231
+ */
232
+ export function getWorkspaceRoots(
233
+ config?: t.LocalExecutionConfig
234
+ ): string[] {
235
+ const root = getLocalCwd(config);
236
+ const extras = config?.workspace?.additionalRoots ?? [];
237
+ if (extras.length === 0) return [root];
238
+ const seen = new Set<string>([root]);
239
+ const out: string[] = [root];
240
+ for (const extra of extras) {
241
+ // Relative `additionalRoots` entries are anchored to the
242
+ // workspace root (so monorepo configs like
243
+ // `additionalRoots: ['../shared']` resolve to a sibling of
244
+ // `root` rather than to `process.cwd()/../shared`, which would
245
+ // mean something completely different on a server with a
246
+ // different cwd).
247
+ const abs = isAbsolute(extra) ? resolve(extra) : resolve(root, extra);
248
+ if (!seen.has(abs)) {
249
+ seen.add(abs);
250
+ out.push(abs);
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+
256
+ /**
257
+ * Pluggable spawn resolver. Honours `local.exec.spawn` first, falls
258
+ * back to the legacy top-level `local.spawn`, then to Node's
259
+ * `child_process.spawn`. Centralised so engine swapping is one knob.
260
+ */
261
+ export function getSpawn(
262
+ config?: t.LocalExecutionConfig
263
+ ): t.LocalSpawn {
264
+ return (config?.exec?.spawn ?? config?.spawn ?? spawn) as t.LocalSpawn;
265
+ }
266
+
267
+ /**
268
+ * Pluggable filesystem resolver. Honours `local.exec.fs`, falls back
269
+ * to the Node-host implementation. A future remote engine supplies
270
+ * its own implementation here and inherits every file-touching tool.
271
+ */
272
+ export function getWorkspaceFS(
273
+ config?: t.LocalExecutionConfig
274
+ ): WorkspaceFS {
275
+ return config?.exec?.fs ?? nodeWorkspaceFS;
276
+ }
277
+
278
+ /**
279
+ * Resolves the workspace boundary for *write* operations. Honours
280
+ * `workspace.allowWriteOutside` (and the deprecated
281
+ * `allowOutsideWorkspace`) by returning `null`, which the path-safety
282
+ * helpers interpret as "skip the write clamp".
283
+ */
284
+ export function getWriteRoots(
285
+ config?: t.LocalExecutionConfig
286
+ ): string[] | null {
287
+ // Granular flag wins over the legacy one when explicitly set
288
+ // (true OR false) — otherwise a host tightening access during
289
+ // migration (`allowOutsideWorkspace: true, workspace.
290
+ // allowWriteOutside: false`) would still get the loose behavior
291
+ // because the legacy flag short-circuited the OR. Codex P1 #36.
292
+ const granular = config?.workspace?.allowWriteOutside;
293
+ if (granular === true) return null;
294
+ if (granular === false) return getWorkspaceRoots(config);
295
+ if (config?.allowOutsideWorkspace === true) return null;
296
+ return getWorkspaceRoots(config);
297
+ }
298
+
299
+ /**
300
+ * Resolves the workspace boundary for *read* operations. Honours
301
+ * `workspace.allowReadOutside` (and the deprecated
302
+ * `allowOutsideWorkspace`) by returning `null`.
303
+ */
304
+ export function getReadRoots(
305
+ config?: t.LocalExecutionConfig
306
+ ): string[] | null {
307
+ // Same precedence as getWriteRoots: granular flag is authoritative
308
+ // when set, legacy flag is the fallback. Codex P1 #36.
309
+ const granular = config?.workspace?.allowReadOutside;
310
+ if (granular === true) return null;
311
+ if (granular === false) return getWorkspaceRoots(config);
312
+ if (config?.allowOutsideWorkspace === true) return null;
313
+ return getWorkspaceRoots(config);
314
+ }
315
+
316
+ export function getLocalSessionId(config?: t.LocalExecutionConfig): string {
317
+ const cwd = getLocalCwd(config);
318
+ const digest = createHash('sha1').update(cwd).digest('hex').slice(0, 12);
319
+ return `${DEFAULT_LOCAL_SESSION_ID}:${digest}`;
320
+ }
321
+
322
+ const missingSandboxRuntimeMessage = [
323
+ 'Local sandbox is enabled, but @anthropic-ai/sandbox-runtime is not installed.',
324
+ 'Install it with `npm install @anthropic-ai/sandbox-runtime`, or disable local sandboxing with `local.sandbox.enabled: false`.',
325
+ ].join(' ');
326
+
327
+ /** Lazy-loads the ESM-only sandbox runtime only when sandboxing is enabled. */
328
+ function loadSandboxRuntime(): Promise<SandboxRuntimeModule> {
329
+ sandboxRuntimePromise ??= import('@anthropic-ai/sandbox-runtime');
330
+ return sandboxRuntimePromise;
331
+ }
332
+
333
+ function shouldUseLocalSandbox(config: t.LocalExecutionConfig): boolean {
334
+ return config.sandbox?.enabled === true;
335
+ }
336
+
337
+ let sandboxOffWarned = false;
338
+
339
+ function maybeWarnSandboxOff(config: t.LocalExecutionConfig): void {
340
+ if (sandboxOffWarned || shouldUseLocalSandbox(config)) {
341
+ return;
342
+ }
343
+ sandboxOffWarned = true;
344
+ // eslint-disable-next-line no-console
345
+ console.warn(
346
+ '[@librechat/agents] Local execution engine is running without ' +
347
+ '@anthropic-ai/sandbox-runtime wrapping. The agent has full access to ' +
348
+ 'the host filesystem and network. Set toolExecution.local.sandbox.enabled ' +
349
+ '= true to opt into process sandboxing.'
350
+ );
351
+ }
352
+
353
+ /**
354
+ * Test-only reset hook for the sandbox-off warning latch.
355
+ *
356
+ * @internal Not part of the public SDK surface.
357
+ */
358
+ export function _resetLocalEngineWarningsForTests(): void {
359
+ sandboxOffWarned = false;
360
+ }
361
+
362
+ export function truncateLocalOutput(
363
+ value: string,
364
+ maxChars = DEFAULT_MAX_OUTPUT_CHARS
365
+ ): string {
366
+ if (value.length <= maxChars) {
367
+ return value;
368
+ }
369
+ const head = Math.floor(maxChars * 0.6);
370
+ const tail = maxChars - head;
371
+ const omitted = value.length - maxChars;
372
+ return `${value.slice(0, head)}\n\n[... ${omitted} characters truncated ...]\n\n${value.slice(
373
+ value.length - tail
374
+ )}`;
375
+ }
376
+
377
+ function stripQuotedContent(command: string): string {
378
+ let output = '';
379
+ let quote: '"' | '\'' | '`' | undefined;
380
+ let escaped = false;
381
+
382
+ for (let i = 0; i < command.length; i++) {
383
+ const char = command[i];
384
+
385
+ if (escaped) {
386
+ escaped = false;
387
+ output += ' ';
388
+ continue;
389
+ }
390
+
391
+ if (char === '\\') {
392
+ escaped = true;
393
+ output += ' ';
394
+ continue;
395
+ }
396
+
397
+ if (quote != null) {
398
+ if (char === quote) {
399
+ quote = undefined;
400
+ }
401
+ output += ' ';
402
+ continue;
403
+ }
404
+
405
+ if (char === '"' || char === '\'' || char === '`') {
406
+ quote = char;
407
+ output += ' ';
408
+ continue;
409
+ }
410
+
411
+ if (char === '#') {
412
+ while (i < command.length && command[i] !== '\n') {
413
+ output += ' ';
414
+ i++;
415
+ }
416
+ output += '\n';
417
+ continue;
418
+ }
419
+
420
+ output += char;
421
+ }
422
+
423
+ return output;
424
+ }
425
+
426
+ export async function validateBashCommand(
427
+ command: string,
428
+ config: t.LocalExecutionConfig = {}
429
+ ): Promise<BashValidationResult> {
430
+ const errors: string[] = [];
431
+ const warnings: string[] = [];
432
+ const normalized = stripQuotedContent(command);
433
+
434
+ if (command.trim() === '') {
435
+ errors.push('Command is empty.');
436
+ }
437
+
438
+ if (command.includes('\0')) {
439
+ errors.push('Command contains a NUL byte.');
440
+ }
441
+
442
+ if (config.allowDangerousCommands !== true) {
443
+ let blocked = false;
444
+ // Strip-then-match for the bare-form patterns (avoids false
445
+ // positives where the destructive text is buried inside a
446
+ // string the user is just printing).
447
+ for (const pattern of dangerousCommandPatterns) {
448
+ if (pattern.test(normalized)) {
449
+ errors.push('Command matches a destructive command pattern.');
450
+ blocked = true;
451
+ break;
452
+ }
453
+ }
454
+ // Original-form pass for patterns that REQUIRE matching quote
455
+ // pairs around a destructive path. Without this, `rm -rf "/"`
456
+ // and `chmod -R 777 "/"` slip past the strip-then-match pass
457
+ // because their destructive target is inside quotes.
458
+ if (!blocked) {
459
+ for (const pattern of quotedDestructivePatterns) {
460
+ if (pattern.test(command)) {
461
+ errors.push(
462
+ 'Command matches a destructive command pattern (quoted target).'
463
+ );
464
+ blocked = true;
465
+ break;
466
+ }
467
+ }
468
+ }
469
+ if (!blocked) {
470
+ for (const pattern of nestedShellDestructivePatterns) {
471
+ if (pattern.test(command)) {
472
+ errors.push(
473
+ 'Command matches a destructive command pattern (nested shell payload).'
474
+ );
475
+ break;
476
+ }
477
+ }
478
+ }
479
+ }
480
+
481
+ const bashAstMode = config.bashAst ?? 'off';
482
+ if (bashAstMode !== 'off' && config.allowDangerousCommands !== true) {
483
+ const findings = runBashAstChecks(normalized, bashAstMode);
484
+ const split = bashAstFindingsToErrors(findings);
485
+ errors.push(...split.errors);
486
+ warnings.push(...split.warnings);
487
+ }
488
+
489
+ if (config.readOnly === true && mutatingCommandPattern.test(normalized)) {
490
+ errors.push('Command appears to mutate files or repository state in read-only local mode.');
491
+ }
492
+
493
+ // Use the same shell the actual execution path will use. Hard-coding
494
+ // DEFAULT_SHELL here would reject perfectly valid commands when the
495
+ // host configures `local.shell` to a non-bash binary (or when the
496
+ // runtime doesn't have bash installed at all but does have e.g. zsh).
497
+ const syntaxShell = config.shell ?? DEFAULT_SHELL;
498
+ const syntax = await spawnLocalProcess(
499
+ syntaxShell,
500
+ ['-n', '-c', command],
501
+ {
502
+ ...config,
503
+ timeoutMs: Math.min(config.timeoutMs ?? DEFAULT_TIMEOUT_MS, 5000),
504
+ sandbox: { enabled: false },
505
+ },
506
+ { internal: true }
507
+ ).catch((error: Error): SpawnResult => ({
508
+ stdout: '',
509
+ stderr: error.message,
510
+ exitCode: 1,
511
+ timedOut: false,
512
+ }));
513
+
514
+ if (syntax.exitCode !== 0) {
515
+ errors.push(
516
+ syntax.stderr.trim() === ''
517
+ ? 'Command failed shell syntax validation.'
518
+ : `Command failed shell syntax validation: ${syntax.stderr.trim()}`
519
+ );
520
+ }
521
+
522
+ if (/\bsudo\b/.test(normalized)) {
523
+ warnings.push('Command requests elevated privileges with sudo.');
524
+ }
525
+
526
+ return {
527
+ valid: errors.length === 0,
528
+ errors,
529
+ warnings,
530
+ };
531
+ }
532
+
533
+ async function ensureSandbox(
534
+ config: t.LocalExecutionConfig,
535
+ cwd: string
536
+ ): Promise<SandboxManagerType | undefined> {
537
+ if (!shouldUseLocalSandbox(config)) {
538
+ return undefined;
539
+ }
540
+
541
+ const runtime = await loadSandboxRuntime().catch((error: Error) => {
542
+ throw new Error(`${missingSandboxRuntimeMessage} Cause: ${error.message}`);
543
+ });
544
+
545
+ const runtimeConfig = buildSandboxRuntimeConfig(
546
+ config,
547
+ cwd,
548
+ runtime.getDefaultWritePaths
549
+ );
550
+ const nextKey = JSON.stringify(runtimeConfig);
551
+
552
+ if (sandboxInitialized && sandboxConfigKey === nextKey) {
553
+ return runtime.SandboxManager;
554
+ }
555
+
556
+ const dependencyCheck = runtime.SandboxManager.checkDependencies();
557
+ if (dependencyCheck.errors.length > 0) {
558
+ if (config.sandbox?.failIfUnavailable === true) {
559
+ throw new Error(
560
+ `Local sandbox requested but unavailable: ${dependencyCheck.errors.join('; ')}`
561
+ );
562
+ }
563
+ return undefined;
564
+ }
565
+
566
+ if (sandboxInitialized) {
567
+ await runtime.SandboxManager.reset();
568
+ }
569
+
570
+ // Cast at the runtime boundary — our public `BuiltSandboxRuntimeConfig`
571
+ // is intentionally structural to keep the optional peer dep out of
572
+ // generated `.d.ts` (Codex P1 #22). It's a structural subset of the
573
+ // peer's `SandboxRuntimeConfig`, so the assignment is sound at the
574
+ // one site where the peer is actually loaded.
575
+ await runtime.SandboxManager.initialize(
576
+ runtimeConfig as unknown as SandboxRuntimeConfig
577
+ );
578
+ sandboxInitialized = true;
579
+ sandboxConfigKey = nextKey;
580
+ return runtime.SandboxManager;
581
+ }
582
+
583
+ /**
584
+ * Loopback addresses the in-process programmatic-tool bridge listens
585
+ * on (`LocalProgrammaticToolCalling.ts` binds 127.0.0.1). Sandboxed
586
+ * code launched by `run_tools_with_code` / `run_tools_with_bash` HTTPs
587
+ * back to that address — without the entries below, the bridge is
588
+ * silently blocked under sandbox.
589
+ */
590
+ const BRIDGE_LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'] as const;
591
+
592
+ /**
593
+ * Structural shape of the sandbox-runtime config we hand to
594
+ * `SandboxManager.initialize()`. Intentionally NOT typed as the peer
595
+ * `SandboxRuntimeConfig` from `@anthropic-ai/sandbox-runtime`: that
596
+ * package is an OPTIONAL peer dep, and exporting a function whose
597
+ * return type references it would make our generated `.d.ts` import
598
+ * a module the consumer may not have installed (Codex P1 #22 — type-
599
+ * checking would fail with `Cannot find module
600
+ * '@anthropic-ai/sandbox-runtime'` for any host that doesn't enable
601
+ * local sandboxing). The shape here is a structural subset; assignable
602
+ * to the real `SandboxRuntimeConfig` at the one runtime call site.
603
+ *
604
+ * @internal
605
+ */
606
+ export interface BuiltSandboxRuntimeConfig {
607
+ network: {
608
+ allowedDomains: string[];
609
+ deniedDomains: string[];
610
+ allowUnixSockets?: string[];
611
+ allowAllUnixSockets?: boolean;
612
+ allowLocalBinding?: boolean;
613
+ allowMachLookup?: string[];
614
+ };
615
+ filesystem: {
616
+ denyRead: string[];
617
+ allowRead?: string[];
618
+ allowWrite: string[];
619
+ denyWrite: string[];
620
+ allowGitConfig?: boolean;
621
+ };
622
+ }
623
+
624
+ export function buildSandboxRuntimeConfig(
625
+ config: t.LocalExecutionConfig,
626
+ cwd: string,
627
+ getDefaultWritePaths: () => string[]
628
+ ): BuiltSandboxRuntimeConfig {
629
+ const sandbox = config.sandbox;
630
+ // Seed allowedDomains with loopback so the programmatic-tool bridge
631
+ // works under sandbox. If the host explicitly denied a loopback
632
+ // entry via `deniedDomains`, respect that and skip seeding it.
633
+ const userAllowed = sandbox?.network?.allowedDomains ?? [];
634
+ const denied = new Set(sandbox?.network?.deniedDomains ?? []);
635
+ const seededLoopback = BRIDGE_LOOPBACK_HOSTS.filter(
636
+ (host) => !denied.has(host) && !userAllowed.includes(host)
637
+ );
638
+ const allowedDomains = [...seededLoopback, ...userAllowed];
639
+ // Mirror the file-tools workspace boundary: anything in
640
+ // `additionalRoots` counts as in-workspace, so sandboxed shell/code
641
+ // can write there too. Without this, file_tools can resolve a
642
+ // sibling-root path but `bash`/`execute_code` is denied write
643
+ // access — confusing divergence flagged in Codex P2 #15.
644
+ const workspaceWriteRoots =
645
+ config.workspace?.additionalRoots != null
646
+ ? getWorkspaceRoots(config)
647
+ : [cwd];
648
+ return {
649
+ network: {
650
+ allowedDomains,
651
+ deniedDomains: sandbox?.network?.deniedDomains ?? [],
652
+ ...(sandbox?.network?.allowUnixSockets != null && {
653
+ allowUnixSockets: sandbox.network.allowUnixSockets,
654
+ }),
655
+ ...(sandbox?.network?.allowAllUnixSockets != null && {
656
+ allowAllUnixSockets: sandbox.network.allowAllUnixSockets,
657
+ }),
658
+ ...(sandbox?.network?.allowLocalBinding != null && {
659
+ allowLocalBinding: sandbox.network.allowLocalBinding,
660
+ }),
661
+ ...(sandbox?.network?.allowMachLookup != null && {
662
+ allowMachLookup: sandbox.network.allowMachLookup,
663
+ }),
664
+ },
665
+ filesystem: {
666
+ denyRead: sandbox?.filesystem?.denyRead ?? [],
667
+ allowRead: sandbox?.filesystem?.allowRead,
668
+ allowWrite: sandbox?.filesystem?.allowWrite ?? [
669
+ ...workspaceWriteRoots,
670
+ ...getDefaultWritePaths(),
671
+ ],
672
+ denyWrite: sandbox?.filesystem?.denyWrite ?? [
673
+ '.env',
674
+ '.env.*',
675
+ '.git/config',
676
+ '.git/hooks/**',
677
+ ],
678
+ allowGitConfig: sandbox?.filesystem?.allowGitConfig,
679
+ },
680
+ };
681
+ }
682
+
683
+ /**
684
+ * Internal options for {@link spawnLocalProcess} that we don't want
685
+ * exposed on the public `LocalExecutionConfig` type.
686
+ *
687
+ * @internal
688
+ */
689
+ export interface SpawnLocalProcessOptions {
690
+ /**
691
+ * When true, suppress the "sandbox is off" warning AND its latch
692
+ * for this spawn. Use for SDK-internal probes (`bash -n` syntax
693
+ * preflight, `rg --version`, etc.) that intentionally run with
694
+ * the sandbox forced off — the warning is noise for those, and
695
+ * letting the latch flip would hide the warning when a *real*
696
+ * unsandboxed execution happens later in the same process.
697
+ */
698
+ internal?: boolean;
699
+ }
700
+
701
+ export async function spawnLocalProcess(
702
+ command: string,
703
+ args: string[],
704
+ config: t.LocalExecutionConfig = {},
705
+ options?: SpawnLocalProcessOptions
706
+ ): Promise<SpawnResult> {
707
+ const cwd = getLocalCwd(config);
708
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
709
+ const maxOutputChars = config.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
710
+ // Streaming caps. Local tools execute arbitrary shell/code, so a noisy
711
+ // command (`yes`, `cat /dev/urandom | base64`, a verbose build) could
712
+ // accumulate gigabytes in memory before hitting the post-close cap.
713
+ // We bound in-memory per-stream and spill the rest to disk; we also
714
+ // hard-kill the child once total streamed bytes pass `maxSpawnedBytes`
715
+ // so a process producing unbounded output gets stopped instead of
716
+ // letting the host OOM.
717
+ const inMemoryCapBytes = maxOutputChars * 2;
718
+ const hardKillBytes =
719
+ config.maxSpawnedBytes ?? DEFAULT_MAX_SPAWNED_BYTES;
720
+ const sandboxManager = await ensureSandbox(config, cwd);
721
+ // Internal probes (validateBashCommand syntax preflight,
722
+ // isRipgrepAvailable, syntax-check probe cache priming) pass
723
+ // `internal: true` so they don't emit a misleading "sandbox is
724
+ // off" warning AND don't flip `sandboxOffWarned = true`. Without
725
+ // this Codex P2 path: a run with `sandbox.enabled: true` would
726
+ // see a false warning from the syntax preflight, and the latch
727
+ // flip would suppress the warning in a *later* truly-unsandboxed
728
+ // run — exactly the scenario operators need to see.
729
+ if (sandboxManager == null && options?.internal !== true) {
730
+ maybeWarnSandboxOff(config);
731
+ }
732
+ let spawnCommand = command;
733
+ let spawnArgs = args;
734
+
735
+ if (sandboxManager != null) {
736
+ const rendered = [command, ...args.map(shellQuote)].join(' ');
737
+ const sandboxed = await sandboxManager.wrapWithSandbox(rendered);
738
+ spawnCommand = config.shell ?? DEFAULT_SHELL;
739
+ spawnArgs = ['-lc', sandboxed];
740
+ }
741
+
742
+ const launcher = getSpawn(config);
743
+ return new Promise<SpawnResult>((resolveResult, reject) => {
744
+ const child = launcher(spawnCommand, spawnArgs, {
745
+ cwd,
746
+ detached: process.platform !== 'win32',
747
+ env: { ...process.env, ...(config.env ?? {}) },
748
+ stdio: ['ignore', 'pipe', 'pipe'],
749
+ });
750
+
751
+ let stdout = '';
752
+ let stderr = '';
753
+ let totalSpawnedBytes = 0;
754
+ let overflowKilled = false;
755
+ let spillStream: import('fs').WriteStream | undefined;
756
+ let spillPath: string | undefined;
757
+ let settled = false;
758
+ let timedOut = false;
759
+ let timeout: NodeJS.Timeout | undefined;
760
+
761
+ const ensureSpill = (): void => {
762
+ if (spillStream != null) return;
763
+ // Lazy-open the temp file the first time a stream's in-memory
764
+ // buffer overflows. Seed it with everything we've buffered so
765
+ // the file holds the FULL output (not just the post-cap tail).
766
+ // Uses the static `createWriteStream` import — `require('fs')`
767
+ // would throw `ReferenceError: require is not defined` in ESM
768
+ // consumers (this package ships both `dist/cjs` and `dist/esm`).
769
+ spillPath = resolve(tmpdir(), `lc-local-output-${randomUUID()}.txt`);
770
+ spillStream = createWriteStream(spillPath);
771
+ spillStream.write('===== stdout =====\n');
772
+ spillStream.write(stdout);
773
+ spillStream.write('\n===== stderr =====\n');
774
+ spillStream.write(stderr);
775
+ spillStream.write('\n===== overflow stream begins here =====\n');
776
+ };
777
+
778
+ const handleChunk = (
779
+ buf: Buffer,
780
+ kind: 'stdout' | 'stderr'
781
+ ): void => {
782
+ totalSpawnedBytes += buf.length;
783
+ // hardKillBytes <= 0 means "no cap" per the public config contract
784
+ // (see LocalExecutionConfig.maxSpawnedBytes). Skip the kill check
785
+ // entirely in that case so a single byte doesn't terminate the run.
786
+ if (
787
+ hardKillBytes > 0 &&
788
+ totalSpawnedBytes > hardKillBytes &&
789
+ !overflowKilled
790
+ ) {
791
+ overflowKilled = true;
792
+ killProcessTree(child);
793
+ return;
794
+ }
795
+ const current = kind === 'stdout' ? stdout : stderr;
796
+ if (current.length < inMemoryCapBytes) {
797
+ const text = buf.toString('utf8');
798
+ if (kind === 'stdout') stdout += text;
799
+ else stderr += text;
800
+ if (current.length + text.length >= inMemoryCapBytes) {
801
+ ensureSpill();
802
+ }
803
+ } else {
804
+ ensureSpill();
805
+ spillStream!.write(`[${kind}] `);
806
+ spillStream!.write(buf);
807
+ }
808
+ };
809
+
810
+ const finish = (result: SpawnResult): void => {
811
+ if (settled) {
812
+ return;
813
+ }
814
+ settled = true;
815
+ if (timeout != null) {
816
+ clearTimeout(timeout);
817
+ }
818
+ const finalize = (): void => {
819
+ const truncated = {
820
+ stdout: truncateLocalOutput(result.stdout, maxOutputChars),
821
+ stderr: truncateLocalOutput(result.stderr, maxOutputChars),
822
+ };
823
+ resolveResult({
824
+ ...result,
825
+ ...truncated,
826
+ ...(spillPath != null ? { fullOutputPath: spillPath } : {}),
827
+ });
828
+ };
829
+ if (spillStream == null) {
830
+ finalize();
831
+ return;
832
+ }
833
+ // Wait for the temp file to flush before reporting the path.
834
+ // Otherwise the model sees `full_output_path: …` for a file
835
+ // that's still being written.
836
+ spillStream.end(() => finalize());
837
+ };
838
+
839
+ const fail = (error: Error): void => {
840
+ if (settled) {
841
+ return;
842
+ }
843
+ settled = true;
844
+ if (timeout != null) {
845
+ clearTimeout(timeout);
846
+ }
847
+ if (spillStream != null) {
848
+ spillStream.end();
849
+ }
850
+ reject(error);
851
+ };
852
+
853
+ if (timeoutMs > 0) {
854
+ timeout = setTimeout(() => {
855
+ timedOut = true;
856
+ killProcessTree(child);
857
+ }, timeoutMs);
858
+ }
859
+
860
+ child.stdout?.on('data', (chunk: Buffer) => {
861
+ handleChunk(chunk, 'stdout');
862
+ });
863
+
864
+ child.stderr?.on('data', (chunk: Buffer) => {
865
+ handleChunk(chunk, 'stderr');
866
+ });
867
+
868
+ child.on('error', fail);
869
+
870
+ child.on('close', (exitCode, signal) => {
871
+ // Synthesize a non-zero exit code whenever the process exited
872
+ // by signal — Node reports `exitCode: null` in that case and
873
+ // the formatter only prints non-null exit codes, so signal
874
+ // kills (overflow guard, `kill -9 $$` from inside the script,
875
+ // native crashes, OS OOM killer, …) would otherwise look like
876
+ // successful runs (Codex P1 + Codex P2). Overflow path keeps
877
+ // its 137 (SIGKILL) for compatibility; other signals map per
878
+ // POSIX `128 + signum`.
879
+ let finalExit: number | null = exitCode;
880
+ if (finalExit == null) {
881
+ if (overflowKilled) {
882
+ finalExit = 137;
883
+ } else if (signal != null) {
884
+ finalExit = exitCodeForSignal(signal);
885
+ }
886
+ }
887
+ finish({
888
+ stdout,
889
+ stderr,
890
+ exitCode: finalExit,
891
+ timedOut,
892
+ ...(overflowKilled ? { overflowKilled: true } : {}),
893
+ ...(signal != null ? { signal } : {}),
894
+ });
895
+ });
896
+ });
897
+ }
898
+
899
+ export async function executeLocalBash(
900
+ command: string,
901
+ config: t.LocalExecutionConfig = {}
902
+ ): Promise<SpawnResult> {
903
+ const validation = await validateBashCommand(command, config);
904
+ if (!validation.valid) {
905
+ throw new Error(validation.errors.join('\n'));
906
+ }
907
+ const shell = config.shell ?? DEFAULT_SHELL;
908
+ return spawnLocalProcess(shell, ['-lc', command], config);
909
+ }
910
+
911
+ /**
912
+ * Variant of `executeLocalBash` that exposes `args` as positional
913
+ * shell parameters (`$1`, `$2`, …). Mirrors what the other runtimes
914
+ * do in `getRuntimeCommand`. Uses the standard `bash -c <code> --
915
+ * arg0 arg1 …` form: the `--` becomes `$0`, then `args[0]` is `$1`
916
+ * and so on. Same AST validation as the no-args path.
917
+ *
918
+ * Used by both the `execute_code`/`lang:'bash'` path AND the
919
+ * `bash_tool` factory so the schema's `args` contract works
920
+ * identically in both surfaces.
921
+ */
922
+ /**
923
+ * Matches a single arg that, on its own, references a protected
924
+ * location (`/`, `~`, `$HOME`, `${HOME}`, `.`, with optional trailing
925
+ * slash, wildcard, or dot-glob suffix). Used to spot the
926
+ * `command: 'rm -rf "$1"', args: ['/']` shape where the destructive
927
+ * target is moved into a positional arg to evade the command regex.
928
+ * Codex P1 [45], extended for dot-glob in Codex P1 [47] (mirrors the
929
+ * `DESTRUCTIVE_TARGET` suffix matrix exactly).
930
+ */
931
+ const PROTECTED_TARGET_ARG_RE =
932
+ /^(?:\/|~|\$\{?HOME\}?|\.)(?:\/?\.?\*|\/)?$/;
933
+
934
+ /**
935
+ * Mutating-op recognizer for the args check. Conservative: only the
936
+ * three operations the destructive-command guard already covers
937
+ * directly (`rm -rf …`, `chmod -R …`, `chown -R …`). Other shell
938
+ * builtins might mutate state (`mv`, `cp` over an existing file,
939
+ * etc.) but the destructive guard doesn't try to catch those today,
940
+ * so we don't widen here either.
941
+ */
942
+ const DESTRUCTIVE_OP_IN_COMMAND_RE =
943
+ /\b(?:rm\s+-[^\s]*[rf]|chmod\s+-R|chown\s+-R)\b/;
944
+
945
+ export async function executeLocalBashWithArgs(
946
+ command: string,
947
+ args: readonly string[],
948
+ config: t.LocalExecutionConfig = {}
949
+ ): Promise<SpawnResult> {
950
+ const validation = await validateBashCommand(command, config);
951
+ if (!validation.valid) {
952
+ throw new Error(validation.errors.join('\n'));
953
+ }
954
+ // Per-arg protected-target check (Codex P1 [45]). The command
955
+ // regex can't see `$1`/`$@` substitutions at runtime — `command:
956
+ // 'rm -rf "$1"', args: ['/']` would expand to `rm -rf '/'` inside
957
+ // bash but the validator only saw `rm -rf "$1"` (no destructive
958
+ // target). Block when (a) the command contains a destructive op
959
+ // AND (b) at least one arg matches the protected-target shape.
960
+ // Skipped when allowDangerousCommands is true (host-opted-in).
961
+ if (
962
+ args.length > 0 &&
963
+ config.allowDangerousCommands !== true &&
964
+ DESTRUCTIVE_OP_IN_COMMAND_RE.test(command)
965
+ ) {
966
+ const offending = args.find((a) => PROTECTED_TARGET_ARG_RE.test(a));
967
+ if (offending !== undefined) {
968
+ throw new Error(
969
+ `Command matches a destructive command pattern (protected target "${offending}" passed via positional arg).`
970
+ );
971
+ }
972
+ }
973
+ const shell = config.shell ?? DEFAULT_SHELL;
974
+ return spawnLocalProcess(
975
+ shell,
976
+ ['-lc', command, '--', ...args],
977
+ config
978
+ );
979
+ }
980
+
981
+ export async function executeLocalCode(
982
+ input: {
983
+ lang: string;
984
+ code: string;
985
+ args?: string[];
986
+ },
987
+ config: t.LocalExecutionConfig = {}
988
+ ): Promise<SpawnResult> {
989
+ if (input.lang === 'bash') {
990
+ // Append `args` as positional parameters via the standard
991
+ // `bash -c <code> -- <args...>` form so `$1`, `$2`, … inside
992
+ // `code` resolve correctly. Honours the same args contract the
993
+ // other runtimes (py, js, …) already support.
994
+ if (input.args != null && input.args.length > 0) {
995
+ return executeLocalBashWithArgs(input.code, input.args, config);
996
+ }
997
+ return executeLocalBash(input.code, config);
998
+ }
999
+
1000
+ const tempDir = resolve(tmpdir(), `lc-local-${randomUUID()}`);
1001
+ await mkdir(tempDir, { recursive: true });
1002
+
1003
+ try {
1004
+ const runtime = getRuntimeCommand(
1005
+ input.lang,
1006
+ tempDir,
1007
+ input.code,
1008
+ input.args,
1009
+ config.shell
1010
+ );
1011
+ if (runtime.source != null) {
1012
+ await writeFile(resolve(tempDir, runtime.fileName), runtime.source, 'utf8');
1013
+ }
1014
+ return await spawnLocalProcess(runtime.command, runtime.args, config);
1015
+ } finally {
1016
+ await rm(tempDir, { recursive: true, force: true });
1017
+ }
1018
+ }
1019
+
1020
+ function getRuntimeCommand(
1021
+ lang: string,
1022
+ tempDir: string,
1023
+ code: string,
1024
+ args: string[] = [],
1025
+ // Override for the shell used by compile-style runtimes (`rs`,
1026
+ // `c`, `cpp`, `java`, `d`, `f90`). Threads `local.shell` so a host
1027
+ // that doesn't have bash (or wants `/bin/sh` / zsh) can still
1028
+ // execute these languages — Codex P2 #29: the bare-bash hardcode
1029
+ // mirrored the same gap that Codex P1 #6 fixed for the syntax
1030
+ // preflight, but had been missed for these runtime invocations.
1031
+ shellOverride?: string
1032
+ ): RuntimeCommand {
1033
+ const fileFor = (name: string): string => resolve(tempDir, name);
1034
+ const shell = shellOverride ?? configShell();
1035
+
1036
+ switch (lang) {
1037
+ case 'py':
1038
+ return {
1039
+ command: 'python3',
1040
+ args: [fileFor('main.py'), ...args],
1041
+ fileName: 'main.py',
1042
+ source: code,
1043
+ };
1044
+ case 'js':
1045
+ return {
1046
+ command: 'node',
1047
+ args: [fileFor('main.js'), ...args],
1048
+ fileName: 'main.js',
1049
+ source: code,
1050
+ };
1051
+ case 'ts':
1052
+ return {
1053
+ command: 'npx',
1054
+ args: ['--no-install', 'tsx', fileFor('main.ts'), ...args],
1055
+ fileName: 'main.ts',
1056
+ source: code,
1057
+ };
1058
+ case 'php':
1059
+ return {
1060
+ command: 'php',
1061
+ args: [fileFor('main.php'), ...args],
1062
+ fileName: 'main.php',
1063
+ source: code,
1064
+ };
1065
+ case 'go':
1066
+ return {
1067
+ command: 'go',
1068
+ args: ['run', fileFor('main.go'), ...args],
1069
+ fileName: 'main.go',
1070
+ source: code,
1071
+ };
1072
+ case 'rs':
1073
+ return {
1074
+ command: shell,
1075
+ args: [
1076
+ '-lc',
1077
+ `rustc ${shellQuote(fileFor('main.rs'))} -o ${shellQuote(
1078
+ fileFor('main-rs')
1079
+ )} && ${shellQuote(fileFor('main-rs'))} ${args.map(shellQuote).join(' ')}`,
1080
+ ],
1081
+ fileName: 'main.rs',
1082
+ source: code,
1083
+ };
1084
+ case 'c':
1085
+ return {
1086
+ command: shell,
1087
+ args: [
1088
+ '-lc',
1089
+ `cc ${shellQuote(fileFor('main.c'))} -o ${shellQuote(
1090
+ fileFor('main-c')
1091
+ )} && ${shellQuote(fileFor('main-c'))} ${args.map(shellQuote).join(' ')}`,
1092
+ ],
1093
+ fileName: 'main.c',
1094
+ source: code,
1095
+ };
1096
+ case 'cpp':
1097
+ return {
1098
+ command: shell,
1099
+ args: [
1100
+ '-lc',
1101
+ `c++ ${shellQuote(fileFor('main.cpp'))} -o ${shellQuote(
1102
+ fileFor('main-cpp')
1103
+ )} && ${shellQuote(fileFor('main-cpp'))} ${args.map(shellQuote).join(' ')}`,
1104
+ ],
1105
+ fileName: 'main.cpp',
1106
+ source: code,
1107
+ };
1108
+ case 'java':
1109
+ return {
1110
+ command: shell,
1111
+ args: [
1112
+ '-lc',
1113
+ `javac ${shellQuote(fileFor('Main.java'))} && java -cp ${shellQuote(
1114
+ tempDir
1115
+ )} Main ${args.map(shellQuote).join(' ')}`,
1116
+ ],
1117
+ fileName: 'Main.java',
1118
+ source: code,
1119
+ };
1120
+ case 'r':
1121
+ return {
1122
+ command: 'Rscript',
1123
+ args: [fileFor('main.R'), ...args],
1124
+ fileName: 'main.R',
1125
+ source: code,
1126
+ };
1127
+ case 'd':
1128
+ return {
1129
+ command: shell,
1130
+ args: [
1131
+ '-lc',
1132
+ `dmd ${shellQuote(fileFor('main.d'))} -of=${shellQuote(
1133
+ fileFor('main-d')
1134
+ )} && ${shellQuote(fileFor('main-d'))} ${args.map(shellQuote).join(' ')}`,
1135
+ ],
1136
+ fileName: 'main.d',
1137
+ source: code,
1138
+ };
1139
+ case 'f90':
1140
+ return {
1141
+ command: shell,
1142
+ args: [
1143
+ '-lc',
1144
+ `gfortran ${shellQuote(fileFor('main.f90'))} -o ${shellQuote(
1145
+ fileFor('main-f90')
1146
+ )} && ${shellQuote(fileFor('main-f90'))} ${args.map(shellQuote).join(' ')}`,
1147
+ ],
1148
+ fileName: 'main.f90',
1149
+ source: code,
1150
+ };
1151
+ default:
1152
+ throw new Error(`Unsupported local runtime: ${lang}`);
1153
+ }
1154
+ }
1155
+
1156
+ function configShell(): string {
1157
+ return process.platform === 'win32' ? 'bash.exe' : 'bash';
1158
+ }
1159
+
1160
+ /**
1161
+ * How long after SIGTERM we wait before escalating to SIGKILL. A
1162
+ * cooperative process gets a graceful chance to flush + clean up;
1163
+ * a process that ignores or traps SIGTERM (`trap '' TERM`) gets
1164
+ * killed unconditionally so timeoutMs / maxSpawnedBytes can't be
1165
+ * defeated by a hostile script. Codex P1 #28 — pre-fix the spawn
1166
+ * promise would never resolve in that case and the entire tool run
1167
+ * would hang past the advertised timeout.
1168
+ */
1169
+ const SIGKILL_ESCALATION_MS = 2000;
1170
+
1171
+ function sigterm(child: ChildProcess): void {
1172
+ if (child.pid == null) return;
1173
+ try {
1174
+ if (process.platform === 'win32') {
1175
+ child.kill('SIGTERM');
1176
+ return;
1177
+ }
1178
+ process.kill(-child.pid, 'SIGTERM');
1179
+ } catch {
1180
+ child.kill('SIGTERM');
1181
+ }
1182
+ }
1183
+
1184
+ function sigkill(child: ChildProcess): void {
1185
+ if (child.pid == null) return;
1186
+ if (child.exitCode != null || child.signalCode != null) return;
1187
+ try {
1188
+ if (process.platform === 'win32') {
1189
+ child.kill('SIGKILL');
1190
+ return;
1191
+ }
1192
+ process.kill(-child.pid, 'SIGKILL');
1193
+ } catch {
1194
+ try {
1195
+ child.kill('SIGKILL');
1196
+ } catch {
1197
+ /* already dead */
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ function killProcessTree(child: ChildProcess): void {
1203
+ sigterm(child);
1204
+ // Escalate to SIGKILL if the child is still alive after the grace
1205
+ // window. Use unref() so the timer doesn't keep the Node process
1206
+ // alive past the parent's natural exit.
1207
+ const escalation = setTimeout(() => sigkill(child), SIGKILL_ESCALATION_MS);
1208
+ escalation.unref?.();
1209
+ child.once('close', () => clearTimeout(escalation));
1210
+ }
1211
+
1212
+ export function shellQuote(value: string): string {
1213
+ if (value === '') {
1214
+ return '\'\'';
1215
+ }
1216
+ if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) {
1217
+ return value;
1218
+ }
1219
+ return `'${value.replace(/'/g, '\'\\\'\'')}'`;
1220
+ }
1221
+
1222
+ export function resolveWorkspacePath(
1223
+ filePath: string,
1224
+ config: t.LocalExecutionConfig = {},
1225
+ intent: 'read' | 'write' = 'write'
1226
+ ): string {
1227
+ const cwd = getLocalCwd(config);
1228
+ const absolutePath = isAbsolute(filePath) ? resolve(filePath) : resolve(cwd, filePath);
1229
+
1230
+ const roots = intent === 'write' ? getWriteRoots(config) : getReadRoots(config);
1231
+ if (roots == null) return absolutePath; // explicit allow-outside
1232
+
1233
+ if (absolutePath === cwd || isInsideAnyRoot(absolutePath, roots)) {
1234
+ return absolutePath;
1235
+ }
1236
+ throw new Error(`Path is outside the local workspace: ${filePath}`);
1237
+ }
1238
+
1239
+ function isInsideAnyRoot(absolutePath: string, roots: string[]): boolean {
1240
+ for (const root of roots) {
1241
+ if (absolutePath === root) return true;
1242
+ const rel = relative(root, absolutePath);
1243
+ if (!rel.startsWith('..') && !isAbsolute(rel)) return true;
1244
+ }
1245
+ return false;
1246
+ }
1247
+
1248
+ type RealpathFn = (p: string) => Promise<string>;
1249
+
1250
+ async function realpathOrSelf(
1251
+ absolutePath: string,
1252
+ realpathImpl: RealpathFn = realpath
1253
+ ): Promise<string> {
1254
+ try {
1255
+ return await realpathImpl(absolutePath);
1256
+ } catch {
1257
+ return absolutePath;
1258
+ }
1259
+ }
1260
+
1261
+ /**
1262
+ * Resolves the realpath of `absolutePath`, falling back to the nearest
1263
+ * existing ancestor when the target itself does not yet exist (so the
1264
+ * containment check still works for `write_file` to a brand-new path).
1265
+ *
1266
+ * Codex P2 #38: takes the realpath impl as a parameter so callers
1267
+ * can route through `WorkspaceFS.realpath` when a custom engine is
1268
+ * configured. Pre-fix, host `fs/promises.realpath` would fail on a
1269
+ * remote/in-memory FS path and silently fall back to lexical
1270
+ * containment, leaving the symlink-escape clamp ineffective on
1271
+ * non-default engines.
1272
+ */
1273
+ async function realpathOfPathOrAncestor(
1274
+ absolutePath: string,
1275
+ realpathImpl: RealpathFn = realpath
1276
+ ): Promise<string> {
1277
+ let current = absolutePath;
1278
+ let suffix = '';
1279
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1280
+ while (true) {
1281
+ try {
1282
+ const real = await realpathImpl(current);
1283
+ return suffix === '' ? real : resolve(real, suffix);
1284
+ } catch {
1285
+ const parent = resolve(current, '..');
1286
+ if (parent === current) {
1287
+ return absolutePath;
1288
+ }
1289
+ const base = current.slice(parent.length + 1);
1290
+ suffix = suffix === '' ? base : `${base}/${suffix}`;
1291
+ current = parent;
1292
+ }
1293
+ }
1294
+ }
1295
+
1296
+ /**
1297
+ * Resolves a workspace path AND follows any symlinks before checking
1298
+ * containment, so a symlink inside the workspace pointing outside is
1299
+ * rejected even though the lexical path looks safe. Handles paths that
1300
+ * don't yet exist (e.g. write_file targets) by realpath-resolving the
1301
+ * nearest existing ancestor and re-attaching the unresolved suffix.
1302
+ */
1303
+ export async function resolveWorkspacePathSafe(
1304
+ filePath: string,
1305
+ config: t.LocalExecutionConfig = {},
1306
+ intent: 'read' | 'write' = 'write'
1307
+ ): Promise<string> {
1308
+ const lexical = resolveWorkspacePath(filePath, config, intent);
1309
+ const roots = intent === 'write' ? getWriteRoots(config) : getReadRoots(config);
1310
+ if (roots == null) {
1311
+ return lexical;
1312
+ }
1313
+ // Route realpath through the configured WorkspaceFS so a custom
1314
+ // engine (in-memory, remote) gets the same symlink-escape clamp
1315
+ // the host-fs path gets. Codex P2 #38: pre-fix the host realpath
1316
+ // would fail on a non-default FS path and silently fall back to
1317
+ // lexical containment, leaving the clamp ineffective.
1318
+ const fsRealpath: RealpathFn = (p) => getWorkspaceFS(config).realpath(p);
1319
+ const realRoots = await Promise.all(
1320
+ roots.map((r) => realpathOrSelf(r, fsRealpath))
1321
+ );
1322
+ const realPath = await realpathOfPathOrAncestor(lexical, fsRealpath);
1323
+ if (isInsideAnyRoot(realPath, realRoots)) {
1324
+ return lexical;
1325
+ }
1326
+ throw new Error(
1327
+ `Path is outside the local workspace (symlink escape): ${filePath}`
1328
+ );
1329
+ }