@wlv-zedd/dsh-chatgpt-web 1.0.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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-demo.png +0 -0
  5. package/assets/promo-dshmarket-official.png +0 -0
  6. package/cordis.patch.yml +4 -0
  7. package/lib/cli.js +239642 -0
  8. package/lib/plugin.js +195 -0
  9. package/package.json +88 -0
  10. package/screenshots.json +5 -0
  11. package/src/adapters/base.ts +16 -0
  12. package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
  13. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
  14. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
  15. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
  16. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
  17. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
  18. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
  19. package/src/adapters/chatgpt-web/concurrency.ts +6 -0
  20. package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
  21. package/src/adapters/chatgpt-web/environment.ts +669 -0
  22. package/src/adapters/chatgpt-web/index.ts +1544 -0
  23. package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
  24. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
  25. package/src/adapters/chatgpt-web/markdown.ts +418 -0
  26. package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
  27. package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
  28. package/src/adapters/chatgpt-web/model.ts +70 -0
  29. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
  30. package/src/adapters/chatgpt-web/output-validation.ts +62 -0
  31. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
  32. package/src/adapters/chatgpt-web/prompt.ts +702 -0
  33. package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
  34. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
  35. package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
  36. package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
  37. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
  38. package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
  39. package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
  40. package/src/adapters/chatgpt-web/usage.ts +121 -0
  41. package/src/adapters/image.ts +9 -0
  42. package/src/bridge.ts +1083 -0
  43. package/src/browser-login.ts +521 -0
  44. package/src/chatgpt-session.ts +240 -0
  45. package/src/chatgpt-web-models.ts +400 -0
  46. package/src/cli.ts +568 -0
  47. package/src/codex-integration-document.ts +824 -0
  48. package/src/codex-integration-journal.ts +212 -0
  49. package/src/codex-integration-route.ts +515 -0
  50. package/src/codex-integration-shared.ts +332 -0
  51. package/src/codex-integration.ts +529 -0
  52. package/src/codex-interrupt-hook.ts +158 -0
  53. package/src/config.ts +616 -0
  54. package/src/dev-chat/cli.ts +432 -0
  55. package/src/dev-chat/constants.ts +3 -0
  56. package/src/dev-chat/driver.ts +655 -0
  57. package/src/dev-chat/profile.ts +223 -0
  58. package/src/dev-chat/session.ts +287 -0
  59. package/src/dev-chat/transport.ts +54 -0
  60. package/src/doctor.ts +237 -0
  61. package/src/event-queue.ts +45 -0
  62. package/src/http-body.ts +30 -0
  63. package/src/launcher-browser-host.ts +695 -0
  64. package/src/lib/errors.ts +281 -0
  65. package/src/lib/token-estimate.ts +42 -0
  66. package/src/login-helper.cjs +140 -0
  67. package/src/model-catalog.ts +197 -0
  68. package/src/native-passthrough.ts +261 -0
  69. package/src/plugin.ts +191 -0
  70. package/src/process.ts +45 -0
  71. package/src/responses/compaction.ts +199 -0
  72. package/src/responses/parser.ts +633 -0
  73. package/src/responses/reasoning-envelope.ts +49 -0
  74. package/src/responses/schema.ts +172 -0
  75. package/src/responses/state.ts +230 -0
  76. package/src/server.ts +1111 -0
  77. package/src/service.ts +315 -0
  78. package/src/setup.ts +671 -0
  79. package/src/stall-timeout.ts +23 -0
  80. package/src/tunnel-service.ts +160 -0
  81. package/src/tunnel.ts +417 -0
  82. package/src/turndown-plugin-gfm.d.ts +5 -0
  83. package/src/types.ts +307 -0
  84. package/src/usage/totals.ts +12 -0
  85. package/src/version.ts +1 -0
