agentroam 0.2.0-preview.48 → 0.2.0-preview.53
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 +6 -4
- package/dist/abort.js +25 -0
- package/dist/cli.js +9 -7
- package/dist/codex-runtime-manager.js +22 -2
- package/dist/codex-sharing.js +1 -1
- package/dist/native-runtime.js +26 -4
- package/dist/opencode-runtime-manager.js +21 -1
- package/dist/runtime-manager.js +60 -23
- package/dist/service/macos-launch-agent.js +2 -1
- package/dist/service/service-command.js +25 -7
- package/dist/service/windows-task-service.js +3 -2
- package/dist/tunnel/public-readiness.js +75 -50
- package/dist/tunnel/relay-orchestrator.js +29 -13
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ npx agentroam
|
|
|
40
40
|
|
|
41
41
|
On first run:
|
|
42
42
|
|
|
43
|
-
1. AgentRoam tries a bundled **Cloudflare Quick Tunnel
|
|
43
|
+
1. AgentRoam tries a bundled **Cloudflare Quick Tunnel** for up to 30 seconds, then uses **LAN access** if that attempt fails or times out.
|
|
44
44
|
2. A **QR code** prints in the terminal — scan it on your phone.
|
|
45
45
|
3. Complete **one-time pairing** (5-minute token) and create the admin account.
|
|
46
46
|
4. Use the web terminal + file tree remotely.
|
|
@@ -49,7 +49,7 @@ On first run:
|
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
51
|
agentroam # start (default)
|
|
52
|
-
agentroam doctor # check native modules
|
|
52
|
+
agentroam doctor # check native modules and install Codex if missing
|
|
53
53
|
agentroam version
|
|
54
54
|
agent-tui # start the bundled terminal Agent UI
|
|
55
55
|
```
|
|
@@ -103,7 +103,7 @@ hibernation, shutdown, and low-battery forced sleep remain controlled by the OS.
|
|
|
103
103
|
|------|-------------|
|
|
104
104
|
| `--root <path>` | Workspace root (repeatable). Default: cwd |
|
|
105
105
|
| `--local-only` | Skip tunnel; LAN/localhost only |
|
|
106
|
-
| `--relay auto` | Cloudflare, then
|
|
106
|
+
| `--relay auto` | Cloudflare, then LAN on failure or timeout (default) |
|
|
107
107
|
| `--relay cloudflare` | Cloudflare only, then LAN |
|
|
108
108
|
| `--relay pinggy` | Pinggy only, then LAN |
|
|
109
109
|
| `--relay custom --tunnel-command "<cmd>"` | Your own tunnel command |
|
|
@@ -111,7 +111,9 @@ hibernation, shutdown, and low-battery forced sleep remain controlled by the OS.
|
|
|
111
111
|
| `--port <n>` | Fixed local port |
|
|
112
112
|
| `--data-dir <path>` | State directory (default: `~/.agentroam`) |
|
|
113
113
|
|
|
114
|
-
The platform cloudflared binary is installed from npm after size and SHA-256 verification, so startup does not download from GitHub.
|
|
114
|
+
The platform cloudflared binary is installed from npm after size and SHA-256 verification, so startup does not download from GitHub. Automatic mode attempts Cloudflare for at most 30 seconds, including connection and public health checks. Any failure falls back directly to LAN, where the phone must be on the same local network. Pinggy is only used when explicitly selected with `--relay pinggy`; a failed explicit relay also falls back to LAN.
|
|
115
|
+
|
|
116
|
+
Startup reuses a compatible installed Codex CLI. Only when none is found does it install `@openai/codex@latest`; Codex preparation is limited to 180 seconds. Installed OpenCode is reused, and missing OpenCode is skipped without downloading. If Codex installation fails or times out, the local service still starts with an explicit warning that Codex sessions are unavailable. Run `agentroam doctor` to repair Codex, then restart the service. Remote desktop uses the bundled native runtime and still requires system authorization.
|
|
115
117
|
|
|
116
118
|
## What's included
|
|
117
119
|
|
package/dist/abort.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Bound an asynchronous operation even when its implementation ignores cancellation. */
|
|
2
|
+
export async function withAbortSignal(operation, signal) {
|
|
3
|
+
signal.throwIfAborted();
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const abort = () => {
|
|
6
|
+
signal.removeEventListener("abort", abort);
|
|
7
|
+
reject(signal.reason);
|
|
8
|
+
};
|
|
9
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
10
|
+
Promise.resolve().then(() => {
|
|
11
|
+
signal.throwIfAborted();
|
|
12
|
+
return operation();
|
|
13
|
+
}).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export async function withTimeout(operation, timeoutMs, message) {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(new Error(message)), timeoutMs);
|
|
19
|
+
try {
|
|
20
|
+
return await withAbortSignal(() => operation(controller.signal), controller.signal);
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,8 @@ import { installedDataDir, pairingAdmin, printPairingCode, terminalText } from "
|
|
|
11
11
|
import { detectPlatform } from "./platform.js";
|
|
12
12
|
import { AGENTROAM_VERSION, resolvePlatformRuntime, resolvePlatformTui } from "./platform-packages.js";
|
|
13
13
|
import { acquireSleepInhibitor } from "./power/sleep-inhibitor.js";
|
|
14
|
-
import { RuntimeManager } from "./runtime-manager.js";
|
|
14
|
+
import { CODEX_PREPARATION_TIMEOUT_MS, RuntimeManager } from "./runtime-manager.js";
|
|
15
|
+
import { withTimeout } from "./abort.js";
|
|
15
16
|
import { ServiceRuntimeReporter } from "./service/runtime-state.js";
|
|
16
17
|
import { runServiceCommand } from "./service/service-command.js";
|
|
17
18
|
import { resolveServicePaths } from "./service/service-files.js";
|
|
@@ -197,7 +198,7 @@ async function executeCommand(options, argv) {
|
|
|
197
198
|
await serviceReporter?.localReady(runtime.localUrl);
|
|
198
199
|
if (!serviceReporter)
|
|
199
200
|
printLocalDesktopUrl(runtime.localUrl);
|
|
200
|
-
const lanUrl =
|
|
201
|
+
const lanUrl = findLanUrl(runtime.port) ?? runtime.localUrl;
|
|
201
202
|
const relayRuntime = runtime;
|
|
202
203
|
relay = await withCliProgress("正在建立手机访问连接", () => selectRelay({
|
|
203
204
|
cli: options,
|
|
@@ -207,9 +208,8 @@ async function executeCommand(options, argv) {
|
|
|
207
208
|
port: relayRuntime.port,
|
|
208
209
|
signal: controller.signal,
|
|
209
210
|
log: (line) => process.stderr.write(`${line}\n`),
|
|
210
|
-
onAttempt: (provider) => console.log(`▲
|
|
211
|
-
onFailure: (provider, message) => console.error(`⚠ ${providerDisplayName(provider)}
|
|
212
|
-
allowLanFallback: options.localOnly,
|
|
211
|
+
onAttempt: (provider) => console.log(`▲ 正在连接 ${providerDisplayName(provider)}(最多 30 秒,失败后使用局域网)…`),
|
|
212
|
+
onFailure: (provider, message) => console.error(`⚠ ${providerDisplayName(provider)} 不可用:${message};正在切换到局域网。`),
|
|
213
213
|
}));
|
|
214
214
|
if (controller.signal.aborted) {
|
|
215
215
|
await relay.tunnel?.close();
|
|
@@ -220,6 +220,7 @@ async function executeCommand(options, argv) {
|
|
|
220
220
|
console.error(` Continuing with local network access: ${relay.publicUrl}/web`);
|
|
221
221
|
else
|
|
222
222
|
console.log(`✓ Local network: ${relay.publicUrl}/web`);
|
|
223
|
+
console.log(lanUrl === runtime.localUrl ? "当前没有可用的局域网地址,可先在本机使用;连接 Wi-Fi 或有线网络后重启服务以生成手机访问地址。" : "手机与电脑连接同一局域网后,即可使用以上地址或二维码连接。");
|
|
223
224
|
}
|
|
224
225
|
else {
|
|
225
226
|
console.log(`✓ ${providerDisplayName(relay.provider)} tunnel ready: ${relay.publicUrl}`);
|
|
@@ -297,11 +298,12 @@ async function doctor(target, dataDir) {
|
|
|
297
298
|
reportDoctorFailure(error, "agent-tui: ");
|
|
298
299
|
}
|
|
299
300
|
try {
|
|
300
|
-
const codex = await withCliProgress("正在检查和准备 Codex", () => resolveCodexRuntime({
|
|
301
|
+
const codex = await withCliProgress("正在检查和准备 Codex(最多 180 秒)", () => withTimeout(signal => resolveCodexRuntime({
|
|
301
302
|
dataDir,
|
|
302
303
|
target,
|
|
304
|
+
signal,
|
|
303
305
|
onProgress: (message) => console.log(`… ${message}`),
|
|
304
|
-
}));
|
|
306
|
+
}), CODEX_PREPARATION_TIMEOUT_MS, "Codex 组件准备超时(180 秒);请检查网络后重试。"));
|
|
305
307
|
console.log(`✓ Codex ${codex.version} (${codex.source}) ${codex.executable}`);
|
|
306
308
|
}
|
|
307
309
|
catch (error) {
|
|
@@ -3,22 +3,25 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { access, chmod, mkdir, mkdtemp, open, readdir, rename, rm, stat, } from "node:fs/promises";
|
|
4
4
|
import { delimiter, dirname, isAbsolute, relative, resolve, win32 } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
+
import { withAbortSignal } from "./abort.js";
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
// Fresh installs follow npm's latest stable release; existing installs have an independent floor.
|
|
8
9
|
export const CODEX_RUNTIME_TAG = "latest";
|
|
9
10
|
export const CODEX_MINIMUM_VERSION = "0.153.0";
|
|
10
11
|
const CODEX_PACKAGE = `@openai/codex@${CODEX_RUNTIME_TAG}`;
|
|
11
12
|
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
12
|
-
export const CODEX_INSTALL_TIMEOUT_MS =
|
|
13
|
+
export const CODEX_INSTALL_TIMEOUT_MS = 180_000;
|
|
13
14
|
const DEFAULT_LOCK_WAIT_MS = 190_000;
|
|
14
15
|
export const CODEX_INSTALL_STALE_LOCK_MS = CODEX_INSTALL_TIMEOUT_MS + 5 * 60_000;
|
|
15
16
|
const DEFAULT_STALE_LOCK_MS = CODEX_INSTALL_STALE_LOCK_MS;
|
|
16
17
|
const defaultDependencies = {
|
|
17
|
-
async run(command, args, timeoutMs) {
|
|
18
|
+
async run(command, args, timeoutMs, signal) {
|
|
18
19
|
try {
|
|
19
20
|
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
20
21
|
encoding: "utf8",
|
|
21
22
|
timeout: timeoutMs,
|
|
23
|
+
signal,
|
|
24
|
+
killSignal: "SIGKILL",
|
|
22
25
|
maxBuffer: 4 * 1024 * 1024,
|
|
23
26
|
windowsHide: true,
|
|
24
27
|
});
|
|
@@ -36,9 +39,21 @@ const defaultDependencies = {
|
|
|
36
39
|
now: () => Date.now(),
|
|
37
40
|
};
|
|
38
41
|
export async function resolveCodexRuntime(options) {
|
|
42
|
+
return options.signal
|
|
43
|
+
? withAbortSignal(() => resolveRuntime(options), options.signal)
|
|
44
|
+
: resolveRuntime(options);
|
|
45
|
+
}
|
|
46
|
+
async function resolveRuntime(options) {
|
|
39
47
|
const environment = options.environment ?? process.env;
|
|
40
48
|
const platform = options.platform ?? process.platform;
|
|
41
49
|
const dependencies = { ...defaultDependencies, ...options.dependencies };
|
|
50
|
+
const signal = options.signal;
|
|
51
|
+
if (signal) {
|
|
52
|
+
const run = dependencies.run;
|
|
53
|
+
const sleep = dependencies.sleep;
|
|
54
|
+
dependencies.run = (command, args, timeoutMs) => withAbortSignal(() => run(command, args, timeoutMs, signal), signal);
|
|
55
|
+
dependencies.sleep = (delayMs) => withAbortSignal(() => sleep(delayMs), signal);
|
|
56
|
+
}
|
|
42
57
|
const explicit = environment.AGENT_CODEX_BIN?.trim();
|
|
43
58
|
if (explicit) {
|
|
44
59
|
const absolute = platform === "win32" ? win32.isAbsolute(explicit) : isAbsolute(explicit);
|
|
@@ -54,6 +69,9 @@ export async function resolveCodexRuntime(options) {
|
|
|
54
69
|
const managed = await findManagedRuntime(managedParent, options.target, platform, dependencies);
|
|
55
70
|
if (managed)
|
|
56
71
|
return managed;
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
if (options.installIfMissing === false)
|
|
74
|
+
throw new Error("Codex is not installed; run agentroam doctor to install it.");
|
|
57
75
|
options.onProgress?.(`Installing managed Codex from ${CODEX_PACKAGE}...`);
|
|
58
76
|
return installManagedCodex({
|
|
59
77
|
...options,
|
|
@@ -199,6 +217,7 @@ async function installManagedCodex(options) {
|
|
|
199
217
|
throw new Error(`Codex runtime validation failed after installing ${CODEX_PACKAGE}`);
|
|
200
218
|
}
|
|
201
219
|
const managedRoot = resolve(parent, temporary.version);
|
|
220
|
+
options.signal?.throwIfAborted();
|
|
202
221
|
// An invalid cache for this version must not block a validated replacement.
|
|
203
222
|
await rm(managedRoot, { recursive: true, force: true });
|
|
204
223
|
await rename(temporaryRoot, managedRoot);
|
|
@@ -209,6 +228,7 @@ async function installManagedCodex(options) {
|
|
|
209
228
|
return installed;
|
|
210
229
|
}
|
|
211
230
|
catch (error) {
|
|
231
|
+
options.signal?.throwIfAborted();
|
|
212
232
|
const concurrent = await findManagedRuntime(parent, options.target, options.platform, options.dependencies);
|
|
213
233
|
if (concurrent)
|
|
214
234
|
return concurrent;
|
package/dist/codex-sharing.js
CHANGED
|
@@ -47,7 +47,7 @@ export async function configureCodexSharing(options, context = {}) {
|
|
|
47
47
|
};
|
|
48
48
|
const json = `${paths.root}/job.json`;
|
|
49
49
|
const temporary = `${paths.root}/job.plist`;
|
|
50
|
-
const commandOptions = { encoding: "utf8", timeout: 20_000, maxBuffer: 1024 * 1024 };
|
|
50
|
+
const commandOptions = { encoding: "utf8", timeout: 20_000, maxBuffer: 1024 * 1024, signal: context.signal, killSignal: "SIGKILL" };
|
|
51
51
|
// Identical startup calls must not unload/reload an already healthy job.
|
|
52
52
|
let unchanged = false;
|
|
53
53
|
try {
|
package/dist/native-runtime.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { chmod } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execute = promisify(execFile);
|
|
6
|
+
const WINDOWS_PTY_PROBE = `
|
|
7
|
+
const pty = require(process.argv[1]);
|
|
8
|
+
const marker = "agentroam-runtime-probe";
|
|
9
|
+
const terminal = pty.spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "echo " + marker], {
|
|
10
|
+
name: "xterm-256color", cols: 80, rows: 24, cwd: process.cwd(), env: process.env,
|
|
11
|
+
});
|
|
12
|
+
let output = "";
|
|
13
|
+
const timer = setTimeout(() => process.exit(1), 5000);
|
|
14
|
+
terminal.onData(chunk => { output = (output + chunk).slice(-4096); });
|
|
15
|
+
terminal.onExit(({ exitCode }) => {
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
process.exit(exitCode === 0 && output.includes(marker) ? 0 : 1);
|
|
18
|
+
});
|
|
19
|
+
`;
|
|
3
20
|
export function probeSQLiteRuntime(runtimeRequire) {
|
|
4
21
|
const Database = runtimeRequire("better-sqlite3");
|
|
5
22
|
const database = new Database(":memory:");
|
|
@@ -15,12 +32,17 @@ export function probeSQLiteRuntime(runtimeRequire) {
|
|
|
15
32
|
}
|
|
16
33
|
}
|
|
17
34
|
export async function probeTerminalRuntime(runtimeRequire, platform = process.platform) {
|
|
35
|
+
if (platform === "win32") {
|
|
36
|
+
// ConPTY retains an output worker after shell exit. Keep the probe in an
|
|
37
|
+
// owned child process so terminating it releases all handles together.
|
|
38
|
+
await execute(process.execPath, ["--input-type=commonjs", "-e", WINDOWS_PTY_PROBE, runtimeRequire.resolve("node-pty")], {
|
|
39
|
+
timeout: 10_000, windowsHide: true, maxBuffer: 64 * 1024,
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
18
43
|
const pty = runtimeRequire("node-pty");
|
|
19
|
-
const windows = platform === "win32";
|
|
20
44
|
const marker = "agentroam-runtime-probe";
|
|
21
|
-
const terminal = pty.spawn(
|
|
22
|
-
? ["/d", "/s", "/c", `echo ${marker}`]
|
|
23
|
-
: ["-c", `printf ${marker}`], {
|
|
45
|
+
const terminal = pty.spawn("/bin/sh", ["-c", `printf ${marker}`], {
|
|
24
46
|
name: "xterm-256color", cols: 80, rows: 24, cwd: process.cwd(), env: process.env,
|
|
25
47
|
});
|
|
26
48
|
await new Promise((resolveProbe, rejectProbe) => {
|
|
@@ -4,6 +4,7 @@ import { access, chmod, mkdir, mkdtemp, open, rename, rm, stat } from "node:fs/p
|
|
|
4
4
|
import { delimiter, isAbsolute, relative, resolve, win32 } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { resolveNpmExecutor } from "./codex-runtime-manager.js";
|
|
7
|
+
import { withAbortSignal } from "./abort.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
// Default managed installation; compatibility has an independent minimum.
|
|
9
10
|
export const OPENCODE_RUNTIME_VERSION = "1.18.27";
|
|
@@ -11,11 +12,13 @@ export const OPENCODE_MINIMUM_VERSION = "1.18.27";
|
|
|
11
12
|
const OPENCODE_PACKAGE = `opencode-ai@${OPENCODE_RUNTIME_VERSION}`;
|
|
12
13
|
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
13
14
|
const defaults = {
|
|
14
|
-
async run(command, args, timeoutMs) {
|
|
15
|
+
async run(command, args, timeoutMs, signal) {
|
|
15
16
|
try {
|
|
16
17
|
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
17
18
|
encoding: "utf8",
|
|
18
19
|
timeout: timeoutMs,
|
|
20
|
+
signal,
|
|
21
|
+
killSignal: "SIGKILL",
|
|
19
22
|
maxBuffer: 4 * 1024 * 1024,
|
|
20
23
|
windowsHide: true,
|
|
21
24
|
});
|
|
@@ -30,9 +33,21 @@ const defaults = {
|
|
|
30
33
|
now: () => Date.now(),
|
|
31
34
|
};
|
|
32
35
|
export async function resolveOpenCodeRuntime(options) {
|
|
36
|
+
return options.signal
|
|
37
|
+
? withAbortSignal(() => resolveRuntime(options), options.signal)
|
|
38
|
+
: resolveRuntime(options);
|
|
39
|
+
}
|
|
40
|
+
async function resolveRuntime(options) {
|
|
33
41
|
const environment = options.environment ?? process.env;
|
|
34
42
|
const platform = options.platform ?? process.platform;
|
|
35
43
|
const dependencies = { ...defaults, ...options.dependencies };
|
|
44
|
+
const signal = options.signal;
|
|
45
|
+
if (signal) {
|
|
46
|
+
const run = dependencies.run;
|
|
47
|
+
const sleep = dependencies.sleep;
|
|
48
|
+
dependencies.run = (command, args, timeoutMs) => withAbortSignal(() => run(command, args, timeoutMs, signal), signal);
|
|
49
|
+
dependencies.sleep = (delayMs) => withAbortSignal(() => sleep(delayMs), signal);
|
|
50
|
+
}
|
|
36
51
|
const explicit = environment.AGENT_OPENCODE_BIN?.trim();
|
|
37
52
|
if (explicit) {
|
|
38
53
|
const absolute = platform === "win32" ? win32.isAbsolute(explicit) : isAbsolute(explicit);
|
|
@@ -47,6 +62,9 @@ export async function resolveOpenCodeRuntime(options) {
|
|
|
47
62
|
const managed = await validateManaged(managedRoot, platform, dependencies);
|
|
48
63
|
if (managed)
|
|
49
64
|
return managed;
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
if (options.installIfMissing === false)
|
|
67
|
+
throw new Error("OpenCode is not installed; optional OpenCode sessions are unavailable.");
|
|
50
68
|
options.onProgress?.(`Installing managed OpenCode ${OPENCODE_RUNTIME_VERSION}...`);
|
|
51
69
|
return install({ ...options, environment, platform, dependencies, managedRoot });
|
|
52
70
|
}
|
|
@@ -114,6 +132,7 @@ async function install(options) {
|
|
|
114
132
|
const validated = await validateManaged(temporary, options.platform, options.dependencies);
|
|
115
133
|
if (!validated)
|
|
116
134
|
throw new Error(`validation failed after installing ${OPENCODE_PACKAGE}`);
|
|
135
|
+
options.signal?.throwIfAborted();
|
|
117
136
|
await rename(temporary, options.managedRoot);
|
|
118
137
|
temporary = null;
|
|
119
138
|
const activated = await validateManaged(options.managedRoot, options.platform, options.dependencies);
|
|
@@ -122,6 +141,7 @@ async function install(options) {
|
|
|
122
141
|
return activated;
|
|
123
142
|
}
|
|
124
143
|
catch (error) {
|
|
144
|
+
options.signal?.throwIfAborted();
|
|
125
145
|
const concurrent = await validateManaged(options.managedRoot, options.platform, options.dependencies);
|
|
126
146
|
if (concurrent)
|
|
127
147
|
return concurrent;
|
package/dist/runtime-manager.js
CHANGED
|
@@ -9,6 +9,10 @@ import { findAvailablePort } from "./port.js";
|
|
|
9
9
|
import { ProcessSupervisor } from "./process-supervisor.js";
|
|
10
10
|
import { configureCodexSharing } from "./codex-sharing.js";
|
|
11
11
|
import { withCliProgress } from "../bin/cli-progress.mjs";
|
|
12
|
+
import { withAbortSignal, withTimeout } from "./abort.js";
|
|
13
|
+
export const CODEX_PREPARATION_TIMEOUT_MS = 180_000;
|
|
14
|
+
const OPTIONAL_RUNTIME_CHECK_TIMEOUT_MS = 60_000;
|
|
15
|
+
const CODEX_SHARING_TIMEOUT_MS = 10_000;
|
|
12
16
|
export class RuntimeManager {
|
|
13
17
|
supervisor;
|
|
14
18
|
codexResolver;
|
|
@@ -24,16 +28,15 @@ export class RuntimeManager {
|
|
|
24
28
|
const port = await findAvailablePort(options.port);
|
|
25
29
|
const dataDir = resolve(options.dataDir);
|
|
26
30
|
await Promise.all(["data", "bin", "cache", "logs"].map((name) => mkdir(resolve(dataDir, name), { recursive: true })));
|
|
27
|
-
const
|
|
28
|
-
if (process.platform === "darwin" &&
|
|
31
|
+
const childEnvironment = await withCliProgress("正在检查 Agent 组件;缺少 Codex 时自动安装最新版,最多等待 180 秒", () => prepareAgentRuntimeEnvironment(process.env, { dataDir, target }, this.codexResolver, this.report, this.opencodeResolver));
|
|
32
|
+
if (process.platform === "darwin" && childEnvironment.AGENT_CODEX_BIN) {
|
|
29
33
|
try {
|
|
30
|
-
await configureCodexSharing(options, { environment:
|
|
34
|
+
await withTimeout(signal => configureCodexSharing(options, { environment: childEnvironment, signal }), CODEX_SHARING_TIMEOUT_MS, "Codex 共享后台配置超时(10 秒)");
|
|
31
35
|
}
|
|
32
36
|
catch (error) {
|
|
33
37
|
this.report(`Codex 共享启动配置未完成,可在电脑菜单中修复:${error instanceof Error ? error.message : error}`);
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
|
-
const childEnvironment = await withCliProgress("正在准备 OpenCode 组件", () => prepareOpenCodeRuntimeEnvironment(codexEnvironment, { dataDir, target }, this.opencodeResolver, this.report));
|
|
37
40
|
const { runtimeRoot } = resolvePlatformRuntime(target);
|
|
38
41
|
await repairNativeRuntimePermissions(runtimeRoot);
|
|
39
42
|
const gateway = resolve(runtimeRoot, "ws-server.mjs");
|
|
@@ -44,7 +47,7 @@ export class RuntimeManager {
|
|
|
44
47
|
NODE_ENV: "production",
|
|
45
48
|
NEXT_DIST_DIR: ".next",
|
|
46
49
|
PORT: String(port),
|
|
47
|
-
HOST:
|
|
50
|
+
HOST: "0.0.0.0",
|
|
48
51
|
AGENT_DATA_DIR: resolve(dataDir, "data"),
|
|
49
52
|
// 应用日志收口到 <dataDir>/logs,与 launchd 的 service.stdout/stderr.log
|
|
50
53
|
// 及 CLI 自身的错误日志同一个目录,用户排障时只需打包这一处。
|
|
@@ -63,7 +66,14 @@ export class RuntimeManager {
|
|
|
63
66
|
child.once("exit", (code, signal) => resolveExit({ code, signal }));
|
|
64
67
|
});
|
|
65
68
|
const localUrl = `http://127.0.0.1:${port}`;
|
|
66
|
-
|
|
69
|
+
let status;
|
|
70
|
+
try {
|
|
71
|
+
status = await withCliProgress("正在等待本机服务就绪", () => waitForHealth(`${localUrl}/api/web-auth/status`, child, 20_000));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
await this.supervisor.stopAll();
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
67
77
|
return {
|
|
68
78
|
port,
|
|
69
79
|
localUrl,
|
|
@@ -75,15 +85,29 @@ export class RuntimeManager {
|
|
|
75
85
|
};
|
|
76
86
|
}
|
|
77
87
|
}
|
|
88
|
+
export async function prepareAgentRuntimeEnvironment(environment, options, codexResolver = resolveCodexRuntime, report = (message) => process.stderr.write(`${message}\n`), opencodeResolver = resolveOpenCodeRuntime) {
|
|
89
|
+
const [codex, opencode] = await Promise.all([
|
|
90
|
+
prepareCodexRuntimeEnvironment(environment, options, codexResolver, report),
|
|
91
|
+
prepareOpenCodeRuntimeEnvironment(environment, options, opencodeResolver, report),
|
|
92
|
+
]);
|
|
93
|
+
return {
|
|
94
|
+
...codex,
|
|
95
|
+
AGENT_OPENCODE_BIN: opencode.AGENT_OPENCODE_BIN,
|
|
96
|
+
AGENT_OPENCODE_RUNTIME_ERROR: opencode.AGENT_OPENCODE_RUNTIME_ERROR,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
78
99
|
export async function prepareCodexRuntimeEnvironment(environment, options, resolver = resolveCodexRuntime, report = (message) => process.stderr.write(`${message}\n`)) {
|
|
79
100
|
const childEnvironment = { ...environment };
|
|
101
|
+
const timeoutMs = options.timeoutMs ?? CODEX_PREPARATION_TIMEOUT_MS;
|
|
80
102
|
report(`Checking Codex ${options.target} runtime...`);
|
|
81
103
|
try {
|
|
82
|
-
const resolution = await resolver({
|
|
83
|
-
|
|
104
|
+
const resolution = await withTimeout(signal => resolver({
|
|
105
|
+
dataDir: options.dataDir,
|
|
106
|
+
target: options.target,
|
|
107
|
+
signal,
|
|
84
108
|
environment,
|
|
85
109
|
onProgress: report,
|
|
86
|
-
});
|
|
110
|
+
}), timeoutMs, `Codex 组件准备超时(${timeoutMs / 1000} 秒);请检查网络后运行 agentroam doctor 重试。`);
|
|
87
111
|
childEnvironment.AGENT_CODEX_BIN = resolution.executable;
|
|
88
112
|
delete childEnvironment.AGENT_CODEX_RUNTIME_ERROR;
|
|
89
113
|
report(`Codex ${resolution.version} (${resolution.source}): ${resolution.executable}`);
|
|
@@ -92,7 +116,7 @@ export async function prepareCodexRuntimeEnvironment(environment, options, resol
|
|
|
92
116
|
delete childEnvironment.AGENT_CODEX_BIN;
|
|
93
117
|
const message = error instanceof Error ? error.message : String(error);
|
|
94
118
|
childEnvironment.AGENT_CODEX_RUNTIME_ERROR = message;
|
|
95
|
-
report(
|
|
119
|
+
report(`⚠ Codex 会话功能未就绪,局域网服务将继续启动;运行 agentroam doctor 修复后重启服务:${message}`);
|
|
96
120
|
}
|
|
97
121
|
return childEnvironment;
|
|
98
122
|
}
|
|
@@ -100,7 +124,9 @@ export async function prepareOpenCodeRuntimeEnvironment(environment, options, re
|
|
|
100
124
|
const childEnvironment = { ...environment };
|
|
101
125
|
report(`Checking OpenCode ${options.target} runtime...`);
|
|
102
126
|
try {
|
|
103
|
-
const resolution = await resolver({
|
|
127
|
+
const resolution = await withTimeout(signal => resolver({
|
|
128
|
+
dataDir: options.dataDir, target: options.target, environment, signal, installIfMissing: false, onProgress: report,
|
|
129
|
+
}), options.timeoutMs ?? OPTIONAL_RUNTIME_CHECK_TIMEOUT_MS, "OpenCode 组件检查超时;已跳过,不下载安装。");
|
|
104
130
|
childEnvironment.AGENT_OPENCODE_BIN = resolution.executable;
|
|
105
131
|
delete childEnvironment.AGENT_OPENCODE_RUNTIME_ERROR;
|
|
106
132
|
report(`OpenCode ${resolution.version} (${resolution.source}): ${resolution.executable}`);
|
|
@@ -109,22 +135,33 @@ export async function prepareOpenCodeRuntimeEnvironment(environment, options, re
|
|
|
109
135
|
delete childEnvironment.AGENT_OPENCODE_BIN;
|
|
110
136
|
const message = error instanceof Error ? error.message : String(error);
|
|
111
137
|
childEnvironment.AGENT_OPENCODE_RUNTIME_ERROR = message;
|
|
112
|
-
report(`OpenCode
|
|
138
|
+
report(`OpenCode 不可用,已跳过(不自动安装):${message}`);
|
|
113
139
|
}
|
|
114
140
|
return childEnvironment;
|
|
115
141
|
}
|
|
116
142
|
async function waitForHealth(url, child, timeoutMs) {
|
|
117
143
|
const end = Date.now() + timeoutMs;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const timer = setTimeout(() => controller.abort(new Error("local server health check timed out")), timeoutMs);
|
|
146
|
+
try {
|
|
147
|
+
return await withAbortSignal(async () => {
|
|
148
|
+
while (Date.now() < end) {
|
|
149
|
+
controller.signal.throwIfAborted();
|
|
150
|
+
if (child.exitCode !== null)
|
|
151
|
+
throw new Error(`local server exited with ${child.exitCode}`);
|
|
152
|
+
try {
|
|
153
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
154
|
+
if (response.ok)
|
|
155
|
+
return response.json();
|
|
156
|
+
void response.body?.cancel().catch(() => { });
|
|
157
|
+
}
|
|
158
|
+
catch { }
|
|
159
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
160
|
+
}
|
|
161
|
+
throw new Error("local server health check timed out");
|
|
162
|
+
}, controller.signal);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
clearTimeout(timer);
|
|
128
166
|
}
|
|
129
|
-
throw new Error("local server health check timed out");
|
|
130
167
|
}
|
|
@@ -19,7 +19,8 @@ export class MacLaunchAgent {
|
|
|
19
19
|
this.runner = options.runner ?? runCommand;
|
|
20
20
|
this.processExists = options.processExists ?? isProcessRunning;
|
|
21
21
|
this.now = options.now ?? (() => new Date());
|
|
22
|
-
|
|
22
|
+
// Codex preparation (180s), sharing (10s), local server (20s), relay (30s), plus cleanup.
|
|
23
|
+
this.readyTimeoutMs = options.readyTimeoutMs ?? 270_000;
|
|
23
24
|
this.stopTimeoutMs = options.stopTimeoutMs ?? 15_000;
|
|
24
25
|
this.pollIntervalMs = options.pollIntervalMs ?? 250;
|
|
25
26
|
}
|
|
@@ -12,6 +12,7 @@ import { configureCodexSharing } from "../codex-sharing.js";
|
|
|
12
12
|
import { setupCodexDesktop } from "../codex-desktop-setup.js";
|
|
13
13
|
import { authorizeRemoteDesktop } from "../desktop-authorization.js";
|
|
14
14
|
import { withCliProgress } from "../../bin/cli-progress.mjs";
|
|
15
|
+
import { withTimeout } from "../abort.js";
|
|
15
16
|
export async function runServiceCommand(options, context) {
|
|
16
17
|
const platform = context.platform ?? process.platform;
|
|
17
18
|
const log = context.log ?? console.log;
|
|
@@ -33,18 +34,20 @@ export async function runServiceCommand(options, context) {
|
|
|
33
34
|
const environmentPath = buildServiceEnvironmentPath(context.nodePath, environment.PATH ?? environment.Path, platform);
|
|
34
35
|
let codexPath;
|
|
35
36
|
try {
|
|
36
|
-
const codex = await withCliProgress("
|
|
37
|
+
const codex = await withCliProgress("正在检查已有 Codex;缺失时由后台服务限时安装最新版", () => withTimeout(signal => (context.codexResolver ?? resolveCodexRuntime)({
|
|
37
38
|
dataDir: options.dataDir,
|
|
38
39
|
target: detectPlatform(platform, context.arch ?? process.arch, nodeVersion),
|
|
39
40
|
environment: { ...environment, PATH: environmentPath },
|
|
40
41
|
platform,
|
|
41
42
|
nodeExecutable: context.nodePath,
|
|
43
|
+
installIfMissing: false,
|
|
44
|
+
signal,
|
|
42
45
|
onProgress: (message) => log(`… ${message}`),
|
|
43
|
-
}));
|
|
46
|
+
}), 60_000, "检查已有 Codex 超时;后台服务将继续检查。"));
|
|
44
47
|
codexPath = codex.executable;
|
|
45
48
|
}
|
|
46
49
|
catch (error) {
|
|
47
|
-
log(
|
|
50
|
+
log(`Codex 将在后台服务启动时检查并限时补齐:${error instanceof Error ? error.message : error}`);
|
|
48
51
|
}
|
|
49
52
|
const config = {
|
|
50
53
|
version: context.version,
|
|
@@ -63,17 +66,20 @@ export async function runServiceCommand(options, context) {
|
|
|
63
66
|
};
|
|
64
67
|
if (platform === "darwin" && codexPath) {
|
|
65
68
|
try {
|
|
66
|
-
await (context.configureSharing ?? configureCodexSharing)(options, {
|
|
69
|
+
await withTimeout(signal => (context.configureSharing ?? configureCodexSharing)(options, {
|
|
70
|
+
homeDir: context.homeDir, environment: { ...environment, PATH: environmentPath, AGENT_CODEX_BIN: codexPath }, signal,
|
|
71
|
+
}), 10_000, "Codex 默认共享配置超时(10 秒)");
|
|
67
72
|
}
|
|
68
73
|
catch (error) {
|
|
69
74
|
log(`Codex 默认共享配置未完成,可在电脑菜单中修复:${error instanceof Error ? error.message : error}`);
|
|
70
75
|
}
|
|
71
76
|
}
|
|
72
|
-
const { definition, state } = await withCliProgress("
|
|
77
|
+
const { definition, state } = await withCliProgress("正在启动后台服务;Codex 准备最多 180 秒,公网连接最多等待 30 秒,失败后使用局域网", () => controller.install(config));
|
|
73
78
|
log(`✓ AgentRoam service installed: ${definition}`);
|
|
74
79
|
if (state?.localUrl && state.status !== "stopped")
|
|
75
80
|
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
76
81
|
requireReadyService(state);
|
|
82
|
+
printLanFallback(state, log);
|
|
77
83
|
if (state?.localUrl && state.status !== "stopped" && (platform === "darwin" || platform === "win32")) {
|
|
78
84
|
if (environment.AGENTROAM_INSTALL_REMOTE_DESKTOP === "skip") {
|
|
79
85
|
log("已跳过安装时远程桌面授权,可稍后执行 agentroam desktop authorize。");
|
|
@@ -100,10 +106,11 @@ export async function runServiceCommand(options, context) {
|
|
|
100
106
|
return;
|
|
101
107
|
}
|
|
102
108
|
case "start": {
|
|
103
|
-
const state = await withCliProgress("
|
|
109
|
+
const state = await withCliProgress("正在启动后台服务;Codex 准备最多 180 秒,公网连接最多等待 30 秒,失败后使用局域网", () => controller.start());
|
|
104
110
|
if (state?.localUrl && state.status !== "stopped")
|
|
105
111
|
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
106
112
|
requireReadyService(state);
|
|
113
|
+
printLanFallback(state, log);
|
|
107
114
|
log("✓ AgentRoam service started");
|
|
108
115
|
await logAccessUrl(state.accessUrl);
|
|
109
116
|
return;
|
|
@@ -126,6 +133,7 @@ export async function runServiceCommand(options, context) {
|
|
|
126
133
|
if (status.running && status.state?.localUrl)
|
|
127
134
|
printLocalDesktopUrl(status.state.localUrl, log, platform);
|
|
128
135
|
if (status.running && status.state?.status === "ready" && status.state.accessUrl) {
|
|
136
|
+
printLanFallback(status.state, log);
|
|
129
137
|
await logAccessUrl(status.state.accessUrl);
|
|
130
138
|
}
|
|
131
139
|
return;
|
|
@@ -144,10 +152,11 @@ export async function runServiceCommand(options, context) {
|
|
|
144
152
|
return;
|
|
145
153
|
}
|
|
146
154
|
case "restart": {
|
|
147
|
-
const state = await withCliProgress("
|
|
155
|
+
const state = await withCliProgress("正在重启后台服务;Codex 准备最多 180 秒,公网连接最多等待 30 秒,失败后使用局域网", () => controller.restart());
|
|
148
156
|
if (state?.localUrl && state.status !== "stopped")
|
|
149
157
|
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
150
158
|
requireReadyService(state);
|
|
159
|
+
printLanFallback(state, log);
|
|
151
160
|
log("✓ AgentRoam service restarted");
|
|
152
161
|
await logAccessUrl(state.accessUrl);
|
|
153
162
|
return;
|
|
@@ -162,6 +171,15 @@ export async function runServiceCommand(options, context) {
|
|
|
162
171
|
throw cliError("service action is required");
|
|
163
172
|
}
|
|
164
173
|
}
|
|
174
|
+
function printLanFallback(state, log) {
|
|
175
|
+
if (state.provider !== "lan")
|
|
176
|
+
return;
|
|
177
|
+
if (state.accessUrl && ["127.0.0.1", "localhost", "[::1]"].includes(new URL(state.accessUrl).hostname)) {
|
|
178
|
+
log("✓ 本机服务已启动;当前没有局域网地址,连接 Wi-Fi 或有线网络后重启服务即可生成手机访问地址。");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
log("✓ 后台服务已通过局域网启动;手机与电脑连接同一局域网后扫码连接。");
|
|
182
|
+
}
|
|
165
183
|
function requireReadyService(state) {
|
|
166
184
|
if (state?.status !== "ready" || !state.accessUrl) {
|
|
167
185
|
throw Object.assign(new Error(`AgentRoam 后台服务未就绪(${state?.status ?? "未取得状态"})。请运行 agentroam service logs 查看原因,修复后运行 agentroam service start,再执行 agentroam pair。`), { exitCode: 1 });
|
|
@@ -17,7 +17,8 @@ export class WindowsTaskService {
|
|
|
17
17
|
this.runner = options.runner ?? runCommand;
|
|
18
18
|
this.processExists = options.processExists ?? isProcessRunning;
|
|
19
19
|
this.now = options.now ?? (() => new Date());
|
|
20
|
-
|
|
20
|
+
// Leave room for bounded component preparation, local readiness and relay fallback.
|
|
21
|
+
this.readyTimeoutMs = options.readyTimeoutMs ?? 270_000;
|
|
21
22
|
this.stopTimeoutMs = options.stopTimeoutMs ?? 15_000;
|
|
22
23
|
this.pollIntervalMs = options.pollIntervalMs ?? 250;
|
|
23
24
|
this.validate = options.validateConfig ?? validateWindowsConfig;
|
|
@@ -219,7 +220,7 @@ $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
|
219
220
|
$action = New-ScheduledTaskAction -Execute ${powerShellLiteral(config.nodePath)} -Argument ${powerShellLiteral(argumentsValue)} -WorkingDirectory ${powerShellLiteral(config.roots[0])}
|
|
220
221
|
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
|
|
221
222
|
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
|
|
222
|
-
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -RestartCount 999 -RestartInterval (New-TimeSpan -
|
|
223
|
+
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
|
223
224
|
Register-ScheduledTask -TaskName ${powerShellLiteral(WINDOWS_SERVICE_NAME)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
|
224
225
|
`;
|
|
225
226
|
}
|
|
@@ -1,64 +1,83 @@
|
|
|
1
1
|
import { fetch as undiciFetch, ProxyAgent } from "undici";
|
|
2
2
|
import { resolveProxyForUrl } from "./proxy-settings.js";
|
|
3
3
|
import { AGENTROAM_VERSION } from "../platform-packages.js";
|
|
4
|
+
import { withAbortSignal } from "../abort.js";
|
|
4
5
|
export async function waitForPublicReadiness(publicUrl, options = {}) {
|
|
5
6
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
6
7
|
const intervalMs = options.intervalMs ?? 750;
|
|
7
8
|
const endpoint = new URL("/api/web-auth/status", `${publicUrl.replace(/\/$/, "")}/`);
|
|
8
|
-
|
|
9
|
+
const deadline = Date.now() + timeoutMs;
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
let lastFailure = "no response";
|
|
12
|
+
const timer = setTimeout(() => {
|
|
13
|
+
const detail = lastFailure === "no response" ? "request timed out" : `${lastFailure} (request timed out)`;
|
|
14
|
+
controller.abort(new Error(`public tunnel not ready: ${detail}`));
|
|
15
|
+
}, timeoutMs);
|
|
16
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
17
|
+
const connection = { proxy: null };
|
|
9
18
|
let fetchImpl = options.fetchImpl;
|
|
10
|
-
if (!fetchImpl) {
|
|
11
|
-
const proxyResolver = options.proxyResolver ?? resolveProxyForUrl;
|
|
12
|
-
const proxyUrl = await proxyResolver(endpoint).catch(() => null);
|
|
13
|
-
if (proxyUrl)
|
|
14
|
-
proxyFetch = (options.proxyFetchFactory ?? createProxyFetch)(proxyUrl);
|
|
15
|
-
fetchImpl = proxyFetch?.fetch ?? fetch;
|
|
16
|
-
}
|
|
17
19
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
20
|
+
await withAbortSignal(async () => {
|
|
21
|
+
if (!fetchImpl) {
|
|
22
|
+
const proxyResolver = options.proxyResolver ?? resolveProxyForUrl;
|
|
23
|
+
const proxyUrl = await proxyResolver(endpoint).catch(() => null);
|
|
24
|
+
signal.throwIfAborted();
|
|
25
|
+
if (proxyUrl)
|
|
26
|
+
connection.proxy = (options.proxyFetchFactory ?? createProxyFetch)(proxyUrl);
|
|
27
|
+
fetchImpl = connection.proxy?.fetch ?? fetch;
|
|
28
|
+
}
|
|
29
|
+
while (Date.now() <= deadline) {
|
|
30
|
+
signal.throwIfAborted();
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetchImpl(endpoint, {
|
|
33
|
+
cache: "no-store",
|
|
34
|
+
headers: { "user-agent": `agentroam-readiness/${AGENTROAM_VERSION}` },
|
|
35
|
+
signal,
|
|
36
|
+
});
|
|
37
|
+
if (response.ok) {
|
|
38
|
+
let body;
|
|
39
|
+
try {
|
|
40
|
+
body = (await response.json());
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new FatalReadinessError("HTTP 200 with invalid auth status JSON");
|
|
44
|
+
}
|
|
45
|
+
signal.throwIfAborted();
|
|
46
|
+
if (typeof body.authenticated === "boolean" && typeof body.needsSetup === "boolean")
|
|
47
|
+
return;
|
|
35
48
|
throw new FatalReadinessError("HTTP 200 with invalid auth status JSON");
|
|
36
49
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
else {
|
|
51
|
+
lastFailure = `HTTP ${response.status}`;
|
|
52
|
+
void response.body?.cancel().catch(() => { });
|
|
53
|
+
if (![404, 502, 503, 530].includes(response.status))
|
|
54
|
+
throw new FatalReadinessError(lastFailure);
|
|
55
|
+
}
|
|
40
56
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (options.signal?.aborted)
|
|
59
|
+
throw new Error("public tunnel readiness aborted");
|
|
60
|
+
signal.throwIfAborted();
|
|
61
|
+
if (error instanceof FatalReadinessError)
|
|
62
|
+
throw new Error(`public tunnel not ready: ${error.message}`);
|
|
63
|
+
lastFailure = error instanceof Error ? error.message : String(error);
|
|
45
64
|
}
|
|
65
|
+
if (Date.now() + intervalMs > deadline)
|
|
66
|
+
break;
|
|
67
|
+
await delay(intervalMs, signal);
|
|
46
68
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (Date.now() + intervalMs > deadline)
|
|
55
|
-
break;
|
|
56
|
-
await delay(intervalMs, options.signal);
|
|
57
|
-
}
|
|
58
|
-
throw new Error(`public tunnel not ready: ${lastFailure}`);
|
|
69
|
+
throw new Error(`public tunnel not ready: ${lastFailure}`);
|
|
70
|
+
}, signal);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (options.signal?.aborted)
|
|
74
|
+
throw new Error("public tunnel readiness aborted");
|
|
75
|
+
throw error;
|
|
59
76
|
}
|
|
60
77
|
finally {
|
|
61
|
-
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
if (connection.proxy)
|
|
80
|
+
await withAbortSignal(() => connection.proxy.close(), AbortSignal.timeout(3_000)).catch(() => { });
|
|
62
81
|
}
|
|
63
82
|
}
|
|
64
83
|
class FatalReadinessError extends Error {
|
|
@@ -68,16 +87,22 @@ function createProxyFetch(proxyUrl) {
|
|
|
68
87
|
return {
|
|
69
88
|
fetch: async (input, init) => (await undiciFetch(input, { ...init, dispatcher })),
|
|
70
89
|
close: async () => {
|
|
71
|
-
await dispatcher.
|
|
90
|
+
await dispatcher.destroy();
|
|
72
91
|
},
|
|
73
92
|
};
|
|
74
93
|
}
|
|
75
94
|
function delay(milliseconds, signal) {
|
|
76
95
|
return new Promise((resolvePromise, reject) => {
|
|
77
|
-
const
|
|
78
|
-
signal?.addEventListener("abort", () => {
|
|
96
|
+
const abort = () => {
|
|
79
97
|
clearTimeout(timer);
|
|
80
|
-
reject(
|
|
81
|
-
}
|
|
98
|
+
reject(signal?.reason);
|
|
99
|
+
};
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
signal?.removeEventListener("abort", abort);
|
|
102
|
+
resolvePromise();
|
|
103
|
+
}, milliseconds);
|
|
104
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
105
|
+
if (signal?.aborted)
|
|
106
|
+
abort();
|
|
82
107
|
});
|
|
83
108
|
}
|
|
@@ -3,33 +3,49 @@ import { CloudflareTunnelProvider } from "./cloudflare-provider.js";
|
|
|
3
3
|
import { CustomCommandTunnelProvider } from "./custom-command-provider.js";
|
|
4
4
|
import { PinggyTunnelProvider } from "./pinggy-provider.js";
|
|
5
5
|
import { waitForPublicReadiness } from "./public-readiness.js";
|
|
6
|
+
import { withAbortSignal } from "../abort.js";
|
|
7
|
+
export const RELAY_START_TIMEOUT_MS = 30_000;
|
|
6
8
|
export async function selectRelay(options) {
|
|
9
|
+
options.signal.throwIfAborted();
|
|
7
10
|
if (options.cli.localOnly)
|
|
8
11
|
return { tunnel: null, publicUrl: options.lanUrl, provider: "lan", failures: [] };
|
|
9
|
-
const providers = options.cli.relay === "auto" ? ["cloudflare"
|
|
12
|
+
const providers = options.cli.relay === "auto" ? ["cloudflare"] : [options.cli.relay];
|
|
10
13
|
const failures = [];
|
|
11
14
|
const readiness = options.readiness ?? waitForPublicReadiness;
|
|
12
15
|
for (const providerName of providers) {
|
|
13
16
|
options.onAttempt?.(providerName);
|
|
14
|
-
|
|
17
|
+
const attempt = { tunnel: null };
|
|
18
|
+
const deadline = new AbortController();
|
|
19
|
+
const timeoutMs = options.timeoutMs ?? RELAY_START_TIMEOUT_MS;
|
|
20
|
+
const timer = setTimeout(() => deadline.abort(new Error(`relay connection timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
21
|
+
const signal = AbortSignal.any([options.signal, deadline.signal]);
|
|
15
22
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
return await withAbortSignal(async () => {
|
|
24
|
+
const provider = await createProvider(providerName, options);
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
const handle = await provider.start({ localUrl: options.localUrl, signal, log: options.log });
|
|
27
|
+
if (signal.aborted) {
|
|
28
|
+
void handle.close().catch(() => { });
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
}
|
|
31
|
+
attempt.tunnel = handle;
|
|
32
|
+
await readiness(handle.publicUrl, { signal });
|
|
33
|
+
signal.throwIfAborted();
|
|
34
|
+
return { tunnel: handle, publicUrl: handle.publicUrl, provider: providerName, failures };
|
|
35
|
+
}, signal);
|
|
20
36
|
}
|
|
21
37
|
catch (error) {
|
|
22
|
-
|
|
38
|
+
deadline.abort(error);
|
|
39
|
+
if (attempt.tunnel)
|
|
40
|
+
await withAbortSignal(() => attempt.tunnel.close(), AbortSignal.timeout(3_000)).catch(() => { });
|
|
41
|
+
options.signal.throwIfAborted();
|
|
23
42
|
const message = error instanceof Error ? error.message : String(error);
|
|
24
43
|
failures.push({ provider: providerName, message });
|
|
25
44
|
options.onFailure?.(providerName, message);
|
|
26
|
-
if (options.signal.aborted)
|
|
27
|
-
break;
|
|
28
45
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
throw new Error(`public relay unavailable${detail ? ` (${detail})` : ""}`);
|
|
46
|
+
finally {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
33
49
|
}
|
|
34
50
|
return { tunnel: null, publicUrl: options.lanUrl, provider: "lan", failures };
|
|
35
51
|
}
|
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.53",
|
|
4
4
|
"description": "Zero-install mobile remote terminal and file console",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
"ws": "^8.21.3"
|
|
28
28
|
},
|
|
29
29
|
"optionalDependencies": {
|
|
30
|
-
"agentroam-runtime-darwin-arm64": "0.2.0-preview.
|
|
31
|
-
"agentroam-runtime-win32-x64": "0.2.0-preview.
|
|
32
|
-
"agentroam-cloudflared-darwin-arm64": "0.2.0-preview.
|
|
33
|
-
"agentroam-cloudflared-win32-x64": "0.2.0-preview.
|
|
34
|
-
"agentroam-tui-darwin-arm64": "0.2.0-preview.
|
|
35
|
-
"@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.
|
|
30
|
+
"agentroam-runtime-darwin-arm64": "0.2.0-preview.53",
|
|
31
|
+
"agentroam-runtime-win32-x64": "0.2.0-preview.53",
|
|
32
|
+
"agentroam-cloudflared-darwin-arm64": "0.2.0-preview.53",
|
|
33
|
+
"agentroam-cloudflared-win32-x64": "0.2.0-preview.53",
|
|
34
|
+
"agentroam-tui-darwin-arm64": "0.2.0-preview.53",
|
|
35
|
+
"@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.53"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/node": "^22.0.0",
|