@ai-sdk/harness-codex 1.0.11 → 1.0.13
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 +17 -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 +313 -28
- package/dist/bridge/index.mjs.map +1 -1
- package/dist/index.js +19 -78
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bridge/cli-relay.ts +122 -5
- package/src/bridge/host-tool-mcp.ts +7 -10
- package/src/bridge/index.ts +109 -45
- package/src/bridge/tool-relay-auth.ts +195 -0
- package/src/codex-harness.ts +19 -89
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,
|
|
@@ -24,8 +24,14 @@ import { createServer, type Server } from 'node:http';
|
|
|
24
24
|
import {
|
|
25
25
|
CLI_SHIM_FILENAME,
|
|
26
26
|
buildCliShimScript,
|
|
27
|
-
|
|
27
|
+
parseToolRelayCommand,
|
|
28
28
|
} from './cli-relay';
|
|
29
|
+
import {
|
|
30
|
+
ToolRelayAuthorizer,
|
|
31
|
+
ToolRelayPendingCalls,
|
|
32
|
+
isToolRelayRequestFromAllowedProcess,
|
|
33
|
+
type ToolRelayCall,
|
|
34
|
+
} from './tool-relay-auth';
|
|
29
35
|
import { argv, env as procEnv, stdout } from 'node:process';
|
|
30
36
|
|
|
31
37
|
/*
|
|
@@ -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);
|
|
@@ -119,12 +131,12 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
|
119
131
|
* implement the workaround and can be removed once the upstream bug is fixed.
|
|
120
132
|
*/
|
|
121
133
|
const mcpServers: Record<string, unknown> = {};
|
|
122
|
-
let relay:
|
|
134
|
+
let relay: ToolRelay | undefined;
|
|
123
135
|
let cliShimPath: string | undefined;
|
|
124
136
|
if (start.tools && start.tools.length > 0) {
|
|
125
|
-
|
|
137
|
+
cliShimPath = `${cliShimDir}/${CLI_SHIM_FILENAME}`;
|
|
126
138
|
relay = await startToolRelay({
|
|
127
|
-
|
|
139
|
+
allowedScriptPaths: [cliShimPath, `${bootstrapDir}/host-tool-mcp.mjs`],
|
|
128
140
|
tools: start.tools,
|
|
129
141
|
emit,
|
|
130
142
|
requestToolResult: turn.requestToolResult,
|
|
@@ -142,15 +154,13 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
|
142
154
|
})),
|
|
143
155
|
),
|
|
144
156
|
TOOL_RELAY_URL: `http://127.0.0.1:${relay.port}`,
|
|
145
|
-
TOOL_RELAY_TOKEN: relayToken,
|
|
146
157
|
},
|
|
147
158
|
};
|
|
148
159
|
// Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
|
|
149
|
-
cliShimPath = `${cliShimDir}/${CLI_SHIM_FILENAME}`;
|
|
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
|
}
|
|
@@ -232,12 +242,28 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
|
232
242
|
emit({ type: 'bridge-thread', threadId: event.thread_id });
|
|
233
243
|
}
|
|
234
244
|
// Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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);
|
|
241
267
|
continue;
|
|
242
268
|
}
|
|
243
269
|
translateAndEmit(event, {
|
|
@@ -321,6 +347,25 @@ type CodexEvent = {
|
|
|
321
347
|
thread_id?: string;
|
|
322
348
|
};
|
|
323
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
|
+
|
|
324
369
|
function translateAndEmit(
|
|
325
370
|
event: CodexEvent,
|
|
326
371
|
ctx: {
|
|
@@ -513,34 +558,32 @@ function defaultUsage(): Record<string, unknown> {
|
|
|
513
558
|
}
|
|
514
559
|
|
|
515
560
|
/**
|
|
516
|
-
* Tool relay — HTTP server on 127.0.0.1:0
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
* `{ 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 }`.
|
|
521
565
|
*/
|
|
522
566
|
async function startToolRelay({
|
|
523
|
-
|
|
567
|
+
allowedScriptPaths,
|
|
524
568
|
tools,
|
|
525
569
|
emit,
|
|
526
570
|
requestToolResult,
|
|
527
571
|
}: {
|
|
528
|
-
|
|
572
|
+
allowedScriptPaths: ReadonlyArray<string>;
|
|
529
573
|
tools: ReadonlyArray<{ name: string }>;
|
|
530
574
|
emit: Emit;
|
|
531
575
|
requestToolResult: (
|
|
532
576
|
toolCallId: string,
|
|
533
577
|
) => Promise<{ output: unknown; isError?: boolean }>;
|
|
534
|
-
}): Promise<
|
|
578
|
+
}): Promise<ToolRelay> {
|
|
535
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();
|
|
536
583
|
|
|
537
584
|
const server = createServer(async (req, res) => {
|
|
538
585
|
try {
|
|
539
|
-
if (
|
|
540
|
-
req.method !== 'POST' ||
|
|
541
|
-
req.url !== '/' ||
|
|
542
|
-
req.headers.authorization !== `Bearer ${relayToken}`
|
|
543
|
-
) {
|
|
586
|
+
if (req.method !== 'POST' || req.url !== '/') {
|
|
544
587
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
545
588
|
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
546
589
|
return;
|
|
@@ -563,23 +606,42 @@ async function startToolRelay({
|
|
|
563
606
|
);
|
|
564
607
|
return;
|
|
565
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
|
+
}
|
|
566
621
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
+
},
|
|
582
643
|
});
|
|
644
|
+
const { output } = await result;
|
|
583
645
|
|
|
584
646
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
585
647
|
res.end(JSON.stringify({ result: output }));
|
|
@@ -603,6 +665,8 @@ async function startToolRelay({
|
|
|
603
665
|
return {
|
|
604
666
|
port: address.port,
|
|
605
667
|
close: () => closeServer(server),
|
|
668
|
+
authorizeToolCall: call => authorizer.authorizeToolCall(call),
|
|
669
|
+
authorizeAnyToolCall: () => authorizer.authorizeAnyToolCall(),
|
|
606
670
|
};
|
|
607
671
|
}
|
|
608
672
|
|
|
@@ -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
|
+
}
|
package/src/codex-harness.ts
CHANGED
|
@@ -23,8 +23,11 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
classifyDiskLog,
|
|
25
25
|
markBridgeStarting,
|
|
26
|
+
resolveSandboxHomeDir,
|
|
26
27
|
SandboxChannel,
|
|
28
|
+
shellQuote,
|
|
27
29
|
waitForBridgeReady,
|
|
30
|
+
writeSkills as writeHarnessSkills,
|
|
28
31
|
} from '@ai-sdk/harness/utils';
|
|
29
32
|
import {
|
|
30
33
|
type Experimental_SandboxProcess,
|
|
@@ -192,6 +195,13 @@ export function createCodex(
|
|
|
192
195
|
return cachedBootstrap;
|
|
193
196
|
},
|
|
194
197
|
doStart: async startOpts => {
|
|
198
|
+
if (startOpts.builtinToolFiltering != null) {
|
|
199
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
200
|
+
message:
|
|
201
|
+
"Harness 'codex' does not support built-in tool filtering controls.",
|
|
202
|
+
harnessId: 'codex',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
195
205
|
if (
|
|
196
206
|
startOpts.permissionMode != null &&
|
|
197
207
|
startOpts.permissionMode !== 'allow-all'
|
|
@@ -316,7 +326,7 @@ export function createCodex(
|
|
|
316
326
|
const token = randomBytes(32).toString('hex');
|
|
317
327
|
const codexSkillSetup =
|
|
318
328
|
startOpts.skills && startOpts.skills.length > 0
|
|
319
|
-
? await
|
|
329
|
+
? await writeCodexSkills({
|
|
320
330
|
sandbox: session,
|
|
321
331
|
skills: startOpts.skills,
|
|
322
332
|
abortSignal: startOpts.abortSignal,
|
|
@@ -456,7 +466,7 @@ async function readBridgeAsset(name: string): Promise<string> {
|
|
|
456
466
|
throw lastErr ?? new Error(`bridge asset not found: ${name}`);
|
|
457
467
|
}
|
|
458
468
|
|
|
459
|
-
async function
|
|
469
|
+
async function writeCodexSkills({
|
|
460
470
|
sandbox,
|
|
461
471
|
skills,
|
|
462
472
|
abortSignal,
|
|
@@ -465,16 +475,6 @@ async function writeSkills({
|
|
|
465
475
|
skills: ReadonlyArray<HarnessV1Skill>;
|
|
466
476
|
abortSignal?: AbortSignal;
|
|
467
477
|
}): Promise<WriteSkillsResult> {
|
|
468
|
-
for (const skill of skills) {
|
|
469
|
-
safeCodexSkillName(skill.name);
|
|
470
|
-
for (const file of skill.files ?? []) {
|
|
471
|
-
safeCodexSkillFilePath({
|
|
472
|
-
skillName: skill.name,
|
|
473
|
-
filePath: file.path,
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
478
|
const homeDir = await resolveSandboxHomeDir({ sandbox, abortSignal });
|
|
479
479
|
const codexHomeDir = path.posix.join(homeDir, '.codex');
|
|
480
480
|
await sandbox.run({
|
|
@@ -483,92 +483,22 @@ async function writeSkills({
|
|
|
483
483
|
});
|
|
484
484
|
|
|
485
485
|
const rootDir = path.posix.join(homeDir, '.agents', 'skills');
|
|
486
|
-
await
|
|
487
|
-
|
|
486
|
+
await writeHarnessSkills({
|
|
487
|
+
sandbox,
|
|
488
|
+
rootDir,
|
|
489
|
+
skills,
|
|
488
490
|
abortSignal,
|
|
491
|
+
invalidSkillNameMessage: ({ name }) => `Invalid Codex skill name: ${name}`,
|
|
492
|
+
invalidSkillFilePathMessage: ({ skillName, filePath }) =>
|
|
493
|
+
`Invalid Codex skill file path for ${skillName}: ${filePath}`,
|
|
489
494
|
});
|
|
490
495
|
|
|
491
|
-
for (const skill of skills) {
|
|
492
|
-
const name = safeCodexSkillName(skill.name);
|
|
493
|
-
const skillDir = path.posix.join(rootDir, name);
|
|
494
|
-
const content = `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}`;
|
|
495
|
-
|
|
496
|
-
await sandbox.writeTextFile({
|
|
497
|
-
path: path.posix.join(skillDir, 'SKILL.md'),
|
|
498
|
-
content,
|
|
499
|
-
abortSignal,
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
for (const file of skill.files ?? []) {
|
|
503
|
-
const filePath = safeCodexSkillFilePath({
|
|
504
|
-
skillName: skill.name,
|
|
505
|
-
filePath: file.path,
|
|
506
|
-
});
|
|
507
|
-
await sandbox.writeTextFile({
|
|
508
|
-
path: path.posix.join(skillDir, filePath),
|
|
509
|
-
content: file.content,
|
|
510
|
-
abortSignal,
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
496
|
return {
|
|
516
497
|
homeDir,
|
|
517
498
|
codexHomeDir,
|
|
518
499
|
};
|
|
519
500
|
}
|
|
520
501
|
|
|
521
|
-
async function resolveSandboxHomeDir({
|
|
522
|
-
sandbox,
|
|
523
|
-
abortSignal,
|
|
524
|
-
}: {
|
|
525
|
-
sandbox: Experimental_SandboxSession;
|
|
526
|
-
abortSignal?: AbortSignal;
|
|
527
|
-
}): Promise<string> {
|
|
528
|
-
const result = await sandbox.run({
|
|
529
|
-
command: 'printf "%s" "$HOME"',
|
|
530
|
-
abortSignal,
|
|
531
|
-
});
|
|
532
|
-
const homeDir = result.stdout.trim();
|
|
533
|
-
if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
|
|
534
|
-
throw new Error(
|
|
535
|
-
`Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`,
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
return homeDir;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
function safeCodexSkillName(name: string): string {
|
|
542
|
-
if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
|
|
543
|
-
throw new Error(`Invalid Codex skill name: ${name}`);
|
|
544
|
-
}
|
|
545
|
-
return name;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function safeCodexSkillFilePath({
|
|
549
|
-
skillName,
|
|
550
|
-
filePath,
|
|
551
|
-
}: {
|
|
552
|
-
skillName: string;
|
|
553
|
-
filePath: string;
|
|
554
|
-
}): string {
|
|
555
|
-
const normalized = path.posix.normalize(filePath);
|
|
556
|
-
if (
|
|
557
|
-
normalized === '.' ||
|
|
558
|
-
normalized.startsWith('../') ||
|
|
559
|
-
path.posix.isAbsolute(normalized)
|
|
560
|
-
) {
|
|
561
|
-
throw new Error(
|
|
562
|
-
`Invalid Codex skill file path for ${skillName}: ${filePath}`,
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
return normalized;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function shellQuote(value: string): string {
|
|
569
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
502
|
async function forwardBridgeStderr(
|
|
573
503
|
stream: ReadableStream<Uint8Array>,
|
|
574
504
|
): Promise<void> {
|