@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-demo.png +0 -0
  5. package/assets/promo-dshmarket-official.png +0 -0
  6. package/cordis.patch.yml +4 -0
  7. package/lib/cli.js +239642 -0
  8. package/lib/plugin.js +195 -0
  9. package/package.json +88 -0
  10. package/screenshots.json +5 -0
  11. package/src/adapters/base.ts +16 -0
  12. package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
  13. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
  14. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
  15. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
  16. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
  17. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
  18. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
  19. package/src/adapters/chatgpt-web/concurrency.ts +6 -0
  20. package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
  21. package/src/adapters/chatgpt-web/environment.ts +669 -0
  22. package/src/adapters/chatgpt-web/index.ts +1544 -0
  23. package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
  24. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
  25. package/src/adapters/chatgpt-web/markdown.ts +418 -0
  26. package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
  27. package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
  28. package/src/adapters/chatgpt-web/model.ts +70 -0
  29. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
  30. package/src/adapters/chatgpt-web/output-validation.ts +62 -0
  31. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
  32. package/src/adapters/chatgpt-web/prompt.ts +702 -0
  33. package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
  34. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
  35. package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
  36. package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
  37. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
  38. package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
  39. package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
  40. package/src/adapters/chatgpt-web/usage.ts +121 -0
  41. package/src/adapters/image.ts +9 -0
  42. package/src/bridge.ts +1083 -0
  43. package/src/browser-login.ts +521 -0
  44. package/src/chatgpt-session.ts +240 -0
  45. package/src/chatgpt-web-models.ts +400 -0
  46. package/src/cli.ts +568 -0
  47. package/src/codex-integration-document.ts +824 -0
  48. package/src/codex-integration-journal.ts +212 -0
  49. package/src/codex-integration-route.ts +515 -0
  50. package/src/codex-integration-shared.ts +332 -0
  51. package/src/codex-integration.ts +529 -0
  52. package/src/codex-interrupt-hook.ts +158 -0
  53. package/src/config.ts +616 -0
  54. package/src/dev-chat/cli.ts +432 -0
  55. package/src/dev-chat/constants.ts +3 -0
  56. package/src/dev-chat/driver.ts +655 -0
  57. package/src/dev-chat/profile.ts +223 -0
  58. package/src/dev-chat/session.ts +287 -0
  59. package/src/dev-chat/transport.ts +54 -0
  60. package/src/doctor.ts +237 -0
  61. package/src/event-queue.ts +45 -0
  62. package/src/http-body.ts +30 -0
  63. package/src/launcher-browser-host.ts +695 -0
  64. package/src/lib/errors.ts +281 -0
  65. package/src/lib/token-estimate.ts +42 -0
  66. package/src/login-helper.cjs +140 -0
  67. package/src/model-catalog.ts +197 -0
  68. package/src/native-passthrough.ts +261 -0
  69. package/src/plugin.ts +191 -0
  70. package/src/process.ts +45 -0
  71. package/src/responses/compaction.ts +199 -0
  72. package/src/responses/parser.ts +633 -0
  73. package/src/responses/reasoning-envelope.ts +49 -0
  74. package/src/responses/schema.ts +172 -0
  75. package/src/responses/state.ts +230 -0
  76. package/src/server.ts +1111 -0
  77. package/src/service.ts +315 -0
  78. package/src/setup.ts +671 -0
  79. package/src/stall-timeout.ts +23 -0
  80. package/src/tunnel-service.ts +160 -0
  81. package/src/tunnel.ts +417 -0
  82. package/src/turndown-plugin-gfm.d.ts +5 -0
  83. package/src/types.ts +307 -0
  84. package/src/usage/totals.ts +12 -0
  85. package/src/version.ts +1 -0
