@steipete/oracle 0.15.0 → 0.15.2

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 (46) hide show
  1. package/dist/bin/oracle-cli.js +14 -6
  2. package/dist/docs-site/bridge.html +17 -1
  3. package/dist/docs-site/browser-mode.html +2 -2
  4. package/dist/docs-site/configuration.html +12 -2
  5. package/dist/docs-site/openai-endpoints.html +12 -0
  6. package/dist/scripts/test-browser.js +13 -2
  7. package/dist/src/browser/actions/assistantResponse.js +81 -50
  8. package/dist/src/browser/actions/attachments.js +31 -5
  9. package/dist/src/browser/actions/deepResearch.js +218 -73
  10. package/dist/src/browser/actions/modelSelection.js +30 -7
  11. package/dist/src/browser/actions/promptComposer.js +75 -19
  12. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  13. package/dist/src/browser/artifacts.js +191 -6
  14. package/dist/src/browser/chatgptFiles.js +529 -98
  15. package/dist/src/browser/chatgptImages.js +3 -4
  16. package/dist/src/browser/chromeLifecycle.js +1 -0
  17. package/dist/src/browser/constants.js +6 -0
  18. package/dist/src/browser/conversationTurns.js +16 -0
  19. package/dist/src/browser/conversationUrlMonitor.js +64 -0
  20. package/dist/src/browser/cookies.js +72 -0
  21. package/dist/src/browser/index.js +103 -94
  22. package/dist/src/browser/projectSourcesRunner.js +3 -2
  23. package/dist/src/browser/reattach.js +27 -11
  24. package/dist/src/browser/reattachHelpers.js +14 -5
  25. package/dist/src/browser/sessionRunner.js +9 -3
  26. package/dist/src/cli/bridge/client.js +4 -1
  27. package/dist/src/cli/bridge/doctor.js +19 -0
  28. package/dist/src/cli/runOptions.js +11 -2
  29. package/dist/src/cli/sessionDisplay.js +6 -1
  30. package/dist/src/cli/sessionRunner.js +28 -10
  31. package/dist/src/config.js +3 -0
  32. package/dist/src/oracle/client.js +2 -0
  33. package/dist/src/oracle/modelResolver.js +85 -0
  34. package/dist/src/oracle/multiModelRunner.js +4 -1
  35. package/dist/src/oracle/oscProgress.js +3 -2
  36. package/dist/src/oracle/run.js +4 -1
  37. package/dist/src/remote/client.js +253 -22
  38. package/dist/src/remote/health.js +27 -0
  39. package/dist/src/remote/server.js +239 -4
  40. package/dist/src/remote/types.js +1 -1
  41. package/dist/src/sessionManager.js +1 -0
  42. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  43. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  44. package/package.json +20 -20
  45. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  46. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -1,6 +1,11 @@
1
1
  import http from "node:http";
2
+ import { createWriteStream } from "node:fs";
3
+ import { Transform } from "node:stream";
4
+ import { pipeline } from "node:stream/promises";
2
5
  import path from "node:path";
3
- import { readFile } from "node:fs/promises";
6
+ import { mkdir, readFile, rename, rm, stat } from "node:fs/promises";
7
+ import { appendArtifacts, computeFileSha256, resolveSessionArtifactsDir, resolveUniqueArtifactPath, sanitizeArtifactFilename, sanitizeArtifactMimeType, validateArtifactFile, } from "../browser/artifacts.js";
8
+ import { MAX_REMOTE_ARTIFACT_BYTES, } from "./types.js";
4
9
  import { parseHostPort } from "../bridge/connection.js";
