codex-snapshots 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -6
- package/bin/codex-snapshot.mjs +1 -6326
- package/deploy/aliyun/README.md +311 -0
- package/deploy/aliyun/backup-share-data.sh +109 -0
- package/deploy/aliyun/check-ecs-status.sh +149 -0
- package/deploy/aliyun/codex-snapshot-share.env.example +29 -0
- package/deploy/aliyun/codex-snapshot-share.service +26 -0
- package/deploy/aliyun/configure-github-pages-api.sh +141 -0
- package/deploy/aliyun/configure-local-publisher.sh +197 -0
- package/deploy/aliyun/deploy-to-ecs.sh +669 -0
- package/deploy/aliyun/deploy.env.example +52 -0
- package/deploy/aliyun/doctor.mjs +398 -0
- package/deploy/aliyun/install-share-api.sh +252 -0
- package/deploy/aliyun/install-system-deps.sh +84 -0
- package/deploy/aliyun/nginx-codex-snapshots.bootstrap.conf +34 -0
- package/deploy/aliyun/nginx-codex-snapshots.conf +52 -0
- package/deploy/aliyun/preflight.mjs +321 -0
- package/deploy/aliyun/restore-share-data.sh +141 -0
- package/deploy/aliyun/verify-public-share.mjs +404 -0
- package/dist/cli/codex-snapshot.mjs +2654 -0
- package/dist/core/privacy.js +81 -0
- package/dist/core/snapshot.js +1 -0
- package/dist/renderers/markdown.mjs +81 -0
- package/dist/renderers/transcript.js +195 -0
- package/dist/server/http.js +10 -0
- package/dist/server/local-security.js +66 -0
- package/dist/server/local-viewer-app.mjs +1670 -0
- package/dist/server/local-viewer.mjs +210 -0
- package/dist/server/share-api.mjs +1149 -0
- package/dist/server/share-store.js +136 -0
- package/dist/shared/sanitize.js +126 -0
- package/dist/shared/transcript.js +1 -0
- package/dist/sources/index.mjs +2 -0
- package/dist/sources/local-history.mjs +2221 -0
- package/package.json +42 -14
- package/scripts/build-site.mjs +71 -0
- package/scripts/launch-agent.mjs +19 -227
- package/scripts/serve-site.mjs +2 -2
- package/scripts/test-aliyun-deploy-config.sh +230 -0
- package/scripts/test-share-api.mjs +967 -0
- package/scripts/test-site-config.mjs +100 -0
- package/scripts/test-static-site.mjs +403 -0
- package/scripts/write-site-config.mjs +161 -0
- package/server/share-api.mjs +1 -771
- package/site/assets/config.js +3 -0
- package/site/assets/share.js +43 -106
- package/site/assets/site.css +3 -605
- package/site/assets/site.js +15 -92
- package/site/favicon.svg +7 -0
- package/site/index.html +3 -83
- package/site/share/index.html +3 -8
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHmac } from "node:crypto";
|
|
5
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
const TOKEN = "test-token";
|
|
14
|
+
const SITE_URL = "https://ffffhx.github.io/codex-snapshots/";
|
|
15
|
+
const PUBLIC_API_URL = "https://snapshots.example.com";
|
|
16
|
+
const FIXED_SHARE_ID = "snap_testshare1234567890";
|
|
17
|
+
const GOAL_OBJECTIVE = "Keep the publishing flow safe and visible.";
|
|
18
|
+
|
|
19
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "codex-snapshots-share-test-"));
|
|
20
|
+
let serverProcess;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const port = await getFreePort();
|
|
24
|
+
const localApiUrl = `http://127.0.0.1:${port}`;
|
|
25
|
+
const dataFile = path.join(tempDir, "shares.json");
|
|
26
|
+
|
|
27
|
+
serverProcess = spawn(process.execPath, ["server/share-api.mjs", "--host", "127.0.0.1", "--port", String(port)], {
|
|
28
|
+
cwd: ROOT_DIR,
|
|
29
|
+
env: {
|
|
30
|
+
...process.env,
|
|
31
|
+
SNAPSHOT_SHARE_TOKEN: TOKEN,
|
|
32
|
+
SNAPSHOT_SHARE_DATA_FILE: dataFile,
|
|
33
|
+
SNAPSHOT_SHARE_SITE_URL: SITE_URL,
|
|
34
|
+
SNAPSHOT_SHARE_PUBLIC_API_URL: PUBLIC_API_URL,
|
|
35
|
+
SNAPSHOT_SHARE_VIEWER_PATH: "/share/",
|
|
36
|
+
},
|
|
37
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const output = collectChildOutput(serverProcess);
|
|
41
|
+
await waitForHealth(localApiUrl, output);
|
|
42
|
+
|
|
43
|
+
await assertInitialHealth(localApiUrl);
|
|
44
|
+
await assertCorsHeaders(localApiUrl);
|
|
45
|
+
await assertUnauthorizedPublish(localApiUrl);
|
|
46
|
+
await assertPublish(localApiUrl);
|
|
47
|
+
await assertPublicList(localApiUrl, 1);
|
|
48
|
+
await assertShareDetail(localApiUrl, FIXED_SHARE_ID);
|
|
49
|
+
await assertUnauthorizedDelete(localApiUrl, FIXED_SHARE_ID);
|
|
50
|
+
await assertVerifyScriptReadOnly(localApiUrl);
|
|
51
|
+
await assertPublicList(localApiUrl, 1);
|
|
52
|
+
await assertVerifyScriptPublish(localApiUrl);
|
|
53
|
+
await assertPublicList(localApiUrl, 2);
|
|
54
|
+
await assertLocalViewerPublish(localApiUrl);
|
|
55
|
+
await assertPublicList(localApiUrl, 3);
|
|
56
|
+
await assertDelete(localApiUrl, FIXED_SHARE_ID);
|
|
57
|
+
await assertPublicList(localApiUrl, 2);
|
|
58
|
+
await stopChild(serverProcess);
|
|
59
|
+
serverProcess = null;
|
|
60
|
+
await assertGithubOwnershipAuth();
|
|
61
|
+
|
|
62
|
+
console.log("✓ share API integration checks passed");
|
|
63
|
+
} finally {
|
|
64
|
+
await stopChild(serverProcess);
|
|
65
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function assertInitialHealth(apiUrl) {
|
|
69
|
+
const payload = await fetchJson(`${apiUrl}/api/snapshots/health`);
|
|
70
|
+
assert(payload.ok === true, "health should return ok=true");
|
|
71
|
+
assert(payload.shares === 0, `initial share count should be 0, got ${payload.shares}`);
|
|
72
|
+
assert(!Object.hasOwn(payload, "storage"), "public health endpoint should not expose the server storage path");
|
|
73
|
+
|
|
74
|
+
const rootHealth = await fetchJson(`${apiUrl}/health`);
|
|
75
|
+
assert(rootHealth.ok === true, "root health should return ok=true");
|
|
76
|
+
assert(!Object.hasOwn(rootHealth, "storage"), "root health endpoint should not expose the server storage path");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function assertCorsHeaders(apiUrl) {
|
|
80
|
+
const siteOrigin = new URL(SITE_URL).origin;
|
|
81
|
+
const getResponse = await fetch(`${apiUrl}/api/snapshots`, {
|
|
82
|
+
headers: {
|
|
83
|
+
origin: siteOrigin,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
assert(getResponse.ok, `cross-origin public list should return ok, got ${getResponse.status}`);
|
|
88
|
+
assert(getResponse.headers.get("access-control-allow-origin") === siteOrigin, "public list should allow configured cross-origin reads");
|
|
89
|
+
assert(getResponse.headers.get("access-control-allow-credentials") === "true", "public list should allow GitHub session credentials");
|
|
90
|
+
|
|
91
|
+
const optionsResponse = await fetch(`${apiUrl}/api/snapshots`, {
|
|
92
|
+
method: "OPTIONS",
|
|
93
|
+
headers: {
|
|
94
|
+
"access-control-request-headers": "authorization,content-type",
|
|
95
|
+
"access-control-request-method": "POST",
|
|
96
|
+
origin: siteOrigin,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
assert(optionsResponse.status === 204, `CORS preflight should return 204, got ${optionsResponse.status}`);
|
|
101
|
+
assert(optionsResponse.headers.get("access-control-allow-origin") === siteOrigin, "CORS preflight should allow the public site origin");
|
|
102
|
+
assert(
|
|
103
|
+
String(optionsResponse.headers.get("access-control-allow-methods") || "").includes("OPTIONS"),
|
|
104
|
+
"CORS preflight should include OPTIONS in allowed methods"
|
|
105
|
+
);
|
|
106
|
+
assert(
|
|
107
|
+
String(optionsResponse.headers.get("access-control-allow-methods") || "").includes("DELETE"),
|
|
108
|
+
"CORS preflight should include DELETE in allowed methods"
|
|
109
|
+
);
|
|
110
|
+
assert(
|
|
111
|
+
String(optionsResponse.headers.get("access-control-allow-headers") || "").includes("authorization"),
|
|
112
|
+
"CORS preflight should allow authorization header"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function assertUnauthorizedPublish(apiUrl) {
|
|
117
|
+
const response = await fetch(`${apiUrl}/api/snapshots`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "content-type": "application/json" },
|
|
120
|
+
body: JSON.stringify({ snapshot: createSnapshot("Unauthorized snapshot") }),
|
|
121
|
+
});
|
|
122
|
+
assert(response.status === 401, `unauthorized publish should return 401, got ${response.status}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function assertPublish(apiUrl) {
|
|
126
|
+
const payload = await fetchJson(`${apiUrl}/api/snapshots`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: {
|
|
129
|
+
authorization: `Bearer ${TOKEN}`,
|
|
130
|
+
"content-type": "application/json",
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
shareId: FIXED_SHARE_ID,
|
|
134
|
+
apiUrl: PUBLIC_API_URL,
|
|
135
|
+
siteUrl: SITE_URL,
|
|
136
|
+
snapshot: createSnapshot("Public Session from integration test"),
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
assert(payload.ok === true, `publish should return ok=true: ${JSON.stringify(payload)}`);
|
|
141
|
+
assert(payload.id === FIXED_SHARE_ID, `publish should preserve share id: ${payload.id}`);
|
|
142
|
+
|
|
143
|
+
const url = new URL(payload.url);
|
|
144
|
+
assert(url.origin === new URL(SITE_URL).origin, `share URL should use site origin: ${payload.url}`);
|
|
145
|
+
assert(url.pathname === "/codex-snapshots/share/", `share URL should use GitHub Pages share path: ${payload.url}`);
|
|
146
|
+
assert(url.searchParams.get("id") === FIXED_SHARE_ID, `share URL should include id: ${payload.url}`);
|
|
147
|
+
assert(url.searchParams.get("api") === PUBLIC_API_URL, `share URL should include public API URL: ${payload.url}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function assertPublicList(apiUrl, expectedTotal) {
|
|
151
|
+
const payload = await fetchJson(`${apiUrl}/api/snapshots?limit=12`);
|
|
152
|
+
assert(Array.isArray(payload.shares), "list endpoint should return shares array");
|
|
153
|
+
assert(payload.total === expectedTotal, `list total should be ${expectedTotal}, got ${payload.total}`);
|
|
154
|
+
assert(payload.count === expectedTotal, `list count should be ${expectedTotal}, got ${payload.count}`);
|
|
155
|
+
|
|
156
|
+
const [first] = payload.shares;
|
|
157
|
+
assert(first?.id, "list should include summary id");
|
|
158
|
+
assert(!Object.hasOwn(first, "snapshot"), "list summaries must not include full snapshot payloads");
|
|
159
|
+
assert(first.url?.startsWith(`${SITE_URL.replace(/\/+$/, "")}/share/`), `summary URL should point at site: ${first.url}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function assertShareDetail(apiUrl, shareId) {
|
|
163
|
+
const payload = await fetchJson(`${apiUrl}/api/snapshots/${encodeURIComponent(shareId)}`);
|
|
164
|
+
assert(payload.share?.id === shareId, `detail should return share id ${shareId}`);
|
|
165
|
+
assert(payload.share?.goalObjective === GOAL_OBJECTIVE, "detail share metadata should include the goal objective");
|
|
166
|
+
assert(payload.snapshot?.goalObjective === GOAL_OBJECTIVE, "detail snapshot metadata should include the goal objective");
|
|
167
|
+
assert(Array.isArray(payload.snapshot?.turns), "detail should include snapshot turns");
|
|
168
|
+
assert(payload.snapshot.turns.length === 2, `detail should include 2 turns, got ${payload.snapshot.turns.length}`);
|
|
169
|
+
assert(!Object.hasOwn(payload.snapshot, "cwd"), "published snapshot should not expose cwd");
|
|
170
|
+
assert(!Object.hasOwn(payload.snapshot, "filePath"), "published snapshot should not expose filePath");
|
|
171
|
+
const assistantTurn = payload.snapshot.turns.find((turn) => turn.role === "assistant");
|
|
172
|
+
assert(!assistantTurn.html.includes("javascript:"), "share API should strip unsafe link protocols from HTML");
|
|
173
|
+
assert(!assistantTurn.html.includes("onclick"), "share API should strip inline event handlers from HTML");
|
|
174
|
+
assert(!assistantTurn.html.includes("<script"), "share API should strip script tags from HTML");
|
|
175
|
+
assert(assistantTurn.html.includes('target="_blank"'), "share API should force links to open in a new tab");
|
|
176
|
+
assert(assistantTurn.html.includes('rel="noopener noreferrer"'), "share API should force safe link rel attributes");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function assertUnauthorizedDelete(apiUrl, shareId) {
|
|
180
|
+
const response = await fetch(`${apiUrl}/api/snapshots/${encodeURIComponent(shareId)}`, {
|
|
181
|
+
method: "DELETE",
|
|
182
|
+
});
|
|
183
|
+
assert(response.status === 401, `unauthorized delete should return 401, got ${response.status}`);
|
|
184
|
+
|
|
185
|
+
const payload = await fetchJson(`${apiUrl}/api/snapshots/${encodeURIComponent(shareId)}`);
|
|
186
|
+
assert(payload.share?.id === shareId, "unauthorized delete should leave the share available");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function assertDelete(apiUrl, shareId) {
|
|
190
|
+
const response = await fetch(`${apiUrl}/api/snapshots/${encodeURIComponent(shareId)}`, {
|
|
191
|
+
method: "DELETE",
|
|
192
|
+
headers: {
|
|
193
|
+
authorization: `Bearer ${TOKEN}`,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
const payload = await response.json();
|
|
197
|
+
|
|
198
|
+
assert(response.status === 200, `authenticated delete should return 200, got ${response.status}`);
|
|
199
|
+
assert(payload.ok === true, `authenticated delete should return ok=true: ${JSON.stringify(payload)}`);
|
|
200
|
+
assert(payload.deleted === true, `authenticated delete should report deleted=true: ${JSON.stringify(payload)}`);
|
|
201
|
+
assert(payload.id === shareId, `authenticated delete should echo the share id: ${JSON.stringify(payload)}`);
|
|
202
|
+
|
|
203
|
+
const detailResponse = await fetch(`${apiUrl}/api/snapshots/${encodeURIComponent(shareId)}`);
|
|
204
|
+
assert(detailResponse.status === 404, `deleted share detail should return 404, got ${detailResponse.status}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function assertGithubOwnershipAuth() {
|
|
208
|
+
const port = await getFreePort();
|
|
209
|
+
const apiUrl = `http://127.0.0.1:${port}`;
|
|
210
|
+
const publicApiUrl = "https://snapshots.example.com/codex-snapshots";
|
|
211
|
+
const dataFile = path.join(tempDir, "github-auth-shares.json");
|
|
212
|
+
const sessionSecret = "github-session-secret-for-tests";
|
|
213
|
+
let authServer;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
authServer = spawn(process.execPath, ["server/share-api.mjs", "--host", "127.0.0.1", "--port", String(port)], {
|
|
217
|
+
cwd: ROOT_DIR,
|
|
218
|
+
env: {
|
|
219
|
+
...process.env,
|
|
220
|
+
SNAPSHOT_AUTH_ALLOWED_ORIGINS: SITE_URL.replace(/\/+$/, ""),
|
|
221
|
+
SNAPSHOT_AUTH_COOKIE_SAMESITE: "Lax",
|
|
222
|
+
SNAPSHOT_AUTH_COOKIE_SECURE: "false",
|
|
223
|
+
SNAPSHOT_GITHUB_CLIENT_ID: "test-client-id",
|
|
224
|
+
SNAPSHOT_GITHUB_CLIENT_SECRET: "test-client-secret",
|
|
225
|
+
SNAPSHOT_GITHUB_OWNER_LOGIN: "site-owner",
|
|
226
|
+
SNAPSHOT_SESSION_SECRET: sessionSecret,
|
|
227
|
+
SNAPSHOT_SHARE_DATA_FILE: dataFile,
|
|
228
|
+
SNAPSHOT_SHARE_PUBLIC_API_URL: publicApiUrl,
|
|
229
|
+
SNAPSHOT_SHARE_SITE_URL: SITE_URL,
|
|
230
|
+
SNAPSHOT_SHARE_TOKEN: TOKEN,
|
|
231
|
+
},
|
|
232
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
233
|
+
});
|
|
234
|
+
serverProcess = authServer;
|
|
235
|
+
const output = collectChildOutput(authServer);
|
|
236
|
+
await waitForHealth(apiUrl, output);
|
|
237
|
+
|
|
238
|
+
const loginState = await fetchJson(`${apiUrl}/api/auth/me?returnTo=${encodeURIComponent(SITE_URL)}`);
|
|
239
|
+
assert(loginState.configured === true, "GitHub auth state should report configured=true");
|
|
240
|
+
assert(
|
|
241
|
+
String(loginState.loginUrl || "").startsWith(`${publicApiUrl}/api/auth/github/start?`),
|
|
242
|
+
`loginUrl should preserve the public API path prefix: ${loginState.loginUrl}`,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const startResponse = await fetch(`${apiUrl}/api/auth/github/start?returnTo=${encodeURIComponent(SITE_URL)}`, {
|
|
246
|
+
redirect: "manual",
|
|
247
|
+
});
|
|
248
|
+
assert(startResponse.status === 302, `GitHub login start should redirect, got ${startResponse.status}`);
|
|
249
|
+
const githubLocation = new URL(startResponse.headers.get("location"));
|
|
250
|
+
assert(
|
|
251
|
+
githubLocation.searchParams.get("redirect_uri") === `${publicApiUrl}/api/auth/github/callback`,
|
|
252
|
+
`GitHub redirect_uri should preserve the public API path prefix: ${githubLocation.searchParams.get("redirect_uri")}`,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const aliceCookie = githubSessionCookie(
|
|
256
|
+
{
|
|
257
|
+
id: "42",
|
|
258
|
+
login: "alice",
|
|
259
|
+
avatarUrl: "",
|
|
260
|
+
profileUrl: "https://github.com/alice",
|
|
261
|
+
},
|
|
262
|
+
sessionSecret,
|
|
263
|
+
);
|
|
264
|
+
const bobCookie = githubSessionCookie(
|
|
265
|
+
{
|
|
266
|
+
id: "77",
|
|
267
|
+
login: "bob",
|
|
268
|
+
avatarUrl: "",
|
|
269
|
+
profileUrl: "https://github.com/bob",
|
|
270
|
+
},
|
|
271
|
+
sessionSecret,
|
|
272
|
+
);
|
|
273
|
+
const ownerCookie = githubSessionCookie(
|
|
274
|
+
{
|
|
275
|
+
id: "1",
|
|
276
|
+
login: "site-owner",
|
|
277
|
+
avatarUrl: "",
|
|
278
|
+
profileUrl: "https://github.com/site-owner",
|
|
279
|
+
},
|
|
280
|
+
sessionSecret,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const siteOrigin = new URL(SITE_URL).origin;
|
|
284
|
+
const corsResponse = await fetch(`${apiUrl}/api/auth/me`, {
|
|
285
|
+
headers: {
|
|
286
|
+
cookie: ownerCookie,
|
|
287
|
+
origin: siteOrigin,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
const corsPayload = await corsResponse.json();
|
|
291
|
+
assert(corsPayload.user?.isOwner === true, "owner GitHub session should be marked as site owner");
|
|
292
|
+
assert(
|
|
293
|
+
corsResponse.headers.get("access-control-allow-origin") === siteOrigin,
|
|
294
|
+
"auth endpoint should echo the configured site origin",
|
|
295
|
+
);
|
|
296
|
+
assert(corsResponse.headers.get("access-control-allow-credentials") === "true", "auth endpoint should allow credentials");
|
|
297
|
+
|
|
298
|
+
const tokenPublishResponse = await fetch(`${apiUrl}/api/snapshots`, {
|
|
299
|
+
method: "POST",
|
|
300
|
+
headers: {
|
|
301
|
+
authorization: `Bearer ${TOKEN}`,
|
|
302
|
+
"content-type": "application/json",
|
|
303
|
+
origin: SITE_URL.replace(/\/+$/, ""),
|
|
304
|
+
},
|
|
305
|
+
body: JSON.stringify({ shareId: "snap_tokenblocked123456", snapshot: createSnapshot("Token should not publish with GitHub auth") }),
|
|
306
|
+
});
|
|
307
|
+
assert(tokenPublishResponse.status === 401, `GitHub auth mode should reject bearer-token publish by default, got ${tokenPublishResponse.status}`);
|
|
308
|
+
|
|
309
|
+
const aliceShare = await publishWithGithubSession(apiUrl, aliceCookie, "snap_aliceowned123456", "Alice owned session");
|
|
310
|
+
assert(aliceShare.owner?.login === "alice", `published share should include owner login: ${JSON.stringify(aliceShare)}`);
|
|
311
|
+
|
|
312
|
+
const bobDeleteResponse = await fetch(`${apiUrl}/api/snapshots/snap_aliceowned123456`, {
|
|
313
|
+
method: "DELETE",
|
|
314
|
+
headers: {
|
|
315
|
+
cookie: bobCookie,
|
|
316
|
+
origin: SITE_URL.replace(/\/+$/, ""),
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
assert(bobDeleteResponse.status === 403, `other GitHub users should not delete Alice's share, got ${bobDeleteResponse.status}`);
|
|
320
|
+
|
|
321
|
+
const aliceDeleteResponse = await fetch(`${apiUrl}/api/snapshots/snap_aliceowned123456`, {
|
|
322
|
+
method: "DELETE",
|
|
323
|
+
headers: {
|
|
324
|
+
cookie: aliceCookie,
|
|
325
|
+
origin: SITE_URL.replace(/\/+$/, ""),
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
assert(aliceDeleteResponse.status === 200, `share owner should delete their own share, got ${aliceDeleteResponse.status}`);
|
|
329
|
+
|
|
330
|
+
await publishWithGithubSession(apiUrl, aliceCookie, "snap_ownerdelete123456", "Owner can delete this session");
|
|
331
|
+
const ownerDeleteResponse = await fetch(`${apiUrl}/api/snapshots/snap_ownerdelete123456`, {
|
|
332
|
+
method: "DELETE",
|
|
333
|
+
headers: {
|
|
334
|
+
cookie: ownerCookie,
|
|
335
|
+
origin: SITE_URL.replace(/\/+$/, ""),
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
assert(ownerDeleteResponse.status === 200, `site owner should delete any share, got ${ownerDeleteResponse.status}`);
|
|
339
|
+
} finally {
|
|
340
|
+
await stopChild(authServer);
|
|
341
|
+
if (serverProcess === authServer) {
|
|
342
|
+
serverProcess = null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function publishWithGithubSession(apiUrl, cookie, shareId, title) {
|
|
348
|
+
const response = await fetch(`${apiUrl}/api/snapshots`, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: {
|
|
351
|
+
cookie,
|
|
352
|
+
"content-type": "application/json",
|
|
353
|
+
origin: SITE_URL.replace(/\/+$/, ""),
|
|
354
|
+
},
|
|
355
|
+
body: JSON.stringify({
|
|
356
|
+
shareId,
|
|
357
|
+
apiUrl,
|
|
358
|
+
siteUrl: SITE_URL,
|
|
359
|
+
snapshot: createSnapshot(title),
|
|
360
|
+
}),
|
|
361
|
+
});
|
|
362
|
+
const payload = await response.json();
|
|
363
|
+
assert(response.status === 200, `GitHub session publish should succeed, got ${response.status}: ${JSON.stringify(payload)}`);
|
|
364
|
+
const detail = await fetchJson(`${apiUrl}/api/snapshots/${encodeURIComponent(shareId)}`);
|
|
365
|
+
return detail.share;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function assertVerifyScriptReadOnly(apiUrl) {
|
|
369
|
+
const result = await runNode(
|
|
370
|
+
[
|
|
371
|
+
"deploy/aliyun/verify-public-share.mjs",
|
|
372
|
+
"--api-url",
|
|
373
|
+
apiUrl,
|
|
374
|
+
"--site-url",
|
|
375
|
+
SITE_URL,
|
|
376
|
+
"--skip-site-config",
|
|
377
|
+
],
|
|
378
|
+
{
|
|
379
|
+
SNAPSHOT_SHARE_TOKEN: TOKEN,
|
|
380
|
+
}
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
assert(result.stdout.includes("Skipped publish check"), "verify script should skip publish unless --publish is passed");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function assertVerifyScriptPublish(apiUrl) {
|
|
387
|
+
const tokenFile = path.join(tempDir, "verify-local-publisher.json");
|
|
388
|
+
const siteServer = await startTestSiteServer(apiUrl);
|
|
389
|
+
|
|
390
|
+
await writeFile(
|
|
391
|
+
tokenFile,
|
|
392
|
+
`${JSON.stringify({
|
|
393
|
+
snapshotShareToken: TOKEN,
|
|
394
|
+
snapshotShareApiUrl: apiUrl,
|
|
395
|
+
snapshotShareSiteUrl: siteServer.url,
|
|
396
|
+
}, null, 2)}\n`,
|
|
397
|
+
"utf8"
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const result = await runNode(
|
|
402
|
+
[
|
|
403
|
+
"deploy/aliyun/verify-public-share.mjs",
|
|
404
|
+
"--api-url",
|
|
405
|
+
apiUrl,
|
|
406
|
+
"--site-url",
|
|
407
|
+
siteServer.url,
|
|
408
|
+
"--check-local-config",
|
|
409
|
+
"--publish",
|
|
410
|
+
"--token-file",
|
|
411
|
+
tokenFile,
|
|
412
|
+
"--share-id",
|
|
413
|
+
"snap_verifyshare123456",
|
|
414
|
+
],
|
|
415
|
+
{
|
|
416
|
+
SNAPSHOT_SHARE_TOKEN: "",
|
|
417
|
+
}
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
assert(result.stdout.includes("Publish check ok"), "verify script should publish when --publish is passed");
|
|
421
|
+
assert(result.stdout.includes("Site config points at the public API"), "verify script should check site config");
|
|
422
|
+
assert(
|
|
423
|
+
result.stdout.includes("Public share page shell is reachable"),
|
|
424
|
+
"verify script should check the returned public share page"
|
|
425
|
+
);
|
|
426
|
+
assert(
|
|
427
|
+
result.stdout.includes("Local publisher config points at the public API"),
|
|
428
|
+
"verify script should check local publisher config"
|
|
429
|
+
);
|
|
430
|
+
} finally {
|
|
431
|
+
await siteServer.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function assertLocalViewerPublish(apiUrl) {
|
|
436
|
+
const codexHome = path.join(tempDir, "codex-home");
|
|
437
|
+
const sessionDir = path.join(codexHome, "sessions");
|
|
438
|
+
const sessionPath = path.join(sessionDir, "local-publish-session.jsonl");
|
|
439
|
+
const traeRecordingsDir = path.join(tempDir, "trae-recordings");
|
|
440
|
+
const traeRecordingPath = path.join(traeRecordingsDir, "dom-thread-local-viewer-test.jsonl");
|
|
441
|
+
const tokenFile = path.join(tempDir, "local-publisher-agent.json");
|
|
442
|
+
const viewerPort = await getFreePort();
|
|
443
|
+
const viewerUrl = `http://127.0.0.1:${viewerPort}`;
|
|
444
|
+
let viewerProcess;
|
|
445
|
+
|
|
446
|
+
await mkdir(sessionDir, { recursive: true });
|
|
447
|
+
await mkdir(traeRecordingsDir, { recursive: true });
|
|
448
|
+
await writeFile(sessionPath, `${createCodexSessionJsonl()}\n`, "utf8");
|
|
449
|
+
await writeFile(traeRecordingPath, `${createTraeRecordingJsonl()}\n`, "utf8");
|
|
450
|
+
await writeFile(
|
|
451
|
+
tokenFile,
|
|
452
|
+
`${JSON.stringify({
|
|
453
|
+
snapshotShareToken: TOKEN,
|
|
454
|
+
snapshotShareApiUrl: apiUrl,
|
|
455
|
+
snapshotShareSiteUrl: SITE_URL,
|
|
456
|
+
}, null, 2)}\n`,
|
|
457
|
+
"utf8"
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
viewerProcess = spawn(
|
|
462
|
+
process.execPath,
|
|
463
|
+
[
|
|
464
|
+
"bin/codex-snapshot.mjs",
|
|
465
|
+
"serve",
|
|
466
|
+
"--host",
|
|
467
|
+
"127.0.0.1",
|
|
468
|
+
"--port",
|
|
469
|
+
String(viewerPort),
|
|
470
|
+
"--codex-home",
|
|
471
|
+
codexHome,
|
|
472
|
+
"--claude-home",
|
|
473
|
+
path.join(tempDir, "claude-home"),
|
|
474
|
+
"--trae-home",
|
|
475
|
+
path.join(tempDir, "trae-home"),
|
|
476
|
+
"--trae-app-home",
|
|
477
|
+
path.join(tempDir, "trae-app-home"),
|
|
478
|
+
"--trae-recordings-dir",
|
|
479
|
+
traeRecordingsDir,
|
|
480
|
+
],
|
|
481
|
+
{
|
|
482
|
+
cwd: ROOT_DIR,
|
|
483
|
+
env: {
|
|
484
|
+
...process.env,
|
|
485
|
+
CODEX_SNAPSHOTS_AGENT_FILE: tokenFile,
|
|
486
|
+
CODEX_SNAPSHOTS_SHARE_API_URL: "",
|
|
487
|
+
CODEX_SNAPSHOTS_SHARE_TOKEN: "",
|
|
488
|
+
NEXT_PUBLIC_TOKEN_BOARD_API_URL: "",
|
|
489
|
+
SNAPSHOT_SHARE_API_URL: "",
|
|
490
|
+
SNAPSHOT_SHARE_PUBLIC_API_URL: "",
|
|
491
|
+
SNAPSHOT_SHARE_SITE_URL: "",
|
|
492
|
+
SNAPSHOT_SHARE_TOKEN: "",
|
|
493
|
+
SNAPSHOT_SHARE_TOKEN_FILE: tokenFile,
|
|
494
|
+
TOKEN_BOARD_AGENT_FILE: "",
|
|
495
|
+
TOKEN_BOARD_AGENT_TOKEN: "",
|
|
496
|
+
TOKEN_BOARD_API_URL: "",
|
|
497
|
+
TOKEN_BOARD_UPLOAD_TOKEN: "",
|
|
498
|
+
},
|
|
499
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
500
|
+
}
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const output = collectChildOutput(viewerProcess);
|
|
504
|
+
await waitForJson(`${viewerUrl}/api/sessions?source=codex&limit=5`, output, viewerProcess);
|
|
505
|
+
const allSessions = await fetchJson(`${viewerUrl}/api/sessions?source=all&limit=5&completeOnly=0`);
|
|
506
|
+
assert(
|
|
507
|
+
allSessions.some((session) => session.source === "trae" && session.sourceKind === "recorded"),
|
|
508
|
+
"local viewer should list Trae recorded sessions without runtime reference errors"
|
|
509
|
+
);
|
|
510
|
+
const viewerHtml = await fetchText(viewerUrl);
|
|
511
|
+
assert(viewerHtml.includes(apiUrl), "local viewer should read share API URL from the agent config file");
|
|
512
|
+
assert(viewerHtml.includes(SITE_URL.replace(/\/+$/, "")), "local viewer should read site URL from the agent config file");
|
|
513
|
+
assert(!viewerHtml.includes(TOKEN), "local viewer HTML must not expose the publish token");
|
|
514
|
+
assert(viewerHtml.includes("collapsedProjects"), "local viewer should track collapsed projects");
|
|
515
|
+
assert(viewerHtml.includes("data-project-collapse"), "local viewer project headers should be clickable collapse controls");
|
|
516
|
+
assert(viewerHtml.includes("CODEX_SNAPSHOT_CSRF_TOKEN"), "local viewer should include a CSRF token for publish actions");
|
|
517
|
+
const csrfToken = extractCsrfToken(viewerHtml);
|
|
518
|
+
|
|
519
|
+
const options = new URLSearchParams({
|
|
520
|
+
id: sessionPath,
|
|
521
|
+
includeTools: "0",
|
|
522
|
+
includeToolOutput: "0",
|
|
523
|
+
redact: "1",
|
|
524
|
+
safety: "0",
|
|
525
|
+
});
|
|
526
|
+
const publishUrl = `${viewerUrl}/api/publish?${options.toString()}`;
|
|
527
|
+
const snapshotPayload = await fetchJson(`${viewerUrl}/api/snapshot?${options.toString()}`);
|
|
528
|
+
assert(snapshotPayload.goalObjective === GOAL_OBJECTIVE, "local viewer snapshot metadata should expose the goal objective");
|
|
529
|
+
assert(
|
|
530
|
+
!snapshotPayload.turns?.some((turn) => String(turn.text || "").includes("<goal_context>")),
|
|
531
|
+
"local viewer snapshot turns should not include Codex internal goal context",
|
|
532
|
+
);
|
|
533
|
+
const getPublishResponse = await fetch(publishUrl);
|
|
534
|
+
assert(getPublishResponse.status === 405, `local viewer publish should reject GET, got ${getPublishResponse.status}`);
|
|
535
|
+
|
|
536
|
+
const noOriginPublishResponse = await fetch(publishUrl, {
|
|
537
|
+
method: "POST",
|
|
538
|
+
headers: {
|
|
539
|
+
"x-codex-snapshot-csrf": csrfToken,
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
assert(
|
|
543
|
+
noOriginPublishResponse.status === 403,
|
|
544
|
+
`local viewer publish should reject POST without Origin, got ${noOriginPublishResponse.status}`,
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const badCsrfPublishResponse = await fetch(publishUrl, {
|
|
548
|
+
method: "POST",
|
|
549
|
+
headers: {
|
|
550
|
+
origin: new URL(viewerUrl).origin,
|
|
551
|
+
"x-codex-snapshot-csrf": "bad-token",
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
assert(
|
|
555
|
+
badCsrfPublishResponse.status === 403,
|
|
556
|
+
`local viewer publish should reject invalid CSRF tokens, got ${badCsrfPublishResponse.status}`,
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const payload = await fetchJson(publishUrl, {
|
|
560
|
+
method: "POST",
|
|
561
|
+
headers: {
|
|
562
|
+
origin: new URL(viewerUrl).origin,
|
|
563
|
+
"x-codex-snapshot-csrf": csrfToken,
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
assert(payload.id?.startsWith("snap_"), `local viewer publish should return a share id: ${JSON.stringify(payload)}`);
|
|
568
|
+
assert(payload.url, `local viewer publish should return a share URL: ${JSON.stringify(payload)}`);
|
|
569
|
+
|
|
570
|
+
const url = new URL(payload.url);
|
|
571
|
+
assert(url.origin === new URL(SITE_URL).origin, `local viewer share URL should use site origin: ${payload.url}`);
|
|
572
|
+
assert(url.searchParams.get("api") === apiUrl, `local viewer share URL should include API URL: ${payload.url}`);
|
|
573
|
+
|
|
574
|
+
const detail = await fetchJson(`${apiUrl}/api/snapshots/${encodeURIComponent(payload.id)}`);
|
|
575
|
+
assert(detail.share?.title === "Publish this session to the public website.", "local viewer should publish the selected session title");
|
|
576
|
+
assert(detail.share?.goalObjective === GOAL_OBJECTIVE, "local viewer should publish goal metadata on the share summary");
|
|
577
|
+
assert(detail.snapshot?.goalObjective === GOAL_OBJECTIVE, "local viewer should publish goal metadata on the snapshot");
|
|
578
|
+
assert(detail.snapshot?.redacted === true, "local viewer publish should force redacted snapshots");
|
|
579
|
+
assert(
|
|
580
|
+
!detail.snapshot?.turns?.some((turn) => String(turn.text || "").includes("<goal_context>")),
|
|
581
|
+
"local viewer should not publish Codex internal goal context turns",
|
|
582
|
+
);
|
|
583
|
+
const assistantTurn = detail.snapshot?.turns?.find((turn) => turn.role === "assistant");
|
|
584
|
+
assert(
|
|
585
|
+
assistantTurn?.html?.includes('target="_blank"'),
|
|
586
|
+
"local viewer should publish markdown links that open in a new tab"
|
|
587
|
+
);
|
|
588
|
+
assert(
|
|
589
|
+
assistantTurn?.html?.includes('rel="noopener noreferrer"'),
|
|
590
|
+
"local viewer should publish markdown links with opener protection"
|
|
591
|
+
);
|
|
592
|
+
} finally {
|
|
593
|
+
await stopChild(viewerProcess);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function createSnapshot(title) {
|
|
598
|
+
return {
|
|
599
|
+
title,
|
|
600
|
+
engine: "codex",
|
|
601
|
+
engineLabel: "Codex",
|
|
602
|
+
goalObjective: GOAL_OBJECTIVE,
|
|
603
|
+
cwd: "/Users/example/private-project",
|
|
604
|
+
filePath: "/Users/example/private-project/.codex/session.json",
|
|
605
|
+
redacted: true,
|
|
606
|
+
turns: [
|
|
607
|
+
{
|
|
608
|
+
role: "user",
|
|
609
|
+
text: "Can this session be listed publicly?",
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
role: "assistant",
|
|
613
|
+
text: "Yes. A redacted share can be published and listed by the public API.",
|
|
614
|
+
html: '<p><a href="javascript:alert(1)" onclick="alert(2)">unsafe</a> <a href="https://example.com">safe</a><script>alert(3)</script></p>',
|
|
615
|
+
},
|
|
616
|
+
],
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function createCodexSessionJsonl() {
|
|
621
|
+
return [
|
|
622
|
+
{
|
|
623
|
+
type: "session_meta",
|
|
624
|
+
timestamp: "2026-05-28T00:00:00.000Z",
|
|
625
|
+
payload: {
|
|
626
|
+
id: "local-publish-session-001",
|
|
627
|
+
cwd: "/Users/example/private-project",
|
|
628
|
+
timestamp: "2026-05-28T00:00:00.000Z",
|
|
629
|
+
model_provider: "openai",
|
|
630
|
+
originator: "codex",
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
type: "response_item",
|
|
635
|
+
timestamp: "2026-05-28T00:00:01.000Z",
|
|
636
|
+
payload: {
|
|
637
|
+
type: "message",
|
|
638
|
+
role: "user",
|
|
639
|
+
content: [
|
|
640
|
+
{
|
|
641
|
+
type: "input_text",
|
|
642
|
+
text: "Publish this session to the public website.",
|
|
643
|
+
},
|
|
644
|
+
],
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
type: "response_item",
|
|
649
|
+
timestamp: "2026-05-28T00:00:02.000Z",
|
|
650
|
+
payload: {
|
|
651
|
+
type: "message",
|
|
652
|
+
role: "user",
|
|
653
|
+
content: [
|
|
654
|
+
{
|
|
655
|
+
type: "input_text",
|
|
656
|
+
text: [
|
|
657
|
+
"<goal_context>",
|
|
658
|
+
"Continue working toward the active thread goal.",
|
|
659
|
+
"",
|
|
660
|
+
"<objective>",
|
|
661
|
+
GOAL_OBJECTIVE,
|
|
662
|
+
"</objective>",
|
|
663
|
+
"",
|
|
664
|
+
"Blocked audit:",
|
|
665
|
+
"- Do not call update_goal unless the goal is complete.",
|
|
666
|
+
"</goal_context>",
|
|
667
|
+
].join("\n"),
|
|
668
|
+
},
|
|
669
|
+
],
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
type: "response_item",
|
|
674
|
+
timestamp: "2026-05-28T00:00:03.000Z",
|
|
675
|
+
payload: {
|
|
676
|
+
type: "message",
|
|
677
|
+
role: "assistant",
|
|
678
|
+
content: [
|
|
679
|
+
{
|
|
680
|
+
type: "output_text",
|
|
681
|
+
text: "The session is redacted and ready for [public listing](https://example.com/share).",
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
].map((row) => JSON.stringify(row)).join("\n");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function createTraeRecordingJsonl() {
|
|
690
|
+
return [
|
|
691
|
+
{
|
|
692
|
+
schema: "trae-local-recorder-event/v1",
|
|
693
|
+
kind: "dom-message",
|
|
694
|
+
source: "dom",
|
|
695
|
+
domThreadId: "dom-thread-local-viewer-test",
|
|
696
|
+
pageSession: "page-local-viewer-test",
|
|
697
|
+
capturedAt: "2026-05-28T00:00:01.000Z",
|
|
698
|
+
sequence: 1,
|
|
699
|
+
body: JSON.stringify({ role: "user", text: "Trae captured question" }),
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
schema: "trae-local-recorder-event/v1",
|
|
703
|
+
kind: "dom-message",
|
|
704
|
+
source: "dom",
|
|
705
|
+
domThreadId: "dom-thread-local-viewer-test",
|
|
706
|
+
pageSession: "page-local-viewer-test",
|
|
707
|
+
capturedAt: "2026-05-28T00:00:02.000Z",
|
|
708
|
+
sequence: 2,
|
|
709
|
+
body: JSON.stringify({ role: "assistant", text: "Trae captured answer" }),
|
|
710
|
+
},
|
|
711
|
+
].map((row) => JSON.stringify(row)).join("\n");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function waitForHealth(apiUrl, output) {
|
|
715
|
+
const deadline = Date.now() + 7000;
|
|
716
|
+
let lastError;
|
|
717
|
+
|
|
718
|
+
while (Date.now() < deadline) {
|
|
719
|
+
if (serverProcess?.exitCode !== null) {
|
|
720
|
+
throw new Error(`share API exited early with code ${serverProcess.exitCode}\n${output.text()}`);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
const payload = await fetchJson(`${apiUrl}/api/snapshots/health`, { timeoutMs: 500 });
|
|
725
|
+
if (payload.ok === true) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
} catch (error) {
|
|
729
|
+
lastError = error;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
await sleep(120);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
throw new Error(`share API did not become ready: ${lastError?.message || "timeout"}\n${output.text()}`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function waitForJson(url, output, childProcess) {
|
|
739
|
+
const deadline = Date.now() + 7000;
|
|
740
|
+
let lastError;
|
|
741
|
+
|
|
742
|
+
while (Date.now() < deadline) {
|
|
743
|
+
if (childProcess?.exitCode !== null) {
|
|
744
|
+
throw new Error(`process exited early with code ${childProcess.exitCode}\n${output.text()}`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
await fetchJson(url, { timeoutMs: 500 });
|
|
749
|
+
return;
|
|
750
|
+
} catch (error) {
|
|
751
|
+
lastError = error;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
await sleep(120);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
throw new Error(`process did not become ready: ${lastError?.message || "timeout"}\n${output.text()}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function fetchJson(url, options = {}) {
|
|
761
|
+
const { timeoutMs = 2000, ...fetchOptions } = options;
|
|
762
|
+
const response = await fetch(url, {
|
|
763
|
+
...fetchOptions,
|
|
764
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
765
|
+
});
|
|
766
|
+
const text = await response.text();
|
|
767
|
+
let payload;
|
|
768
|
+
|
|
769
|
+
try {
|
|
770
|
+
payload = JSON.parse(text);
|
|
771
|
+
} catch {
|
|
772
|
+
throw new Error(`Expected JSON from ${url}, got ${text.slice(0, 200)}`);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (!response.ok) {
|
|
776
|
+
throw new Error(`${url} failed with HTTP ${response.status}: ${payload.error || text}`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return payload;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function fetchText(url, options = {}) {
|
|
783
|
+
const { timeoutMs = 2000, ...fetchOptions } = options;
|
|
784
|
+
const response = await fetch(url, {
|
|
785
|
+
...fetchOptions,
|
|
786
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
787
|
+
});
|
|
788
|
+
const text = await response.text();
|
|
789
|
+
|
|
790
|
+
if (!response.ok) {
|
|
791
|
+
throw new Error(`${url} failed with HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return text;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function startTestSiteServer(apiUrl) {
|
|
798
|
+
const server = http.createServer((request, response) => {
|
|
799
|
+
const url = new URL(request.url || "/", "http://127.0.0.1");
|
|
800
|
+
|
|
801
|
+
if (request.method !== "GET") {
|
|
802
|
+
response.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
|
|
803
|
+
response.end("method not allowed");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (url.pathname === "/assets/config.js") {
|
|
808
|
+
response.writeHead(200, { "content-type": "application/javascript; charset=utf-8" });
|
|
809
|
+
response.end(`window.CODEX_SNAPSHOTS_CONFIG = { apiUrl: ${JSON.stringify(apiUrl)} };\n`);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (url.pathname === "/share/" || url.pathname === "/share") {
|
|
814
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
815
|
+
response.end(`<!doctype html>
|
|
816
|
+
<html lang="zh-CN">
|
|
817
|
+
<head><meta charset="utf-8"><title>Codex Snapshots</title></head>
|
|
818
|
+
<body>
|
|
819
|
+
<main id="share-content"></main>
|
|
820
|
+
<script src="../assets/config.js"></script>
|
|
821
|
+
<script src="../assets/share.js"></script>
|
|
822
|
+
</body>
|
|
823
|
+
</html>`);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
828
|
+
response.end("not found");
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
await new Promise((resolve, reject) => {
|
|
832
|
+
server.once("error", reject);
|
|
833
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
const address = server.address();
|
|
837
|
+
assert(address && typeof address === "object", "test site server should expose a port");
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
841
|
+
close() {
|
|
842
|
+
return new Promise((resolve, reject) => {
|
|
843
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
844
|
+
});
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function runNode(args, env) {
|
|
850
|
+
return new Promise((resolve, reject) => {
|
|
851
|
+
const child = spawn(process.execPath, args, {
|
|
852
|
+
cwd: ROOT_DIR,
|
|
853
|
+
env: {
|
|
854
|
+
...process.env,
|
|
855
|
+
...env,
|
|
856
|
+
},
|
|
857
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
858
|
+
});
|
|
859
|
+
const output = collectChildOutput(child);
|
|
860
|
+
|
|
861
|
+
child.once("error", reject);
|
|
862
|
+
child.once("exit", (code, signal) => {
|
|
863
|
+
const text = output.text();
|
|
864
|
+
if (code === 0) {
|
|
865
|
+
resolve({
|
|
866
|
+
stdout: output.stdout,
|
|
867
|
+
stderr: output.stderr,
|
|
868
|
+
});
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
reject(new Error(`node ${args.join(" ")} failed with ${signal || code}\n${text}`));
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function collectChildOutput(child) {
|
|
877
|
+
const chunks = {
|
|
878
|
+
stdout: "",
|
|
879
|
+
stderr: "",
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
child?.stdout?.setEncoding("utf8");
|
|
883
|
+
child?.stderr?.setEncoding("utf8");
|
|
884
|
+
child?.stdout?.on("data", (chunk) => {
|
|
885
|
+
chunks.stdout += chunk;
|
|
886
|
+
});
|
|
887
|
+
child?.stderr?.on("data", (chunk) => {
|
|
888
|
+
chunks.stderr += chunk;
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
get stdout() {
|
|
893
|
+
return chunks.stdout;
|
|
894
|
+
},
|
|
895
|
+
get stderr() {
|
|
896
|
+
return chunks.stderr;
|
|
897
|
+
},
|
|
898
|
+
text() {
|
|
899
|
+
return [chunks.stdout, chunks.stderr].filter(Boolean).join("\n");
|
|
900
|
+
},
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function extractCsrfToken(html) {
|
|
905
|
+
const match = html.match(/CODEX_SNAPSHOT_CSRF_TOKEN=("(?:\\.|[^"])*")/);
|
|
906
|
+
assert(match, "viewer HTML should expose a JSON-encoded CSRF token");
|
|
907
|
+
const token = JSON.parse(match[1]);
|
|
908
|
+
assert(typeof token === "string" && token.length >= 32, "CSRF token should be a strong string");
|
|
909
|
+
return token;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function githubSessionCookie(user, secret) {
|
|
913
|
+
const body = Buffer.from(
|
|
914
|
+
JSON.stringify({
|
|
915
|
+
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
|
916
|
+
user,
|
|
917
|
+
}),
|
|
918
|
+
"utf8",
|
|
919
|
+
).toString("base64url");
|
|
920
|
+
const signature = createHmac("sha256", secret).update(body).digest("base64url");
|
|
921
|
+
return `codex_snapshots_session=${encodeURIComponent(`${body}.${signature}`)}`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function getFreePort() {
|
|
925
|
+
return new Promise((resolve, reject) => {
|
|
926
|
+
const server = net.createServer();
|
|
927
|
+
server.unref();
|
|
928
|
+
server.once("error", reject);
|
|
929
|
+
server.listen(0, "127.0.0.1", () => {
|
|
930
|
+
const address = server.address();
|
|
931
|
+
server.close(() => {
|
|
932
|
+
if (address && typeof address === "object") {
|
|
933
|
+
resolve(address.port);
|
|
934
|
+
} else {
|
|
935
|
+
reject(new Error("Could not allocate a local test port"));
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function stopChild(child) {
|
|
943
|
+
if (!child || child.exitCode !== null) {
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
child.kill("SIGTERM");
|
|
948
|
+
|
|
949
|
+
await Promise.race([
|
|
950
|
+
new Promise((resolve) => child.once("exit", resolve)),
|
|
951
|
+
sleep(1500).then(() => {
|
|
952
|
+
if (child.exitCode === null) {
|
|
953
|
+
child.kill("SIGKILL");
|
|
954
|
+
}
|
|
955
|
+
}),
|
|
956
|
+
]);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function assert(condition, message) {
|
|
960
|
+
if (!condition) {
|
|
961
|
+
throw new Error(message);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function sleep(ms) {
|
|
966
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
967
|
+
}
|