@steipete/oracle 0.17.3 → 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 (161) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +75 -64
  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 +267 -15
  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 +15 -3
  26. package/dist/src/browser/detect.js +1 -1
  27. package/dist/src/browser/index.js +447 -263
  28. package/dist/src/browser/modelDisplay.js +9 -0
  29. package/dist/src/browser/pageActions.js +2 -2
  30. package/dist/src/browser/policies.js +6 -2
  31. package/dist/src/browser/profileState.js +114 -9
  32. package/dist/src/browser/projectSourcesRunner.js +88 -17
  33. package/dist/src/browser/prompt.js +272 -113
  34. package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
  35. package/dist/src/browser/reattach.js +14 -4
  36. package/dist/src/browser/sessionRunner.js +71 -11
  37. package/dist/src/browser/tabLeaseRegistry.js +414 -38
  38. package/dist/src/browser/utils.js +20 -2
  39. package/dist/src/cli/browserConfig.js +40 -15
  40. package/dist/src/cli/browserDefaults.js +13 -0
  41. package/dist/src/cli/browserTabs.js +12 -29
  42. package/dist/src/cli/detachedSession.js +69 -0
  43. package/dist/src/cli/dryRun.js +58 -47
  44. package/dist/src/cli/harvestIntegrity.js +140 -0
  45. package/dist/src/cli/projectSources.js +5 -3
  46. package/dist/src/cli/serveBrowserConfig.js +24 -0
  47. package/dist/src/cli/sessionDisplay.js +9 -2
  48. package/dist/src/cli/sessionRunner.js +91 -7
  49. package/dist/src/config.js +1 -0
  50. package/dist/src/gemini-web/client.js +67 -21
  51. package/dist/src/gemini-web/executor.js +4 -0
  52. package/dist/src/gemini-web/http.js +15 -0
  53. package/dist/src/gemini-web/models.js +15 -11
  54. package/dist/src/mcp/server.js +11 -14
  55. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  56. package/dist/src/mcp/tools/consult.js +97 -26
  57. package/dist/src/mcp/tools/projectSources.js +4 -6
  58. package/dist/src/mcp/tools/sessionResources.js +1 -1
  59. package/dist/src/mcp/tools/sessions.js +2 -2
  60. package/dist/src/mcp/tools/wait.js +182 -0
  61. package/dist/src/mcp/types.js +7 -0
  62. package/dist/src/oracle/errors.js +11 -0
  63. package/dist/src/oracle/providerRoutePlan.js +3 -2
  64. package/dist/src/remote/client.js +70 -6
  65. package/dist/src/remote/health.js +6 -2
  66. package/dist/src/remote/runSlots.js +83 -0
  67. package/dist/src/remote/server.js +351 -72
  68. package/dist/src/sessionManager.js +36 -1
  69. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  70. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  71. package/package.json +25 -23
  72. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  73. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  74. package/dist/bin/oracle.js +0 -683
  75. package/dist/docs-site/.nojekyll +0 -0
  76. package/dist/docs-site/CNAME +0 -1
  77. package/dist/docs-site/RELEASING.html +0 -410
  78. package/dist/docs-site/agents.html +0 -374
  79. package/dist/docs-site/anthropic.html +0 -368
  80. package/dist/docs-site/bridge.html +0 -400
  81. package/dist/docs-site/browser-mode.html +0 -588
  82. package/dist/docs-site/chromium-forks.html +0 -347
  83. package/dist/docs-site/cli-reference.html +0 -346
  84. package/dist/docs-site/configuration.html +0 -436
  85. package/dist/docs-site/favicon.svg +0 -14
  86. package/dist/docs-site/followup.html +0 -368
  87. package/dist/docs-site/gemini.html +0 -379
  88. package/dist/docs-site/grok.html +0 -325
  89. package/dist/docs-site/index.html +0 -360
  90. package/dist/docs-site/install.html +0 -335
  91. package/dist/docs-site/linux.html +0 -321
  92. package/dist/docs-site/llms.txt +0 -43
  93. package/dist/docs-site/manual-tests.html +0 -595
  94. package/dist/docs-site/mcp.html +0 -368
  95. package/dist/docs-site/multimodel.html +0 -364
  96. package/dist/docs-site/mythical-pro-agents.html +0 -360
  97. package/dist/docs-site/notifier.html +0 -338
  98. package/dist/docs-site/openai-endpoints.html +0 -387
  99. package/dist/docs-site/openrouter.html +0 -344
  100. package/dist/docs-site/quickstart.html +0 -369
  101. package/dist/docs-site/refactor/ux.html +0 -532
  102. package/dist/docs-site/sessions.html +0 -388
  103. package/dist/docs-site/social-card.png +0 -0
  104. package/dist/docs-site/social-card.svg +0 -79
  105. package/dist/docs-site/spec.html +0 -363
  106. package/dist/docs-site/testing.html +0 -320
  107. package/dist/docs-site/tui-debug.html +0 -326
  108. package/dist/docs-site/windows-work.html +0 -323
  109. package/dist/docs-site/windows.html +0 -320
  110. package/dist/markdansi/types/index.js +0 -4
  111. package/dist/oracle/bin/oracle-cli.js +0 -472
  112. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  113. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  114. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  115. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  116. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  117. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  118. package/dist/oracle/src/browser/config.js +0 -33
  119. package/dist/oracle/src/browser/constants.js +0 -40
  120. package/dist/oracle/src/browser/cookies.js +0 -210
  121. package/dist/oracle/src/browser/domDebug.js +0 -36
  122. package/dist/oracle/src/browser/index.js +0 -331
  123. package/dist/oracle/src/browser/pageActions.js +0 -5
  124. package/dist/oracle/src/browser/prompt.js +0 -88
  125. package/dist/oracle/src/browser/promptSummary.js +0 -20
  126. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  127. package/dist/oracle/src/browser/types.js +0 -1
  128. package/dist/oracle/src/browser/utils.js +0 -62
  129. package/dist/oracle/src/browserMode.js +0 -1
  130. package/dist/oracle/src/cli/browserConfig.js +0 -44
  131. package/dist/oracle/src/cli/dryRun.js +0 -59
  132. package/dist/oracle/src/cli/engine.js +0 -17
  133. package/dist/oracle/src/cli/errorUtils.js +0 -9
  134. package/dist/oracle/src/cli/help.js +0 -70
  135. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  136. package/dist/oracle/src/cli/options.js +0 -103
  137. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  138. package/dist/oracle/src/cli/rootAlias.js +0 -30
  139. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  140. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  141. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  142. package/dist/oracle/src/heartbeat.js +0 -43
  143. package/dist/oracle/src/oracle/client.js +0 -48
  144. package/dist/oracle/src/oracle/config.js +0 -29
  145. package/dist/oracle/src/oracle/errors.js +0 -101
  146. package/dist/oracle/src/oracle/files.js +0 -220
  147. package/dist/oracle/src/oracle/format.js +0 -33
  148. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  149. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  150. package/dist/oracle/src/oracle/request.js +0 -48
  151. package/dist/oracle/src/oracle/run.js +0 -444
  152. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  153. package/dist/oracle/src/oracle/types.js +0 -1
  154. package/dist/oracle/src/oracle.js +0 -9
  155. package/dist/oracle/src/sessionManager.js +0 -205
  156. package/dist/oracle/src/version.js +0 -39
  157. package/dist/scripts/chrome/browser-tools.js +0 -295
  158. package/dist/src/browser/chromeCookies.js +0 -312
  159. package/dist/src/browser/keytarShim.js +0 -56
  160. package/dist/src/browser/profileSync.js +0 -141
  161. 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,17 +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;
