agentroam 0.2.0-preview.2 → 0.2.0-preview.4

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 (100) hide show
  1. package/README.md +12 -11
  2. package/dist/args.js +24 -8
  3. package/dist/args.test.js +25 -1
  4. package/dist/cli.js +112 -52
  5. package/dist/cloudflared/bundled-asset.js +37 -0
  6. package/dist/cloudflared/bundled-asset.test.js +50 -0
  7. package/dist/cloudflared/installer.js +83 -40
  8. package/dist/cloudflared/installer.test.js +63 -4
  9. package/dist/cloudflared/manifest.js +3 -5
  10. package/dist/tunnel/child-process.js +23 -0
  11. package/dist/tunnel/cloudflare-provider.js +59 -8
  12. package/dist/tunnel/cloudflare-provider.test.js +33 -0
  13. package/dist/tunnel/custom-command-provider.js +54 -4
  14. package/dist/tunnel/pinggy-provider.js +84 -0
  15. package/dist/tunnel/pinggy-provider.test.js +40 -0
  16. package/dist/tunnel/proxy-settings.js +141 -0
  17. package/dist/tunnel/proxy-settings.test.js +111 -0
  18. package/dist/tunnel/public-readiness.js +82 -0
  19. package/dist/tunnel/public-readiness.test.js +104 -0
  20. package/dist/tunnel/relay-orchestrator.js +43 -0
  21. package/dist/tunnel/relay-orchestrator.test.js +72 -0
  22. package/package.json +29 -8
  23. package/runtime/.next/BUILD_ID +1 -1
  24. package/runtime/.next/app-build-manifest.json +4 -4
  25. package/runtime/.next/app-path-routes-manifest.json +1 -1
  26. package/runtime/.next/build-manifest.json +7 -7
  27. package/runtime/.next/prerender-manifest.json +1 -1
  28. package/runtime/.next/react-loadable-manifest.json +2 -2
  29. package/runtime/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  30. package/runtime/.next/server/app/_not-found.html +1 -1
  31. package/runtime/.next/server/app/_not-found.rsc +1 -1
  32. package/runtime/.next/server/app/api/agent/abort/route.js +1 -1
  33. package/runtime/.next/server/app/api/agent/answer/route.js +1 -1
  34. package/runtime/.next/server/app/api/agent/model/route.js +1 -0
  35. package/runtime/.next/server/app/api/agent/model/route.js.nft.json +1 -0
  36. package/runtime/.next/server/app/api/agent/model.body +1 -0
  37. package/runtime/.next/server/app/api/agent/model.meta +1 -0
  38. package/runtime/.next/server/app/api/agent/run/route.js +1 -1
  39. package/runtime/.next/server/app/api/agent/steer/route.js +1 -0
  40. package/runtime/.next/server/app/api/agent/steer/route.js.nft.json +1 -0
  41. package/runtime/.next/server/app/api/agent/stream/route.js +3 -2
  42. package/runtime/.next/server/app/api/mcp/connect/route.js +1 -1
  43. package/runtime/.next/server/app/api/plugins/load/route.js +1 -1
  44. package/runtime/.next/server/app/api/remote-tools/register/route.js +1 -1
  45. package/runtime/.next/server/app/api/sessions/[id]/route.js +1 -1
  46. package/runtime/.next/server/app/api/sessions/route.js +1 -1
  47. package/runtime/.next/server/app/api/skills/route.js +1 -1
  48. package/runtime/.next/server/app/api/tools/route.js +1 -1
  49. package/runtime/.next/server/app/api/web-auth/login/route.js +1 -1
  50. package/runtime/.next/server/app/api/web-auth/logout/route.js +1 -1
  51. package/runtime/.next/server/app/api/web-auth/revoke-all/route.js +1 -1
  52. package/runtime/.next/server/app/api/web-auth/setup/route.js +1 -1
  53. package/runtime/.next/server/app/api/web-auth/status/route.js +1 -1
  54. package/runtime/.next/server/app/api/web-console/bootstrap/route.js +1 -1
  55. package/runtime/.next/server/app/api/web-console/device-state/route.js +1 -1
  56. package/runtime/.next/server/app/api/web-console/history/route.js +1 -1
  57. package/runtime/.next/server/app/api/web-console/history/route.js.nft.json +1 -1
  58. package/runtime/.next/server/app/api/web-console/preferences/route.js +1 -1
  59. package/runtime/.next/server/app/web/page.js +10 -10
  60. package/runtime/.next/server/app/web/page_client-reference-manifest.js +1 -1
  61. package/runtime/.next/server/app/web.html +1 -1
  62. package/runtime/.next/server/app/web.rsc +2 -2
  63. package/runtime/.next/server/app-paths-manifest.json +15 -13
  64. package/runtime/.next/server/chunks/322.js +28 -12
  65. package/runtime/.next/server/middleware-build-manifest.js +1 -1
  66. package/runtime/.next/server/middleware-react-loadable-manifest.js +1 -1
  67. package/runtime/.next/server/pages/404.html +1 -1
  68. package/runtime/.next/server/pages/500.html +1 -1
  69. package/runtime/.next/server/server-reference-manifest.json +1 -1
  70. package/runtime/.next/static/chunks/982.9e87a5cc0e6b13cf.js +1 -0
  71. package/runtime/.next/static/chunks/app/web/page-02e0df331bb14489.js +1 -0
  72. package/runtime/.next/static/chunks/{main-a7dd8c98b48af038.js → main-30618c50527ce4e9.js} +1 -1
  73. package/runtime/.next/static/chunks/{webpack-e56f3a1950168597.js → webpack-0c872a6ee48cbbdf.js} +1 -1
  74. package/runtime/node_modules/.package-lock.json +3 -3
  75. package/runtime/node_modules/@agent/core/dist/domain/context/ContextLoader.d.ts +2 -0
  76. package/runtime/node_modules/@agent/core/dist/domain/context/ContextLoader.d.ts.map +1 -1
  77. package/runtime/node_modules/@agent/core/dist/index.js +114 -12
  78. package/runtime/node_modules/@agent/core/dist/index.js.map +7 -6
  79. package/runtime/node_modules/@agent/core/dist/infrastructure/SQLiteAnonymousWebStore.d.ts +13 -0
  80. package/runtime/node_modules/@agent/core/dist/infrastructure/SQLiteAnonymousWebStore.d.ts.map +1 -0
  81. package/runtime/node_modules/@agent/core/dist/infrastructure/SQLiteAnonymousWebStore.test.d.ts +2 -0
  82. package/runtime/node_modules/@agent/core/dist/infrastructure/SQLiteAnonymousWebStore.test.d.ts.map +1 -0
  83. package/runtime/node_modules/@agent/core/dist/infrastructure/SQLiteDatabase.d.ts.map +1 -1
  84. package/runtime/node_modules/@agent/core/dist/infrastructure/SQLiteSessionStore.d.ts.map +1 -1
  85. package/runtime/node_modules/@agent/core/dist/infrastructure/index.d.ts +1 -0
  86. package/runtime/node_modules/@agent/core/dist/infrastructure/index.d.ts.map +1 -1
  87. package/runtime/node_modules/node-abi/abi_registry.json +7 -0
  88. package/runtime/node_modules/node-abi/package.json +1 -1
  89. package/runtime/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  90. package/runtime/package-lock.json +3 -3
  91. package/runtime/webapp/dist/assets/App-lXIeMH5u.js +52 -0
  92. package/runtime/webapp/dist/assets/index-BAFMhDtY.js +42 -0
  93. package/runtime/webapp/dist/assets/index-Beg9M2xI.css +1 -0
  94. package/runtime/webapp/dist/index.html +17 -0
  95. package/runtime/webapp/dist/pcm-audio-worklet.js +126 -0
  96. package/runtime/ws-server.mjs +148 -18
  97. package/runtime/.next/static/chunks/804.0e20fa702013ffef.js +0 -1
  98. package/runtime/.next/static/chunks/app/web/page-800bcc7d84eb45e4.js +0 -1
  99. /package/runtime/.next/static/{47UkTmBGxP5qz2mnMbNEp → dclalTmecO-HMVQvSKG7g}/_buildManifest.js +0 -0
  100. /package/runtime/.next/static/{47UkTmBGxP5qz2mnMbNEp → dclalTmecO-HMVQvSKG7g}/_ssgManifest.js +0 -0
