@steipete/oracle 0.18.0 → 0.20.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 (169) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +76 -76
  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 +170 -45
  13. package/dist/src/browser/actions/modelSelection.js +91 -4
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +364 -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 +156 -16
  19. package/dist/src/browser/actions/webSearch.js +96 -0
  20. package/dist/src/browser/attachRunning.js +46 -2
  21. package/dist/src/browser/attachmentValidation.js +53 -0
  22. package/dist/src/browser/cancellation.js +105 -0
  23. package/dist/src/browser/chatgptImages.js +2 -0
  24. package/dist/src/browser/chatgptThrottle.js +104 -0
  25. package/dist/src/browser/chromeLifecycle.js +272 -47
  26. package/dist/src/browser/config.js +14 -1
  27. package/dist/src/browser/detect.js +1 -1
  28. package/dist/src/browser/index.js +525 -274
  29. package/dist/src/browser/liveTabs.js +3 -4
  30. package/dist/src/browser/modelDisplay.js +9 -0
  31. package/dist/src/browser/pageActions.js +2 -2
  32. package/dist/src/browser/profileState.js +114 -9
  33. package/dist/src/browser/projectSourcesRunner.js +83 -15
  34. package/dist/src/browser/prompt.js +272 -113
  35. package/dist/src/browser/providers/chatgptDomProvider.js +3 -0
  36. package/dist/src/browser/reattach.js +31 -4
  37. package/dist/src/browser/recoveryTarget.js +131 -0
  38. package/dist/src/browser/sessionRunner.js +73 -11
  39. package/dist/src/browser/tabLeaseRegistry.js +434 -38
  40. package/dist/src/browser/targetClaim.js +54 -0
  41. package/dist/src/browser/utils.js +20 -2
  42. package/dist/src/cli/browserConfig.js +36 -4
  43. package/dist/src/cli/browserDefaults.js +10 -0
  44. package/dist/src/cli/browserTabs.js +15 -29
  45. package/dist/src/cli/detachedSession.js +69 -0
  46. package/dist/src/cli/dryRun.js +58 -48
  47. package/dist/src/cli/harvestIntegrity.js +140 -0
  48. package/dist/src/cli/options.js +15 -0
  49. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  50. package/dist/src/cli/runOptions.js +19 -4
  51. package/dist/src/cli/serveBrowserConfig.js +24 -0
  52. package/dist/src/cli/sessionDisplay.js +13 -7
  53. package/dist/src/cli/sessionRunner.js +95 -12
  54. package/dist/src/config.js +1 -0
  55. package/dist/src/gemini-web/client.js +67 -21
  56. package/dist/src/gemini-web/executor.js +4 -0
  57. package/dist/src/gemini-web/http.js +15 -0
  58. package/dist/src/gemini-web/models.js +15 -11
  59. package/dist/src/mcp/server.js +11 -14
  60. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  61. package/dist/src/mcp/tools/consult.js +86 -24
  62. package/dist/src/mcp/tools/projectSources.js +4 -6
  63. package/dist/src/mcp/tools/sessionResources.js +1 -1
  64. package/dist/src/mcp/tools/sessions.js +2 -2
  65. package/dist/src/mcp/tools/wait.js +182 -0
  66. package/dist/src/mcp/types.js +8 -1
  67. package/dist/src/oracle/config.js +14 -0
  68. package/dist/src/oracle/errors.js +11 -0
  69. package/dist/src/oracle/geminiModels.js +1 -0
  70. package/dist/src/oracle/providerRoutePlan.js +3 -2
  71. package/dist/src/oracle/run.js +27 -4
  72. package/dist/src/remote/client.js +73 -6
  73. package/dist/src/remote/health.js +6 -2
  74. package/dist/src/remote/runSlots.js +83 -0
  75. package/dist/src/remote/server.js +346 -71
  76. package/dist/src/sessionManager.js +36 -1
  77. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  78. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  79. package/package.json +25 -23
  80. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  81. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  82. package/dist/bin/oracle.js +0 -683
  83. package/dist/docs-site/.nojekyll +0 -0
  84. package/dist/docs-site/CNAME +0 -1
  85. package/dist/docs-site/RELEASING.html +0 -410
  86. package/dist/docs-site/agents.html +0 -374
  87. package/dist/docs-site/anthropic.html +0 -368
  88. package/dist/docs-site/bridge.html +0 -400
  89. package/dist/docs-site/browser-mode.html +0 -588
  90. package/dist/docs-site/chromium-forks.html +0 -347
  91. package/dist/docs-site/cli-reference.html +0 -346
  92. package/dist/docs-site/configuration.html +0 -436
  93. package/dist/docs-site/favicon.svg +0 -14
  94. package/dist/docs-site/followup.html +0 -368
  95. package/dist/docs-site/gemini.html +0 -379
  96. package/dist/docs-site/grok.html +0 -325
  97. package/dist/docs-site/index.html +0 -360
  98. package/dist/docs-site/install.html +0 -335
  99. package/dist/docs-site/linux.html +0 -321
  100. package/dist/docs-site/llms.txt +0 -43
  101. package/dist/docs-site/manual-tests.html +0 -595
  102. package/dist/docs-site/mcp.html +0 -368
  103. package/dist/docs-site/multimodel.html +0 -364
  104. package/dist/docs-site/mythical-pro-agents.html +0 -360
  105. package/dist/docs-site/notifier.html +0 -338
  106. package/dist/docs-site/openai-endpoints.html +0 -387
  107. package/dist/docs-site/openrouter.html +0 -344
  108. package/dist/docs-site/quickstart.html +0 -369
  109. package/dist/docs-site/refactor/ux.html +0 -532
  110. package/dist/docs-site/sessions.html +0 -388
  111. package/dist/docs-site/social-card.png +0 -0
  112. package/dist/docs-site/social-card.svg +0 -79
  113. package/dist/docs-site/spec.html +0 -363
  114. package/dist/docs-site/testing.html +0 -320
  115. package/dist/docs-site/tui-debug.html +0 -326
  116. package/dist/docs-site/windows-work.html +0 -323
  117. package/dist/docs-site/windows.html +0 -320
  118. package/dist/markdansi/types/index.js +0 -4
  119. package/dist/oracle/bin/oracle-cli.js +0 -472
  120. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  121. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  122. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  123. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  124. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  125. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  126. package/dist/oracle/src/browser/config.js +0 -33
  127. package/dist/oracle/src/browser/constants.js +0 -40
  128. package/dist/oracle/src/browser/cookies.js +0 -210
  129. package/dist/oracle/src/browser/domDebug.js +0 -36
  130. package/dist/oracle/src/browser/index.js +0 -331
  131. package/dist/oracle/src/browser/pageActions.js +0 -5
  132. package/dist/oracle/src/browser/prompt.js +0 -88
  133. package/dist/oracle/src/browser/promptSummary.js +0 -20
  134. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  135. package/dist/oracle/src/browser/types.js +0 -1
  136. package/dist/oracle/src/browser/utils.js +0 -62
  137. package/dist/oracle/src/browserMode.js +0 -1
  138. package/dist/oracle/src/cli/browserConfig.js +0 -44
  139. package/dist/oracle/src/cli/dryRun.js +0 -59
  140. package/dist/oracle/src/cli/engine.js +0 -17
  141. package/dist/oracle/src/cli/errorUtils.js +0 -9
  142. package/dist/oracle/src/cli/help.js +0 -70
  143. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  144. package/dist/oracle/src/cli/options.js +0 -103
  145. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  146. package/dist/oracle/src/cli/rootAlias.js +0 -30
  147. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  148. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  149. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  150. package/dist/oracle/src/heartbeat.js +0 -43
  151. package/dist/oracle/src/oracle/client.js +0 -48
  152. package/dist/oracle/src/oracle/config.js +0 -29
  153. package/dist/oracle/src/oracle/errors.js +0 -101
  154. package/dist/oracle/src/oracle/files.js +0 -220
  155. package/dist/oracle/src/oracle/format.js +0 -33
  156. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  157. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  158. package/dist/oracle/src/oracle/request.js +0 -48
  159. package/dist/oracle/src/oracle/run.js +0 -444
  160. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  161. package/dist/oracle/src/oracle/types.js +0 -1
  162. package/dist/oracle/src/oracle.js +0 -9
  163. package/dist/oracle/src/sessionManager.js +0 -205
  164. package/dist/oracle/src/version.js +0 -39
  165. package/dist/scripts/chrome/browser-tools.js +0 -295
  166. package/dist/src/browser/chromeCookies.js +0 -312
  167. package/dist/src/browser/keytarShim.js +0 -56
  168. package/dist/src/browser/profileSync.js +0 -141
  169. 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,13 @@ 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
