@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
@@ -0,0 +1,160 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { homedir, userInfo } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { AppConfig } from "./config";
5
+ import { atomicWriteFile, getConfigDir } from "./config";
6
+ import { runCommand, runChecked } from "./process";
7
+
8
+ const LABEL = "io.github.codex-chatgpt-web.tunnel";
9
+
10
+ export interface TunnelServiceStatus {
11
+ supported: boolean;
12
+ installed: boolean;
13
+ loaded: boolean;
14
+ running: boolean;
15
+ label: string;
16
+ definitionPath?: string;
17
+ }
18
+
19
+ function xml(value: string): string {
20
+ return value
21
+ .replaceAll("&", "&")
22
+ .replaceAll("<", "&lt;")
23
+ .replaceAll(">", "&gt;")
24
+ .replaceAll('"', "&quot;")
25
+ .replaceAll("'", "&apos;");
26
+ }
27
+
28
+ function plistPath(): string {
29
+ return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
30
+ }
31
+
32
+ function launchDomain(): string {
33
+ return `gui/${userInfo().uid}`;
34
+ }
35
+
36
+ function serviceTarget(): string {
37
+ return `${launchDomain()}/${LABEL}`;
38
+ }
39
+
40
+ function settings(config: AppConfig) {
41
+ if (config.mode !== "full" || !config.tunnel) throw new Error("Tunnel service requires full mode");
42
+ return config.tunnel;
43
+ }
44
+
45
+ function assertMacOs(): void {
46
+ if (process.platform !== "darwin") {
47
+ throw new Error("Managed tunnel service installation is currently supported on macOS only");
48
+ }
49
+ }
50
+
51
+ export function tunnelServiceDefinition(config: AppConfig): string {
52
+ const tunnel = settings(config);
53
+ const logDir = join(getConfigDir(), "logs");
54
+ const args = [tunnel.binaryPath, "run", "--profile-dir", tunnel.profileDir, "--profile", tunnel.profileName];
55
+ return `<?xml version="1.0" encoding="UTF-8"?>
56
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
57
+ <plist version="1.0">
58
+ <dict>
59
+ <key>Label</key>
60
+ <string>${LABEL}</string>
61
+ <key>ProgramArguments</key>
62
+ <array>
63
+ ${args.map(arg => ` <string>${xml(arg)}</string>`).join("\n")}
64
+ </array>
65
+ <key>EnvironmentVariables</key>
66
+ <dict>
67
+ <key>DSH_CHATGPT_FREE_HOME</key>
68
+ <string>${xml(getConfigDir())}</string>
69
+ </dict>
70
+ <key>RunAtLoad</key>
71
+ <true/>
72
+ <key>KeepAlive</key>
73
+ <true/>
74
+ <key>ThrottleInterval</key>
75
+ <integer>10</integer>
76
+ <key>StandardOutPath</key>
77
+ <string>${xml(join(logDir, "tunnel.stdout.log"))}</string>
78
+ <key>StandardErrorPath</key>
79
+ <string>${xml(join(logDir, "tunnel.stderr.log"))}</string>
80
+ <key>ProcessType</key>
81
+ <string>Background</string>
82
+ </dict>
83
+ </plist>
84
+ `;
85
+ }
86
+
87
+ export function getTunnelServiceStatus(): TunnelServiceStatus {
88
+ if (process.platform !== "darwin") {
89
+ return { supported: false, installed: false, loaded: false, running: false, label: LABEL };
90
+ }
91
+ const path = plistPath();
92
+ const result = runCommand("launchctl", ["print", serviceTarget()]);
93
+ return {
94
+ supported: true,
95
+ installed: existsSync(path),
96
+ loaded: result.status === 0,
97
+ running: result.status === 0 && /^\s*state = running\s*$/m.test(result.stdout),
98
+ label: LABEL,
99
+ definitionPath: path,
100
+ };
101
+ }
102
+
103
+ export function tunnelServiceDefinitionMatches(config: AppConfig): boolean {
104
+ const path = plistPath();
105
+ return existsSync(path) && readFileSync(path, "utf8") === tunnelServiceDefinition(config);
106
+ }
107
+
108
+ export function installTunnelService(config: AppConfig): TunnelServiceStatus {
109
+ assertMacOs();
110
+ const tunnel = settings(config);
111
+ const profile = join(tunnel.profileDir, `${tunnel.profileName}.yaml`);
112
+ if (!existsSync(tunnel.binaryPath)) throw new Error(`Tunnel client is missing: ${tunnel.binaryPath}`);
113
+ if (!existsSync(profile)) throw new Error(`Tunnel profile is missing: ${profile}`);
114
+ const current = getTunnelServiceStatus();
115
+ const next = tunnelServiceDefinition(config);
116
+ if (current.loaded && (!current.installed || readFileSync(plistPath(), "utf8") !== next)) {
117
+ throw new Error("Refusing to replace a loaded tunnel service definition; stop it before installing the update");
118
+ }
119
+ mkdirSync(dirname(plistPath()), { recursive: true, mode: 0o700 });
120
+ mkdirSync(join(getConfigDir(), "logs"), { recursive: true, mode: 0o700 });
121
+ if (!current.installed || readFileSync(plistPath(), "utf8") !== next) atomicWriteFile(plistPath(), next);
122
+ if (!current.loaded) runChecked("launchctl", ["bootstrap", launchDomain(), plistPath()]);
123
+ return getTunnelServiceStatus();
124
+ }
125
+
126
+ export function startTunnelService(): TunnelServiceStatus {
127
+ assertMacOs();
128
+ if (!existsSync(plistPath())) throw new Error("Tunnel service is not installed; rerun full setup");
129
+ if (!getTunnelServiceStatus().loaded) runChecked("launchctl", ["bootstrap", launchDomain(), plistPath()]);
130
+ return getTunnelServiceStatus();
131
+ }
132
+
133
+ async function waitForTunnelServiceUnloaded(timeoutMs = 20_000): Promise<void> {
134
+ const deadline = Date.now() + timeoutMs;
135
+ while (getTunnelServiceStatus().loaded && Date.now() < deadline) {
136
+ await new Promise(resolveWait => setTimeout(resolveWait, 50));
137
+ }
138
+ if (getTunnelServiceStatus().loaded) throw new Error(`launchd did not unload ${LABEL} after ${timeoutMs}ms`);
139
+ }
140
+
141
+ export async function stopTunnelService(): Promise<TunnelServiceStatus> {
142
+ assertMacOs();
143
+ if (getTunnelServiceStatus().loaded) {
144
+ runChecked("launchctl", ["bootout", serviceTarget()]);
145
+ await waitForTunnelServiceUnloaded();
146
+ }
147
+ return getTunnelServiceStatus();
148
+ }
149
+
150
+ export async function restartTunnelService(): Promise<TunnelServiceStatus> {
151
+ await stopTunnelService();
152
+ return startTunnelService();
153
+ }
154
+
155
+ export async function uninstallTunnelService(): Promise<TunnelServiceStatus> {
156
+ assertMacOs();
157
+ await stopTunnelService();
158
+ rmSync(plistPath(), { force: true });
159
+ return getTunnelServiceStatus();
160
+ }
package/src/tunnel.ts ADDED
@@ -0,0 +1,417 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { unzipSync } from "fflate";
5
+ import type { AppConfig, BrowserInteractionMode, TunnelConfig } from "./config";
6
+ import { atomicWriteFile, getConfigDir } from "./config";
7
+ import { runCommand, runChecked } from "./process";
8
+
9
+ export const TUNNEL_VERSION = "0.0.12";
10
+ const MIGRATABLE_TUNNEL_VERSIONS = new Set(["0.0.10"]);
11
+ const RELEASE_BASE = `https://github.com/openai/tunnel-client/releases/download/v${TUNNEL_VERSION}`;
12
+ const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
13
+ export const TUNNEL_READY_TIMEOUT_MS = 120_000;
14
+ const TUNNEL_STATUS_POLL_INTERVAL_MS = 1_000;
15
+
16
+ interface TunnelInstallManifest {
17
+ version: 1;
18
+ tunnelClientVersion: string;
19
+ asset: string;
20
+ archiveSha256: string;
21
+ binarySha256: string;
22
+ }
23
+
24
+ export function tunnelClientInstallAction(installedVersion: string): "reuse" | "upgrade" {
25
+ if (installedVersion === TUNNEL_VERSION) return "reuse";
26
+ if (MIGRATABLE_TUNNEL_VERSIONS.has(installedVersion)) return "upgrade";
27
+ throw new Error(`Installed tunnel-client version ${installedVersion} is not a trusted upgrade source`);
28
+ }
29
+
30
+ function sha256(bytes: Uint8Array): string {
31
+ return createHash("sha256").update(bytes).digest("hex");
32
+ }
33
+
34
+ function platformAsset(): string {
35
+ const os = process.platform === "darwin" ? "darwin"
36
+ : process.platform === "linux" ? "linux"
37
+ : process.platform === "win32" ? "windows"
38
+ : undefined;
39
+ const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : undefined;
40
+ if (!os || !arch) throw new Error(`openai/tunnel-client has no pinned build for ${process.platform}/${process.arch}`);
41
+ return `tunnel-client-v${TUNNEL_VERSION}-${os}-${arch}.zip`;
42
+ }
43
+
44
+ async function fetchBytes(url: string, timeoutMs = 120_000): Promise<Uint8Array> {
45
+ const controller = new AbortController();
46
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
47
+ try {
48
+ const response = await fetch(url, { redirect: "follow", signal: controller.signal });
49
+ if (!response.ok) throw new Error(`Download failed (${response.status}): ${url}`);
50
+ const length = Number(response.headers.get("content-length") ?? "0");
51
+ if (Number.isFinite(length) && length > MAX_DOWNLOAD_BYTES) throw new Error(`Download exceeds ${MAX_DOWNLOAD_BYTES} bytes: ${url}`);
52
+ const bytes = new Uint8Array(await response.arrayBuffer());
53
+ if (bytes.byteLength > MAX_DOWNLOAD_BYTES) throw new Error(`Download exceeds ${MAX_DOWNLOAD_BYTES} bytes: ${url}`);
54
+ return bytes;
55
+ } catch (error) {
56
+ if (controller.signal.aborted) throw new Error(`Download timed out after ${timeoutMs}ms: ${url}`);
57
+ throw error;
58
+ } finally {
59
+ clearTimeout(timeout);
60
+ }
61
+ }
62
+
63
+ function parseExpectedChecksum(text: string, asset: string): string {
64
+ const line = text.split(/\r?\n/).find(candidate => candidate.trim().endsWith(asset));
65
+ const checksum = line?.trim().split(/\s+/)[0]?.toLowerCase();
66
+ if (!checksum || !/^[a-f0-9]{64}$/.test(checksum)) throw new Error(`SHA256SUMS.txt has no valid entry for ${asset}`);
67
+ return checksum;
68
+ }
69
+
70
+ function binaryPath(): string {
71
+ return join(getConfigDir(), "bin", process.platform === "win32" ? "tunnel-client.exe" : "tunnel-client");
72
+ }
73
+
74
+ function manifestPath(): string {
75
+ return join(getConfigDir(), "bin", "tunnel-client-manifest.json");
76
+ }
77
+
78
+ export async function installTunnelClient(): Promise<string> {
79
+ const executable = binaryPath();
80
+ const manifestFile = manifestPath();
81
+ let previousInstallation: { binary: Uint8Array; manifestText: string } | undefined;
82
+ if (existsSync(executable) && existsSync(manifestFile)) {
83
+ const manifestText = readFileSync(manifestFile, "utf8");
84
+ const manifest = JSON.parse(manifestText) as Partial<TunnelInstallManifest>;
85
+ const installedBinary = new Uint8Array(readFileSync(executable));
86
+ const actual = sha256(installedBinary);
87
+ if (manifest.version !== 1 || typeof manifest.tunnelClientVersion !== "string"
88
+ || manifest.binarySha256 !== actual) {
89
+ throw new Error(`Existing tunnel-client failed integrity validation: ${executable}`);
90
+ }
91
+ if (process.platform !== "win32" && (statSync(executable).mode & 0o111) === 0) {
92
+ throw new Error(`Existing tunnel-client is not executable: ${executable}`);
93
+ }
94
+ const action = tunnelClientInstallAction(manifest.tunnelClientVersion);
95
+ const installedVersion = runChecked(executable, ["--version"], { timeout: 10_000 });
96
+ if (!installedVersion.stdout.includes(manifest.tunnelClientVersion)
97
+ && !installedVersion.stderr.includes(manifest.tunnelClientVersion)) {
98
+ throw new Error(`Existing tunnel-client did not report version ${manifest.tunnelClientVersion}`);
99
+ }
100
+ if (action === "reuse") return executable;
101
+ previousInstallation = { binary: installedBinary, manifestText };
102
+ }
103
+ if (!previousInstallation && (existsSync(executable) || existsSync(manifestFile))) {
104
+ rmSync(executable, { force: true });
105
+ rmSync(manifestFile, { force: true });
106
+ }
107
+
108
+ const asset = platformAsset();
109
+ const [archive, sums] = await Promise.all([
110
+ fetchBytes(`${RELEASE_BASE}/${asset}`),
111
+ fetchBytes(`${RELEASE_BASE}/SHA256SUMS.txt`),
112
+ ]);
113
+ const expected = parseExpectedChecksum(new TextDecoder().decode(sums), asset);
114
+ const archiveHash = sha256(archive);
115
+ if (archiveHash !== expected) throw new Error(`Checksum mismatch for ${asset}`);
116
+ const files = unzipSync(archive);
117
+ const expectedName = process.platform === "win32" ? "tunnel-client.exe" : "tunnel-client";
118
+ const entry = Object.entries(files).find(([name]) => basename(name) === expectedName);
119
+ if (!entry) throw new Error(`${asset} does not contain ${expectedName}`);
120
+ const binary = entry[1];
121
+ mkdirSync(dirname(executable), { recursive: true, mode: 0o700 });
122
+ const stagedExecutable = `${executable}.install-${process.pid}-${randomUUID()}${process.platform === "win32" ? ".exe" : ""}`;
123
+ atomicWriteFile(stagedExecutable, binary);
124
+ let version: ReturnType<typeof runChecked>;
125
+ try {
126
+ if (process.platform !== "win32") chmodSync(stagedExecutable, 0o700);
127
+ version = runChecked(stagedExecutable, ["--version"], { timeout: 10_000 });
128
+ if (!version.stdout.includes(TUNNEL_VERSION) && !version.stderr.includes(TUNNEL_VERSION)) {
129
+ throw new Error(`Installed tunnel-client did not report version ${TUNNEL_VERSION}`);
130
+ }
131
+ } finally {
132
+ rmSync(stagedExecutable, { force: true });
133
+ }
134
+ const manifest: TunnelInstallManifest = {
135
+ version: 1,
136
+ tunnelClientVersion: TUNNEL_VERSION,
137
+ asset,
138
+ archiveSha256: archiveHash,
139
+ binarySha256: sha256(binary),
140
+ };
141
+ try {
142
+ atomicWriteFile(executable, binary);
143
+ if (process.platform !== "win32") chmodSync(executable, 0o700);
144
+ atomicWriteFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
145
+ } catch (error) {
146
+ if (previousInstallation) {
147
+ atomicWriteFile(executable, previousInstallation.binary);
148
+ if (process.platform !== "win32") chmodSync(executable, 0o700);
149
+ atomicWriteFile(manifestFile, previousInstallation.manifestText);
150
+ } else {
151
+ rmSync(executable, { force: true });
152
+ rmSync(manifestFile, { force: true });
153
+ }
154
+ throw error;
155
+ }
156
+ return executable;
157
+ }
158
+
159
+ export function installRuntimeKey(
160
+ sourcePath: string,
161
+ interactionMode: BrowserInteractionMode = "automatic",
162
+ ): string {
163
+ if (!existsSync(sourcePath)) throw new Error(`Tunnel runtime key file does not exist: ${sourcePath}`);
164
+ const key = readFileSync(sourcePath);
165
+ if (key.byteLength === 0 || key.byteLength > 64 * 1024) throw new Error("Tunnel runtime key file is empty or unexpectedly large");
166
+ return installRuntimeKeyBytes(key, interactionMode);
167
+ }
168
+
169
+ export function managedRuntimeKeyPath(interactionMode: BrowserInteractionMode = "automatic"): string {
170
+ const fileName = interactionMode === "manual"
171
+ ? "tunnel-runtime-zero-risk.key"
172
+ : "tunnel-runtime-automatic.key";
173
+ return join(getConfigDir(), "secrets", fileName);
174
+ }
175
+
176
+ export function installRuntimeKeyBytes(
177
+ key: Uint8Array | string,
178
+ interactionMode: BrowserInteractionMode = "automatic",
179
+ ): string {
180
+ const bytes = typeof key === "string" ? new TextEncoder().encode(key.trim()) : key;
181
+ if (bytes.byteLength === 0 || bytes.byteLength > 64 * 1024) throw new Error("Tunnel runtime key is empty or unexpectedly large");
182
+ const destination = managedRuntimeKeyPath(interactionMode);
183
+ atomicWriteFile(destination, bytes);
184
+ return destination;
185
+ }
186
+
187
+ export function createTunnelConfig(options: {
188
+ binaryPath: string;
189
+ tunnelId: string;
190
+ runtimeKeyFile: string;
191
+ profileName?: string;
192
+ alias?: string;
193
+ }): TunnelConfig {
194
+ if (!/^tunnel_[a-f0-9]{32}$/.test(options.tunnelId)) throw new Error("--tunnel-id must be tunnel_ followed by 32 lowercase hexadecimal characters");
195
+ const profileName = options.profileName ?? "codex-chatgpt-web";
196
+ const alias = options.alias ?? "codex-chatgpt-web";
197
+ if (!/^[A-Za-z0-9._-]+$/.test(profileName) || !/^[A-Za-z0-9._-]+$/.test(alias)) {
198
+ throw new Error("Tunnel profile and alias may contain only letters, digits, dot, underscore, and dash");
199
+ }
200
+ return {
201
+ binaryPath: options.binaryPath,
202
+ tunnelId: options.tunnelId,
203
+ runtimeKeyFile: options.runtimeKeyFile,
204
+ profileDir: join(getConfigDir(), "tunnel", "profiles"),
205
+ profileName,
206
+ alias,
207
+ };
208
+ }
209
+
210
+ function shellQuote(value: string): string {
211
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
212
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
213
+ }
214
+
215
+ function tunnelCommandQuoted(value: string): string {
216
+ if (/[\r\n]/.test(value)) throw new Error("Tunnel MCP command values must not contain newlines");
217
+ // tunnel-client parses mcp.command with backslash escapes on every platform.
218
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
219
+ }
220
+
221
+ export function mcpCommand(config: AppConfig, platform = process.platform): string {
222
+ const contract = config.browserInteractionMode === "manual" ? "safe" : "native";
223
+ const command = [
224
+ ...config.runtimeCommand,
225
+ "mcp",
226
+ "--contract",
227
+ contract,
228
+ "--broker-socket",
229
+ config.brokerSocketPath,
230
+ ];
231
+ if (platform === "win32") {
232
+ return command.map(tunnelCommandQuoted).join(" ");
233
+ }
234
+ return command.map(shellQuote).join(" ");
235
+ }
236
+
237
+ function tunnel(config: AppConfig): TunnelConfig {
238
+ if (config.mode !== "full" || !config.tunnel) throw new Error("Tunnel commands require full mode");
239
+ return config.tunnel;
240
+ }
241
+
242
+ export function connectTunnel(config: AppConfig): void {
243
+ const settings = tunnel(config);
244
+ mkdirSync(settings.profileDir, { recursive: true, mode: 0o700 });
245
+ const result = runCommand(settings.binaryPath, [
246
+ "runtimes", "connect",
247
+ "--alias", settings.alias,
248
+ "--profile", settings.profileName,
249
+ "--profile-dir", settings.profileDir,
250
+ "--tunnel-client-bin", settings.binaryPath,
251
+ "--tunnel-id", settings.tunnelId,
252
+ "--runtime-api-key", `file:${settings.runtimeKeyFile}`,
253
+ "--mcp-command", mcpCommand(config),
254
+ "--json",
255
+ ], { timeout: TUNNEL_READY_TIMEOUT_MS });
256
+ const structuredOutput = result.stdout.trim();
257
+ const launchError = structuredOutput
258
+ ? tunnelConnectLaunchError(structuredOutput)
259
+ : undefined;
260
+ if (result.status !== 0) {
261
+ const detail = launchError && launchError !== "tunnel-client returned non-JSON connect output"
262
+ ? launchError
263
+ : safeTunnelDetail(tunnelCommandOutput(result) || `exit ${result.status}`);
264
+ throw new Error(`Tunnel managed startup failed: ${detail}`);
265
+ }
266
+ if (launchError) throw new Error(`Tunnel runtime exited during launch: ${launchError}`);
267
+ }
268
+
269
+ export function stopTunnel(config: AppConfig): void {
270
+ const settings = tunnel(config);
271
+ const result = runCommand(
272
+ settings.binaryPath,
273
+ ["runtimes", "stop", settings.alias, "--json"],
274
+ { timeout: 15_000 },
275
+ );
276
+ if (result.status !== 0
277
+ && !/not found|not running|unknown alias|\balias\b[^\r\n]{0,160}\bis not known\b/i.test(
278
+ `${result.stdout}\n${result.stderr}`,
279
+ )) {
280
+ throw new Error(`Failed to stop tunnel runtime: ${result.stderr.trim() || result.stdout.trim()}`);
281
+ }
282
+ }
283
+
284
+ export interface TunnelRuntimeStatus {
285
+ ok: boolean;
286
+ processRunning: boolean;
287
+ healthy: boolean;
288
+ ready: boolean;
289
+ state?: string;
290
+ detail: string;
291
+ }
292
+
293
+ export function tunnelCommandOutput(result: {
294
+ status: number;
295
+ stdout: string;
296
+ stderr: string;
297
+ }): string {
298
+ const stdout = result.stdout.trim();
299
+ const stderr = result.stderr.trim();
300
+ return result.status === 0
301
+ ? (stdout || stderr)
302
+ : [stderr, stdout].filter(Boolean).join("\n");
303
+ }
304
+
305
+ function safeTunnelDetail(value: unknown): string {
306
+ const text = typeof value === "string" ? value : JSON.stringify(value);
307
+ return text
308
+ .replace(/tunnel_[a-f0-9]{32}/g, "[tunnel-id]")
309
+ .replace(/sk-[A-Za-z0-9_-]{12,}/g, "[redacted-key]")
310
+ .slice(0, 2_000);
311
+ }
312
+
313
+ function nestedRecord(value: unknown, key: string): Record<string, unknown> | undefined {
314
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
315
+ const nested = (value as Record<string, unknown>)[key];
316
+ return nested && typeof nested === "object" && !Array.isArray(nested)
317
+ ? nested as Record<string, unknown>
318
+ : undefined;
319
+ }
320
+
321
+ function runtimeLogTail(parsed: Record<string, unknown>): string | undefined {
322
+ const launchTail = nestedRecord(parsed, "launch_diagnostics")?.log_tail;
323
+ if (typeof launchTail === "string" && launchTail.trim()) return launchTail.trim();
324
+ const statusTail = nestedRecord(nestedRecord(parsed, "local"), "log")?.tail;
325
+ return typeof statusTail === "string" && statusTail.trim() ? statusTail.trim() : undefined;
326
+ }
327
+
328
+ export function tunnelConnectLaunchError(output: string): string | undefined {
329
+ let parsed: Record<string, unknown>;
330
+ try {
331
+ parsed = JSON.parse(output) as Record<string, unknown>;
332
+ } catch {
333
+ return "tunnel-client returned non-JSON connect output";
334
+ }
335
+ const running = parsed.running === true;
336
+ const healthy = parsed.healthy === true;
337
+ const ready = parsed.ready === true;
338
+ if (running && healthy) return undefined;
339
+ const diagnostics = nestedRecord(parsed, "launch_diagnostics");
340
+ const exitCode = typeof parsed.exit_code === "number" ? parsed.exit_code
341
+ : typeof diagnostics?.exit_code === "number" ? diagnostics.exit_code
342
+ : undefined;
343
+ const remoteError = typeof parsed.remote_error === "string" && parsed.remote_error.trim()
344
+ ? parsed.remote_error.trim()
345
+ : undefined;
346
+ const logTail = runtimeLogTail(parsed);
347
+ return safeTunnelDetail([
348
+ `running=${running}`,
349
+ `healthy=${healthy}`,
350
+ `ready=${ready}`,
351
+ ...(exitCode !== undefined ? [`exit_code=${exitCode}`] : []),
352
+ ...(remoteError ? [`remote_error=${remoteError}`] : []),
353
+ ...(logTail ? [`runtime_log=${logTail}`] : []),
354
+ ...(!remoteError && !logTail ? ["runtime did not complete a healthy launch"] : []),
355
+ ].join("; "));
356
+ }
357
+
358
+ export function parseTunnelStatus(output: string, exitStatus = 0): TunnelRuntimeStatus {
359
+ if (exitStatus !== 0) {
360
+ return { ok: false, processRunning: false, healthy: false, ready: false, detail: safeTunnelDetail(output) };
361
+ }
362
+ try {
363
+ const parsed = JSON.parse(output) as Record<string, unknown>;
364
+ const processRunning = parsed.process_running === true;
365
+ const healthy = parsed.healthy === true;
366
+ const ready = parsed.ready === true;
367
+ const state = typeof parsed.runtime_state === "string" ? parsed.runtime_state
368
+ : typeof parsed.status === "string" ? parsed.status
369
+ : undefined;
370
+ const issues = parsed.local && typeof parsed.local === "object" && Array.isArray((parsed.local as { issues?: unknown }).issues)
371
+ ? ((parsed.local as { issues: unknown[] }).issues).filter(issue => typeof issue === "string").slice(0, 3)
372
+ : [];
373
+ const explicitError = typeof parsed.error === "string" && parsed.error ? parsed.error : undefined;
374
+ const logTail = runtimeLogTail(parsed);
375
+ const ok = processRunning && healthy && ready;
376
+ const detail = ok
377
+ ? "process_running=true healthy=true ready=true"
378
+ : safeTunnelDetail([
379
+ `process_running=${processRunning}`,
380
+ `healthy=${healthy}`,
381
+ `ready=${ready}`,
382
+ ...(state ? [`state=${state}`] : []),
383
+ ...(explicitError ? [explicitError] : []),
384
+ ...issues,
385
+ ...(logTail ? [`runtime_log=${logTail}`] : []),
386
+ ].join("; "));
387
+ return { ok, processRunning, healthy, ready, ...(state ? { state } : {}), detail };
388
+ } catch {
389
+ return { ok: false, processRunning: false, healthy: false, ready: false, detail: `tunnel-client returned non-JSON status: ${safeTunnelDetail(output)}` };
390
+ }
391
+ }
392
+
393
+ export function tunnelStatus(config: AppConfig): TunnelRuntimeStatus {
394
+ const settings = tunnel(config);
395
+ if (!existsSync(settings.binaryPath)) {
396
+ return { ok: false, processRunning: false, healthy: false, ready: false, detail: `Missing ${settings.binaryPath}` };
397
+ }
398
+ const result = runCommand(
399
+ settings.binaryPath,
400
+ ["runtimes", "status", settings.alias, "--json"],
401
+ { timeout: 10_000 },
402
+ );
403
+ return parseTunnelStatus(tunnelCommandOutput(result), result.status);
404
+ }
405
+
406
+ export async function waitForTunnelReady(
407
+ config: AppConfig,
408
+ timeoutMs = TUNNEL_READY_TIMEOUT_MS,
409
+ ): Promise<TunnelRuntimeStatus> {
410
+ const deadline = Date.now() + timeoutMs;
411
+ let status = tunnelStatus(config);
412
+ while (!status.ok && Date.now() < deadline) {
413
+ await new Promise(resolveWait => setTimeout(resolveWait, TUNNEL_STATUS_POLL_INTERVAL_MS));
414
+ status = tunnelStatus(config);
415
+ }
416
+ return status;
417
+ }
@@ -0,0 +1,5 @@
1
+ declare module "turndown-plugin-gfm" {
2
+ import TurndownService = require("turndown");
3
+
4
+ export const gfm: TurndownService.Plugin;
5
+ }