@@ -0,0 +1,23 @@
1
+ export async function stopChildProcess(child, timeoutMs = 3_000) {
2
+ if (child.exitCode !== null || !child.pid)
3
+ return;
4
+ const exited = new Promise((resolvePromise) => child.once("exit", () => resolvePromise()));
5
+ signal(child, "SIGTERM");
6
+ await Promise.race([exited, new Promise((resolvePromise) => setTimeout(resolvePromise, timeoutMs))]);
7
+ if (child.exitCode === null)
8
+ signal(child, "SIGKILL");
9
+ }
10
+ function signal(child, name) {
11
+ try {
12
+ if (process.platform !== "win32" && child.pid)
13
+ process.kill(-child.pid, name);
14
+ else
15
+ child.kill(name);
16
+ }
17
+ catch {
18
+ try {
19
+ child.kill(name);
20
+ }
21
+ catch { }
22
+ }
23
+ }
@@ -1,15 +1,66 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { stopChildProcess } from "./child-process.js";
2
3
  export class CloudflareTunnelProvider {
3
4
  executable;
4
5
  constructor(executable) {
5
6
  this.executable = executable;
6
7
  }
7
- async start({ localUrl, signal, log }) { const child = spawn(this.executable, ["tunnel", "--no-autoupdate", "--url", localUrl], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); const publicUrl = await waitForUrl(child, log, 30000); signal.addEventListener("abort", () => child.kill(), { once: true }); const exited = new Promise(resolve => child.once("exit", (code, sig) => resolve({ code, signal: sig }))); return { publicUrl, exited, close: async () => { if (child.exitCode === null)
8
- child.kill(); await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 3000))]); } }; }
8
+ async start({ localUrl, signal, log }) {
9
+ const child = spawn(this.executable, ["tunnel", "--no-autoupdate", "--url", localUrl], {
10
+ stdio: ["ignore", "pipe", "pipe"],
11
+ windowsHide: true,
12
+ detached: process.platform !== "win32",
13
+ });
14
+ const abort = () => void stopChildProcess(child);
15
+ signal.addEventListener("abort", abort, { once: true });
16
+ if (signal.aborted)
17
+ abort();
18
+ try {
19
+ const publicUrl = await waitForConnectedUrl(child, log, 30_000);
20
+ const exited = new Promise((resolvePromise) => child.once("exit", (code, childSignal) => resolvePromise({ code, signal: childSignal })));
21
+ return { publicUrl, exited, close: () => stopChildProcess(child) };
22
+ }
23
+ catch (error) {
24
+ await stopChildProcess(child);
25
+ throw error;
26
+ }
27
+ }
28
+ }
29
+ export function waitForConnectedUrl(child, log, timeoutMs) {
30
+ return new Promise((resolvePromise, reject) => {
31
+ const buffers = { stdout: "", stderr: "" };
32
+ let publicUrl = null;
33
+ let connected = false;
34
+ let settled = false;
35
+ const finish = (error, url) => {
36
+ if (settled)
37
+ return;
38
+ settled = true;
39
+ clearTimeout(timer);
40
+ if (error)
41
+ reject(error);
42
+ else
43
+ resolvePromise(url);
44
+ };
45
+ const timer = setTimeout(() => finish(new Error("cloudflared tunnel connection timeout")), timeoutMs);
46
+ const read = (stream) => (chunk) => {
47
+ const text = chunk.toString();
48
+ const buffer = (buffers[stream] + text).slice(-8192);
49
+ buffers[stream] = buffer;
50
+ for (const line of text.split(/\r?\n/))
51
+ if (line)
52
+ log(line.replace(/pair=[^\s&]+/g, "pair=[REDACTED]"));
53
+ const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
54
+ if (match)
55
+ publicUrl = match[0];
56
+ if (/Registered tunnel connection/i.test(buffer))
57
+ connected = true;
58
+ if (publicUrl && connected)
59
+ finish(undefined, publicUrl);
60
+ };
61
+ child.stdout?.on("data", read("stdout"));
62
+ child.stderr?.on("data", read("stderr"));
63
+ child.once("exit", (code) => finish(new Error(`cloudflared exited before tunnel became ready (${code})`)));
64
+ child.once("error", (error) => finish(error));
65
+ });
9
66
  }