+ researchPlan: result.researchPlan,
818
+ archive: result.archive,
819
+ tabUrl: result.tabUrl,
820
+ conversationId: result.conversationId,
821
+ promptSubmitted: result.promptSubmitted,
571
822
  warnings: warnings.length > 0 ? warnings : undefined,
572
823
  chromePid: undefined,
573
824
  chromePort: undefined,
@@ -580,7 +831,31 @@ function formatSocket(req) {
580
831
  const port = socket.remotePort ?? "0";
581
832
  return `${host}:${port}`;
582
833
  }
834
+ /**
835
+ * Addresses a client could actually reach this service on.
836
+ *
837
+ * A loopback bind is reachable only from this machine, so listing the host's LAN
838
+ * and tailnet addresses there is not merely noisy — it tells an operator the
839
+ * service is exposed when it is not, which is the wrong direction for a mistake
840
+ * about a token that grants browser automation.
841
+ */
583
842
  function formatReachableAddresses(bindAddress, port) {
843
+ if (isLoopbackAddress(bindAddress)) {
844
+ return [`${formatHostPort(bindAddress, port)}`];
845
+ }
846
+ return formatAllInterfaceAddresses(bindAddress, port);
847
+ }
848
+ function isLoopbackAddress(address) {
849
+ const normalized = address
850
+ .trim()
851
+ .toLowerCase()
852
+ .replace(/^\[|\]$/g, "");
853
+ return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
854
+ }
855
+ function formatHostPort(address, port) {
856
+ return address.includes(":") ? `[${address}]:${port}` : `${address}:${port}`;
857
+ }
858
+ function formatAllInterfaceAddresses(bindAddress, port) {
584
859
  const ipv4 = [];
585
860
  const ipv6 = [];
586
861
  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
  }