package/src/setup.ts ADDED
@@ -0,0 +1,671 @@
1
+ import { existsSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { createServer } from "node:net";
4
+ import { join } from "node:path";
5
+ import type { AppConfig, BrowserInteractionMode, RuntimeMode, SubagentProtocol } from "./config";
6
+ import {
7
+ currentRuntimeCommand,
8
+ defaultBrokerEndpoint,
9
+ defaultChromeExecutable,
10
+ defaultConfig,
11
+ getConfigPath,
12
+ loadConfigForSetup,
13
+ resolveInteractionConnectorIdentities,
14
+ resolveDevSetupConnectorName,
15
+ saveConfig,
16
+ tunnelConfigForInteractionMode,
17
+ } from "./config";
18
+ import {
19
+ browserLoginStateExists,
20
+ inspectBrowserLoginCapabilities,
21
+ loginToChatGpt,
22
+ storedBrowserLoginCapabilities,
23
+ } from "./browser-login";
24
+ import {
25
+ installCodexIntegration,
26
+ preflightCodexIntegration,
27
+ readCodexSubagentProtocol,
28
+ } from "./codex-integration";
29
+ import { inspectLauncherBrowserHost } from "./launcher-browser-host";
30
+ import {
31
+ DEV_CONFIG_PURPOSE,
32
+ DEV_LAUNCHER_PROFILE,
33
+ DEV_TUNNEL_BASE_NAME,
34
+ } from "./dev-chat/constants";
35
+ import {
36
+ assertServiceIdle,
37
+ getServiceStatus,
38
+ installService,
39
+ removeLegacyRuntimeArtifacts,
40
+ restartService,
41
+ uninstallService,
42
+ } from "./service";
43
+ import { connectTunnel, createTunnelConfig, installRuntimeKey, installRuntimeKeyBytes, installTunnelClient, managedRuntimeKeyPath, stopTunnel, waitForTunnelReady } from "./tunnel";
44
+ import { getTunnelServiceStatus, installTunnelService, restartTunnelService, stopTunnelService, tunnelServiceDefinitionMatches, uninstallTunnelService } from "./tunnel-service";
45
+ import { VERSION } from "./version";
46
+
47
+ export interface SetupOptions {
48
+ mode: RuntimeMode;
49
+ browserInteractionMode?: BrowserInteractionMode;
50
+ subagentProtocol?: SubagentProtocol;
51
+ port?: number;
52
+ chromeExecutablePath?: string;
53
+ browserHostDescriptorPath?: string;
54
+ refreshAccountCapabilities?: boolean;
55
+ appName?: string;
56
+ forceLogin?: boolean;
57
+ autoApproveToolCalls?: boolean;
58
+ experimentalBiggerContext?: boolean;
59
+ zeroRiskProEnabled?: boolean;
60
+ replaceCodexRoute?: boolean;
61
+ restartService?: boolean;
62
+ acknowledgedUnofficial?: boolean;
63
+ tunnelId?: string;
64
+ runtimeKeyFile?: string;
65
+ runtimeKeyValue?: string;
66
+ }
67
+
68
+ export interface SetupResult {
69
+ mode: RuntimeMode;
70
+ configPath: string;
71
+ loginCreated: boolean;
72
+ serviceLoaded: boolean;
73
+ tunnelReady: boolean | null;
74
+ codexRestartRequired: true;
75
+ connectorSetupRequired: boolean;
76
+ }
77
+
78
+ interface PreparedSetup {
79
+ existing: AppConfig | undefined;
80
+ config: AppConfig;
81
+ launcherOwned: boolean;
82
+ }
83
+
84
+ export interface DevProfileSetupResult {
85
+ mode: RuntimeMode;
86
+ configPath: string;
87
+ tunnelReady: boolean | null;
88
+ connectorSetupRequired: boolean;
89
+ }
90
+
91
+ export interface ExistingFullSetupCredentials {
92
+ tunnelId: boolean;
93
+ runtimeKey: boolean;
94
+ }
95
+
96
+ export function launcherCapabilityProbeRequired(
97
+ existing: AppConfig | undefined,
98
+ refreshAccountCapabilities = false,
99
+ interactionMode: BrowserInteractionMode = existing?.browserInteractionMode ?? "automatic",
100
+ ): boolean {
101
+ if (interactionMode === "manual") return false;
102
+ return refreshAccountCapabilities
103
+ || existing?.browserInteractionMode === "manual"
104
+ || existing?.browserHost !== "launcher"
105
+ || typeof existing.solAvailable !== "boolean"
106
+ || typeof existing.proAvailable !== "boolean";
107
+ }
108
+
109
+ export function existingFullSetupCredentials(
110
+ existing: AppConfig | undefined,
111
+ interactionMode: BrowserInteractionMode = existing?.browserInteractionMode ?? "automatic",
112
+ ): ExistingFullSetupCredentials {
113
+ const tunnel = existing?.mode === "full"
114
+ ? tunnelConfigForInteractionMode(existing, interactionMode)
115
+ : undefined;
116
+ return {
117
+ tunnelId: Boolean(tunnel?.tunnelId),
118
+ runtimeKey: Boolean(tunnel?.runtimeKeyFile && existsSync(tunnel.runtimeKeyFile)),
119
+ };
120
+ }
121
+
122
+ function loadExistingConfig(): AppConfig | undefined {
123
+ if (!existsSync(getConfigPath())) return undefined;
124
+ return loadConfigForSetup();
125
+ }
126
+
127
+ function meaningfulRuntimeChange(before: AppConfig, after: AppConfig): boolean {
128
+ return JSON.stringify({
129
+ mode: before.mode,
130
+ subagentProtocol: before.subagentProtocol,
131
+ releaseVersion: before.releaseVersion,
132
+ host: before.host,
133
+ port: before.port,
134
+ contextWindow: before.contextWindow,
135
+ appName: before.appName,
136
+ automaticAppName: before.automaticAppName,
137
+ manualAppName: before.manualAppName,
138
+ browserHost: before.browserHost,
139
+ browserInteractionMode: before.browserInteractionMode,
140
+ browserHostDescriptorPath: before.browserHostDescriptorPath,
141
+ chromeExecutablePath: before.chromeExecutablePath,
142
+ storageStatePath: before.storageStatePath,
143
+ brokerSocketPath: before.brokerSocketPath,
144
+ headed: before.headed,
145
+ solAvailable: before.solAvailable,
146
+ proAvailable: before.proAvailable,
147
+ experimentalBiggerContext: before.experimentalBiggerContext,
148
+ zeroRiskProEnabled: before.zeroRiskProEnabled,
149
+ autoApproveToolCalls: before.autoApproveToolCalls,
150
+ controlToken: before.controlToken,
151
+ runtimeCommand: before.runtimeCommand,
152
+ tunnel: before.tunnel,
153
+ automaticTunnel: before.automaticTunnel,
154
+ manualTunnel: before.manualTunnel,
155
+ }) !== JSON.stringify({
156
+ mode: after.mode,
157
+ subagentProtocol: after.subagentProtocol,
158
+ releaseVersion: after.releaseVersion,
159
+ host: after.host,
160
+ port: after.port,
161
+ contextWindow: after.contextWindow,
162
+ appName: after.appName,
163
+ automaticAppName: after.automaticAppName,
164
+ manualAppName: after.manualAppName,
165
+ browserHost: after.browserHost,
166
+ browserInteractionMode: after.browserInteractionMode,
167
+ browserHostDescriptorPath: after.browserHostDescriptorPath,
168
+ chromeExecutablePath: after.chromeExecutablePath,
169
+ storageStatePath: after.storageStatePath,
170
+ brokerSocketPath: after.brokerSocketPath,
171
+ headed: after.headed,
172
+ solAvailable: after.solAvailable,
173
+ proAvailable: after.proAvailable,
174
+ experimentalBiggerContext: after.experimentalBiggerContext,
175
+ zeroRiskProEnabled: after.zeroRiskProEnabled,
176
+ autoApproveToolCalls: after.autoApproveToolCalls,
177
+ controlToken: after.controlToken,
178
+ runtimeCommand: after.runtimeCommand,
179
+ tunnel: after.tunnel,
180
+ automaticTunnel: after.automaticTunnel,
181
+ manualTunnel: after.manualTunnel,
182
+ });
183
+ }
184
+
185
+ export function tunnelWorkerRuntimeChanged(before: AppConfig | undefined, after: AppConfig): boolean {
186
+ if (!before || before.mode !== "full" || after.mode !== "full") return false;
187
+ return before.releaseVersion !== after.releaseVersion
188
+ || JSON.stringify(before.runtimeCommand) !== JSON.stringify(after.runtimeCommand)
189
+ || before.brokerSocketPath !== after.brokerSocketPath
190
+ || before.browserInteractionMode !== after.browserInteractionMode
191
+ || JSON.stringify(before.tunnel) !== JSON.stringify(after.tunnel);
192
+ }
193
+
194
+ async function assertPortAvailable(host: string, port: number): Promise<void> {
195
+ await new Promise<void>((resolveAvailable, rejectAvailable) => {
196
+ const server = createServer();
197
+ server.unref();
198
+ server.once("error", error => rejectAvailable(new Error(`Cannot bind ${host}:${port}: ${error.message}`)));
199
+ server.listen(port, host, () => server.close(error => error ? rejectAvailable(error) : resolveAvailable()));
200
+ });
201
+ }
202
+
203
+ export function setupProxyIsReady(
204
+ health: Record<string, unknown>,
205
+ config: Pick<AppConfig, "mode" | "releaseVersion">,
206
+ ): boolean {
207
+ return (health.service === "dsh-chatgpt-web" || health.service === "codex-chatgpt-web")
208
+ && health.status === "ok"
209
+ && health.mode === config.mode
210
+ && health.version === config.releaseVersion
211
+ && health.accepting_turns === true;
212
+ }
213
+
214
+ async function waitForProxy(config: AppConfig, timeoutMs = 10_000): Promise<void> {
215
+ const deadline = Date.now() + timeoutMs;
216
+ let lastError = "not reachable";
217
+ while (Date.now() < deadline) {
218
+ const controller = new AbortController();
219
+ const requestTimeout = setTimeout(() => controller.abort(), 2_000);
220
+ try {
221
+ const response = await fetch(`http://${config.host}:${config.port}/healthz`, {
222
+ signal: controller.signal,
223
+ });
224
+ if (response.ok) {
225
+ const body = await response.json() as Record<string, unknown>;
226
+ if (setupProxyIsReady(body, config)) return;
227
+ lastError = `unexpected health payload: ${JSON.stringify(body)}`;
228
+ } else {
229
+ lastError = `HTTP ${response.status}`;
230
+ }
231
+ } catch (error) {
232
+ lastError = error instanceof Error ? error.message : String(error);
233
+ } finally {
234
+ clearTimeout(requestTimeout);
235
+ }
236
+ await new Promise(resolveWait => setTimeout(resolveWait, 250));
237
+ }
238
+ throw new Error(`Responses proxy did not become ready: ${lastError}`);
239
+ }
240
+
241
+ function baseConfig(existing: AppConfig | undefined, options: SetupOptions): AppConfig {
242
+ const config = existing ? structuredClone(existing) : defaultConfig(options.mode);
243
+ config.mode = options.mode;
244
+ if (options.browserInteractionMode) config.browserInteractionMode = options.browserInteractionMode;
245
+ Object.assign(config, resolveInteractionConnectorIdentities(
246
+ existing,
247
+ config.browserInteractionMode,
248
+ options.appName,
249
+ ));
250
+ if (options.subagentProtocol) config.subagentProtocol = options.subagentProtocol;
251
+ config.releaseVersion = VERSION;
252
+ config.runtimeCommand = currentRuntimeCommand();
253
+ if (options.port !== undefined) {
254
+ if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65_535) throw new Error("--port must be an integer from 1 to 65535");
255
+ config.port = options.port;
256
+ }
257
+ if (options.chromeExecutablePath) config.chromeExecutablePath = options.chromeExecutablePath;
258
+ if (options.browserHostDescriptorPath) {
259
+ config.browserHost = "launcher";
260
+ config.browserHostDescriptorPath = options.browserHostDescriptorPath;
261
+ config.brokerSocketPath = defaultBrokerEndpoint();
262
+ } else if (options.chromeExecutablePath) {
263
+ config.browserHost = "managed-chrome";
264
+ delete config.browserHostDescriptorPath;
265
+ }
266
+ if (options.autoApproveToolCalls !== undefined) config.autoApproveToolCalls = options.autoApproveToolCalls;
267
+ if (options.experimentalBiggerContext !== undefined) {
268
+ config.experimentalBiggerContext = options.experimentalBiggerContext;
269
+ }
270
+ if (options.zeroRiskProEnabled !== undefined) {
271
+ if (config.browserInteractionMode !== "manual") {
272
+ throw new Error("Zero Risk Pro can be configured only with --zero-risk-browser-interaction");
273
+ }
274
+ config.zeroRiskProEnabled = options.zeroRiskProEnabled;
275
+ }
276
+ if (config.browserInteractionMode === "manual") {
277
+ if (options.refreshAccountCapabilities) {
278
+ throw new Error("Zero Risk cannot refresh account capabilities");
279
+ }
280
+ if (options.forceLogin) {
281
+ throw new Error("Zero Risk uses the launcher's existing ChatGPT session; --login is unavailable");
282
+ }
283
+ if (options.experimentalBiggerContext === true) {
284
+ throw new Error("Zero Risk does not support Bigger Context");
285
+ }
286
+ if (config.mode !== "full") {
287
+ throw new Error("Zero Risk requires --full so Codex Zero Risk can signal start, tools, and completion");
288
+ }
289
+ if (config.browserHost !== "launcher") {
290
+ throw new Error("Zero Risk requires the Launcher; pass --browser-host-descriptor from the running Launcher");
291
+ }
292
+ config.experimentalBiggerContext = false;
293
+ }
294
+ if (options.acknowledgedUnofficial) config.acknowledgedUnofficialAt = new Date().toISOString();
295
+ if (!config.acknowledgedUnofficialAt) {
296
+ throw new Error("Setup requires explicit acknowledgement that this is unofficial browser automation. Pass --acknowledge-unofficial.");
297
+ }
298
+ return config;
299
+ }
300
+
301
+ async function inspectLauncherCapabilities(
302
+ config: AppConfig,
303
+ existing: AppConfig | undefined,
304
+ refreshAccountCapabilities: boolean,
305
+ expectedProfile: "production" | "development",
306
+ ): Promise<{ solAvailable: boolean; proAvailable: boolean }> {
307
+ const detectCapabilities = launcherCapabilityProbeRequired(
308
+ existing,
309
+ refreshAccountCapabilities,
310
+ config.browserInteractionMode,
311
+ );
312
+ const inspected = await inspectLauncherBrowserHost(config.browserHostDescriptorPath!, {
313
+ detectCapabilities,
314
+ expectedProfile,
315
+ });
316
+ return {
317
+ solAvailable: detectCapabilities ? inspected.solAvailable === true : existing!.solAvailable,
318
+ proAvailable: detectCapabilities ? inspected.proAvailable === true : existing!.proAvailable,
319
+ };
320
+ }
321
+
322
+ async function configureTunnel(config: AppConfig, existing: AppConfig | undefined, options: SetupOptions): Promise<void> {
323
+ if (config.mode === "browser-only") {
324
+ delete config.tunnel;
325
+ delete config.automaticTunnel;
326
+ delete config.manualTunnel;
327
+ return;
328
+ }
329
+ const interactionMode = config.browserInteractionMode;
330
+ const legacyTunnel = existing?.mode === "full"
331
+ && !existing.automaticTunnel
332
+ && !existing.manualTunnel
333
+ ? existing.tunnel
334
+ : undefined;
335
+ let automaticTunnel = existing?.automaticTunnel
336
+ ?? legacyTunnel;
337
+ let manualTunnel = existing?.manualTunnel;
338
+ const existingTunnel = interactionMode === "manual" ? manualTunnel : automaticTunnel;
339
+ const tunnelId = options.tunnelId ?? existingTunnel?.tunnelId;
340
+ if (!tunnelId) {
341
+ throw new Error(`${interactionMode === "manual" ? "Zero Risk" : "Automatic"} mode requires its own Tunnel ID`);
342
+ }
343
+ let runtimeKeyFile = existingTunnel?.runtimeKeyFile;
344
+ const managedKeyFile = managedRuntimeKeyPath(interactionMode);
345
+ if ((!runtimeKeyFile || !existsSync(runtimeKeyFile)) && existsSync(managedKeyFile)) {
346
+ runtimeKeyFile = managedKeyFile;
347
+ }
348
+ if (options.runtimeKeyFile) runtimeKeyFile = installRuntimeKey(options.runtimeKeyFile, interactionMode);
349
+ if (options.runtimeKeyValue) runtimeKeyFile = installRuntimeKeyBytes(options.runtimeKeyValue, interactionMode);
350
+ if (runtimeKeyFile && runtimeKeyFile !== managedKeyFile && existsSync(runtimeKeyFile)) {
351
+ runtimeKeyFile = installRuntimeKey(runtimeKeyFile, interactionMode);
352
+ }
353
+ if (!runtimeKeyFile || !existsSync(runtimeKeyFile)) {
354
+ throw new Error(`${interactionMode === "manual" ? "Zero Risk" : "Automatic"} mode requires its own runtime key`);
355
+ }
356
+ const installedBinary = await installTunnelClient();
357
+ const productionProfileName = interactionMode === "manual"
358
+ ? "codex-chatgpt-web-zero-risk"
359
+ : "codex-chatgpt-web";
360
+ const profileName = config.purpose === DEV_CONFIG_PURPOSE
361
+ ? interactionMode === "manual" ? `${DEV_TUNNEL_BASE_NAME}-zero-risk` : DEV_TUNNEL_BASE_NAME
362
+ : productionProfileName;
363
+ const configuredTunnel = createTunnelConfig({
364
+ binaryPath: installedBinary,
365
+ tunnelId,
366
+ runtimeKeyFile,
367
+ profileName,
368
+ alias: profileName,
369
+ });
370
+ const otherTunnel = interactionMode === "manual" ? automaticTunnel : manualTunnel;
371
+ if (otherTunnel?.tunnelId === configuredTunnel.tunnelId) {
372
+ throw new Error("Automatic and Zero Risk require different Tunnel IDs and separate ChatGPT connectors");
373
+ }
374
+ if (interactionMode === "manual") manualTunnel = configuredTunnel;
375
+ else automaticTunnel = configuredTunnel;
376
+ config.tunnel = configuredTunnel;
377
+ if (automaticTunnel) config.automaticTunnel = automaticTunnel;
378
+ else delete config.automaticTunnel;
379
+ if (manualTunnel) config.manualTunnel = manualTunnel;
380
+ else delete config.manualTunnel;
381
+ }
382
+
383
+ async function bootstrapTunnelProfile(config: AppConfig): Promise<void> {
384
+ let bootstrapError: unknown;
385
+ try {
386
+ // `runtimes connect` writes the native profile and returns once its managed runtime is healthy.
387
+ // Readiness follows after a successful control-plane poll, so setup proves it separately before
388
+ // stopping the validation runtime. The launcher supervisor reconnects the committed profile.
389
+ connectTunnel(config);
390
+ const status = await waitForTunnelReady(config);
391
+ if (!status.ok) throw new Error(`Tunnel runtime did not become healthy and ready: ${status.detail}`);
392
+ } catch (error) {
393
+ bootstrapError = error;
394
+ }
395
+ try {
396
+ stopTunnel(config);
397
+ } catch (stopError) {
398
+ if (bootstrapError) {
399
+ const primary = bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError);
400
+ const cleanup = stopError instanceof Error ? stopError.message : String(stopError);
401
+ throw new Error(`${primary}; temporary tunnel cleanup also failed: ${cleanup}`);
402
+ }
403
+ throw stopError;
404
+ }
405
+ if (bootstrapError) throw bootstrapError;
406
+ }
407
+
408
+ function prepareSetup(options: SetupOptions): PreparedSetup {
409
+ const existing = loadExistingConfig();
410
+ if (existing?.purpose === DEV_CONFIG_PURPOSE) {
411
+ throw new Error("A DEV harness configuration cannot be installed into Codex");
412
+ }
413
+ const config = baseConfig(existing, {
414
+ ...options,
415
+ subagentProtocol: options.subagentProtocol
416
+ ?? readCodexSubagentProtocol(existing?.subagentProtocol ?? "compatibility-v1"),
417
+ });
418
+ const launcherOwned = config.browserHost === "launcher";
419
+ if (!launcherOwned) {
420
+ const chromePath = options.chromeExecutablePath || defaultChromeExecutable();
421
+ if (!existsSync(chromePath)) {
422
+ throw new Error(`Google Chrome executable not found at: ${chromePath}. Provide --chrome <path>`);
423
+ }
424
+ }
425
+ return { existing, config, launcherOwned };
426
+ }
427
+
428
+ export function preflightSetup(options: SetupOptions): void {
429
+ const { existing, config } = prepareSetup(options);
430
+ if (config.mode === "full") {
431
+ const saved = existing?.mode === "full"
432
+ ? tunnelConfigForInteractionMode(existing, config.browserInteractionMode)
433
+ : undefined;
434
+ const tunnelId = options.tunnelId ?? saved?.tunnelId;
435
+ if (!tunnelId) {
436
+ throw new Error(
437
+ `${config.browserInteractionMode === "manual" ? "Zero Risk" : "Automatic"} mode needs its own MCP Tunnel ID`,
438
+ );
439
+ }
440
+ const savedKey = saved?.runtimeKeyFile;
441
+ const managedKey = managedRuntimeKeyPath(config.browserInteractionMode);
442
+ const hasRuntimeKey = Boolean(
443
+ options.runtimeKeyValue
444
+ || (options.runtimeKeyFile && existsSync(options.runtimeKeyFile))
445
+ || (savedKey && existsSync(savedKey))
446
+ || existsSync(managedKey),
447
+ );
448
+ if (!hasRuntimeKey) {
449
+ throw new Error(
450
+ `${config.browserInteractionMode === "manual" ? "Zero Risk" : "Automatic"} mode needs its own MCP runtime key`,
451
+ );
452
+ }
453
+ const otherMode = config.browserInteractionMode === "manual" ? "automatic" : "manual";
454
+ const other = existing?.mode === "full"
455
+ ? tunnelConfigForInteractionMode(existing, otherMode)
456
+ : undefined;
457
+ if (other?.tunnelId === tunnelId) {
458
+ throw new Error("Automatic and Zero Risk require different Tunnel IDs and separate ChatGPT connectors");
459
+ }
460
+ }
461
+ preflightCodexIntegration(config, {
462
+ replaceExistingRoute: options.replaceCodexRoute,
463
+ });
464
+ }
465
+
466
+ export async function setup(options: SetupOptions): Promise<SetupResult> {
467
+ const { existing, config, launcherOwned } = prepareSetup(options);
468
+ preflightCodexIntegration(config, {
469
+ replaceExistingRoute: options.replaceCodexRoute,
470
+ });
471
+ const refreshTunnelWorker = tunnelWorkerRuntimeChanged(existing, config);
472
+ if (existing && options.restartService) config.controlToken = randomBytes(32).toString("base64url");
473
+ const beforeService = getServiceStatus();
474
+ if (launcherOwned && (beforeService.installed || beforeService.loaded)) {
475
+ if (!existing) {
476
+ throw new Error("A legacy background service exists without a verifiable configuration; refusing automatic migration");
477
+ }
478
+ if (!options.restartService) {
479
+ throw new Error(
480
+ "Launcher ownership migration must stop the legacy background service. "
481
+ + "Retry from the launcher after the active Codex task finishes.",
482
+ );
483
+ }
484
+ }
485
+ if (beforeService.loaded && !existing) {
486
+ throw new Error("A dsh-chatgpt-web service is loaded but its configuration is missing; refusing to replace an unverifiable process");
487
+ }
488
+
489
+ let loginCreated = false;
490
+ let solAvailable: boolean | undefined = config.solAvailable;
491
+ let proAvailable: boolean | undefined = config.proAvailable;
492
+ if (config.browserInteractionMode === "manual") {
493
+ // The generic manual route is independent of account capabilities. The launcher may open the
494
+ // authenticated surface, but setup must not inspect its model selector or infer availability.
495
+ } else if (config.browserHost === "launcher") {
496
+ if (options.forceLogin) throw new Error("Launcher browser login is owned by the launcher UI; --login cannot replace it");
497
+ const capabilities = await inspectLauncherCapabilities(
498
+ config,
499
+ existing,
500
+ options.refreshAccountCapabilities === true,
501
+ "production",
502
+ );
503
+ solAvailable = capabilities.solAvailable;
504
+ proAvailable = capabilities.proAvailable;
505
+ } else {
506
+ const stored = storedBrowserLoginCapabilities(config);
507
+ solAvailable = stored.solAvailable;
508
+ proAvailable = stored.proAvailable;
509
+ const loginRequired = options.forceLogin || !browserLoginStateExists(config);
510
+ const capabilityProbeRequired = !loginRequired
511
+ && (options.refreshAccountCapabilities === true
512
+ || existing?.browserInteractionMode === "manual"
513
+ || solAvailable === undefined
514
+ || proAvailable === undefined);
515
+ if (beforeService.loaded && (loginRequired || capabilityProbeRequired) && !options.restartService) {
516
+ throw new Error(
517
+ "Setup must verify the browser account before changing the running daemon. "
518
+ + "Rerun from a normal terminal with --restart-service after the active task finishes.",
519
+ );
520
+ }
521
+ if (beforeService.loaded && (loginRequired || capabilityProbeRequired) && existing) await assertServiceIdle(existing);
522
+ if (loginRequired) {
523
+ const login = await loginToChatGpt(config);
524
+ solAvailable = login.solAvailable;
525
+ proAvailable = login.proAvailable;
526
+ loginCreated = true;
527
+ } else if (capabilityProbeRequired) {
528
+ const inspected = await inspectBrowserLoginCapabilities(config);
529
+ solAvailable = inspected.solAvailable;
530
+ proAvailable = inspected.proAvailable;
531
+ }
532
+ }
533
+ config.solAvailable = solAvailable === true;
534
+ config.proAvailable = config.solAvailable && proAvailable === true;
535
+ const explicitTunnelChange = Boolean(options.tunnelId || options.runtimeKeyFile || options.runtimeKeyValue);
536
+ const preliminaryChange = Boolean(existing && (meaningfulRuntimeChange(existing, config) || explicitTunnelChange || options.forceLogin));
537
+ if (beforeService.loaded && preliminaryChange && !options.restartService) {
538
+ throw new Error(
539
+ "The daemon is currently serving a Codex task and setup would change its runtime. "
540
+ + "Rerun from a normal terminal with --restart-service after the active task finishes.",
541
+ );
542
+ }
543
+ if (beforeService.loaded && preliminaryChange && existing) await assertServiceIdle(existing);
544
+ await configureTunnel(config, existing, options);
545
+
546
+ const changedWhileLoaded = Boolean(existing && beforeService.loaded && meaningfulRuntimeChange(existing, config));
547
+ if (changedWhileLoaded && !options.restartService) {
548
+ throw new Error(
549
+ "The daemon is currently serving a Codex task and setup would change its runtime. "
550
+ + "Rerun from a normal terminal with --restart-service after the active task finishes.",
551
+ );
552
+ }
553
+ if (changedWhileLoaded && !preliminaryChange && existing) await assertServiceIdle(existing);
554
+ if (!beforeService.loaded) await assertPortAvailable(config.host, config.port);
555
+
556
+ if (!launcherOwned) {
557
+ saveConfig(config);
558
+ if (process.platform === "darwin") {
559
+ installService(config);
560
+ if (changedWhileLoaded && options.restartService && existing) await restartService(existing);
561
+ await waitForProxy(config);
562
+ }
563
+ }
564
+
565
+ let tunnelReady: boolean | null = null;
566
+ if (config.mode === "browser-only" && existing?.mode === "full") {
567
+ const previousTunnelService = getTunnelServiceStatus();
568
+ if (previousTunnelService.installed || previousTunnelService.loaded) await uninstallTunnelService();
569
+ stopTunnel(existing);
570
+ }
571
+ if (config.mode === "full") {
572
+ const profilePath = join(config.tunnel!.profileDir, `${config.tunnel!.profileName}.yaml`);
573
+ const tunnelService = getTunnelServiceStatus();
574
+ const needsProfile = !existsSync(profilePath);
575
+ if (launcherOwned) {
576
+ if (tunnelService.installed || tunnelService.loaded) await uninstallTunnelService();
577
+ if (needsProfile || refreshTunnelWorker || explicitTunnelChange) {
578
+ await bootstrapTunnelProfile(config);
579
+ }
580
+ } else {
581
+ const needsOwnershipMigration = !tunnelService.installed || !tunnelService.loaded || !tunnelServiceDefinitionMatches(config);
582
+ if (needsOwnershipMigration || needsProfile) {
583
+ await assertServiceIdle(config);
584
+ if (tunnelService.loaded) await stopTunnelService();
585
+ await bootstrapTunnelProfile(config);
586
+ installTunnelService(config);
587
+ } else if (refreshTunnelWorker) {
588
+ await assertServiceIdle(config);
589
+ await restartTunnelService();
590
+ }
591
+ const status = await waitForTunnelReady(config);
592
+ if (!status.ok) throw new Error(`Tunnel runtime did not become healthy and ready: ${status.detail}`);
593
+ tunnelReady = true;
594
+ }
595
+ }
596
+ if (launcherOwned && (beforeService.installed || beforeService.loaded)) {
597
+ await uninstallService(existing!);
598
+ }
599
+ if (launcherOwned) saveConfig(config);
600
+ // Keep the previous terminal runtime intact through the ownership handoff. A later launcher
601
+ // setup removes it once the launcher-owned configuration is already the established baseline.
602
+ const migratingTerminalRuntime = Boolean(
603
+ launcherOwned && existing && existing.browserHost !== "launcher",
604
+ );
605
+ if (!migratingTerminalRuntime) removeLegacyRuntimeArtifacts(config);
606
+ installCodexIntegration(config, {
607
+ replaceExistingRoute: options.replaceCodexRoute,
608
+ });
609
+
610
+ return {
611
+ mode: config.mode,
612
+ configPath: getConfigPath(),
613
+ loginCreated,
614
+ serviceLoaded: launcherOwned ? false : getServiceStatus().loaded,
615
+ tunnelReady,
616
+ codexRestartRequired: true,
617
+ connectorSetupRequired: config.mode === "full",
618
+ };
619
+ }
620
+
621
+ /**
622
+ * Configure the isolated launcher/browser/tunnel inputs used by the repository DEV harness.
623
+ * This deliberately has no Codex integration, Responses listener, or system service; the DEV
624
+ * launcher supervises only the isolated MCP tunnel after this transaction commits.
625
+ */
626
+ export async function setupDevProfile(options: SetupOptions): Promise<DevProfileSetupResult> {
627
+ const existing = loadExistingConfig();
628
+ if (existing && existing.purpose !== DEV_CONFIG_PURPOSE) {
629
+ throw new Error("DEV profile home contains a non-DEV configuration; refusing to repurpose it");
630
+ }
631
+ if (!options.browserHostDescriptorPath) {
632
+ throw new Error("DEV profile setup requires the isolated launcher browser descriptor");
633
+ }
634
+ const config = baseConfig(existing, {
635
+ ...options,
636
+ appName: resolveDevSetupConnectorName(existing?.automaticAppName, options.appName),
637
+ });
638
+ if (config.browserHost !== "launcher") {
639
+ throw new Error("DEV profile setup requires the desktop launcher browser host");
640
+ }
641
+ config.purpose = DEV_CONFIG_PURPOSE;
642
+ if (config.browserInteractionMode === "automatic") {
643
+ const capabilities = await inspectLauncherCapabilities(
644
+ config,
645
+ existing,
646
+ options.refreshAccountCapabilities === true,
647
+ DEV_LAUNCHER_PROFILE,
648
+ );
649
+ config.solAvailable = capabilities.solAvailable;
650
+ config.proAvailable = capabilities.solAvailable && capabilities.proAvailable;
651
+ }
652
+
653
+ const explicitTunnelChange = Boolean(options.tunnelId || options.runtimeKeyFile || options.runtimeKeyValue);
654
+ await configureTunnel(config, existing, options);
655
+ let tunnelReady: boolean | null = null;
656
+ if (config.mode === "full") {
657
+ const profilePath = join(config.tunnel!.profileDir, `${config.tunnel!.profileName}.yaml`);
658
+ const needsProfile = !existsSync(profilePath);
659
+ if (needsProfile || tunnelWorkerRuntimeChanged(existing, config) || explicitTunnelChange) {
660
+ await bootstrapTunnelProfile(config);
661
+ }
662
+ tunnelReady = false;
663
+ }
664
+ saveConfig(config);
665
+ return {
666
+ mode: config.mode,
667
+ configPath: getConfigPath(),
668
+ tunnelReady,
669
+ connectorSetupRequired: config.mode === "full",
670
+ };
671
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Bridge upstream stall budget: seconds of silence (no adapter events) before the
3
+ * Responses bridge emits `response.incomplete` / `upstream_stall_timeout`.
4
+ *
5
+ * Raised from 90s so long reasoning + large tool writes are not cut mid-turn.
6
+ * Hung streams still die; they just get a more realistic window.
7
+ */
8
+ export const DEFAULT_STALL_TIMEOUT_SEC = 300;
9
+
10
+ // Keep a malformed or accidentally enormous configuration within a practical recovery budget.
11
+ export const MAX_STALL_TIMEOUT_SEC = 3_600;
12
+
13
+ /**
14
+ * Resolve the effective bridge stall deadline for a turn.
15
+ * - unset / non-finite config → {@link DEFAULT_STALL_TIMEOUT_SEC}
16
+ * - finite config → ceil, clamped to the practical [1, {@link MAX_STALL_TIMEOUT_SEC}] range
17
+ */
18
+ export function resolveStallTimeoutSec(configuredSec: number | undefined): number {
19
+ if (typeof configuredSec === "number" && Number.isFinite(configuredSec)) {
20
+ return Math.min(MAX_STALL_TIMEOUT_SEC, Math.max(1, Math.ceil(configuredSec)));
21
+ }
22
+ return DEFAULT_STALL_TIMEOUT_SEC;
23
+ }