@ai-sdk/harness-codex 0.0.0-6b196531-20260710185421
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 +391 -0
- package/LICENSE +13 -0
- package/README.md +73 -0
- package/dist/bridge/host-tool-mcp.mjs +103 -0
- package/dist/bridge/host-tool-mcp.mjs.map +1 -0
- package/dist/bridge/index.mjs +1297 -0
- package/dist/bridge/index.mjs.map +1 -0
- package/dist/bridge/package.json +12 -0
- package/dist/bridge/pnpm-lock.yaml +879 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +899 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
- package/src/bridge/cli-relay.ts +198 -0
- package/src/bridge/codex-step-tracker.ts +83 -0
- package/src/bridge/host-tool-mcp.ts +160 -0
- package/src/bridge/index.ts +746 -0
- package/src/bridge/package.json +12 -0
- package/src/bridge/pnpm-lock.yaml +879 -0
- package/src/bridge/tool-relay-auth.ts +195 -0
- package/src/codex-auth.ts +119 -0
- package/src/codex-bridge-protocol.ts +38 -0
- package/src/codex-harness.ts +1132 -0
- package/src/index.ts +13 -0
- package/src/version.ts +6 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { getAiGatewayAuthFromEnv } from '@ai-sdk/harness/utils';
|
|
2
|
+
|
|
3
|
+
export type CodexAuthOptions = {
|
|
4
|
+
readonly openaiCompatible?: {
|
|
5
|
+
readonly apiKey?: string;
|
|
6
|
+
readonly baseUrl?: string;
|
|
7
|
+
readonly modelProviderName?: string;
|
|
8
|
+
readonly queryParamsJson?: string;
|
|
9
|
+
};
|
|
10
|
+
readonly openai?: {
|
|
11
|
+
readonly apiKey?: string;
|
|
12
|
+
readonly baseUrl?: string;
|
|
13
|
+
readonly organization?: string;
|
|
14
|
+
readonly project?: string;
|
|
15
|
+
};
|
|
16
|
+
readonly gateway?: {
|
|
17
|
+
readonly apiKey?: string;
|
|
18
|
+
readonly baseUrl?: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the environment-variable blob the codex bridge needs. Precedence:
|
|
24
|
+
*
|
|
25
|
+
* 1. Explicit `auth.openaiCompatible` — pin to a custom OpenAI-compatible endpoint.
|
|
26
|
+
* 2. Explicit `auth.openai` — pin to direct OpenAI auth.
|
|
27
|
+
* 3. Explicit `auth.gateway` — pin to Vercel AI Gateway.
|
|
28
|
+
* 4. Auto-detect from the host process env: gateway first
|
|
29
|
+
* (`AI_GATEWAY_API_KEY` / `VERCEL_OIDC_TOKEN`), then `CODEX_API_KEY` /
|
|
30
|
+
* `OPENAI_API_KEY`.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveCodexEnv(
|
|
33
|
+
auth: CodexAuthOptions | undefined,
|
|
34
|
+
processEnv: Record<string, string | undefined> = process.env,
|
|
35
|
+
): Record<string, string> {
|
|
36
|
+
if (auth?.openaiCompatible) {
|
|
37
|
+
return pickOpenAICompatible(auth.openaiCompatible, processEnv);
|
|
38
|
+
}
|
|
39
|
+
if (auth?.openai) {
|
|
40
|
+
return pickOpenAI({ explicit: auth.openai, processEnv });
|
|
41
|
+
}
|
|
42
|
+
const gatewayAuthFromEnv = getAiGatewayAuthFromEnv({
|
|
43
|
+
env: processEnv,
|
|
44
|
+
});
|
|
45
|
+
if (auth?.gateway) {
|
|
46
|
+
return pickGateway({
|
|
47
|
+
explicit: auth.gateway,
|
|
48
|
+
gatewayAuthFromEnv,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (gatewayAuthFromEnv.apiKey) {
|
|
52
|
+
return pickGateway({
|
|
53
|
+
explicit: {},
|
|
54
|
+
gatewayAuthFromEnv,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return pickOpenAI({ processEnv });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pickOpenAICompatible(
|
|
61
|
+
explicit: NonNullable<CodexAuthOptions['openaiCompatible']>,
|
|
62
|
+
processEnv: Record<string, string | undefined>,
|
|
63
|
+
): Record<string, string> {
|
|
64
|
+
const env: Record<string, string> = {};
|
|
65
|
+
const apiKey =
|
|
66
|
+
explicit.apiKey ?? processEnv.OPENAI_API_KEY ?? processEnv.CODEX_API_KEY;
|
|
67
|
+
if (apiKey) env.CODEX_API_KEY = apiKey;
|
|
68
|
+
if (explicit.baseUrl) env.OPENAI_BASE_URL = explicit.baseUrl;
|
|
69
|
+
if (explicit.modelProviderName)
|
|
70
|
+
env.CODEX_MODEL_PROVIDER_NAME = explicit.modelProviderName;
|
|
71
|
+
if (explicit.queryParamsJson)
|
|
72
|
+
env.OPENAI_QUERY_PARAMS_JSON = explicit.queryParamsJson;
|
|
73
|
+
return env;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pickOpenAI({
|
|
77
|
+
explicit,
|
|
78
|
+
processEnv,
|
|
79
|
+
}: {
|
|
80
|
+
explicit?: NonNullable<CodexAuthOptions['openai']>;
|
|
81
|
+
processEnv: Record<string, string | undefined>;
|
|
82
|
+
}): Record<string, string> {
|
|
83
|
+
const env: Record<string, string> = {};
|
|
84
|
+
const apiKey =
|
|
85
|
+
explicit?.apiKey ?? processEnv.OPENAI_API_KEY ?? processEnv.CODEX_API_KEY;
|
|
86
|
+
if (apiKey) env.CODEX_API_KEY = apiKey;
|
|
87
|
+
const baseUrl = explicit?.baseUrl ?? processEnv.OPENAI_BASE_URL;
|
|
88
|
+
if (baseUrl) env.OPENAI_BASE_URL = baseUrl;
|
|
89
|
+
const organization = explicit?.organization ?? processEnv.OPENAI_ORGANIZATION;
|
|
90
|
+
if (organization) env.OPENAI_ORGANIZATION = organization;
|
|
91
|
+
const project = explicit?.project ?? processEnv.OPENAI_PROJECT;
|
|
92
|
+
if (project) env.OPENAI_PROJECT = project;
|
|
93
|
+
return env;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pickGateway({
|
|
97
|
+
explicit,
|
|
98
|
+
gatewayAuthFromEnv,
|
|
99
|
+
}: {
|
|
100
|
+
explicit: NonNullable<CodexAuthOptions['gateway']>;
|
|
101
|
+
gatewayAuthFromEnv: ReturnType<typeof getAiGatewayAuthFromEnv>;
|
|
102
|
+
}): Record<string, string> {
|
|
103
|
+
const apiKey = explicit.apiKey ?? gatewayAuthFromEnv.apiKey;
|
|
104
|
+
const baseUrl = toCodexGatewayBaseUrl(
|
|
105
|
+
explicit.baseUrl ?? gatewayAuthFromEnv.baseUrl,
|
|
106
|
+
);
|
|
107
|
+
const env: Record<string, string> = {};
|
|
108
|
+
if (apiKey) {
|
|
109
|
+
env.AI_GATEWAY_API_KEY = apiKey;
|
|
110
|
+
env.CODEX_API_KEY = apiKey;
|
|
111
|
+
}
|
|
112
|
+
env.AI_GATEWAY_BASE_URL = baseUrl;
|
|
113
|
+
return env;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toCodexGatewayBaseUrl(baseUrl: string): string {
|
|
117
|
+
const trimmed = baseUrl.replace(/\/+$/, '');
|
|
118
|
+
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
|
|
119
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
harnessV1BridgeInboundCommandSchemas,
|
|
3
|
+
harnessV1BridgeOutboundMessageSchema,
|
|
4
|
+
harnessV1BridgeReadySchema,
|
|
5
|
+
harnessV1BridgeStartBaseSchema,
|
|
6
|
+
} from '@ai-sdk/harness';
|
|
7
|
+
import { z } from 'zod/v4';
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* Codex's bridge wire protocol. The outbound events (including `file-change`
|
|
11
|
+
* and the `bridge-thread` resume coordinate), transport frames, shared inbound
|
|
12
|
+
* commands, and `bridge-ready` line all come from the shared `@ai-sdk/harness`
|
|
13
|
+
* protocol — the only Codex-specific piece is the `start` payload.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const outboundMessageSchema = harnessV1BridgeOutboundMessageSchema;
|
|
17
|
+
export type OutboundMessage = z.infer<typeof outboundMessageSchema>;
|
|
18
|
+
|
|
19
|
+
export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
|
|
20
|
+
reasoningEffort: z.enum(['low', 'medium', 'high']).optional(),
|
|
21
|
+
webSearch: z.boolean().optional(),
|
|
22
|
+
// Resume signal. When supplied, the bridge calls
|
|
23
|
+
// `codex.resumeThread(resumeThreadId, …)` instead of starting a fresh thread.
|
|
24
|
+
// The host sources the id from lifecycle state `data` cached from a prior
|
|
25
|
+
// `agent.detach`.
|
|
26
|
+
resumeThreadId: z.string().optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type StartMessage = z.infer<typeof startMessageSchema>;
|
|
30
|
+
|
|
31
|
+
export const inboundMessageSchema = z.discriminatedUnion('type', [
|
|
32
|
+
startMessageSchema,
|
|
33
|
+
...harnessV1BridgeInboundCommandSchemas,
|
|
34
|
+
]);
|
|
35
|
+
export type InboundMessage = z.infer<typeof inboundMessageSchema>;
|
|
36
|
+
|
|
37
|
+
export const bridgeReadySchema = harnessV1BridgeReadySchema;
|
|
38
|
+
export type BridgeReady = z.infer<typeof bridgeReadySchema>;
|