@steipete/oracle 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +65 -63
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/archiveConversation.js +31 -7
  5. package/dist/src/browser/actions/assistantResponse.js +43 -1
  6. package/dist/src/browser/actions/attachmentContext.js +239 -0
  7. package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
  8. package/dist/src/browser/actions/attachmentEvidence.js +78 -0
  9. package/dist/src/browser/actions/attachmentProgress.js +41 -0
  10. package/dist/src/browser/actions/attachmentPrompt.js +130 -0
  11. package/dist/src/browser/actions/attachments.js +306 -355
  12. package/dist/src/browser/actions/deepResearch.js +2 -1
  13. package/dist/src/browser/actions/modelSelection.js +13 -3
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +361 -186
  16. package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
  17. package/dist/src/browser/actions/thinkingStatus.js +2 -5
  18. package/dist/src/browser/actions/thinkingTime.js +119 -16
  19. package/dist/src/browser/attachRunning.js +46 -2
  20. package/dist/src/browser/attachmentValidation.js +53 -0
  21. package/dist/src/browser/cancellation.js +105 -0
  22. package/dist/src/browser/chatgptImages.js +2 -0
  23. package/dist/src/browser/chatgptThrottle.js +104 -0
  24. package/dist/src/browser/chromeLifecycle.js +267 -43
  25. package/dist/src/browser/config.js +13 -0
  26. package/dist/src/browser/detect.js +1 -1
  27. package/dist/src/browser/index.js +444 -261
  28. package/dist/src/browser/modelDisplay.js +9 -0
  29. package/dist/src/browser/pageActions.js +2 -2
  30. package/dist/src/browser/profileState.js +114 -9
  31. package/dist/src/browser/projectSourcesRunner.js +83 -15
  32. package/dist/src/browser/prompt.js +272 -113
  33. package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
  34. package/dist/src/browser/reattach.js +10 -3
  35. package/dist/src/browser/sessionRunner.js +71 -11
  36. package/dist/src/browser/tabLeaseRegistry.js +414 -38
  37. package/dist/src/browser/utils.js +20 -2
  38. package/dist/src/cli/browserConfig.js +3 -1
  39. package/dist/src/cli/browserDefaults.js +10 -0
  40. package/dist/src/cli/browserTabs.js +12 -29
  41. package/dist/src/cli/detachedSession.js +69 -0
  42. package/dist/src/cli/dryRun.js +58 -48
  43. package/dist/src/cli/harvestIntegrity.js +140 -0
  44. package/dist/src/cli/serveBrowserConfig.js +24 -0
  45. package/dist/src/cli/sessionDisplay.js +9 -2
  46. package/dist/src/cli/sessionRunner.js +91 -7
  47. package/dist/src/config.js +1 -0
  48. package/dist/src/gemini-web/client.js +67 -21
  49. package/dist/src/gemini-web/executor.js +4 -0
  50. package/dist/src/gemini-web/http.js +15 -0
  51. package/dist/src/gemini-web/models.js +15 -11
  52. package/dist/src/mcp/server.js +11 -14
  53. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  54. package/dist/src/mcp/tools/consult.js +84 -22
  55. package/dist/src/mcp/tools/projectSources.js +4 -6
  56. package/dist/src/mcp/tools/sessionResources.js +1 -1
  57. package/dist/src/mcp/tools/sessions.js +2 -2
  58. package/dist/src/mcp/tools/wait.js +182 -0
  59. package/dist/src/mcp/types.js +7 -0
  60. package/dist/src/oracle/errors.js +11 -0
  61. package/dist/src/oracle/providerRoutePlan.js +3 -2
  62. package/dist/src/remote/client.js +70 -6
  63. package/dist/src/remote/health.js +6 -2
  64. package/dist/src/remote/runSlots.js +83 -0
  65. package/dist/src/remote/server.js +345 -71
  66. package/dist/src/sessionManager.js +36 -1
  67. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  68. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  69. package/package.json +25 -23
  70. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  71. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  72. package/dist/bin/oracle.js +0 -683
  73. package/dist/docs-site/.nojekyll +0 -0
  74. package/dist/docs-site/CNAME +0 -1
  75. package/dist/docs-site/RELEASING.html +0 -410
  76. package/dist/docs-site/agents.html +0 -374
  77. package/dist/docs-site/anthropic.html +0 -368
  78. package/dist/docs-site/bridge.html +0 -400
  79. package/dist/docs-site/browser-mode.html +0 -588
  80. package/dist/docs-site/chromium-forks.html +0 -347
  81. package/dist/docs-site/cli-reference.html +0 -346
  82. package/dist/docs-site/configuration.html +0 -436
  83. package/dist/docs-site/favicon.svg +0 -14
  84. package/dist/docs-site/followup.html +0 -368
  85. package/dist/docs-site/gemini.html +0 -379
  86. package/dist/docs-site/grok.html +0 -325
  87. package/dist/docs-site/index.html +0 -360
  88. package/dist/docs-site/install.html +0 -335
  89. package/dist/docs-site/linux.html +0 -321
  90. package/dist/docs-site/llms.txt +0 -43
  91. package/dist/docs-site/manual-tests.html +0 -595
  92. package/dist/docs-site/mcp.html +0 -368
  93. package/dist/docs-site/multimodel.html +0 -364
  94. package/dist/docs-site/mythical-pro-agents.html +0 -360
  95. package/dist/docs-site/notifier.html +0 -338
  96. package/dist/docs-site/openai-endpoints.html +0 -387
  97. package/dist/docs-site/openrouter.html +0 -344
  98. package/dist/docs-site/quickstart.html +0 -369
  99. package/dist/docs-site/refactor/ux.html +0 -532
  100. package/dist/docs-site/sessions.html +0 -388
  101. package/dist/docs-site/social-card.png +0 -0
  102. package/dist/docs-site/social-card.svg +0 -79
  103. package/dist/docs-site/spec.html +0 -363
  104. package/dist/docs-site/testing.html +0 -320
  105. package/dist/docs-site/tui-debug.html +0 -326
  106. package/dist/docs-site/windows-work.html +0 -323
  107. package/dist/docs-site/windows.html +0 -320
  108. package/dist/markdansi/types/index.js +0 -4
  109. package/dist/oracle/bin/oracle-cli.js +0 -472
  110. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  111. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  112. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  113. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  114. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  115. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  116. package/dist/oracle/src/browser/config.js +0 -33
  117. package/dist/oracle/src/browser/constants.js +0 -40
  118. package/dist/oracle/src/browser/cookies.js +0 -210
  119. package/dist/oracle/src/browser/domDebug.js +0 -36
  120. package/dist/oracle/src/browser/index.js +0 -331
  121. package/dist/oracle/src/browser/pageActions.js +0 -5
  122. package/dist/oracle/src/browser/prompt.js +0 -88
  123. package/dist/oracle/src/browser/promptSummary.js +0 -20
  124. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  125. package/dist/oracle/src/browser/types.js +0 -1
  126. package/dist/oracle/src/browser/utils.js +0 -62
  127. package/dist/oracle/src/browserMode.js +0 -1
  128. package/dist/oracle/src/cli/browserConfig.js +0 -44
  129. package/dist/oracle/src/cli/dryRun.js +0 -59
  130. package/dist/oracle/src/cli/engine.js +0 -17
  131. package/dist/oracle/src/cli/errorUtils.js +0 -9
  132. package/dist/oracle/src/cli/help.js +0 -70
  133. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  134. package/dist/oracle/src/cli/options.js +0 -103
  135. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  136. package/dist/oracle/src/cli/rootAlias.js +0 -30
  137. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  138. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  139. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  140. package/dist/oracle/src/heartbeat.js +0 -43
  141. package/dist/oracle/src/oracle/client.js +0 -48
  142. package/dist/oracle/src/oracle/config.js +0 -29
  143. package/dist/oracle/src/oracle/errors.js +0 -101
  144. package/dist/oracle/src/oracle/files.js +0 -220
  145. package/dist/oracle/src/oracle/format.js +0 -33
  146. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  147. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  148. package/dist/oracle/src/oracle/request.js +0 -48
  149. package/dist/oracle/src/oracle/run.js +0 -444
  150. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  151. package/dist/oracle/src/oracle/types.js +0 -1
  152. package/dist/oracle/src/oracle.js +0 -9
  153. package/dist/oracle/src/sessionManager.js +0 -205
  154. package/dist/oracle/src/version.js +0 -39
  155. package/dist/scripts/chrome/browser-tools.js +0 -295
  156. package/dist/src/browser/chromeCookies.js +0 -312
  157. package/dist/src/browser/keytarShim.js +0 -56
  158. package/dist/src/browser/profileSync.js +0 -141
  159. package/dist/src/browser/windowsCookies.js +0 -219
