@tumnel/codex 0.1.1
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/README.md +64 -0
- package/dist/cli.js +1911 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +56 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +1818 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1911 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Codex } from "@openai/codex-sdk";
|
|
5
|
+
|
|
6
|
+
// src/codex-adapter.ts
|
|
7
|
+
import { z as z2 } from "zod";
|
|
8
|
+
|
|
9
|
+
// src/codex-sessions.ts
|
|
10
|
+
import { createHash, randomBytes } from "crypto";
|
|
11
|
+
import { createReadStream } from "fs";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { readFile, readdir, stat } from "fs/promises";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
var sessionMetaSchema = z.object({
|
|
17
|
+
type: z.literal("session_meta"),
|
|
18
|
+
payload: z.object({
|
|
19
|
+
session_id: z.string().min(1).optional(),
|
|
20
|
+
id: z.string().min(1).optional(),
|
|
21
|
+
cwd: z.string().min(1).optional(),
|
|
22
|
+
timestamp: z.string().optional(),
|
|
23
|
+
cli_version: z.string().min(1).optional()
|
|
24
|
+
}).passthrough()
|
|
25
|
+
});
|
|
26
|
+
function defaultSessionsDir(environment = process.env) {
|
|
27
|
+
const root = environment.CODEX_HOME ?? join(homedir(), ".codex");
|
|
28
|
+
return join(root, "sessions");
|
|
29
|
+
}
|
|
30
|
+
function projectId(directory) {
|
|
31
|
+
const hash = createHash("sha256").update(directory.toLowerCase()).digest("base64url").slice(0, 12);
|
|
32
|
+
return `project_${hash}`;
|
|
33
|
+
}
|
|
34
|
+
function createSessionId() {
|
|
35
|
+
return `codex_${randomBytes(9).toString("base64url")}`;
|
|
36
|
+
}
|
|
37
|
+
function createMessageId(record) {
|
|
38
|
+
record.messageIndex += 1;
|
|
39
|
+
return `message_${record.messageIndex}`;
|
|
40
|
+
}
|
|
41
|
+
async function readFirstLine(path) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 64 * 1024 });
|
|
44
|
+
let buffer = "";
|
|
45
|
+
stream.on("data", (chunk) => {
|
|
46
|
+
buffer += chunk;
|
|
47
|
+
const newline = buffer.indexOf("\n");
|
|
48
|
+
if (newline !== -1) {
|
|
49
|
+
stream.destroy();
|
|
50
|
+
resolve(buffer.slice(0, newline));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (buffer.length > 512 * 1024) {
|
|
54
|
+
stream.destroy();
|
|
55
|
+
resolve(buffer);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
stream.on("error", () => resolve(null));
|
|
60
|
+
stream.on("end", () => resolve(buffer.length ? buffer : null));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async function readRolloutTitle(path, fallback) {
|
|
64
|
+
let source;
|
|
65
|
+
try {
|
|
66
|
+
source = await readFile(path, "utf8");
|
|
67
|
+
} catch {
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
let title = fallback;
|
|
71
|
+
for (const line of source.split(/\r?\n/)) {
|
|
72
|
+
if (!line.trim()) continue;
|
|
73
|
+
try {
|
|
74
|
+
const outer = JSON.parse(line);
|
|
75
|
+
const payload = asRecord(outer.payload);
|
|
76
|
+
if (stringValue(payload?.type) !== "thread_name_updated") continue;
|
|
77
|
+
title = stringValue(payload?.thread_name) ?? title;
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return title;
|
|
82
|
+
}
|
|
83
|
+
async function walkRollouts(dir) {
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const results = [];
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const path = join(dir, entry.name);
|
|
93
|
+
if (entry.isDirectory()) results.push(...await walkRollouts(path));
|
|
94
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) results.push(path);
|
|
95
|
+
}
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
98
|
+
function asRecord(value) {
|
|
99
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
100
|
+
}
|
|
101
|
+
function stringValue(value) {
|
|
102
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
103
|
+
}
|
|
104
|
+
function timestampValue(value, fallback) {
|
|
105
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
106
|
+
if (typeof value === "string") {
|
|
107
|
+
const parsed = Date.parse(value);
|
|
108
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
109
|
+
}
|
|
110
|
+
return fallback;
|
|
111
|
+
}
|
|
112
|
+
function textFromValue(value) {
|
|
113
|
+
if (typeof value === "string") return value;
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
const text = value.map((item) => textFromValue(item)).filter((item) => item !== null).join("");
|
|
116
|
+
return text || null;
|
|
117
|
+
}
|
|
118
|
+
const record = asRecord(value);
|
|
119
|
+
if (!record) return null;
|
|
120
|
+
for (const key of ["text", "message", "content", "output_text"]) {
|
|
121
|
+
const text = textFromValue(record[key]);
|
|
122
|
+
if (text !== null) return text;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
function parseJsonValue(value) {
|
|
127
|
+
if (typeof value !== "string") return value;
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(value);
|
|
130
|
+
} catch {
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function historyMessageId(sessionId, index) {
|
|
135
|
+
return `history_${sessionId}_${index}`;
|
|
136
|
+
}
|
|
137
|
+
function historyPartId(sessionId, index, partIndex) {
|
|
138
|
+
return `history_${sessionId}_${index}_${partIndex}`;
|
|
139
|
+
}
|
|
140
|
+
function createHistoryMessage(sessionId, index, role, created) {
|
|
141
|
+
return {
|
|
142
|
+
info: {
|
|
143
|
+
id: historyMessageId(sessionId, index),
|
|
144
|
+
sessionID: sessionId,
|
|
145
|
+
role,
|
|
146
|
+
time: { created, completed: role === "assistant" ? created : void 0 }
|
|
147
|
+
},
|
|
148
|
+
parts: []
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function addHistoryText(message, sessionId, partIndex, type, text) {
|
|
152
|
+
if (!text.trim()) return;
|
|
153
|
+
message.parts.push({
|
|
154
|
+
id: historyPartId(sessionId, Number(message.info.id.split("_").at(-1) ?? 0), partIndex),
|
|
155
|
+
sessionID: sessionId,
|
|
156
|
+
messageID: message.info.id,
|
|
157
|
+
type,
|
|
158
|
+
text
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function localFileUrl(value) {
|
|
162
|
+
const path = stringValue(value);
|
|
163
|
+
if (!path) return null;
|
|
164
|
+
return path.startsWith("file://") ? path : `file://${path}`;
|
|
165
|
+
}
|
|
166
|
+
async function readRolloutHistory(path, sessionId) {
|
|
167
|
+
let source;
|
|
168
|
+
let updated = 0;
|
|
169
|
+
try {
|
|
170
|
+
source = await readFile(path, "utf8");
|
|
171
|
+
updated = (await stat(path)).mtimeMs;
|
|
172
|
+
} catch {
|
|
173
|
+
return { messages: [], title: "(untitled session)", updated };
|
|
174
|
+
}
|
|
175
|
+
const messages = [];
|
|
176
|
+
const toolParts = /* @__PURE__ */ new Map();
|
|
177
|
+
let title = "(untitled session)";
|
|
178
|
+
let messageIndex = 0;
|
|
179
|
+
let lastAssistant = null;
|
|
180
|
+
const ensureAssistant = (created) => {
|
|
181
|
+
if (lastAssistant) return lastAssistant;
|
|
182
|
+
const message = createHistoryMessage(sessionId, messageIndex++, "assistant", created);
|
|
183
|
+
messages.push(message);
|
|
184
|
+
lastAssistant = message;
|
|
185
|
+
return message;
|
|
186
|
+
};
|
|
187
|
+
for (const line of source.split(/\r?\n/)) {
|
|
188
|
+
if (!line.trim()) continue;
|
|
189
|
+
let outer;
|
|
190
|
+
try {
|
|
191
|
+
outer = JSON.parse(line);
|
|
192
|
+
} catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const payload = asRecord(outer.payload);
|
|
196
|
+
if (!payload) continue;
|
|
197
|
+
const type = stringValue(payload.type);
|
|
198
|
+
const created = timestampValue(outer.timestamp, updated || Date.now());
|
|
199
|
+
if (type === "user_message") {
|
|
200
|
+
const text = stringValue(payload.message);
|
|
201
|
+
const message = createHistoryMessage(sessionId, messageIndex++, "user", created);
|
|
202
|
+
if (text) addHistoryText(message, sessionId, 0, "text", text);
|
|
203
|
+
const localImages = Array.isArray(payload.local_images) ? payload.local_images : [];
|
|
204
|
+
for (const [index, image] of localImages.entries()) {
|
|
205
|
+
const url = localFileUrl(image);
|
|
206
|
+
if (!url) continue;
|
|
207
|
+
message.parts.push({
|
|
208
|
+
id: historyPartId(sessionId, Number(message.info.id.split("_").at(-1) ?? 0), index + 1),
|
|
209
|
+
sessionID: sessionId,
|
|
210
|
+
messageID: message.info.id,
|
|
211
|
+
type: "file",
|
|
212
|
+
url
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (message.parts.length) messages.push(message);
|
|
216
|
+
lastAssistant = null;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (type === "agent_message") {
|
|
220
|
+
const text = stringValue(payload.message);
|
|
221
|
+
if (!text) continue;
|
|
222
|
+
const message = createHistoryMessage(sessionId, messageIndex++, "assistant", created);
|
|
223
|
+
addHistoryText(message, sessionId, Number(message.info.id.split("_").at(-1) ?? 0), "text", text);
|
|
224
|
+
messages.push(message);
|
|
225
|
+
lastAssistant = message;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (type === "reasoning") {
|
|
229
|
+
const text = textFromValue(payload.summary) ?? textFromValue(payload.content);
|
|
230
|
+
if (!text) continue;
|
|
231
|
+
const message = ensureAssistant(created);
|
|
232
|
+
addHistoryText(message, sessionId, message.parts.length, "reasoning", text);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (type === "thread_name_updated") {
|
|
236
|
+
title = stringValue(payload.thread_name) ?? title;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (type === "function_call" || type === "custom_tool_call") {
|
|
240
|
+
const message = ensureAssistant(created);
|
|
241
|
+
const callId = stringValue(payload.call_id) ?? `${sessionId}:${message.parts.length}`;
|
|
242
|
+
const name = stringValue(payload.name) ?? (type === "function_call" ? "function" : "tool");
|
|
243
|
+
const input = parseJsonValue(payload.arguments ?? payload.input);
|
|
244
|
+
const part = {
|
|
245
|
+
id: `history_${callId}`,
|
|
246
|
+
sessionID: sessionId,
|
|
247
|
+
messageID: message.info.id,
|
|
248
|
+
type: "tool",
|
|
249
|
+
tool: name,
|
|
250
|
+
state: {
|
|
251
|
+
status: "running",
|
|
252
|
+
title: name,
|
|
253
|
+
input: { arguments: input }
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
message.parts.push(part);
|
|
257
|
+
toolParts.set(callId, part);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (type === "function_call_output" || type === "custom_tool_call_output") {
|
|
261
|
+
const callId = stringValue(payload.call_id);
|
|
262
|
+
if (!callId) continue;
|
|
263
|
+
const part = toolParts.get(callId);
|
|
264
|
+
if (!part) continue;
|
|
265
|
+
const state = asRecord(part.state) ?? {};
|
|
266
|
+
part.state = {
|
|
267
|
+
...state,
|
|
268
|
+
status: "completed",
|
|
269
|
+
output: payload.output ?? payload.result ?? null
|
|
270
|
+
};
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (type === "exec_command_end") {
|
|
274
|
+
const callId = stringValue(payload.call_id);
|
|
275
|
+
const part = callId ? toolParts.get(callId) : void 0;
|
|
276
|
+
if (!part) continue;
|
|
277
|
+
const state = asRecord(part.state) ?? {};
|
|
278
|
+
part.state = {
|
|
279
|
+
...state,
|
|
280
|
+
status: payload.exit_code === 0 ? "completed" : "error",
|
|
281
|
+
...payload.aggregated_output !== void 0 ? { output: payload.aggregated_output } : {}
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
for (const message of messages) {
|
|
286
|
+
if (message.info.role === "assistant") {
|
|
287
|
+
message.info.time.completed ??= updated || message.info.time.created;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { messages, title, updated };
|
|
291
|
+
}
|
|
292
|
+
async function discoverSessions(sessionsDir) {
|
|
293
|
+
const rollouts = await walkRollouts(sessionsDir);
|
|
294
|
+
const sessions = [];
|
|
295
|
+
for (const path of rollouts) {
|
|
296
|
+
const line = await readFirstLine(path);
|
|
297
|
+
if (!line) continue;
|
|
298
|
+
let parsed;
|
|
299
|
+
try {
|
|
300
|
+
parsed = JSON.parse(line);
|
|
301
|
+
} catch {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const result = sessionMetaSchema.safeParse(parsed);
|
|
305
|
+
if (!result.success) continue;
|
|
306
|
+
const meta = result.data.payload;
|
|
307
|
+
const id = meta.session_id ?? meta.id;
|
|
308
|
+
if (!id || !meta.cwd) continue;
|
|
309
|
+
const created = meta.timestamp ? Date.parse(meta.timestamp) : NaN;
|
|
310
|
+
let mtime = created;
|
|
311
|
+
try {
|
|
312
|
+
const info = await stat(path);
|
|
313
|
+
mtime = info.mtimeMs;
|
|
314
|
+
} catch {
|
|
315
|
+
mtime = Number.isFinite(created) ? created : 0;
|
|
316
|
+
}
|
|
317
|
+
sessions.push({
|
|
318
|
+
id,
|
|
319
|
+
directory: meta.cwd,
|
|
320
|
+
version: meta.cli_version ?? "unknown",
|
|
321
|
+
created: Number.isFinite(created) ? created : mtime,
|
|
322
|
+
updated: mtime,
|
|
323
|
+
title: await readRolloutTitle(path, "(untitled session)"),
|
|
324
|
+
rolloutPath: path
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return sessions;
|
|
328
|
+
}
|
|
329
|
+
var CodexSessionStore = class {
|
|
330
|
+
constructor(sessionsDir = defaultSessionsDir()) {
|
|
331
|
+
this.sessionsDir = sessionsDir;
|
|
332
|
+
}
|
|
333
|
+
sessionsDir;
|
|
334
|
+
records = /* @__PURE__ */ new Map();
|
|
335
|
+
discoveryCache = {
|
|
336
|
+
at: 0,
|
|
337
|
+
byDirectory: /* @__PURE__ */ new Map()
|
|
338
|
+
};
|
|
339
|
+
create(input) {
|
|
340
|
+
const now = Date.now();
|
|
341
|
+
const record = {
|
|
342
|
+
id: input.id,
|
|
343
|
+
projectID: projectId(input.directory),
|
|
344
|
+
directory: input.directory,
|
|
345
|
+
title: input.title ?? "(untitled session)",
|
|
346
|
+
version: input.version,
|
|
347
|
+
created: now,
|
|
348
|
+
updated: now,
|
|
349
|
+
thread: input.thread ?? null,
|
|
350
|
+
codexThreadId: input.codexThreadId ?? null,
|
|
351
|
+
model: input.model,
|
|
352
|
+
busy: false,
|
|
353
|
+
failed: false,
|
|
354
|
+
messages: input.messages ?? [],
|
|
355
|
+
messageIndex: input.messages?.length ?? 0
|
|
356
|
+
};
|
|
357
|
+
this.records.set(record.id, record);
|
|
358
|
+
return record;
|
|
359
|
+
}
|
|
360
|
+
get(id) {
|
|
361
|
+
return this.records.get(id);
|
|
362
|
+
}
|
|
363
|
+
has(id) {
|
|
364
|
+
return this.records.has(id);
|
|
365
|
+
}
|
|
366
|
+
list(directory) {
|
|
367
|
+
const all = [...this.records.values()];
|
|
368
|
+
return directory ? all.filter((record) => samePath(record.directory, directory)) : all;
|
|
369
|
+
}
|
|
370
|
+
remove(id) {
|
|
371
|
+
return this.records.delete(id);
|
|
372
|
+
}
|
|
373
|
+
touch(id) {
|
|
374
|
+
const record = this.records.get(id);
|
|
375
|
+
if (record) record.updated = Date.now();
|
|
376
|
+
}
|
|
377
|
+
async refreshDiscovery() {
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
if (this.discoveryCache.at !== 0 && now - this.discoveryCache.at <= 3e4) return;
|
|
380
|
+
const discovered = await discoverSessions(this.sessionsDir);
|
|
381
|
+
const byDirectory = /* @__PURE__ */ new Map();
|
|
382
|
+
for (const session of discovered) {
|
|
383
|
+
const list = byDirectory.get(session.directory) ?? [];
|
|
384
|
+
list.push(session);
|
|
385
|
+
byDirectory.set(session.directory, list);
|
|
386
|
+
}
|
|
387
|
+
this.discoveryCache.at = now;
|
|
388
|
+
this.discoveryCache.byDirectory = byDirectory;
|
|
389
|
+
}
|
|
390
|
+
async discoverAll(directory) {
|
|
391
|
+
await this.refreshDiscovery();
|
|
392
|
+
return directory ? [...this.discoveryCache.byDirectory.entries()].filter(([dir]) => samePath(dir, directory)).flatMap(([, sessions]) => sessions) : [...this.discoveryCache.byDirectory.values()].flat();
|
|
393
|
+
}
|
|
394
|
+
async discover(directory) {
|
|
395
|
+
const matching = await this.discoverAll(directory);
|
|
396
|
+
const liveThreadIds = new Set(
|
|
397
|
+
[...this.records.values()].map((record) => record.codexThreadId).filter((id) => Boolean(id))
|
|
398
|
+
);
|
|
399
|
+
return matching.filter((session) => !liveThreadIds.has(session.id));
|
|
400
|
+
}
|
|
401
|
+
async loadHistory(sessionId, directory) {
|
|
402
|
+
const discovered = (await this.discoverAll(directory)).find((session) => session.id === sessionId);
|
|
403
|
+
if (!discovered) return null;
|
|
404
|
+
return readRolloutHistory(discovered.rolloutPath, discovered.id);
|
|
405
|
+
}
|
|
406
|
+
directories() {
|
|
407
|
+
const directories = new Set(this.list().map((record) => record.directory));
|
|
408
|
+
for (const session of this.discoveryCache.byDirectory.values()) {
|
|
409
|
+
for (const record of session) directories.add(record.directory);
|
|
410
|
+
}
|
|
411
|
+
return [...directories];
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
function samePath(left, right) {
|
|
415
|
+
if (process.platform === "win32") return left.toLowerCase() === right.toLowerCase();
|
|
416
|
+
return left === right;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/safe-json.ts
|
|
420
|
+
var sensitiveKey = /(?:api[-_]?key|access[-_]?token|refresh[-_]?token|authorization|credential|password|private[-_]?key|secret)/i;
|
|
421
|
+
function toSafeJson(value) {
|
|
422
|
+
return visit(value, /* @__PURE__ */ new WeakSet(), 0);
|
|
423
|
+
}
|
|
424
|
+
function visit(value, seen, depth) {
|
|
425
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
426
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
|
|
427
|
+
if (typeof value === "bigint") return value.toString();
|
|
428
|
+
if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
if (depth >= 30) return "[Maximum depth reached]";
|
|
432
|
+
if (value instanceof Date) return value.toISOString();
|
|
433
|
+
if (value instanceof Error) {
|
|
434
|
+
return { name: value.name, message: value.message };
|
|
435
|
+
}
|
|
436
|
+
if (ArrayBuffer.isView(value)) {
|
|
437
|
+
return `[${value.constructor.name} omitted]`;
|
|
438
|
+
}
|
|
439
|
+
if (value instanceof ArrayBuffer) return "[ArrayBuffer omitted]";
|
|
440
|
+
if (typeof value !== "object") return String(value);
|
|
441
|
+
if (seen.has(value)) return "[Circular]";
|
|
442
|
+
seen.add(value);
|
|
443
|
+
if (Array.isArray(value)) {
|
|
444
|
+
const result2 = value.map((entry) => visit(entry, seen, depth + 1));
|
|
445
|
+
seen.delete(value);
|
|
446
|
+
return result2;
|
|
447
|
+
}
|
|
448
|
+
const result = {};
|
|
449
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
450
|
+
if (sensitiveKey.test(key)) {
|
|
451
|
+
result[key] = "[REDACTED]";
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (typeof entry === "undefined" || typeof entry === "function" || typeof entry === "symbol") continue;
|
|
455
|
+
result[key] = visit(entry, seen, depth + 1);
|
|
456
|
+
}
|
|
457
|
+
seen.delete(value);
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// src/codex-adapter.ts
|
|
462
|
+
var directorySchema = z2.string().min(1).max(4096).optional();
|
|
463
|
+
var emptyParamsSchema = z2.object({}).strict();
|
|
464
|
+
var projectListSchema = z2.object({ directory: z2.string().max(4096).optional() }).strict();
|
|
465
|
+
var sessionListSchema = z2.object({ directory: directorySchema }).strict();
|
|
466
|
+
var sessionCreateSchema = z2.object({
|
|
467
|
+
directory: directorySchema,
|
|
468
|
+
parentID: z2.string().min(1).max(256).optional(),
|
|
469
|
+
title: z2.string().min(1).max(512).optional()
|
|
470
|
+
}).strict();
|
|
471
|
+
var sessionForkSchema = z2.object({
|
|
472
|
+
sessionId: z2.string().min(1).max(256),
|
|
473
|
+
directory: directorySchema,
|
|
474
|
+
messageID: z2.string().min(1).max(256).optional(),
|
|
475
|
+
title: z2.string().min(1).max(512).optional()
|
|
476
|
+
}).strict();
|
|
477
|
+
var sessionDeleteSchema = z2.object({
|
|
478
|
+
sessionId: z2.string().min(1).max(256),
|
|
479
|
+
directory: directorySchema
|
|
480
|
+
}).strict();
|
|
481
|
+
var sessionStatusSchema = z2.object({ directory: directorySchema }).strict();
|
|
482
|
+
var sessionMessagesSchema = z2.object({
|
|
483
|
+
sessionId: z2.string().min(1).max(256),
|
|
484
|
+
directory: directorySchema,
|
|
485
|
+
limit: z2.number().int().positive().max(1e3).optional()
|
|
486
|
+
}).strict();
|
|
487
|
+
var textPartSchema = z2.object({
|
|
488
|
+
type: z2.literal("text"),
|
|
489
|
+
text: z2.string().max(1e6)
|
|
490
|
+
}).strict();
|
|
491
|
+
var filePartSchema = z2.object({
|
|
492
|
+
type: z2.literal("file"),
|
|
493
|
+
mime: z2.string().min(1).max(256),
|
|
494
|
+
filename: z2.string().min(1).max(1024).optional(),
|
|
495
|
+
url: z2.string().min(1).max(8e6)
|
|
496
|
+
}).strict();
|
|
497
|
+
var sessionPromptSchema = z2.object({
|
|
498
|
+
sessionId: z2.string().min(1).max(256),
|
|
499
|
+
directory: directorySchema,
|
|
500
|
+
messageID: z2.string().min(1).max(256).optional(),
|
|
501
|
+
model: z2.object({
|
|
502
|
+
providerID: z2.string().min(1).max(128),
|
|
503
|
+
modelID: z2.string().min(1).max(256)
|
|
504
|
+
}).strict().optional(),
|
|
505
|
+
agent: z2.string().min(1).max(128).optional(),
|
|
506
|
+
variant: z2.string().min(1).max(128).optional(),
|
|
507
|
+
noReply: z2.boolean().optional(),
|
|
508
|
+
system: z2.string().max(1e5).optional(),
|
|
509
|
+
tools: z2.record(z2.string().min(1).max(128), z2.boolean()).optional(),
|
|
510
|
+
parts: z2.array(z2.union([textPartSchema, filePartSchema])).min(1).max(100)
|
|
511
|
+
}).strict();
|
|
512
|
+
var sessionAbortSchema = z2.object({
|
|
513
|
+
sessionId: z2.string().min(1).max(256),
|
|
514
|
+
directory: directorySchema
|
|
515
|
+
}).strict();
|
|
516
|
+
var permissionReplySchema = z2.object({
|
|
517
|
+
sessionId: z2.string().min(1).max(256),
|
|
518
|
+
permissionId: z2.string().min(1).max(256),
|
|
519
|
+
directory: directorySchema,
|
|
520
|
+
response: z2.enum(["once", "always", "reject"])
|
|
521
|
+
}).strict();
|
|
522
|
+
var providerListSchema = z2.object({ directory: directorySchema }).strict();
|
|
523
|
+
var agentListSchema = z2.object({ directory: directorySchema }).strict();
|
|
524
|
+
var permissionListSchema = z2.object({
|
|
525
|
+
directory: directorySchema,
|
|
526
|
+
sessionId: z2.string().min(1).max(256).optional()
|
|
527
|
+
}).strict();
|
|
528
|
+
var questionListSchema = z2.object({
|
|
529
|
+
directory: directorySchema,
|
|
530
|
+
sessionId: z2.string().min(1).max(256).optional()
|
|
531
|
+
}).strict();
|
|
532
|
+
var questionReplySchema = z2.object({
|
|
533
|
+
requestId: z2.string().min(1).max(256),
|
|
534
|
+
directory: directorySchema,
|
|
535
|
+
answers: z2.array(z2.array(z2.string().trim().min(1).max(8e3)).min(1).max(32)).min(1).max(32)
|
|
536
|
+
}).strict();
|
|
537
|
+
var questionRejectSchema = z2.object({
|
|
538
|
+
requestId: z2.string().min(1).max(256),
|
|
539
|
+
directory: directorySchema
|
|
540
|
+
}).strict();
|
|
541
|
+
var EventBus = class {
|
|
542
|
+
queue = [];
|
|
543
|
+
wake = null;
|
|
544
|
+
push(event) {
|
|
545
|
+
this.queue.push(event);
|
|
546
|
+
this.wake?.();
|
|
547
|
+
this.wake = null;
|
|
548
|
+
}
|
|
549
|
+
async *stream(signal) {
|
|
550
|
+
while (!signal.aborted) {
|
|
551
|
+
const next = this.queue.shift();
|
|
552
|
+
if (next !== void 0) {
|
|
553
|
+
yield toSafeJson(next);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
await new Promise((resolve) => {
|
|
557
|
+
const done = () => {
|
|
558
|
+
signal.removeEventListener("abort", done);
|
|
559
|
+
this.wake = null;
|
|
560
|
+
resolve();
|
|
561
|
+
};
|
|
562
|
+
signal.addEventListener("abort", done, { once: true });
|
|
563
|
+
this.wake = done;
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
function errorMessage(error) {
|
|
569
|
+
const message = error instanceof Error ? error.message : "Codex request failed";
|
|
570
|
+
return message.slice(0, 512);
|
|
571
|
+
}
|
|
572
|
+
function sessionView(record) {
|
|
573
|
+
return {
|
|
574
|
+
id: record.id,
|
|
575
|
+
projectID: record.projectID,
|
|
576
|
+
directory: record.directory,
|
|
577
|
+
title: record.title,
|
|
578
|
+
version: record.version,
|
|
579
|
+
time: { created: record.created, updated: record.updated }
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function discoveredSessionView(session) {
|
|
583
|
+
return {
|
|
584
|
+
id: session.id,
|
|
585
|
+
projectID: projectId(session.directory),
|
|
586
|
+
directory: session.directory,
|
|
587
|
+
title: session.title,
|
|
588
|
+
version: session.version,
|
|
589
|
+
time: { created: session.created, updated: session.updated }
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function buildUserMessage(record, parts, now = Date.now()) {
|
|
593
|
+
const info = {
|
|
594
|
+
id: createMessageId(record),
|
|
595
|
+
sessionID: record.id,
|
|
596
|
+
role: "user",
|
|
597
|
+
time: { created: now }
|
|
598
|
+
};
|
|
599
|
+
const messageParts = [];
|
|
600
|
+
for (const [index, part] of parts.entries()) {
|
|
601
|
+
if (part.type === "text") {
|
|
602
|
+
messageParts.push({
|
|
603
|
+
id: `${info.id}_${index}`,
|
|
604
|
+
sessionID: record.id,
|
|
605
|
+
messageID: info.id,
|
|
606
|
+
type: "text",
|
|
607
|
+
text: part.text
|
|
608
|
+
});
|
|
609
|
+
} else {
|
|
610
|
+
const file = {
|
|
611
|
+
id: `${info.id}_${index}`,
|
|
612
|
+
sessionID: record.id,
|
|
613
|
+
messageID: info.id,
|
|
614
|
+
type: "file",
|
|
615
|
+
mime: part.mime,
|
|
616
|
+
url: part.url
|
|
617
|
+
};
|
|
618
|
+
if (part.filename) file.filename = part.filename;
|
|
619
|
+
messageParts.push(file);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return { info, parts: messageParts };
|
|
623
|
+
}
|
|
624
|
+
function createAssistantMessage(record, now = Date.now()) {
|
|
625
|
+
const info = {
|
|
626
|
+
id: createMessageId(record),
|
|
627
|
+
sessionID: record.id,
|
|
628
|
+
role: "assistant",
|
|
629
|
+
time: { created: now }
|
|
630
|
+
};
|
|
631
|
+
return { info, parts: [] };
|
|
632
|
+
}
|
|
633
|
+
function activeAssistantMessage(record) {
|
|
634
|
+
const last = record.messages[record.messages.length - 1];
|
|
635
|
+
return last && last.info.role === "assistant" ? last : null;
|
|
636
|
+
}
|
|
637
|
+
function upsertStreamedPart(message, part, lifecycle, now = Date.now()) {
|
|
638
|
+
const index = message.parts.findIndex((existing2) => existing2.id === part.id);
|
|
639
|
+
const existing = index === -1 ? void 0 : message.parts[index];
|
|
640
|
+
if (part.type === "reasoning") {
|
|
641
|
+
const previousTime = existing?.time && typeof existing.time === "object" ? existing.time : null;
|
|
642
|
+
const started = typeof previousTime?.start === "number" ? previousTime.start : now;
|
|
643
|
+
part.time = lifecycle === "completed" ? { start: started, end: now } : { start: started };
|
|
644
|
+
}
|
|
645
|
+
if (index === -1) message.parts.push(part);
|
|
646
|
+
else message.parts[index] = part;
|
|
647
|
+
return part;
|
|
648
|
+
}
|
|
649
|
+
function itemPart(record, item) {
|
|
650
|
+
const message = activeAssistantMessage(record);
|
|
651
|
+
if (!message) return null;
|
|
652
|
+
const base = {
|
|
653
|
+
id: item.id,
|
|
654
|
+
sessionID: record.id,
|
|
655
|
+
messageID: message.info.id
|
|
656
|
+
};
|
|
657
|
+
switch (item.type) {
|
|
658
|
+
case "agent_message":
|
|
659
|
+
return { ...base, type: "text", text: item.text };
|
|
660
|
+
case "reasoning":
|
|
661
|
+
return { ...base, type: "reasoning", text: item.text };
|
|
662
|
+
case "command_execution":
|
|
663
|
+
return {
|
|
664
|
+
...base,
|
|
665
|
+
type: "tool",
|
|
666
|
+
tool: "bash",
|
|
667
|
+
state: {
|
|
668
|
+
status: item.status === "in_progress" ? "running" : item.status === "failed" ? "error" : "completed",
|
|
669
|
+
title: item.command.slice(0, 512),
|
|
670
|
+
input: { command: item.command },
|
|
671
|
+
...item.aggregated_output ? { output: item.aggregated_output } : {}
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
case "file_change":
|
|
675
|
+
return {
|
|
676
|
+
...base,
|
|
677
|
+
type: "tool",
|
|
678
|
+
tool: "edit",
|
|
679
|
+
state: {
|
|
680
|
+
status: item.status === "failed" ? "error" : "completed",
|
|
681
|
+
title: item.changes.length === 1 ? `Edited ${item.changes[0].path}` : `Edited ${item.changes.length} files`,
|
|
682
|
+
input: { changes: item.changes }
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
case "mcp_tool_call":
|
|
686
|
+
return {
|
|
687
|
+
...base,
|
|
688
|
+
type: "tool",
|
|
689
|
+
tool: item.tool,
|
|
690
|
+
state: {
|
|
691
|
+
status: item.status === "in_progress" ? "running" : item.status === "failed" ? "error" : "completed",
|
|
692
|
+
title: `${item.server}:${item.tool}`,
|
|
693
|
+
input: { arguments: item.arguments },
|
|
694
|
+
...item.error ? { error: item.error.message } : {}
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
case "web_search":
|
|
698
|
+
return {
|
|
699
|
+
...base,
|
|
700
|
+
type: "tool",
|
|
701
|
+
tool: "web_search",
|
|
702
|
+
state: {
|
|
703
|
+
status: "completed",
|
|
704
|
+
title: "Web search",
|
|
705
|
+
input: { query: item.query }
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
case "todo_list": {
|
|
709
|
+
const completed = item.items.filter((todo) => todo.completed).length;
|
|
710
|
+
return {
|
|
711
|
+
...base,
|
|
712
|
+
type: "tool",
|
|
713
|
+
tool: "todo_list",
|
|
714
|
+
state: {
|
|
715
|
+
status: completed === item.items.length ? "completed" : "running",
|
|
716
|
+
title: item.items.length > 0 ? `Tasks ${completed}/${item.items.length}` : "Tasks",
|
|
717
|
+
input: { items: item.items }
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
case "error":
|
|
722
|
+
return {
|
|
723
|
+
...base,
|
|
724
|
+
type: "tool",
|
|
725
|
+
tool: "codex_error",
|
|
726
|
+
state: {
|
|
727
|
+
status: "error",
|
|
728
|
+
title: "Codex error",
|
|
729
|
+
input: {},
|
|
730
|
+
error: item.message
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function toUserInput(parts) {
|
|
736
|
+
const input = [];
|
|
737
|
+
for (const part of parts) {
|
|
738
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.length) {
|
|
739
|
+
input.push({ type: "text", text: part.text });
|
|
740
|
+
} else if (part.type === "file" && typeof part.url === "string") {
|
|
741
|
+
const path = part.url.replace(/^file:\/\//, "");
|
|
742
|
+
if (path) input.push({ type: "local_image", path });
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return input;
|
|
746
|
+
}
|
|
747
|
+
function threadOptions(config, directory, model) {
|
|
748
|
+
return {
|
|
749
|
+
workingDirectory: directory,
|
|
750
|
+
model: model ?? config.model,
|
|
751
|
+
sandboxMode: config.sandboxMode,
|
|
752
|
+
approvalPolicy: config.approvalPolicy,
|
|
753
|
+
modelReasoningEffort: config.modelReasoningEffort,
|
|
754
|
+
webSearchMode: config.webSearchMode,
|
|
755
|
+
skipGitRepoCheck: config.skipGitRepoCheck,
|
|
756
|
+
networkAccessEnabled: config.networkAccess
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
function ensureThread(codex, record, config, model) {
|
|
760
|
+
const selectedModel = model ?? record.model ?? config.model;
|
|
761
|
+
if (record.thread && record.model === selectedModel) return record.thread;
|
|
762
|
+
record.model = selectedModel;
|
|
763
|
+
if (record.codexThreadId) {
|
|
764
|
+
record.thread = codex.resumeThread(
|
|
765
|
+
record.codexThreadId,
|
|
766
|
+
threadOptions(config, record.directory, selectedModel)
|
|
767
|
+
);
|
|
768
|
+
} else {
|
|
769
|
+
record.thread = codex.startThread(threadOptions(config, record.directory, selectedModel));
|
|
770
|
+
}
|
|
771
|
+
return record.thread;
|
|
772
|
+
}
|
|
773
|
+
function createCodexAdapter(options) {
|
|
774
|
+
const { codex, config, sessions } = options;
|
|
775
|
+
const bus = new EventBus();
|
|
776
|
+
const log = options.log ?? (async () => void 0);
|
|
777
|
+
const controllers = /* @__PURE__ */ new WeakMap();
|
|
778
|
+
const emit = (type, directory, properties) => {
|
|
779
|
+
bus.push({ directory, payload: { type, properties } });
|
|
780
|
+
};
|
|
781
|
+
const finishRun = (record, controller) => {
|
|
782
|
+
controllers.delete(record);
|
|
783
|
+
record.busy = false;
|
|
784
|
+
record.updated = Date.now();
|
|
785
|
+
emit("session.updated", record.directory, { info: sessionView(record) });
|
|
786
|
+
if (!record.failed) {
|
|
787
|
+
emit("session.status", record.directory, { sessionID: record.id, status: { type: "idle" } });
|
|
788
|
+
}
|
|
789
|
+
emit("session.idle", record.directory, { sessionID: record.id });
|
|
790
|
+
void log("info", "Codex turn finished", { sessionId: record.id });
|
|
791
|
+
};
|
|
792
|
+
const handleThreadEvent = (record, event) => {
|
|
793
|
+
switch (event.type) {
|
|
794
|
+
case "thread.started":
|
|
795
|
+
record.codexThreadId = event.thread_id;
|
|
796
|
+
return;
|
|
797
|
+
case "turn.completed": {
|
|
798
|
+
const message = activeAssistantMessage(record);
|
|
799
|
+
if (message) {
|
|
800
|
+
message.info.time.completed = Date.now();
|
|
801
|
+
emit("message.updated", record.directory, { info: message.info });
|
|
802
|
+
}
|
|
803
|
+
void log("info", "Codex turn completed", {
|
|
804
|
+
sessionId: record.id,
|
|
805
|
+
inputTokens: event.usage.input_tokens,
|
|
806
|
+
outputTokens: event.usage.output_tokens
|
|
807
|
+
});
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
case "turn.failed":
|
|
811
|
+
record.failed = true;
|
|
812
|
+
emit("session.error", record.directory, { sessionID: record.id, message: event.error.message });
|
|
813
|
+
return;
|
|
814
|
+
case "item.started":
|
|
815
|
+
case "item.updated":
|
|
816
|
+
case "item.completed": {
|
|
817
|
+
let message = activeAssistantMessage(record);
|
|
818
|
+
if (!message) {
|
|
819
|
+
message = createAssistantMessage(record);
|
|
820
|
+
record.messages.push(message);
|
|
821
|
+
emit("message.updated", record.directory, { info: message.info });
|
|
822
|
+
}
|
|
823
|
+
const part = itemPart(record, event.item);
|
|
824
|
+
if (!part) return;
|
|
825
|
+
const lifecycle = event.type === "item.started" ? "started" : event.type === "item.updated" ? "updated" : "completed";
|
|
826
|
+
emit("message.part.updated", record.directory, {
|
|
827
|
+
part: upsertStreamedPart(message, part, lifecycle)
|
|
828
|
+
});
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
case "error":
|
|
832
|
+
record.failed = true;
|
|
833
|
+
emit("session.error", record.directory, { sessionID: record.id, message: event.message });
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
const startRun = (record, input, model) => {
|
|
838
|
+
if (record.busy) throw new Error("Codex session is already running a turn");
|
|
839
|
+
record.busy = true;
|
|
840
|
+
record.failed = false;
|
|
841
|
+
const controller = new AbortController();
|
|
842
|
+
controllers.set(record, controller);
|
|
843
|
+
const thread = ensureThread(codex, record, config, model);
|
|
844
|
+
emit("session.status", record.directory, { sessionID: record.id, status: { type: "busy" } });
|
|
845
|
+
void log("info", "Codex turn started", { sessionId: record.id });
|
|
846
|
+
void (async () => {
|
|
847
|
+
try {
|
|
848
|
+
const { events } = await thread.runStreamed(input, { signal: controller.signal });
|
|
849
|
+
for await (const event of events) handleThreadEvent(record, event);
|
|
850
|
+
} catch (error) {
|
|
851
|
+
if (!controller.signal.aborted) {
|
|
852
|
+
record.failed = true;
|
|
853
|
+
emit("session.error", record.directory, {
|
|
854
|
+
sessionID: record.id,
|
|
855
|
+
message: errorMessage(error)
|
|
856
|
+
});
|
|
857
|
+
void log("warn", "Codex turn failed", { sessionId: record.id, reason: errorMessage(error) });
|
|
858
|
+
}
|
|
859
|
+
} finally {
|
|
860
|
+
finishRun(record, controller);
|
|
861
|
+
}
|
|
862
|
+
})();
|
|
863
|
+
};
|
|
864
|
+
return {
|
|
865
|
+
async invoke(method, rawParams) {
|
|
866
|
+
switch (method) {
|
|
867
|
+
case "device.health": {
|
|
868
|
+
emptyParamsSchema.parse(rawParams);
|
|
869
|
+
return {
|
|
870
|
+
healthy: true,
|
|
871
|
+
connector: "codex",
|
|
872
|
+
version: "0.1.0",
|
|
873
|
+
model: config.model ?? null
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
case "project.list": {
|
|
877
|
+
const params = projectListSchema.parse(rawParams);
|
|
878
|
+
const directory = params.directory || void 0;
|
|
879
|
+
const records = sessions.list(directory);
|
|
880
|
+
const discovered = await sessions.discoverAll(directory);
|
|
881
|
+
const directories = /* @__PURE__ */ new Map();
|
|
882
|
+
for (const record of records) {
|
|
883
|
+
const existing = directories.get(record.directory);
|
|
884
|
+
if (existing === void 0 || record.created < existing) {
|
|
885
|
+
directories.set(record.directory, record.created);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
for (const session of discovered) {
|
|
889
|
+
const existing = directories.get(session.directory);
|
|
890
|
+
if (existing === void 0 || session.created < existing) {
|
|
891
|
+
directories.set(session.directory, session.created);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return [...directories.entries()].map(([worktree, created]) => ({
|
|
895
|
+
id: projectId(worktree),
|
|
896
|
+
worktree,
|
|
897
|
+
time: { created }
|
|
898
|
+
}));
|
|
899
|
+
}
|
|
900
|
+
case "session.list": {
|
|
901
|
+
const params = sessionListSchema.parse(rawParams);
|
|
902
|
+
const discovered = params.directory ? await sessions.discover(params.directory) : await sessions.discover();
|
|
903
|
+
const views = [
|
|
904
|
+
...sessions.list(params.directory).map(sessionView),
|
|
905
|
+
...discovered.map(discoveredSessionView)
|
|
906
|
+
];
|
|
907
|
+
return views.map(toSafeJson);
|
|
908
|
+
}
|
|
909
|
+
case "session.create": {
|
|
910
|
+
const params = sessionCreateSchema.parse(rawParams);
|
|
911
|
+
if (!params.directory) throw new Error("session.create requires a directory");
|
|
912
|
+
const record = sessions.create({
|
|
913
|
+
id: createSessionId(),
|
|
914
|
+
directory: params.directory,
|
|
915
|
+
title: params.title,
|
|
916
|
+
version: "0.1.0",
|
|
917
|
+
model: config.model
|
|
918
|
+
});
|
|
919
|
+
return toSafeJson(sessionView(record));
|
|
920
|
+
}
|
|
921
|
+
case "session.fork":
|
|
922
|
+
throw new Error("session.fork is not supported by the Codex connector");
|
|
923
|
+
case "session.delete": {
|
|
924
|
+
const params = sessionDeleteSchema.parse(rawParams);
|
|
925
|
+
const record = sessions.get(params.sessionId);
|
|
926
|
+
if (record) {
|
|
927
|
+
emit("session.deleted", record.directory, { info: sessionView(record) });
|
|
928
|
+
sessions.remove(record.id);
|
|
929
|
+
}
|
|
930
|
+
return true;
|
|
931
|
+
}
|
|
932
|
+
case "session.status": {
|
|
933
|
+
const params = sessionStatusSchema.parse(rawParams);
|
|
934
|
+
const statuses = {};
|
|
935
|
+
for (const record of sessions.list(params.directory)) {
|
|
936
|
+
statuses[record.id] = {
|
|
937
|
+
type: record.busy ? "busy" : record.failed ? "error" : "idle"
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
return statuses;
|
|
941
|
+
}
|
|
942
|
+
case "session.messages": {
|
|
943
|
+
const params = sessionMessagesSchema.parse(rawParams);
|
|
944
|
+
const record = sessions.get(params.sessionId);
|
|
945
|
+
if (!record) {
|
|
946
|
+
const history = await sessions.loadHistory(params.sessionId, params.directory);
|
|
947
|
+
if (!history) return [];
|
|
948
|
+
const messages2 = params.limit ? history.messages.slice(-params.limit) : history.messages;
|
|
949
|
+
return toSafeJson(messages2);
|
|
950
|
+
}
|
|
951
|
+
const messages = params.limit ? record.messages.slice(-params.limit) : record.messages;
|
|
952
|
+
return toSafeJson(messages.map((message) => ({ info: message.info, parts: message.parts })));
|
|
953
|
+
}
|
|
954
|
+
case "session.prompt": {
|
|
955
|
+
const params = sessionPromptSchema.parse(rawParams);
|
|
956
|
+
const selectedModel = params.model ? params.model.providerID === "codex" ? params.model.modelID : (() => {
|
|
957
|
+
throw new Error(`Codex does not support provider ${params.model?.providerID}`);
|
|
958
|
+
})() : void 0;
|
|
959
|
+
let record = sessions.get(params.sessionId);
|
|
960
|
+
if (!record) {
|
|
961
|
+
const discovered = await sessions.discover(params.directory);
|
|
962
|
+
const session = discovered.find((item) => item.id === params.sessionId);
|
|
963
|
+
if (!session) throw new Error(`Unknown Codex session ${params.sessionId}`);
|
|
964
|
+
const history = await sessions.loadHistory(session.id, session.directory);
|
|
965
|
+
record = sessions.create({
|
|
966
|
+
id: params.sessionId,
|
|
967
|
+
directory: session.directory,
|
|
968
|
+
title: history?.title ?? session.title,
|
|
969
|
+
version: session.version,
|
|
970
|
+
codexThreadId: session.id,
|
|
971
|
+
model: selectedModel ?? config.model,
|
|
972
|
+
messages: history?.messages
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
const userMessage = buildUserMessage(record, params.parts);
|
|
976
|
+
record.messages.push(userMessage);
|
|
977
|
+
emit("message.updated", record.directory, { info: userMessage.info });
|
|
978
|
+
startRun(record, toUserInput(params.parts), selectedModel);
|
|
979
|
+
return { accepted: true };
|
|
980
|
+
}
|
|
981
|
+
case "session.abort": {
|
|
982
|
+
const params = sessionAbortSchema.parse(rawParams);
|
|
983
|
+
const record = sessions.get(params.sessionId);
|
|
984
|
+
const controller = record ? controllers.get(record) : void 0;
|
|
985
|
+
controller?.abort();
|
|
986
|
+
return { accepted: true };
|
|
987
|
+
}
|
|
988
|
+
case "provider.list": {
|
|
989
|
+
providerListSchema.parse(rawParams);
|
|
990
|
+
if (!config.model) return toSafeJson({ default: {}, providers: [] });
|
|
991
|
+
return toSafeJson({
|
|
992
|
+
default: { codex: config.model },
|
|
993
|
+
providers: [
|
|
994
|
+
{
|
|
995
|
+
id: "codex",
|
|
996
|
+
name: "Codex",
|
|
997
|
+
models: [
|
|
998
|
+
{
|
|
999
|
+
id: config.model,
|
|
1000
|
+
name: config.model,
|
|
1001
|
+
attachment: false,
|
|
1002
|
+
reasoning: true,
|
|
1003
|
+
toolCall: true,
|
|
1004
|
+
status: "active",
|
|
1005
|
+
variants: []
|
|
1006
|
+
}
|
|
1007
|
+
]
|
|
1008
|
+
}
|
|
1009
|
+
]
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
case "agent.list": {
|
|
1013
|
+
agentListSchema.parse(rawParams);
|
|
1014
|
+
return [];
|
|
1015
|
+
}
|
|
1016
|
+
case "permission.list": {
|
|
1017
|
+
permissionListSchema.parse(rawParams);
|
|
1018
|
+
return [];
|
|
1019
|
+
}
|
|
1020
|
+
case "permission.reply": {
|
|
1021
|
+
permissionReplySchema.parse(rawParams);
|
|
1022
|
+
return true;
|
|
1023
|
+
}
|
|
1024
|
+
case "question.list": {
|
|
1025
|
+
questionListSchema.parse(rawParams);
|
|
1026
|
+
return [];
|
|
1027
|
+
}
|
|
1028
|
+
case "question.reply": {
|
|
1029
|
+
questionReplySchema.parse(rawParams);
|
|
1030
|
+
throw new Error("The Codex connector does not support interactive questions");
|
|
1031
|
+
}
|
|
1032
|
+
case "question.reject": {
|
|
1033
|
+
questionRejectSchema.parse(rawParams);
|
|
1034
|
+
throw new Error("The Codex connector does not support interactive questions");
|
|
1035
|
+
}
|
|
1036
|
+
case "event.subscribe": {
|
|
1037
|
+
emptyParamsSchema.parse(rawParams);
|
|
1038
|
+
return { subscribed: true };
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
},
|
|
1042
|
+
async *events(signal) {
|
|
1043
|
+
yield* bus.stream(signal);
|
|
1044
|
+
},
|
|
1045
|
+
async log(level, message, extra) {
|
|
1046
|
+
await log(level, message, extra);
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// ../protocol/src/index.ts
|
|
1052
|
+
import { z as z3 } from "zod";
|
|
1053
|
+
var BRIDGE_PROTOCOL_VERSION = 1;
|
|
1054
|
+
var bridgeRoleSchema = z3.enum(["device", "browser"]);
|
|
1055
|
+
var deviceIdSchema = z3.string().min(1).max(128).regex(/^[a-zA-Z0-9._:-]+$/, "Device ID contains unsupported characters");
|
|
1056
|
+
var clientIdSchema = z3.string().min(1).max(128).regex(/^[a-zA-Z0-9._:-]+$/, "Client ID contains unsupported characters");
|
|
1057
|
+
var base64UrlSchema = z3.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/, "Value must use unpadded base64url encoding");
|
|
1058
|
+
var deviceAuthProofSchema = z3.object({
|
|
1059
|
+
deviceId: deviceIdSchema,
|
|
1060
|
+
publicKey: z3.string().min(32).max(512),
|
|
1061
|
+
timestamp: z3.number().int().positive(),
|
|
1062
|
+
nonce: base64UrlSchema.max(128),
|
|
1063
|
+
signature: base64UrlSchema
|
|
1064
|
+
}).strict();
|
|
1065
|
+
var pairingClientSchema = z3.object({
|
|
1066
|
+
clientId: clientIdSchema,
|
|
1067
|
+
label: z3.string().trim().min(1).max(80).optional()
|
|
1068
|
+
}).strict();
|
|
1069
|
+
var pairingStartRequestSchema = z3.object({
|
|
1070
|
+
proof: deviceAuthProofSchema,
|
|
1071
|
+
client: pairingClientSchema
|
|
1072
|
+
}).strict();
|
|
1073
|
+
var pairingClaimRequestSchema = z3.object({
|
|
1074
|
+
ticket: base64UrlSchema.max(128),
|
|
1075
|
+
client: pairingClientSchema
|
|
1076
|
+
}).strict();
|
|
1077
|
+
var pairingStartResponseSchema = z3.object({
|
|
1078
|
+
deviceId: deviceIdSchema,
|
|
1079
|
+
pairingId: base64UrlSchema.max(128),
|
|
1080
|
+
pairUrl: z3.string().url(),
|
|
1081
|
+
expiresAt: z3.number().int().positive(),
|
|
1082
|
+
deviceOnline: z3.boolean()
|
|
1083
|
+
}).strict();
|
|
1084
|
+
var pairingClaimResponseSchema = z3.object({
|
|
1085
|
+
deviceId: deviceIdSchema,
|
|
1086
|
+
authorized: z3.literal(true)
|
|
1087
|
+
}).strict();
|
|
1088
|
+
var pairingRevokeRequestSchema = z3.object({
|
|
1089
|
+
clientId: clientIdSchema
|
|
1090
|
+
}).strict();
|
|
1091
|
+
var pairingRevokeResponseSchema = z3.object({
|
|
1092
|
+
revoked: z3.literal(true)
|
|
1093
|
+
}).strict();
|
|
1094
|
+
var pairedDeviceSchema = z3.object({
|
|
1095
|
+
clientId: clientIdSchema,
|
|
1096
|
+
label: z3.string().nullable(),
|
|
1097
|
+
online: z3.boolean(),
|
|
1098
|
+
pairedAt: z3.number().int().positive()
|
|
1099
|
+
}).strict();
|
|
1100
|
+
var pairedDevicesResponseSchema = z3.object({
|
|
1101
|
+
devices: z3.array(pairedDeviceSchema)
|
|
1102
|
+
}).strict();
|
|
1103
|
+
var bridgeConnectionSchema = z3.object({
|
|
1104
|
+
role: bridgeRoleSchema,
|
|
1105
|
+
clientId: clientIdSchema
|
|
1106
|
+
});
|
|
1107
|
+
var bridgeMethodSchema = z3.enum([
|
|
1108
|
+
"device.health",
|
|
1109
|
+
"project.list",
|
|
1110
|
+
"session.list",
|
|
1111
|
+
"session.create",
|
|
1112
|
+
"session.fork",
|
|
1113
|
+
"session.delete",
|
|
1114
|
+
"session.status",
|
|
1115
|
+
"session.messages",
|
|
1116
|
+
"session.prompt",
|
|
1117
|
+
"session.abort",
|
|
1118
|
+
"provider.list",
|
|
1119
|
+
"agent.list",
|
|
1120
|
+
"permission.list",
|
|
1121
|
+
"permission.reply",
|
|
1122
|
+
"question.list",
|
|
1123
|
+
"question.reply",
|
|
1124
|
+
"question.reject",
|
|
1125
|
+
"event.subscribe"
|
|
1126
|
+
]);
|
|
1127
|
+
var jsonValueSchema = z3.lazy(
|
|
1128
|
+
() => z3.union([
|
|
1129
|
+
z3.null(),
|
|
1130
|
+
z3.boolean(),
|
|
1131
|
+
z3.number(),
|
|
1132
|
+
z3.string(),
|
|
1133
|
+
z3.array(jsonValueSchema),
|
|
1134
|
+
z3.record(z3.string(), jsonValueSchema)
|
|
1135
|
+
])
|
|
1136
|
+
);
|
|
1137
|
+
var messageBase = {
|
|
1138
|
+
version: z3.literal(BRIDGE_PROTOCOL_VERSION)
|
|
1139
|
+
};
|
|
1140
|
+
var bridgeHelloSchema = z3.object({
|
|
1141
|
+
...messageBase,
|
|
1142
|
+
type: z3.literal("device.hello"),
|
|
1143
|
+
clientId: clientIdSchema,
|
|
1144
|
+
agentVersion: z3.string().min(1).max(64),
|
|
1145
|
+
connectorId: z3.string().min(1).max(64).default("opencode")
|
|
1146
|
+
}).strict();
|
|
1147
|
+
var bridgeRequestSchema = z3.object({
|
|
1148
|
+
...messageBase,
|
|
1149
|
+
type: z3.literal("request"),
|
|
1150
|
+
id: z3.string().min(1).max(128),
|
|
1151
|
+
connectorId: z3.string().min(1).max(64).optional(),
|
|
1152
|
+
method: bridgeMethodSchema,
|
|
1153
|
+
params: jsonValueSchema
|
|
1154
|
+
}).strict();
|
|
1155
|
+
var bridgeErrorSchema = z3.object({
|
|
1156
|
+
code: z3.enum([
|
|
1157
|
+
"INVALID_MESSAGE",
|
|
1158
|
+
"ROLE_NOT_ALLOWED",
|
|
1159
|
+
"DEVICE_OFFLINE",
|
|
1160
|
+
"REQUEST_NOT_FOUND",
|
|
1161
|
+
"DUPLICATE_REQUEST",
|
|
1162
|
+
"SESSION_BUSY",
|
|
1163
|
+
"INTERNAL_ERROR"
|
|
1164
|
+
]),
|
|
1165
|
+
message: z3.string().min(1).max(512)
|
|
1166
|
+
}).strict();
|
|
1167
|
+
var bridgeSuccessResponseSchema = z3.object({
|
|
1168
|
+
...messageBase,
|
|
1169
|
+
type: z3.literal("response"),
|
|
1170
|
+
id: z3.string().min(1).max(128),
|
|
1171
|
+
ok: z3.literal(true),
|
|
1172
|
+
result: jsonValueSchema
|
|
1173
|
+
}).strict();
|
|
1174
|
+
var bridgeFailureResponseSchema = z3.object({
|
|
1175
|
+
...messageBase,
|
|
1176
|
+
type: z3.literal("response"),
|
|
1177
|
+
id: z3.string().min(1).max(128),
|
|
1178
|
+
ok: z3.literal(false),
|
|
1179
|
+
error: bridgeErrorSchema
|
|
1180
|
+
}).strict();
|
|
1181
|
+
var bridgeResponseSchema = z3.union([
|
|
1182
|
+
bridgeSuccessResponseSchema,
|
|
1183
|
+
bridgeFailureResponseSchema
|
|
1184
|
+
]);
|
|
1185
|
+
var bridgeEventSchema = z3.object({
|
|
1186
|
+
...messageBase,
|
|
1187
|
+
type: z3.literal("event"),
|
|
1188
|
+
connectorId: z3.string().min(1).max(64).optional(),
|
|
1189
|
+
event: z3.string().min(1).max(128),
|
|
1190
|
+
payload: jsonValueSchema
|
|
1191
|
+
}).strict();
|
|
1192
|
+
var bridgeSystemSchema = z3.object({
|
|
1193
|
+
...messageBase,
|
|
1194
|
+
type: z3.literal("system"),
|
|
1195
|
+
event: z3.enum([
|
|
1196
|
+
"connection.ready",
|
|
1197
|
+
"presence.changed",
|
|
1198
|
+
"pairing.claimed",
|
|
1199
|
+
"protocol.error"
|
|
1200
|
+
]),
|
|
1201
|
+
payload: jsonValueSchema
|
|
1202
|
+
}).strict();
|
|
1203
|
+
var bridgeMessageSchema = z3.union([
|
|
1204
|
+
bridgeHelloSchema,
|
|
1205
|
+
bridgeRequestSchema,
|
|
1206
|
+
bridgeResponseSchema,
|
|
1207
|
+
bridgeEventSchema,
|
|
1208
|
+
bridgeSystemSchema
|
|
1209
|
+
]);
|
|
1210
|
+
function parseBridgeMessage(value) {
|
|
1211
|
+
let parsed;
|
|
1212
|
+
try {
|
|
1213
|
+
parsed = JSON.parse(value);
|
|
1214
|
+
} catch {
|
|
1215
|
+
return {
|
|
1216
|
+
ok: false,
|
|
1217
|
+
error: {
|
|
1218
|
+
code: "INVALID_MESSAGE",
|
|
1219
|
+
message: "Message must be valid JSON"
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
const result = bridgeMessageSchema.safeParse(parsed);
|
|
1224
|
+
if (!result.success) {
|
|
1225
|
+
return {
|
|
1226
|
+
ok: false,
|
|
1227
|
+
error: {
|
|
1228
|
+
code: "INVALID_MESSAGE",
|
|
1229
|
+
message: result.error.issues[0]?.message ?? "Message does not match the bridge protocol"
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
return { ok: true, message: result.data };
|
|
1234
|
+
}
|
|
1235
|
+
function encodeBridgeMessage(message) {
|
|
1236
|
+
return JSON.stringify(bridgeMessageSchema.parse(message));
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// src/identity.ts
|
|
1240
|
+
import {
|
|
1241
|
+
createHash as createHash2,
|
|
1242
|
+
createPrivateKey,
|
|
1243
|
+
createPublicKey,
|
|
1244
|
+
generateKeyPairSync,
|
|
1245
|
+
randomBytes as randomBytes2,
|
|
1246
|
+
sign,
|
|
1247
|
+
verify
|
|
1248
|
+
} from "crypto";
|
|
1249
|
+
import { homedir as homedir2 } from "os";
|
|
1250
|
+
import { dirname, join as join2 } from "path";
|
|
1251
|
+
import { mkdir, open, readFile as readFile2 } from "fs/promises";
|
|
1252
|
+
import { z as z4 } from "zod";
|
|
1253
|
+
var DEVICE_PROOF_PREFIX = "tumnel-device-proof-v1";
|
|
1254
|
+
var identitySchema = z4.object({
|
|
1255
|
+
version: z4.literal(1),
|
|
1256
|
+
deviceId: z4.string().regex(/^device_[A-Za-z0-9_-]{32,64}$/),
|
|
1257
|
+
publicKey: z4.string().min(1),
|
|
1258
|
+
privateKey: z4.string().min(1),
|
|
1259
|
+
createdAt: z4.string().datetime()
|
|
1260
|
+
}).strict();
|
|
1261
|
+
function defaultIdentityPath(environment = process.env) {
|
|
1262
|
+
const configRoot = environment.TUMNEL_CONFIG_DIR ?? environment.XDG_CONFIG_HOME ?? (process.platform === "win32" && environment.USERPROFILE ? join2(environment.USERPROFILE, ".config") : join2(homedir2(), ".config"));
|
|
1263
|
+
return join2(configRoot, "tumnel", "identity.json");
|
|
1264
|
+
}
|
|
1265
|
+
function legacyIdentityPaths(environment = process.env) {
|
|
1266
|
+
const configRoot = environment.TUMNEL_CONFIG_DIR ?? environment.XDG_CONFIG_HOME ?? (process.platform === "win32" && environment.USERPROFILE ? join2(environment.USERPROFILE, ".config") : join2(homedir2(), ".config"));
|
|
1267
|
+
return [
|
|
1268
|
+
join2(configRoot, "opencode", "tumnel", "identity.json"),
|
|
1269
|
+
join2(configRoot, "codex", "tumnel", "identity.json")
|
|
1270
|
+
];
|
|
1271
|
+
}
|
|
1272
|
+
function deviceIdFromPublicKey(publicKey) {
|
|
1273
|
+
const fingerprint = createHash2("sha256").update(Buffer.from(publicKey, "base64")).digest("base64url");
|
|
1274
|
+
return `device_${fingerprint}`;
|
|
1275
|
+
}
|
|
1276
|
+
function createIdentity() {
|
|
1277
|
+
const pair = generateKeyPairSync("ed25519");
|
|
1278
|
+
const publicKey = pair.publicKey.export({ type: "spki", format: "der" }).toString("base64");
|
|
1279
|
+
const privateKey = pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
1280
|
+
return {
|
|
1281
|
+
version: 1,
|
|
1282
|
+
deviceId: deviceIdFromPublicKey(publicKey),
|
|
1283
|
+
publicKey,
|
|
1284
|
+
privateKey,
|
|
1285
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
function validateKeyPair(identity) {
|
|
1289
|
+
if (deviceIdFromPublicKey(identity.publicKey) !== identity.deviceId) {
|
|
1290
|
+
throw new Error("Tumnel identity fingerprint does not match its public key");
|
|
1291
|
+
}
|
|
1292
|
+
const challenge = Buffer.from("tumnel-device-identity-v1");
|
|
1293
|
+
const signature = sign(null, challenge, createPrivateKey(identity.privateKey));
|
|
1294
|
+
const valid = verify(
|
|
1295
|
+
null,
|
|
1296
|
+
challenge,
|
|
1297
|
+
createPublicKey({ key: Buffer.from(identity.publicKey, "base64"), format: "der", type: "spki" }),
|
|
1298
|
+
signature
|
|
1299
|
+
);
|
|
1300
|
+
if (!valid) {
|
|
1301
|
+
throw new Error("Tumnel identity contains an invalid key pair");
|
|
1302
|
+
}
|
|
1303
|
+
return identity;
|
|
1304
|
+
}
|
|
1305
|
+
async function readIdentity(path) {
|
|
1306
|
+
const contents = await readFile2(path, "utf8");
|
|
1307
|
+
return validateKeyPair(identitySchema.parse(JSON.parse(contents)));
|
|
1308
|
+
}
|
|
1309
|
+
async function persistNewIdentity(path, identity) {
|
|
1310
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
1311
|
+
const handle = await open(path, "wx", 384);
|
|
1312
|
+
try {
|
|
1313
|
+
await handle.writeFile(`${JSON.stringify(identity, null, 2)}
|
|
1314
|
+
`, "utf8");
|
|
1315
|
+
await handle.sync();
|
|
1316
|
+
} finally {
|
|
1317
|
+
await handle.close();
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
async function loadOrCreateIdentity(path = defaultIdentityPath(), migrationSources = path === defaultIdentityPath() ? legacyIdentityPaths() : []) {
|
|
1321
|
+
try {
|
|
1322
|
+
return await readIdentity(path);
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
if (error.code !== "ENOENT") throw error;
|
|
1325
|
+
}
|
|
1326
|
+
if (migrationSources.length > 0) {
|
|
1327
|
+
for (const legacyPath of migrationSources) {
|
|
1328
|
+
try {
|
|
1329
|
+
const identity2 = await readIdentity(legacyPath);
|
|
1330
|
+
try {
|
|
1331
|
+
await persistNewIdentity(path, identity2);
|
|
1332
|
+
return identity2;
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
if (error.code !== "EEXIST") throw error;
|
|
1335
|
+
return readIdentity(path);
|
|
1336
|
+
}
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
if (error.code !== "ENOENT") throw error;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
const identity = createIdentity();
|
|
1343
|
+
try {
|
|
1344
|
+
await persistNewIdentity(path, identity);
|
|
1345
|
+
return identity;
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
if (error.code !== "EEXIST") throw error;
|
|
1348
|
+
return readIdentity(path);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
function createDeviceProof(identity, timestamp = Date.now(), nonce = randomBytes2(24).toString("base64url")) {
|
|
1352
|
+
const message = `${DEVICE_PROOF_PREFIX}
|
|
1353
|
+
${identity.deviceId}
|
|
1354
|
+
${timestamp}
|
|
1355
|
+
${nonce}`;
|
|
1356
|
+
const signature = sign(null, Buffer.from(message), createPrivateKey(identity.privateKey));
|
|
1357
|
+
return {
|
|
1358
|
+
deviceId: identity.deviceId,
|
|
1359
|
+
publicKey: identity.publicKey,
|
|
1360
|
+
timestamp,
|
|
1361
|
+
nonce,
|
|
1362
|
+
signature: signature.toString("base64url")
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// src/client.ts
|
|
1367
|
+
var CONNECT_TIMEOUT_MS = 15e3;
|
|
1368
|
+
var STABLE_CONNECTION_MS = 3e4;
|
|
1369
|
+
var INITIAL_BACKOFF_MS = 500;
|
|
1370
|
+
var MAX_BACKOFF_MS = 3e4;
|
|
1371
|
+
function bridgeUrl(relayUrl, identity, proof) {
|
|
1372
|
+
const url = new URL(relayUrl);
|
|
1373
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
1374
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
1375
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
|
1376
|
+
throw new Error("TUMNEL_RELAY_URL must use http, https, ws, or wss");
|
|
1377
|
+
}
|
|
1378
|
+
const basePath = url.pathname.replace(/\/$/, "");
|
|
1379
|
+
url.pathname = `${basePath}/api/bridge/${encodeURIComponent(identity.deviceId)}`;
|
|
1380
|
+
url.search = "";
|
|
1381
|
+
url.searchParams.set("role", "device");
|
|
1382
|
+
url.searchParams.set("clientId", identity.deviceId);
|
|
1383
|
+
url.searchParams.set("connectorId", "codex");
|
|
1384
|
+
url.searchParams.set("publicKey", proof.publicKey);
|
|
1385
|
+
url.searchParams.set("timestamp", String(proof.timestamp));
|
|
1386
|
+
url.searchParams.set("nonce", proof.nonce);
|
|
1387
|
+
url.searchParams.set("signature", proof.signature);
|
|
1388
|
+
return url.toString();
|
|
1389
|
+
}
|
|
1390
|
+
function abortableDelay(milliseconds, signal) {
|
|
1391
|
+
return new Promise((resolve) => {
|
|
1392
|
+
if (signal.aborted) return resolve();
|
|
1393
|
+
const timeout = setTimeout(resolve, milliseconds);
|
|
1394
|
+
signal.addEventListener(
|
|
1395
|
+
"abort",
|
|
1396
|
+
() => {
|
|
1397
|
+
clearTimeout(timeout);
|
|
1398
|
+
resolve();
|
|
1399
|
+
},
|
|
1400
|
+
{ once: true }
|
|
1401
|
+
);
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
function eventName(payload) {
|
|
1405
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return "codex.event";
|
|
1406
|
+
if (typeof payload.type === "string") return payload.type.slice(0, 128);
|
|
1407
|
+
const nested = payload.payload;
|
|
1408
|
+
if (typeof nested === "object" && nested !== null && !Array.isArray(nested) && typeof nested.type === "string") {
|
|
1409
|
+
return nested.type.slice(0, 128);
|
|
1410
|
+
}
|
|
1411
|
+
return "codex.event";
|
|
1412
|
+
}
|
|
1413
|
+
async function messageText(data) {
|
|
1414
|
+
if (typeof data === "string") return data;
|
|
1415
|
+
if (data instanceof Blob) return data.text();
|
|
1416
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
|
1417
|
+
if (ArrayBuffer.isView(data)) {
|
|
1418
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
|
1419
|
+
}
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
function errorMessage2(error) {
|
|
1423
|
+
const message = error instanceof Error ? error.message : "Codex request failed";
|
|
1424
|
+
return message.slice(0, 512);
|
|
1425
|
+
}
|
|
1426
|
+
var TumnelBridgeClient = class {
|
|
1427
|
+
relayUrl;
|
|
1428
|
+
identity;
|
|
1429
|
+
adapter;
|
|
1430
|
+
webSocketFactory;
|
|
1431
|
+
random;
|
|
1432
|
+
proofFactory;
|
|
1433
|
+
abortController = new AbortController();
|
|
1434
|
+
connectionLoop;
|
|
1435
|
+
eventLoop;
|
|
1436
|
+
socket;
|
|
1437
|
+
currentState = "idle";
|
|
1438
|
+
constructor(options) {
|
|
1439
|
+
this.relayUrl = options.relayUrl;
|
|
1440
|
+
this.identity = options.identity;
|
|
1441
|
+
this.adapter = options.adapter;
|
|
1442
|
+
this.webSocketFactory = options.webSocketFactory ?? ((url) => new WebSocket(url));
|
|
1443
|
+
this.random = options.random ?? Math.random;
|
|
1444
|
+
this.proofFactory = options.proofFactory ?? (() => createDeviceProof(this.identity));
|
|
1445
|
+
}
|
|
1446
|
+
get state() {
|
|
1447
|
+
return this.currentState;
|
|
1448
|
+
}
|
|
1449
|
+
get deviceId() {
|
|
1450
|
+
return this.identity.deviceId;
|
|
1451
|
+
}
|
|
1452
|
+
start() {
|
|
1453
|
+
if (this.connectionLoop || this.abortController.signal.aborted) return;
|
|
1454
|
+
this.connectionLoop = this.runConnectionLoop();
|
|
1455
|
+
this.eventLoop = this.runEventLoop();
|
|
1456
|
+
}
|
|
1457
|
+
async stop() {
|
|
1458
|
+
if (this.currentState === "stopped") return;
|
|
1459
|
+
this.currentState = "stopped";
|
|
1460
|
+
this.abortController.abort();
|
|
1461
|
+
this.socket?.close(1e3, "Connector disposed");
|
|
1462
|
+
await Promise.allSettled([this.connectionLoop, this.eventLoop]);
|
|
1463
|
+
}
|
|
1464
|
+
async runConnectionLoop() {
|
|
1465
|
+
let attempt = 0;
|
|
1466
|
+
while (!this.abortController.signal.aborted) {
|
|
1467
|
+
const startedAt = Date.now();
|
|
1468
|
+
try {
|
|
1469
|
+
await this.connectOnce();
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
await this.adapter.log("warn", "Cloud relay connection failed", {
|
|
1472
|
+
reason: errorMessage2(error)
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
if (this.abortController.signal.aborted) break;
|
|
1476
|
+
attempt = Date.now() - startedAt >= STABLE_CONNECTION_MS ? 0 : attempt + 1;
|
|
1477
|
+
const exponential = Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * 2 ** Math.min(attempt, 10));
|
|
1478
|
+
const delay = Math.round(exponential * (0.75 + this.random() * 0.5));
|
|
1479
|
+
await this.adapter.log("info", "Reconnecting to Cloud relay", {
|
|
1480
|
+
attempt,
|
|
1481
|
+
delayMs: delay
|
|
1482
|
+
});
|
|
1483
|
+
await abortableDelay(delay, this.abortController.signal);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
async connectOnce() {
|
|
1487
|
+
this.currentState = "connecting";
|
|
1488
|
+
const proof = await this.proofFactory();
|
|
1489
|
+
const socket = this.webSocketFactory(bridgeUrl(this.relayUrl, this.identity, proof));
|
|
1490
|
+
this.socket = socket;
|
|
1491
|
+
return new Promise((resolve, reject) => {
|
|
1492
|
+
let opened = false;
|
|
1493
|
+
const timeout = setTimeout(() => {
|
|
1494
|
+
socket.close(4e3, "Connection timeout");
|
|
1495
|
+
reject(new Error("Timed out connecting to Cloud relay"));
|
|
1496
|
+
}, CONNECT_TIMEOUT_MS);
|
|
1497
|
+
const abort = () => socket.close(1e3, "Connector disposed");
|
|
1498
|
+
this.abortController.signal.addEventListener("abort", abort, { once: true });
|
|
1499
|
+
socket.addEventListener("open", () => {
|
|
1500
|
+
opened = true;
|
|
1501
|
+
clearTimeout(timeout);
|
|
1502
|
+
this.currentState = "connected";
|
|
1503
|
+
const hello = {
|
|
1504
|
+
version: BRIDGE_PROTOCOL_VERSION,
|
|
1505
|
+
type: "device.hello",
|
|
1506
|
+
clientId: this.identity.deviceId,
|
|
1507
|
+
agentVersion: "0.1.1",
|
|
1508
|
+
connectorId: "codex"
|
|
1509
|
+
};
|
|
1510
|
+
socket.send(encodeBridgeMessage(hello));
|
|
1511
|
+
void this.adapter.log("info", "Connected to Cloud relay", {
|
|
1512
|
+
deviceId: this.identity.deviceId
|
|
1513
|
+
});
|
|
1514
|
+
});
|
|
1515
|
+
socket.addEventListener("message", (event) => {
|
|
1516
|
+
void this.handleMessage(socket, event.data);
|
|
1517
|
+
});
|
|
1518
|
+
socket.addEventListener("error", () => {
|
|
1519
|
+
if (!opened) {
|
|
1520
|
+
clearTimeout(timeout);
|
|
1521
|
+
reject(new Error("Cloud relay WebSocket error"));
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
1524
|
+
socket.addEventListener("close", (event) => {
|
|
1525
|
+
clearTimeout(timeout);
|
|
1526
|
+
this.abortController.signal.removeEventListener("abort", abort);
|
|
1527
|
+
if (this.socket === socket) this.socket = void 0;
|
|
1528
|
+
if (!this.abortController.signal.aborted) this.currentState = "idle";
|
|
1529
|
+
if (!this.abortController.signal.aborted) {
|
|
1530
|
+
void this.adapter.log("warn", "Cloud relay disconnected", {
|
|
1531
|
+
code: event.code,
|
|
1532
|
+
reason: event.reason.slice(0, 256),
|
|
1533
|
+
wasClean: event.wasClean
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
if (opened) resolve();
|
|
1537
|
+
else reject(new Error("Cloud relay closed before connecting"));
|
|
1538
|
+
});
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
async handleMessage(socket, data) {
|
|
1542
|
+
const raw = await messageText(data);
|
|
1543
|
+
if (raw === "pong" || raw === null) return;
|
|
1544
|
+
const parsed = parseBridgeMessage(raw);
|
|
1545
|
+
if (!parsed.ok) {
|
|
1546
|
+
await this.adapter.log("warn", "Ignored invalid Cloud relay message", {
|
|
1547
|
+
code: parsed.error.code,
|
|
1548
|
+
message: parsed.error.message
|
|
1549
|
+
});
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
if (parsed.message.type !== "request") return;
|
|
1553
|
+
await this.handleRequest(socket, parsed.message);
|
|
1554
|
+
}
|
|
1555
|
+
async handleRequest(socket, request) {
|
|
1556
|
+
let response;
|
|
1557
|
+
try {
|
|
1558
|
+
response = {
|
|
1559
|
+
version: BRIDGE_PROTOCOL_VERSION,
|
|
1560
|
+
type: "response",
|
|
1561
|
+
id: request.id,
|
|
1562
|
+
ok: true,
|
|
1563
|
+
result: await this.adapter.invoke(request.method, request.params)
|
|
1564
|
+
};
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
response = {
|
|
1567
|
+
version: BRIDGE_PROTOCOL_VERSION,
|
|
1568
|
+
type: "response",
|
|
1569
|
+
id: request.id,
|
|
1570
|
+
ok: false,
|
|
1571
|
+
error: {
|
|
1572
|
+
code: "INTERNAL_ERROR",
|
|
1573
|
+
message: errorMessage2(error)
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(encodeBridgeMessage(response));
|
|
1578
|
+
}
|
|
1579
|
+
async runEventLoop() {
|
|
1580
|
+
let attempt = 0;
|
|
1581
|
+
while (!this.abortController.signal.aborted) {
|
|
1582
|
+
try {
|
|
1583
|
+
for await (const payload of this.adapter.events(this.abortController.signal)) {
|
|
1584
|
+
const event = {
|
|
1585
|
+
version: BRIDGE_PROTOCOL_VERSION,
|
|
1586
|
+
type: "event",
|
|
1587
|
+
event: eventName(payload),
|
|
1588
|
+
payload
|
|
1589
|
+
};
|
|
1590
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
1591
|
+
this.socket.send(encodeBridgeMessage(event));
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
attempt = 0;
|
|
1595
|
+
} catch (error) {
|
|
1596
|
+
if (this.abortController.signal.aborted) break;
|
|
1597
|
+
attempt += 1;
|
|
1598
|
+
await this.adapter.log("warn", "Codex event stream disconnected", {
|
|
1599
|
+
reason: errorMessage2(error),
|
|
1600
|
+
attempt
|
|
1601
|
+
});
|
|
1602
|
+
const delay = Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * 2 ** Math.min(attempt, 10));
|
|
1603
|
+
await abortableDelay(delay, this.abortController.signal);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
// src/config.ts
|
|
1610
|
+
import { z as z5 } from "zod";
|
|
1611
|
+
var DEFAULT_RELAY_URL = "https://openbridge.jlfloressanchez01.workers.dev";
|
|
1612
|
+
var DEFAULT_LOCAL_PORT = 43817;
|
|
1613
|
+
var DEFAULT_SANDBOX_MODE = "workspace-write";
|
|
1614
|
+
var DEFAULT_APPROVAL_POLICY = "on-request";
|
|
1615
|
+
var localPortSchema = z5.number().int().min(1024).max(65535);
|
|
1616
|
+
var sandboxModeSchema = z5.enum(["read-only", "workspace-write", "danger-full-access"]);
|
|
1617
|
+
var approvalPolicySchema = z5.enum(["never", "on-request", "on-failure", "untrusted"]);
|
|
1618
|
+
var modelReasoningEffortSchema = z5.enum(["minimal", "low", "medium", "high", "xhigh"]);
|
|
1619
|
+
var webSearchModeSchema = z5.enum(["disabled", "cached", "live"]);
|
|
1620
|
+
var connectorOptionsSchema = z5.object({
|
|
1621
|
+
relayUrl: z5.string().url().optional(),
|
|
1622
|
+
identityPath: z5.string().min(1).optional(),
|
|
1623
|
+
localPort: localPortSchema.optional(),
|
|
1624
|
+
appOrigins: z5.array(z5.string().url()).max(10).optional(),
|
|
1625
|
+
model: z5.string().min(1).max(256).optional(),
|
|
1626
|
+
sandboxMode: sandboxModeSchema.optional(),
|
|
1627
|
+
approvalPolicy: approvalPolicySchema.optional(),
|
|
1628
|
+
modelReasoningEffort: modelReasoningEffortSchema.optional(),
|
|
1629
|
+
webSearchMode: webSearchModeSchema.optional(),
|
|
1630
|
+
skipGitRepoCheck: z5.boolean().optional(),
|
|
1631
|
+
networkAccess: z5.boolean().optional()
|
|
1632
|
+
}).strict();
|
|
1633
|
+
function envBool(value) {
|
|
1634
|
+
if (value === void 0) return void 0;
|
|
1635
|
+
return value === "1" || value.toLowerCase() === "true";
|
|
1636
|
+
}
|
|
1637
|
+
function resolveConfig(options, environment = process.env) {
|
|
1638
|
+
const parsedOptions = connectorOptionsSchema.parse(options ?? {});
|
|
1639
|
+
const relayUrl = parsedOptions.relayUrl ?? environment.TUMNEL_RELAY_URL ?? DEFAULT_RELAY_URL;
|
|
1640
|
+
const configuredOrigins = environment.TUMNEL_APP_ORIGIN?.split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
1641
|
+
const sandboxMode = sandboxModeSchema.parse(
|
|
1642
|
+
parsedOptions.sandboxMode ?? environment.CODEX_SANDBOX ?? DEFAULT_SANDBOX_MODE
|
|
1643
|
+
);
|
|
1644
|
+
const approvalPolicy = approvalPolicySchema.parse(
|
|
1645
|
+
parsedOptions.approvalPolicy ?? environment.CODEX_APPROVAL_POLICY ?? DEFAULT_APPROVAL_POLICY
|
|
1646
|
+
);
|
|
1647
|
+
return {
|
|
1648
|
+
relayUrl,
|
|
1649
|
+
identityPath: parsedOptions.identityPath ?? environment.TUMNEL_IDENTITY_PATH,
|
|
1650
|
+
localPort: localPortSchema.parse(
|
|
1651
|
+
parsedOptions.localPort ?? (environment.TUMNEL_LOCAL_PORT ? Number(environment.TUMNEL_LOCAL_PORT) : DEFAULT_LOCAL_PORT)
|
|
1652
|
+
),
|
|
1653
|
+
appOrigins: parsedOptions.appOrigins ?? configuredOrigins ?? [],
|
|
1654
|
+
model: parsedOptions.model ?? environment.CODEX_MODEL,
|
|
1655
|
+
sandboxMode,
|
|
1656
|
+
approvalPolicy,
|
|
1657
|
+
modelReasoningEffort: parsedOptions.modelReasoningEffort ?? environment.CODEX_REASONING_EFFORT,
|
|
1658
|
+
webSearchMode: parsedOptions.webSearchMode ?? environment.CODEX_WEB_SEARCH,
|
|
1659
|
+
skipGitRepoCheck: parsedOptions.skipGitRepoCheck ?? envBool(environment.CODEX_SKIP_GIT_REPO_CHECK) ?? false,
|
|
1660
|
+
networkAccess: parsedOptions.networkAccess ?? envBool(environment.CODEX_NETWORK_ACCESS) ?? false
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// src/local-server.ts
|
|
1665
|
+
import { createServer } from "http";
|
|
1666
|
+
function relayOrigin(relayUrl) {
|
|
1667
|
+
const url = new URL(relayUrl);
|
|
1668
|
+
if (url.protocol === "ws:") url.protocol = "http:";
|
|
1669
|
+
if (url.protocol === "wss:") url.protocol = "https:";
|
|
1670
|
+
return url.origin;
|
|
1671
|
+
}
|
|
1672
|
+
function closeServer(server) {
|
|
1673
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
1674
|
+
}
|
|
1675
|
+
async function startPairingServer(options) {
|
|
1676
|
+
const allowedOrigins = /* @__PURE__ */ new Set([
|
|
1677
|
+
relayOrigin(options.relayUrl),
|
|
1678
|
+
"http://127.0.0.1:5173",
|
|
1679
|
+
"http://localhost:5173",
|
|
1680
|
+
...options.appOrigins
|
|
1681
|
+
]);
|
|
1682
|
+
const server = createServer((request, response) => {
|
|
1683
|
+
const origin = request.headers.origin;
|
|
1684
|
+
const expectedHosts = /* @__PURE__ */ new Set([
|
|
1685
|
+
`127.0.0.1:${options.port}`,
|
|
1686
|
+
`localhost:${options.port}`
|
|
1687
|
+
]);
|
|
1688
|
+
if (!expectedHosts.has(request.headers.host ?? "")) {
|
|
1689
|
+
response.writeHead(403, { "Cache-Control": "no-store" });
|
|
1690
|
+
response.end("Forbidden");
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host}`);
|
|
1694
|
+
if (request.method === "GET" && requestUrl.pathname === "/v1/health") {
|
|
1695
|
+
const body2 = JSON.stringify({ ready: true });
|
|
1696
|
+
response.writeHead(200, {
|
|
1697
|
+
"Cache-Control": "no-store",
|
|
1698
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1699
|
+
"Content-Length": Buffer.byteLength(body2).toString()
|
|
1700
|
+
});
|
|
1701
|
+
response.end(body2);
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
if (request.method === "GET" && requestUrl.pathname === "/v1/connect") {
|
|
1705
|
+
const returnOrigin = requestUrl.searchParams.get("returnOrigin");
|
|
1706
|
+
const clientId = clientIdSchema.safeParse(requestUrl.searchParams.get("clientId"));
|
|
1707
|
+
if (!returnOrigin || !allowedOrigins.has(returnOrigin) || !clientId.success) {
|
|
1708
|
+
response.writeHead(400, { "Cache-Control": "no-store" });
|
|
1709
|
+
response.end("Invalid pairing handoff");
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const payload = Buffer.from(JSON.stringify({
|
|
1713
|
+
proof: createDeviceProof(options.identity),
|
|
1714
|
+
client: { clientId: clientId.data, label: "Desktop browser" }
|
|
1715
|
+
})).toString("base64url");
|
|
1716
|
+
const redirect = new URL("/connect", returnOrigin);
|
|
1717
|
+
redirect.hash = new URLSearchParams({ payload }).toString();
|
|
1718
|
+
response.writeHead(302, {
|
|
1719
|
+
"Cache-Control": "no-store",
|
|
1720
|
+
"Referrer-Policy": "no-referrer",
|
|
1721
|
+
Location: redirect.toString()
|
|
1722
|
+
});
|
|
1723
|
+
response.end();
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
if (!origin || !allowedOrigins.has(origin)) {
|
|
1727
|
+
response.writeHead(403, { "Cache-Control": "no-store" });
|
|
1728
|
+
response.end("Forbidden");
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
const corsHeaders = {
|
|
1732
|
+
"Access-Control-Allow-Origin": origin,
|
|
1733
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
1734
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
1735
|
+
"Cache-Control": "no-store",
|
|
1736
|
+
Vary: "Origin"
|
|
1737
|
+
};
|
|
1738
|
+
if (request.headers["access-control-request-private-network"] === "true") {
|
|
1739
|
+
corsHeaders["Access-Control-Allow-Private-Network"] = "true";
|
|
1740
|
+
}
|
|
1741
|
+
if (request.method === "OPTIONS") {
|
|
1742
|
+
response.writeHead(204, corsHeaders);
|
|
1743
|
+
response.end();
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
if (request.method !== "GET" || requestUrl.pathname !== "/v1/pairing-proof") {
|
|
1747
|
+
response.writeHead(404, corsHeaders);
|
|
1748
|
+
response.end("Not Found");
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
const body = JSON.stringify({ proof: createDeviceProof(options.identity) });
|
|
1752
|
+
response.writeHead(200, {
|
|
1753
|
+
...corsHeaders,
|
|
1754
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1755
|
+
"Content-Length": Buffer.byteLength(body).toString()
|
|
1756
|
+
});
|
|
1757
|
+
response.end(body);
|
|
1758
|
+
});
|
|
1759
|
+
const listening = await new Promise((resolve) => {
|
|
1760
|
+
const onError = (error) => {
|
|
1761
|
+
server.removeListener("listening", onListening);
|
|
1762
|
+
void options.log("warn", "Local pairing endpoint is unavailable", {
|
|
1763
|
+
port: options.port,
|
|
1764
|
+
reason: error.code ?? error.message
|
|
1765
|
+
});
|
|
1766
|
+
resolve(false);
|
|
1767
|
+
};
|
|
1768
|
+
const onListening = () => {
|
|
1769
|
+
server.removeListener("error", onError);
|
|
1770
|
+
resolve(true);
|
|
1771
|
+
};
|
|
1772
|
+
server.once("error", onError);
|
|
1773
|
+
server.once("listening", onListening);
|
|
1774
|
+
server.listen(options.port, "127.0.0.1");
|
|
1775
|
+
});
|
|
1776
|
+
if (!listening) return null;
|
|
1777
|
+
await options.log("info", "Local pairing endpoint ready", { port: options.port });
|
|
1778
|
+
return {
|
|
1779
|
+
port: options.port,
|
|
1780
|
+
stop: () => closeServer(server)
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/index.ts
|
|
1785
|
+
async function createCodexConnector(options = {}) {
|
|
1786
|
+
const config = resolveConfig(options.config, options.environment);
|
|
1787
|
+
const identity = await loadOrCreateIdentity(config.identityPath);
|
|
1788
|
+
const codex = options.codex ?? new Codex();
|
|
1789
|
+
const sessions = new CodexSessionStore(options.sessionsDir);
|
|
1790
|
+
const adapter = createCodexAdapter({ codex, config, sessions, log: options.log });
|
|
1791
|
+
const bridge = new TumnelBridgeClient({
|
|
1792
|
+
relayUrl: config.relayUrl,
|
|
1793
|
+
identity,
|
|
1794
|
+
adapter
|
|
1795
|
+
});
|
|
1796
|
+
const pairingServer = await startPairingServer({
|
|
1797
|
+
identity,
|
|
1798
|
+
port: config.localPort,
|
|
1799
|
+
relayUrl: config.relayUrl,
|
|
1800
|
+
appOrigins: config.appOrigins,
|
|
1801
|
+
log: (level, message, fields) => adapter.log(level, message, fields)
|
|
1802
|
+
});
|
|
1803
|
+
bridge.start();
|
|
1804
|
+
return {
|
|
1805
|
+
bridge,
|
|
1806
|
+
pairingServer,
|
|
1807
|
+
stop: async () => {
|
|
1808
|
+
await bridge.stop();
|
|
1809
|
+
await pairingServer?.stop();
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// src/cli.ts
|
|
1815
|
+
var printHelp = () => {
|
|
1816
|
+
console.log(`Tumnel connector for Codex
|
|
1817
|
+
|
|
1818
|
+
Usage:
|
|
1819
|
+
tumnel-codex [options]
|
|
1820
|
+
|
|
1821
|
+
Options:
|
|
1822
|
+
--model <model> Codex model id (e.g. gpt-5-mini)
|
|
1823
|
+
--port <port> Local pairing endpoint port (default 43817)
|
|
1824
|
+
--relay-url <url> Tumnel relay URL (default: production relay)
|
|
1825
|
+
--sandbox <mode> Sandbox: read-only | workspace-write | danger-full-access
|
|
1826
|
+
--approval-policy <policy> Approval: never | on-request | on-failure | untrusted
|
|
1827
|
+
--version Print the connector version
|
|
1828
|
+
-h, --help Show this help
|
|
1829
|
+
|
|
1830
|
+
Environment:
|
|
1831
|
+
TUMNEL_RELAY_URL, TUMNEL_APP_ORIGIN, TUMNEL_IDENTITY_PATH, TUMNEL_LOCAL_PORT,
|
|
1832
|
+
TUMNEL_CONFIG_DIR, CODEX_MODEL, CODEX_SANDBOX, CODEX_APPROVAL_POLICY,
|
|
1833
|
+
CODEX_REASONING_EFFORT, CODEX_WEB_SEARCH, CODEX_NETWORK_ACCESS,
|
|
1834
|
+
CODEX_SKIP_GIT_REPO_CHECK, CODEX_HOME
|
|
1835
|
+
|
|
1836
|
+
The connect command starts the Codex agent bridge and keeps it connected to the
|
|
1837
|
+
Tumnel relay until interrupted. Pair from the Tumnel web app when it is online.`);
|
|
1838
|
+
};
|
|
1839
|
+
var printVersion = async () => {
|
|
1840
|
+
const { version } = JSON.parse(
|
|
1841
|
+
await import("fs/promises").then(
|
|
1842
|
+
({ readFile: readFile3 }) => readFile3(new URL("../package.json", import.meta.url), "utf8")
|
|
1843
|
+
)
|
|
1844
|
+
);
|
|
1845
|
+
console.log(`@tumnel/codex ${version}`);
|
|
1846
|
+
};
|
|
1847
|
+
var run = async () => {
|
|
1848
|
+
const command = process.argv[2] ?? "connect";
|
|
1849
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
1850
|
+
printHelp();
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
if (command === "--version" || command === "-v") {
|
|
1854
|
+
printVersion();
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
if (command !== "connect" && !command.startsWith("-")) {
|
|
1858
|
+
throw new Error(`Unknown command: ${command}`);
|
|
1859
|
+
}
|
|
1860
|
+
const args = command === "connect" ? process.argv.slice(3) : process.argv.slice(2);
|
|
1861
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
1862
|
+
printHelp();
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
const versionIndex = args.indexOf("--version");
|
|
1866
|
+
if (versionIndex !== -1) {
|
|
1867
|
+
await printVersion();
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
const config = {};
|
|
1871
|
+
const flag = (name) => {
|
|
1872
|
+
const index = args.indexOf(name);
|
|
1873
|
+
if (index === -1) return;
|
|
1874
|
+
const value = args[index + 1];
|
|
1875
|
+
if (!value) throw new Error(`Missing value for ${name}`);
|
|
1876
|
+
const key = {
|
|
1877
|
+
"--model": "model",
|
|
1878
|
+
"--port": "localPort",
|
|
1879
|
+
"--relay-url": "relayUrl",
|
|
1880
|
+
"--sandbox": "sandboxMode",
|
|
1881
|
+
"--approval-policy": "approvalPolicy"
|
|
1882
|
+
}[name] ?? name;
|
|
1883
|
+
config[key] = name === "--port" ? Number(value) : value;
|
|
1884
|
+
};
|
|
1885
|
+
flag("--model");
|
|
1886
|
+
flag("--port");
|
|
1887
|
+
flag("--relay-url");
|
|
1888
|
+
flag("--sandbox");
|
|
1889
|
+
flag("--approval-policy");
|
|
1890
|
+
const connector = await createCodexConnector({ config });
|
|
1891
|
+
const port = connector.pairingServer?.port;
|
|
1892
|
+
console.log("Tumnel Codex connector started");
|
|
1893
|
+
console.log(` Device ID: ${connector.bridge.deviceId}`);
|
|
1894
|
+
if (port) console.log(` Pairing port: http://127.0.0.1:${port}`);
|
|
1895
|
+
console.log("Press Ctrl+C to stop.\n");
|
|
1896
|
+
let stopping = false;
|
|
1897
|
+
const stop = async () => {
|
|
1898
|
+
if (stopping) return;
|
|
1899
|
+
stopping = true;
|
|
1900
|
+
console.log("\nStopping Tumnel Codex connector...");
|
|
1901
|
+
await connector.stop();
|
|
1902
|
+
process.exit(0);
|
|
1903
|
+
};
|
|
1904
|
+
process.on("SIGINT", () => void stop());
|
|
1905
|
+
process.on("SIGTERM", () => void stop());
|
|
1906
|
+
};
|
|
1907
|
+
run().catch((error) => {
|
|
1908
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1909
|
+
process.exitCode = 1;
|
|
1910
|
+
});
|
|
1911
|
+
//# sourceMappingURL=cli.js.map
|