10
- async function waitForUrl(child, log, timeoutMs) { return new Promise((resolve, reject) => { let buffer = ""; const timer = setTimeout(() => reject(new Error("cloudflared tunnel URL timeout")), timeoutMs); const read = (chunk) => { const text = chunk.toString(); buffer = (buffer + text).slice(-8192); for (const line of text.split(/\r?\n/))
11
- if (line)
12
- log(line.replace(/pair=[^\s&]+/g, "pair=[REDACTED]")); const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i); if (match) {
13
- clearTimeout(timer);
14
- resolve(match[0]);
15
- } }; child.stdout?.on("data", read); child.stderr?.on("data", read); child.once("exit", code => { clearTimeout(timer); reject(new Error(`cloudflared exited before URL (${code})`)); }); child.once("error", reject); }); }
@@ -0,0 +1,33 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { PassThrough } from "node:stream";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { waitForConnectedUrl } from "./cloudflare-provider.js";
5
+ describe("waitForConnectedUrl", () => {
6
+ it("waits for both the public URL and a registered tunnel connection", async () => {
7
+ const { child, stderr } = fakeChild();
8
+ let resolved = false;
9
+ const ready = waitForConnectedUrl(child, vi.fn(), 100).then((url) => {
10
+ resolved = true;
11
+ return url;
12
+ });
13
+ stderr.write("https://ready.trycloudflare.com\n");
14
+ await new Promise((resolvePromise) => setImmediate(resolvePromise));
15
+ expect(resolved).toBe(false);
16
+ stderr.write("INF Registered tunnel ");
17
+ stderr.write("connection connIndex=0 protocol=quic\n");
18
+ await expect(ready).resolves.toBe("https://ready.trycloudflare.com");
19
+ });
20
+ it("supports registration output arriving before the URL", async () => {
21
+ const { child, stdout, stderr } = fakeChild();
22
+ const ready = waitForConnectedUrl(child, vi.fn(), 100);
23
+ stdout.write("INF Registered tunnel connection connIndex=0 protocol=http2\n");
24
+ stderr.write("Visit https://later.trycloudflare.com when ready\n");
25
+ await expect(ready).resolves.toBe("https://later.trycloudflare.com");
26
+ });
27
+ });
28
+ function fakeChild() {
29
+ const stdout = new PassThrough();
30
+ const stderr = new PassThrough();
31
+ const child = Object.assign(new EventEmitter(), { stdout, stderr });
32
+ return { child, stdout, stderr };
33
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { stopChildProcess } from "./child-process.js";
2
3
  export class CustomCommandTunnelProvider {
3
4
  command;
4
5
  port;
@@ -6,8 +7,57 @@ export class CustomCommandTunnelProvider {
6
7
  this.command = command;
7
8
  this.port = port;
8
9
  }
9
- async start({ signal, log }) { const rendered = this.command.replaceAll("{port}", String(this.port)); const child = spawn(rendered, { shell: true, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); const publicUrl = await new Promise((resolve, reject) => { let buffer = ""; const timer = setTimeout(() => reject(new Error("custom tunnel URL timeout")), 30000); const read = (chunk) => { const text = chunk.toString(); buffer = (buffer + text).slice(-8192); log(text.trim()); const match = buffer.match(/https:\/\/[^\s]+/i); if (match) {
10
- clearTimeout(timer);
11
- resolve(match[0].replace(/[),.;]+$/, ""));
12
- } }; child.stdout?.on("data", read); child.stderr?.on("data", read); child.once("error", reject); }); signal.addEventListener("abort", () => child.kill(), { once: true }); const exited = new Promise(resolve => child.once("exit", (code, sig) => resolve({ code, signal: sig }))); return { publicUrl, exited, close: async () => { child.kill(); } }; }
10
+ async start({ signal, log }) {
11
+ const rendered = this.command.replaceAll("{port}", String(this.port));
12
+ const child = spawn(rendered, {
13
+ shell: true,
14
+ stdio: ["ignore", "pipe", "pipe"],
15
+ windowsHide: true,
16
+ detached: process.platform !== "win32",
17
+ });
18
+ const abort = () => void stopChildProcess(child);
19
+ signal.addEventListener("abort", abort, { once: true });
20
+ if (signal.aborted)
21
+ abort();
22
+ try {
23
+ const publicUrl = await waitForCustomUrl(child, log, 30_000);
24
+ const exited = new Promise((resolvePromise) => child.once("exit", (code, childSignal) => resolvePromise({ code, signal: childSignal })));
25
+ return { publicUrl, exited, close: () => stopChildProcess(child) };
26
+ }
27
+ catch (error) {
28
+ await stopChildProcess(child);
29
+ throw error;
30
+ }
31
+ }
32
+ }
33
+ function waitForCustomUrl(child, log, timeoutMs) {
34
+ return new Promise((resolvePromise, reject) => {
35
+ let buffer = "";
36
+ let settled = false;
37
+ const finish = (error, url) => {
38
+ if (settled)
39
+ return;
40
+ settled = true;
41
+ clearTimeout(timer);
42
+ if (error)
43
+ reject(error);
44
+ else
45
+ resolvePromise(url);
46
+ };
47
+ const timer = setTimeout(() => finish(new Error("custom tunnel URL timeout")), timeoutMs);
48
+ const read = (chunk) => {
49
+ const text = chunk.toString();
50
+ buffer = (buffer + text).slice(-8192);
51
+ for (const line of text.split(/\r?\n/))
52
+ if (line)
53
+ log(line.replace(/pair=[^\s&]+/g, "pair=[REDACTED]"));
54
+ const match = buffer.match(/https:\/\/[^\s]+/i);
55
+ if (match)
56
+ finish(undefined, match[0].replace(/[),.;]+$/, ""));
57
+ };
58
+ child.stdout?.on("data", read);
59
+ child.stderr?.on("data", read);
60
+ child.once("error", (error) => finish(error));
61
+ child.once("exit", (code) => finish(new Error(`custom tunnel exited before URL (${code})`)));
62
+ });
13
63
  }