5
10
  export function createRemoteBrowserExecutor({ host, token }) {
6
11
  // Return a drop-in replacement for runBrowserMode so the browser session runner can stay unchanged.
@@ -25,6 +30,18 @@ export function createRemoteBrowserExecutor({ host, token }) {
25
30
  const body = Buffer.from(JSON.stringify(payload));
26
31
  const { hostname, port } = parseHost(host);
27
32
  return new Promise((resolve, reject) => {
33
+ const transferredFiles = [];
34
+ const transferFailures = [];
35
+ const transferPromises = [];
36
+ let artifactTransferQueue = Promise.resolve();
37
+ let settled = false;
38
+ let resolved = null;
39
+ const fail = (error) => {
40
+ if (settled)
41
+ return;
42
+ settled = true;
43
+ reject(error);
44
+ };
28
45
  const req = http.request({
29
46
  hostname,
30
47
  port,
@@ -38,13 +55,12 @@ export function createRemoteBrowserExecutor({ host, token }) {
38
55
  }, (res) => {
39
56
  if (res.statusCode !== 200) {
40
57
  collectError(res)
41
- .then((message) => reject(new Error(message)))
42
- .catch(reject);
58
+ .then((message) => fail(new Error(message)))
59
+ .catch(fail);
43
60
  return;
44
61
  }
45
62
  res.setEncoding("utf8");
46
63
  let buffer = "";
47
- let resolved = null;
48
64
  res.on("data", (chunk) => {
49
65
  buffer += chunk;
50
66
  let newlineIndex = buffer.indexOf("\n");
@@ -52,22 +68,51 @@ export function createRemoteBrowserExecutor({ host, token }) {
52
68
  const line = buffer.slice(0, newlineIndex).trim();
53
69
  buffer = buffer.slice(newlineIndex + 1);
54
70
  if (line.length > 0) {
55
- handleEvent(line, options, (result) => {
56
- resolved = result;
57
- }, reject);
71
+ const transferPromise = handleEvent({
72
+ line,
73
+ options,
74
+ hostname,
75
+ port,
76
+ token,
77
+ onResult: (result) => {
78
+ resolved = result;
79
+ },
80
+ onArtifact: (artifact) => {
81
+ transferredFiles.push(artifact);
82
+ },
83
+ onArtifactFailure: (message) => {
84
+ transferFailures.push(message);
85
+ },
86
+ enqueueArtifactTransfer: (transfer) => {
87
+ const queued = artifactTransferQueue.then(transfer);
88
+ artifactTransferQueue = queued.catch(() => undefined);
89
+ return queued;
90
+ },
91
+ onError: fail,
92
+ });
93
+ if (transferPromise) {
94
+ transferPromises.push(transferPromise);
95
+ }
58
96
  }
59
97
  newlineIndex = buffer.indexOf("\n");
60
98
  }
61
99
  });
62
100
  res.on("end", () => {
63
- if (resolved) {
64
- resolve(resolved);
65
- return;
66
- }
67
- reject(new Error("Remote browser run completed without a result."));
101
+ void (async () => {
102
+ await Promise.allSettled(transferPromises);
103
+ if (settled)
104
+ return;
105
+ if (!resolved) {
106
+ fail(new Error("Remote browser run completed without a result."));
107
+ return;
108
+ }
109
+ settled = true;
110
+ resolve(mergeTransferredArtifacts(resolved, transferredFiles, transferFailures));
111
+ })().catch(fail);
68
112
  });
113
+ res.on("error", fail);
69
114
  });
70
- req.on("error", reject);
115
+ req.on("error", fail);
71
116
  req.write(body);
72
117
  req.end();
73
118
  });
@@ -95,26 +140,212 @@ function parseHost(input) {
95
140
  throw new Error(`Invalid remote host: ${input} (${error instanceof Error ? error.message : String(error)})`);
96
141
  }
97
142
  }
98
- function handleEvent(line, options, onResult, onError) {
143
+ function handleEvent(params) {
99
144
  let event;
100
145
  try {
101
- event = JSON.parse(line);
146
+ event = JSON.parse(params.line);
102
147
  }
103
148
  catch (error) {
104
- onError(new Error(`Failed to parse remote event: ${error instanceof Error ? error.message : String(error)}`));
105
- return;
149
+ params.onError(new Error(`Failed to parse remote event: ${error instanceof Error ? error.message : String(error)}`));
150
+ return null;
106
151
  }
107
152
  if (event.type === "log") {
108
- options.log?.(event.message);
109
- return;
153
+ params.options.log?.(event.message);
154
+ return null;
110
155
  }
111
156
  if (event.type === "error") {
112
- onError(new Error(event.message));
113
- return;
157
+ params.onError(new Error(event.message));
158
+ return null;
159
+ }
160
+ if (event.type === "artifact-progress") {
161
+ if (params.options.verbose) {
162
+ params.options.log?.(`[browser] Artifact ${event.artifactId} ${event.phase}${event.receivedBytes !== undefined && event.totalBytes !== undefined
163
+ ? ` ${event.receivedBytes}/${event.totalBytes} bytes`
164
+ : ""}`);
165
+ }
166
+ return null;
167
+ }
168
+ if (event.type === "artifact-ready") {
169
+ const displayFilename = sanitizeArtifactFilename(String(event.artifact?.filename ?? ""), "artifact.bin");
170
+ const transfer = params.enqueueArtifactTransfer(() => transferRemoteArtifact({
171
+ hostname: params.hostname,
172
+ port: params.port,
173
+ token: params.token,
174
+ descriptor: event.artifact,
175
+ sessionId: params.options.sessionId,
176
+ log: params.options.log,
177
+ })
178
+ .then((artifact) => {
179
+ params.onArtifact(artifact);
180
+ })
181
+ .catch((error) => {
182
+ const message = error instanceof Error ? error.message : String(error);
183
+ const fallback = `Oracle captured the browser text response, but bridge artifact transfer failed for ${displayFilename}. Open the ChatGPT browser on the bridge host, download the ZIP/file shown in the current response, and copy it to a cloud-readable path. Reason: ${message}`;
184
+ params.options.log?.(`[browser] ${fallback}`);
185
+ params.onArtifactFailure(fallback);
186
+ }));
187
+ return transfer;
114
188
  }
115
189
  if (event.type === "result") {
116
- onResult(event.result);
190
+ params.onResult(event.result);
191
+ }
192
+ return null;
193
+ }
194
+ async function transferRemoteArtifact(params) {
195
+ validateRemoteArtifactDescriptor(params.descriptor);
196
+ const sessionId = params.sessionId ?? params.descriptor.runId;
197
+ const artifactsDir = resolveSessionArtifactsDir(sessionId);
198
+ await mkdir(artifactsDir, { recursive: true });
199
+ const filename = sanitizeArtifactFilename(params.descriptor.filename, `artifact-${params.descriptor.artifactId}.bin`);
200
+ const finalPath = await resolveUniqueArtifactPath(path.join(artifactsDir, filename));
201
+ const partPath = `${finalPath}.part-${params.descriptor.artifactId}`;
202
+ const artifactPath = `/runs/${encodeURIComponent(params.descriptor.runId)}/artifacts/${encodeURIComponent(params.descriptor.artifactId)}`;
203
+ params.log?.(`[browser] Transferring artifact ${filename} from bridge host...`);
204
+ await downloadArtifactToFile({
205
+ hostname: params.hostname,
206
+ port: params.port,
207
+ path: artifactPath,
208
+ token: params.token,
209
+ targetPath: partPath,
210
+ descriptor: params.descriptor,
211
+ }).catch(async (error) => {
212
+ await rm(partPath, { force: true }).catch(() => undefined);
213
+ throw error;
214
+ });
215
+ const fileStat = await stat(partPath);
216
+ if (fileStat.size !== params.descriptor.byteSize) {
217
+ await rm(partPath, { force: true }).catch(() => undefined);
218
+ throw new Error(`size mismatch (${fileStat.size} != ${params.descriptor.byteSize})`);
219
+ }
220
+ const sha256 = await computeFileSha256(partPath);
221
+ if (sha256 !== params.descriptor.sha256) {
222
+ await rm(partPath, { force: true }).catch(() => undefined);
223
+ throw new Error("sha256 mismatch");
224
+ }
225
+ const validation = await validateArtifactFile({
226
+ path: partPath,
227
+ filename,
228
+ mimeType: sanitizeArtifactMimeType(params.descriptor.mimeType),
229
+ });
230
+ if (!validation.ok) {
231
+ await rm(partPath, { force: true }).catch(() => undefined);
232
+ throw new Error(`${validation.type} validation failed: ${validation.error ?? "invalid"}`);
233
+ }
234
+ await rename(partPath, finalPath);
235
+ params.log?.(`[browser] Transferred artifact to ${finalPath}`);
236
+ const publishedFilename = path.basename(finalPath);
237
+ return {
238
+ kind: "file",
239
+ path: finalPath,
240
+ label: publishedFilename,
241
+ mimeType: sanitizeArtifactMimeType(params.descriptor.mimeType),
242
+ sizeBytes: fileStat.size,
243
+ sourceUrl: "bridge-artifact",
244
+ sha256,
245
+ validation,
246
+ transfer: { status: "completed", bytes: fileStat.size },
247
+ origin: { mode: "bridge" },
248
+ url: "bridge-artifact",
249
+ finalUrl: "bridge-artifact",
250
+ filename: publishedFilename,
251
+ };
252
+ }
253
+ async function downloadArtifactToFile(params) {
254
+ await new Promise((resolve, reject) => {
255
+ const req = http.request({
256
+ hostname: params.hostname,
257
+ port: params.port,
258
+ path: params.path,
259
+ method: "GET",
260
+ headers: params.token ? { authorization: `Bearer ${params.token}` } : undefined,
261
+ }, (res) => {
262
+ if (res.statusCode !== 200) {
263
+ collectError(res)
264
+ .then((message) => reject(new Error(message)))
265
+ .catch(reject);
266
+ return;
267
+ }
268
+ const headerSha = String(res.headers["x-oracle-artifact-sha256"] ?? "");
269
+ if (headerSha && headerSha !== params.descriptor.sha256) {
270
+ res.resume();
271
+ reject(new Error("artifact sha256 header mismatch"));
272
+ return;
273
+ }
274
+ const contentLengthHeader = res.headers["content-length"];
275
+ const contentLength = typeof contentLengthHeader === "string" ? Number(contentLengthHeader) : undefined;
276
+ if (contentLength !== undefined &&
277
+ (!Number.isSafeInteger(contentLength) ||
278
+ contentLength <= 0 ||
279
+ contentLength > MAX_REMOTE_ARTIFACT_BYTES ||
280
+ contentLength !== params.descriptor.byteSize)) {
281
+ res.resume();
282
+ reject(new Error("artifact content-length mismatch"));
283
+ return;
284
+ }
285
+ const output = createWriteStream(params.targetPath, { flags: "wx" });
286
+ let receivedBytes = 0;
287
+ const limiter = new Transform({
288
+ transform(chunk, _encoding, callback) {
289
+ receivedBytes += chunk.length;
290
+ if (receivedBytes > params.descriptor.byteSize ||
291
+ receivedBytes > MAX_REMOTE_ARTIFACT_BYTES) {
292
+ callback(new Error("artifact exceeded declared size"));
293
+ return;
294
+ }
295
+ callback(null, chunk);
296
+ },
297
+ });
298
+ void pipeline(res, limiter, output).then(() => resolve(), reject);
299
+ });
300
+ req.on("error", reject);
301
+ req.end();
302
+ });
303
+ }
304
+ function validateRemoteArtifactDescriptor(descriptor) {
305
+ if (!descriptor ||
306
+ typeof descriptor !== "object" ||
307
+ descriptor.kind !== "file" ||
308
+ typeof descriptor.runId !== "string" ||
309
+ !/^[a-zA-Z0-9_-]{1,128}$/.test(descriptor.runId) ||
310
+ typeof descriptor.artifactId !== "string" ||
311
+ !/^[a-zA-Z0-9_-]{1,128}$/.test(descriptor.artifactId) ||
312
+ typeof descriptor.filename !== "string" ||
313
+ !Number.isSafeInteger(descriptor.byteSize) ||
314
+ descriptor.byteSize <= 0 ||
315
+ descriptor.byteSize > MAX_REMOTE_ARTIFACT_BYTES ||
316
+ typeof descriptor.sha256 !== "string" ||
317
+ !/^[a-f0-9]{64}$/.test(descriptor.sha256)) {
318
+ throw new Error("invalid bridge artifact descriptor");
319
+ }
320
+ }
321
+ function mergeTransferredArtifacts(result, transferredFiles, transferFailures) {
322
+ const artifacts = appendArtifacts(result.artifacts, transferredFiles);
323
+ const savedFiles = appendSavedFiles(result.savedFiles, transferredFiles);
324
+ const warnings = [
325
+ ...(result.warnings ?? []),
326
+ ...transferFailures.map((message) => ({
327
+ code: "remote-artifact-transfer-failed",
328
+ severity: "warning",
329
+ message,
330
+ })),
331
+ ];
332
+ return {
333
+ ...result,
334
+ artifacts,
335
+ savedFiles,
336
+ warnings: warnings.length > 0 ? warnings : undefined,
337
+ };
338
+ }
339
+ function appendSavedFiles(existing, additions) {
340
+ const merged = new Map();
341
+ for (const artifact of existing ?? []) {
342
+ merged.set(artifact.path, artifact);
343
+ }
344
+ for (const artifact of additions) {
345
+ merged.set(artifact.path, artifact);
117
346
  }
347
+ const values = Array.from(merged.values());
348
+ return values.length > 0 ? values : undefined;
118
349
  }
119
350
  function collectError(res) {
120
351
  return new Promise((resolve, reject) => {
@@ -1,6 +1,7 @@
1
1
  import http from "node:http";
2
2
  import net from "node:net";
3
3
  import { parseHostPort } from "../bridge/connection.js";
4
+ import { MAX_REMOTE_ARTIFACT_BYTES } from "./types.js";
4
5
  export async function checkTcpConnection(host, timeoutMs = 2000) {
5
6
  const { hostname, port } = parseHostPort(host);
6
7
  return await new Promise((resolve) => {
@@ -47,11 +48,13 @@ export async function checkRemoteHealth({ host, token, timeoutMs = 5000, }) {
47
48
  const ok = response.json.ok === true;
48
49
  const version = response.json.version;
49
50
  const uptimeSeconds = response.json.uptimeSeconds;
51
+ const capabilities = parseCapabilities(response.json.capabilities);
50
52
  return {
51
53
  ok,
52
54
  statusCode: response.statusCode,
53
55
  version: typeof version === "string" ? version : undefined,
54
56
  uptimeSeconds: typeof uptimeSeconds === "number" ? uptimeSeconds : undefined,
57
+ capabilities,
55
58
  };
56
59
  }
57
60
  if (response.statusCode === 404) {
@@ -68,6 +71,30 @@ export async function checkRemoteHealth({ host, token, timeoutMs = 5000, }) {
68
71
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
69
72
  }
70
73
  }
74
+ function parseCapabilities(value) {
75
+ if (!value || typeof value !== "object") {
76
+ return undefined;
77
+ }
78
+ const raw = value;
79
+ if (raw.artifactTransfer !== true) {
80
+ return undefined;
81
+ }
82
+ const artifactProtocolVersion = raw.artifactProtocolVersion;
83
+ const maxArtifactBytes = raw.maxArtifactBytes;
84
+ if (typeof artifactProtocolVersion !== "number" ||
85
+ !Number.isSafeInteger(artifactProtocolVersion) ||
86
+ artifactProtocolVersion <= 0 ||
87
+ typeof maxArtifactBytes !== "number" ||
88
+ !Number.isSafeInteger(maxArtifactBytes) ||
89
+ maxArtifactBytes <= 0) {
90
+ return undefined;
91
+ }
92
+ return {
93
+ artifactTransfer: true,
94
+ artifactProtocolVersion,
95
+ maxArtifactBytes: Math.min(maxArtifactBytes, MAX_REMOTE_ARTIFACT_BYTES),
96
+ };
97
+ }
71
98
  function extractErrorMessage(json, bodyText) {
72
99
  if (json && typeof json === "object") {
73
100
  const err = json.error;
@@ -1,17 +1,29 @@
1
1
  import http from "node:http";
2
+ import { createReadStream } from "node:fs";
3
+ import { pipeline } from "node:stream/promises";
2
4
  import os from "node:os";
3
5
  import path from "node:path";
4
6
  import net from "node:net";
5
7
  import { randomBytes, randomUUID } from "node:crypto";
6
8
  import { spawn, spawnSync } from "node:child_process";
7
- import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
9
+ import { mkdtemp, rm, mkdir, writeFile, stat, realpath } from "node:fs/promises";
8
10
  import chalk from "chalk";
9
11
  import { runBrowserMode } from "../browserMode.js";
12
+ import { MAX_REMOTE_ARTIFACT_BYTES } from "./types.js";
10
13
  import { getCookies } from "@steipete/sweet-cookie";
11
14
  import { CHATGPT_URL } from "../browser/constants.js";
12
15
  import { getCliVersion } from "../version.js";
16
+ import { getOracleHomeDir } from "../oracleHome.js";
13
17
  import { cleanupStaleProfileState, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "../browser/profileState.js";
14
18
  import { normalizeChatgptUrl } from "../browser/utils.js";
19
+ import { computeFileSha256, sanitizeArtifactFilename, sanitizeArtifactMimeType, validateArtifactFile, } from "../browser/artifacts.js";
20
+ const ARTIFACT_PROTOCOL_VERSION = 1;
21
+ const REMOTE_ARTIFACT_TTL_MS = 30 * 60 * 1000;
22
+ const ARTIFACT_CAPABILITIES = {
23
+ artifactTransfer: true,
24
+ artifactProtocolVersion: ARTIFACT_PROTOCOL_VERSION,
25
+ maxArtifactBytes: MAX_REMOTE_ARTIFACT_BYTES,
26
+ };
15
27
  async function findAvailablePort() {
16
28
  return await new Promise((resolve, reject) => {
17
29
  const srv = net.createServer();
@@ -40,6 +52,7 @@ export async function createRemoteServer(options = {}, deps = {}) {
40
52
  : (_formatter, msg) => msg;
41
53
  // Single-flight guard: remote Chrome can only host one run at a time, so we serialize requests.
42
54
  let busy = false;
55
+ const artifactRegistry = new Map();
43
56
  if (!process.listenerCount("unhandledRejection")) {
44
57
  process.on("unhandledRejection", (reason) => {
45
58
  logger(`Unhandled promise rejection in remote server: ${reason instanceof Error ? reason.message : String(reason)}`);
@@ -67,9 +80,24 @@ export async function createRemoteServer(options = {}, deps = {}) {
67
80
  ok: true,
68
81
  version: getCliVersion(),
69
82
  uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
83
+ capabilities: ARTIFACT_CAPABILITIES,
70
84
  }));
71
85
  return;
72
86
  }
87
+ const artifactMatch = matchArtifactRequest(req);
88
+ if (artifactMatch) {
89
+ await serveRemoteArtifact({
90
+ req,
91
+ res,
92
+ authToken,
93
+ artifactRegistry,
94
+ logger,
95
+ verbose,
96
+ runId: artifactMatch.runId,
97
+ artifactId: artifactMatch.artifactId,
98
+ });
99
+ return;
100
+ }
73
101
  if (req.method !== "POST" || req.url !== "/runs") {
74
102
  res.statusCode = 404;
75
103
  res.end();
@@ -190,8 +218,30 @@ export async function createRemoteServer(options = {}, deps = {}) {
190
218
  sessionId: payload.options.sessionId,
191
219
  followUpPrompts: payload.options.followUpPrompts,
192
220
  });
193
- sendEvent({ type: "result", result: sanitizeResult(result) });
194
- logger(`[serve] Run ${runId} completed in ${Date.now() - runStartedAt}ms`);
221
+ const artifactRegistration = await registerRemoteArtifacts({
222
+ runId,
223
+ result,
224
+ artifactRegistry,
225
+ logger,
226
+ });
227
+ const artifactDescriptors = artifactRegistration.descriptors;
228
+ if (artifactDescriptors.length > 0) {
229
+ sendEvent({
230
+ type: "log",
231
+ message: `[browser] ${artifactDescriptors.length} artifact(s) ready for bridge transfer. ` +
232
+ "If no cloud-local artifact path appears, upgrade both Oracle bridge endpoints or copy the file manually from the Windows browser host.",
233
+ });
234
+ }
235
+ for (const artifact of artifactDescriptors) {
236
+ sendEvent({ type: "artifact-ready", runId, artifact });
237
+ }
238
+ sendEvent({
239
+ type: "result",
240
+ result: sanitizeResult(result, artifactRegistration.warnings),
241
+ });
242
+ logger(`[serve] Run ${runId} completed in ${Date.now() - runStartedAt}ms${artifactDescriptors.length > 0
243
+ ? `; ${artifactDescriptors.length} artifact(s) ready for bridge transfer`
244
+ : ""}`);
195
245
  }
196
246
  catch (error) {
197
247
  const message = error instanceof Error ? error.message : String(error);
@@ -303,6 +353,190 @@ export async function serveRemote(options = {}) {
303
353
  process.on("SIGTERM", shutdown);
304
354
  });
305
355
  }
356
+ function matchArtifactRequest(req) {
357
+ if (req.method !== "GET" || !req.url) {
358
+ return null;
359
+ }
360
+ let url;
361
+ try {
362
+ url = new URL(req.url, "http://oracle.local");
363
+ }
364
+ catch {
365
+ return null;
366
+ }
367
+ const match = /^\/runs\/([^/]+)\/artifacts\/([^/]+)$/.exec(url.pathname);
368
+ if (!match) {
369
+ return null;
370
+ }
371
+ try {
372
+ return {
373
+ runId: decodeURIComponent(match[1] ?? ""),
374
+ artifactId: decodeURIComponent(match[2] ?? ""),
375
+ };
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ }
381
+ async function serveRemoteArtifact(params) {
382
+ const authHeader = params.req.headers.authorization ?? "";
383
+ if (authHeader !== `Bearer ${params.authToken}`) {
384
+ if (params.verbose) {
385
+ params.logger(`[serve] Unauthorized artifact transfer attempt from ${formatSocket(params.req)} (missing/invalid token)`);
386
+ }
387
+ params.res.writeHead(401, { "Content-Type": "application/json" });
388
+ params.res.end(JSON.stringify({ error: "unauthorized" }));
389
+ return;
390
+ }
391
+ pruneExpiredArtifacts(params.artifactRegistry);
392
+ const key = remoteArtifactKey(params.runId, params.artifactId);
393
+ const artifact = params.artifactRegistry.get(key);
394
+ if (!artifact) {
395
+ params.res.writeHead(404, { "Content-Type": "application/json" });
396
+ params.res.end(JSON.stringify({ error: "artifact_not_found" }));
397
+ return;
398
+ }
399
+ if (Date.now() > artifact.expiresAt) {
400
+ params.artifactRegistry.delete(key);
401
+ params.res.writeHead(410, { "Content-Type": "application/json" });
402
+ params.res.end(JSON.stringify({ error: "artifact_expired" }));
403
+ return;
404
+ }
405
+ const fileStat = await stat(artifact.filePath).catch(() => null);
406
+ if (!fileStat?.isFile() || fileStat.size <= 0) {
407
+ params.res.writeHead(410, { "Content-Type": "application/json" });
408
+ params.res.end(JSON.stringify({ error: "artifact_unavailable" }));
409
+ return;
410
+ }
411
+ if (fileStat.size > MAX_REMOTE_ARTIFACT_BYTES) {
412
+ params.res.writeHead(413, { "Content-Type": "application/json" });
413
+ params.res.end(JSON.stringify({ error: "artifact_too_large" }));
414
+ return;
415
+ }
416
+ const filename = sanitizeArtifactFilename(artifact.descriptor.filename, "artifact.bin");
417
+ params.res.writeHead(200, {
418
+ "Content-Type": sanitizeArtifactMimeType(artifact.descriptor.mimeType) ?? "application/octet-stream",
419
+ "Content-Length": fileStat.size,
420
+ "Content-Disposition": `attachment; filename="${filename.replace(/"/g, "")}"`,
421
+ "Cache-Control": "no-store",
422
+ "X-Content-Type-Options": "nosniff",
423
+ "X-Oracle-Artifact-Id": artifact.descriptor.artifactId,
424
+ "X-Oracle-Artifact-Sha256": artifact.descriptor.sha256,
425
+ });
426
+ await pipeline(createReadStream(artifact.filePath), params.res).catch((error) => {
427
+ params.logger(`[serve] Artifact transfer failed for ${artifact.descriptor.artifactId}: ${error instanceof Error ? error.message : String(error)}`);
428
+ });
429
+ }
430
+ function pruneExpiredArtifacts(artifactRegistry) {
431
+ const now = Date.now();
432
+ for (const [key, artifact] of artifactRegistry) {
433
+ if (artifact.expiresAt <= now) {
434
+ artifactRegistry.delete(key);
435
+ }
436
+ }
437
+ }
438
+ function remoteArtifactKey(runId, artifactId) {
439
+ return `${runId}:${artifactId}`;
440
+ }
441
+ async function registerRemoteArtifacts(params) {
442
+ pruneExpiredArtifacts(params.artifactRegistry);
443
+ const seen = new Set();
444
+ const fileArtifacts = [
445
+ ...(params.result.savedFiles ?? []),
446
+ ...(params.result.artifacts ?? []).filter((artifact) => artifact.kind === "file"),
447
+ ];
448
+ const descriptors = [];
449
+ const warnings = [];
450
+ for (const artifact of fileArtifacts) {
451
+ if (!artifact?.path || seen.has(artifact.path)) {
452
+ continue;
453
+ }
454
+ seen.add(artifact.path);
455
+ const registration = await buildRemoteArtifactRegistration(params.runId, artifact).catch((error) => {
456
+ const filename = sanitizeArtifactFilename(path.basename(artifact.path), "artifact.bin");
457
+ params.logger(`[serve] Skipping remote artifact descriptor: ${error instanceof Error ? error.message : String(error)}`);
458
+ warnings.push({
459
+ code: "remote-artifact-registration-failed",
460
+ severity: "warning",
461
+ message: `Oracle captured the browser text response, but the bridge host could not prepare ${filename} for transfer. ` +
462
+ "Open the ChatGPT browser on the bridge host, download the ZIP/file shown in the current response, and copy it to a cloud-readable path.",
463
+ });
464
+ return null;
465
+ });
466
+ if (!registration) {
467
+ continue;
468
+ }
469
+ params.artifactRegistry.set(remoteArtifactKey(params.runId, registration.descriptor.artifactId), {
470
+ descriptor: registration.descriptor,
471
+ filePath: registration.filePath,
472
+ expiresAt: Date.now() + REMOTE_ARTIFACT_TTL_MS,
473
+ });
474
+ descriptors.push(registration.descriptor);
475
+ }
476
+ return { descriptors, warnings };
477
+ }
478
+ async function buildRemoteArtifactRegistration(runId, artifact) {
479
+ if (artifact.path.endsWith(".crdownload")) {
480
+ throw new Error("artifact is still a Chrome partial download");
481
+ }
482
+ const filePath = await resolveRegisteredArtifactPath(artifact.path);
483
+ const fileStat = await stat(filePath);
484
+ if (!fileStat.isFile() || fileStat.size <= 0) {
485
+ throw new Error("artifact is not a completed non-empty file");
486
+ }
487
+ if (fileStat.size > MAX_REMOTE_ARTIFACT_BYTES) {
488
+ throw new Error("artifact exceeds bridge transfer size limit");
489
+ }
490
+ const filename = sanitizeArtifactFilename(path.basename(filePath), "artifact.bin");
491
+ const mimeType = sanitizeArtifactMimeType(artifact.mimeType);
492
+ // Recompute security metadata from the exact file registered for transfer.
493
+ const validation = await validateArtifactFile({
494
+ path: filePath,
495
+ filename,
496
+ mimeType,
497
+ });
498
+ const sha256 = await computeFileSha256(filePath);
499
+ return {
500
+ filePath,
501
+ descriptor: {
502
+ artifactId: randomUUID(),
503
+ runId,
504
+ kind: "file",
505
+ filename,
506
+ mimeType,
507
+ byteSize: fileStat.size,
508
+ sha256,
509
+ validation,
510
+ sourceUrlKind: classifySourceUrlKind(artifact.sourceUrl),
511
+ transferStatus: "ready",
512
+ },
513
+ };
514
+ }
515
+ async function resolveRegisteredArtifactPath(filePath) {
516
+ const [resolvedFile, sessionsRoot] = await Promise.all([
517
+ realpath(filePath),
518
+ realpath(path.join(getOracleHomeDir(), "sessions")),
519
+ ]);
520
+ const relative = path.relative(sessionsRoot, resolvedFile);
521
+ const segments = relative.split(path.sep);
522
+ if (!relative ||
523
+ relative.startsWith(`..${path.sep}`) ||
524
+ path.isAbsolute(relative) ||
525
+ segments.length < 3 ||
526
+ segments[1] !== "artifacts") {
527
+ throw new Error("artifact is outside Oracle's session artifact boundary");
528
+ }
529
+ return resolvedFile;
530
+ }
531
+ function classifySourceUrlKind(sourceUrl) {
532
+ if (sourceUrl?.startsWith("sandbox:")) {
533
+ return "sandbox";
534
+ }
535
+ if (sourceUrl === "browser-download") {
536
+ return "browser-download";
537
+ }
538
+ return "chatgpt-file-endpoint";
539
+ }
306
540
  async function readRequestBody(req) {
307
541
  const chunks = [];
308
542
  for await (const chunk of req) {
@@ -313,7 +547,7 @@ async function readRequestBody(req) {
313
547
  function sanitizeName(raw) {
314
548
  return raw.replace(/[^a-zA-Z0-9._-]/g, "_");
315
549
  }
316
- function sanitizeResult(result) {
550
+ function sanitizeResult(result, warnings = []) {
317
551
  return {
318
552
  answerText: result.answerText,
319
553
  answerMarkdown: result.answerMarkdown,
@@ -321,6 +555,7 @@ function sanitizeResult(result) {
321
555
  tookMs: result.tookMs,
322
556
  answerTokens: result.answerTokens,
323
557
  answerChars: result.answerChars,
558
+ warnings: warnings.length > 0 ? warnings : undefined,
324
559
  chromePid: undefined,
325
560
  chromePort: undefined,
326
561
  userDataDir: undefined,
@@ -1 +1 @@
1
- export {};
1
+ export const MAX_REMOTE_ARTIFACT_BYTES = 512 * 1024 * 1024;