@@ -8,7 +8,12 @@ import { randomBytes, randomUUID } from "node:crypto";
8
8
  import { spawn, spawnSync } from "node:child_process";
9
9
  import { mkdtemp, rm, mkdir, writeFile, stat, realpath } from "node:fs/promises";
10
10
  import chalk from "chalk";
11
+ import { materializeStagedFallbackBundle } from "../browser/prompt.js";
11
12
  import { runBrowserMode } from "../browserMode.js";
13
+ import { resolveBrowserConfig } from "../browser/config.js";
14
+ import { RunSlots } from "./runSlots.js";
15
+ export { RunSlots } from "./runSlots.js";
16
+ import { loadUserConfig } from "../config.js";
12
17
  import { MAX_REMOTE_ARTIFACT_BYTES } from "./types.js";
13
18
  import { getCookies } from "@steipete/sweet-cookie";
14
19
  import { CHATGPT_URL } from "../browser/constants.js";
@@ -20,6 +25,8 @@ import { computeFileSha256, sanitizeArtifactFilename, sanitizeArtifactMimeType,
20
25
  const ARTIFACT_PROTOCOL_VERSION = 1;
21
26
  const REMOTE_ARTIFACT_TTL_MS = 30 * 60 * 1000;
22
27
  const ARTIFACT_CAPABILITIES = {
28
+ runCancellation: true,
29
+ deferredFallbackBundling: true,
23
30
  artifactTransfer: true,
24
31
  artifactProtocolVersion: ARTIFACT_PROTOCOL_VERSION,
25
32
  maxArtifactBytes: MAX_REMOTE_ARTIFACT_BYTES,
@@ -40,8 +47,29 @@ async function findAvailablePort() {
40
47
  });
41
48
  });