@@ -0,0 +1,84 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { stopChildProcess } from "./child-process.js";
5
+ export class PinggyTunnelProvider {
6
+ port;
7
+ dataDir;
8
+ spawnImpl;
9
+ constructor(port, dataDir, spawnImpl = spawn) {
10
+ this.port = port;
11
+ this.dataDir = dataDir;
12
+ this.spawnImpl = spawnImpl;
13
+ }
14
+ async start({ signal, log }) {
15
+ const sshDir = resolve(this.dataDir, "ssh");
16
+ await mkdir(sshDir, { recursive: true });
17
+ const knownHosts = resolve(sshDir, "known_hosts");
18
+ const args = [
19
+ "-p", "443",
20
+ "-o", "ExitOnForwardFailure=yes",
21
+ "-o", "ServerAliveInterval=30",
22
+ "-o", "ServerAliveCountMax=3",
23
+ "-o", "StrictHostKeyChecking=accept-new",
24
+ "-o", `UserKnownHostsFile=${knownHosts}`,
25
+ "-R", `0:localhost:${this.port}`,
26
+ "free.pinggy.io",
27
+ ];
28
+ const child = this.spawnImpl("ssh", args, {
29
+ stdio: ["ignore", "pipe", "pipe"],
30
+ windowsHide: true,
31
+ detached: process.platform !== "win32",
32
+ });
33
+ const abort = () => void stopChildProcess(child);
34
+ signal.addEventListener("abort", abort, { once: true });
35
+ if (signal.aborted)
36
+ abort();
37
+ try {
38
+ const publicUrl = await waitForPinggyUrl(child, log, 30_000);
39
+ const exited = new Promise((resolvePromise) => child.once("exit", (code, childSignal) => resolvePromise({ code, signal: childSignal })));
40
+ return { publicUrl, exited, close: () => stopChildProcess(child) };
41
+ }
42
+ catch (error) {
43
+ await stopChildProcess(child);
44
+ throw error;
45
+ }
46
+ }
47
+ }
48
+ export function parsePinggyUrl(text) {
49
+ return text.match(/https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.pinggy-free\.link/i)?.[0] ?? null;
50
+ }
51
+ function waitForPinggyUrl(child, log, timeoutMs) {
52
+ return new Promise((resolvePromise, reject) => {
53
+ let buffer = "";
54
+ let settled = false;
55
+ const finish = (error, url) => {
56
+ if (settled)
57
+ return;
58
+ settled = true;
59
+ clearTimeout(timer);
60
+ if (error)
61
+ reject(error);
62
+ else
63
+ resolvePromise(url);
64
+ };
65
+ const timer = setTimeout(() => finish(new Error("Pinggy tunnel URL timeout")), timeoutMs);
66
+ const read = (chunk) => {
67
+ const text = chunk.toString();
68
+ buffer = (buffer + text).slice(-16_384);
69
+ for (const line of text.split(/\r?\n/))
70
+ if (line)
71
+ log(redact(line));
72
+ const url = parsePinggyUrl(buffer);
73
+ if (url)
74
+ finish(undefined, url);
75
+ };
76
+ child.stdout?.on("data", read);
77
+ child.stderr?.on("data", read);
78
+ child.once("error", (error) => finish(error));
79
+ child.once("exit", (code) => finish(new Error(`Pinggy SSH exited before URL (${code})`)));
80
+ });
81
+ }
82
+ function redact(text) {
83
+ return text.replace(/pair=[^\s&]+/g, "pair=[REDACTED]");
84
+ }
@@ -0,0 +1,40 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { PassThrough } from "node:stream";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { PinggyTunnelProvider, parsePinggyUrl } from "./pinggy-provider.js";
5
+ describe("parsePinggyUrl", () => {
6
+ it("accepts only Pinggy free HTTPS URLs", () => {
7
+ expect(parsePinggyUrl("open https://abc-123.run.pinggy-free.link now")).toBe("https://abc-123.run.pinggy-free.link");
8
+ expect(parsePinggyUrl("https://example.com")).toBeNull();
9
+ expect(parsePinggyUrl("http://abc.run.pinggy-free.link")).toBeNull();
10
+ });
11
+ });
12
+ describe("PinggyTunnelProvider", () => {
13
+ it("starts SSH on port 443 with persistent host-key checking", async () => {
14
+ const child = fakeChild();
15
+ const spawnImpl = vi.fn((_command, _args) => child);
16
+ const provider = new PinggyTunnelProvider(43210, "/tmp/agentroam-test", spawnImpl);
17
+ const started = provider.start({ localUrl: "http://127.0.0.1:43210", signal: new AbortController().signal, log: vi.fn() });
18
+ queueMicrotask(() => child.stderr.write("https://sample.run.pinggy-free.link\n"));
19
+ const handle = await started;
20
+ expect(handle.publicUrl).toBe("https://sample.run.pinggy-free.link");
21
+ const args = spawnImpl.mock.calls[0][1];
22
+ expect(args).toContain("443");
23
+ expect(args).toContain("StrictHostKeyChecking=accept-new");
24
+ expect(args).toContain("0:localhost:43210");
25
+ expect(args.some((value) => value.startsWith("UserKnownHostsFile="))).toBe(true);
26
+ });
27
+ });
28
+ function fakeChild() {
29
+ const child = new EventEmitter();
30
+ child.stdout = new PassThrough();
31
+ child.stderr = new PassThrough();
32
+ Object.defineProperty(child, "exitCode", { value: null, writable: true });
33
+ Object.defineProperty(child, "pid", { value: undefined, writable: true });
34
+ child.kill = vi.fn(() => {
35
+ child.exitCode = 0;
36
+ child.emit("exit", 0, null);
37
+ return true;
38
+ });
39
+ return child;
40
+ }
@@ -0,0 +1,141 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ export async function resolveProxyForUrl(target, options = {}) {
5
+ const env = options.env ?? process.env;
6
+ const noProxy = env.no_proxy?.trim() ? env.no_proxy : env.NO_PROXY;
7
+ if (matchesNoProxy(target, noProxy))
8
+ return null;
9
+ const environmentProxy = selectEnvironmentProxy(target.protocol, env);
10
+ if (environmentProxy !== null)
11
+ return normalizeProxyUrl(environmentProxy);
12
+ if ((options.platform ?? process.platform) !== "darwin")
13
+ return null;
14
+ try {
15
+ const output = await (options.readMacSystemProxy ?? readMacSystemProxy)();
16
+ const settings = parseMacSystemProxy(output);
17
+ if (target.protocol === "https:")
18
+ return settings.httpsProxy ?? settings.httpProxy;
19
+ if (target.protocol === "http:")
20
+ return settings.httpProxy;
21
+ }
22
+ catch {
23
+ // Proxy discovery is best-effort; direct readiness retains the previous behavior.
24
+ }
25
+ return null;
26
+ }
27
+ export function parseMacSystemProxy(output) {
28
+ const values = new Map();
29
+ for (const line of output.split(/\r?\n/)) {
30
+ const match = /^\s*([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*?)\s*$/.exec(line);
31
+ if (match)
32
+ values.set(match[1], match[2]);
33
+ }
34
+ return {
35
+ httpProxy: buildMacProxy(values, "HTTP"),
36
+ httpsProxy: buildMacProxy(values, "HTTPS"),
37
+ };
38
+ }
39
+ async function readMacSystemProxy() {
40
+ const { stdout } = await execFileAsync("/usr/sbin/scutil", ["--proxy"], {
41
+ encoding: "utf8",
42
+ timeout: 2_000,
43
+ });
44
+ return stdout;
45
+ }
46
+ function selectEnvironmentProxy(protocol, env) {
47
+ const candidates = protocol === "https:"
48
+ ? [env.https_proxy, env.HTTPS_PROXY, env.http_proxy, env.HTTP_PROXY, env.all_proxy, env.ALL_PROXY]
49
+ : protocol === "http:"
50
+ ? [env.http_proxy, env.HTTP_PROXY, env.all_proxy, env.ALL_PROXY]
51
+ : [env.all_proxy, env.ALL_PROXY];
52
+ for (const candidate of candidates) {
53
+ if (candidate !== undefined && candidate.trim() !== "")
54
+ return candidate;
55
+ }
56
+ return null;
57
+ }
58
+ function normalizeProxyUrl(value) {
59
+ const candidate = value.trim();
60
+ try {
61
+ const proxy = new URL(candidate.includes("://") ? candidate : `http://${candidate}`);
62
+ if (!["http:", "https:"].includes(proxy.protocol) || proxy.hostname === "")
63
+ return null;
64
+ return proxy.toString();
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ function buildMacProxy(values, prefix) {
71
+ if (values.get(`${prefix}Enable`) !== "1")
72
+ return null;
73
+ const host = values.get(`${prefix}Proxy`)?.trim();
74
+ const portText = values.get(`${prefix}Port`)?.trim();
75
+ const port = portText === undefined ? Number.NaN : Number(portText);
76
+ if (!host || /[\s\/?#@]/.test(host) || !Number.isInteger(port) || port < 1 || port > 65_535)
77
+ return null;
78
+ const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
79
+ return normalizeProxyUrl(`http://${formattedHost}:${port}`);
80
+ }
81
+ function matchesNoProxy(target, value) {
82
+ if (!value)
83
+ return false;
84
+ const hostname = normalizeHostname(target.hostname);
85
+ const targetPort = target.port || defaultPort(target.protocol);
86
+ for (const entry of value.split(",")) {
87
+ const rule = parseNoProxyRule(entry);
88
+ if (!rule)
89
+ continue;
90
+ if (rule.host === "*")
91
+ return true;
92
+ if (rule.port !== null && rule.port !== targetPort)
93
+ continue;
94
+ if (hostname === rule.host || hostname.endsWith(`.${rule.host}`))
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+ function parseNoProxyRule(entry) {
100
+ let value = entry.trim().toLowerCase();
101
+ if (!value)
102
+ return null;
103
+ if (value === "*")
104
+ return { host: "*", port: null };
105
+ let host = value;
106
+ let port = null;
107
+ if (value.startsWith("[")) {
108
+ const closingBracket = value.indexOf("]");
109
+ if (closingBracket < 0)
110
+ return null;
111
+ host = value.slice(1, closingBracket);
112
+ const remainder = value.slice(closingBracket + 1);
113
+ if (remainder) {
114
+ if (!/^:\d+$/.test(remainder))
115
+ return null;
116
+ port = remainder.slice(1);
117
+ }
118
+ }
119
+ else {
120
+ const colon = value.lastIndexOf(":");
121
+ if (colon >= 0 && value.indexOf(":") === colon) {
122
+ const possiblePort = value.slice(colon + 1);
123
+ if (/^\d+$/.test(possiblePort)) {
124
+ host = value.slice(0, colon);
125
+ port = possiblePort;
126
+ }
127
+ }
128
+ }
129
+ host = normalizeHostname(host.replace(/^\*?\./, ""));
130
+ return host ? { host, port } : null;
131
+ }
132
+ function normalizeHostname(hostname) {
133
+ return hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
134
+ }
135
+ function defaultPort(protocol) {
136
+ if (protocol === "https:")
137
+ return "443";
138
+ if (protocol === "http:")
139
+ return "80";
140
+ return "";
141
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { parseMacSystemProxy, resolveProxyForUrl } from "./proxy-settings.js";
3
+ const target = new URL("https://api.example.test/status");
4
+ describe("resolveProxyForUrl", () => {
5
+ it("prefers the lowercase HTTPS proxy and normalizes an omitted scheme", async () => {
6
+ await expect(resolveProxyForUrl(target, {
7
+ env: {
8
+ https_proxy: "127.0.0.1:7897",
9
+ HTTPS_PROXY: "http://uppercase.test:8080",
10
+ HTTP_PROXY: "http://http.test:8080",
11
+ },
12
+ platform: "linux",
13
+ })).resolves.toBe("http://127.0.0.1:7897/");
14
+ });
15
+ it("uses HTTP_PROXY as the HTTPS fallback and ALL_PROXY for HTTP", async () => {
16
+ await expect(resolveProxyForUrl(target, { env: { HTTP_PROXY: "http://http.test:8080" }, platform: "linux" })).resolves.toBe("http://http.test:8080/");
17
+ await expect(resolveProxyForUrl(new URL("http://example.test"), {
18
+ env: { ALL_PROXY: "https://all.test:8443" },
19
+ platform: "linux",
20
+ })).resolves.toBe("https://all.test:8443/");
21
+ });
22
+ it.each([
23
+ ["*", "https://api.example.test/status"],
24
+ ["api.example.test", "https://api.example.test/status"],
25
+ [".example.test", "https://api.example.test/status"],
26
+ ["example.test", "https://api.example.test/status"],
27
+ ["api.example.test:443", "https://api.example.test/status"],
28
+ ["api.example.test:8443", "https://api.example.test:8443/status"],
29
+ ])("bypasses the proxy for NO_PROXY=%s", async (noProxy, url) => {
30
+ await expect(resolveProxyForUrl(new URL(url), {
31
+ env: { HTTPS_PROXY: "http://proxy.test:8080", NO_PROXY: noProxy },
32
+ platform: "darwin",
33
+ readMacSystemProxy: vi.fn(async () => macProxyOutput()),
34
+ })).resolves.toBeNull();
35
+ });
36
+ it("does not bypass a suffix lookalike or a rule with a different port", async () => {
37
+ await expect(resolveProxyForUrl(target, {
38
+ env: { HTTPS_PROXY: "http://proxy.test:8080", no_proxy: "notexample.test,api.example.test:8443" },
39
+ platform: "linux",
40
+ })).resolves.toBe("http://proxy.test:8080/");
41
+ });
42
+ it("falls back to uppercase NO_PROXY when the lowercase value is empty", async () => {
43
+ await expect(resolveProxyForUrl(target, {
44
+ env: { HTTPS_PROXY: "http://proxy.test:8080", no_proxy: "", NO_PROXY: "example.test" },
45
+ platform: "linux",
46
+ })).resolves.toBeNull();
47
+ });
48
+ it("falls back to the enabled macOS HTTPS proxy", async () => {
49
+ await expect(resolveProxyForUrl(target, {
50
+ env: {},
51
+ platform: "darwin",
52
+ readMacSystemProxy: async () => macProxyOutput(),
53
+ })).resolves.toBe("http://127.0.0.1:7897/");
54
+ });
55
+ it("uses the macOS HTTP proxy when HTTPS proxying is disabled", async () => {
56
+ await expect(resolveProxyForUrl(target, {
57
+ env: {},
58
+ platform: "darwin",
59
+ readMacSystemProxy: async () => macProxyOutput({ httpsEnabled: false }),
60
+ })).resolves.toBe("http://127.0.0.1:7898/");
61
+ });
62
+ it("returns direct mode for command failures and non-macOS platforms", async () => {
63
+ await expect(resolveProxyForUrl(target, {
64
+ env: {},
65
+ platform: "darwin",
66
+ readMacSystemProxy: async () => {
67
+ throw new Error("scutil failed");
68
+ },
69
+ })).resolves.toBeNull();
70
+ const reader = vi.fn(async () => macProxyOutput());
71
+ await expect(resolveProxyForUrl(target, { env: {}, platform: "linux", readMacSystemProxy: reader })).resolves.toBeNull();
72
+ expect(reader).not.toHaveBeenCalled();
73
+ });
74
+ });
75
+ describe("parseMacSystemProxy", () => {
76
+ it("parses enabled HTTP and HTTPS settings", () => {
77
+ expect(parseMacSystemProxy(macProxyOutput())).toEqual({
78
+ httpProxy: "http://127.0.0.1:7898/",
79
+ httpsProxy: "http://127.0.0.1:7897/",
80
+ });
81
+ });
82
+ it("ignores disabled and malformed settings", () => {
83
+ expect(parseMacSystemProxy(`
84
+ HTTPEnable : 0
85
+ HTTPProxy : 127.0.0.1
86
+ HTTPPort : 7898
87
+ HTTPSEnable : 1
88
+ HTTPSProxy : 127.0.0.1
89
+ HTTPSPort : not-a-port
90
+ `)).toEqual({ httpProxy: null, httpsProxy: null });
91
+ expect(parseMacSystemProxy("HTTPSEnable : 1\nHTTPSProxy : 127.0.0.1\nHTTPSPort : 70000")).toEqual({
92
+ httpProxy: null,
93
+ httpsProxy: null,
94
+ });
95
+ expect(parseMacSystemProxy("HTTPSEnable : 1\nHTTPSProxy : proxy.test/path\nHTTPSPort : 7897")).toEqual({
96
+ httpProxy: null,
97
+ httpsProxy: null,
98
+ });
99
+ });
100
+ });
101
+ function macProxyOutput({ httpsEnabled = true } = {}) {
102
+ return `
103
+ <dictionary> {
104
+ HTTPEnable : 1
105
+ HTTPPort : 7898
106
+ HTTPProxy : 127.0.0.1
107
+ HTTPSEnable : ${httpsEnabled ? "1" : "0"}
108
+ HTTPSPort : 7897
109
+ HTTPSProxy : 127.0.0.1
110
+ }`;
111
+ }
@@ -0,0 +1,82 @@
1
+ import { fetch as undiciFetch, ProxyAgent } from "undici";
2
+ import { resolveProxyForUrl } from "./proxy-settings.js";
3
+ export async function waitForPublicReadiness(publicUrl, options = {}) {
4
+ const timeoutMs = options.timeoutMs ?? 30_000;
5
+ const intervalMs = options.intervalMs ?? 750;
6
+ const endpoint = new URL("/api/web-auth/status", `${publicUrl.replace(/\/$/, "")}/`);
7
+ let proxyFetch = null;
8
+ let fetchImpl = options.fetchImpl;
9
+ if (!fetchImpl) {
10
+ const proxyResolver = options.proxyResolver ?? resolveProxyForUrl;
11
+ const proxyUrl = await proxyResolver(endpoint).catch(() => null);
12
+ if (proxyUrl)
13
+ proxyFetch = (options.proxyFetchFactory ?? createProxyFetch)(proxyUrl);
14
+ fetchImpl = proxyFetch?.fetch ?? fetch;
15
+ }
16
+ try {
17
+ const deadline = Date.now() + timeoutMs;
18
+ let lastFailure = "no response";
19
+ while (Date.now() <= deadline) {
20
+ if (options.signal?.aborted)
21
+ throw new Error("public tunnel readiness aborted");
22
+ try {
23
+ const response = await fetchImpl(endpoint, {
24
+ cache: "no-store",
25
+ headers: { "user-agent": "agentroam-readiness/0.2.0-preview.4" },
26
+ signal: options.signal,
27
+ });
28
+ if (response.ok) {
29
+ let body;
30
+ try {
31
+ body = (await response.json());
32
+ }
33
+ catch {
34
+ throw new FatalReadinessError("HTTP 200 with invalid auth status JSON");
35
+ }
36
+ if (typeof body.authenticated === "boolean" && typeof body.needsSetup === "boolean")
37
+ return;
38
+ throw new FatalReadinessError("HTTP 200 with invalid auth status JSON");
39
+ }
40
+ else {
41
+ lastFailure = `HTTP ${response.status}`;
42
+ if (![404, 502, 503, 530].includes(response.status))
43
+ throw new FatalReadinessError(lastFailure);
44
+ }
45
+ }
46
+ catch (error) {
47
+ if (options.signal?.aborted)
48
+ throw new Error("public tunnel readiness aborted");
49
+ if (error instanceof FatalReadinessError)
50
+ throw new Error(`public tunnel not ready: ${error.message}`);
51
+ lastFailure = error instanceof Error ? error.message : String(error);
52
+ }
53
+ if (Date.now() + intervalMs > deadline)
54
+ break;
55
+ await delay(intervalMs, options.signal);
56
+ }
57
+ throw new Error(`public tunnel not ready: ${lastFailure}`);
58
+ }
59
+ finally {
60
+ await proxyFetch?.close().catch(() => { });
61
+ }
62
+ }
63
+ class FatalReadinessError extends Error {
64
+ }
65
+ function createProxyFetch(proxyUrl) {
66
+ const dispatcher = new ProxyAgent(proxyUrl);
67
+ return {
68
+ fetch: async (input, init) => (await undiciFetch(input, { ...init, dispatcher })),
69
+ close: async () => {
70
+ await dispatcher.close();
71
+ },
72
+ };
73
+ }
74
+ function delay(milliseconds, signal) {
75
+ return new Promise((resolvePromise, reject) => {
76
+ const timer = setTimeout(resolvePromise, milliseconds);
77
+ signal?.addEventListener("abort", () => {
78
+ clearTimeout(timer);
79
+ reject(new Error("public tunnel readiness aborted"));
80
+ }, { once: true });
81
+ });
82
+ }