@ai-sdk/harness-codex 1.0.10 → 1.0.12
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 +13 -0
- package/dist/bridge/host-tool-mcp.mjs +1 -3
- package/dist/bridge/host-tool-mcp.mjs.map +1 -1
- package/dist/bridge/index.mjs +333 -89
- package/dist/bridge/index.mjs.map +1 -1
- package/dist/index.js +76 -10
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/bridge/cli-relay.ts +125 -41
- package/src/bridge/host-tool-mcp.ts +1 -3
- package/src/bridge/index.ts +141 -101
- package/src/bridge/tool-relay-auth.ts +195 -0
- package/src/codex-bridge-protocol.ts +0 -1
- package/src/codex-harness.ts +102 -20
package/src/bridge/index.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// This file supplies only the Codex-specific turn driver.
|
|
6
6
|
//
|
|
7
7
|
// Host-defined tools are routed through an HTTP relay bound to
|
|
8
|
-
// `127.0.0.1:0
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// `127.0.0.1:0`. The codex CLI spawns `host-tool-mcp.mjs` (shipped alongside
|
|
9
|
+
// this file) as a stdio MCP server; the shim POSTs each tool call to the
|
|
10
|
+
// relay, which emits `tool-call` to the host and waits for the matching
|
|
11
|
+
// `tool-result`.
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
runBridge,
|
|
@@ -18,15 +18,20 @@ import {
|
|
|
18
18
|
import type { HarnessV1BuiltinToolName } from '@ai-sdk/harness';
|
|
19
19
|
import type { StartMessage } from '../codex-bridge-protocol';
|
|
20
20
|
import { randomUUID } from 'node:crypto';
|
|
21
|
-
import { writeFile } from 'node:fs/promises';
|
|
21
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
22
22
|
import { createServer, type Server } from 'node:http';
|
|
23
23
|
// Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
|
|
24
24
|
import {
|
|
25
25
|
CLI_SHIM_FILENAME,
|
|
26
26
|
buildCliShimScript,
|
|
27
|
-
|
|
28
|
-
isToolRelayCommand,
|
|
27
|
+
parseToolRelayCommand,
|
|
29
28
|
} from './cli-relay';
|
|
29
|
+
import {
|
|
30
|
+
ToolRelayAuthorizer,
|
|
31
|
+
ToolRelayPendingCalls,
|
|
32
|
+
isToolRelayRequestFromAllowedProcess,
|
|
33
|
+
type ToolRelayCall,
|
|
34
|
+
} from './tool-relay-auth';
|
|
30
35
|
import { argv, env as procEnv, stdout } from 'node:process';
|
|
31
36
|
|
|
32
37
|
/*
|
|
@@ -63,14 +68,15 @@ function toCommonName(nativeName: string): HarnessV1BuiltinToolName | string {
|
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
const args = parseArgs(argv.slice(2));
|
|
66
|
-
const workdir = args.workdir;
|
|
67
|
-
const bridgeStateDir =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
const workdir = requireArg({ value: args.workdir, name: '--workdir' });
|
|
72
|
+
const bridgeStateDir = requireArg({
|
|
73
|
+
value: args.bridgeStateDir,
|
|
74
|
+
name: '--bridge-state-dir',
|
|
75
|
+
});
|
|
76
|
+
const cliShimDir = requireArg({
|
|
77
|
+
value: args.cliShimDir,
|
|
78
|
+
name: '--cli-shim-dir',
|
|
79
|
+
});
|
|
74
80
|
const bootstrapDir = args.bootstrapDir ?? workdir;
|
|
75
81
|
|
|
76
82
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -88,6 +94,12 @@ await runBridge<StartMessage>({
|
|
|
88
94
|
});
|
|
89
95
|
|
|
90
96
|
type Emit = (msg: Record<string, unknown>) => void;
|
|
97
|
+
type ToolRelay = {
|
|
98
|
+
port: number;
|
|
99
|
+
close(): void;
|
|
100
|
+
authorizeToolCall(call: ToolRelayCall): void;
|
|
101
|
+
authorizeAnyToolCall(): void;
|
|
102
|
+
};
|
|
91
103
|
|
|
92
104
|
async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
93
105
|
const emit: Emit = msg => turn.emit(msg as BridgeEvent);
|
|
@@ -114,18 +126,17 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
|
114
126
|
* Until that's fixed, host tools are made available to the model via a
|
|
115
127
|
* separate CLI-relay workaround (see `./cli-relay.ts`). The MCP server
|
|
116
128
|
* config below is kept so that the day codex starts exposing MCP tools
|
|
117
|
-
* properly, host tools work both ways.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* and can be removed once the upstream bug is fixed.
|
|
129
|
+
* properly, host tools work both ways. Writing the shim here, adding matching
|
|
130
|
+
* prompt guidance in the host adapter, and filtering the shim command below
|
|
131
|
+
* implement the workaround and can be removed once the upstream bug is fixed.
|
|
121
132
|
*/
|
|
122
133
|
const mcpServers: Record<string, unknown> = {};
|
|
123
|
-
let relay:
|
|
134
|
+
let relay: ToolRelay | undefined;
|
|
124
135
|
let cliShimPath: string | undefined;
|
|
125
136
|
if (start.tools && start.tools.length > 0) {
|
|
126
|
-
|
|
137
|
+
cliShimPath = `${cliShimDir}/${CLI_SHIM_FILENAME}`;
|
|
127
138
|
relay = await startToolRelay({
|
|
128
|
-
|
|
139
|
+
allowedScriptPaths: [cliShimPath, `${bootstrapDir}/host-tool-mcp.mjs`],
|
|
129
140
|
tools: start.tools,
|
|
130
141
|
emit,
|
|
131
142
|
requestToolResult: turn.requestToolResult,
|
|
@@ -143,14 +154,13 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
|
143
154
|
})),
|
|
144
155
|
),
|
|
145
156
|
TOOL_RELAY_URL: `http://127.0.0.1:${relay.port}`,
|
|
146
|
-
TOOL_RELAY_TOKEN: relayToken,
|
|
147
157
|
},
|
|
148
158
|
};
|
|
149
159
|
// Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
|
|
150
|
-
|
|
160
|
+
await mkdir(cliShimDir, { recursive: true });
|
|
151
161
|
await writeFile(
|
|
152
162
|
cliShimPath,
|
|
153
|
-
buildCliShimScript({ relayPort: relay.port
|
|
163
|
+
buildCliShimScript({ relayPort: relay.port }),
|
|
154
164
|
'utf8',
|
|
155
165
|
);
|
|
156
166
|
}
|
|
@@ -212,18 +222,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
|
212
222
|
|
|
213
223
|
emit({ type: 'stream-start' });
|
|
214
224
|
|
|
215
|
-
const userMessage =
|
|
216
|
-
text: start.prompt,
|
|
217
|
-
instructions: start.instructions,
|
|
218
|
-
// Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
|
|
219
|
-
toolUsageBlock:
|
|
220
|
-
cliShimPath && start.tools && start.tools.length > 0
|
|
221
|
-
? composeToolUsageInstructions({
|
|
222
|
-
tools: start.tools,
|
|
223
|
-
cliShimPath,
|
|
224
|
-
})
|
|
225
|
-
: undefined,
|
|
226
|
-
});
|
|
225
|
+
const userMessage = start.prompt;
|
|
227
226
|
let turnUsage: Record<string, unknown> | undefined;
|
|
228
227
|
const textByItem = new Map<string, string>();
|
|
229
228
|
const reasoningByItem = new Map<string, string>();
|
|
@@ -243,12 +242,28 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
|
243
242
|
emit({ type: 'bridge-thread', threadId: event.thread_id });
|
|
244
243
|
}
|
|
245
244
|
// Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
245
|
+
if (cliShimPath && event.item?.type === 'command_execution') {
|
|
246
|
+
const relayCall =
|
|
247
|
+
typeof event.item.command === 'string'
|
|
248
|
+
? parseToolRelayCommand({
|
|
249
|
+
command: event.item.command,
|
|
250
|
+
cliShimPath,
|
|
251
|
+
})
|
|
252
|
+
: undefined;
|
|
253
|
+
if (event.type === 'item.started' && relay) {
|
|
254
|
+
if (relayCall) {
|
|
255
|
+
relay.authorizeToolCall(relayCall);
|
|
256
|
+
} else if (typeof event.item.command !== 'string') {
|
|
257
|
+
relay.authorizeAnyToolCall();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (relayCall) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (relay && isHostMcpToolEvent(event)) {
|
|
265
|
+
const relayCall = relayCallFromCodexMcpEvent(event);
|
|
266
|
+
if (relayCall) relay.authorizeToolCall(relayCall);
|
|
252
267
|
continue;
|
|
253
268
|
}
|
|
254
269
|
translateAndEmit(event, {
|
|
@@ -332,6 +347,25 @@ type CodexEvent = {
|
|
|
332
347
|
thread_id?: string;
|
|
333
348
|
};
|
|
334
349
|
|
|
350
|
+
function isHostMcpToolEvent(event: CodexEvent): boolean {
|
|
351
|
+
return (
|
|
352
|
+
event.item?.type === 'mcp_tool_call' &&
|
|
353
|
+
event.item.server === 'harness-tools'
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function relayCallFromCodexMcpEvent(
|
|
358
|
+
event: CodexEvent,
|
|
359
|
+
): ToolRelayCall | undefined {
|
|
360
|
+
if (event.type !== 'item.started') return undefined;
|
|
361
|
+
const toolName = event.item?.tool;
|
|
362
|
+
if (!toolName) return undefined;
|
|
363
|
+
return {
|
|
364
|
+
toolName,
|
|
365
|
+
input: event.item?.arguments ?? {},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
335
369
|
function translateAndEmit(
|
|
336
370
|
event: CodexEvent,
|
|
337
371
|
ctx: {
|
|
@@ -523,65 +557,33 @@ function defaultUsage(): Record<string, unknown> {
|
|
|
523
557
|
};
|
|
524
558
|
}
|
|
525
559
|
|
|
526
|
-
function composeUserMessage({
|
|
527
|
-
text,
|
|
528
|
-
instructions,
|
|
529
|
-
toolUsageBlock,
|
|
530
|
-
}: {
|
|
531
|
-
text: string;
|
|
532
|
-
instructions: string | undefined;
|
|
533
|
-
toolUsageBlock: string | undefined;
|
|
534
|
-
}): string {
|
|
535
|
-
const blocks: string[] = [];
|
|
536
|
-
/*
|
|
537
|
-
* Frame instructions as system-provided operating guidance, not something
|
|
538
|
-
* the user wrote, so the agent does not echo the prepended text back as if
|
|
539
|
-
* the user had asked for it. Only present on the first user message of a
|
|
540
|
-
* fresh session (the host gates it), so the matching `<user-message>` fence
|
|
541
|
-
* is added only when instructions are present too.
|
|
542
|
-
*/
|
|
543
|
-
if (instructions) {
|
|
544
|
-
blocks.push(
|
|
545
|
-
'<session-instructions>\n' +
|
|
546
|
-
'The block below is operating guidance from the system, not a message from the user — follow it, but do not mention it or attribute it to the user.\n\n' +
|
|
547
|
-
`${instructions}\n` +
|
|
548
|
-
'</session-instructions>',
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
if (toolUsageBlock) blocks.push(toolUsageBlock);
|
|
552
|
-
blocks.push(instructions ? `<user-message>\n${text}\n</user-message>` : text);
|
|
553
|
-
return blocks.join('\n\n');
|
|
554
|
-
}
|
|
555
|
-
|
|
556
560
|
/**
|
|
557
|
-
* Tool relay — HTTP server on 127.0.0.1:0
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* `{ result }`.
|
|
561
|
+
* Tool relay — HTTP server on 127.0.0.1:0. The MCP stdio shim spawned by
|
|
562
|
+
* codex POSTs each tool invocation here; the relay forwards the call to the
|
|
563
|
+
* host (via the shared runtime's `emit`), awaits the matching `tool-result`
|
|
564
|
+
* (via `requestToolResult`), and responds with `{ result }`.
|
|
562
565
|
*/
|
|
563
566
|
async function startToolRelay({
|
|
564
|
-
|
|
567
|
+
allowedScriptPaths,
|
|
565
568
|
tools,
|
|
566
569
|
emit,
|
|
567
570
|
requestToolResult,
|
|
568
571
|
}: {
|
|
569
|
-
|
|
572
|
+
allowedScriptPaths: ReadonlyArray<string>;
|
|
570
573
|
tools: ReadonlyArray<{ name: string }>;
|
|
571
574
|
emit: Emit;
|
|
572
575
|
requestToolResult: (
|
|
573
576
|
toolCallId: string,
|
|
574
577
|
) => Promise<{ output: unknown; isError?: boolean }>;
|
|
575
|
-
}): Promise<
|
|
578
|
+
}): Promise<ToolRelay> {
|
|
576
579
|
const toolNames = new Set(tools.map(t => t.name));
|
|
580
|
+
const allowedScriptPathSet = new Set(allowedScriptPaths);
|
|
581
|
+
const authorizer = new ToolRelayAuthorizer();
|
|
582
|
+
const pendingCalls = new ToolRelayPendingCalls();
|
|
577
583
|
|
|
578
584
|
const server = createServer(async (req, res) => {
|
|
579
585
|
try {
|
|
580
|
-
if (
|
|
581
|
-
req.method !== 'POST' ||
|
|
582
|
-
req.url !== '/' ||
|
|
583
|
-
req.headers.authorization !== `Bearer ${relayToken}`
|
|
584
|
-
) {
|
|
586
|
+
if (req.method !== 'POST' || req.url !== '/') {
|
|
585
587
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
586
588
|
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
587
589
|
return;
|
|
@@ -604,23 +606,42 @@ async function startToolRelay({
|
|
|
604
606
|
);
|
|
605
607
|
return;
|
|
606
608
|
}
|
|
609
|
+
const relayCall = { toolName, input };
|
|
610
|
+
const authorized =
|
|
611
|
+
authorizer.consumeToolCall(relayCall) ||
|
|
612
|
+
(await isToolRelayRequestFromAllowedProcess({
|
|
613
|
+
socket: req.socket,
|
|
614
|
+
allowedScriptPaths: allowedScriptPathSet,
|
|
615
|
+
}));
|
|
616
|
+
if (!authorized) {
|
|
617
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
618
|
+
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
607
621
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
622
|
+
const { result } = pendingCalls.begin({
|
|
623
|
+
call: relayCall,
|
|
624
|
+
run: async () => {
|
|
625
|
+
emit({
|
|
626
|
+
type: 'tool-call',
|
|
627
|
+
toolCallId: requestId,
|
|
628
|
+
toolName,
|
|
629
|
+
input: JSON.stringify(input ?? {}),
|
|
630
|
+
providerExecuted: false,
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const toolResult = await requestToolResult(requestId);
|
|
634
|
+
emit({
|
|
635
|
+
type: 'tool-result',
|
|
636
|
+
toolCallId: requestId,
|
|
637
|
+
toolName,
|
|
638
|
+
result: toolResult.output ?? null,
|
|
639
|
+
isError: !!toolResult.isError,
|
|
640
|
+
});
|
|
641
|
+
return toolResult;
|
|
642
|
+
},
|
|
623
643
|
});
|
|
644
|
+
const { output } = await result;
|
|
624
645
|
|
|
625
646
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
626
647
|
res.end(JSON.stringify({ result: output }));
|
|
@@ -644,6 +665,8 @@ async function startToolRelay({
|
|
|
644
665
|
return {
|
|
645
666
|
port: address.port,
|
|
646
667
|
close: () => closeServer(server),
|
|
668
|
+
authorizeToolCall: call => authorizer.authorizeToolCall(call),
|
|
669
|
+
authorizeAnyToolCall: () => authorizer.authorizeAnyToolCall(),
|
|
647
670
|
};
|
|
648
671
|
}
|
|
649
672
|
|
|
@@ -657,11 +680,13 @@ function parseArgs(args: string[]): {
|
|
|
657
680
|
workdir?: string;
|
|
658
681
|
bridgeStateDir?: string;
|
|
659
682
|
bootstrapDir?: string;
|
|
683
|
+
cliShimDir?: string;
|
|
660
684
|
} {
|
|
661
685
|
const out: {
|
|
662
686
|
workdir?: string;
|
|
663
687
|
bridgeStateDir?: string;
|
|
664
688
|
bootstrapDir?: string;
|
|
689
|
+
cliShimDir?: string;
|
|
665
690
|
} = {};
|
|
666
691
|
for (let i = 0; i < args.length; i++) {
|
|
667
692
|
if (args[i] === '--workdir' && i + 1 < args.length) {
|
|
@@ -670,6 +695,8 @@ function parseArgs(args: string[]): {
|
|
|
670
695
|
out.bridgeStateDir = args[++i];
|
|
671
696
|
} else if (args[i] === '--bootstrap-dir' && i + 1 < args.length) {
|
|
672
697
|
out.bootstrapDir = args[++i];
|
|
698
|
+
} else if (args[i] === '--cli-shim-dir' && i + 1 < args.length) {
|
|
699
|
+
out.cliShimDir = args[++i];
|
|
673
700
|
}
|
|
674
701
|
}
|
|
675
702
|
return out;
|
|
@@ -686,3 +713,16 @@ function emitFatal(message: string): never {
|
|
|
686
713
|
stdout.write(JSON.stringify({ type: 'bridge-fatal', message }) + '\n');
|
|
687
714
|
process.exit(1);
|
|
688
715
|
}
|
|
716
|
+
|
|
717
|
+
function requireArg({
|
|
718
|
+
value,
|
|
719
|
+
name,
|
|
720
|
+
}: {
|
|
721
|
+
value: string | undefined;
|
|
722
|
+
name: string;
|
|
723
|
+
}): string {
|
|
724
|
+
if (!value) {
|
|
725
|
+
emitFatal(`Missing ${name} argument.`);
|
|
726
|
+
}
|
|
727
|
+
return value;
|
|
728
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { readdir, readFile, readlink } from 'node:fs/promises';
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
|
|
4
|
+
export type ToolRelayCall = {
|
|
5
|
+
toolName: string;
|
|
6
|
+
input: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ToolRelayResult = {
|
|
10
|
+
output: unknown;
|
|
11
|
+
isError?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class ToolRelayAuthorizer {
|
|
15
|
+
private readonly ttlMs: number;
|
|
16
|
+
private readonly now: () => number;
|
|
17
|
+
private readonly authorizations: Array<{
|
|
18
|
+
key?: string;
|
|
19
|
+
expiresAt: number;
|
|
20
|
+
}> = [];
|
|
21
|
+
|
|
22
|
+
constructor({
|
|
23
|
+
ttlMs = 10_000,
|
|
24
|
+
now = Date.now,
|
|
25
|
+
}: {
|
|
26
|
+
ttlMs?: number;
|
|
27
|
+
now?: () => number;
|
|
28
|
+
} = {}) {
|
|
29
|
+
this.ttlMs = ttlMs;
|
|
30
|
+
this.now = now;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
authorizeToolCall(call: ToolRelayCall): void {
|
|
34
|
+
this.pruneExpired();
|
|
35
|
+
this.authorizations.push({
|
|
36
|
+
key: toolRelayCallKey(call),
|
|
37
|
+
expiresAt: this.now() + this.ttlMs,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
authorizeAnyToolCall(): void {
|
|
42
|
+
this.pruneExpired();
|
|
43
|
+
this.authorizations.push({
|
|
44
|
+
expiresAt: this.now() + this.ttlMs,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
consumeToolCall(call: ToolRelayCall): boolean {
|
|
49
|
+
this.pruneExpired();
|
|
50
|
+
const key = toolRelayCallKey(call);
|
|
51
|
+
let index = this.authorizations.findIndex(auth => auth.key === key);
|
|
52
|
+
if (index === -1) {
|
|
53
|
+
index = this.authorizations.findIndex(auth => auth.key === undefined);
|
|
54
|
+
}
|
|
55
|
+
if (index === -1) return false;
|
|
56
|
+
this.authorizations.splice(index, 1);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private pruneExpired(): void {
|
|
61
|
+
const now = this.now();
|
|
62
|
+
for (let i = this.authorizations.length - 1; i >= 0; i--) {
|
|
63
|
+
if (this.authorizations[i].expiresAt <= now) {
|
|
64
|
+
this.authorizations.splice(i, 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class ToolRelayPendingCalls {
|
|
71
|
+
private readonly calls = new Map<string, Promise<ToolRelayResult>>();
|
|
72
|
+
|
|
73
|
+
begin({
|
|
74
|
+
call,
|
|
75
|
+
run,
|
|
76
|
+
}: {
|
|
77
|
+
call: ToolRelayCall;
|
|
78
|
+
run: () => Promise<ToolRelayResult>;
|
|
79
|
+
}): { result: Promise<ToolRelayResult>; isNew: boolean } {
|
|
80
|
+
const key = toolRelayCallKey(call);
|
|
81
|
+
const existing = this.calls.get(key);
|
|
82
|
+
if (existing) return { result: existing, isNew: false };
|
|
83
|
+
|
|
84
|
+
const result = run();
|
|
85
|
+
this.calls.set(key, result);
|
|
86
|
+
void result
|
|
87
|
+
.finally(() => {
|
|
88
|
+
if (this.calls.get(key) === result) {
|
|
89
|
+
this.calls.delete(key);
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
.catch(() => {});
|
|
93
|
+
return { result, isNew: true };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toolRelayCallKey({ toolName, input }: ToolRelayCall): string {
|
|
98
|
+
return `${toolName}\0${canonicalJson(input ?? {})}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function canonicalJson(value: unknown): string {
|
|
102
|
+
return JSON.stringify(normalizeJsonValue(value));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeJsonValue(value: unknown): unknown {
|
|
106
|
+
if (Array.isArray(value)) {
|
|
107
|
+
return value.map(normalizeJsonValue);
|
|
108
|
+
}
|
|
109
|
+
if (value && typeof value === 'object') {
|
|
110
|
+
return Object.fromEntries(
|
|
111
|
+
Object.entries(value as Record<string, unknown>)
|
|
112
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
113
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
114
|
+
.map(([key, entryValue]) => [key, normalizeJsonValue(entryValue)]),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function isToolRelayRequestFromAllowedProcess({
|
|
121
|
+
socket,
|
|
122
|
+
allowedScriptPaths,
|
|
123
|
+
}: {
|
|
124
|
+
socket: Socket;
|
|
125
|
+
allowedScriptPaths: ReadonlySet<string>;
|
|
126
|
+
}): Promise<boolean> {
|
|
127
|
+
if (process.platform !== 'linux') return false;
|
|
128
|
+
if (!socket.remotePort || !socket.localPort) return false;
|
|
129
|
+
|
|
130
|
+
const inode = await findTcpSocketInode({
|
|
131
|
+
clientPort: socket.remotePort,
|
|
132
|
+
serverPort: socket.localPort,
|
|
133
|
+
});
|
|
134
|
+
if (!inode) return false;
|
|
135
|
+
|
|
136
|
+
const cmdline = await findProcessCmdlineForSocketInode({ inode });
|
|
137
|
+
return cmdline?.some(arg => allowedScriptPaths.has(arg)) ?? false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function findTcpSocketInode({
|
|
141
|
+
clientPort,
|
|
142
|
+
serverPort,
|
|
143
|
+
}: {
|
|
144
|
+
clientPort: number;
|
|
145
|
+
serverPort: number;
|
|
146
|
+
}): Promise<string | undefined> {
|
|
147
|
+
for (const tablePath of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
148
|
+
const table = await readFile(tablePath, 'utf8').catch(() => undefined);
|
|
149
|
+
if (!table) continue;
|
|
150
|
+
for (const line of table.split('\n').slice(1)) {
|
|
151
|
+
const columns = line.trim().split(/\s+/);
|
|
152
|
+
if (columns.length < 10) continue;
|
|
153
|
+
const local = parseProcNetAddress(columns[1]);
|
|
154
|
+
const remote = parseProcNetAddress(columns[2]);
|
|
155
|
+
if (
|
|
156
|
+
local?.port === clientPort &&
|
|
157
|
+
remote?.port === serverPort &&
|
|
158
|
+
columns[9] !== '0'
|
|
159
|
+
) {
|
|
160
|
+
return columns[9];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseProcNetAddress(value: string): { port: number } | undefined {
|
|
168
|
+
const [, portHex] = value.split(':');
|
|
169
|
+
if (!portHex) return undefined;
|
|
170
|
+
return { port: Number.parseInt(portHex, 16) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function findProcessCmdlineForSocketInode({
|
|
174
|
+
inode,
|
|
175
|
+
}: {
|
|
176
|
+
inode: string;
|
|
177
|
+
}): Promise<string[] | undefined> {
|
|
178
|
+
const procEntries = await readdir('/proc', { withFileTypes: true }).catch(
|
|
179
|
+
() => [],
|
|
180
|
+
);
|
|
181
|
+
for (const entry of procEntries) {
|
|
182
|
+
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
|
183
|
+
const fdDir = `/proc/${entry.name}/fd`;
|
|
184
|
+
const fds = await readdir(fdDir).catch(() => []);
|
|
185
|
+
for (const fd of fds) {
|
|
186
|
+
const target = await readlink(`${fdDir}/${fd}`).catch(() => undefined);
|
|
187
|
+
if (target !== `socket:[${inode}]`) continue;
|
|
188
|
+
const cmdline = await readFile(`/proc/${entry.name}/cmdline`, 'utf8')
|
|
189
|
+
.then(value => value.split('\0').filter(Boolean))
|
|
190
|
+
.catch(() => undefined);
|
|
191
|
+
if (cmdline) return cmdline;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
@@ -17,7 +17,6 @@ export const outboundMessageSchema = harnessV1BridgeOutboundMessageSchema;
|
|
|
17
17
|
export type OutboundMessage = z.infer<typeof outboundMessageSchema>;
|
|
18
18
|
|
|
19
19
|
export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
|
|
20
|
-
instructions: z.string().optional(),
|
|
21
20
|
reasoningEffort: z.enum(['low', 'medium', 'high']).optional(),
|
|
22
21
|
webSearch: z.boolean().optional(),
|
|
23
22
|
// Resume signal. When supplied, the bridge calls
|