@wlv-zedd/dsh-chatgpt-web 1.0.0
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/LICENSE +21 -0
- package/README.md +140 -0
- package/assets/demo.gif +0 -0
- package/assets/hero-demo.png +0 -0
- package/assets/promo-dshmarket-official.png +0 -0
- package/cordis.patch.yml +4 -0
- package/lib/cli.js +239642 -0
- package/lib/plugin.js +195 -0
- package/package.json +88 -0
- package/screenshots.json +5 -0
- package/src/adapters/base.ts +16 -0
- package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
- package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
- package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
- package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
- package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
- package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
- package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
- package/src/adapters/chatgpt-web/concurrency.ts +6 -0
- package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
- package/src/adapters/chatgpt-web/environment.ts +669 -0
- package/src/adapters/chatgpt-web/index.ts +1544 -0
- package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
- package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
- package/src/adapters/chatgpt-web/markdown.ts +418 -0
- package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
- package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
- package/src/adapters/chatgpt-web/model.ts +70 -0
- package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
- package/src/adapters/chatgpt-web/output-validation.ts +62 -0
- package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
- package/src/adapters/chatgpt-web/prompt.ts +702 -0
- package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
- package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
- package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
- package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
- package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
- package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
- package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
- package/src/adapters/chatgpt-web/usage.ts +121 -0
- package/src/adapters/image.ts +9 -0
- package/src/bridge.ts +1083 -0
- package/src/browser-login.ts +521 -0
- package/src/chatgpt-session.ts +240 -0
- package/src/chatgpt-web-models.ts +400 -0
- package/src/cli.ts +568 -0
- package/src/codex-integration-document.ts +824 -0
- package/src/codex-integration-journal.ts +212 -0
- package/src/codex-integration-route.ts +515 -0
- package/src/codex-integration-shared.ts +332 -0
- package/src/codex-integration.ts +529 -0
- package/src/codex-interrupt-hook.ts +158 -0
- package/src/config.ts +616 -0
- package/src/dev-chat/cli.ts +432 -0
- package/src/dev-chat/constants.ts +3 -0
- package/src/dev-chat/driver.ts +655 -0
- package/src/dev-chat/profile.ts +223 -0
- package/src/dev-chat/session.ts +287 -0
- package/src/dev-chat/transport.ts +54 -0
- package/src/doctor.ts +237 -0
- package/src/event-queue.ts +45 -0
- package/src/http-body.ts +30 -0
- package/src/launcher-browser-host.ts +695 -0
- package/src/lib/errors.ts +281 -0
- package/src/lib/token-estimate.ts +42 -0
- package/src/login-helper.cjs +140 -0
- package/src/model-catalog.ts +197 -0
- package/src/native-passthrough.ts +261 -0
- package/src/plugin.ts +191 -0
- package/src/process.ts +45 -0
- package/src/responses/compaction.ts +199 -0
- package/src/responses/parser.ts +633 -0
- package/src/responses/reasoning-envelope.ts +49 -0
- package/src/responses/schema.ts +172 -0
- package/src/responses/state.ts +230 -0
- package/src/server.ts +1111 -0
- package/src/service.ts +315 -0
- package/src/setup.ts +671 -0
- package/src/stall-timeout.ts +23 -0
- package/src/tunnel-service.ts +160 -0
- package/src/tunnel.ts +417 -0
- package/src/turndown-plugin-gfm.d.ts +5 -0
- package/src/types.ts +307 -0
- package/src/usage/totals.ts +12 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
fstatSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
13
|
+
import { isDeepStrictEqual } from "node:util";
|
|
14
|
+
import { expandUserPath } from "../../config";
|
|
15
|
+
import { findTopLevelAssignment } from "../../codex-integration-document";
|
|
16
|
+
import type { CodexTool } from "../../types";
|
|
17
|
+
import type {
|
|
18
|
+
ChatGptThreadSpawnLineage,
|
|
19
|
+
ChatGptTurnEnvironment,
|
|
20
|
+
} from "./environment";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
|
|
23
|
+
type SqliteQueryStatement = {
|
|
24
|
+
get: (...params: unknown[]) => unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type SqliteDb = {
|
|
28
|
+
query: (sql: string) => SqliteQueryStatement;
|
|
29
|
+
close: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function openSqliteDatabase(path: string): SqliteDb | undefined {
|
|
33
|
+
if (typeof Bun !== "undefined") {
|
|
34
|
+
try {
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const bunSqlite = require("bun:sqlite") as { Database: new (path: string, options?: unknown) => SqliteDb };
|
|
37
|
+
return new bunSqlite.Database(path, { readonly: true, strict: true });
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const require = createRequire(import.meta.url);
|
|
44
|
+
const nodeSqlite = require("node:sqlite") as {
|
|
45
|
+
DatabaseSync: new (path: string, options?: unknown) => {
|
|
46
|
+
prepare: (sql: string) => { get: (...params: unknown[]) => unknown };
|
|
47
|
+
close: () => void;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const db = new nodeSqlite.DatabaseSync(path, { readOnly: true });
|
|
51
|
+
return {
|
|
52
|
+
query: (sql: string) => db.prepare(sql),
|
|
53
|
+
close: () => db.close(),
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CODEX_ID_SOURCE = "[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
|
|
61
|
+
const CODEX_ID = new RegExp(`^${CODEX_ID_SOURCE}$`, "i");
|
|
62
|
+
const ROLLOUT_READ_CHUNK_BYTES = 64 * 1024;
|
|
63
|
+
const MAX_ROLLOUT_JSON_LINE_BYTES = 16 * 1024 * 1024;
|
|
64
|
+
const MAX_ROLLOUT_DIRECTORY_ENTRIES = 100_000;
|
|
65
|
+
|
|
66
|
+
type IndexedRollout =
|
|
67
|
+
| { kind: "unavailable" }
|
|
68
|
+
| { kind: "absent" }
|
|
69
|
+
| { kind: "found"; path: string };
|
|
70
|
+
|
|
71
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
72
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
73
|
+
? value as Record<string, unknown>
|
|
74
|
+
: undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pathIdentity(value: string): string {
|
|
78
|
+
const normalized = resolve(value);
|
|
79
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function contains(root: string, path: string): boolean {
|
|
83
|
+
const rel = relative(pathIdentity(root), pathIdentity(path));
|
|
84
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function canonicalRolloutName(name: string, threadId: string): boolean {
|
|
88
|
+
const escapedThreadId = threadId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
89
|
+
return new RegExp(
|
|
90
|
+
`^rollout-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-${escapedThreadId}(?:_${CODEX_ID_SOURCE})?\\.jsonl$`,
|
|
91
|
+
"i",
|
|
92
|
+
).test(name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function configuredSqliteHome(codexHome: string, explicit?: string): string {
|
|
96
|
+
if (explicit) return resolve(explicit);
|
|
97
|
+
|
|
98
|
+
const configPath = join(codexHome, "config.toml");
|
|
99
|
+
if (existsSync(configPath)) {
|
|
100
|
+
const configured = findTopLevelAssignment(
|
|
101
|
+
readFileSync(configPath, "utf8").split(/\r\n|\n|\r/),
|
|
102
|
+
"sqlite_home",
|
|
103
|
+
);
|
|
104
|
+
if (configured.present) {
|
|
105
|
+
const value = configured.value?.trim();
|
|
106
|
+
if (!value) throw new Error("sqlite_home in Codex config must not be empty");
|
|
107
|
+
return resolve(codexHome, expandUserPath(value));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const environmentValue = process.env.CODEX_SQLITE_HOME?.trim();
|
|
112
|
+
return resolve(environmentValue ? expandUserPath(environmentValue) : codexHome);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function indexedRollout(
|
|
116
|
+
sqliteHome: string,
|
|
117
|
+
lineage: ChatGptThreadSpawnLineage,
|
|
118
|
+
): IndexedRollout {
|
|
119
|
+
const databasePath = join(sqliteHome, "state_5.sqlite");
|
|
120
|
+
if (!existsSync(databasePath)) return { kind: "unavailable" };
|
|
121
|
+
let database: SqliteDb | undefined;
|
|
122
|
+
try {
|
|
123
|
+
database = openSqliteDatabase(databasePath);
|
|
124
|
+
if (!database) return { kind: "unavailable" };
|
|
125
|
+
const row = database.query(`
|
|
126
|
+
SELECT t.rollout_path, t.agent_path, e.parent_thread_id, e.status
|
|
127
|
+
FROM threads AS t
|
|
128
|
+
JOIN thread_spawn_edges AS e ON e.child_thread_id = t.id
|
|
129
|
+
WHERE t.id = ?
|
|
130
|
+
LIMIT 1
|
|
131
|
+
`).get(lineage.threadId) as {
|
|
132
|
+
rollout_path?: unknown;
|
|
133
|
+
agent_path?: unknown;
|
|
134
|
+
parent_thread_id?: unknown;
|
|
135
|
+
status?: unknown;
|
|
136
|
+
} | null;
|
|
137
|
+
if (!row) return { kind: "absent" };
|
|
138
|
+
if (typeof row.rollout_path !== "string"
|
|
139
|
+
|| row.agent_path !== lineage.agentName
|
|
140
|
+
|| row.parent_thread_id !== lineage.parentThreadId
|
|
141
|
+
|| row.status !== "open") {
|
|
142
|
+
throw new Error("Codex state does not authenticate the requested subagent rollout");
|
|
143
|
+
}
|
|
144
|
+
return { kind: "found", path: row.rollout_path };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof Error && error.message === "Codex state does not authenticate the requested subagent rollout") {
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
// State storage is optional in Codex. A missing/older schema does not create authority; the
|
|
150
|
+
// canonical rollout itself can still prove the exact child, parent, agent path, and turn.
|
|
151
|
+
return { kind: "unavailable" };
|
|
152
|
+
} finally {
|
|
153
|
+
database?.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function validateRolloutPath(codexHome: string, candidate: string, threadId: string): string {
|
|
158
|
+
if (!isAbsolute(candidate)) throw new Error("Codex state returned a non-absolute rollout path");
|
|
159
|
+
const sessionsRoot = realpathSync(join(codexHome, "sessions"));
|
|
160
|
+
if (lstatSync(candidate).isSymbolicLink()) throw new Error("Codex rollout path is a symbolic link");
|
|
161
|
+
const rolloutPath = realpathSync(candidate);
|
|
162
|
+
if (!lstatSync(rolloutPath).isFile()) throw new Error("Codex rollout path is not a regular file");
|
|
163
|
+
if (!contains(sessionsRoot, rolloutPath)) throw new Error("Codex rollout path escapes the sessions directory");
|
|
164
|
+
if (!canonicalRolloutName(basename(rolloutPath), threadId)) {
|
|
165
|
+
throw new Error("Codex rollout filename does not belong to the requested thread");
|
|
166
|
+
}
|
|
167
|
+
return rolloutPath;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function scanCanonicalRollouts(codexHome: string, threadId: string): string[] {
|
|
171
|
+
const sessionsRoot = join(codexHome, "sessions");
|
|
172
|
+
if (!existsSync(sessionsRoot)) return [];
|
|
173
|
+
const matches: string[] = [];
|
|
174
|
+
let visited = 0;
|
|
175
|
+
const visitLevel = (path: string, depth: number): void => {
|
|
176
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
177
|
+
visited += 1;
|
|
178
|
+
if (visited > MAX_ROLLOUT_DIRECTORY_ENTRIES) {
|
|
179
|
+
throw new Error("Codex sessions directory is too large for an unindexed rollout lookup");
|
|
180
|
+
}
|
|
181
|
+
if (entry.isSymbolicLink()) continue;
|
|
182
|
+
const child = join(path, entry.name);
|
|
183
|
+
if (depth < 3) {
|
|
184
|
+
if (entry.isDirectory() && /^\d+$/.test(entry.name)) visitLevel(child, depth + 1);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (entry.isFile() && canonicalRolloutName(entry.name, threadId)) matches.push(child);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
visitLevel(sessionsRoot, 0);
|
|
191
|
+
return matches;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseJsonLine(line: Buffer): Record<string, unknown> {
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(line.toString("utf8").replace(/^\uFEFF/, ""));
|
|
197
|
+
const item = record(parsed);
|
|
198
|
+
if (!item) throw new Error("not an object");
|
|
199
|
+
return item;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new Error("Codex rollout contains an invalid complete JSONL record", { cause: error });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function firstRolloutRecord(fd: number, size: number): Record<string, unknown> {
|
|
206
|
+
let position = 0;
|
|
207
|
+
let buffered = Buffer.alloc(0);
|
|
208
|
+
while (position < size) {
|
|
209
|
+
const length = Math.min(ROLLOUT_READ_CHUNK_BYTES, size - position);
|
|
210
|
+
const chunk = Buffer.alloc(length);
|
|
211
|
+
const count = readSync(fd, chunk, 0, length, position);
|
|
212
|
+
if (count <= 0) break;
|
|
213
|
+
position += count;
|
|
214
|
+
buffered = Buffer.concat([buffered, chunk.subarray(0, count)]);
|
|
215
|
+
const newline = buffered.indexOf(0x0a);
|
|
216
|
+
if (newline >= 0) return parseJsonLine(buffered.subarray(0, newline));
|
|
217
|
+
if (buffered.length > MAX_ROLLOUT_JSON_LINE_BYTES) {
|
|
218
|
+
throw new Error("Codex rollout session metadata exceeds the bounded JSONL record size");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
throw new Error("Codex rollout has no complete session metadata record");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function latestTurnContext(fd: number, size: number): Record<string, unknown> | undefined {
|
|
225
|
+
let position = size;
|
|
226
|
+
let carry = Buffer.alloc(0);
|
|
227
|
+
let firstSegmentAtEof = true;
|
|
228
|
+
const fileEndsWithNewline = (() => {
|
|
229
|
+
if (size === 0) return false;
|
|
230
|
+
const byte = Buffer.alloc(1);
|
|
231
|
+
return readSync(fd, byte, 0, 1, size - 1) === 1 && byte[0] === 0x0a;
|
|
232
|
+
})();
|
|
233
|
+
|
|
234
|
+
while (position > 0) {
|
|
235
|
+
const length = Math.min(ROLLOUT_READ_CHUNK_BYTES, position);
|
|
236
|
+
position -= length;
|
|
237
|
+
const chunk = Buffer.alloc(length);
|
|
238
|
+
const count = readSync(fd, chunk, 0, length, position);
|
|
239
|
+
if (count !== length) throw new Error("Codex rollout changed during authority lookup");
|
|
240
|
+
const data = Buffer.concat([chunk, carry]);
|
|
241
|
+
let lineEnd = data.length;
|
|
242
|
+
for (let index = data.length - 1; index >= 0; index -= 1) {
|
|
243
|
+
if (data[index] !== 0x0a) continue;
|
|
244
|
+
const line = data.subarray(index + 1, lineEnd);
|
|
245
|
+
const trailingPartial = firstSegmentAtEof && !fileEndsWithNewline;
|
|
246
|
+
firstSegmentAtEof = false;
|
|
247
|
+
lineEnd = index;
|
|
248
|
+
if (trailingPartial || line.length === 0) continue;
|
|
249
|
+
if (line.length > MAX_ROLLOUT_JSON_LINE_BYTES) {
|
|
250
|
+
throw new Error("Codex rollout JSONL record exceeds the bounded record size");
|
|
251
|
+
}
|
|
252
|
+
const item = parseJsonLine(line);
|
|
253
|
+
if (item.type === "turn_context") return record(item.payload);
|
|
254
|
+
}
|
|
255
|
+
carry = Buffer.from(data.subarray(0, lineEnd));
|
|
256
|
+
if (carry.length > MAX_ROLLOUT_JSON_LINE_BYTES) {
|
|
257
|
+
throw new Error("Codex rollout JSONL record exceeds the bounded record size");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (carry.length === 0) return undefined;
|
|
261
|
+
const item = parseJsonLine(carry);
|
|
262
|
+
return item.type === "turn_context" ? record(item.payload) : undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function validateSessionMeta(
|
|
266
|
+
item: Record<string, unknown>,
|
|
267
|
+
lineage: ChatGptThreadSpawnLineage,
|
|
268
|
+
): void {
|
|
269
|
+
const payload = record(item.payload);
|
|
270
|
+
const source = record(payload?.source);
|
|
271
|
+
const subagent = record(source?.subagent);
|
|
272
|
+
const spawn = record(subagent?.thread_spawn);
|
|
273
|
+
if (item.type !== "session_meta"
|
|
274
|
+
|| payload?.id !== lineage.threadId
|
|
275
|
+
|| payload.parent_thread_id !== lineage.parentThreadId
|
|
276
|
+
|| payload.agent_path !== lineage.agentName
|
|
277
|
+
|| payload.thread_source !== "subagent"
|
|
278
|
+
|| spawn?.parent_thread_id !== lineage.parentThreadId
|
|
279
|
+
|| spawn.agent_path !== lineage.agentName) {
|
|
280
|
+
throw new Error("Codex rollout session metadata does not authenticate the requested subagent");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function absolutePaths(value: unknown, field: string): string[] {
|
|
285
|
+
if (!Array.isArray(value) || value.some(path => typeof path !== "string" || !isAbsolute(path))) {
|
|
286
|
+
throw new Error(`Codex rollout ${field} is invalid`);
|
|
287
|
+
}
|
|
288
|
+
const unique = new Map<string, string>();
|
|
289
|
+
for (const path of value as string[]) {
|
|
290
|
+
const normalized = resolve(path);
|
|
291
|
+
if (!unique.has(pathIdentity(normalized))) unique.set(pathIdentity(normalized), normalized);
|
|
292
|
+
}
|
|
293
|
+
return [...unique.values()];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function validGlobScanMaxDepth(value: unknown): boolean {
|
|
297
|
+
return value === undefined || (Number.isSafeInteger(value) && (value as number) > 0);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function validRestrictiveEntry(entryValue: unknown): { rootRead: boolean } | undefined {
|
|
301
|
+
const entry = record(entryValue);
|
|
302
|
+
const path = record(entry?.path);
|
|
303
|
+
if (!entry || !path
|
|
304
|
+
|| (entry.access !== "read" && entry.access !== "deny")
|
|
305
|
+
|| (entry.missing_path_behavior !== undefined && entry.missing_path_behavior !== "skip")) return undefined;
|
|
306
|
+
if (path.type === "special") {
|
|
307
|
+
const special = record(path.value)?.kind;
|
|
308
|
+
if (typeof special !== "string" || !special) return undefined;
|
|
309
|
+
return {
|
|
310
|
+
rootRead: special === "root"
|
|
311
|
+
&& entry.access === "read"
|
|
312
|
+
&& entry.missing_path_behavior === undefined,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (path.type === "path") {
|
|
316
|
+
return typeof path.path === "string" && isAbsolute(path.path) ? { rootRead: false } : undefined;
|
|
317
|
+
}
|
|
318
|
+
if (path.type === "glob_pattern") {
|
|
319
|
+
return typeof path.pattern === "string" && path.pattern.length > 0 ? { rootRead: false } : undefined;
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function exactManagedReadOnlyProfile(
|
|
325
|
+
value: Record<string, unknown>,
|
|
326
|
+
expectedNetwork: "enabled" | "restricted",
|
|
327
|
+
): boolean {
|
|
328
|
+
const fileSystem = record(value.file_system);
|
|
329
|
+
const entries = fileSystem?.entries;
|
|
330
|
+
if (fileSystem?.type !== "restricted"
|
|
331
|
+
|| !validGlobScanMaxDepth(fileSystem.glob_scan_max_depth)
|
|
332
|
+
|| !Array.isArray(entries)
|
|
333
|
+
|| value.network !== expectedNetwork) return false;
|
|
334
|
+
let rootReads = 0;
|
|
335
|
+
for (const entry of entries) {
|
|
336
|
+
const restrictive = validRestrictiveEntry(entry);
|
|
337
|
+
if (!restrictive) return false;
|
|
338
|
+
if (restrictive.rootRead) rootReads += 1;
|
|
339
|
+
}
|
|
340
|
+
return rootReads === 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function networkAccess(value: Record<string, unknown>, field: string): boolean {
|
|
344
|
+
if (value.network_access !== undefined && typeof value.network_access !== "boolean") {
|
|
345
|
+
throw new Error(`Codex rollout ${field} network_access is invalid`);
|
|
346
|
+
}
|
|
347
|
+
return value.network_access === true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function splitPolicyMatchesProfile(
|
|
351
|
+
splitValue: unknown,
|
|
352
|
+
profileFileSystem: Record<string, unknown>,
|
|
353
|
+
): boolean {
|
|
354
|
+
if (splitValue === undefined || splitValue === null) return true;
|
|
355
|
+
const split = record(splitValue);
|
|
356
|
+
if (!split) return false;
|
|
357
|
+
const profileType = profileFileSystem.type;
|
|
358
|
+
if (profileType !== "restricted" && profileType !== "unrestricted") return false;
|
|
359
|
+
if (split.kind !== profileType) return false;
|
|
360
|
+
if (profileType === "unrestricted") {
|
|
361
|
+
return split.entries === undefined && split.glob_scan_max_depth === undefined;
|
|
362
|
+
}
|
|
363
|
+
return isDeepStrictEqual(split.entries, profileFileSystem.entries)
|
|
364
|
+
&& split.glob_scan_max_depth === profileFileSystem.glob_scan_max_depth;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function exactManagedWorkspaceWriteProfile(
|
|
368
|
+
profile: Record<string, unknown>,
|
|
369
|
+
roots: string[],
|
|
370
|
+
cwd: string,
|
|
371
|
+
sandbox: Record<string, unknown>,
|
|
372
|
+
): { networkAccess: boolean; writableRoots: string[] } | undefined {
|
|
373
|
+
const fileSystem = record(profile.file_system);
|
|
374
|
+
if (fileSystem?.type !== "restricted"
|
|
375
|
+
|| !validGlobScanMaxDepth(fileSystem.glob_scan_max_depth)
|
|
376
|
+
|| !Array.isArray(fileSystem.entries)
|
|
377
|
+
|| (profile.network !== "restricted" && profile.network !== "enabled")) return undefined;
|
|
378
|
+
|
|
379
|
+
const rawWritableRoots = sandbox.writable_roots ?? [];
|
|
380
|
+
if (!Array.isArray(rawWritableRoots)
|
|
381
|
+
|| rawWritableRoots.some(path => typeof path !== "string" || !isAbsolute(path))
|
|
382
|
+
|| (sandbox.exclude_tmpdir_env_var !== undefined && typeof sandbox.exclude_tmpdir_env_var !== "boolean")
|
|
383
|
+
|| (sandbox.exclude_slash_tmp !== undefined && typeof sandbox.exclude_slash_tmp !== "boolean")) return undefined;
|
|
384
|
+
const expectedWritableRoots = [cwd, ...rawWritableRoots.map(path => resolve(path as string))];
|
|
385
|
+
const uniqueExpectedWritableRoots = [...new Map(expectedWritableRoots.map(path => (
|
|
386
|
+
[pathIdentity(path), path] as const
|
|
387
|
+
))).values()];
|
|
388
|
+
if (uniqueExpectedWritableRoots.length !== expectedWritableRoots.length
|
|
389
|
+
|| uniqueExpectedWritableRoots.some(path => !roots.some(root => contains(root, path)))) return undefined;
|
|
390
|
+
|
|
391
|
+
let rootRead = 0;
|
|
392
|
+
let projectRootsWrite = 0;
|
|
393
|
+
const directWrites: string[] = [];
|
|
394
|
+
const specialWrites = new Set<string>();
|
|
395
|
+
for (const value of fileSystem.entries) {
|
|
396
|
+
const entry = record(value);
|
|
397
|
+
const path = record(entry?.path);
|
|
398
|
+
if (!entry || !path
|
|
399
|
+
|| (entry.access !== "read" && entry.access !== "write" && entry.access !== "deny")
|
|
400
|
+
|| (entry.missing_path_behavior !== undefined && entry.missing_path_behavior !== "skip")) return undefined;
|
|
401
|
+
|
|
402
|
+
if (path.type === "special") {
|
|
403
|
+
const special = record(path.value)?.kind;
|
|
404
|
+
if (special === "root" && entry.access === "read" && entry.missing_path_behavior === undefined) {
|
|
405
|
+
rootRead += 1;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (entry.access !== "write") {
|
|
409
|
+
if (typeof special !== "string" || !special) return undefined;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (special === "project_roots" && entry.access === "write" && entry.missing_path_behavior === undefined) {
|
|
413
|
+
projectRootsWrite += 1;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if ((special === "slash_tmp" || special === "tmpdir")
|
|
417
|
+
&& entry.access === "write"
|
|
418
|
+
&& entry.missing_path_behavior === undefined
|
|
419
|
+
&& !specialWrites.has(special)) {
|
|
420
|
+
specialWrites.add(special);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Read/deny entries only narrow the profile. Codex legitimately adds external worktree gitdirs
|
|
427
|
+
// and protected metadata paths here, so they do not affect the writable authority recovered
|
|
428
|
+
// by this bridge. Every write entry, in contrast, must be one of the exact legacy roots below.
|
|
429
|
+
if (entry.access !== "write") {
|
|
430
|
+
if (path.type === "path") {
|
|
431
|
+
if (typeof path.path !== "string" || !isAbsolute(path.path)) return undefined;
|
|
432
|
+
} else if (path.type === "glob_pattern") {
|
|
433
|
+
if (typeof path.pattern !== "string" || !path.pattern) return undefined;
|
|
434
|
+
} else {
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (path.type !== "path"
|
|
440
|
+
|| typeof path.path !== "string"
|
|
441
|
+
|| !isAbsolute(path.path)
|
|
442
|
+
|| entry.missing_path_behavior !== undefined) return undefined;
|
|
443
|
+
directWrites.push(resolve(path.path));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (rootRead !== 1 || projectRootsWrite > 1) return undefined;
|
|
447
|
+
const uniqueDirectWrites = [...new Map(directWrites.map(path => (
|
|
448
|
+
[pathIdentity(path), path] as const
|
|
449
|
+
))).values()];
|
|
450
|
+
if (uniqueDirectWrites.length !== directWrites.length) return undefined;
|
|
451
|
+
const expectedIdentities = new Set(uniqueExpectedWritableRoots.map(pathIdentity));
|
|
452
|
+
if (uniqueDirectWrites.some(path => !expectedIdentities.has(pathIdentity(path)))) return undefined;
|
|
453
|
+
if (projectRootsWrite === 0 && uniqueDirectWrites.length !== uniqueExpectedWritableRoots.length) return undefined;
|
|
454
|
+
if (projectRootsWrite === 1) {
|
|
455
|
+
const rootIdentities = new Set(roots.map(pathIdentity));
|
|
456
|
+
if (rootIdentities.size !== expectedIdentities.size
|
|
457
|
+
|| [...rootIdentities].some(path => !expectedIdentities.has(path))) return undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const expectsSlashTmp = sandbox.exclude_slash_tmp !== true;
|
|
461
|
+
const expectsTmpdir = sandbox.exclude_tmpdir_env_var !== true;
|
|
462
|
+
if (specialWrites.has("slash_tmp") !== expectsSlashTmp
|
|
463
|
+
|| specialWrites.has("tmpdir") !== expectsTmpdir) return undefined;
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
networkAccess: profile.network === "enabled",
|
|
467
|
+
writableRoots: uniqueExpectedWritableRoots,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function environmentFromTurnContext(
|
|
472
|
+
payload: Record<string, unknown>,
|
|
473
|
+
expectedTurnId: string,
|
|
474
|
+
tools: readonly CodexTool[] | undefined,
|
|
475
|
+
): ChatGptTurnEnvironment {
|
|
476
|
+
if (payload.turn_id !== expectedTurnId) {
|
|
477
|
+
throw new Error("Latest Codex rollout turn context does not belong to the requested turn");
|
|
478
|
+
}
|
|
479
|
+
if (typeof payload.cwd !== "string" || !isAbsolute(payload.cwd)) {
|
|
480
|
+
throw new Error("Codex rollout cwd is invalid");
|
|
481
|
+
}
|
|
482
|
+
const cwd = resolve(payload.cwd);
|
|
483
|
+
const declaredRoots = payload.workspace_roots === undefined
|
|
484
|
+
? []
|
|
485
|
+
: absolutePaths(payload.workspace_roots, "workspace_roots");
|
|
486
|
+
const roots = declaredRoots.length > 0 ? declaredRoots : [cwd];
|
|
487
|
+
if (!roots.some(root => contains(root, cwd))) {
|
|
488
|
+
throw new Error("Codex rollout cwd is outside its workspace roots");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const permissionProfile = record(payload.permission_profile);
|
|
492
|
+
const sandbox = record(payload.sandbox_policy);
|
|
493
|
+
if (!permissionProfile || !sandbox) {
|
|
494
|
+
throw new Error("Codex rollout is missing its authoritative permission profile");
|
|
495
|
+
}
|
|
496
|
+
if (permissionProfile.type === "disabled" && sandbox.type === "danger-full-access") {
|
|
497
|
+
const split = payload.file_system_sandbox_policy;
|
|
498
|
+
if (split !== undefined && split !== null) {
|
|
499
|
+
const unrestricted = record(split);
|
|
500
|
+
if (unrestricted?.kind !== "unrestricted"
|
|
501
|
+
|| unrestricted.entries !== undefined
|
|
502
|
+
|| unrestricted.glob_scan_max_depth !== undefined) {
|
|
503
|
+
throw new Error("Codex rollout split filesystem policy conflicts with full access");
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
cwd,
|
|
508
|
+
roots,
|
|
509
|
+
writableRoots: roots,
|
|
510
|
+
sandboxPolicy: { type: "dangerFullAccess" },
|
|
511
|
+
tools: [...(tools ?? [])],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
if (permissionProfile.type === "managed" && sandbox.type === "read-only") {
|
|
515
|
+
const enabled = networkAccess(sandbox, "read-only");
|
|
516
|
+
const expectedNetwork = enabled ? "enabled" : "restricted";
|
|
517
|
+
const fileSystem = record(permissionProfile.file_system);
|
|
518
|
+
if (!fileSystem
|
|
519
|
+
|| !exactManagedReadOnlyProfile(permissionProfile, expectedNetwork)
|
|
520
|
+
|| !splitPolicyMatchesProfile(payload.file_system_sandbox_policy, fileSystem)) {
|
|
521
|
+
throw new Error("Codex rollout read-only permission profile is inconsistent");
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
cwd,
|
|
525
|
+
roots,
|
|
526
|
+
writableRoots: [],
|
|
527
|
+
sandboxPolicy: { type: "readOnly", networkAccess: enabled },
|
|
528
|
+
tools: [...(tools ?? [])],
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
if (permissionProfile.type === "managed" && sandbox.type === "workspace-write") {
|
|
532
|
+
const fileSystem = record(permissionProfile.file_system);
|
|
533
|
+
const workspace = exactManagedWorkspaceWriteProfile(permissionProfile, roots, cwd, sandbox);
|
|
534
|
+
if (!fileSystem
|
|
535
|
+
|| !workspace
|
|
536
|
+
|| networkAccess(sandbox, "workspace-write") !== workspace.networkAccess
|
|
537
|
+
|| !splitPolicyMatchesProfile(payload.file_system_sandbox_policy, fileSystem)) {
|
|
538
|
+
throw new Error("Codex rollout workspace-write permission profile is inconsistent");
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
cwd,
|
|
542
|
+
roots,
|
|
543
|
+
writableRoots: workspace.writableRoots,
|
|
544
|
+
sandboxPolicy: {
|
|
545
|
+
type: "workspaceWrite",
|
|
546
|
+
writableRoots: workspace.writableRoots,
|
|
547
|
+
networkAccess: workspace.networkAccess,
|
|
548
|
+
},
|
|
549
|
+
tools: [...(tools ?? [])],
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
throw new Error("Codex rollout permission profile cannot be represented safely by the Web bridge");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function validateMetadataConsistency(
|
|
556
|
+
lineage: ChatGptThreadSpawnLineage,
|
|
557
|
+
environment: ChatGptTurnEnvironment,
|
|
558
|
+
): void {
|
|
559
|
+
// Request sandbox/workspace fields are diagnostic only. They narrow a rollout-derived authority
|
|
560
|
+
// here and never create or expand it.
|
|
561
|
+
if (environment.sandboxPolicy.type !== lineage.sandboxType) {
|
|
562
|
+
throw new Error("ChatGPT Web subagent sandbox metadata conflicts with its Codex rollout");
|
|
563
|
+
}
|
|
564
|
+
if (lineage.workspaceRoots.length > 0
|
|
565
|
+
&& !lineage.workspaceRoots.some(root => contains(root, environment.cwd))) {
|
|
566
|
+
throw new Error("ChatGPT Web subagent workspace metadata does not contain its Codex rollout cwd");
|
|
567
|
+
}
|
|
568
|
+
if (lineage.workspaceRoots.some(root => !environment.roots.some(rolloutRoot => (
|
|
569
|
+
contains(rolloutRoot, root) || contains(root, rolloutRoot)
|
|
570
|
+
)))) {
|
|
571
|
+
throw new Error("ChatGPT Web subagent workspace metadata conflicts with its Codex rollout roots");
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function resolveCurrentCodexChildRolloutEnvironment(options: {
|
|
576
|
+
codexHome: string;
|
|
577
|
+
sqliteHome?: string;
|
|
578
|
+
lineage: ChatGptThreadSpawnLineage;
|
|
579
|
+
turnId: string;
|
|
580
|
+
tools?: readonly CodexTool[];
|
|
581
|
+
}): ChatGptTurnEnvironment | undefined {
|
|
582
|
+
const { codexHome, lineage, turnId, tools } = options;
|
|
583
|
+
const nativeThreadId = CODEX_ID.test(lineage.threadId);
|
|
584
|
+
const nativeTurnId = CODEX_ID.test(turnId);
|
|
585
|
+
if (!nativeThreadId && !nativeTurnId) return undefined;
|
|
586
|
+
if (!nativeThreadId || !nativeTurnId || !CODEX_ID.test(lineage.parentThreadId)) {
|
|
587
|
+
throw new Error("Codex subagent lineage contains an invalid native identifier");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const indexed = indexedRollout(configuredSqliteHome(codexHome, options.sqliteHome), lineage);
|
|
591
|
+
const candidates = indexed.kind === "found"
|
|
592
|
+
? [indexed.path]
|
|
593
|
+
: scanCanonicalRollouts(codexHome, lineage.threadId);
|
|
594
|
+
if (candidates.length === 0) {
|
|
595
|
+
throw new Error("Codex has no canonical rollout for the requested subagent thread");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const matching: ChatGptTurnEnvironment[] = [];
|
|
599
|
+
for (const candidate of candidates) {
|
|
600
|
+
const rolloutPath = validateRolloutPath(codexHome, candidate, lineage.threadId);
|
|
601
|
+
const fd = openSync(rolloutPath, "r");
|
|
602
|
+
try {
|
|
603
|
+
const size = fstatSync(fd).size;
|
|
604
|
+
if (!Number.isSafeInteger(size) || size <= 0) throw new Error("Codex rollout is empty");
|
|
605
|
+
validateSessionMeta(firstRolloutRecord(fd, size), lineage);
|
|
606
|
+
const latest = latestTurnContext(fd, size);
|
|
607
|
+
if (!latest) throw new Error("Codex rollout has no complete turn context");
|
|
608
|
+
if (latest.turn_id !== turnId) {
|
|
609
|
+
if (indexed.kind === "found") {
|
|
610
|
+
throw new Error("Latest Codex rollout turn context does not belong to the requested turn");
|
|
611
|
+
}
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const environment = environmentFromTurnContext(latest, turnId, tools);
|
|
615
|
+
validateMetadataConsistency(lineage, environment);
|
|
616
|
+
matching.push(environment);
|
|
617
|
+
} finally {
|
|
618
|
+
closeSync(fd);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (matching.length === 0) {
|
|
622
|
+
throw new Error("Codex has no canonical rollout for the requested current turn");
|
|
623
|
+
}
|
|
624
|
+
if (matching.length > 1) {
|
|
625
|
+
throw new Error("Codex has multiple canonical rollouts for the requested current turn");
|
|
626
|
+
}
|
|
627
|
+
return matching[0]!;
|
|
628
|
+
}
|