@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.
- package/CHANGELOG.md +26 -0
- package/README.md +12 -13
- package/dist/bridge/index.mjs +1248 -484
- package/dist/bridge/index.mjs.map +1 -1
- package/dist/bridge/package.json +1 -1
- package/dist/bridge/pnpm-lock.yaml +1 -9
- package/dist/index.d.ts +1399 -0
- package/dist/index.js +46 -73
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/bridge/codex-app-server-client.ts +320 -0
- package/src/bridge/codex-app-server-driver.ts +617 -0
- package/src/bridge/codex-step-tracker.ts +2 -0
- package/src/bridge/codex-tool-filtering-hook.ts +148 -0
- package/src/bridge/codex-tool-filtering.ts +42 -0
- package/src/bridge/create-app-server-event-handler.ts +531 -0
- package/src/bridge/create-emit-stream-event.ts +27 -2
- package/src/bridge/index.ts +64 -193
- package/src/bridge/package.json +1 -1
- package/src/bridge/pnpm-lock.yaml +1 -9
- package/src/codex-bridge-protocol.ts +4 -4
- package/src/codex-harness.ts +56 -117
- package/src/bridge/cli-relay.ts +0 -268
- package/src/bridge/tool-relay-auth.ts +0 -156
- package/src/bridge/tool-relay.ts +0 -119
package/src/codex-harness.ts
CHANGED
|
@@ -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
|
|
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
|
|
153
|
-
*
|
|
154
|
+
* the wire (`commonName ?? nativeName`). Schemas reflect the corresponding
|
|
155
|
+
* app-server item payloads.
|
|
154
156
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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 `
|
|
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 `
|
|
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)}
|
|
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 `
|
|
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:
|
|
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)
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
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
|
|
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
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
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;
|
package/src/bridge/cli-relay.ts
DELETED
|
@@ -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
|
-
}
|