@easonwumac/computer-linker 0.1.2
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.
- package/CHANGELOG.md +230 -0
- package/LICENSE +21 -0
- package/README.md +539 -0
- package/SECURITY.md +48 -0
- package/dist/api.d.ts +2 -0
- package/dist/api.js +360 -0
- package/dist/audit.d.ts +70 -0
- package/dist/audit.js +102 -0
- package/dist/capabilities.d.ts +98 -0
- package/dist/capabilities.js +718 -0
- package/dist/capability-policy.d.ts +22 -0
- package/dist/capability-policy.js +103 -0
- package/dist/chatgpt.d.ts +167 -0
- package/dist/chatgpt.js +561 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +4621 -0
- package/dist/client-smoke.d.ts +44 -0
- package/dist/client-smoke.js +639 -0
- package/dist/client.d.ts +217 -0
- package/dist/client.js +357 -0
- package/dist/codex-runs.d.ts +35 -0
- package/dist/codex-runs.js +66 -0
- package/dist/computer-contract.d.ts +33 -0
- package/dist/computer-contract.js +384 -0
- package/dist/computer-operation-registry.d.ts +45 -0
- package/dist/computer-operation-registry.js +179 -0
- package/dist/config-diagnostics.d.ts +11 -0
- package/dist/config-diagnostics.js +185 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +69 -0
- package/dist/history-insights.d.ts +132 -0
- package/dist/history-insights.js +457 -0
- package/dist/http-auth.d.ts +3 -0
- package/dist/http-auth.js +15 -0
- package/dist/mcp-surface.d.ts +5 -0
- package/dist/mcp-surface.js +25 -0
- package/dist/oauth-provider.d.ts +52 -0
- package/dist/oauth-provider.js +325 -0
- package/dist/package-metadata.d.ts +7 -0
- package/dist/package-metadata.js +24 -0
- package/dist/permissions.d.ts +43 -0
- package/dist/permissions.js +150 -0
- package/dist/platform-shell.d.ts +28 -0
- package/dist/platform-shell.js +124 -0
- package/dist/processes.d.ts +50 -0
- package/dist/processes.js +178 -0
- package/dist/profile.d.ts +159 -0
- package/dist/profile.js +416 -0
- package/dist/screenshot.d.ts +47 -0
- package/dist/screenshot.js +302 -0
- package/dist/search.d.ts +34 -0
- package/dist/search.js +340 -0
- package/dist/security.d.ts +10 -0
- package/dist/security.js +108 -0
- package/dist/sensitive-files.d.ts +4 -0
- package/dist/sensitive-files.js +96 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +713 -0
- package/dist/service.d.ts +125 -0
- package/dist/service.js +486 -0
- package/dist/sessions.d.ts +26 -0
- package/dist/sessions.js +34 -0
- package/dist/tunnels.d.ts +161 -0
- package/dist/tunnels.js +1243 -0
- package/dist/workspace-operations.d.ts +170 -0
- package/dist/workspace-operations.js +3219 -0
- package/dist/workspaces.d.ts +61 -0
- package/dist/workspaces.js +353 -0
- package/docs/agent-instructions.md +65 -0
- package/docs/alpha-evidence.example.json +54 -0
- package/docs/api-compatibility.md +56 -0
- package/docs/architecture.md +561 -0
- package/docs/chatgpt-setup.md +397 -0
- package/docs/client-recipes.md +98 -0
- package/docs/client-sdk.md +163 -0
- package/docs/computer-operation-v1.schema.json +143 -0
- package/docs/manual-test-plan.md +322 -0
- package/docs/product-spec.md +911 -0
- package/docs/release-checklist.md +285 -0
- package/docs/service-mode.md +99 -0
- package/examples/minimal-mcp-client.mjs +114 -0
- package/package.json +87 -0
package/dist/tunnels.js
ADDED
|
@@ -0,0 +1,1243 @@
|
|
|
1
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
import { configDir } from "./config.js";
|
|
7
|
+
import { executableCommand, windowsVerbatimArgumentsOption } from "./platform-shell.js";
|
|
8
|
+
export async function exposeWithTunnel(options) {
|
|
9
|
+
const child = getTunnelProvider(options.provider).expose(options);
|
|
10
|
+
child.on("exit", (code, signal) => {
|
|
11
|
+
if (signal)
|
|
12
|
+
process.exitCode = 0;
|
|
13
|
+
else
|
|
14
|
+
process.exitCode = code ?? 1;
|
|
15
|
+
});
|
|
16
|
+
await once(child, "exit");
|
|
17
|
+
}
|
|
18
|
+
export function tunnelCommand(options) {
|
|
19
|
+
return getTunnelProvider(options.provider).command(options);
|
|
20
|
+
}
|
|
21
|
+
export function tunnelDiagnostics(input) {
|
|
22
|
+
const providers = tunnelProviders.map((provider) => provider.status(input));
|
|
23
|
+
const effectivePublicUrl = providers
|
|
24
|
+
.find((provider) => provider.publicUrlSource === "running-tunnel")?.publicUrl
|
|
25
|
+
?? input.publicBaseUrl;
|
|
26
|
+
return {
|
|
27
|
+
tools: tunnelProviders.map((provider) => provider.detect()),
|
|
28
|
+
commands: providers.flatMap((provider) => provider.commands),
|
|
29
|
+
providerContracts: tunnelProviderContracts(input.localPort),
|
|
30
|
+
providers,
|
|
31
|
+
publicBaseUrlConfigured: Boolean(input.publicBaseUrl),
|
|
32
|
+
publicBaseUrl: input.publicBaseUrl,
|
|
33
|
+
effectivePublicUrl,
|
|
34
|
+
effectivePublicUrlSource: providers.some((provider) => provider.publicUrlSource === "running-tunnel")
|
|
35
|
+
? "running-tunnel"
|
|
36
|
+
: input.publicBaseUrl ? "configured" : undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function getTunnelProviders() {
|
|
40
|
+
return [...tunnelProviders];
|
|
41
|
+
}
|
|
42
|
+
export function getTunnelProvider(name) {
|
|
43
|
+
const provider = tunnelProviders.find((item) => item.name === name);
|
|
44
|
+
if (!provider)
|
|
45
|
+
throw new Error(`Unknown tunnel provider: ${name}`);
|
|
46
|
+
return provider;
|
|
47
|
+
}
|
|
48
|
+
export function tunnelProviderContracts(localPort) {
|
|
49
|
+
return tunnelProviders.map((provider) => ({
|
|
50
|
+
provider: provider.name,
|
|
51
|
+
modes: tunnelProviderModes(provider.name),
|
|
52
|
+
commands: tunnelProviderCommands(provider.name, localPort),
|
|
53
|
+
lifecycle: {
|
|
54
|
+
detect: true,
|
|
55
|
+
status: true,
|
|
56
|
+
expose: true,
|
|
57
|
+
getPublicUrl: true,
|
|
58
|
+
stop: true,
|
|
59
|
+
},
|
|
60
|
+
publicUrlSources: provider.name === "openai" ? [] : ["configured", "running-tunnel"],
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
function tunnelProviderModes(provider) {
|
|
64
|
+
if (provider === "cloudflare")
|
|
65
|
+
return ["quick-tunnel"];
|
|
66
|
+
if (provider === "openai")
|
|
67
|
+
return ["secure-mcp-tunnel"];
|
|
68
|
+
return ["funnel"];
|
|
69
|
+
}
|
|
70
|
+
function tunnelProviderCommands(provider, localPort) {
|
|
71
|
+
if (provider === "cloudflare")
|
|
72
|
+
return [getTunnelProvider(provider).command({ provider, localPort })];
|
|
73
|
+
if (provider === "openai") {
|
|
74
|
+
const tunnelId = configuredOpenAiTunnelId();
|
|
75
|
+
return tunnelId ? [getTunnelProvider(provider).command({ provider, localPort, openaiTunnelId: tunnelId })] : [];
|
|
76
|
+
}
|
|
77
|
+
return [
|
|
78
|
+
getTunnelProvider(provider).command({ provider, localPort, tailscaleMode: "funnel" }),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
const cloudflareTunnelProvider = {
|
|
82
|
+
name: "cloudflare",
|
|
83
|
+
detect: () => commandStatus("cloudflared", ["--version"]),
|
|
84
|
+
status(input) {
|
|
85
|
+
const tool = this.detect();
|
|
86
|
+
const running = runningTunnelForProvider("cloudflare", input);
|
|
87
|
+
return {
|
|
88
|
+
provider: "cloudflare",
|
|
89
|
+
available: tool.available,
|
|
90
|
+
publicUrl: this.getPublicUrl(input),
|
|
91
|
+
publicUrlSource: running?.publicUrl ? "running-tunnel" : input.publicBaseUrl ? "configured" : undefined,
|
|
92
|
+
running: Boolean(running),
|
|
93
|
+
runningProcessId: running?.id,
|
|
94
|
+
runningMode: running?.mode,
|
|
95
|
+
status: tool.version,
|
|
96
|
+
error: tool.error,
|
|
97
|
+
commands: [this.command({ provider: "cloudflare", localPort: input.localPort })],
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
command(options) {
|
|
101
|
+
const args = ["tunnel", "--url", `http://127.0.0.1:${options.localPort}`];
|
|
102
|
+
return {
|
|
103
|
+
provider: "cloudflare",
|
|
104
|
+
command: "cloudflared",
|
|
105
|
+
args,
|
|
106
|
+
display: ["cloudflared", ...args].join(" "),
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
expose(options) {
|
|
110
|
+
const command = this.command(options);
|
|
111
|
+
return spawnTunnelCommand(command, { stdio: "inherit" });
|
|
112
|
+
},
|
|
113
|
+
getPublicUrl(input) {
|
|
114
|
+
return runningTunnelForProvider("cloudflare", input)?.publicUrl ?? input.publicBaseUrl;
|
|
115
|
+
},
|
|
116
|
+
stop(process, signal = "SIGTERM") {
|
|
117
|
+
process.kill(signal);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const tailscaleTunnelProvider = {
|
|
121
|
+
name: "tailscale",
|
|
122
|
+
detect: tailscaleStatus,
|
|
123
|
+
status(input) {
|
|
124
|
+
const tool = this.detect();
|
|
125
|
+
const running = runningTunnelForProvider("tailscale", input);
|
|
126
|
+
return {
|
|
127
|
+
provider: "tailscale",
|
|
128
|
+
available: tool.available,
|
|
129
|
+
publicUrl: this.getPublicUrl(input),
|
|
130
|
+
publicUrlSource: running?.publicUrl ? "running-tunnel" : input.publicBaseUrl ? "configured" : undefined,
|
|
131
|
+
running: Boolean(running),
|
|
132
|
+
runningProcessId: running?.id,
|
|
133
|
+
runningMode: running?.mode,
|
|
134
|
+
status: tool.status ?? tool.version,
|
|
135
|
+
error: tool.error,
|
|
136
|
+
commands: [
|
|
137
|
+
this.command({ provider: "tailscale", localPort: input.localPort, tailscaleMode: "funnel" }),
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
command(options) {
|
|
142
|
+
const mode = options.tailscaleMode ?? "funnel";
|
|
143
|
+
const args = mode === "funnel"
|
|
144
|
+
? ["funnel", "--yes", String(options.localPort)]
|
|
145
|
+
: ["serve", `localhost:${options.localPort}`];
|
|
146
|
+
return {
|
|
147
|
+
provider: "tailscale",
|
|
148
|
+
mode,
|
|
149
|
+
command: "tailscale",
|
|
150
|
+
args,
|
|
151
|
+
display: ["tailscale", ...args].join(" "),
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
expose(options) {
|
|
155
|
+
const command = this.command(options);
|
|
156
|
+
return spawnTunnelCommand(command, { stdio: "inherit" });
|
|
157
|
+
},
|
|
158
|
+
getPublicUrl(input) {
|
|
159
|
+
const running = runningTunnelForProvider("tailscale", input);
|
|
160
|
+
return running?.publicUrl ?? (running ? detectTailscalePublicUrl() : undefined) ?? input.publicBaseUrl;
|
|
161
|
+
},
|
|
162
|
+
stop(process, signal = "SIGTERM") {
|
|
163
|
+
process.kill(signal);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const openAiTunnelProvider = {
|
|
167
|
+
name: "openai",
|
|
168
|
+
detect: openAiTunnelClientStatus,
|
|
169
|
+
status(input) {
|
|
170
|
+
const tool = this.detect();
|
|
171
|
+
const running = runningTunnelForProvider("openai", input);
|
|
172
|
+
return {
|
|
173
|
+
provider: "openai",
|
|
174
|
+
available: tool.available,
|
|
175
|
+
running: Boolean(running),
|
|
176
|
+
runningProcessId: running?.id,
|
|
177
|
+
status: tool.version ?? tool.status,
|
|
178
|
+
error: tool.error,
|
|
179
|
+
commands: (input.openaiTunnelId ?? configuredOpenAiTunnelId())
|
|
180
|
+
? [this.command({
|
|
181
|
+
provider: "openai",
|
|
182
|
+
localPort: input.localPort,
|
|
183
|
+
openaiTunnelId: input.openaiTunnelId,
|
|
184
|
+
openaiClientPath: input.openaiClientPath,
|
|
185
|
+
ownerToken: input.ownerToken,
|
|
186
|
+
})]
|
|
187
|
+
: [],
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
command(options) {
|
|
191
|
+
const tunnelId = openAiTunnelIdFromOptions(options);
|
|
192
|
+
const command = openAiTunnelClientCommand(options.openaiClientPath);
|
|
193
|
+
const localMcpUrl = `http://127.0.0.1:${options.localPort}/mcp`;
|
|
194
|
+
const runtimeDir = openAiTunnelRuntimeDir();
|
|
195
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
196
|
+
const args = [
|
|
197
|
+
"run",
|
|
198
|
+
"--control-plane.tunnel-id",
|
|
199
|
+
tunnelId,
|
|
200
|
+
"--mcp.server-url",
|
|
201
|
+
`url=${localMcpUrl}`,
|
|
202
|
+
"--mcp.extra-headers",
|
|
203
|
+
"Authorization: env:COMPUTER_LINKER_MCP_AUTHORIZATION",
|
|
204
|
+
"--health.listen-addr",
|
|
205
|
+
"127.0.0.1:0",
|
|
206
|
+
"--health.url-file",
|
|
207
|
+
openAiTunnelHealthUrlFile(tunnelId),
|
|
208
|
+
"--pid.file",
|
|
209
|
+
openAiTunnelPidFile(tunnelId),
|
|
210
|
+
];
|
|
211
|
+
return {
|
|
212
|
+
provider: "openai",
|
|
213
|
+
command,
|
|
214
|
+
args,
|
|
215
|
+
display: ["tunnel-client", ...args].join(" "),
|
|
216
|
+
env: options.ownerToken ? { COMPUTER_LINKER_MCP_AUTHORIZATION: `Bearer ${options.ownerToken}` } : undefined,
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
expose(options) {
|
|
220
|
+
const command = this.command(options);
|
|
221
|
+
return spawnTunnelCommand(command, { stdio: "inherit" });
|
|
222
|
+
},
|
|
223
|
+
getPublicUrl() {
|
|
224
|
+
return undefined;
|
|
225
|
+
},
|
|
226
|
+
stop(process, signal = "SIGTERM") {
|
|
227
|
+
process.kill(signal);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
const tunnelProviders = [
|
|
231
|
+
cloudflareTunnelProvider,
|
|
232
|
+
tailscaleTunnelProvider,
|
|
233
|
+
openAiTunnelProvider,
|
|
234
|
+
];
|
|
235
|
+
function spawnTunnelCommand(command, options) {
|
|
236
|
+
const invocation = executableCommand(command.command, command.args);
|
|
237
|
+
return spawn(invocation.command, invocation.args, {
|
|
238
|
+
stdio: options.stdio,
|
|
239
|
+
env: command.env ? { ...process.env, ...command.env } : process.env,
|
|
240
|
+
windowsHide: true,
|
|
241
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
function commandStatus(name, args, commandName = name) {
|
|
245
|
+
try {
|
|
246
|
+
const command = executableCommand(commandName, args);
|
|
247
|
+
const output = execFileSync(command.command, command.args, {
|
|
248
|
+
encoding: "utf8",
|
|
249
|
+
timeout: 1500,
|
|
250
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
251
|
+
...windowsVerbatimArgumentsOption(command),
|
|
252
|
+
}).trim();
|
|
253
|
+
return {
|
|
254
|
+
name,
|
|
255
|
+
available: true,
|
|
256
|
+
version: firstLine(output),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
return {
|
|
261
|
+
name,
|
|
262
|
+
available: false,
|
|
263
|
+
error: error instanceof Error ? error.message : String(error),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const openAiTunnelClientRepositoryApi = "https://api.github.com/repos/openai/tunnel-client/releases/latest";
|
|
268
|
+
const openAiTunnelClientOverrideEnv = "COMPUTER_LINKER_OPENAI_TUNNEL_CLIENT";
|
|
269
|
+
const openAiTunnelIdEnv = "COMPUTER_LINKER_OPENAI_TUNNEL_ID";
|
|
270
|
+
const legacyOpenAiTunnelClientOverrideEnv = "WORKSPACE_LINKER_OPENAI_TUNNEL_CLIENT";
|
|
271
|
+
const legacyOpenAiTunnelIdEnv = "WORKSPACE_LINKER_OPENAI_TUNNEL_ID";
|
|
272
|
+
export async function ensureOpenAiTunnelClientInstalled(options = {}) {
|
|
273
|
+
const override = normalizeOptionalPath(options.clientPath)
|
|
274
|
+
?? normalizeOptionalPath(process.env[openAiTunnelClientOverrideEnv])
|
|
275
|
+
?? normalizeOptionalPath(process.env[legacyOpenAiTunnelClientOverrideEnv]);
|
|
276
|
+
if (override) {
|
|
277
|
+
if (!existsSync(override)) {
|
|
278
|
+
throw new Error(`OpenAI tunnel-client override does not exist: ${override}`);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
path: override,
|
|
282
|
+
source: "override",
|
|
283
|
+
version: readTunnelClientVersion(override),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const managedPath = openAiTunnelClientManagedPath();
|
|
287
|
+
if (existsSync(managedPath)) {
|
|
288
|
+
return {
|
|
289
|
+
path: managedPath,
|
|
290
|
+
source: "managed",
|
|
291
|
+
version: readTunnelClientVersion(managedPath),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const release = await fetchOpenAiTunnelClientLatestRelease();
|
|
295
|
+
const target = openAiTunnelClientTarget();
|
|
296
|
+
const assetSuffix = `${target.os}-${target.arch}.zip`;
|
|
297
|
+
const asset = release.assets.find((item) => item.name.endsWith(assetSuffix) && item.name.startsWith("tunnel-client-"));
|
|
298
|
+
const sumsAsset = release.assets.find((item) => item.name === "SHA256SUMS.txt");
|
|
299
|
+
if (!asset) {
|
|
300
|
+
throw new Error(`OpenAI tunnel-client release ${release.tag_name} does not include an asset for ${assetSuffix}`);
|
|
301
|
+
}
|
|
302
|
+
if (!sumsAsset) {
|
|
303
|
+
throw new Error(`OpenAI tunnel-client release ${release.tag_name} does not include SHA256SUMS.txt`);
|
|
304
|
+
}
|
|
305
|
+
const [archive, sha256Sums] = await Promise.all([
|
|
306
|
+
fetchBinary(asset.browser_download_url),
|
|
307
|
+
fetchText(sumsAsset.browser_download_url),
|
|
308
|
+
]);
|
|
309
|
+
const expectedSha256 = sha256FromSums(sha256Sums, asset.name);
|
|
310
|
+
if (!expectedSha256) {
|
|
311
|
+
throw new Error(`SHA256SUMS.txt does not include ${asset.name}`);
|
|
312
|
+
}
|
|
313
|
+
const actualSha256 = createHash("sha256").update(archive).digest("hex");
|
|
314
|
+
if (actualSha256.toLowerCase() !== expectedSha256.toLowerCase()) {
|
|
315
|
+
throw new Error(`OpenAI tunnel-client checksum mismatch for ${asset.name}`);
|
|
316
|
+
}
|
|
317
|
+
const toolsDir = openAiTunnelClientToolsDir();
|
|
318
|
+
const downloadDir = join(toolsDir, "downloads");
|
|
319
|
+
const extractDir = join(toolsDir, "extract");
|
|
320
|
+
mkdirSync(downloadDir, { recursive: true });
|
|
321
|
+
rmSync(extractDir, { recursive: true, force: true });
|
|
322
|
+
mkdirSync(extractDir, { recursive: true });
|
|
323
|
+
const archivePath = join(downloadDir, asset.name);
|
|
324
|
+
writeFileSync(archivePath, archive, { mode: 0o600 });
|
|
325
|
+
extractZipArchive(archivePath, extractDir);
|
|
326
|
+
const extractedBinary = findOpenAiTunnelClientBinary(extractDir);
|
|
327
|
+
if (!extractedBinary) {
|
|
328
|
+
throw new Error(`OpenAI tunnel-client archive did not contain ${openAiTunnelClientBinaryName()}`);
|
|
329
|
+
}
|
|
330
|
+
mkdirSync(toolsDir, { recursive: true });
|
|
331
|
+
copyFileSync(extractedBinary, managedPath);
|
|
332
|
+
if (process.platform !== "win32")
|
|
333
|
+
chmodSync(managedPath, 0o755);
|
|
334
|
+
writeFileSync(join(toolsDir, "release.json"), `${JSON.stringify({
|
|
335
|
+
tag: release.tag_name,
|
|
336
|
+
releaseUrl: release.html_url,
|
|
337
|
+
assetName: asset.name,
|
|
338
|
+
sha256: actualSha256,
|
|
339
|
+
installedAt: new Date().toISOString(),
|
|
340
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
341
|
+
rmSync(extractDir, { recursive: true, force: true });
|
|
342
|
+
return {
|
|
343
|
+
path: managedPath,
|
|
344
|
+
source: "downloaded",
|
|
345
|
+
version: readTunnelClientVersion(managedPath),
|
|
346
|
+
releaseTag: release.tag_name,
|
|
347
|
+
releaseUrl: release.html_url,
|
|
348
|
+
assetName: asset.name,
|
|
349
|
+
sha256: actualSha256,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
export function openAiTunnelClientManagedPath() {
|
|
353
|
+
return join(openAiTunnelClientToolsDir(), openAiTunnelClientBinaryName());
|
|
354
|
+
}
|
|
355
|
+
export function configuredOpenAiTunnelId() {
|
|
356
|
+
const value = process.env[openAiTunnelIdEnv]?.trim()
|
|
357
|
+
|| process.env[legacyOpenAiTunnelIdEnv]?.trim();
|
|
358
|
+
return value || undefined;
|
|
359
|
+
}
|
|
360
|
+
export function openAiTunnelHealthUrlFile(tunnelId) {
|
|
361
|
+
return join(openAiTunnelRuntimeDir(), `${safeFilename(tunnelId)}.health.url`);
|
|
362
|
+
}
|
|
363
|
+
function openAiTunnelPidFile(tunnelId) {
|
|
364
|
+
return join(openAiTunnelRuntimeDir(), `${safeFilename(tunnelId)}.pid`);
|
|
365
|
+
}
|
|
366
|
+
function openAiTunnelClientStatus() {
|
|
367
|
+
const candidates = [
|
|
368
|
+
normalizeOptionalPath(process.env[openAiTunnelClientOverrideEnv]),
|
|
369
|
+
normalizeOptionalPath(process.env[legacyOpenAiTunnelClientOverrideEnv]),
|
|
370
|
+
existsSync(openAiTunnelClientManagedPath()) ? openAiTunnelClientManagedPath() : undefined,
|
|
371
|
+
"tunnel-client",
|
|
372
|
+
].filter((item) => Boolean(item));
|
|
373
|
+
let lastError;
|
|
374
|
+
for (const candidate of candidates) {
|
|
375
|
+
const status = commandStatus("tunnel-client", ["--version"], candidate);
|
|
376
|
+
if (status.available)
|
|
377
|
+
return status;
|
|
378
|
+
lastError = status.error;
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
name: "tunnel-client",
|
|
382
|
+
available: false,
|
|
383
|
+
error: lastError ?? "OpenAI tunnel-client is not installed; `computer-linker start --tunnel openai` can download the official release.",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function openAiTunnelClientCommand(clientPath) {
|
|
387
|
+
const override = normalizeOptionalPath(clientPath)
|
|
388
|
+
?? normalizeOptionalPath(process.env[openAiTunnelClientOverrideEnv])
|
|
389
|
+
?? normalizeOptionalPath(process.env[legacyOpenAiTunnelClientOverrideEnv]);
|
|
390
|
+
if (override)
|
|
391
|
+
return override;
|
|
392
|
+
const managedPath = openAiTunnelClientManagedPath();
|
|
393
|
+
return existsSync(managedPath) ? managedPath : "tunnel-client";
|
|
394
|
+
}
|
|
395
|
+
function openAiTunnelIdFromOptions(options) {
|
|
396
|
+
const tunnelId = options.openaiTunnelId ?? configuredOpenAiTunnelId();
|
|
397
|
+
if (!tunnelId) {
|
|
398
|
+
throw new Error(`OpenAI tunnel id is required. Pass --tunnel-id tunnel_... or set ${openAiTunnelIdEnv}.`);
|
|
399
|
+
}
|
|
400
|
+
return tunnelId;
|
|
401
|
+
}
|
|
402
|
+
function openAiTunnelClientToolsDir() {
|
|
403
|
+
return join(configDir(), "tools", "openai-tunnel-client");
|
|
404
|
+
}
|
|
405
|
+
function openAiTunnelRuntimeDir() {
|
|
406
|
+
return join(configDir(), "openai-tunnel");
|
|
407
|
+
}
|
|
408
|
+
function openAiTunnelClientBinaryName() {
|
|
409
|
+
return process.platform === "win32" ? "tunnel-client.exe" : "tunnel-client";
|
|
410
|
+
}
|
|
411
|
+
function openAiTunnelClientTarget() {
|
|
412
|
+
const os = process.platform === "win32"
|
|
413
|
+
? "windows"
|
|
414
|
+
: process.platform === "darwin"
|
|
415
|
+
? "darwin"
|
|
416
|
+
: process.platform === "linux"
|
|
417
|
+
? "linux"
|
|
418
|
+
: undefined;
|
|
419
|
+
const arch = process.arch === "x64"
|
|
420
|
+
? "amd64"
|
|
421
|
+
: process.arch === "arm64"
|
|
422
|
+
? "arm64"
|
|
423
|
+
: undefined;
|
|
424
|
+
if (!os || !arch) {
|
|
425
|
+
throw new Error(`OpenAI tunnel-client auto-download is not supported on ${process.platform}/${process.arch}`);
|
|
426
|
+
}
|
|
427
|
+
return { os, arch };
|
|
428
|
+
}
|
|
429
|
+
async function fetchOpenAiTunnelClientLatestRelease() {
|
|
430
|
+
const response = await fetch(openAiTunnelClientRepositoryApi, {
|
|
431
|
+
headers: {
|
|
432
|
+
"Accept": "application/vnd.github+json",
|
|
433
|
+
"User-Agent": "computer-linker",
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
if (!response.ok) {
|
|
437
|
+
throw new Error(`Failed to query OpenAI tunnel-client latest release: HTTP ${response.status}`);
|
|
438
|
+
}
|
|
439
|
+
const release = await response.json();
|
|
440
|
+
if (!release.tag_name || !Array.isArray(release.assets)) {
|
|
441
|
+
throw new Error("OpenAI tunnel-client latest release response is missing expected fields");
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
tag_name: release.tag_name,
|
|
445
|
+
html_url: typeof release.html_url === "string" ? release.html_url : undefined,
|
|
446
|
+
assets: release.assets.flatMap((asset) => (asset && typeof asset === "object" && typeof asset.name === "string" && typeof asset.browser_download_url === "string"
|
|
447
|
+
? [{ name: asset.name, browser_download_url: asset.browser_download_url }]
|
|
448
|
+
: [])),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
async function fetchBinary(url) {
|
|
452
|
+
const response = await fetch(url, {
|
|
453
|
+
headers: {
|
|
454
|
+
"Accept": "application/octet-stream",
|
|
455
|
+
"User-Agent": "computer-linker",
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
if (!response.ok) {
|
|
459
|
+
throw new Error(`Failed to download ${url}: HTTP ${response.status}`);
|
|
460
|
+
}
|
|
461
|
+
return Buffer.from(await response.arrayBuffer());
|
|
462
|
+
}
|
|
463
|
+
async function fetchText(url) {
|
|
464
|
+
const response = await fetch(url, {
|
|
465
|
+
headers: {
|
|
466
|
+
"Accept": "text/plain",
|
|
467
|
+
"User-Agent": "computer-linker",
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
if (!response.ok) {
|
|
471
|
+
throw new Error(`Failed to download ${url}: HTTP ${response.status}`);
|
|
472
|
+
}
|
|
473
|
+
return response.text();
|
|
474
|
+
}
|
|
475
|
+
function sha256FromSums(text, assetName) {
|
|
476
|
+
const escapedName = assetName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
477
|
+
const pattern = new RegExp(`^([a-f0-9]{64})\\s+\\*?${escapedName}$`, "im");
|
|
478
|
+
return text.match(pattern)?.[1];
|
|
479
|
+
}
|
|
480
|
+
function extractZipArchive(archivePath, extractDir) {
|
|
481
|
+
const tar = spawnSync("tar", ["-xf", archivePath, "-C", extractDir], {
|
|
482
|
+
encoding: "utf8",
|
|
483
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
484
|
+
});
|
|
485
|
+
if (tar.status === 0)
|
|
486
|
+
return;
|
|
487
|
+
const unzip = spawnSync("unzip", ["-q", archivePath, "-d", extractDir], {
|
|
488
|
+
encoding: "utf8",
|
|
489
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
490
|
+
});
|
|
491
|
+
if (unzip.status === 0)
|
|
492
|
+
return;
|
|
493
|
+
if (process.platform === "win32") {
|
|
494
|
+
const powershell = spawnSync("powershell.exe", [
|
|
495
|
+
"-NoProfile",
|
|
496
|
+
"-Command",
|
|
497
|
+
"Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force",
|
|
498
|
+
archivePath,
|
|
499
|
+
extractDir,
|
|
500
|
+
], {
|
|
501
|
+
encoding: "utf8",
|
|
502
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
503
|
+
});
|
|
504
|
+
if (powershell.status === 0)
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
throw new Error(`Failed to extract ${basename(archivePath)}. tar: ${tar.stderr || tar.stdout} unzip: ${unzip.stderr || unzip.stdout}`);
|
|
508
|
+
}
|
|
509
|
+
function findOpenAiTunnelClientBinary(directory) {
|
|
510
|
+
for (const entry of readdirSync(directory)) {
|
|
511
|
+
const path = join(directory, entry);
|
|
512
|
+
const stat = statSync(path);
|
|
513
|
+
if (stat.isDirectory()) {
|
|
514
|
+
const nested = findOpenAiTunnelClientBinary(path);
|
|
515
|
+
if (nested)
|
|
516
|
+
return nested;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (entry === openAiTunnelClientBinaryName())
|
|
520
|
+
return path;
|
|
521
|
+
}
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
function readTunnelClientVersion(commandName) {
|
|
525
|
+
const status = commandStatus("tunnel-client", ["--version"], commandName);
|
|
526
|
+
return status.version;
|
|
527
|
+
}
|
|
528
|
+
function normalizeOptionalPath(value) {
|
|
529
|
+
const trimmed = value?.trim();
|
|
530
|
+
return trimmed || undefined;
|
|
531
|
+
}
|
|
532
|
+
function safeFilename(value) {
|
|
533
|
+
return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || "tunnel";
|
|
534
|
+
}
|
|
535
|
+
function tailscaleStatus() {
|
|
536
|
+
const status = commandStatus("tailscale", ["version"]);
|
|
537
|
+
if (!status.available)
|
|
538
|
+
return status;
|
|
539
|
+
try {
|
|
540
|
+
const command = executableCommand("tailscale", ["serve", "status"]);
|
|
541
|
+
const output = execFileSync(command.command, command.args, {
|
|
542
|
+
encoding: "utf8",
|
|
543
|
+
timeout: 1500,
|
|
544
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
545
|
+
...windowsVerbatimArgumentsOption(command),
|
|
546
|
+
}).trim();
|
|
547
|
+
return {
|
|
548
|
+
...status,
|
|
549
|
+
status: output || "No Tailscale status reported.",
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
return {
|
|
554
|
+
...status,
|
|
555
|
+
status: "Tailscale status unavailable.",
|
|
556
|
+
error: error instanceof Error ? error.message : String(error),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function firstLine(value) {
|
|
561
|
+
return value.split(/\r?\n/)[0] ?? value;
|
|
562
|
+
}
|
|
563
|
+
function runningTunnelForProvider(provider, input) {
|
|
564
|
+
return input.tunnels?.find((tp) => tp.status === "running" && tp.provider === provider);
|
|
565
|
+
}
|
|
566
|
+
const tunnelProcesses = new Map();
|
|
567
|
+
const maxOutputBytes = 32 * 1024;
|
|
568
|
+
const maxPersistedTunnelEvents = 200;
|
|
569
|
+
const maxPersistedTunnelSnapshots = 50;
|
|
570
|
+
export function startTunnelProcess(options) {
|
|
571
|
+
const provider = getTunnelProvider(options.provider);
|
|
572
|
+
const cmd = provider.command(options);
|
|
573
|
+
const existing = findRunningTunnel(options);
|
|
574
|
+
if (existing)
|
|
575
|
+
return snapshotTunnel(existing);
|
|
576
|
+
const spawnCommand = executableCommand(cmd.command, cmd.args);
|
|
577
|
+
const child = spawn(spawnCommand.command, spawnCommand.args, {
|
|
578
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
579
|
+
detached: process.platform !== "win32",
|
|
580
|
+
env: cmd.env ? { ...process.env, ...cmd.env } : process.env,
|
|
581
|
+
windowsHide: true,
|
|
582
|
+
windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
|
|
583
|
+
});
|
|
584
|
+
const id = `tunnel_${randomUUID()}`;
|
|
585
|
+
const tunnelProcess = {
|
|
586
|
+
child,
|
|
587
|
+
id,
|
|
588
|
+
provider: cmd.provider,
|
|
589
|
+
mode: cmd.mode,
|
|
590
|
+
localPort: options.localPort,
|
|
591
|
+
command: cmd.command,
|
|
592
|
+
args: cmd.args,
|
|
593
|
+
display: cmd.display,
|
|
594
|
+
pid: child.pid,
|
|
595
|
+
startedAt: new Date().toISOString(),
|
|
596
|
+
status: "running",
|
|
597
|
+
exitCode: null,
|
|
598
|
+
stdout: "",
|
|
599
|
+
stderr: "",
|
|
600
|
+
events: [{
|
|
601
|
+
timestamp: new Date().toISOString(),
|
|
602
|
+
provider: cmd.provider,
|
|
603
|
+
tunnelId: id,
|
|
604
|
+
localPort: options.localPort,
|
|
605
|
+
pid: child.pid,
|
|
606
|
+
severity: "info",
|
|
607
|
+
kind: "process_started",
|
|
608
|
+
message: "tunnel process started",
|
|
609
|
+
}],
|
|
610
|
+
};
|
|
611
|
+
tunnelProcesses.set(id, tunnelProcess);
|
|
612
|
+
persistTunnelProcesses();
|
|
613
|
+
child.stdout?.on("data", (chunk) => {
|
|
614
|
+
tunnelProcess.stdout = appendBounded(tunnelProcess.stdout, chunk.toString("utf8"));
|
|
615
|
+
const url = extractPublicUrl(tunnelProcess.stdout, cmd.provider);
|
|
616
|
+
if (url)
|
|
617
|
+
tunnelProcess.publicUrl = url;
|
|
618
|
+
persistTunnelProcesses();
|
|
619
|
+
});
|
|
620
|
+
child.stderr?.on("data", (chunk) => {
|
|
621
|
+
tunnelProcess.stderr = appendBounded(tunnelProcess.stderr, chunk.toString("utf8"));
|
|
622
|
+
const url = extractPublicUrl(tunnelProcess.stderr, cmd.provider);
|
|
623
|
+
if (url)
|
|
624
|
+
tunnelProcess.publicUrl = url;
|
|
625
|
+
persistTunnelProcesses();
|
|
626
|
+
});
|
|
627
|
+
child.on("exit", (code, signal) => {
|
|
628
|
+
tunnelProcess.status = "exited";
|
|
629
|
+
tunnelProcess.exitCode = code;
|
|
630
|
+
tunnelProcess.signal = signal ?? undefined;
|
|
631
|
+
tunnelProcess.endedAt = new Date().toISOString();
|
|
632
|
+
persistTunnelProcesses();
|
|
633
|
+
});
|
|
634
|
+
child.on("error", (error) => {
|
|
635
|
+
tunnelProcess.status = "exited";
|
|
636
|
+
tunnelProcess.exitCode = null;
|
|
637
|
+
tunnelProcess.stderr = appendBounded(tunnelProcess.stderr, error.message);
|
|
638
|
+
tunnelProcess.endedAt = new Date().toISOString();
|
|
639
|
+
persistTunnelProcesses();
|
|
640
|
+
});
|
|
641
|
+
return snapshotTunnel(tunnelProcess);
|
|
642
|
+
}
|
|
643
|
+
export async function stopTunnelProcess(id, signal = "SIGTERM") {
|
|
644
|
+
const tp = tunnelProcesses.get(id);
|
|
645
|
+
if (!tp)
|
|
646
|
+
throw new Error(`Unknown tunnel process: ${id}`);
|
|
647
|
+
if (tp.status !== "running")
|
|
648
|
+
return snapshotTunnel(tp);
|
|
649
|
+
const normalizedSignal = signal === "SIGKILL" || signal === "SIGINT" || signal === "SIGTERM" ? signal : "SIGTERM";
|
|
650
|
+
terminateTunnelGroup(tp, normalizedSignal);
|
|
651
|
+
await waitForTunnelExit(tp, 500);
|
|
652
|
+
if (normalizedSignal !== "SIGKILL" && tp.status === "running") {
|
|
653
|
+
terminateTunnelGroup(tp, "SIGKILL");
|
|
654
|
+
await waitForTunnelExit(tp, 500);
|
|
655
|
+
}
|
|
656
|
+
persistTunnelProcesses();
|
|
657
|
+
return snapshotTunnel(tp);
|
|
658
|
+
}
|
|
659
|
+
export function listTunnelProcesses() {
|
|
660
|
+
return mergedTunnelSnapshots()
|
|
661
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
662
|
+
}
|
|
663
|
+
export function refreshTunnelPublicUrl(id) {
|
|
664
|
+
const tp = tunnelProcesses.get(id);
|
|
665
|
+
if (!tp)
|
|
666
|
+
return undefined;
|
|
667
|
+
const publicUrl = tp.publicUrl ?? detectTunnelPublicUrl(tp.provider);
|
|
668
|
+
if (publicUrl) {
|
|
669
|
+
tp.publicUrl = publicUrl;
|
|
670
|
+
persistTunnelProcesses();
|
|
671
|
+
}
|
|
672
|
+
return snapshotTunnel(tp);
|
|
673
|
+
}
|
|
674
|
+
export function stopAllTunnelProcesses(signal = "SIGTERM") {
|
|
675
|
+
const normalizedSignal = signal === "SIGKILL" || signal === "SIGINT" || signal === "SIGTERM" ? signal : "SIGTERM";
|
|
676
|
+
return Promise.all([...tunnelProcesses.values()]
|
|
677
|
+
.filter((tp) => tp.status === "running")
|
|
678
|
+
.map((tp) => stopTunnelProcess(tp.id, normalizedSignal)));
|
|
679
|
+
}
|
|
680
|
+
function findRunningTunnel(options) {
|
|
681
|
+
const expectedMode = options.provider === "tailscale" ? options.tailscaleMode ?? "funnel" : undefined;
|
|
682
|
+
return [...tunnelProcesses.values()].find((tp) => tp.status === "running" &&
|
|
683
|
+
tp.provider === options.provider &&
|
|
684
|
+
tp.localPort === options.localPort &&
|
|
685
|
+
tp.mode === expectedMode);
|
|
686
|
+
}
|
|
687
|
+
function snapshotTunnel(tp) {
|
|
688
|
+
const events = tunnelRuntimeEventsForSnapshot(tp, { includeInfo: true, limit: maxPersistedTunnelEvents });
|
|
689
|
+
const lastError = events.find((event) => event.severity !== "info");
|
|
690
|
+
const lastRecovery = events.find((event) => event.kind === "controlplane_recovered");
|
|
691
|
+
return {
|
|
692
|
+
id: tp.id,
|
|
693
|
+
provider: tp.provider,
|
|
694
|
+
mode: tp.mode,
|
|
695
|
+
localPort: tp.localPort,
|
|
696
|
+
command: tp.command,
|
|
697
|
+
args: tp.args,
|
|
698
|
+
display: tp.display,
|
|
699
|
+
pid: tp.pid,
|
|
700
|
+
startedAt: tp.startedAt,
|
|
701
|
+
endedAt: tp.endedAt,
|
|
702
|
+
status: tp.status,
|
|
703
|
+
exitCode: tp.exitCode,
|
|
704
|
+
signal: tp.signal,
|
|
705
|
+
stdout: tp.stdout,
|
|
706
|
+
stderr: tp.stderr,
|
|
707
|
+
publicUrl: tp.publicUrl,
|
|
708
|
+
events,
|
|
709
|
+
lastError: lastError?.detail ?? lastError?.message,
|
|
710
|
+
lastRecoveryAt: lastRecovery?.timestamp,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
function mergedTunnelSnapshots() {
|
|
714
|
+
const byId = new Map();
|
|
715
|
+
for (const snapshot of readPersistedTunnelSnapshots()) {
|
|
716
|
+
byId.set(snapshot.id, snapshot);
|
|
717
|
+
}
|
|
718
|
+
for (const process of tunnelProcesses.values()) {
|
|
719
|
+
byId.set(process.id, snapshotTunnel(process));
|
|
720
|
+
}
|
|
721
|
+
if (byId.size === 0)
|
|
722
|
+
return [];
|
|
723
|
+
return refreshPersistedTunnelSnapshots([...byId.values()]);
|
|
724
|
+
}
|
|
725
|
+
function tunnelStatePath() {
|
|
726
|
+
return join(configDir(), "tunnels.json");
|
|
727
|
+
}
|
|
728
|
+
function readPersistedTunnelSnapshots() {
|
|
729
|
+
try {
|
|
730
|
+
if (!existsSync(tunnelStatePath()))
|
|
731
|
+
return [];
|
|
732
|
+
const parsed = JSON.parse(readFileSync(tunnelStatePath(), "utf8"));
|
|
733
|
+
if (!Array.isArray(parsed))
|
|
734
|
+
return [];
|
|
735
|
+
return parsed.flatMap((item) => parseTunnelSnapshot(item));
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
return [];
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function persistTunnelProcesses() {
|
|
742
|
+
const byId = new Map();
|
|
743
|
+
for (const snapshot of readPersistedTunnelSnapshots()) {
|
|
744
|
+
byId.set(snapshot.id, snapshot);
|
|
745
|
+
}
|
|
746
|
+
for (const process of tunnelProcesses.values()) {
|
|
747
|
+
byId.set(process.id, snapshotTunnel(process));
|
|
748
|
+
}
|
|
749
|
+
writePersistedTunnelSnapshots([...byId.values()]);
|
|
750
|
+
}
|
|
751
|
+
function refreshPersistedTunnelSnapshots(snapshots) {
|
|
752
|
+
const refreshed = snapshots.map((snapshot) => (snapshot.status === "running" && snapshot.pid && !isProcessAlive(snapshot.pid)
|
|
753
|
+
? {
|
|
754
|
+
...snapshot,
|
|
755
|
+
status: "exited",
|
|
756
|
+
exitCode: snapshot.exitCode,
|
|
757
|
+
endedAt: snapshot.endedAt ?? new Date().toISOString(),
|
|
758
|
+
}
|
|
759
|
+
: snapshot));
|
|
760
|
+
writePersistedTunnelSnapshots(refreshed);
|
|
761
|
+
return refreshed;
|
|
762
|
+
}
|
|
763
|
+
function writePersistedTunnelSnapshots(snapshots) {
|
|
764
|
+
try {
|
|
765
|
+
mkdirSync(configDir(), { recursive: true });
|
|
766
|
+
const sorted = snapshots
|
|
767
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
|
768
|
+
.slice(0, maxPersistedTunnelSnapshots)
|
|
769
|
+
.map(compactTunnelSnapshotForPersistence);
|
|
770
|
+
writeFileSync(tunnelStatePath(), `${JSON.stringify(sorted, null, 2)}\n`, { mode: 0o600 });
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// Tunnel snapshots are best-effort diagnostics; tunnel operation should continue without them.
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function parseTunnelSnapshot(value) {
|
|
777
|
+
if (!value || typeof value !== "object")
|
|
778
|
+
return [];
|
|
779
|
+
const item = value;
|
|
780
|
+
if (typeof item.id !== "string" ||
|
|
781
|
+
(item.provider !== "cloudflare" && item.provider !== "tailscale" && item.provider !== "openai") ||
|
|
782
|
+
typeof item.localPort !== "number" ||
|
|
783
|
+
typeof item.command !== "string" ||
|
|
784
|
+
!Array.isArray(item.args) ||
|
|
785
|
+
typeof item.display !== "string" ||
|
|
786
|
+
typeof item.startedAt !== "string" ||
|
|
787
|
+
(item.status !== "running" && item.status !== "exited")) {
|
|
788
|
+
return [];
|
|
789
|
+
}
|
|
790
|
+
return [{
|
|
791
|
+
id: item.id,
|
|
792
|
+
provider: item.provider,
|
|
793
|
+
mode: item.mode === "serve" || item.mode === "funnel" ? item.mode : undefined,
|
|
794
|
+
localPort: item.localPort,
|
|
795
|
+
command: item.command,
|
|
796
|
+
args: item.args.filter((arg) => typeof arg === "string"),
|
|
797
|
+
display: item.display,
|
|
798
|
+
pid: typeof item.pid === "number" ? item.pid : undefined,
|
|
799
|
+
startedAt: item.startedAt,
|
|
800
|
+
endedAt: typeof item.endedAt === "string" ? item.endedAt : undefined,
|
|
801
|
+
status: item.status,
|
|
802
|
+
exitCode: typeof item.exitCode === "number" ? item.exitCode : null,
|
|
803
|
+
signal: typeof item.signal === "string" ? item.signal : undefined,
|
|
804
|
+
stdout: typeof item.stdout === "string" ? item.stdout : "",
|
|
805
|
+
stderr: typeof item.stderr === "string" ? item.stderr : "",
|
|
806
|
+
publicUrl: typeof item.publicUrl === "string" ? item.publicUrl : undefined,
|
|
807
|
+
events: parseTunnelRuntimeEvents(item.events),
|
|
808
|
+
lastError: typeof item.lastError === "string" ? item.lastError : undefined,
|
|
809
|
+
lastRecoveryAt: typeof item.lastRecoveryAt === "string" ? item.lastRecoveryAt : undefined,
|
|
810
|
+
}];
|
|
811
|
+
}
|
|
812
|
+
function compactTunnelSnapshotForPersistence(snapshot) {
|
|
813
|
+
const events = tunnelRuntimeEventsForSnapshot(snapshot, { includeInfo: true, limit: maxPersistedTunnelEvents });
|
|
814
|
+
const lastError = events.find((event) => event.severity !== "info");
|
|
815
|
+
const lastRecovery = events.find((event) => event.kind === "controlplane_recovered");
|
|
816
|
+
return {
|
|
817
|
+
...snapshot,
|
|
818
|
+
stdout: appendBounded("", snapshot.stdout),
|
|
819
|
+
stderr: appendBounded("", snapshot.stderr),
|
|
820
|
+
events,
|
|
821
|
+
lastError: lastError?.detail ?? lastError?.message ?? snapshot.lastError,
|
|
822
|
+
lastRecoveryAt: lastRecovery?.timestamp ?? snapshot.lastRecoveryAt,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
export function tunnelRuntimeEvents(snapshots = listTunnelProcesses(), options = {}) {
|
|
826
|
+
const limit = normalizeTunnelEventLimit(options.limit);
|
|
827
|
+
const includeInfo = options.includeInfo ?? false;
|
|
828
|
+
const events = snapshots.flatMap((snapshot) => tunnelRuntimeEventsForSnapshot(snapshot, { includeInfo }));
|
|
829
|
+
return events
|
|
830
|
+
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
|
831
|
+
.slice(0, limit);
|
|
832
|
+
}
|
|
833
|
+
function tunnelRuntimeEventsForSnapshot(snapshot, options = {}) {
|
|
834
|
+
const includeInfo = options.includeInfo ?? false;
|
|
835
|
+
const byKey = new Map();
|
|
836
|
+
for (const event of parseTunnelRuntimeEvents(snapshot.events)) {
|
|
837
|
+
if (shouldIncludeTunnelEvent(event, includeInfo))
|
|
838
|
+
byKey.set(tunnelEventKey(event), event);
|
|
839
|
+
}
|
|
840
|
+
const parsed = [
|
|
841
|
+
...parseTunnelLogOutput(snapshot.stdout, snapshot, "stdout"),
|
|
842
|
+
...parseTunnelLogOutput(snapshot.stderr, snapshot, "stderr"),
|
|
843
|
+
];
|
|
844
|
+
for (const event of parsed) {
|
|
845
|
+
if (shouldIncludeTunnelEvent(event, includeInfo))
|
|
846
|
+
byKey.set(tunnelEventKey(event), event);
|
|
847
|
+
}
|
|
848
|
+
if (includeInfo) {
|
|
849
|
+
byKey.set(`started:${snapshot.id}`, {
|
|
850
|
+
timestamp: snapshot.startedAt,
|
|
851
|
+
provider: snapshot.provider,
|
|
852
|
+
tunnelId: snapshot.id,
|
|
853
|
+
localPort: snapshot.localPort,
|
|
854
|
+
pid: snapshot.pid,
|
|
855
|
+
severity: "info",
|
|
856
|
+
kind: "process_started",
|
|
857
|
+
message: "tunnel process started",
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
if (snapshot.endedAt) {
|
|
861
|
+
const exited = {
|
|
862
|
+
timestamp: snapshot.endedAt,
|
|
863
|
+
provider: snapshot.provider,
|
|
864
|
+
tunnelId: snapshot.id,
|
|
865
|
+
localPort: snapshot.localPort,
|
|
866
|
+
pid: snapshot.pid,
|
|
867
|
+
severity: snapshot.exitCode === 0 ? "info" : "warn",
|
|
868
|
+
kind: "process_exited",
|
|
869
|
+
message: "tunnel process exited",
|
|
870
|
+
detail: `exitCode=${snapshot.exitCode ?? snapshot.signal ?? "unknown"}`,
|
|
871
|
+
};
|
|
872
|
+
if (shouldIncludeTunnelEvent(exited, includeInfo))
|
|
873
|
+
byKey.set(`exited:${snapshot.id}`, exited);
|
|
874
|
+
}
|
|
875
|
+
return [...byKey.values()]
|
|
876
|
+
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
|
877
|
+
.slice(0, normalizeTunnelEventLimit(options.limit));
|
|
878
|
+
}
|
|
879
|
+
function parseTunnelRuntimeEvents(value) {
|
|
880
|
+
if (!Array.isArray(value))
|
|
881
|
+
return [];
|
|
882
|
+
return value.flatMap((item) => {
|
|
883
|
+
if (!item || typeof item !== "object")
|
|
884
|
+
return [];
|
|
885
|
+
const event = item;
|
|
886
|
+
if (typeof event.timestamp !== "string" ||
|
|
887
|
+
(event.provider !== "cloudflare" && event.provider !== "tailscale" && event.provider !== "openai") ||
|
|
888
|
+
typeof event.tunnelId !== "string" ||
|
|
889
|
+
!isTunnelRuntimeEventKind(event.kind) ||
|
|
890
|
+
(event.severity !== "info" && event.severity !== "warn" && event.severity !== "error") ||
|
|
891
|
+
typeof event.message !== "string") {
|
|
892
|
+
return [];
|
|
893
|
+
}
|
|
894
|
+
return [{
|
|
895
|
+
timestamp: event.timestamp,
|
|
896
|
+
provider: event.provider,
|
|
897
|
+
tunnelId: event.tunnelId,
|
|
898
|
+
localPort: typeof event.localPort === "number" ? event.localPort : undefined,
|
|
899
|
+
pid: typeof event.pid === "number" ? event.pid : undefined,
|
|
900
|
+
severity: event.severity,
|
|
901
|
+
kind: event.kind,
|
|
902
|
+
message: event.message,
|
|
903
|
+
detail: typeof event.detail === "string" ? event.detail : undefined,
|
|
904
|
+
statusCode: typeof event.statusCode === "number" ? event.statusCode : undefined,
|
|
905
|
+
status: typeof event.status === "string" ? event.status : undefined,
|
|
906
|
+
rpcMethod: typeof event.rpcMethod === "string" ? event.rpcMethod : undefined,
|
|
907
|
+
requestId: typeof event.requestId === "string" ? event.requestId : undefined,
|
|
908
|
+
cmdRequestId: typeof event.cmdRequestId === "string" ? event.cmdRequestId : undefined,
|
|
909
|
+
rpcRequestId: typeof event.rpcRequestId === "string" ? event.rpcRequestId : undefined,
|
|
910
|
+
sessionId: typeof event.sessionId === "string" ? event.sessionId : undefined,
|
|
911
|
+
tunnelRequestId: typeof event.tunnelRequestId === "string" ? event.tunnelRequestId : undefined,
|
|
912
|
+
}];
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
function isTunnelRuntimeEventKind(value) {
|
|
916
|
+
return value === "process_started" ||
|
|
917
|
+
value === "process_exited" ||
|
|
918
|
+
value === "dispatcher_forwarded" ||
|
|
919
|
+
value === "dispatcher_acknowledged" ||
|
|
920
|
+
value === "mcp_upstream_error" ||
|
|
921
|
+
value === "controlplane_poll_failed" ||
|
|
922
|
+
value === "controlplane_recovered";
|
|
923
|
+
}
|
|
924
|
+
function parseTunnelLogOutput(output, snapshot, source) {
|
|
925
|
+
if (!output)
|
|
926
|
+
return [];
|
|
927
|
+
return output
|
|
928
|
+
.split(/\r?\n/)
|
|
929
|
+
.flatMap((line) => parseTunnelLogLine(line, snapshot, source));
|
|
930
|
+
}
|
|
931
|
+
function parseTunnelLogLine(line, snapshot, source) {
|
|
932
|
+
const trimmed = line.trim();
|
|
933
|
+
if (!trimmed)
|
|
934
|
+
return [];
|
|
935
|
+
const match = /^(\d{4})\/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+([A-Z]+)\s+(.+)$/.exec(trimmed);
|
|
936
|
+
if (!match)
|
|
937
|
+
return [];
|
|
938
|
+
const attrs = parseTunnelLogAttributes(match[8] ?? "");
|
|
939
|
+
const attrStart = (match[8] ?? "").search(/\s+[A-Za-z_][A-Za-z0-9_]*=/);
|
|
940
|
+
const message = attrStart >= 0 ? (match[8] ?? "").slice(0, attrStart).trim() : (match[8] ?? "").trim();
|
|
941
|
+
const kind = tunnelRuntimeEventKindFromMessage(message);
|
|
942
|
+
if (!kind)
|
|
943
|
+
return [];
|
|
944
|
+
const timestamp = tunnelLogTimestampToIso(match);
|
|
945
|
+
const statusCode = parseOptionalInteger(attrs.status_code);
|
|
946
|
+
const detail = attrs.error ?? attrs.status ?? attrs.body ?? attrs.err ?? undefined;
|
|
947
|
+
const event = {
|
|
948
|
+
timestamp,
|
|
949
|
+
provider: snapshot.provider,
|
|
950
|
+
tunnelId: snapshot.id,
|
|
951
|
+
localPort: snapshot.localPort,
|
|
952
|
+
pid: snapshot.pid,
|
|
953
|
+
severity: tunnelEventSeverity(match[7] ?? "INFO", kind),
|
|
954
|
+
kind,
|
|
955
|
+
message: message || `${source} tunnel event`,
|
|
956
|
+
detail,
|
|
957
|
+
statusCode,
|
|
958
|
+
status: attrs.status,
|
|
959
|
+
rpcMethod: attrs.rpc_method ?? attrs.method,
|
|
960
|
+
requestId: attrs.request_id,
|
|
961
|
+
cmdRequestId: attrs.cmd_request_id,
|
|
962
|
+
rpcRequestId: attrs.rpc_request_id,
|
|
963
|
+
sessionId: attrs.session_id,
|
|
964
|
+
tunnelRequestId: attrs.tunnel_request_id,
|
|
965
|
+
};
|
|
966
|
+
return [event];
|
|
967
|
+
}
|
|
968
|
+
function tunnelLogTimestampToIso(match) {
|
|
969
|
+
const year = Number(match[1]);
|
|
970
|
+
const month = Number(match[2]);
|
|
971
|
+
const day = Number(match[3]);
|
|
972
|
+
const hour = Number(match[4]);
|
|
973
|
+
const minute = Number(match[5]);
|
|
974
|
+
const second = Number(match[6]);
|
|
975
|
+
if ([year, month, day, hour, minute, second].some((value) => !Number.isFinite(value))) {
|
|
976
|
+
return new Date().toISOString();
|
|
977
|
+
}
|
|
978
|
+
return new Date(year, month - 1, day, hour, minute, second).toISOString();
|
|
979
|
+
}
|
|
980
|
+
function parseTunnelLogAttributes(text) {
|
|
981
|
+
const attrs = {};
|
|
982
|
+
const pattern = /([A-Za-z_][A-Za-z0-9_]*)=(?:"((?:\\.|[^"\\])*)"|(\S+))/g;
|
|
983
|
+
let match;
|
|
984
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
985
|
+
const key = match[1];
|
|
986
|
+
if (!key)
|
|
987
|
+
continue;
|
|
988
|
+
attrs[key] = unescapeTunnelLogValue(match[2] ?? match[3] ?? "");
|
|
989
|
+
}
|
|
990
|
+
return attrs;
|
|
991
|
+
}
|
|
992
|
+
function unescapeTunnelLogValue(value) {
|
|
993
|
+
return value.replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
994
|
+
}
|
|
995
|
+
function tunnelRuntimeEventKindFromMessage(message) {
|
|
996
|
+
if (message.startsWith("dispatcher forwarded command to MCP server"))
|
|
997
|
+
return "dispatcher_forwarded";
|
|
998
|
+
if (message.startsWith("dispatcher acknowledged notification"))
|
|
999
|
+
return "dispatcher_acknowledged";
|
|
1000
|
+
if (message.startsWith("dispatcher received MCP upstream error"))
|
|
1001
|
+
return "mcp_upstream_error";
|
|
1002
|
+
if (message.startsWith("poll failed"))
|
|
1003
|
+
return "controlplane_poll_failed";
|
|
1004
|
+
if (message.startsWith("poller recovered"))
|
|
1005
|
+
return "controlplane_recovered";
|
|
1006
|
+
return undefined;
|
|
1007
|
+
}
|
|
1008
|
+
function tunnelEventSeverity(level, kind) {
|
|
1009
|
+
if (kind === "mcp_upstream_error")
|
|
1010
|
+
return "error";
|
|
1011
|
+
if (kind === "controlplane_poll_failed")
|
|
1012
|
+
return "warn";
|
|
1013
|
+
if (level === "ERROR" || level === "ERR")
|
|
1014
|
+
return "error";
|
|
1015
|
+
if (level === "WARN" || level === "WARNING")
|
|
1016
|
+
return "warn";
|
|
1017
|
+
return "info";
|
|
1018
|
+
}
|
|
1019
|
+
function shouldIncludeTunnelEvent(event, includeInfo) {
|
|
1020
|
+
if (includeInfo)
|
|
1021
|
+
return true;
|
|
1022
|
+
return event.severity !== "info" || event.kind === "controlplane_recovered";
|
|
1023
|
+
}
|
|
1024
|
+
function tunnelEventKey(event) {
|
|
1025
|
+
return [
|
|
1026
|
+
event.timestamp,
|
|
1027
|
+
event.provider,
|
|
1028
|
+
event.tunnelId,
|
|
1029
|
+
event.kind,
|
|
1030
|
+
event.requestId ?? "",
|
|
1031
|
+
event.cmdRequestId ?? "",
|
|
1032
|
+
event.rpcRequestId ?? "",
|
|
1033
|
+
event.sessionId ?? "",
|
|
1034
|
+
event.tunnelRequestId ?? "",
|
|
1035
|
+
event.detail ?? "",
|
|
1036
|
+
].join("|");
|
|
1037
|
+
}
|
|
1038
|
+
function normalizeTunnelEventLimit(value) {
|
|
1039
|
+
return Number.isInteger(value) && value && value > 0 ? Math.min(value, 1000) : 200;
|
|
1040
|
+
}
|
|
1041
|
+
function parseOptionalInteger(value) {
|
|
1042
|
+
if (!value)
|
|
1043
|
+
return undefined;
|
|
1044
|
+
const parsed = Number.parseInt(value, 10);
|
|
1045
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1046
|
+
}
|
|
1047
|
+
function isProcessAlive(pid) {
|
|
1048
|
+
try {
|
|
1049
|
+
process.kill(pid, 0);
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
function appendBounded(current, next) {
|
|
1057
|
+
let output = current + next;
|
|
1058
|
+
while (Buffer.byteLength(output, "utf8") > maxOutputBytes) {
|
|
1059
|
+
output = output.slice(Math.max(1, output.length - maxOutputBytes));
|
|
1060
|
+
}
|
|
1061
|
+
return output;
|
|
1062
|
+
}
|
|
1063
|
+
function extractPublicUrl(output, provider) {
|
|
1064
|
+
const patterns = provider === "cloudflare"
|
|
1065
|
+
? [/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i]
|
|
1066
|
+
: provider === "tailscale"
|
|
1067
|
+
? [/https:\/\/[a-z0-9.-]+\.ts\.net/i]
|
|
1068
|
+
: [];
|
|
1069
|
+
for (const pattern of patterns) {
|
|
1070
|
+
const match = output.match(pattern);
|
|
1071
|
+
if (match?.[0])
|
|
1072
|
+
return match[0];
|
|
1073
|
+
}
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
function detectTunnelPublicUrl(provider) {
|
|
1077
|
+
if (provider !== "tailscale")
|
|
1078
|
+
return undefined;
|
|
1079
|
+
return detectTailscalePublicUrl();
|
|
1080
|
+
}
|
|
1081
|
+
export function detectTailscalePublicUrl() {
|
|
1082
|
+
const funnelOutputs = [
|
|
1083
|
+
readTailscaleCommand(["funnel", "status", "--json"]),
|
|
1084
|
+
readTailscaleCommand(["funnel", "status"]),
|
|
1085
|
+
].filter((output) => Boolean(output));
|
|
1086
|
+
let funnelConfirmed = false;
|
|
1087
|
+
for (const output of funnelOutputs) {
|
|
1088
|
+
const publicUrl = tailscalePublicUrlFromFunnelStatus(output);
|
|
1089
|
+
if (publicUrl)
|
|
1090
|
+
return publicUrl;
|
|
1091
|
+
funnelConfirmed ||= tailscaleFunnelStatusIsPublic(output);
|
|
1092
|
+
}
|
|
1093
|
+
if (funnelConfirmed) {
|
|
1094
|
+
const statusOutput = readTailscaleCommand(["status", "--json"]);
|
|
1095
|
+
return statusOutput ? tailscalePublicUrlFromStatusJson(statusOutput) : undefined;
|
|
1096
|
+
}
|
|
1097
|
+
return undefined;
|
|
1098
|
+
}
|
|
1099
|
+
function readTailscaleCommand(args) {
|
|
1100
|
+
try {
|
|
1101
|
+
const command = executableCommand("tailscale", args);
|
|
1102
|
+
return execFileSync(command.command, command.args, {
|
|
1103
|
+
encoding: "utf8",
|
|
1104
|
+
timeout: 2000,
|
|
1105
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1106
|
+
...windowsVerbatimArgumentsOption(command),
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
catch {
|
|
1110
|
+
return undefined;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
export function tailscalePublicUrlFromFunnelStatus(output) {
|
|
1114
|
+
if (!tailscaleFunnelStatusIsPublic(output))
|
|
1115
|
+
return undefined;
|
|
1116
|
+
return tailscalePublicUrlFromStatusJson(output);
|
|
1117
|
+
}
|
|
1118
|
+
export function tailscalePublicUrlFromStatusJson(output) {
|
|
1119
|
+
let parsed;
|
|
1120
|
+
try {
|
|
1121
|
+
parsed = JSON.parse(output);
|
|
1122
|
+
}
|
|
1123
|
+
catch {
|
|
1124
|
+
return extractPublicUrl(output, "tailscale");
|
|
1125
|
+
}
|
|
1126
|
+
const embeddedUrl = findTailscaleUrl(parsed);
|
|
1127
|
+
if (embeddedUrl)
|
|
1128
|
+
return embeddedUrl;
|
|
1129
|
+
if (parsed && typeof parsed === "object") {
|
|
1130
|
+
const status = parsed;
|
|
1131
|
+
const certDomain = Array.isArray(status.CertDomains)
|
|
1132
|
+
? status.CertDomains.find((item) => typeof item === "string")
|
|
1133
|
+
: undefined;
|
|
1134
|
+
return normalizeTailscaleHostname(certDomain)
|
|
1135
|
+
?? normalizeTailscaleHostname(typeof status.Self?.DNSName === "string" ? status.Self.DNSName : undefined);
|
|
1136
|
+
}
|
|
1137
|
+
return undefined;
|
|
1138
|
+
}
|
|
1139
|
+
function tailscaleFunnelStatusIsPublic(output) {
|
|
1140
|
+
if (/\b(funnel on|available on the internet)\b/i.test(output))
|
|
1141
|
+
return true;
|
|
1142
|
+
let parsed;
|
|
1143
|
+
try {
|
|
1144
|
+
parsed = JSON.parse(output);
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
return containsEnabledFunnelMarker(parsed);
|
|
1150
|
+
}
|
|
1151
|
+
function containsEnabledFunnelMarker(value) {
|
|
1152
|
+
if (typeof value === "string") {
|
|
1153
|
+
return /\b(funnel on|available on the internet)\b/i.test(value);
|
|
1154
|
+
}
|
|
1155
|
+
if (Array.isArray(value)) {
|
|
1156
|
+
return value.some((item) => containsEnabledFunnelMarker(item));
|
|
1157
|
+
}
|
|
1158
|
+
if (value && typeof value === "object") {
|
|
1159
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1160
|
+
if (key.toLowerCase().includes("funnel") && item === true)
|
|
1161
|
+
return true;
|
|
1162
|
+
if (containsEnabledFunnelMarker(item))
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
function findTailscaleUrl(value) {
|
|
1169
|
+
if (typeof value === "string") {
|
|
1170
|
+
return normalizeTailscaleHostname(value);
|
|
1171
|
+
}
|
|
1172
|
+
if (Array.isArray(value)) {
|
|
1173
|
+
for (const item of value) {
|
|
1174
|
+
const found = findTailscaleUrl(item);
|
|
1175
|
+
if (found)
|
|
1176
|
+
return found;
|
|
1177
|
+
}
|
|
1178
|
+
return undefined;
|
|
1179
|
+
}
|
|
1180
|
+
if (value && typeof value === "object") {
|
|
1181
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1182
|
+
const keyUrl = normalizeTailscaleHostname(key);
|
|
1183
|
+
if (keyUrl)
|
|
1184
|
+
return keyUrl;
|
|
1185
|
+
const found = findTailscaleUrl(item);
|
|
1186
|
+
if (found)
|
|
1187
|
+
return found;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return undefined;
|
|
1191
|
+
}
|
|
1192
|
+
function normalizeTailscaleHostname(value) {
|
|
1193
|
+
if (!value)
|
|
1194
|
+
return undefined;
|
|
1195
|
+
const trimmed = value.trim();
|
|
1196
|
+
if (!trimmed)
|
|
1197
|
+
return undefined;
|
|
1198
|
+
try {
|
|
1199
|
+
const parsed = new URL(trimmed);
|
|
1200
|
+
if (parsed.protocol === "https:" && parsed.hostname.toLowerCase().endsWith(".ts.net")) {
|
|
1201
|
+
return parsed.origin;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
// Fall through to DNS-name normalization.
|
|
1206
|
+
}
|
|
1207
|
+
const hostname = trimmed.replace(/\.$/, "").toLowerCase();
|
|
1208
|
+
if (!/^[a-z0-9.-]+\.ts\.net$/i.test(hostname))
|
|
1209
|
+
return undefined;
|
|
1210
|
+
return `https://${hostname}`;
|
|
1211
|
+
}
|
|
1212
|
+
function terminateTunnelGroup(tp, signal) {
|
|
1213
|
+
if (process.platform === "win32" && tp.pid) {
|
|
1214
|
+
spawnSync("taskkill", ["/pid", String(tp.pid), "/t", "/f"], { stdio: "ignore" });
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
if (tp.pid && process.platform !== "win32") {
|
|
1218
|
+
try {
|
|
1219
|
+
process.kill(-tp.pid, signal);
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
// Fall back to killing the child directly.
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
getTunnelProvider(tp.provider).stop(tp.child, signal);
|
|
1227
|
+
}
|
|
1228
|
+
async function waitForTunnelExit(tp, timeoutMs) {
|
|
1229
|
+
if (tp.status !== "running")
|
|
1230
|
+
return true;
|
|
1231
|
+
return new Promise((resolve) => {
|
|
1232
|
+
const onExit = () => {
|
|
1233
|
+
clearTimeout(timeout);
|
|
1234
|
+
resolve(true);
|
|
1235
|
+
};
|
|
1236
|
+
const timeout = setTimeout(() => {
|
|
1237
|
+
tp.child.off("exit", onExit);
|
|
1238
|
+
resolve(false);
|
|
1239
|
+
}, timeoutMs);
|
|
1240
|
+
tp.child.once("exit", onExit);
|
|
1241
|
+
timeout.unref();
|
|
1242
|
+
});
|
|
1243
|
+
}
|