agentroam 0.2.0-preview.2 → 0.2.0-preview.3
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/README.md +12 -11
- package/dist/args.js +24 -8
- package/dist/args.test.js +25 -1
- package/dist/cli.js +112 -52
- package/dist/cloudflared/bundled-asset.js +37 -0
- package/dist/cloudflared/bundled-asset.test.js +50 -0
- package/dist/cloudflared/installer.js +83 -40
- package/dist/cloudflared/installer.test.js +63 -4
- package/dist/cloudflared/manifest.js +3 -5
- package/dist/tunnel/child-process.js +23 -0
- package/dist/tunnel/cloudflare-provider.js +52 -8
- package/dist/tunnel/custom-command-provider.js +54 -4
- package/dist/tunnel/pinggy-provider.js +84 -0
- package/dist/tunnel/pinggy-provider.test.js +40 -0
- package/dist/tunnel/public-readiness.js +58 -0
- package/dist/tunnel/public-readiness.test.js +24 -0
- package/dist/tunnel/relay-orchestrator.js +43 -0
- package/dist/tunnel/relay-orchestrator.test.js +72 -0
- package/package.json +4 -1
- package/runtime/.next/BUILD_ID +1 -1
- package/runtime/.next/app-build-manifest.json +4 -4
- package/runtime/.next/app-path-routes-manifest.json +1 -1
- package/runtime/.next/build-manifest.json +5 -5
- package/runtime/.next/prerender-manifest.json +1 -1
- package/runtime/.next/react-loadable-manifest.json +2 -2
- package/runtime/.next/server/app/_not-found.html +1 -1
- package/runtime/.next/server/app/_not-found.rsc +1 -1
- package/runtime/.next/server/app/web/page_client-reference-manifest.js +1 -1
- package/runtime/.next/server/app/web.html +1 -1
- package/runtime/.next/server/app/web.rsc +2 -2
- package/runtime/.next/server/app-paths-manifest.json +11 -11
- package/runtime/.next/server/middleware-build-manifest.js +1 -1
- package/runtime/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/runtime/.next/server/pages/404.html +1 -1
- package/runtime/.next/server/pages/500.html +1 -1
- package/runtime/.next/server/server-reference-manifest.json +1 -1
- package/runtime/.next/static/chunks/982.bf2e6e7fe55cd5ca.js +1 -0
- package/runtime/.next/static/chunks/app/web/{page-800bcc7d84eb45e4.js → page-0ee508ac538f72d3.js} +1 -1
- package/runtime/.next/static/chunks/{webpack-e56f3a1950168597.js → webpack-d91bc49de770fd58.js} +1 -1
- package/runtime/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/runtime/.next/static/chunks/804.0e20fa702013ffef.js +0 -1
- /package/runtime/.next/static/{47UkTmBGxP5qz2mnMbNEp → OszWLAB2kMAnNfSwJzpyw}/_buildManifest.js +0 -0
- /package/runtime/.next/static/{47UkTmBGxP5qz2mnMbNEp → OszWLAB2kMAnNfSwJzpyw}/_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,59 @@
|
|
|
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 }) {
|
|
8
|
-
|
|
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 waitForUrl(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
|
+
function waitForUrl(child, log, timeoutMs) {
|
|
30
|
+
return new Promise((resolvePromise, reject) => {
|
|
31
|
+
let buffer = "";
|
|
32
|
+
let settled = false;
|
|
33
|
+
const finish = (error, url) => {
|
|
34
|
+
if (settled)
|
|
35
|
+
return;
|
|
36
|
+
settled = true;
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
if (error)
|
|
39
|
+
reject(error);
|
|
40
|
+
else
|
|
41
|
+
resolvePromise(url);
|
|
42
|
+
};
|
|
43
|
+
const timer = setTimeout(() => finish(new Error("cloudflared tunnel URL timeout")), timeoutMs);
|
|
44
|
+
const read = (chunk) => {
|
|
45
|
+
const text = chunk.toString();
|
|
46
|
+
buffer = (buffer + text).slice(-8192);
|
|
47
|
+
for (const line of text.split(/\r?\n/))
|
|
48
|
+
if (line)
|
|
49
|
+
log(line.replace(/pair=[^\s&]+/g, "pair=[REDACTED]"));
|
|
50
|
+
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
51
|
+
if (match)
|
|
52
|
+
finish(undefined, match[0]);
|
|
53
|
+
};
|
|
54
|
+
child.stdout?.on("data", read);
|
|
55
|
+
child.stderr?.on("data", read);
|
|
56
|
+
child.once("exit", (code) => finish(new Error(`cloudflared exited before URL (${code})`)));
|
|
57
|
+
child.once("error", (error) => finish(error));
|
|
58
|
+
});
|
|
9
59
|
}
|
|
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); }); }
|
|
@@ -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 }) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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,58 @@
|
|
|
1
|
+
export async function waitForPublicReadiness(publicUrl, options = {}) {
|
|
2
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
3
|
+
const intervalMs = options.intervalMs ?? 750;
|
|
4
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
5
|
+
const endpoint = new URL("/api/web-auth/status", `${publicUrl.replace(/\/$/, "")}/`);
|
|
6
|
+
const deadline = Date.now() + timeoutMs;
|
|
7
|
+
let lastFailure = "no response";
|
|
8
|
+
while (Date.now() <= deadline) {
|
|
9
|
+
if (options.signal?.aborted)
|
|
10
|
+
throw new Error("public tunnel readiness aborted");
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetchImpl(endpoint, {
|
|
13
|
+
cache: "no-store",
|
|
14
|
+
headers: { "user-agent": "agentroam-readiness/0.2.0-preview.3" },
|
|
15
|
+
signal: options.signal,
|
|
16
|
+
});
|
|
17
|
+
if (response.ok) {
|
|
18
|
+
let body;
|
|
19
|
+
try {
|
|
20
|
+
body = (await response.json());
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new FatalReadinessError("HTTP 200 with invalid auth status JSON");
|
|
24
|
+
}
|
|
25
|
+
if (typeof body.authenticated === "boolean" && typeof body.needsSetup === "boolean")
|
|
26
|
+
return;
|
|
27
|
+
throw new FatalReadinessError("HTTP 200 with invalid auth status JSON");
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
lastFailure = `HTTP ${response.status}`;
|
|
31
|
+
if (![404, 502, 503, 530].includes(response.status))
|
|
32
|
+
throw new FatalReadinessError(lastFailure);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (options.signal?.aborted)
|
|
37
|
+
throw new Error("public tunnel readiness aborted");
|
|
38
|
+
if (error instanceof FatalReadinessError)
|
|
39
|
+
throw new Error(`public tunnel not ready: ${error.message}`);
|
|
40
|
+
lastFailure = error instanceof Error ? error.message : String(error);
|
|
41
|
+
}
|
|
42
|
+
if (Date.now() + intervalMs > deadline)
|
|
43
|
+
break;
|
|
44
|
+
await delay(intervalMs, options.signal);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`public tunnel not ready: ${lastFailure}`);
|
|
47
|
+
}
|
|
48
|
+
class FatalReadinessError extends Error {
|
|
49
|
+
}
|
|
50
|
+
function delay(milliseconds, signal) {
|
|
51
|
+
return new Promise((resolvePromise, reject) => {
|
|
52
|
+
const timer = setTimeout(resolvePromise, milliseconds);
|
|
53
|
+
signal?.addEventListener("abort", () => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
reject(new Error("public tunnel readiness aborted"));
|
|
56
|
+
}, { once: true });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { waitForPublicReadiness } from "./public-readiness.js";
|
|
3
|
+
describe("waitForPublicReadiness", () => {
|
|
4
|
+
it("retries a transient 530 and accepts the expected auth status", async () => {
|
|
5
|
+
const fetchImpl = vi
|
|
6
|
+
.fn()
|
|
7
|
+
.mockResolvedValueOnce(new Response("unavailable", { status: 530 }))
|
|
8
|
+
.mockResolvedValueOnce(Response.json({ authenticated: false, needsSetup: true }, { status: 200 }));
|
|
9
|
+
await waitForPublicReadiness("https://example.test", {
|
|
10
|
+
fetchImpl,
|
|
11
|
+
intervalMs: 1,
|
|
12
|
+
timeoutMs: 100,
|
|
13
|
+
});
|
|
14
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
15
|
+
});
|
|
16
|
+
it("rejects an invalid HTTP 200 payload", async () => {
|
|
17
|
+
const fetchImpl = vi.fn().mockResolvedValue(Response.json({ ok: true }));
|
|
18
|
+
await expect(waitForPublicReadiness("https://example.test", { fetchImpl, intervalMs: 1, timeoutMs: 5 })).rejects.toThrow("invalid auth status JSON");
|
|
19
|
+
});
|
|
20
|
+
it("includes the last HTTP status in timeout diagnostics", async () => {
|
|
21
|
+
const fetchImpl = vi.fn().mockResolvedValue(new Response("bad gateway", { status: 502 }));
|
|
22
|
+
await expect(waitForPublicReadiness("https://example.test", { fetchImpl, intervalMs: 1, timeoutMs: 5 })).rejects.toThrow("HTTP 502");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ensureCloudflared } from "../cloudflared/installer.js";
|
|
2
|
+
import { CloudflareTunnelProvider } from "./cloudflare-provider.js";
|
|
3
|
+
import { CustomCommandTunnelProvider } from "./custom-command-provider.js";
|
|
4
|
+
import { PinggyTunnelProvider } from "./pinggy-provider.js";
|
|
5
|
+
import { waitForPublicReadiness } from "./public-readiness.js";
|
|
6
|
+
export async function selectRelay(options) {
|
|
7
|
+
if (options.cli.localOnly)
|
|
8
|
+
return { tunnel: null, publicUrl: options.lanUrl, provider: "lan", failures: [] };
|
|
9
|
+
const providers = options.cli.relay === "auto" ? ["cloudflare", "pinggy"] : [options.cli.relay];
|
|
10
|
+
const failures = [];
|
|
11
|
+
const readiness = options.readiness ?? waitForPublicReadiness;
|
|
12
|
+
for (const providerName of providers) {
|
|
13
|
+
options.onAttempt?.(providerName);
|
|
14
|
+
let tunnel = null;
|
|
15
|
+
try {
|
|
16
|
+
const provider = await createProvider(providerName, options);
|
|
17
|
+
tunnel = await provider.start({ localUrl: options.localUrl, signal: options.signal, log: options.log });
|
|
18
|
+
await readiness(tunnel.publicUrl, { signal: options.signal });
|
|
19
|
+
return { tunnel, publicUrl: tunnel.publicUrl, provider: providerName, failures };
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
await tunnel?.close().catch(() => { });
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
+
failures.push({ provider: providerName, message });
|
|
25
|
+
options.onFailure?.(providerName, message);
|
|
26
|
+
if (options.signal.aborted)
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { tunnel: null, publicUrl: options.lanUrl, provider: "lan", failures };
|
|
31
|
+
}
|
|
32
|
+
async function createProvider(name, options) {
|
|
33
|
+
const override = options.providerFactories?.[name];
|
|
34
|
+
if (override)
|
|
35
|
+
return await override();
|
|
36
|
+
if (name === "cloudflare") {
|
|
37
|
+
const executable = await ensureCloudflared(options.target, options.cli.dataDir);
|
|
38
|
+
return new CloudflareTunnelProvider(executable);
|
|
39
|
+
}
|
|
40
|
+
if (name === "pinggy")
|
|
41
|
+
return new PinggyTunnelProvider(options.port, options.cli.dataDir);
|
|
42
|
+
return new CustomCommandTunnelProvider(options.cli.tunnelCommand, options.port);
|
|
43
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { selectRelay } from "./relay-orchestrator.js";
|
|
3
|
+
describe("selectRelay", () => {
|
|
4
|
+
it("selects Cloudflare when it becomes ready", async () => {
|
|
5
|
+
const cloudflare = fakeProvider("https://ready.trycloudflare.com");
|
|
6
|
+
const result = await selectRelay(options("auto", { cloudflare }));
|
|
7
|
+
expect(result.provider).toBe("cloudflare");
|
|
8
|
+
expect(result.publicUrl).toContain("trycloudflare.com");
|
|
9
|
+
});
|
|
10
|
+
it("closes failed Cloudflare and selects Pinggy", async () => {
|
|
11
|
+
const cloudflare = fakeProvider("https://bad.trycloudflare.com");
|
|
12
|
+
const pinggy = fakeProvider("https://good.run.pinggy-free.link");
|
|
13
|
+
const readiness = vi.fn(async (url) => {
|
|
14
|
+
if (url.includes("bad"))
|
|
15
|
+
throw new Error("HTTP 530");
|
|
16
|
+
});
|
|
17
|
+
const result = await selectRelay(options("auto", { cloudflare, pinggy }, readiness));
|
|
18
|
+
expect(cloudflare.handle.close).toHaveBeenCalledOnce();
|
|
19
|
+
expect(result.provider).toBe("pinggy");
|
|
20
|
+
expect(result.failures).toEqual([{ provider: "cloudflare", message: "HTTP 530" }]);
|
|
21
|
+
});
|
|
22
|
+
it("falls back to LAN when both public providers fail", async () => {
|
|
23
|
+
const cloudflare = fakeProvider("https://bad.trycloudflare.com");
|
|
24
|
+
const pinggy = fakeProvider("https://bad.run.pinggy-free.link");
|
|
25
|
+
const result = await selectRelay(options("auto", { cloudflare, pinggy }, async () => { throw new Error("blocked"); }));
|
|
26
|
+
expect(result.provider).toBe("lan");
|
|
27
|
+
expect(result.publicUrl).toBe("http://10.0.0.2:43210");
|
|
28
|
+
expect(result.failures).toHaveLength(2);
|
|
29
|
+
});
|
|
30
|
+
it("bypasses every public provider in local-only mode", async () => {
|
|
31
|
+
const cloudflare = fakeProvider("https://unused.trycloudflare.com");
|
|
32
|
+
const result = await selectRelay({ ...options("auto", { cloudflare }), cli: cli("auto", true) });
|
|
33
|
+
expect(result.provider).toBe("lan");
|
|
34
|
+
expect(cloudflare.provider.start).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
function options(relay, providers, readiness = async () => { }) {
|
|
38
|
+
return {
|
|
39
|
+
cli: cli(relay),
|
|
40
|
+
target: "darwin-arm64",
|
|
41
|
+
localUrl: "http://127.0.0.1:43210",
|
|
42
|
+
lanUrl: "http://10.0.0.2:43210",
|
|
43
|
+
port: 43210,
|
|
44
|
+
signal: new AbortController().signal,
|
|
45
|
+
log: vi.fn(),
|
|
46
|
+
readiness: readiness,
|
|
47
|
+
providerFactories: Object.fromEntries(Object.entries(providers).map(([name, value]) => [name, async () => value.provider])),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function cli(relay, localOnly = false) {
|
|
51
|
+
return {
|
|
52
|
+
command: "start",
|
|
53
|
+
roots: [process.cwd()],
|
|
54
|
+
port: null,
|
|
55
|
+
relay,
|
|
56
|
+
tunnelCommand: relay === "custom" ? "relay {port}" : null,
|
|
57
|
+
localOnly,
|
|
58
|
+
qr: false,
|
|
59
|
+
dataDir: "/tmp/agentroam-test",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function fakeProvider(publicUrl) {
|
|
63
|
+
const handle = {
|
|
64
|
+
publicUrl,
|
|
65
|
+
close: vi.fn(async () => { }),
|
|
66
|
+
exited: new Promise(() => { }),
|
|
67
|
+
};
|
|
68
|
+
const provider = {
|
|
69
|
+
start: vi.fn(async () => handle),
|
|
70
|
+
};
|
|
71
|
+
return { provider, handle };
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentroam",
|
|
3
|
-
"version": "0.2.0-preview.
|
|
3
|
+
"version": "0.2.0-preview.3",
|
|
4
4
|
"description": "Zero-install mobile remote terminal and file console",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "agentroam": "./bin/agentroam.mjs" },
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"qrcode-terminal": "^0.12.0"
|
|
18
18
|
},
|
|
19
|
+
"optionalDependencies": {
|
|
20
|
+
"agentroam-cloudflared-darwin-arm64": "0.2.0-preview.3"
|
|
21
|
+
},
|
|
19
22
|
"devDependencies": {
|
|
20
23
|
"@types/node": "^22.0.0",
|
|
21
24
|
"@types/qrcode-terminal": "^0.12.2",
|
package/runtime/.next/BUILD_ID
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
OszWLAB2kMAnNfSwJzpyw
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"pages": {
|
|
3
3
|
"/_not-found/page": [
|
|
4
|
-
"static/chunks/webpack-
|
|
4
|
+
"static/chunks/webpack-d91bc49de770fd58.js",
|
|
5
5
|
"static/chunks/3cc391f0-17a90b8b534c353f.js",
|
|
6
6
|
"static/chunks/183-9c8ca0c781655214.js",
|
|
7
7
|
"static/chunks/main-app-07d67f2d4e96ecb8.js",
|
|
8
8
|
"static/chunks/app/_not-found/page-e98edbffee24e843.js"
|
|
9
9
|
],
|
|
10
10
|
"/layout": [
|
|
11
|
-
"static/chunks/webpack-
|
|
11
|
+
"static/chunks/webpack-d91bc49de770fd58.js",
|
|
12
12
|
"static/chunks/3cc391f0-17a90b8b534c353f.js",
|
|
13
13
|
"static/chunks/183-9c8ca0c781655214.js",
|
|
14
14
|
"static/chunks/main-app-07d67f2d4e96ecb8.js",
|
|
15
15
|
"static/chunks/app/layout-9176f750902030b5.js"
|
|
16
16
|
],
|
|
17
17
|
"/web/page": [
|
|
18
|
-
"static/chunks/webpack-
|
|
18
|
+
"static/chunks/webpack-d91bc49de770fd58.js",
|
|
19
19
|
"static/chunks/3cc391f0-17a90b8b534c353f.js",
|
|
20
20
|
"static/chunks/183-9c8ca0c781655214.js",
|
|
21
21
|
"static/chunks/main-app-07d67f2d4e96ecb8.js",
|
|
22
|
-
"static/chunks/app/web/page-
|
|
22
|
+
"static/chunks/app/web/page-0ee508ac538f72d3.js"
|
|
23
23
|
]
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"/_not-found/page":"/_not-found","/api/agent/abort/route":"/api/agent/abort","/api/agent/answer/route":"/api/agent/answer","/api/agent/
|
|
1
|
+
{"/_not-found/page":"/_not-found","/api/agent/abort/route":"/api/agent/abort","/api/agent/answer/route":"/api/agent/answer","/api/agent/stream/route":"/api/agent/stream","/api/agent/run/route":"/api/agent/run","/api/mcp/connect/route":"/api/mcp/connect","/api/mcp/disconnect/route":"/api/mcp/disconnect","/api/mcp/servers/route":"/api/mcp/servers","/api/memory/[key]/route":"/api/memory/[key]","/api/memory/route":"/api/memory","/api/plugins/load/route":"/api/plugins/load","/api/auth/verify/route":"/api/auth/verify","/api/tools/route":"/api/tools","/api/sessions/[id]/route":"/api/sessions/[id]","/api/sessions/route":"/api/sessions","/api/remote-tools/register/route":"/api/remote-tools/register","/api/web-auth/setup/route":"/api/web-auth/setup","/api/web-console/bootstrap/route":"/api/web-console/bootstrap","/api/web-console/device-state/route":"/api/web-console/device-state","/api/web-auth/logout/route":"/api/web-auth/logout","/api/web-auth/login/route":"/api/web-auth/login","/api/web-console/preferences/route":"/api/web-console/preferences","/web/page":"/web","/api/web-auth/status/route":"/api/web-auth/status","/api/web-auth/revoke-all/route":"/api/web-auth/revoke-all","/api/web-console/history/route":"/api/web-console/history","/api/skills/route":"/api/skills"}
|
|
@@ -5,24 +5,24 @@
|
|
|
5
5
|
"devFiles": [],
|
|
6
6
|
"ampDevFiles": [],
|
|
7
7
|
"lowPriorityFiles": [
|
|
8
|
-
"static/
|
|
9
|
-
"static/
|
|
8
|
+
"static/OszWLAB2kMAnNfSwJzpyw/_buildManifest.js",
|
|
9
|
+
"static/OszWLAB2kMAnNfSwJzpyw/_ssgManifest.js"
|
|
10
10
|
],
|
|
11
11
|
"rootMainFiles": [
|
|
12
|
-
"static/chunks/webpack-
|
|
12
|
+
"static/chunks/webpack-d91bc49de770fd58.js",
|
|
13
13
|
"static/chunks/3cc391f0-17a90b8b534c353f.js",
|
|
14
14
|
"static/chunks/183-9c8ca0c781655214.js",
|
|
15
15
|
"static/chunks/main-app-07d67f2d4e96ecb8.js"
|
|
16
16
|
],
|
|
17
17
|
"pages": {
|
|
18
18
|
"/_app": [
|
|
19
|
-
"static/chunks/webpack-
|
|
19
|
+
"static/chunks/webpack-d91bc49de770fd58.js",
|
|
20
20
|
"static/chunks/framework-7bac12ed8564755e.js",
|
|
21
21
|
"static/chunks/main-a7dd8c98b48af038.js",
|
|
22
22
|
"static/chunks/pages/_app-389e6eb8abb0af2d.js"
|
|
23
23
|
],
|
|
24
24
|
"/_error": [
|
|
25
|
-
"static/chunks/webpack-
|
|
25
|
+
"static/chunks/webpack-d91bc49de770fd58.js",
|
|
26
26
|
"static/chunks/framework-7bac12ed8564755e.js",
|
|
27
27
|
"static/chunks/main-a7dd8c98b48af038.js",
|
|
28
28
|
"static/chunks/pages/_error-7cee5565b5ef9bcb.js"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":4,"routes":{"/api/mcp/servers":{"initialHeaders":{"content-type":"application/json","x-next-cache-tags":"_N_T_/layout,_N_T_/api/layout,_N_T_/api/mcp/layout,_N_T_/api/mcp/servers/layout,_N_T_/api/mcp/servers/route,_N_T_/api/mcp/servers"},"experimentalBypassFor":[{"type":"header","key":"Next-Action"},{"type":"header","key":"content-type","value":"multipart/form-data;.*"}],"initialRevalidateSeconds":false,"srcRoute":"/api/mcp/servers","dataRoute":null},"/api/tools":{"initialHeaders":{"content-type":"application/json","x-next-cache-tags":"_N_T_/layout,_N_T_/api/layout,_N_T_/api/tools/layout,_N_T_/api/tools/route,_N_T_/api/tools"},"experimentalBypassFor":[{"type":"header","key":"Next-Action"},{"type":"header","key":"content-type","value":"multipart/form-data;.*"}],"initialRevalidateSeconds":false,"srcRoute":"/api/tools","dataRoute":null},"/web":{"experimentalBypassFor":[{"type":"header","key":"Next-Action"},{"type":"header","key":"content-type","value":"multipart/form-data;.*"}],"initialRevalidateSeconds":false,"srcRoute":"/web","dataRoute":"/web.rsc"}},"dynamicRoutes":{},"notFoundRoutes":[],"preview":{"previewModeId":"
|
|
1
|
+
{"version":4,"routes":{"/api/mcp/servers":{"initialHeaders":{"content-type":"application/json","x-next-cache-tags":"_N_T_/layout,_N_T_/api/layout,_N_T_/api/mcp/layout,_N_T_/api/mcp/servers/layout,_N_T_/api/mcp/servers/route,_N_T_/api/mcp/servers"},"experimentalBypassFor":[{"type":"header","key":"Next-Action"},{"type":"header","key":"content-type","value":"multipart/form-data;.*"}],"initialRevalidateSeconds":false,"srcRoute":"/api/mcp/servers","dataRoute":null},"/api/tools":{"initialHeaders":{"content-type":"application/json","x-next-cache-tags":"_N_T_/layout,_N_T_/api/layout,_N_T_/api/tools/layout,_N_T_/api/tools/route,_N_T_/api/tools"},"experimentalBypassFor":[{"type":"header","key":"Next-Action"},{"type":"header","key":"content-type","value":"multipart/form-data;.*"}],"initialRevalidateSeconds":false,"srcRoute":"/api/tools","dataRoute":null},"/web":{"experimentalBypassFor":[{"type":"header","key":"Next-Action"},{"type":"header","key":"content-type","value":"multipart/form-data;.*"}],"initialRevalidateSeconds":false,"srcRoute":"/web","dataRoute":"/web.rsc"}},"dynamicRoutes":{},"notFoundRoutes":[],"preview":{"previewModeId":"74b5b86f37416c499418aee65000a795","previewModeSigningKey":"1b075952bdcf92f99117ccf12d93c6436030034bea431ecb07532837dfcccf31","previewModeEncryptionKey":"475fe62bfb206339f353c1d47780da5282a63c1236fbff02745cb4565822255d"}}
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
]
|
|
13
13
|
},
|
|
14
14
|
"app/web/page.tsx -> ./TerminalPane": {
|
|
15
|
-
"id":
|
|
15
|
+
"id": 3366,
|
|
16
16
|
"files": [
|
|
17
17
|
"static/css/ac3c182a4b5bc5f1.css",
|
|
18
18
|
"static/chunks/f3dd23f1.b423f4328b6ce988.js",
|
|
19
|
-
"static/chunks/
|
|
19
|
+
"static/chunks/982.bf2e6e7fe55cd5ca.js"
|
|
20
20
|
]
|
|
21
21
|
}
|
|
22
22
|
}
|