195
- // Remote runs always rely on the host's own Chrome profile; ignore any inline cookie transfer.
196
- if (payload.browserConfig) {
197
- payload.browserConfig.inlineCookies = null;
198
- payload.browserConfig.inlineCookiesSource = null;
199
- payload.browserConfig.cookieSync = true;
200
- }
201
- else {
202
- payload.browserConfig = {};
203
- }
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
+ };
329
+ // Remote runs rely on the host's authentication policy; never accept cookie payloads from clients.
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();
204
340
  // Enforce manual-login profile when cookie sync is unavailable (e.g., Windows/WSL).
205
- if (options.manualLoginDefault) {
341
+ if (manualLoginDefault) {
206
342
  payload.browserConfig.manualLogin = true;
207
343
  payload.browserConfig.manualLoginProfileDir = options.manualLoginProfileDir;
208
344
  payload.browserConfig.keepBrowser = true;
@@ -215,17 +351,20 @@ export async function createRemoteServer(options = {}, deps = {}) {
215
351
  attachments,
216
352
  fallbackSubmission,
217
353
  config: payload.browserConfig,
354
+ signal,
218
355
  // `keepBrowser` above preserves the authenticated shared Chrome
219
356
  // process. This separate service policy closes only a successfully
220
357
  // captured tab owned by this run, preventing one renderer leak per
221
358
  // request while incomplete/reattachable tabs remain untouched.
222
- closeOwnedTabOnComplete: Boolean(options.manualLoginDefault && !clientRequestedKeepBrowser),
359
+ closeOwnedTabOnComplete: Boolean(manualLoginDefault && !clientRequestedKeepBrowser),
360
+ closeOwnedTabOnCancel: !clientRequestedKeepBrowser,
223
361
  log: automationLogger,
224
362
  heartbeatIntervalMs: payload.options.heartbeatIntervalMs,
225
363
  verbose: payload.options.verbose,
226
364
  sessionId: payload.options.sessionId,
227
365
  followUpPrompts: payload.options.followUpPrompts,
228
366
  });
367
+ signal?.throwIfAborted();
229
368
  const artifactRegistration = await registerRemoteArtifacts({
230
369
  runId,
231
370
  result,
@@ -252,15 +391,24 @@ export async function createRemoteServer(options = {}, deps = {}) {
252
391
  : ""}`);
253
392
  }
254
393
  catch (error) {
255
- 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);
256
400
  sendEvent({ type: "error", message });
257
- 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}`);
258
402
  }
259
403
  finally {
260
- busy = false;
261
- res.end();
404
+ detachController();
405
+ legacyBusy = false;
406
+ release();
407
+ if (!res.destroyed)
408
+ res.end();
262
409
  try {
263
- await rm(runDir, { recursive: true, force: true });
410
+ if (runDir)
411
+ await rm(runDir, { recursive: true, force: true });
264
412
  }
265
413
  catch {
266
414
  // ignore cleanup errors
@@ -285,6 +433,8 @@ export async function createRemoteServer(options = {}, deps = {}) {
285
433
  port: address.port,
286
434
  token: authToken,
287
435
  async close() {
436
+ for (const controller of controllers)
437
+ controller.abort();
288
438
  await new Promise((resolve, reject) => {
289
439
  server.close((err) => (err ? reject(err) : resolve()));
290
440
  });
@@ -292,8 +442,12 @@ export async function createRemoteServer(options = {}, deps = {}) {
292
442
  };
293
443
  }
294
444
  export async function serveRemote(options = {}) {
445
+ validateAdmissionOptions(options);
295
446
  const manualProfileDir = options.manualLoginProfileDir ?? path.join(os.homedir(), ".oracle", "browser-profile");
296
- const preferManualLogin = options.manualLoginDefault || process.platform === "win32" || isWsl();
447
+ const preferManualLogin = options.manualLoginDefault ||
448
+ options.cookieSyncDefault !== true ||
449
+ process.platform === "win32" ||
450
+ isWsl();
297
451
  let cookies = null;
298
452
  let opened = false;
299
453
  if (isWsl() && process.env.ORACLE_ALLOW_WSL_SERVE !== "1") {
@@ -302,7 +456,13 @@ export async function serveRemote(options = {}) {
302
456
  console.log("Alternatively, start Windows Chrome with --remote-debugging-port=9222 and use `--remote-chrome <windows-ip>:9222`.");
303
457
  return;
304
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
+ }
305
464
  if (!preferManualLogin) {
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.");
306
466
  // Warm-up: ensure this host has a ChatGPT login before accepting runs.
307
467
  const result = await loadLocalChatgptCookies(console.log, CHATGPT_URL);
308
468
  cookies = result.cookies;
@@ -344,11 +504,17 @@ export async function serveRemote(options = {}) {
344
504
  else {
345
505
  console.log(`Detected ${cookies.length} ChatGPT cookies on this host; runs will reuse this session.`);
346
506
  }
347
- const server = await createRemoteServer({
507
+ await runRemoteServer({
348
508
  ...options,
349
509
  manualLoginDefault: preferManualLogin,
350
510
  manualLoginProfileDir: manualProfileDir,
351
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);
352
518
  await new Promise((resolve) => {
353
519
  const shutdown = () => {
354
520
  console.log("Shutting down remote service...");
@@ -545,16 +711,99 @@ function classifySourceUrlKind(sourceUrl) {
545
711
  }
546
712
  return "chatgpt-file-endpoint";
547
713
  }
548
- async function readRequestBody(req) {
549
- const chunks = [];
550
- for await (const chunk of req) {
551
- 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);
552
731
  }
553
- return Buffer.concat(chunks).toString("utf8");
554
732
  }
555
- function sanitizeName(raw) {
556
- 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;
557
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.
558
807
  function sanitizeResult(result, warnings = []) {
559
808
  return {
560
809
  answerText: result.answerText,
@@ -563,6 +812,12 @@ function sanitizeResult(result, warnings = []) {
563
812
  tookMs: result.tookMs,
564
813
  answerTokens: result.answerTokens,
565
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,
566
821
  warnings: warnings.length > 0 ? warnings : undefined,
567
822
  chromePid: undefined,
568
823
  chromePort: undefined,
@@ -575,7 +830,31 @@ function formatSocket(req) {
575
830
  const port = socket.remotePort ?? "0";
576
831
  return `${host}:${port}`;
577
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
+ */
578
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) {
579
858
  const ipv4 = [];
580
859
  const ipv6 = [];
581
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
  }