42
49
  }
50
+ function validateAdmissionOptions(options) {
51
+ if (options.maxConcurrentRuns === undefined && options.maxQueuedRuns !== undefined)
52
+ throw new Error("--max-queued-runs requires --max-concurrent-runs.");
53
+ if (options.maxConcurrentRuns !== undefined &&
54
+ (!Number.isSafeInteger(options.maxConcurrentRuns) || options.maxConcurrentRuns < 1))
55
+ throw new Error("--max-concurrent-runs must be a positive integer.");
56
+ if (options.maxQueuedRuns !== undefined &&
57
+ (!Number.isSafeInteger(options.maxQueuedRuns) || options.maxQueuedRuns < 0))
58
+ throw new Error("--max-queued-runs must be a nonnegative integer.");
59
+ }
43
60
  export async function createRemoteServer(options = {}, deps = {}) {
44
61
  const runBrowser = deps.runBrowser ?? runBrowserMode;
62
+ const attachedBrowser = usesHostBrowserAttachment(options.browserConfig);
63
+ const manualLoginDefault = !attachedBrowser && options.manualLoginDefault;
64
+ const hostBrowserConfig = options.browserConfig
65
+ ? {
66
+ attachRunning: options.browserConfig?.attachRunning,
67
+ remoteChrome: options.browserConfig?.remoteChrome
68
+ ? { ...options.browserConfig.remoteChrome }
69
+ : undefined,
70
+ approvalWaitMs: options.browserConfig?.approvalWaitMs,
71
+ }
72
+ : {};
45
73
  const server = http.createServer();
46
74
  const logger = options.logger ?? console.log;
47
75
  const authToken = options.token ?? randomBytes(16).toString("hex");
@@ -50,8 +78,20 @@ export async function createRemoteServer(options = {}, deps = {}) {
50
78
  const color = process.stdout.isTTY
51
79
  ? (formatter, msg) => formatter(msg)
52
80
  : (_formatter, msg) => msg;
53
- // Single-flight guard: remote Chrome can only host one run at a time, so we serialize requests.
54
- let busy = false;
81
+ validateAdmissionOptions(options);
82
+ const admissionEnabled = options.maxConcurrentRuns !== undefined;
83
+ const hostConfig = admissionEnabled ? (await loadUserConfig()).config.browser : undefined;
84
+ const browserTabCap = admissionEnabled
85
+ ? resolveBrowserConfig({ maxConcurrentTabs: hostConfig?.maxConcurrentTabs }).maxConcurrentTabs
86
+ : undefined;
87
+ const requestedConcurrency = options.maxConcurrentRuns ?? 1;
88
+ const effectiveConcurrency = Math.min(requestedConcurrency, browserTabCap ?? 1);
89
+ if (effectiveConcurrency < requestedConcurrency) {
90
+ logger(`[serve] Admitting ${effectiveConcurrency} concurrent run(s): the host tab cap is lower than the requested ${requestedConcurrency}.`);
91
+ }
92
+ const slots = new RunSlots(effectiveConcurrency, admissionEnabled ? (options.maxQueuedRuns ?? 8) : 0);
93
+ let legacyBusy = false;
94
+ const controllers = new Set();
55
95
  const artifactRegistry = new Map();
56
96
  if (!process.listenerCount("unhandledRejection")) {
57
97
  process.on("unhandledRejection", (reason) => {
@@ -81,6 +121,13 @@ export async function createRemoteServer(options = {}, deps = {}) {
81
121
  version: getCliVersion(),
82
122
  uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
83
123
  capabilities: ARTIFACT_CAPABILITIES,
124
+ // So a caller can decide whether to send work now or later, instead of
125
+ // discovering the answer by being queued.
126
+ activeRuns: slots.activeCount,
127
+ queuedRuns: slots.queuedCount,
128
+ maxConcurrentRuns: slots.capacity,
129
+ maxQueuedRuns: slots.queueCapacity,
130
+ admissionMode: admissionEnabled ? "queue" : "legacy",
84
131
  }));
85
132
  return;
86
133
  }
@@ -112,75 +159,146 @@ export async function createRemoteServer(options = {}, deps = {}) {
112
159
  res.end(JSON.stringify({ error: "unauthorized" }));
113
160
  return;
114
161
  }
115
- if (busy) {
116
- if (verbose) {
117
- logger(`[serve] Busy: rejecting new run from ${formatSocket(req)} while another run is active`);
118
- }
119
- res.writeHead(409, { "Content-Type": "application/json" });
120
- res.end(JSON.stringify({ error: "busy" }));
162
+ const refuse = () => {
163
+ res.writeHead(admissionEnabled ? 503 : 409, {
164
+ "Content-Type": "application/json",
165
+ ...(admissionEnabled ? { "Retry-After": "60" } : {}),
166
+ });
167
+ res.end(JSON.stringify({ error: admissionEnabled ? "queue_full" : "busy" }));
168
+ };
169
+ if (admissionEnabled ? slots.isSaturated : legacyBusy) {
170
+ refuse();
121
171
  return;
122
172
  }
123
- busy = true;
173
+ if (!admissionEnabled)
174
+ legacyBusy = true;
124
175
  const runStartedAt = Date.now();
125
- let payload = null;
176
+ const controller = new AbortController();
177
+ let signal = admissionEnabled ? controller.signal : undefined;
178
+ const onClientGone = () => controller.abort();
179
+ res.on("close", onClientGone);
180
+ req.on("aborted", onClientGone);
181
+ controllers.add(controller);
182
+ const detachController = () => {
183
+ res.off("close", onClientGone);
184
+ req.off("aborted", onClientGone);
185
+ controllers.delete(controller);
186
+ };
187
+ let queuePosition = 0;
188
+ let slotReady = false;
189
+ let pendingSlot;
190
+ let release;
191
+ const reserve = () => {
192
+ queuePosition = slots.positionFor();
193
+ pendingSlot = slots.acquire(signal).then((releaseSlot) => {
194
+ slotReady = true;
195
+ return releaseSlot;
196
+ });
197
+ // A queued request can disconnect before its body finishes parsing.
198
+ void pendingSlot.catch(() => undefined);
199
+ };
200
+ const abandon = async () => {
201
+ controller.abort();
202
+ if (pendingSlot)
203
+ await pendingSlot.then((releaseSlot) => releaseSlot(), () => { });
204
+ detachController();
205
+ legacyBusy = false;
206
+ };
207
+ // Opt-in admission includes body reception, bounding buffered requests and
208
+ // preserving arrival order even when a later caller uploads faster.
209
+ if (admissionEnabled)
210
+ reserve();
211
+ let payload;
126
212
  try {
127
- const body = await readRequestBody(req);
128
- payload = JSON.parse(body);
129
- if (payload?.browserConfig) {
213
+ payload = JSON.parse(await readRequestBody(req, controller.signal));
214
+ if (!payload || typeof payload.prompt !== "string")
215
+ throw new Error("Missing prompt");
216
+ payload.options ??= {};
217
+ if (payload.browserConfig)
130
218
  payload.browserConfig.url = normalizeChatgptUrl(payload.browserConfig.url, CHATGPT_URL);
131
- }
132
219
  }
133
220
  catch {
134
- busy = false;
135
- res.writeHead(400, { "Content-Type": "application/json" });
136
- res.end(JSON.stringify({ error: "invalid_request" }));
221
+ await abandon();
222
+ if (!res.destroyed) {
223
+ res.writeHead(400, { "Content-Type": "application/json" });
224
+ res.end(JSON.stringify({ error: "invalid_request" }));
225
+ }
137
226
  return;
138
227
  }
228
+ if (res.destroyed || req.aborted) {
229
+ await abandon();
230
+ return;
231
+ }
232
+ if (!admissionEnabled) {
233
+ signal = payload.options.cancelOnDisconnect === true ? controller.signal : undefined;
234
+ reserve();
235
+ }
139
236
  res.writeHead(200, { "Content-Type": "application/x-ndjson" });
140
- const runId = randomUUID();
141
- logger(`[serve] Accepted run ${runId} from ${formatSocket(req)} (prompt ${payload?.prompt?.length ?? 0} chars)`);
142
- // Each run gets an isolated temp dir so attachments/logs don't collide.
143
- const runDir = await mkdtemp(path.join(os.tmpdir(), `oracle-serve-${runId}-`));
144
- const attachmentDir = path.join(runDir, "attachments");
145
- await mkdir(attachmentDir, { recursive: true });
146
237
  const sendEvent = (event) => {
147
- res.write(`${JSON.stringify(event)}\n`);
238
+ if (!res.destroyed && !res.writableEnded)
239
+ res.write(`${JSON.stringify(event)}\n`);
148
240
  };
149
- const attachments = [];
241
+ if (queuePosition > 0 && !slotReady)
242
+ sendEvent({
243
+ type: "log",
244
+ message: `[serve] Waiting for a browser slot (position ${queuePosition}; ${slots.activeCount} active).`,
245
+ });
246
+ try {
247
+ release = await pendingSlot;
248
+ signal?.throwIfAborted();
249
+ }
250
+ catch (error) {
251
+ release?.();
252
+ legacyBusy = false;
253
+ sendEvent({ type: "error", message: error instanceof Error ? error.message : String(error) });
254
+ detachController();
255
+ if (!res.destroyed)
256
+ res.end();
257
+ return;
258
+ }
259
+ const runId = randomUUID();
260
+ let runDir = "";
150
261
  let fallbackSubmission;
151
262
  try {
263
+ signal?.throwIfAborted();
264
+ logger(`[serve] Accepted run ${runId} from ${formatSocket(req)} (prompt ${payload.prompt.length} chars)`);
265
+ runDir = await mkdtemp(path.join(os.tmpdir(), `oracle-serve-${runId}-`));
266
+ const attachmentDir = path.join(runDir, "attachments");
267
+ signal?.throwIfAborted();
152
268
  const attachmentsPayload = Array.isArray(payload.attachments) ? payload.attachments : [];
153
- for (const [index, attachment] of attachmentsPayload.entries()) {
154
- const safeName = sanitizeName(attachment.fileName ?? `attachment-${index + 1}`);
155
- const filePath = path.join(attachmentDir, safeName);
156
- await writeFile(filePath, Buffer.from(attachment.contentBase64, "base64"));
157
- attachments.push({
158
- path: filePath,
159
- displayPath: attachment.displayPath,
160
- sizeBytes: attachment.sizeBytes,
161
- });
162
- }
269
+ const attachments = await stageRemoteAttachments(attachmentsPayload, attachmentDir, "attachment");
163
270
  if (payload.fallbackSubmission) {
164
271
  const fallbackAttachmentDir = path.join(runDir, "fallback-attachments");
165
- await mkdir(fallbackAttachmentDir, { recursive: true });
166
- const fallbackAttachments = [];
167
272
  const fallbackPayload = Array.isArray(payload.fallbackSubmission.attachments)
168
273
  ? payload.fallbackSubmission.attachments
169
274
  : [];
170
- for (const [index, attachment] of fallbackPayload.entries()) {
171
- const safeName = sanitizeName(attachment.fileName ?? `fallback-attachment-${index + 1}`);
172
- const filePath = path.join(fallbackAttachmentDir, safeName);
173
- await writeFile(filePath, Buffer.from(attachment.contentBase64, "base64"));
174
- fallbackAttachments.push({
175
- path: filePath,
176
- displayPath: attachment.displayPath,
177
- sizeBytes: attachment.sizeBytes,
178
- });
179
- }
275
+ const fallbackAttachments = await stageRemoteAttachments(fallbackPayload, fallbackAttachmentDir, "fallback-attachment");
180
276
  fallbackSubmission = {
181
277
  prompt: payload.fallbackSubmission.prompt,
182
278
  attachments: fallbackAttachments,
183
279
  };
280
+ const pendingBundle = payload.fallbackSubmission.bundle;
281
+ if (pendingBundle) {
282
+ if (!["text", "zip"].includes(pendingBundle.format) ||
283
+ !["all", "text-only"].includes(pendingBundle.scope)) {
284
+ throw new Error("Invalid fallback bundle format or scope.");
285
+ }
286
+ let preparation;
287
+ const prepare = async () => {
288
+ if (!fallbackSubmission)
289
+ return;
290
+ const prepared = await materializeStagedFallbackBundle({
291
+ composerText: fallbackSubmission.prompt,
292
+ attachments: fallbackSubmission.attachments,
293
+ format: pendingBundle.format,
294
+ scope: pendingBundle.scope,
295
+ bundleParentDir: runDir,
296
+ });
297
+ fallbackSubmission.prompt = prepared.composerText;
298
+ fallbackSubmission.attachments = prepared.attachments;
299
+ };
300
+ fallbackSubmission.prepare = () => (preparation ??= prepare());
301
+ }
184
302
  }
185
303
  // Reuse the existing browser logger surface so clients see the same log stream.
186
304
  const automationLogger = ((message) => {
@@ -192,18 +310,35 @@ export async function createRemoteServer(options = {}, deps = {}) {
192
310
  // Preserve an explicit request to leave the completed conversation tab
193
311
  // open before the service forces `keepBrowser` for process lifetime.
194
312
  const clientRequestedKeepBrowser = payload.browserConfig?.keepBrowser === true;
313
+ // A client may describe the conversation it wants; it may not describe this
314
+ // machine. Rebuilding the config from an allowlist rather than deleting
315
+ // known-bad keys makes that the default: a field added to
316
+ // BrowserSessionConfig later is host-owned until someone decides otherwise,
317
+ // instead of reaching Chrome the moment it exists.
318
+ //
319
+ // The distinction is not stylistic. `chromePath` names an executable this
320
+ // process spawns, `remoteChrome` names a debugger to attach to,
321
+ // `copyProfileSource` names a directory to copy credentials out of, and
322
+ // `browserTabRef`/`attachRunning` select a tab that may belong to somebody
323
+ // else's run. With those reachable, a bridge token is not a permission to
324
+ // ask ChatGPT a question — it is a permission to run code here.
325
+ payload.browserConfig = {
326
+ ...pickClientBrowserConfig(payload.browserConfig),
327
+ ...hostBrowserConfig,
328
+ };
195
329
  // Remote runs rely on the host's authentication policy; never accept cookie payloads from clients.
196
- if (payload.browserConfig) {
197
- payload.browserConfig.inlineCookies = null;
198
- payload.browserConfig.inlineCookiesSource = null;
199
- payload.browserConfig.cookieSync = options.cookieSyncDefault === true;
200
- }
201
- else {
202
- payload.browserConfig = {};
203
- payload.browserConfig.cookieSync = options.cookieSyncDefault === true;
204
- }
330
+ payload.browserConfig.inlineCookies = null;
331
+ payload.browserConfig.inlineCookiesSource = null;
332
+ payload.browserConfig.cookieSync = !attachedBrowser && options.cookieSyncDefault === true;
333
+ const clientSession = typeof payload.options.sessionId === "string"
334
+ ? payload.options.sessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 32)
335
+ : "remote";
336
+ payload.options.sessionId = `${clientSession || "remote"}-${runId}`;
337
+ if (browserTabCap !== undefined)
338
+ payload.browserConfig.maxConcurrentTabs = browserTabCap;
339
+ signal?.throwIfAborted();
205
340
  // Enforce manual-login profile when cookie sync is unavailable (e.g., Windows/WSL).
206
- if (options.manualLoginDefault) {
341
+ if (manualLoginDefault) {
207
342
  payload.browserConfig.manualLogin = true;
208
343
  payload.browserConfig.manualLoginProfileDir = options.manualLoginProfileDir;
209
344
  payload.browserConfig.keepBrowser = true;
@@ -216,17 +351,20 @@ export async function createRemoteServer(options = {}, deps = {}) {
216
351
  attachments,
217
352
  fallbackSubmission,
218
353
  config: payload.browserConfig,
354
+ signal,
219
355
  // `keepBrowser` above preserves the authenticated shared Chrome
220
356
  // process. This separate service policy closes only a successfully
221
357
  // captured tab owned by this run, preventing one renderer leak per
222
358
  // request while incomplete/reattachable tabs remain untouched.
223
- closeOwnedTabOnComplete: Boolean(options.manualLoginDefault && !clientRequestedKeepBrowser),
359
+ closeOwnedTabOnComplete: Boolean(manualLoginDefault && !clientRequestedKeepBrowser),
360
+ closeOwnedTabOnCancel: !clientRequestedKeepBrowser,
224
361
  log: automationLogger,
225
362
  heartbeatIntervalMs: payload.options.heartbeatIntervalMs,
226
363
  verbose: payload.options.verbose,
227
364
  sessionId: payload.options.sessionId,
228
365
  followUpPrompts: payload.options.followUpPrompts,
229
366
  });
367
+ signal?.throwIfAborted();
230
368
  const artifactRegistration = await registerRemoteArtifacts({
231
369
  runId,
232
370
  result,
@@ -253,15 +391,24 @@ export async function createRemoteServer(options = {}, deps = {}) {
253
391
  : ""}`);
254
392
  }
255
393
  catch (error) {
256
- const message = error instanceof Error ? error.message : String(error);
394
+ const cancelled = signal?.aborted === true;
395
+ const message = cancelled
396
+ ? "Browser run cancelled."
397
+ : error instanceof Error
398
+ ? error.message
399
+ : String(error);
257
400
  sendEvent({ type: "error", message });
258
- logger(`[serve] Run ${runId} failed after ${Date.now() - runStartedAt}ms: ${message}`);
401
+ logger(`[serve] Run ${runId} ${cancelled ? "cancelled" : "failed"} after ${Date.now() - runStartedAt}ms: ${message}`);
259
402
  }
260
403
  finally {
261
- busy = false;
262
- res.end();
404
+ detachController();
405
+ legacyBusy = false;
406
+ release();
407
+ if (!res.destroyed)
408
+ res.end();
263
409
  try {
264
- await rm(runDir, { recursive: true, force: true });
410
+ if (runDir)
411
+ await rm(runDir, { recursive: true, force: true });
265
412
  }
266
413
  catch {
267
414
  // ignore cleanup errors
@@ -286,6 +433,8 @@ export async function createRemoteServer(options = {}, deps = {}) {
286
433
  port: address.port,
287
434
  token: authToken,
288
435
  async close() {
436
+ for (const controller of controllers)
437
+ controller.abort();
289
438
  await new Promise((resolve, reject) => {
290
439
  server.close((err) => (err ? reject(err) : resolve()));
291
440
  });
@@ -293,6 +442,7 @@ export async function createRemoteServer(options = {}, deps = {}) {
293
442
  };
294
443
  }
295
444
  export async function serveRemote(options = {}) {
445
+ validateAdmissionOptions(options);
296
446
  const manualProfileDir = options.manualLoginProfileDir ?? path.join(os.homedir(), ".oracle", "browser-profile");
297
447
  const preferManualLogin = options.manualLoginDefault ||
298
448
  options.cookieSyncDefault !== true ||
@@ -306,6 +456,11 @@ export async function serveRemote(options = {}) {
306
456
  console.log("Alternatively, start Windows Chrome with --remote-debugging-port=9222 and use `--remote-chrome <windows-ip>:9222`.");
307
457
  return;
308
458
  }
459
+ if (usesHostBrowserAttachment(options.browserConfig)) {
460
+ console.log("Using the host browser attachment; skipping local Chrome login/bootstrap.");
461
+ await runRemoteServer(options);
462
+ return;
463
+ }
309
464
  if (!preferManualLogin) {
310
465
  console.log("Warning: Chrome cookie copying can invalidate an active ChatGPT session when tokens rotate. Prefer the default dedicated manual-login profile when possible.");
311
466
  // Warm-up: ensure this host has a ChatGPT login before accepting runs.
@@ -349,11 +504,17 @@ export async function serveRemote(options = {}) {
349
504
  else {
350
505
  console.log(`Detected ${cookies.length} ChatGPT cookies on this host; runs will reuse this session.`);
351
506
  }
352
- const server = await createRemoteServer({
507
+ await runRemoteServer({
353
508
  ...options,
354
509
  manualLoginDefault: preferManualLogin,
355
510
  manualLoginProfileDir: manualProfileDir,
356
511
  });
512
+ }
513
+ function usesHostBrowserAttachment(config) {
514
+ return config?.attachRunning === true || Boolean(config?.remoteChrome);
515
+ }
516
+ async function runRemoteServer(options) {
517
+ const server = await createRemoteServer(options);
357
518
  await new Promise((resolve) => {
358
519
  const shutdown = () => {
359
520
  console.log("Shutting down remote service...");
@@ -550,16 +711,99 @@ function classifySourceUrlKind(sourceUrl) {
550
711
  }
551
712
  return "chatgpt-file-endpoint";
552
713
  }
553
- async function readRequestBody(req) {
554
- const chunks = [];
555
- for await (const chunk of req) {
556
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
714
+ async function readRequestBody(req, signal) {
715
+ if (signal?.aborted) {
716
+ req.destroy();
717
+ signal.throwIfAborted();
718
+ }
719
+ const onAbort = () => req.destroy();
720
+ signal?.addEventListener("abort", onAbort, { once: true });
721
+ try {
722
+ const chunks = [];
723
+ for await (const chunk of req) {
724
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
725
+ }
726
+ signal?.throwIfAborted();
727
+ return Buffer.concat(chunks).toString("utf8");
728
+ }
729
+ finally {
730
+ signal?.removeEventListener("abort", onAbort);
557
731
  }
558
- return Buffer.concat(chunks).toString("utf8");
559
732
  }
560
- function sanitizeName(raw) {
561
- return raw.replace(/[^a-zA-Z0-9._-]/g, "_");
733
+ /**
734
+ * Fields a remote caller may set: they describe the conversation and its time
735
+ * budgets. Everything else on BrowserSessionConfig — executable paths, profile
736
+ * directories, debugger endpoints, tab selection, window mode, cookie policy,
737
+ * and the shared-profile concurrency limits — is the host's to decide.
738
+ */
739
+ const CLIENT_BROWSER_CONFIG_FIELDS = [
740
+ "chatgptUrl",
741
+ "url",
742
+ "desiredModel",
743
+ "modelStrategy",
744
+ "thinkingTime",
745
+ "researchMode",
746
+ "archiveConversations",
747
+ "resumeConversationUrl",
748
+ "timeoutMs",
749
+ "inputTimeoutMs",
750
+ "attachmentTimeoutMs",
751
+ "assistantRecheckDelayMs",
752
+ "assistantRecheckTimeoutMs",
753
+ "autoReattachDelayMs",
754
+ "autoReattachIntervalMs",
755
+ "autoReattachTimeoutMs",
756
+ "keepBrowser",
757
+ "debug",
758
+ ];
759
+ export function pickClientBrowserConfig(requested) {
760
+ const accepted = {};
761
+ if (!requested) {
762
+ return accepted;
763
+ }
764
+ for (const field of CLIENT_BROWSER_CONFIG_FIELDS) {
765
+ const value = requested[field];
766
+ if (value !== undefined) {
767
+ accepted[field] = value;
768
+ }
769
+ }
770
+ return accepted;
562
771
  }
772
+ async function stageRemoteAttachments(payload, directory, defaultPrefix) {
773
+ await mkdir(directory, { recursive: true });
774
+ const names = payload.map((attachment, index) => {
775
+ const fallback = `${defaultPrefix}-${index + 1}`;
776
+ const sanitized = (attachment.fileName ?? fallback).replace(/[^a-zA-Z0-9._-]/g, "_");
777
+ return !sanitized || sanitized === "." || sanitized === ".." ? fallback : sanitized;
778
+ });
779
+ // Reserve future names too, and honor case-insensitive host filesystems.
780
+ const reserved = new Set(names.map((name) => name.toLowerCase()));
781
+ const used = new Set();
782
+ const attachments = [];
783
+ for (const [index, attachment] of payload.entries()) {
784
+ const original = names[index];
785
+ let name = original;
786
+ if (used.has(name.toLowerCase())) {
787
+ const extension = path.extname(original);
788
+ const stem = original.slice(0, original.length - extension.length);
789
+ let suffix = 2;
790
+ do {
791
+ name = `${stem}-${suffix++}${extension}`;
792
+ } while (reserved.has(name.toLowerCase()));
793
+ }
794
+ used.add(name.toLowerCase());
795
+ reserved.add(name.toLowerCase());
796
+ const filePath = path.join(directory, name);
797
+ await writeFile(filePath, Buffer.from(attachment.contentBase64, "base64"), { flag: "wx" });
798
+ attachments.push({
799
+ path: filePath,
800
+ displayPath: attachment.displayPath,
801
+ sizeBytes: attachment.sizeBytes,
802
+ });
803
+ }
804
+ return attachments;
805
+ }
806
+ // Return conversation identity and observed selection; keep process/profile details on the host.
563
807
  function sanitizeResult(result, warnings = []) {
564
808
  return {
565
809
  answerText: result.answerText,
@@ -568,6 +812,12 @@ function sanitizeResult(result, warnings = []) {
568
812
  tookMs: result.tookMs,
569
813
  answerTokens: result.answerTokens,
570
814
  answerChars: result.answerChars,
815
+ modelSelection: result.modelSelection,
816
+ thinkingSelection: result.thinkingSelection,
817
+ archive: result.archive,
818
+ tabUrl: result.tabUrl,
819
+ conversationId: result.conversationId,
820
+ promptSubmitted: result.promptSubmitted,
571
821
  warnings: warnings.length > 0 ? warnings : undefined,
572
822
  chromePid: undefined,
573
823
  chromePort: undefined,
@@ -580,7 +830,31 @@ function formatSocket(req) {
580
830
  const port = socket.remotePort ?? "0";
581
831
  return `${host}:${port}`;
582
832
  }
833
+ /**
834
+ * Addresses a client could actually reach this service on.
835
+ *
836
+ * A loopback bind is reachable only from this machine, so listing the host's LAN
837
+ * and tailnet addresses there is not merely noisy — it tells an operator the
838
+ * service is exposed when it is not, which is the wrong direction for a mistake
839
+ * about a token that grants browser automation.
840
+ */
583
841
  function formatReachableAddresses(bindAddress, port) {
842
+ if (isLoopbackAddress(bindAddress)) {
843
+ return [`${formatHostPort(bindAddress, port)}`];
844
+ }
845
+ return formatAllInterfaceAddresses(bindAddress, port);
846
+ }
847
+ function isLoopbackAddress(address) {
848
+ const normalized = address
849
+ .trim()
850
+ .toLowerCase()
851
+ .replace(/^\[|\]$/g, "");
852
+ return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
853
+ }
854
+ function formatHostPort(address, port) {
855
+ return address.includes(":") ? `[${address}]:${port}` : `${address}:${port}`;
856
+ }
857
+ function formatAllInterfaceAddresses(bindAddress, port) {
584
858
  const ipv4 = [];
585
859
  const ipv6 = [];
586
860
  if (bindAddress && bindAddress !== "::" && bindAddress !== "0.0.0.0") {
@@ -141,12 +141,38 @@ async function writeSessionMetadataFile(sessionId, metadata) {
141
141
  encoding: "utf8",
142
142
  mode: 0o600,
143
143
  });
144
- await fs.rename(temporaryPath, targetPath);
144
+ await renameSessionMetadataFile(temporaryPath, targetPath);
145
145
  }
146
146
  finally {
147
147
  await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
148
148
  }
149
149
  }
150
+ const METADATA_RENAME_RETRY_DELAYS_MS = [10, 25, 50, 100, 200, 400, 800];
151
+ const RETRIABLE_METADATA_RENAME_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
152
+ function isRetriableMetadataRenameError(error) {
153
+ return (typeof error === "object" &&
154
+ error !== null &&
155
+ "code" in error &&
156
+ typeof error.code === "string" &&
157
+ RETRIABLE_METADATA_RENAME_CODES.has(error.code));
158
+ }
159
+ async function renameSessionMetadataFile(temporaryPath, targetPath) {
160
+ for (const delayMs of [0, ...METADATA_RENAME_RETRY_DELAYS_MS]) {
161
+ if (delayMs > 0) {
162
+ await wait(delayMs);
163
+ }
164
+ try {
165
+ await fs.rename(temporaryPath, targetPath);
166
+ return;
167
+ }
168
+ catch (error) {
169
+ if (!isRetriableMetadataRenameError(error) ||
170
+ delayMs === METADATA_RENAME_RETRY_DELAYS_MS.at(-1)) {
171
+ throw error;
172
+ }
173
+ }
174
+ }
175
+ }
150
176
  function requestPath(id) {
151
177
  return path.join(sessionDir(id), LEGACY_REQUEST_FILENAME);
152
178
  }
@@ -317,6 +343,7 @@ export async function initializeSession(options, cwd, notifications, baseSlugOve
317
343
  zombieTimeoutMs: options.zombieTimeoutMs,
318
344
  zombieUseLastActivity: options.zombieUseLastActivity,
319
345
  writeOutputPath: options.writeOutputPath,
346
+ writeArtifacts: options.writeArtifacts,
320
347
  partialMode: options.partialMode,
321
348
  waitPreference: options.waitPreference,
322
349
  youtube: options.youtube,
@@ -327,6 +354,7 @@ export async function initializeSession(options, cwd, notifications, baseSlugOve
327
354
  browserResumeConversationUrl: options.browserResumeConversationUrl,
328
355
  aspectRatio: options.aspectRatio,
329
356
  geminiShowThoughts: options.geminiShowThoughts,
357
+ geminiAllowModelFallback: options.geminiAllowModelFallback,
330
358
  },
331
359
  };
332
360
  await ensureDir(modelsDir(sessionId));
@@ -640,6 +668,13 @@ async function markDeadBrowser(meta) {
640
668
  const host = runtime.chromeHost ?? "127.0.0.1";
641
669
  signals.push(await isPortOpen(host, runtime.chromePort));
642
670
  }
671
+ // controllerPid: the foreground process that launched this browser run.
672
+ // When neither chromePid nor chromePort are recorded (common on Linux),
673
+ // signals[] is empty and the early-return below would skip the reap.
674
+ // Use the same isProcessAlive() primitive to fill that gap.
675
+ if (signals.length === 0 && runtime.controllerPid) {
676
+ signals.push(isProcessAlive(runtime.controllerPid));
677
+ }
643
678
  if (signals.length === 0 || signals.some(Boolean)) {
644
679
  return meta;
645
680
  }