@ai-sdk/harness-codex 1.0.126 → 1.0.129

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.
@@ -7,6 +7,7 @@ import {
7
7
  type HarnessV1,
8
8
  type HarnessV1DebugConfig,
9
9
  type HarnessV1BuiltinTool,
10
+ type HarnessV1BuiltinToolFiltering,
10
11
  type HarnessV1ContinueTurnState,
11
12
  type HarnessV1CredentialForwarding,
12
13
  type HarnessV1MintBridgeTokenCallback,
@@ -29,6 +30,7 @@ import {
29
30
  createSandboxCredentialEnvironment,
30
31
  createBridgeErrorHandler,
31
32
  createBridgeStartupError,
33
+ experimental_createBridgeUserMessageSubmitter,
32
34
  drainBridgeProcessStream,
33
35
  forwardBridgeProcessStream,
34
36
  getRestrictedSandboxSession,
@@ -43,6 +45,7 @@ import {
43
45
  type SandboxChannelReconnectOptions,
44
46
  } from '@ai-sdk/harness/utils';
45
47
  import {
48
+ tool,
46
49
  type Experimental_SandboxProcess,
47
50
  type Experimental_SandboxSession as SandboxSession,
48
51
  } from '@ai-sdk/provider-utils';
@@ -67,7 +70,6 @@ import {
67
70
  type InboundMessage,
68
71
  type OutboundMessage,
69
72
  } from './codex-bridge-protocol';
70
- import { CLI_SHIM_FILENAME } from './bridge/cli-relay';
71
73
  import { VERSION } from './version';
72
74
 
73
75
  type CodexChannel = SandboxChannel<OutboundMessage, InboundMessage>;
@@ -147,14 +149,13 @@ export type CodexHarnessSettings = {
147
149
  };
148
150
 
149
151
  /*
150
- * Every native tool the Codex CLI can invoke as a model-callable tool,
152
+ * Every native tool Codex app-server can invoke as a model-callable tool,
151
153
  * declared as a `ToolSet` keyed by what the bridge emits as `toolName` on
152
- * the wire (`commonName ?? nativeName`). Schemas reflect the `ThreadItem`
153
- * union in `@openai/codex-sdk`'s `dist/index.d.ts`.
154
+ * the wire (`commonName ?? nativeName`). Schemas reflect the corresponding
155
+ * app-server item payloads.
154
156
  *
155
- * Codex's other native operations (`apply_patch`, todo planning) surface
156
- * only as side-effect events (`file_change`, `todo_list`) and are not
157
- * model-callable tools — they don't appear here.
157
+ * Other native operations such as todo planning surface as side-effect
158
+ * events and are not model-callable tools.
158
159
  */
159
160
  const CODEX_BUILTIN_TOOLS = {
160
161
  bash: commonTool('bash', {
@@ -169,6 +170,24 @@ const CODEX_BUILTIN_TOOLS = {
169
170
  description: 'Search the web',
170
171
  inputSchema: z.object({ query: z.string() }),
171
172
  }),
173
+ apply_patch: {
174
+ ...tool({
175
+ description: 'Apply a patch to files',
176
+ inputSchema: z.string(),
177
+ }),
178
+ toolUseKind: 'edit',
179
+ },
180
+ view_image: {
181
+ ...tool({
182
+ description: 'View a local image file',
183
+ inputSchema: z.object({
184
+ path: z.string(),
185
+ detail: z.enum(['high', 'original']).optional(),
186
+ environment_id: z.string().optional(),
187
+ }),
188
+ }),
189
+ toolUseKind: 'readonly',
190
+ },
172
191
  } as const satisfies Record<string, HarnessV1BuiltinTool<any, any>>;
173
192
 
174
193
  /**
@@ -185,7 +204,7 @@ const codexBridgeCoordsSchema = z.object({
185
204
 
186
205
  /**
187
206
  * Schema for the adapter-specific lifecycle `data` payload Codex produces.
188
- * `threadId` is what `codex.resumeThread(...)` requires for the replay/rerun
207
+ * `threadId` is what app-server's `thread/resume` method requires for the replay/rerun
189
208
  * rungs; the sandbox lookup is handled separately via
190
209
  * `provider.resumeSession({ sessionId })`. `bridge` carries live coordinates
191
210
  * for cross-process `attach` (present on `doDetach()` and `doSuspendTurn()`
@@ -207,18 +226,12 @@ export function createCodex(
207
226
  specificationVersion: 'harness-v1',
208
227
  harnessId: 'codex',
209
228
  builtinTools: CODEX_BUILTIN_TOOLS,
229
+ supportsBuiltinToolFiltering: true,
210
230
  supportsBuiltinToolApprovals: false,
211
231
  lifecycleStateSchema: codexResumeStateSchema,
212
232
  getBootstrap: getCodexBootstrap,
213
233
  doStart: async startOpts => {
214
234
  const model = DEFAULT_CODEX_MODEL;
215
- if (startOpts.builtinToolFiltering != null) {
216
- throw new HarnessCapabilityUnsupportedError({
217
- message:
218
- "Harness 'codex' does not support built-in tool filtering controls.",
219
- harnessId: 'codex',
220
- });
221
- }
222
235
  if (
223
236
  startOpts.permissionMode != null &&
224
237
  startOpts.permissionMode !== 'allow-all'
@@ -336,8 +349,6 @@ export function createCodex(
336
349
  sessionId: startOpts.sessionId,
337
350
  });
338
351
  const bridgeStateDir = `${sessionDataDir}/bridge`;
339
- const cliShimDir = `${sessionDataDir}/codex`;
340
- const cliShimPath = `${cliShimDir}/${CLI_SHIM_FILENAME}`;
341
352
  const timeoutMs = settings.startupTimeoutMs ?? 120_000;
342
353
 
343
354
  // Normalize each forwarded bridge diagnostics frame into the general
@@ -390,12 +401,12 @@ export function createCodex(
390
401
  return createSession({
391
402
  sessionId: startOpts.sessionId,
392
403
  channel: attachChannel,
393
- cliShimPath,
394
404
  // The live bridge was spawned by another process; no process handle.
395
405
  proc: undefined,
396
406
  model,
397
407
  reasoningEffort: settings.reasoningEffort,
398
408
  webSearch: settings.webSearch,
409
+ builtinToolFiltering: startOpts.builtinToolFiltering,
399
410
  codexConfig: settings.codexConfig,
400
411
  mcpServers: settings.mcpServers,
401
412
  headers: startOpts.headers,
@@ -424,7 +435,7 @@ export function createCodex(
424
435
  * log is replayed *from*. `resumeFrom` is a between-turn resume; even when
425
436
  * it carries bridge coordinates, replaying the previous turn would
426
437
  * re-deliver stale events into the next turn. Those resumes always `rerun`
427
- * via `codex.resumeThread(threadId)` when attach is unavailable.
438
+ * via app-server's `thread/resume` method when attach is unavailable.
428
439
  */
429
440
  let respawnStrategy: CodexRespawnStrategy | undefined = isResume
430
441
  ? 'rerun'
@@ -490,7 +501,7 @@ export function createCodex(
490
501
  });
491
502
 
492
503
  const proc = await toolSafeSandboxSession.spawn({
493
- command: `node ${shellQuote(`${bootstrapDir}/bridge.mjs`)} --workdir ${shellQuote(workDir)} --bridge-state-dir ${shellQuote(bridgeStateDir)} --cli-shim-dir ${shellQuote(cliShimDir)}`,
504
+ command: `node ${shellQuote(`${bootstrapDir}/bridge.mjs`)} --workdir ${shellQuote(workDir)} --bridge-state-dir ${shellQuote(bridgeStateDir)}`,
494
505
  env,
495
506
  abortSignal: startOpts.abortSignal,
496
507
  });
@@ -556,11 +567,11 @@ export function createCodex(
556
567
  return createSession({
557
568
  sessionId: startOpts.sessionId,
558
569
  channel,
559
- cliShimPath,
560
570
  proc,
561
571
  model,
562
572
  reasoningEffort: settings.reasoningEffort,
563
573
  webSearch: settings.webSearch,
574
+ builtinToolFiltering: startOpts.builtinToolFiltering,
564
575
  codexConfig: settings.codexConfig,
565
576
  mcpServers: settings.mcpServers,
566
577
  headers: startOpts.headers,
@@ -707,11 +718,11 @@ function openWebSocket({
707
718
  function createSession({
708
719
  sessionId,
709
720
  channel,
710
- cliShimPath,
711
721
  proc,
712
722
  model,
713
723
  reasoningEffort,
714
724
  webSearch,
725
+ builtinToolFiltering,
715
726
  codexConfig,
716
727
  mcpServers,
717
728
  headers,
@@ -731,12 +742,12 @@ function createSession({
731
742
  }: {
732
743
  sessionId: string;
733
744
  channel: CodexChannel;
734
- cliShimPath: string;
735
745
  /** Undefined on `attach` — the live bridge was spawned by another process. */
736
746
  proc: Experimental_SandboxProcess | undefined;
737
747
  model: string | undefined;
738
748
  reasoningEffort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | undefined;
739
749
  webSearch: boolean | undefined;
750
+ builtinToolFiltering: HarnessV1BuiltinToolFiltering | undefined;
740
751
  codexConfig: Record<string, unknown> | undefined;
741
752
  mcpServers: Record<string, unknown> | undefined;
742
753
  headers: Readonly<Record<string, string>> | undefined;
@@ -758,21 +769,13 @@ function createSession({
758
769
  let stopPromise: Promise<void> | undefined;
759
770
  /*
760
771
  * Send the persisted threadId on the first prompt only when the bridge was
761
- * respawned (rerun/replay) so it takes the `codex.resumeThread(...)` branch.
772
+ * respawned (rerun/replay) so it takes the app-server `thread/resume` branch.
762
773
  * An `attach`ed bridge already holds its threadState in memory and continues
763
774
  * on its own, so it needs no seed.
764
775
  */
765
776
  let pendingResumeThreadId = seedResumeThreadOnFirstPrompt
766
777
  ? resumeThreadId
767
778
  : undefined;
768
- /*
769
- * Host-tool relay guidance is prepended to the first user message of a fresh
770
- * session only. A resumed session (attach/replay/rerun) already carried it in
771
- * its original first message (preserved in the persisted thread), so it
772
- * starts "applied".
773
- */
774
- let initialPromptGuidanceApplied = isResume;
775
-
776
779
  /*
777
780
  * Latest codex thread id, cached from the bridge's `bridge-thread`
778
781
  * announcements. Seeded from lifecycle state so `doDetach()` and `doStop()`
@@ -783,7 +786,6 @@ function createSession({
783
786
  channel.on('bridge-thread', msg => {
784
787
  latestThreadId = msg.threadId;
785
788
  });
786
-
787
789
  const synchronizeTurnConfiguration = async ({
788
790
  skills,
789
791
  instructions,
@@ -821,13 +823,6 @@ function createSession({
821
823
  latestTurnConfigurationFingerprint !== nextFingerprint));
822
824
  latestTurnConfigurationFingerprint = nextFingerprint;
823
825
  if (restartThread) {
824
- /*
825
- * `codex exec resume` retains the native thread's original developer
826
- * instructions and skill catalog. A fresh native thread is therefore
827
- * required for replacement semantics. Host-tool guidance is framed
828
- * again because that guidance also belongs to the new native thread.
829
- */
830
- initialPromptGuidanceApplied = false;
831
826
  pendingResumeThreadId = undefined;
832
827
  }
833
828
  return { restartThread };
@@ -853,6 +848,11 @@ function createSession({
853
848
  pendingResolve = resolve;
854
849
  pendingReject = reject;
855
850
  });
851
+ const userMessageSubmitter = experimental_createBridgeUserMessageSubmitter({
852
+ send: message => channel.send(message),
853
+ onResponse: listener => channel.on('user-message-response', listener),
854
+ onReconnect: listener => channel.onReconnect(listener),
855
+ });
856
856
 
857
857
  const unsubs: Array<() => void> = [];
858
858
  const forward = (event: HarnessV1StreamPart) => {
@@ -880,12 +880,14 @@ function createSession({
880
880
  const settleSuccess = () => {
881
881
  if (isSettled) return;
882
882
  isSettled = true;
883
+ userMessageSubmitter.close();
883
884
  for (const u of unsubs) u();
884
885
  pendingResolve!();
885
886
  };
886
887
  const settleError = (err: unknown) => {
887
888
  if (isSettled) return;
888
889
  isSettled = true;
890
+ userMessageSubmitter.close(err);
889
891
  for (const u of unsubs) u();
890
892
  pendingReject!(err);
891
893
  };
@@ -963,6 +965,9 @@ function createSession({
963
965
  reason: input.reason,
964
966
  });
965
967
  },
968
+ submitUserMessage: async text => {
969
+ await userMessageSubmitter.submit(text);
970
+ },
966
971
  done,
967
972
  };
968
973
 
@@ -1017,24 +1022,9 @@ function createSession({
1017
1022
  abortSignal: promptOpts.abortSignal,
1018
1023
  });
1019
1024
 
1020
- let promptText = extractUserText(promptOpts.prompt);
1021
- if (!initialPromptGuidanceApplied) {
1022
- promptText = frameInitialPromptGuidance({
1023
- toolUsageBlock:
1024
- tools.length > 0
1025
- ? composeToolUsageInstructions({
1026
- tools,
1027
- cliShimPath,
1028
- })
1029
- : undefined,
1030
- userText: promptText,
1031
- });
1032
- }
1033
- initialPromptGuidanceApplied = true;
1034
-
1035
1025
  const startMessage = {
1036
1026
  type: 'start' as const,
1037
- prompt: promptText,
1027
+ prompt: extractUserText(promptOpts.prompt),
1038
1028
  tools,
1039
1029
  ...(promptOpts.responseFormat == null
1040
1030
  ? {}
@@ -1045,6 +1035,7 @@ function createSession({
1045
1035
  model: promptOpts.model ?? model,
1046
1036
  reasoningEffort,
1047
1037
  webSearch,
1038
+ ...(builtinToolFiltering == null ? {} : { builtinToolFiltering }),
1048
1039
  ...(codexConfig == null ? {} : { codexConfig }),
1049
1040
  ...(mcpServers == null ? {} : { mcpServers }),
1050
1041
  ...(headers == null ? {} : { headers }),
@@ -1123,6 +1114,7 @@ function createSession({
1123
1114
  model: continueOpts.model ?? model,
1124
1115
  reasoningEffort,
1125
1116
  webSearch,
1117
+ ...(builtinToolFiltering == null ? {} : { builtinToolFiltering }),
1126
1118
  ...(codexConfig == null ? {} : { codexConfig }),
1127
1119
  ...(mcpServers == null ? {} : { mcpServers }),
1128
1120
  ...(headers == null ? {} : { headers }),
@@ -1139,10 +1131,9 @@ function createSession({
1139
1131
  doCompact: async () => {
1140
1132
  /*
1141
1133
  * Codex compacts its context automatically inside the core turn loop
1142
- * (~90% of the model context window), but the `codex exec` transport this
1143
- * adapter drives exposes no manual compaction trigger and emits no
1144
- * compaction event. Manual `compact()` is therefore unsupported; Codex's
1145
- * own auto-compaction continues to run regardless.
1134
+ * (~90% of the model context window). This adapter does not yet expose a
1135
+ * manual compaction trigger or compaction event, so manual `compact()` is
1136
+ * unsupported while Codex's own auto-compaction continues to run.
1146
1137
  */
1147
1138
  throw new HarnessCapabilityUnsupportedError({
1148
1139
  message:
@@ -1229,7 +1220,7 @@ function createSession({
1229
1220
  * payload — the workdir is still captured by the sandbox snapshot
1230
1221
  * during the subsequent `sandboxSession.stop()`, so the next turn can
1231
1222
  * resume the filesystem state. The trade-off: we lose
1232
- * `threadId`, so the codex CLI starts a fresh thread on the
1223
+ * `threadId`, so Codex app-server starts a fresh thread on the
1233
1224
  * preserved workdir rather than resuming the prior conversation
1234
1225
  * inside Codex's runtime. Ability to continue beats throwing.
1235
1226
  */
@@ -1365,62 +1356,10 @@ function fingerprintCodexTurnConfiguration({
1365
1356
  }
1366
1357
 
1367
1358
  /*
1368
- * Frame host-tool relay guidance and the user's text so Codex treats the
1369
- * prepended block as operating guidance rather than user prose. Applied only
1370
- * to the first user message of a fresh session.
1371
- */
1372
- function frameInitialPromptGuidance({
1373
- toolUsageBlock,
1374
- userText,
1375
- }: {
1376
- toolUsageBlock: string | undefined;
1377
- userText: string;
1378
- }): string {
1379
- const blocks: string[] = [];
1380
- if (toolUsageBlock) blocks.push(toolUsageBlock);
1381
- if (blocks.length === 0) return userText;
1382
- return `${blocks.join('\n\n')}\n\n<user-message>\n${userText}\n</user-message>`;
1383
- }
1384
-
1385
- function composeToolUsageInstructions({
1386
- tools,
1387
- cliShimPath,
1388
- }: {
1389
- tools: ReadonlyArray<{
1390
- name: string;
1391
- description?: string;
1392
- inputSchema?: unknown;
1393
- }>;
1394
- cliShimPath: string;
1395
- }): string {
1396
- const lines: string[] = [
1397
- '<host-tool-instructions>',
1398
- 'You have access to the following host-provided tools. To use one, run the following command via your built-in `bash` tool:',
1399
- '',
1400
- ` node ${cliShimPath} <toolName> '<jsonInput>'`,
1401
- '',
1402
- 'The script prints the JSON result to stdout. Do not invent another way to call these tools — only this CLI invocation will work. Pass the JSON input as a single-quoted argument.',
1403
- 'For every user request that depends on a host-provided tool, run a separate CLI invocation for each needed tool call in the current turn before answering. Do not reuse previous tool results, and do not say you used a host tool unless the command has completed in the current turn.',
1404
- '',
1405
- ];
1406
- for (const toolSpec of tools) {
1407
- lines.push(
1408
- `- **${toolSpec.name}**${toolSpec.description ? ': ' + toolSpec.description : ''}`,
1409
- );
1410
- lines.push(
1411
- ` - Input schema: \`${JSON.stringify(toolSpec.inputSchema ?? {})}\``,
1412
- );
1413
- }
1414
- lines.push('</host-tool-instructions>');
1415
- return lines.join('\n');
1416
- }
1417
-
1418
- /*
1419
- * Reduce a `HarnessV1Prompt` to the plain user text the bridge forwards
1420
- * to the Codex SDK. File and image parts on the message are not yet
1421
- * supported by the underlying runtime — throw rather than silently drop
1422
- * them so callers learn about the gap instead of seeing mysteriously
1423
- * truncated prompts.
1359
+ * Reduce a `HarnessV1Prompt` to the plain user text the bridge forwards to
1360
+ * Codex. The adapter does not yet map file and image parts into app-server
1361
+ * turn input, so throw rather than silently dropping them and producing a
1362
+ * truncated prompt.
1424
1363
  */
1425
1364
  function extractUserText(prompt: HarnessV1Prompt): string {
1426
1365
  if (typeof prompt === 'string') return prompt;
@@ -1,268 +0,0 @@
1
- /*
2
- * Temporary workaround for upstream codex CLI bug
3
- * https://github.com/openai/codex/issues/19425 — MCP tools registered via
4
- * `mcp_servers.*` are not exposed to the model in `codex exec --experimental-json`
5
- * mode (which is what `@openai/codex-sdk` uses). The MCP handshake completes
6
- * and `tools/list` succeeds, but codex never registers the tools as
7
- * model-callable functions.
8
- *
9
- * Until that's fixed upstream, this file implements a CLI-based relay:
10
- *
11
- * 1. A small Node script is written into harness-owned session state at turn
12
- * start (`buildCliShimScript`). It accepts `<toolName> <jsonInput>` as
13
- * argv, POSTs to the same HTTP relay the MCP shim uses, and prints the
14
- * result to stdout.
15
- *
16
- * 2. Tool descriptions and invocation instructions are injected into the
17
- * initial user prompt by the host adapter, telling the model to call host
18
- * tools by running `node <shim-path> <toolName> '<jsonInput>'` via its
19
- * built-in `bash` tool.
20
- *
21
- * 3. The bridge's event loop suppresses the matching `command_execution`
22
- * events (`isToolRelayCommand`) so callers receive clean `tool-call` /
23
- * `tool-result` events from the HTTP relay rather than seeing the
24
- * relay invocations as raw bash commands.
25
- *
26
- * Once #19425 is fixed and MCP tools are properly exposed in exec mode, the
27
- * three hookpoints in `bridge/index.ts` can be removed along with this file.
28
- */
29
- export const CLI_SHIM_FILENAME = 'harness-tool.mjs';
30
-
31
- export function buildCliShimScript({
32
- relayPort,
33
- }: {
34
- relayPort: number;
35
- }): string {
36
- return `#!/usr/bin/env node
37
- const [toolName, inputJson = '{}'] = process.argv.slice(2);
38
- if (!toolName) {
39
- console.error('Usage: harness-tool <tool_name> <json_input>');
40
- process.exit(64);
41
- }
42
- let input;
43
- try {
44
- input = JSON.parse(inputJson);
45
- } catch (error) {
46
- console.error('Invalid JSON input: ' + (error instanceof Error ? error.message : String(error)));
47
- process.exit(64);
48
- }
49
- const requestId = 'cli-' + Date.now() + '-' + Math.random().toString(16).slice(2);
50
- const response = await fetch('http://127.0.0.1:${relayPort}', {
51
- method: 'POST',
52
- headers: { 'Content-Type': 'application/json' },
53
- body: JSON.stringify({ requestId, toolName, input }),
54
- });
55
- const text = await response.text();
56
- let payload;
57
- try {
58
- payload = text ? JSON.parse(text) : {};
59
- } catch {
60
- payload = { error: text };
61
- }
62
- if (!response.ok || payload.error) {
63
- console.error(String(payload.error ?? ('tool relay failed with HTTP ' + response.status)));
64
- process.exit(1);
65
- }
66
- console.log(JSON.stringify(payload.result ?? payload, null, 2));
67
- `;
68
- }
69
-
70
- export function isToolRelayCommand({
71
- command,
72
- cliShimPath,
73
- }: {
74
- command: string;
75
- cliShimPath: string;
76
- }): boolean {
77
- return parseToolRelayCommands({ command, cliShimPath }) !== undefined;
78
- }
79
-
80
- export function parseToolRelayCommand({
81
- command,
82
- cliShimPath,
83
- }: {
84
- command: string;
85
- cliShimPath: string;
86
- }): { toolName: string; input: unknown } | undefined {
87
- const calls = parseToolRelayCommands({ command, cliShimPath });
88
- return calls?.length === 1 ? calls[0] : undefined;
89
- }
90
-
91
- export function parseToolRelayCommands({
92
- command,
93
- cliShimPath,
94
- }: {
95
- command: string;
96
- cliShimPath: string;
97
- }): Array<{ toolName: string; input: unknown }> | undefined {
98
- return parseToolRelayCommandsInternal({ command, cliShimPath, depth: 0 });
99
- }
100
-
101
- function parseToolRelayCommandsInternal({
102
- command,
103
- cliShimPath,
104
- depth,
105
- }: {
106
- command: string;
107
- cliShimPath: string;
108
- depth: number;
109
- }): Array<{ toolName: string; input: unknown }> | undefined {
110
- const commands = splitShellAndCommands(command);
111
- if (!commands) return undefined;
112
- if (commands.length > 1) {
113
- const relayCalls: Array<{ toolName: string; input: unknown }> = [];
114
- for (const nestedCommand of commands) {
115
- const nestedCalls = parseToolRelayCommandsInternal({
116
- command: nestedCommand,
117
- cliShimPath,
118
- depth,
119
- });
120
- if (!nestedCalls) return undefined;
121
- relayCalls.push(...nestedCalls);
122
- }
123
- return relayCalls;
124
- }
125
-
126
- const argv = parseShellWords(command);
127
- if (!argv) return undefined;
128
-
129
- const relayCall = parseDirectToolRelayArgv({ argv, cliShimPath });
130
- if (relayCall) return [relayCall];
131
-
132
- const innerCommand = extractShellEvalCommand(argv);
133
- if (!innerCommand || depth >= 2) return undefined;
134
- return parseToolRelayCommandsInternal({
135
- command: innerCommand,
136
- cliShimPath,
137
- depth: depth + 1,
138
- });
139
- }
140
-
141
- function parseDirectToolRelayArgv({
142
- argv,
143
- cliShimPath,
144
- }: {
145
- argv: string[];
146
- cliShimPath: string;
147
- }): { toolName: string; input: unknown } | undefined {
148
- if (argv.length < 3 || argv.length > 4) return undefined;
149
- if (argv[0] !== 'node' || argv[1] !== cliShimPath) return undefined;
150
- const toolName = argv[2];
151
- if (!toolName) return undefined;
152
- try {
153
- return { toolName, input: JSON.parse(argv[3] ?? '{}') };
154
- } catch {
155
- return undefined;
156
- }
157
- }
158
-
159
- function extractShellEvalCommand(argv: string[]): string | undefined {
160
- if (argv.length !== 3) return undefined;
161
- const shellName = argv[0].split('/').at(-1);
162
- if (shellName !== 'bash' && shellName !== 'sh' && shellName !== 'zsh') {
163
- return undefined;
164
- }
165
- if (argv[1] !== '-c' && argv[1] !== '-lc') return undefined;
166
- return argv[2];
167
- }
168
-
169
- function splitShellAndCommands(command: string): string[] | undefined {
170
- const commands: string[] = [];
171
- let start = 0;
172
- let quote: '"' | "'" | undefined;
173
-
174
- for (let i = 0; i < command.length; i++) {
175
- const char = command[i];
176
- if (quote === "'") {
177
- if (char === "'") quote = undefined;
178
- continue;
179
- }
180
- if (quote === '"') {
181
- if (char === '"') {
182
- quote = undefined;
183
- } else if (char === '\\') {
184
- i++;
185
- }
186
- continue;
187
- }
188
- if (char === '"' || char === "'") {
189
- quote = char;
190
- continue;
191
- }
192
- if (char === '\\') {
193
- i++;
194
- continue;
195
- }
196
- if (char === '&' && command[i + 1] === '&') {
197
- const nestedCommand = command.slice(start, i).trim();
198
- if (!nestedCommand) return undefined;
199
- commands.push(nestedCommand);
200
- start = i + 2;
201
- i++;
202
- }
203
- }
204
-
205
- if (quote) return undefined;
206
- const nestedCommand = command.slice(start).trim();
207
- if (!nestedCommand) return undefined;
208
- commands.push(nestedCommand);
209
- return commands;
210
- }
211
-
212
- function parseShellWords(command: string): string[] | undefined {
213
- const words: string[] = [];
214
- let current = '';
215
- let quote: '"' | "'" | undefined;
216
- let hasCurrent = false;
217
-
218
- const pushCurrent = () => {
219
- if (!hasCurrent) return;
220
- words.push(current);
221
- current = '';
222
- hasCurrent = false;
223
- };
224
-
225
- for (let i = 0; i < command.length; i++) {
226
- const char = command[i];
227
- if (quote === "'") {
228
- if (char === "'") {
229
- quote = undefined;
230
- } else {
231
- current += char;
232
- }
233
- hasCurrent = true;
234
- continue;
235
- }
236
- if (quote === '"') {
237
- if (char === '"') {
238
- quote = undefined;
239
- } else if (char === '\\' && i + 1 < command.length) {
240
- current += command[++i];
241
- } else {
242
- current += char;
243
- }
244
- hasCurrent = true;
245
- continue;
246
- }
247
- if (/\s/.test(char)) {
248
- pushCurrent();
249
- continue;
250
- }
251
- if (char === '"' || char === "'") {
252
- quote = char;
253
- hasCurrent = true;
254
- continue;
255
- }
256
- if (char === '\\' && i + 1 < command.length) {
257
- current += command[++i];
258
- hasCurrent = true;
259
- continue;
260
- }
261
- if (/[;&|<>()`$]/.test(char)) return undefined;
262
- current += char;
263
- hasCurrent = true;
264
- }
265
- if (quote) return undefined;
266
- pushCurrent();
267
- return words;
268
- }