@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,73 @@
|
|
|
1
|
+
import { ChatGptWebAdapterError } from "./adapter-error";
|
|
2
|
+
|
|
3
|
+
/** Maximum number of automatic browser-turn retries after the initial send. */
|
|
4
|
+
export const MAX_CHATGPT_WEB_TURN_RETRIES = 3;
|
|
5
|
+
const RETRY_BUDGET_TTL_MS = 30 * 60_000;
|
|
6
|
+
|
|
7
|
+
interface RetryBudgetEntry {
|
|
8
|
+
retries: number;
|
|
9
|
+
updatedAt: number;
|
|
10
|
+
lastError: {
|
|
11
|
+
message: string;
|
|
12
|
+
status: number;
|
|
13
|
+
errorType: string;
|
|
14
|
+
code: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function exhaustedError(entry: RetryBudgetEntry): ChatGptWebAdapterError {
|
|
19
|
+
return new ChatGptWebAdapterError(
|
|
20
|
+
`${entry.lastError.message} ChatGPT remained unavailable after several attempts.`,
|
|
21
|
+
{
|
|
22
|
+
status: entry.lastError.status,
|
|
23
|
+
errorType: entry.lastError.errorType,
|
|
24
|
+
code: entry.lastError.code,
|
|
25
|
+
retryable: false,
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Tracks only retryable ChatGPT browser failures across adapter instances. The HTTP bridge creates
|
|
32
|
+
* one adapter per request, so this process-local budget must live outside createChatGptWebAdapter.
|
|
33
|
+
*/
|
|
34
|
+
export class ChatGptWebTurnRetryPolicy {
|
|
35
|
+
private readonly entries = new Map<string, RetryBudgetEntry>();
|
|
36
|
+
|
|
37
|
+
constructor(private readonly ttlMs = RETRY_BUDGET_TTL_MS) {}
|
|
38
|
+
|
|
39
|
+
recordRetryableFailure(key: string, error: ChatGptWebAdapterError, now = Date.now()): ChatGptWebAdapterError {
|
|
40
|
+
this.prune(now);
|
|
41
|
+
const previous = this.entries.get(key);
|
|
42
|
+
const entry: RetryBudgetEntry = {
|
|
43
|
+
retries: (previous?.retries ?? 0) + 1,
|
|
44
|
+
updatedAt: now,
|
|
45
|
+
lastError: {
|
|
46
|
+
message: error.message,
|
|
47
|
+
status: error.status,
|
|
48
|
+
errorType: error.errorType,
|
|
49
|
+
code: error.code,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
this.entries.set(key, entry);
|
|
53
|
+
return entry.retries > MAX_CHATGPT_WEB_TURN_RETRIES ? exhaustedError(entry) : error;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
exhaustedError(key: string, now = Date.now()): ChatGptWebAdapterError | undefined {
|
|
57
|
+
this.prune(now);
|
|
58
|
+
const entry = this.entries.get(key);
|
|
59
|
+
return entry && entry.retries > MAX_CHATGPT_WEB_TURN_RETRIES ? exhaustedError(entry) : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
clear(key: string): void {
|
|
63
|
+
this.entries.delete(key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private prune(now: number): void {
|
|
67
|
+
for (const [key, entry] of this.entries) {
|
|
68
|
+
if (now - entry.updatedAt >= this.ttlMs) this.entries.delete(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const chatGptWebTurnRetryPolicy = new ChatGptWebTurnRetryPolicy();
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { atomicWriteFile } from "../../config";
|
|
4
|
+
import { estimateTokens } from "../../lib/token-estimate";
|
|
5
|
+
import { parseRequest } from "../../responses/parser";
|
|
6
|
+
import type { CodexParsedRequest } from "../../types";
|
|
7
|
+
import * as z from "zod/v4";
|
|
8
|
+
import { extractChatGptTurnIdentity, extractChatGptTurnUserRevision } from "./environment";
|
|
9
|
+
|
|
10
|
+
// Alphanumeric by design: ChatGPT's DOM-to-Markdown serializer escapes `_`, `*`, and brackets.
|
|
11
|
+
export const CHATGPT_LUNA_CHECKPOINT_MARKER = "CODEXLUNAPRIVATECHECKPOINTV1A7F3C9D2";
|
|
12
|
+
export const CHATGPT_LUNA_CHECKPOINT_MAX_TOKENS = 4_000;
|
|
13
|
+
|
|
14
|
+
const legacyCheckpointString = z.string().trim().min(1).max(1_200);
|
|
15
|
+
const legacyCheckpointSchema = z.object({
|
|
16
|
+
version: z.literal(1),
|
|
17
|
+
objective: z.string().trim().min(1).max(2_000),
|
|
18
|
+
state: z.array(legacyCheckpointString).max(32),
|
|
19
|
+
evidence: z.array(legacyCheckpointString).max(32),
|
|
20
|
+
decisions: z.array(legacyCheckpointString).max(32),
|
|
21
|
+
pending: z.array(legacyCheckpointString).max(32),
|
|
22
|
+
}).strict();
|
|
23
|
+
const textCheckpointSchema = z.object({
|
|
24
|
+
version: z.literal(2),
|
|
25
|
+
summary: z.string().trim().min(1).max(24_000),
|
|
26
|
+
}).strict();
|
|
27
|
+
const checkpointSchema = z.discriminatedUnion("version", [legacyCheckpointSchema, textCheckpointSchema]);
|
|
28
|
+
|
|
29
|
+
export type ChatGptLunaCheckpoint = z.infer<typeof checkpointSchema>;
|
|
30
|
+
|
|
31
|
+
export interface CapturedChatGptLunaCheckpoint {
|
|
32
|
+
checkpoint: ChatGptLunaCheckpoint;
|
|
33
|
+
answerHash: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CompletedChatGptLunaCheckpoint {
|
|
37
|
+
answer: string;
|
|
38
|
+
visibleRemainder: string;
|
|
39
|
+
captured?: CapturedChatGptLunaCheckpoint;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface StoredChatGptLunaCheckpoint extends CapturedChatGptLunaCheckpoint {
|
|
43
|
+
threadId: string;
|
|
44
|
+
sourceTurnId: string;
|
|
45
|
+
updatedAt: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface StoredChatGptLunaCheckpointFile {
|
|
49
|
+
version: 1;
|
|
50
|
+
checkpoints: StoredChatGptLunaCheckpoint[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const MAX_STORED_CHECKPOINTS = 512;
|
|
54
|
+
const CHECKPOINT_TTL_MS = 30 * 24 * 60 * 60_000;
|
|
55
|
+
const VISIBLE_MARKER_RESERVE_CHARS = CHATGPT_LUNA_CHECKPOINT_MARKER.length + 16;
|
|
56
|
+
|
|
57
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
58
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
59
|
+
? value as Record<string, unknown>
|
|
60
|
+
: undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function itemTurnId(value: unknown): string | undefined {
|
|
64
|
+
const turnId = record(record(value)?.internal_chat_message_metadata_passthrough)?.turn_id;
|
|
65
|
+
return typeof turnId === "string" ? turnId : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function checkpointKey(threadId: string, answerHash: string): string {
|
|
69
|
+
return `${threadId}\u0000${answerHash}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function canonicalAnswer(answer: string): string {
|
|
73
|
+
return answer.replaceAll("\r\n", "\n").trimEnd();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function hashChatGptLunaAnswer(answer: string): string {
|
|
77
|
+
return createHash("sha256").update(canonicalAnswer(answer)).digest("hex");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parseChatGptLunaCheckpoint(value: unknown): ChatGptLunaCheckpoint {
|
|
81
|
+
const checkpoint = checkpointSchema.parse(value);
|
|
82
|
+
const tokens = estimateTokens(JSON.stringify(checkpoint));
|
|
83
|
+
if (tokens > CHATGPT_LUNA_CHECKPOINT_MAX_TOKENS) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`ChatGPT Luna rolling checkpoint requires ${tokens.toLocaleString("en-US")} tokens; maximum is ${CHATGPT_LUNA_CHECKPOINT_MAX_TOKENS.toLocaleString("en-US")}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return checkpoint;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseCheckpointText(text: string): ChatGptLunaCheckpoint {
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
if (!trimmed) throw new Error("ChatGPT Luna did not provide a rolling checkpoint");
|
|
94
|
+
// Luna supplies semantic state, not transport syntax. The bridge owns serialization so quotes,
|
|
95
|
+
// backslashes, control characters, and copied user text cannot make the checkpoint malformed.
|
|
96
|
+
return parseChatGptLunaCheckpoint({ version: 2, summary: trimmed });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Splits the model's final Markdown stream at the private checkpoint marker. A marker-sized tail is
|
|
101
|
+
* held back so a marker split across DOM snapshots can never leak into the outer Codex answer.
|
|
102
|
+
*/
|
|
103
|
+
export class ChatGptLunaCheckpointStream {
|
|
104
|
+
private pending = "";
|
|
105
|
+
private checkpointText = "";
|
|
106
|
+
private visibleAnswer = "";
|
|
107
|
+
private markerSeen = false;
|
|
108
|
+
|
|
109
|
+
push(delta: string): string {
|
|
110
|
+
if (!delta) return "";
|
|
111
|
+
if (this.markerSeen) {
|
|
112
|
+
this.checkpointText += delta;
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.pending += delta;
|
|
117
|
+
const markerIndex = this.pending.indexOf(CHATGPT_LUNA_CHECKPOINT_MARKER);
|
|
118
|
+
if (markerIndex >= 0) {
|
|
119
|
+
const visible = this.pending.slice(0, markerIndex).trimEnd();
|
|
120
|
+
this.checkpointText = this.pending.slice(markerIndex + CHATGPT_LUNA_CHECKPOINT_MARKER.length);
|
|
121
|
+
this.pending = "";
|
|
122
|
+
this.markerSeen = true;
|
|
123
|
+
this.visibleAnswer += visible;
|
|
124
|
+
return visible;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (this.pending.length <= VISIBLE_MARKER_RESERVE_CHARS) return "";
|
|
128
|
+
const emitLength = this.pending.length - VISIBLE_MARKER_RESERVE_CHARS;
|
|
129
|
+
const visible = this.pending.slice(0, emitLength);
|
|
130
|
+
this.pending = this.pending.slice(emitLength);
|
|
131
|
+
this.visibleAnswer += visible;
|
|
132
|
+
return visible;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private flushVisibleRemainder(): string {
|
|
136
|
+
if (this.markerSeen || !this.pending) return "";
|
|
137
|
+
const visible = this.pending;
|
|
138
|
+
this.pending = "";
|
|
139
|
+
this.visibleAnswer += visible;
|
|
140
|
+
return visible;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A missing checkpoint skips the private cache; a present checkpoint still validates strictly. */
|
|
144
|
+
finishOptional(rawResponseText: string): CompletedChatGptLunaCheckpoint {
|
|
145
|
+
if (this.markerSeen) {
|
|
146
|
+
const completed = this.finish(rawResponseText);
|
|
147
|
+
return { ...completed, visibleRemainder: "" };
|
|
148
|
+
}
|
|
149
|
+
if (rawResponseText.includes(CHATGPT_LUNA_CHECKPOINT_MARKER)) {
|
|
150
|
+
throw new Error("ChatGPT Luna rolling checkpoint marker was not preserved in the Markdown stream");
|
|
151
|
+
}
|
|
152
|
+
const visibleRemainder = this.flushVisibleRemainder();
|
|
153
|
+
const answer = canonicalAnswer(this.visibleAnswer);
|
|
154
|
+
if (!answer) throw new Error("ChatGPT Luna completed without a user-facing answer");
|
|
155
|
+
return { answer, visibleRemainder };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
finish(rawResponseText: string): { answer: string; captured: CapturedChatGptLunaCheckpoint } {
|
|
159
|
+
if (!this.markerSeen) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`ChatGPT Luna completed without the required ${CHATGPT_LUNA_CHECKPOINT_MARKER} rolling checkpoint marker`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const rawMarkerIndex = rawResponseText.indexOf(CHATGPT_LUNA_CHECKPOINT_MARKER);
|
|
165
|
+
if (rawMarkerIndex < 0 || rawMarkerIndex !== rawResponseText.lastIndexOf(CHATGPT_LUNA_CHECKPOINT_MARKER)) {
|
|
166
|
+
throw new Error("ChatGPT Luna response must contain exactly one raw rolling checkpoint marker");
|
|
167
|
+
}
|
|
168
|
+
if (this.checkpointText.includes(CHATGPT_LUNA_CHECKPOINT_MARKER)) {
|
|
169
|
+
throw new Error("ChatGPT Luna Markdown stream contained more than one rolling checkpoint marker");
|
|
170
|
+
}
|
|
171
|
+
// Capture the DOM's plain text rather than Turndown Markdown: the checkpoint is opaque
|
|
172
|
+
// assistant-owned state, so Markdown escapes must not alter paths, commands, or evidence.
|
|
173
|
+
const checkpoint = parseCheckpointText(
|
|
174
|
+
rawResponseText.slice(rawMarkerIndex + CHATGPT_LUNA_CHECKPOINT_MARKER.length),
|
|
175
|
+
);
|
|
176
|
+
const fallback = "summary" in checkpoint ? checkpoint.summary : checkpoint.objective;
|
|
177
|
+
const answer = canonicalAnswer(this.visibleAnswer) || fallback;
|
|
178
|
+
if (!answer) throw new Error("ChatGPT Luna completed without a user-facing answer before its rolling checkpoint");
|
|
179
|
+
return {
|
|
180
|
+
answer,
|
|
181
|
+
captured: { checkpoint, answerHash: hashChatGptLunaAnswer(answer) },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function currentTurnBoundary(parsed: CodexParsedRequest, input: unknown[], turnId: string): number | undefined {
|
|
187
|
+
const replayPrefix = Math.min(parsed._replayPrefixLen ?? 0, input.length);
|
|
188
|
+
if (replayPrefix > 0) return replayPrefix;
|
|
189
|
+
const firstCurrentItem = input.findIndex(item => itemTurnId(item) === turnId);
|
|
190
|
+
return firstCurrentItem >= 0 ? firstCurrentItem : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function assistantItemText(value: unknown): string | undefined {
|
|
194
|
+
const item = record(value);
|
|
195
|
+
if (!item || item.role !== "assistant") return undefined;
|
|
196
|
+
if (typeof item.content === "string") return item.content.trim() ? item.content : undefined;
|
|
197
|
+
if (!Array.isArray(item.content)) return undefined;
|
|
198
|
+
const text = item.content.map(block => {
|
|
199
|
+
const content = record(block);
|
|
200
|
+
return content && (content.type === "output_text" || content.type === "text")
|
|
201
|
+
&& typeof content.text === "string"
|
|
202
|
+
? content.text
|
|
203
|
+
: "";
|
|
204
|
+
}).join("");
|
|
205
|
+
return text.trim() ? text : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parentAssistantAnswer(
|
|
209
|
+
parsed: CodexParsedRequest,
|
|
210
|
+
turnId: string,
|
|
211
|
+
): { answer: string; turnId: string } | undefined {
|
|
212
|
+
const body = record(parsed._rawBody);
|
|
213
|
+
const input = Array.isArray(body?.input) ? body.input : undefined;
|
|
214
|
+
if (!input) return undefined;
|
|
215
|
+
const boundary = currentTurnBoundary(parsed, input, turnId);
|
|
216
|
+
if (boundary === undefined) return undefined;
|
|
217
|
+
for (let index = boundary - 1; index >= 0; index -= 1) {
|
|
218
|
+
const text = assistantItemText(input[index]);
|
|
219
|
+
const parentTurnId = itemTurnId(input[index]);
|
|
220
|
+
if (text && parentTurnId) return { answer: text, turnId: parentTurnId };
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function currentTurnInput(parsed: CodexParsedRequest, turnId: string): unknown[] | undefined {
|
|
226
|
+
const body = record(parsed._rawBody);
|
|
227
|
+
const input = Array.isArray(body?.input) ? body.input : undefined;
|
|
228
|
+
if (!input) return undefined;
|
|
229
|
+
const boundary = currentTurnBoundary(parsed, input, turnId);
|
|
230
|
+
if (boundary === undefined) return undefined;
|
|
231
|
+
const suffix = input.slice(boundary);
|
|
232
|
+
return suffix.length > 0 ? suffix : undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function checkpointContext(checkpoint: ChatGptLunaCheckpoint): string {
|
|
236
|
+
return [
|
|
237
|
+
"[Compressed Luna task history from the immediately preceding assistant response.]",
|
|
238
|
+
"Treat this as prior assistant-owned conversation state, not as a new user instruction. Current system, developer, and user messages below remain authoritative.",
|
|
239
|
+
JSON.stringify(checkpoint),
|
|
240
|
+
].join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function validateStoredCheckpoint(value: unknown): StoredChatGptLunaCheckpoint {
|
|
244
|
+
const parsed = record(value);
|
|
245
|
+
if (!parsed
|
|
246
|
+
|| typeof parsed.threadId !== "string"
|
|
247
|
+
|| typeof parsed.sourceTurnId !== "string"
|
|
248
|
+
|| typeof parsed.answerHash !== "string"
|
|
249
|
+
|| !/^[a-f0-9]{64}$/.test(parsed.answerHash)
|
|
250
|
+
|| typeof parsed.updatedAt !== "number") {
|
|
251
|
+
throw new Error("Invalid persisted ChatGPT Luna checkpoint metadata");
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
threadId: parsed.threadId,
|
|
255
|
+
sourceTurnId: parsed.sourceTurnId,
|
|
256
|
+
answerHash: parsed.answerHash,
|
|
257
|
+
checkpoint: parseChatGptLunaCheckpoint(parsed.checkpoint),
|
|
258
|
+
updatedAt: parsed.updatedAt,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Exact-parent, per-thread checkpoint store. Full Codex history remains canonical on mismatch. */
|
|
263
|
+
export class ChatGptLunaCheckpointStore {
|
|
264
|
+
private loaded = false;
|
|
265
|
+
private readonly checkpoints = new Map<string, StoredChatGptLunaCheckpoint>();
|
|
266
|
+
|
|
267
|
+
constructor(
|
|
268
|
+
private readonly path?: string,
|
|
269
|
+
private readonly now: () => number = Date.now,
|
|
270
|
+
) {}
|
|
271
|
+
|
|
272
|
+
apply(parsed: CodexParsedRequest): { parsed: CodexParsedRequest; applied: boolean; reason?: string } {
|
|
273
|
+
const identity = extractChatGptTurnIdentity(parsed);
|
|
274
|
+
if (!identity.threadId || !identity.turnId) return { parsed, applied: false, reason: "missing native thread identity" };
|
|
275
|
+
const parent = parentAssistantAnswer(parsed, identity.turnId);
|
|
276
|
+
if (!parent) return { parsed, applied: false, reason: "no proven completed parent assistant answer" };
|
|
277
|
+
|
|
278
|
+
const parentHash = hashChatGptLunaAnswer(parent.answer);
|
|
279
|
+
const stored = this.get(identity.threadId, parentHash);
|
|
280
|
+
if (!stored) return { parsed, applied: false, reason: "no checkpoint for the exact parent answer" };
|
|
281
|
+
if (stored.sourceTurnId !== parent.turnId) {
|
|
282
|
+
return { parsed, applied: false, reason: "checkpoint source turn does not match the exact parent answer" };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const currentInput = currentTurnInput(parsed, identity.turnId);
|
|
286
|
+
const body = record(parsed._rawBody);
|
|
287
|
+
if (!currentInput || !body) {
|
|
288
|
+
return { parsed, applied: false, reason: "current native turn boundary is unavailable" };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const checkpointItem = {
|
|
292
|
+
type: "message",
|
|
293
|
+
role: "assistant",
|
|
294
|
+
content: [{ type: "output_text", text: checkpointContext(stored.checkpoint) }],
|
|
295
|
+
internal_chat_message_metadata_passthrough: { turn_id: identity.turnId },
|
|
296
|
+
};
|
|
297
|
+
const { previous_response_id: _previousResponseId, ...bodyWithoutPrevious } = body;
|
|
298
|
+
const compacted = parseRequest({
|
|
299
|
+
...bodyWithoutPrevious,
|
|
300
|
+
input: [checkpointItem, ...currentInput],
|
|
301
|
+
});
|
|
302
|
+
// `_rawBody.model` remains the public route slug while the server has already resolved the
|
|
303
|
+
// authoritative backend model and effort on `parsed`. Re-parsing the compacted input must not
|
|
304
|
+
// undo that binding.
|
|
305
|
+
compacted.modelId = parsed.modelId;
|
|
306
|
+
compacted.options = { ...compacted.options, ...parsed.options };
|
|
307
|
+
|
|
308
|
+
// The transport optimization must never change which native user revision is being executed.
|
|
309
|
+
if (JSON.stringify(extractChatGptTurnUserRevision(compacted)) !== JSON.stringify(extractChatGptTurnUserRevision(parsed))) {
|
|
310
|
+
throw new Error("ChatGPT Luna rolling checkpoint changed the active native user revision");
|
|
311
|
+
}
|
|
312
|
+
return { parsed: compacted, applied: true };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
commit(parsed: CodexParsedRequest, captured: CapturedChatGptLunaCheckpoint, answer: string): void {
|
|
316
|
+
const identity = extractChatGptTurnIdentity(parsed);
|
|
317
|
+
if (!identity.threadId || !identity.turnId) {
|
|
318
|
+
throw new Error("ChatGPT Luna rolling checkpoint requires native thread_id and turn_id metadata");
|
|
319
|
+
}
|
|
320
|
+
const checkpoint = parseChatGptLunaCheckpoint(captured.checkpoint);
|
|
321
|
+
const answerHash = hashChatGptLunaAnswer(answer);
|
|
322
|
+
if (captured.answerHash !== answerHash) {
|
|
323
|
+
throw new Error("ChatGPT Luna rolling checkpoint answer hash does not match the completed browser answer");
|
|
324
|
+
}
|
|
325
|
+
this.load();
|
|
326
|
+
const stored: StoredChatGptLunaCheckpoint = {
|
|
327
|
+
threadId: identity.threadId,
|
|
328
|
+
sourceTurnId: identity.turnId,
|
|
329
|
+
answerHash,
|
|
330
|
+
checkpoint,
|
|
331
|
+
updatedAt: this.now(),
|
|
332
|
+
};
|
|
333
|
+
const key = checkpointKey(identity.threadId, answerHash);
|
|
334
|
+
this.checkpoints.delete(key);
|
|
335
|
+
this.checkpoints.set(key, stored);
|
|
336
|
+
this.prune();
|
|
337
|
+
this.persist();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private get(threadId: string, answerHash: string): StoredChatGptLunaCheckpoint | undefined {
|
|
341
|
+
this.load();
|
|
342
|
+
this.prune();
|
|
343
|
+
return this.checkpoints.get(checkpointKey(threadId, answerHash));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private prune(): void {
|
|
347
|
+
const cutoff = this.now() - CHECKPOINT_TTL_MS;
|
|
348
|
+
for (const [key, checkpoint] of this.checkpoints) {
|
|
349
|
+
if (checkpoint.updatedAt < cutoff) this.checkpoints.delete(key);
|
|
350
|
+
}
|
|
351
|
+
while (this.checkpoints.size > MAX_STORED_CHECKPOINTS) {
|
|
352
|
+
const oldest = this.checkpoints.keys().next().value as string | undefined;
|
|
353
|
+
if (!oldest) break;
|
|
354
|
+
this.checkpoints.delete(oldest);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private load(): void {
|
|
359
|
+
if (this.loaded) return;
|
|
360
|
+
this.loaded = true;
|
|
361
|
+
if (!this.path || !existsSync(this.path)) return;
|
|
362
|
+
const payload = JSON.parse(readFileSync(this.path, "utf8")) as Partial<StoredChatGptLunaCheckpointFile>;
|
|
363
|
+
if (payload.version !== 1 || !Array.isArray(payload.checkpoints)) {
|
|
364
|
+
throw new Error(`Invalid ChatGPT Luna checkpoint store: ${this.path}`);
|
|
365
|
+
}
|
|
366
|
+
const checkpoints = payload.checkpoints
|
|
367
|
+
.map(validateStoredCheckpoint)
|
|
368
|
+
.sort((left, right) => left.updatedAt - right.updatedAt)
|
|
369
|
+
.slice(-MAX_STORED_CHECKPOINTS);
|
|
370
|
+
for (const checkpoint of checkpoints) {
|
|
371
|
+
this.checkpoints.set(checkpointKey(checkpoint.threadId, checkpoint.answerHash), checkpoint);
|
|
372
|
+
}
|
|
373
|
+
this.prune();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private persist(): void {
|
|
377
|
+
if (!this.path) return;
|
|
378
|
+
const payload: StoredChatGptLunaCheckpointFile = {
|
|
379
|
+
version: 1,
|
|
380
|
+
checkpoints: [...this.checkpoints.values()],
|
|
381
|
+
};
|
|
382
|
+
atomicWriteFile(this.path, `${JSON.stringify(payload, null, 2)}\n`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { atomicWriteFile } from "../../config";
|
|
4
|
+
import { getCodexHome } from "../../codex-integration-shared";
|
|
5
|
+
import type { CodexParsedRequest } from "../../types";
|
|
6
|
+
import {
|
|
7
|
+
extractChatGptTurnEnvironment,
|
|
8
|
+
extractChatGptTurnIdentity,
|
|
9
|
+
extractChatGptThreadSpawnLineage,
|
|
10
|
+
hasRawChatGptEnvironmentContext,
|
|
11
|
+
MissingTrustedCodexEnvironmentError,
|
|
12
|
+
type ChatGptSandboxPolicy,
|
|
13
|
+
type ChatGptTurnEnvironment,
|
|
14
|
+
} from "./environment";
|
|
15
|
+
import { resolveCurrentCodexChildRolloutEnvironment } from "./codex-rollout-environment";
|
|
16
|
+
|
|
17
|
+
interface StoredThreadEnvironment {
|
|
18
|
+
cwd: string;
|
|
19
|
+
roots: string[];
|
|
20
|
+
writableRoots: string[];
|
|
21
|
+
sandboxPolicy: ChatGptSandboxPolicy;
|
|
22
|
+
updatedAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface StoredThreadEnvironmentFile {
|
|
26
|
+
version: 1;
|
|
27
|
+
threads: Record<string, StoredThreadEnvironment>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_THREAD_ENVIRONMENTS = 256;
|
|
31
|
+
const THREAD_ENVIRONMENT_TTL_MS = 30 * 24 * 60 * 60_000;
|
|
32
|
+
|
|
33
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
34
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
35
|
+
? value as Record<string, unknown>
|
|
36
|
+
: undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pathIdentity(value: string): string {
|
|
40
|
+
const normalized = resolve(value);
|
|
41
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function contains(root: string, path: string): boolean {
|
|
45
|
+
const rel = relative(pathIdentity(root), pathIdentity(path));
|
|
46
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function absolutePaths(value: unknown, field: string): string[] {
|
|
50
|
+
if (!Array.isArray(value) || value.length === 0 || value.some(path => typeof path !== "string" || !isAbsolute(path))) {
|
|
51
|
+
throw new Error(`Invalid persisted ChatGPT thread ${field}`);
|
|
52
|
+
}
|
|
53
|
+
const unique = new Map<string, string>();
|
|
54
|
+
for (const path of value.map(path => resolve(path as string))) {
|
|
55
|
+
if (!unique.has(pathIdentity(path))) unique.set(pathIdentity(path), path);
|
|
56
|
+
}
|
|
57
|
+
return [...unique.values()];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sandboxPolicy(value: unknown, roots: string[], writableRoots: string[]): ChatGptSandboxPolicy {
|
|
61
|
+
const parsed = record(value);
|
|
62
|
+
if (parsed?.type === "dangerFullAccess") {
|
|
63
|
+
const rootIdentities = new Set(roots.map(pathIdentity));
|
|
64
|
+
if (writableRoots.length !== roots.length || writableRoots.some(path => !rootIdentities.has(pathIdentity(path)))) {
|
|
65
|
+
throw new Error("Invalid persisted ChatGPT danger-full-access roots");
|
|
66
|
+
}
|
|
67
|
+
return { type: "dangerFullAccess" };
|
|
68
|
+
}
|
|
69
|
+
if (parsed?.type === "workspaceWrite") {
|
|
70
|
+
if (typeof parsed.networkAccess !== "boolean" || writableRoots.some(path => !roots.some(root => contains(root, path)))) {
|
|
71
|
+
throw new Error("Invalid persisted ChatGPT workspace-write policy");
|
|
72
|
+
}
|
|
73
|
+
return { type: "workspaceWrite", writableRoots, networkAccess: parsed.networkAccess };
|
|
74
|
+
}
|
|
75
|
+
if (parsed?.type === "readOnly") {
|
|
76
|
+
if (typeof parsed.networkAccess !== "boolean" || writableRoots.length !== 0) {
|
|
77
|
+
throw new Error("Invalid persisted ChatGPT read-only policy");
|
|
78
|
+
}
|
|
79
|
+
return { type: "readOnly", networkAccess: parsed.networkAccess };
|
|
80
|
+
}
|
|
81
|
+
throw new Error("Invalid persisted ChatGPT sandbox policy");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateStoredEnvironment(value: unknown): StoredThreadEnvironment {
|
|
85
|
+
const parsed = record(value);
|
|
86
|
+
if (!parsed || typeof parsed.cwd !== "string" || !isAbsolute(parsed.cwd) || typeof parsed.updatedAt !== "number") {
|
|
87
|
+
throw new Error("Invalid persisted ChatGPT thread environment");
|
|
88
|
+
}
|
|
89
|
+
const cwd = resolve(parsed.cwd);
|
|
90
|
+
const roots = absolutePaths(parsed.roots, "roots");
|
|
91
|
+
const writableRoots = Array.isArray(parsed.writableRoots) && parsed.writableRoots.length === 0
|
|
92
|
+
? []
|
|
93
|
+
: absolutePaths(parsed.writableRoots, "writable roots");
|
|
94
|
+
if (!roots.some(root => contains(root, cwd))) throw new Error("Persisted ChatGPT cwd is outside its roots");
|
|
95
|
+
return {
|
|
96
|
+
cwd,
|
|
97
|
+
roots,
|
|
98
|
+
writableRoots,
|
|
99
|
+
sandboxPolicy: sandboxPolicy(parsed.sandboxPolicy, roots, writableRoots),
|
|
100
|
+
updatedAt: parsed.updatedAt,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function authority(environment: ChatGptTurnEnvironment, updatedAt: number): StoredThreadEnvironment {
|
|
105
|
+
return {
|
|
106
|
+
cwd: environment.cwd,
|
|
107
|
+
roots: environment.roots,
|
|
108
|
+
writableRoots: environment.writableRoots,
|
|
109
|
+
sandboxPolicy: environment.sandboxPolicy,
|
|
110
|
+
updatedAt,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Codex emits its trusted environment envelope when a task starts or its environment changes,
|
|
116
|
+
* not on every follow-up. This store carries only that trusted authority across turns. Tool
|
|
117
|
+
* declarations are always taken from the current request and are never persisted.
|
|
118
|
+
*/
|
|
119
|
+
export class ChatGptThreadEnvironmentStore {
|
|
120
|
+
private loaded = false;
|
|
121
|
+
private readonly threads = new Map<string, StoredThreadEnvironment>();
|
|
122
|
+
|
|
123
|
+
constructor(
|
|
124
|
+
private readonly path?: string,
|
|
125
|
+
private readonly now: () => number = Date.now,
|
|
126
|
+
private readonly codexHome: string = getCodexHome(),
|
|
127
|
+
private readonly sqliteHome?: string,
|
|
128
|
+
) {}
|
|
129
|
+
|
|
130
|
+
resolve(parsed: CodexParsedRequest): ChatGptTurnEnvironment {
|
|
131
|
+
const identity = extractChatGptTurnIdentity(parsed);
|
|
132
|
+
try {
|
|
133
|
+
const environment = extractChatGptTurnEnvironment(parsed);
|
|
134
|
+
if (identity.threadId) this.set(identity.threadId, environment);
|
|
135
|
+
return environment;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (!(error instanceof MissingTrustedCodexEnvironmentError) || !identity.threadId) throw error;
|
|
138
|
+
if (hasRawChatGptEnvironmentContext(parsed)) throw error;
|
|
139
|
+
const lineage = extractChatGptThreadSpawnLineage(parsed);
|
|
140
|
+
if (lineage && identity.turnId) {
|
|
141
|
+
const rolloutEnvironment = resolveCurrentCodexChildRolloutEnvironment({
|
|
142
|
+
codexHome: this.codexHome,
|
|
143
|
+
...(this.sqliteHome ? { sqliteHome: this.sqliteHome } : {}),
|
|
144
|
+
lineage,
|
|
145
|
+
turnId: identity.turnId,
|
|
146
|
+
tools: parsed.context.tools,
|
|
147
|
+
});
|
|
148
|
+
if (rolloutEnvironment) {
|
|
149
|
+
this.set(lineage.threadId, rolloutEnvironment);
|
|
150
|
+
return rolloutEnvironment;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const sameThread = this.get(identity.threadId);
|
|
154
|
+
if (sameThread) return {
|
|
155
|
+
cwd: sameThread.cwd,
|
|
156
|
+
roots: sameThread.roots,
|
|
157
|
+
writableRoots: sameThread.writableRoots,
|
|
158
|
+
sandboxPolicy: sameThread.sandboxPolicy,
|
|
159
|
+
tools: parsed.context.tools ?? [],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (!lineage) throw error;
|
|
163
|
+
const parent = this.get(lineage.parentThreadId);
|
|
164
|
+
if (!parent) throw error;
|
|
165
|
+
if (lineage.sandboxType !== parent.sandboxPolicy.type) {
|
|
166
|
+
throw new Error("ChatGPT Web subagent sandbox metadata conflicts with its trusted parent thread");
|
|
167
|
+
}
|
|
168
|
+
if (lineage.workspaceRoots.length > 0 && !lineage.workspaceRoots.some(root => contains(root, parent.cwd))) {
|
|
169
|
+
throw new Error("ChatGPT Web subagent workspace metadata does not contain its trusted parent cwd");
|
|
170
|
+
}
|
|
171
|
+
if (lineage.workspaceRoots.some(root => !parent.roots.some(parentRoot => (
|
|
172
|
+
contains(parentRoot, root) || contains(root, parentRoot)
|
|
173
|
+
)))) {
|
|
174
|
+
throw new Error("ChatGPT Web subagent workspace metadata conflicts with its trusted parent roots");
|
|
175
|
+
}
|
|
176
|
+
const inherited: ChatGptTurnEnvironment = {
|
|
177
|
+
cwd: parent.cwd,
|
|
178
|
+
roots: parent.roots,
|
|
179
|
+
writableRoots: parent.writableRoots,
|
|
180
|
+
sandboxPolicy: parent.sandboxPolicy,
|
|
181
|
+
tools: parsed.context.tools ?? [],
|
|
182
|
+
};
|
|
183
|
+
this.set(lineage.threadId, inherited);
|
|
184
|
+
return inherited;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private get(threadId: string): StoredThreadEnvironment | undefined {
|
|
189
|
+
this.load();
|
|
190
|
+
const stored = this.threads.get(threadId);
|
|
191
|
+
if (!stored) return undefined;
|
|
192
|
+
if (this.now() - stored.updatedAt > THREAD_ENVIRONMENT_TTL_MS) {
|
|
193
|
+
this.threads.delete(threadId);
|
|
194
|
+
this.persist();
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
return stored;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private set(threadId: string, environment: ChatGptTurnEnvironment): void {
|
|
201
|
+
this.load();
|
|
202
|
+
this.threads.delete(threadId);
|
|
203
|
+
this.threads.set(threadId, authority(environment, this.now()));
|
|
204
|
+
while (this.threads.size > MAX_THREAD_ENVIRONMENTS) {
|
|
205
|
+
const oldest = this.threads.keys().next().value as string | undefined;
|
|
206
|
+
if (!oldest) break;
|
|
207
|
+
this.threads.delete(oldest);
|
|
208
|
+
}
|
|
209
|
+
this.persist();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private load(): void {
|
|
213
|
+
if (this.loaded) return;
|
|
214
|
+
this.loaded = true;
|
|
215
|
+
if (!this.path || !existsSync(this.path)) return;
|
|
216
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8")) as Partial<StoredThreadEnvironmentFile>;
|
|
217
|
+
const rawThreads = record(parsed.threads);
|
|
218
|
+
if (parsed.version !== 1 || !rawThreads) {
|
|
219
|
+
throw new Error(`Invalid ChatGPT thread environment store: ${this.path}`);
|
|
220
|
+
}
|
|
221
|
+
const cutoff = this.now() - THREAD_ENVIRONMENT_TTL_MS;
|
|
222
|
+
const entries = Object.entries(rawThreads)
|
|
223
|
+
.map(([threadId, value]) => [threadId, validateStoredEnvironment(value)] as const)
|
|
224
|
+
.filter(([, environment]) => environment.updatedAt >= cutoff)
|
|
225
|
+
.sort((left, right) => left[1].updatedAt - right[1].updatedAt)
|
|
226
|
+
.slice(-MAX_THREAD_ENVIRONMENTS);
|
|
227
|
+
for (const [threadId, environment] of entries) this.threads.set(threadId, environment);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private persist(): void {
|
|
231
|
+
if (!this.path) return;
|
|
232
|
+
const payload: StoredThreadEnvironmentFile = {
|
|
233
|
+
version: 1,
|
|
234
|
+
threads: Object.fromEntries(this.threads),
|
|
235
|
+
};
|
|
236
|
+
atomicWriteFile(this.path, `${JSON.stringify(payload, null, 2)}\n`);
|
|
237
|
+
}
|
|
238
|
+
}
|