@@ -0,0 +1,669 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { isReadableCompactionSummaryText, OPAQUE_COMPACTION_NOTE } from "../../responses/compaction";
5
+ import type { CodexContentPart, CodexParsedRequest, CodexTool } from "../../types";
6
+ import { CHATGPT_WEB_LUNA_MODEL_ID } from "./model";
7
+
8
+ export type ChatGptSandboxPolicy =
9
+ | { type: "dangerFullAccess" }
10
+ | { type: "readOnly"; networkAccess: boolean }
11
+ | { type: "workspaceWrite"; writableRoots: string[]; networkAccess: boolean };
12
+
13
+ export interface ChatGptTurnEnvironment {
14
+ cwd: string;
15
+ roots: string[];
16
+ writableRoots: string[];
17
+ sandboxPolicy: ChatGptSandboxPolicy;
18
+ tools: CodexTool[];
19
+ }
20
+
21
+ export interface ChatGptTurnIdentity {
22
+ threadId?: string;
23
+ turnId?: string;
24
+ parentThreadId?: string;
25
+ agentName?: string;
26
+ subagentKind?: string;
27
+ promptCacheKey?: string;
28
+ }
29
+
30
+ export interface ChatGptThreadSpawnLineage {
31
+ threadId: string;
32
+ parentThreadId: string;
33
+ agentName: string;
34
+ sandboxType: ChatGptSandboxPolicy["type"];
35
+ workspaceRoots: string[];
36
+ }
37
+
38
+ export interface ChatGptTurnUserRevision {
39
+ content: unknown;
40
+ turnId?: string;
41
+ }
42
+
43
+ export const CHATGPT_TURN_REVISION_CONFLICT_MESSAGE =
44
+ "ChatGPT web current user message conflicts with native Codex turn_id metadata";
45
+
46
+ export class MissingTrustedCodexEnvironmentError extends Error {
47
+ constructor(field: string) {
48
+ super(`ChatGPT web turn is missing ${field} in trusted Codex environment context`);
49
+ this.name = "MissingTrustedCodexEnvironmentError";
50
+ }
51
+ }
52
+
53
+ function contentText(content: string | CodexContentPart[]): string {
54
+ if (typeof content === "string") return content;
55
+ return content.filter(part => part.type === "text").map(part => part.text).join("\n");
56
+ }
57
+
58
+ function record(value: unknown): Record<string, unknown> | undefined {
59
+ return value !== null && typeof value === "object" && !Array.isArray(value)
60
+ ? value as Record<string, unknown>
61
+ : undefined;
62
+ }
63
+
64
+ function pathIdentity(value: string): string {
65
+ const normalized = resolve(value);
66
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
67
+ }
68
+
69
+ function clientTurnMetadataFromBody(value: unknown): Record<string, unknown> | undefined {
70
+ const body = record(value);
71
+ const metadata = record(body?.client_metadata);
72
+ const raw = metadata?.["x-codex-turn-metadata"];
73
+ if (typeof raw === "string") {
74
+ try { return record(JSON.parse(raw)); }
75
+ catch { return undefined; }
76
+ }
77
+ return record(raw);
78
+ }
79
+
80
+ function clientTurnMetadata(parsed: CodexParsedRequest): Record<string, unknown> | undefined {
81
+ return clientTurnMetadataFromBody(parsed._rawBody);
82
+ }
83
+
84
+ function itemTurnId(value: unknown): string | undefined {
85
+ const turnId = record(record(value)?.internal_chat_message_metadata_passthrough)?.turn_id;
86
+ return typeof turnId === "string" ? turnId : undefined;
87
+ }
88
+
89
+ function rawMessageText(value: Record<string, unknown>): string {
90
+ if (typeof value.content === "string") return value.content;
91
+ if (!Array.isArray(value.content)) return "";
92
+ return value.content
93
+ .map(part => record(part)?.text)
94
+ .filter((text): text is string => typeof text === "string")
95
+ .join("\n");
96
+ }
97
+
98
+ /** True when the raw Responses input attempted to carry an environment envelope, valid or not. */
99
+ export function hasRawChatGptEnvironmentContext(parsed: CodexParsedRequest): boolean {
100
+ const body = record(parsed._rawBody);
101
+ const input = Array.isArray(body?.input) ? body.input : [];
102
+ return input.some(value => {
103
+ const item = record(value);
104
+ return item?.type === "message" && /<\/?environment_context\b/i.test(rawMessageText(item));
105
+ });
106
+ }
107
+
108
+ function contextualUserMessage(value: Record<string, unknown>): boolean {
109
+ const text = rawMessageText(value).trim();
110
+ return /^<environment_context>[\s\S]*<\/environment_context>$/.test(text)
111
+ || /^<subagent_notification>[\s\S]*<\/subagent_notification>$/.test(text)
112
+ || isReadableCompactionSummaryText(text)
113
+ || text === OPAQUE_COMPACTION_NOTE;
114
+ }
115
+
116
+ function isTurnAbortedNotice(value: Record<string, unknown>): boolean {
117
+ return /^<turn_aborted>[\s\S]*<\/turn_aborted>$/.test(rawMessageText(value).trim());
118
+ }
119
+
120
+ /** Native turn ids that Codex has authoritatively marked as interrupted in this thread. */
121
+ export function priorChatGptAbortedTurnIds(parsed: CodexParsedRequest): string[] {
122
+ const currentTurnId = extractChatGptTurnIdentity(parsed).turnId;
123
+ if (!currentTurnId) return [];
124
+ const body = record(parsed._rawBody);
125
+ const input = Array.isArray(body?.input) ? body.input : [];
126
+ return [...new Set(input.flatMap(value => {
127
+ const item = record(value);
128
+ const abortedTurnId = item ? itemTurnId(item) : undefined;
129
+ return item?.type === "message"
130
+ && item.role === "user"
131
+ && isTurnAbortedNotice(item)
132
+ && abortedTurnId !== undefined
133
+ && abortedTurnId !== currentTurnId
134
+ ? [abortedTurnId]
135
+ : [];
136
+ }))];
137
+ }
138
+
139
+ /**
140
+ * Return the latest real user instruction owned by the current native Codex turn.
141
+ *
142
+ * Provider rounds replay the same instruction and steering appends a newer one. Remote
143
+ * compaction uses this revision to identify and stop the superseded browser response; once Codex
144
+ * installs the replacement history, the immediate continuation starts a fresh browser response
145
+ * under the same logical task revision.
146
+ */
147
+ export function extractChatGptTurnUserRevision(parsed: CodexParsedRequest): unknown {
148
+ const turnId = extractChatGptTurnIdentity(parsed).turnId;
149
+ if (!turnId) throw new Error("ChatGPT web requires native Codex turn_id metadata for browser-session replay");
150
+ const revision = latestChatGptTurnUserRevision(parsed, turnId);
151
+ if (!revision) throw new Error("ChatGPT web requires a current-turn user message for browser-session replay");
152
+ if (revision.turnId !== undefined && revision.turnId !== turnId) {
153
+ throw new Error(CHATGPT_TURN_REVISION_CONFLICT_MESSAGE);
154
+ }
155
+ return revision.content;
156
+ }
157
+
158
+ function latestChatGptTurnUserRevision(parsed: CodexParsedRequest, expectedTurnId?: string): ChatGptTurnUserRevision | undefined {
159
+ const body = record(parsed._rawBody);
160
+ const input = Array.isArray(body?.input) ? body.input : [];
161
+ for (let index = input.length - 1; index >= 0; index -= 1) {
162
+ const item = record(input[index]);
163
+ if (!item) continue;
164
+ const isUserMsg = (item.type === "message" || !item.type) && item.role === "user";
165
+ if (!isUserMsg) continue;
166
+ const messageTurnId = itemTurnId(item);
167
+ // Codex appends an abort report as a user-shaped item carrying the interrupted turn's id. Only
168
+ // suppress that synthetic notice when its metadata proves it belongs to a different turn; a
169
+ // human is still allowed to submit the same XML-looking text as their current instruction.
170
+ if (isTurnAbortedNotice(item)
171
+ && expectedTurnId !== undefined
172
+ && messageTurnId !== undefined
173
+ && messageTurnId !== expectedTurnId) continue;
174
+ if (contextualUserMessage(item)) continue;
175
+ return { content: item.content, ...(messageTurnId ? { turnId: messageTurnId } : {}) };
176
+ }
177
+ for (let index = parsed.context.messages.length - 1; index >= 0; index -= 1) {
178
+ const msg = parsed.context.messages[index];
179
+ if (msg.role === "user") {
180
+ return { content: msg.content };
181
+ }
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ /** The human instruction summarized by a remote compaction request belongs to an earlier turn. */
187
+ export function extractChatGptCompactionSourceRevision(parsed: CodexParsedRequest): ChatGptTurnUserRevision {
188
+ if (!parsed._compactionRequest) throw new Error("ChatGPT web compaction source requires a compaction request");
189
+ const revision = latestChatGptTurnUserRevision(parsed, extractChatGptTurnIdentity(parsed).turnId);
190
+ if (!revision) throw new Error("ChatGPT web compaction requires a source user message");
191
+ return revision;
192
+ }
193
+
194
+ function environmentBeforeUser(input: unknown[], userIndex: number, expectedTurnId?: string): string | undefined {
195
+ if (userIndex <= 0) return undefined;
196
+ const user = record(input[userIndex]);
197
+ if (user?.type !== "message" || user.role !== "user") return undefined;
198
+
199
+ const userTurnId = itemTurnId(user);
200
+ if (!userTurnId || (expectedTurnId && userTurnId !== expectedTurnId)) return undefined;
201
+
202
+ let candidateIndex = userIndex - 1;
203
+ let candidate = record(input[candidateIndex]);
204
+ while (candidate?.type === "message" && candidate.role === "developer") {
205
+ const developerTurnId = itemTurnId(candidate);
206
+ if (developerTurnId !== userTurnId) return undefined;
207
+ candidateIndex -= 1;
208
+ candidate = record(input[candidateIndex]);
209
+ }
210
+ if (candidate?.type !== "message" || candidate.role !== "user") return undefined;
211
+
212
+ const candidateTurnId = itemTurnId(candidate);
213
+ if (candidateTurnId !== userTurnId) return undefined;
214
+
215
+ const content = Array.isArray(candidate.content) ? candidate.content : [];
216
+ for (const part of content) {
217
+ const text = record(part)?.text;
218
+ if (typeof text !== "string") continue;
219
+ const trimmed = text.trim();
220
+ if (/^<environment_context>[\s\S]*<\/environment_context>$/.test(trimmed)) return trimmed;
221
+ }
222
+ return undefined;
223
+ }
224
+
225
+ function sandboxTypeFromEnvironment(text: string): ChatGptSandboxPolicy["type"] | undefined {
226
+ const unrestricted = /<permission_profile\s+type=["']disabled["'][^>]*>[\s\S]*?<file_system\s+type=["']unrestricted["'][^>]*\/?\s*>/i.test(text)
227
+ || /<sandbox_mode>danger-full-access<\/sandbox_mode>/i.test(text);
228
+ const restrictedFileSystem = /<permission_profile\s+type=["']managed["'][^>]*>[\s\S]*?<file_system\s+type=["']restricted["'][^>]*>([\s\S]*?)<\/file_system>/i.exec(text);
229
+ const restrictedHasWriteEntry = restrictedFileSystem !== null
230
+ && /<entry\s+access=["']write["'][^>]*>/i.test(restrictedFileSystem[1]!);
231
+ const workspaceWrite = /<sandbox_mode>workspace-write<\/sandbox_mode>/i.test(text)
232
+ || restrictedHasWriteEntry;
233
+ const readOnly = /<sandbox_mode>read-only<\/sandbox_mode>/i.test(text)
234
+ || (restrictedFileSystem !== null && !restrictedHasWriteEntry);
235
+ if (Number(unrestricted) + Number(workspaceWrite) + Number(readOnly) !== 1) return undefined;
236
+ return unrestricted ? "dangerFullAccess" : workspaceWrite ? "workspaceWrite" : "readOnly";
237
+ }
238
+
239
+ type ChatGptMetadataSandbox = ChatGptSandboxPolicy["type"] | "platform";
240
+
241
+ function canonicalSandboxMetadata(metadata: Record<string, unknown>): unknown {
242
+ return metadata.sandbox_mode ?? metadata.sandbox;
243
+ }
244
+
245
+ function sandboxTypeFromMetadata(value: unknown): ChatGptMetadataSandbox | undefined {
246
+ if (typeof value !== "string") return undefined;
247
+ switch (value.trim().toLowerCase().replaceAll("_", "-")) {
248
+ case "none":
249
+ case "unrestricted":
250
+ case "danger-full-access":
251
+ return "dangerFullAccess";
252
+ case "workspace-write":
253
+ return "workspaceWrite";
254
+ case "read-only":
255
+ return "readOnly";
256
+ // Codex CLI reports the host sandbox mechanism here, while the XML envelope carries the
257
+ // effective filesystem policy. Keep the platform tag as a separate class and validate the
258
+ // actual policy below instead of guessing write access from the platform name.
259
+ case "windows-sandbox":
260
+ case "windows-elevated":
261
+ case "seatbelt":
262
+ case "seccomp":
263
+ return "platform";
264
+ default:
265
+ return undefined;
266
+ }
267
+ }
268
+
269
+ function sandboxMetadataMatchesEnvironment(
270
+ metadataValue: unknown,
271
+ environmentText: string,
272
+ ): boolean {
273
+ const metadataSandbox = sandboxTypeFromMetadata(metadataValue);
274
+ const environmentSandbox = sandboxTypeFromEnvironment(environmentText);
275
+ if (!metadataSandbox || !environmentSandbox) return false;
276
+ if (metadataSandbox === "platform") {
277
+ return environmentSandbox === "workspaceWrite" || environmentSandbox === "readOnly";
278
+ }
279
+ return metadataSandbox === environmentSandbox;
280
+ }
281
+
282
+ function environmentMatchesCanonicalMetadata(
283
+ environmentText: string,
284
+ metadata: Record<string, unknown>,
285
+ requireMetadataBoundRoots: boolean,
286
+ ): boolean {
287
+ const metadataSandboxValue = canonicalSandboxMetadata(metadata);
288
+ const metadataSandbox = sandboxTypeFromMetadata(metadataSandboxValue);
289
+ if (!metadataSandbox) return false;
290
+ const workspaces = record(metadata.workspaces);
291
+ const metadataRoots = workspaces ? Object.keys(workspaces) : [];
292
+ if (metadataRoots.some(path => !isAbsolute(path))) return false;
293
+ const normalizedMetadataRoots = [...new Set(metadataRoots.map(pathIdentity))];
294
+
295
+ let cwdMatches: string[];
296
+ try {
297
+ cwdMatches = environmentCwdMatches(environmentText, normalizedMetadataRoots)
298
+ .map(value => decodeXmlText(value.trim()));
299
+ } catch {
300
+ return false;
301
+ }
302
+ if (cwdMatches.length !== 1 || !isAbsolute(cwdMatches[0]!)) return false;
303
+ const rootMatches = [...environmentText.matchAll(/<workspace_roots>[\s\S]*?<\/workspace_roots>/g)]
304
+ .flatMap(section => [...section[0].matchAll(/<root>([^<]+)<\/root>/g)].map(match => decodeXmlText(match[1]!.trim())));
305
+ const declaredRootValues = rootMatches.length > 0 ? rootMatches : cwdMatches;
306
+ if (declaredRootValues.some(path => !isAbsolute(path))) return false;
307
+ const declaredRoots = [...new Set(declaredRootValues.map(pathIdentity))];
308
+ const cwd = pathIdentity(cwdMatches[0]!);
309
+ if (normalizedMetadataRoots.length > 0
310
+ && !normalizedMetadataRoots.some(root => matchesPath(root, cwd))) return false;
311
+ if (requireMetadataBoundRoots && (
312
+ normalizedMetadataRoots.length === 0
313
+ || declaredRoots.some(root => (
314
+ !normalizedMetadataRoots.some(metadataRoot => matchesPath(metadataRoot, root))
315
+ && !isCurrentThreadVisualizationRoot(root, metadata)
316
+ ))
317
+ )) return false;
318
+ if (!declaredRoots.some(root => matchesPath(root, cwd))) return false;
319
+ return sandboxMetadataMatchesEnvironment(metadataSandboxValue, environmentText);
320
+ }
321
+
322
+ function isCurrentThreadVisualizationRoot(path: string, metadata: Record<string, unknown>): boolean {
323
+ const threadId = typeof metadata.thread_id === "string" ? metadata.thread_id.trim() : "";
324
+ if (!threadId) return false;
325
+
326
+ // Codex advertises its task-scoped visualization output directory in workspace_roots but omits
327
+ // it from Git-oriented turn metadata. Authenticate that one auxiliary shape by both its private
328
+ // Codex home and current thread id; arbitrary roots and another task's output remain untrusted.
329
+ const configuredCodexHome = process.env.CODEX_HOME?.trim();
330
+ const codexHome = resolve(configuredCodexHome || join(homedir(), ".codex"));
331
+ const visualizationBase = pathIdentity(join(codexHome, "visualizations"));
332
+ const rel = relative(visualizationBase, pathIdentity(path));
333
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
334
+
335
+ const parts = rel.split(sep);
336
+ const expectedThreadId = process.platform === "win32" ? threadId.toLowerCase() : threadId;
337
+ return parts.length === 4
338
+ && /^\d{4}$/.test(parts[0]!)
339
+ && /^(?:0[1-9]|1[0-2])$/.test(parts[1]!)
340
+ && /^(?:0[1-9]|[12]\d|3[01])$/.test(parts[2]!)
341
+ && parts[3] === expectedThreadId;
342
+ }
343
+
344
+ function canonicalMetadataEnvironmentBeforeUser(
345
+ input: unknown[],
346
+ userIndex: number,
347
+ metadata: Record<string, unknown> | undefined,
348
+ requireMetadataBoundRoots = false,
349
+ ): string | undefined {
350
+ if (userIndex <= 0 || !metadata) return undefined;
351
+ const metadataTurnId = typeof metadata.turn_id === "string" ? metadata.turn_id.trim() : "";
352
+ const metadataSandbox = sandboxTypeFromMetadata(canonicalSandboxMetadata(metadata));
353
+ if (!metadataTurnId || !metadataSandbox) return undefined;
354
+
355
+ const user = record(input[userIndex]);
356
+ if (user?.type !== "message" || user.role !== "user" || typeof user.id !== "string" || !user.id) return undefined;
357
+ const userTurnId = itemTurnId(user);
358
+ if (userTurnId !== undefined && userTurnId !== metadataTurnId) return undefined;
359
+
360
+ let candidateIndex = userIndex - 1;
361
+ let candidate = record(input[candidateIndex]);
362
+ while (candidate?.type === "message" && candidate.role === "developer") {
363
+ const developerTurnId = itemTurnId(candidate);
364
+ const serverOwnedId = typeof candidate.id === "string" && candidate.id.length > 0;
365
+ if (developerTurnId === undefined ? !serverOwnedId : developerTurnId !== metadataTurnId) return undefined;
366
+ candidateIndex -= 1;
367
+ candidate = record(input[candidateIndex]);
368
+ }
369
+ if (candidate?.type !== "message" || candidate.role !== "user" || typeof candidate.id !== "string" || !candidate.id) return undefined;
370
+ const candidateTurnId = itemTurnId(candidate);
371
+ if (candidateTurnId !== undefined && candidateTurnId !== metadataTurnId) return undefined;
372
+
373
+ const content = Array.isArray(candidate.content) ? candidate.content : [];
374
+ for (const part of content) {
375
+ const text = record(part)?.text;
376
+ if (typeof text !== "string") continue;
377
+ const trimmed = text.trim();
378
+ if (!/^<environment_context>[\s\S]*<\/environment_context>$/.test(trimmed)) continue;
379
+ // Current Codex stamps server-owned item IDs but not per-item turn IDs on the initial request,
380
+ // and canonical workspaces contains Git enrichment rather than filesystem authority. Bind the
381
+ // structurally adjacent context (allowing only provenance-checked developer messages) to
382
+ // canonical turn/sandbox metadata; when Git roots are present, require the primary cwd to agree
383
+ // with them as an additional check.
384
+ if (!environmentMatchesCanonicalMetadata(trimmed, metadata, requireMetadataBoundRoots)) continue;
385
+ return trimmed;
386
+ }
387
+ return undefined;
388
+ }
389
+
390
+ function hasAssistantOutputBetween(input: unknown[], startIndex: number, endIndex: number): boolean {
391
+ for (let index = startIndex; index < endIndex; index += 1) {
392
+ const item = record(input[index]);
393
+ if (!item) continue;
394
+ if (item.type === "message" && item.role === "assistant") return true;
395
+ if (item.type === "function_call" || item.type === "reasoning") return true;
396
+ }
397
+ return false;
398
+ }
399
+
400
+ function rawEnvironmentText(parsed: CodexParsedRequest): string | undefined {
401
+ const body = record(parsed._rawBody);
402
+ const input = Array.isArray(body?.input) ? body.input : [];
403
+ let activeUserIndex = -1;
404
+ for (let index = input.length - 1; index >= 0; index -= 1) {
405
+ const item = record(input[index]);
406
+ if (item?.role === "user" && !contextualUserMessage(item)) {
407
+ activeUserIndex = index;
408
+ break;
409
+ }
410
+ }
411
+ const turnId = clientTurnMetadata(parsed)?.turn_id;
412
+ const currentByTurn = environmentBeforeUser(
413
+ input,
414
+ activeUserIndex,
415
+ typeof turnId === "string" ? turnId : undefined,
416
+ );
417
+ if (currentByTurn) return currentByTurn;
418
+
419
+ const current = canonicalMetadataEnvironmentBeforeUser(input, activeUserIndex, clientTurnMetadata(parsed));
420
+ if (current) return current;
421
+
422
+ // A skill invocation appends another server-owned user item after the real instruction. Recover
423
+ // the earlier current-turn environment/prompt pair only through canonical metadata, and bind all
424
+ // declared roots to metadata workspaces so user-authored XML cannot widen filesystem authority.
425
+ const metadata = clientTurnMetadata(parsed);
426
+ for (let index = activeUserIndex - 1; index > 0; index -= 1) {
427
+ const sameTurn = canonicalMetadataEnvironmentBeforeUser(input, index, metadata, true);
428
+ if (sameTurn) return sameTurn;
429
+ }
430
+
431
+ const replayPrefixLen = Math.min(parsed._replayPrefixLen ?? 0, input.length);
432
+ for (let index = replayPrefixLen - 1; index > 0; index -= 1) {
433
+ const replayed = environmentBeforeUser(input, index);
434
+ if (replayed) return replayed;
435
+ }
436
+
437
+ // Codex can resume a local task by explicitly replaying its native transcript instead of
438
+ // sending previous_response_id. In that shape, accept a historical environment/user pair only
439
+ // when both items carry the same native turn_id and either completed assistant output separates
440
+ // that turn from the active user or the complete historical pair is server-owned and its
441
+ // filesystem authority still matches the current thread's canonical workspace/sandbox metadata.
442
+ // A user-authored <environment_context> inside one chat message cannot satisfy this structure.
443
+ const currentTurnId = typeof turnId === "string" ? turnId : undefined;
444
+ const currentThreadId = typeof metadata?.thread_id === "string" && metadata.thread_id.trim()
445
+ ? metadata.thread_id
446
+ : undefined;
447
+ const activeUser = record(input[activeUserIndex]);
448
+ const activeUserOwned = activeUser?.type === "message"
449
+ && activeUser.role === "user"
450
+ && typeof activeUser.id === "string"
451
+ && activeUser.id.length > 0
452
+ && itemTurnId(activeUser) === currentTurnId;
453
+ if (currentTurnId && itemTurnId(activeUser) === currentTurnId) {
454
+ for (let index = activeUserIndex - 1; index > 0; index -= 1) {
455
+ const historicalUser = record(input[index]);
456
+ const historicalTurnId = itemTurnId(historicalUser);
457
+ if (!historicalTurnId || historicalTurnId === currentTurnId) continue;
458
+ const historical = environmentBeforeUser(input, index);
459
+ if (!historical) continue;
460
+ if (hasAssistantOutputBetween(input, index + 1, activeUserIndex)) return historical;
461
+ if (!currentThreadId || !metadata || !activeUserOwned) continue;
462
+ const bounded = canonicalMetadataEnvironmentBeforeUser(
463
+ input,
464
+ index,
465
+ { ...metadata, turn_id: historicalTurnId, sandbox: canonicalSandboxMetadata(metadata) },
466
+ true,
467
+ );
468
+ if (bounded === historical) return bounded;
469
+ }
470
+ }
471
+ return undefined;
472
+ }
473
+
474
+ function clientMetadataWorkspaceRoots(parsed: CodexParsedRequest): string[] {
475
+ const workspaces = record(clientTurnMetadata(parsed)?.workspaces);
476
+ if (!workspaces) return [];
477
+ const roots = Object.keys(workspaces);
478
+ if (roots.some(path => !isAbsolute(path))) return [];
479
+ return [...new Set(roots.map(pathIdentity))];
480
+ }
481
+
482
+ function trustedEnvironmentText(parsed: CodexParsedRequest): string {
483
+ const raw = rawEnvironmentText(parsed);
484
+ if (raw) return raw;
485
+ // A real Responses request always has `_rawBody`. Parsed system/developer text has already lost
486
+ // the wire provenance needed to distinguish Codex context from user-authored XML, so it must
487
+ // never become filesystem authority for a raw request.
488
+ if (parsed._rawBody !== undefined) return "";
489
+ const system = parsed.context.systemPrompt ?? [];
490
+ const developer = parsed.context.messages
491
+ .filter(message => message.role === "developer")
492
+ .map(message => contentText(message.content));
493
+ return [...system, ...developer].join("\n");
494
+ }
495
+
496
+ function decodeXmlText(value: string): string {
497
+ return value
498
+ .replaceAll("&lt;", "<")
499
+ .replaceAll("&gt;", ">")
500
+ .replaceAll("&amp;", "&")
501
+ .replaceAll("&quot;", "\"")
502
+ .replaceAll("&#39;", "'");
503
+ }
504
+
505
+ function environmentCwdMatches(text: string, preferredRoots: string[] = []): string[] {
506
+ const sections = [...text.matchAll(/<environments>([\s\S]*?)<\/environments>/gi)];
507
+ if (sections.length === 0) {
508
+ const cwdMatches = [...text.matchAll(/<cwd>([^<]+)<\/cwd>/gi)].map(match => match[1] ?? "");
509
+ if (cwdMatches.length > 0 || /<\/?cwd\b/i.test(text)) return cwdMatches;
510
+
511
+ // Codex Desktop 0.150+ can emit a filesystem-only environment diff when an existing task is
512
+ // rebound to another model. Its ordered multi-folder contract uses the first workspace root as
513
+ // the task's working directory and the remaining roots as additional filesystem authority.
514
+ // Recover only that exact cwd-less shape; malformed cwd markup and multi-environment payloads
515
+ // continue to fail closed.
516
+ const rootSections = [...text.matchAll(/<workspace_roots>[\s\S]*?<\/workspace_roots>/gi)];
517
+ if (rootSections.length !== 1) return [];
518
+ const rootSection = rootSections[0]![0];
519
+ const roots = [...rootSection.matchAll(/<root>([^<]+)<\/root>/gi)]
520
+ .map(match => match[1] ?? "");
521
+ const rootOpenings = [...rootSection.matchAll(/<root\b[^>]*>/gi)];
522
+ const rootClosings = [...rootSection.matchAll(/<\/root\s*>/gi)];
523
+ if (rootOpenings.length !== roots.length || rootClosings.length !== roots.length) return [];
524
+ return roots.length > 0 ? [roots[0]!] : [];
525
+ }
526
+ if (sections.length !== 1) return [];
527
+
528
+ const section = sections[0]!;
529
+ const outside = text.replace(section[0], "");
530
+ if (/<cwd>[^<]*<\/cwd>/i.test(outside)) return [];
531
+
532
+ const environments = [...section[1]!.matchAll(/<environment\b([^>]*)>([\s\S]*?)<\/environment>/gi)];
533
+ const primary = environments.filter(match => /\bprimary\s*=\s*["']true["']/i.test(match[1] ?? ""));
534
+ if (primary.length === 1) {
535
+ return [...primary[0]![2]!.matchAll(/<cwd>([^<]+)<\/cwd>/gi)].map(match => match[1] ?? "");
536
+ }
537
+ if (primary.length > 1) return [];
538
+
539
+ // Codex 0.146.x emitted multiple environments without a primary attribute. Only use that
540
+ // legacy shape when canonical workspace metadata identifies one candidate; never pick by order.
541
+ const candidates = environments.flatMap(environment => {
542
+ const cwdMatches = [...environment[2]!.matchAll(/<cwd>([^<]+)<\/cwd>/gi)]
543
+ .map(match => match[1] ?? "");
544
+ return cwdMatches.length === 1 ? cwdMatches : [];
545
+ });
546
+ if (candidates.length === 1) return candidates;
547
+ if (preferredRoots.length === 0) return [];
548
+
549
+ const exact = candidates.filter(candidate => preferredRoots
550
+ .some(root => pathIdentity(root) === pathIdentity(candidate)));
551
+ if (exact.length === 1) return exact;
552
+ const contained = candidates.filter(candidate => preferredRoots
553
+ .some(root => matchesPath(root, candidate)));
554
+ return contained.length === 1 ? contained : [];
555
+ }
556
+
557
+ function uniqueAbsolutePaths(values: string[], field: string): string[] {
558
+ const decoded = values.map(value => decodeXmlText(value.trim()));
559
+ if (decoded.length === 0) throw new MissingTrustedCodexEnvironmentError(field);
560
+ if (decoded.some(path => !isAbsolute(path))) throw new Error(`ChatGPT web ${field} must contain absolute paths`);
561
+ const unique = new Map<string, string>();
562
+ for (const path of decoded.map(value => resolve(value))) {
563
+ if (!unique.has(pathIdentity(path))) unique.set(pathIdentity(path), path);
564
+ }
565
+ return [...unique.values()];
566
+ }
567
+
568
+ function matchesPath(root: string, path: string): boolean {
569
+ const rel = relative(pathIdentity(root), pathIdentity(path));
570
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
571
+ }
572
+
573
+ export function extractChatGptTurnEnvironment(parsed: CodexParsedRequest): ChatGptTurnEnvironment {
574
+ const text = trustedEnvironmentText(parsed);
575
+ const cwdMatches = environmentCwdMatches(text, clientMetadataWorkspaceRoots(parsed));
576
+ const cwdCandidates = uniqueAbsolutePaths(cwdMatches, "cwd");
577
+ if (cwdCandidates.length !== 1) throw new Error("ChatGPT web turn has conflicting trusted Codex cwd values");
578
+ const cwd = cwdCandidates[0]!;
579
+
580
+ const rootMatches = [...text.matchAll(/<workspace_roots>[\s\S]*?<\/workspace_roots>/g)]
581
+ .flatMap(section => [...section[0].matchAll(/<root>([^<]+)<\/root>/g)].map(match => match[1] ?? ""));
582
+ const roots = rootMatches.length > 0 ? uniqueAbsolutePaths(rootMatches, "workspace_roots") : [cwd];
583
+ if (!roots.some(root => matchesPath(root, cwd))) {
584
+ throw new Error("ChatGPT web cwd is outside the trusted Codex workspace roots");
585
+ }
586
+
587
+ const sandboxType = sandboxTypeFromEnvironment(text);
588
+ const networkAccess = /<network_access>enabled<\/network_access>/i.test(text)
589
+ || /network access is enabled/i.test(text);
590
+
591
+ if (!sandboxType) {
592
+ throw new Error("ChatGPT web turn requires one explicit trusted Codex sandbox mode");
593
+ }
594
+ if (sandboxType === "dangerFullAccess") {
595
+ return { cwd, roots, writableRoots: roots, sandboxPolicy: { type: "dangerFullAccess" }, tools: parsed.context.tools ?? [] };
596
+ }
597
+ if (sandboxType === "workspaceWrite") {
598
+ return {
599
+ cwd,
600
+ roots,
601
+ writableRoots: roots,
602
+ sandboxPolicy: { type: "workspaceWrite", writableRoots: roots, networkAccess },
603
+ tools: parsed.context.tools ?? [],
604
+ };
605
+ }
606
+ return { cwd, roots, writableRoots: [], sandboxPolicy: { type: "readOnly", networkAccess }, tools: parsed.context.tools ?? [] };
607
+ }
608
+
609
+ export function extractChatGptTurnIdentity(parsed: CodexParsedRequest): ChatGptTurnIdentity {
610
+ const body = record(parsed._rawBody);
611
+ const base = extractCodexTurnIdentityFromBody(body);
612
+ if (!base.turnId && parsed.modelId === CHATGPT_WEB_LUNA_MODEL_ID) {
613
+ const contentHash = createHash("sha256")
614
+ .update(JSON.stringify(parsed.context.messages))
615
+ .digest("hex")
616
+ .slice(0, 16);
617
+ return {
618
+ threadId: base.threadId ?? "dsh-session",
619
+ turnId: `dsh-luna-${contentHash}`,
620
+ ...(base.parentThreadId ? { parentThreadId: base.parentThreadId } : {}),
621
+ ...(base.agentName ? { agentName: base.agentName } : {}),
622
+ ...(base.subagentKind ? { subagentKind: base.subagentKind } : {}),
623
+ ...(typeof body?.prompt_cache_key === "string" ? { promptCacheKey: body.prompt_cache_key } : {}),
624
+ };
625
+ }
626
+ return {
627
+ ...base,
628
+ ...(typeof body?.prompt_cache_key === "string" ? { promptCacheKey: body.prompt_cache_key } : {}),
629
+ };
630
+ }
631
+
632
+ /** Read only Codex-owned lifecycle identity without interpreting or rewriting the provider body. */
633
+ export function extractCodexTurnIdentityFromBody(value: unknown): ChatGptTurnIdentity {
634
+ const metadata = clientTurnMetadataFromBody(value);
635
+ const threadId = typeof metadata?.thread_id === "string" && metadata.thread_id.trim() ? metadata.thread_id.trim() : undefined;
636
+ const turnId = typeof metadata?.turn_id === "string" && metadata.turn_id.trim() ? metadata.turn_id.trim() : undefined;
637
+ return {
638
+ ...(threadId ? { threadId } : {}),
639
+ ...(turnId ? { turnId } : {}),
640
+ ...(typeof metadata?.parent_thread_id === "string" ? { parentThreadId: metadata.parent_thread_id } : {}),
641
+ ...(typeof metadata?.agent_name === "string" ? { agentName: metadata.agent_name } : {}),
642
+ ...(typeof metadata?.subagent_kind === "string" ? { subagentKind: metadata.subagent_kind } : {}),
643
+ };
644
+ }
645
+
646
+ /**
647
+ * Return the canonical parent link carried by a native Codex thread-spawn request.
648
+ * This is deliberately stricter than generic metadata parsing: only a real child turn with an
649
+ * agent path, explicit turn purpose, sandbox policy, and absolute workspace evidence can inherit
650
+ * filesystem authority from a previously verified parent thread.
651
+ */
652
+ export function extractChatGptThreadSpawnLineage(
653
+ parsed: CodexParsedRequest,
654
+ ): ChatGptThreadSpawnLineage | undefined {
655
+ const metadata = clientTurnMetadata(parsed);
656
+ if (!metadata || metadata.request_kind !== "turn" || metadata.subagent_kind !== "thread_spawn") return undefined;
657
+ const threadId = typeof metadata.thread_id === "string" ? metadata.thread_id.trim() : "";
658
+ const parentThreadId = typeof metadata.parent_thread_id === "string" ? metadata.parent_thread_id.trim() : "";
659
+ const agentName = typeof metadata.agent_name === "string" ? metadata.agent_name.trim() : "";
660
+ if (!threadId || !parentThreadId || threadId === parentThreadId || !/^\/root\/.+/.test(agentName)) return undefined;
661
+
662
+ const sandboxType = sandboxTypeFromMetadata(canonicalSandboxMetadata(metadata));
663
+ if (!sandboxType || sandboxType === "platform") return undefined;
664
+ const workspaces = record(metadata.workspaces);
665
+ const workspacePaths = workspaces ? Object.keys(workspaces) : [];
666
+ if (workspacePaths.some(path => !isAbsolute(path))) return undefined;
667
+ const workspaceRoots = [...new Set(workspacePaths.map(path => resolve(path)))];
668
+ return { threadId, parentThreadId, agentName, sandboxType, workspaceRoots };
669
+ }