agentroam 0.2.0-preview.26 → 0.2.0-preview.27
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 +13 -12
- package/bin/desktop-download.mjs +4 -1
- package/bin/node-preflight.mjs +5 -4
- package/dist/device-pairing.js +46 -5
- package/dist/platform-packages.js +9 -2
- package/dist/tunnel/public-readiness.js +2 -1
- package/dist/update/update-command.js +6 -12
- package/dist/update/update-install.js +34 -0
- package/dist/update/update-worker.js +12 -3
- package/install/README.md +5 -5
- package/install/install-agentroam.ps1 +1 -1
- package/install/install-agentroam.sh +2 -2
- package/install/install-manifest.json +15 -0
- package/package.json +8 -8
- package/dist/args.test.js +0 -84
- package/dist/cloudflared/bundled-asset.test.js +0 -91
- package/dist/cloudflared/installer.test.js +0 -95
- package/dist/codex-runtime-manager.test.js +0 -275
- package/dist/desktop-download.test.js +0 -83
- package/dist/device-pairing.test.js +0 -33
- package/dist/install-script-contract.test.js +0 -176
- package/dist/instance-takeover.test.js +0 -121
- package/dist/native-runtime.test.js +0 -40
- package/dist/network.test.js +0 -20
- package/dist/node-preflight.test.js +0 -136
- package/dist/node-runtime-manager.test.js +0 -184
- package/dist/opencode-runtime-manager.test.js +0 -111
- package/dist/pairing.js +0 -2
- package/dist/platform-packages.test.js +0 -86
- package/dist/platform.test.js +0 -18
- package/dist/port.test.js +0 -3
- package/dist/power/sleep-inhibitor.test.js +0 -104
- package/dist/process-supervisor.test.js +0 -42
- package/dist/runtime-manager.test.js +0 -38
- package/dist/service/launcher-cleanup.test.js +0 -81
- package/dist/service/macos-launch-agent.test.js +0 -327
- package/dist/service/runtime-state.test.js +0 -46
- package/dist/service/service-command.test.js +0 -189
- package/dist/service/service-files.test.js +0 -48
- package/dist/service/windows-service-host.test.js +0 -52
- package/dist/service/windows-task-service.test.js +0 -230
- package/dist/tunnel/cloudflare-provider.test.js +0 -45
- package/dist/tunnel/pinggy-provider.test.js +0 -40
- package/dist/tunnel/proxy-settings.test.js +0 -111
- package/dist/tunnel/public-readiness.test.js +0 -104
- package/dist/tunnel/relay-orchestrator.test.js +0 -84
- package/dist/unlock-service.test.js +0 -37
- package/dist/update/update-command.test.js +0 -70
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { afterEach, expect, it } from "vitest";
|
|
2
|
-
import { mkdtemp, mkdir, writeFile, readFile, rm, symlink, realpath } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { resolve } from "node:path";
|
|
5
|
-
import { cleanupOldLaunchers } from "./launcher-cleanup.js";
|
|
6
|
-
const temporary = [];
|
|
7
|
-
afterEach(async () => { await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))); });
|
|
8
|
-
async function fixture() {
|
|
9
|
-
const dataDir = await realpath(await mkdtemp(resolve(tmpdir(), "launcher-cleanup-")));
|
|
10
|
-
temporary.push(dataDir);
|
|
11
|
-
async function install(version) {
|
|
12
|
-
const root = resolve(dataDir, "launcher", version);
|
|
13
|
-
const pkg = resolve(root, "node_modules/agentroam");
|
|
14
|
-
await mkdir(resolve(pkg, "bin"), { recursive: true });
|
|
15
|
-
await writeFile(resolve(pkg, "package.json"), JSON.stringify({ name: "agentroam", version }));
|
|
16
|
-
await writeFile(resolve(pkg, "bin/agentroam.mjs"), "// fixture");
|
|
17
|
-
return root;
|
|
18
|
-
}
|
|
19
|
-
const current = await install("0.2.0-preview.19");
|
|
20
|
-
const config = { dataDir, version: "0.2.0-preview.19", cliPath: resolve(current, "node_modules/agentroam/bin/agentroam.mjs"), roots: [] };
|
|
21
|
-
return { dataDir, current, config, install };
|
|
22
|
-
}
|
|
23
|
-
it("removes an older managed program while retaining current, newer versions and user data", async () => {
|
|
24
|
-
const f = await fixture();
|
|
25
|
-
const old = await f.install("0.2.0-preview.18");
|
|
26
|
-
const newer = await f.install("0.2.0-preview.20");
|
|
27
|
-
for (const file of ["config.json", "agent.db", "pairing.db"])
|
|
28
|
-
await writeFile(resolve(f.dataDir, file), "preserve");
|
|
29
|
-
await cleanupOldLaunchers(f.config, () => { });
|
|
30
|
-
await expect(readFile(resolve(old, "node_modules/agentroam/package.json"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
31
|
-
for (const root of [f.current, newer])
|
|
32
|
-
expect(await readFile(resolve(root, "node_modules/agentroam/package.json"), "utf8")).toContain("agentroam");
|
|
33
|
-
for (const file of ["config.json", "agent.db", "pairing.db"])
|
|
34
|
-
expect(await readFile(resolve(f.dataDir, file), "utf8")).toBe("preserve");
|
|
35
|
-
});
|
|
36
|
-
it("preserves project roots, unexpected data, locked installs, and linked versions", async () => {
|
|
37
|
-
const f = await fixture();
|
|
38
|
-
const project = await f.install("0.2.0-preview.15");
|
|
39
|
-
f.config.roots = [project];
|
|
40
|
-
const data = await f.install("0.2.0-preview.16");
|
|
41
|
-
await writeFile(resolve(data, "session.db"), "preserve");
|
|
42
|
-
const locked = await f.install("0.2.0-preview.17");
|
|
43
|
-
await mkdir(`${locked}.lock`);
|
|
44
|
-
await symlink(data, resolve(f.dataDir, "launcher/0.2.0-preview.14"));
|
|
45
|
-
await cleanupOldLaunchers(f.config, () => { });
|
|
46
|
-
for (const root of [project, data, locked])
|
|
47
|
-
expect(await readFile(resolve(root, "node_modules/agentroam/package.json"), "utf8")).toContain("agentroam");
|
|
48
|
-
});
|
|
49
|
-
it("does not clean when the active CLI is outside the managed installation", async () => {
|
|
50
|
-
const f = await fixture();
|
|
51
|
-
const old = await f.install("0.2.0-preview.18");
|
|
52
|
-
f.config.cliPath = resolve(f.dataDir, "npx/agentroam.mjs");
|
|
53
|
-
await cleanupOldLaunchers(f.config, () => { });
|
|
54
|
-
expect(await readFile(resolve(old, "node_modules/agentroam/package.json"), "utf8")).toContain("agentroam");
|
|
55
|
-
});
|
|
56
|
-
it.each(["starting", "failed", "ready"])("cleans only after a replacement service is ready (%s)", async (status) => {
|
|
57
|
-
const { runServiceCommand } = await import("./service-command.js");
|
|
58
|
-
const { parseArgs } = await import("../args.js");
|
|
59
|
-
const f = await fixture();
|
|
60
|
-
const old = await f.install("0.2.0-preview.18");
|
|
61
|
-
const install = async () => {
|
|
62
|
-
if (status === "failed")
|
|
63
|
-
throw new Error("activation failed");
|
|
64
|
-
return { definition: "test", state: { status, version: f.config.version } };
|
|
65
|
-
};
|
|
66
|
-
const action = runServiceCommand(parseArgs(["service", "install", "--data-dir", f.dataDir]), {
|
|
67
|
-
platform: "darwin", arch: "arm64", nodeVersion: "25.8.0", nodePath: process.execPath,
|
|
68
|
-
cliPath: f.config.cliPath, version: f.config.version, log: () => { },
|
|
69
|
-
codexResolver: async () => ({ executable: "/test/codex", version: "test", source: "global" }),
|
|
70
|
-
controller: { install },
|
|
71
|
-
});
|
|
72
|
-
if (status === "failed")
|
|
73
|
-
await expect(action).rejects.toThrow("activation failed");
|
|
74
|
-
else
|
|
75
|
-
await action;
|
|
76
|
-
const manifest = readFile(resolve(old, "node_modules/agentroam/package.json"), "utf8");
|
|
77
|
-
if (status === "ready")
|
|
78
|
-
await expect(manifest).rejects.toMatchObject({ code: "ENOENT" });
|
|
79
|
-
else
|
|
80
|
-
expect(await manifest).toContain("agentroam");
|
|
81
|
-
});
|
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { MacLaunchAgent, buildLaunchAgentPlist, buildStartArguments, } from "./macos-launch-agent.js";
|
|
6
|
-
import { resolveServicePaths, writePrivateJson, writePrivateText } from "./service-files.js";
|
|
7
|
-
describe("MacLaunchAgent", () => {
|
|
8
|
-
it("builds one launchd job with separate absolute arguments", async () => {
|
|
9
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-plist-"));
|
|
10
|
-
const paths = resolveServicePaths(home, resolve(home, "data with spaces"));
|
|
11
|
-
const value = config(home, paths.dataDir);
|
|
12
|
-
expect(buildStartArguments(value)).toEqual([
|
|
13
|
-
value.nodePath, value.cliPath, "start", "--root", value.roots[0], "--root", value.roots[1],
|
|
14
|
-
"--port", "3001", "--relay", "custom", "--tunnel-command", "relay --port {port}",
|
|
15
|
-
"--data-dir", paths.dataDir, "--no-qr",
|
|
16
|
-
]);
|
|
17
|
-
expect(buildLaunchAgentPlist(value, paths)).toMatchObject({
|
|
18
|
-
Label: "com.agentroam.service",
|
|
19
|
-
WorkingDirectory: value.roots[0],
|
|
20
|
-
RunAtLoad: true,
|
|
21
|
-
KeepAlive: true,
|
|
22
|
-
ThrottleInterval: 5,
|
|
23
|
-
EnvironmentVariables: {
|
|
24
|
-
AGENTROAM_SERVICE: "1",
|
|
25
|
-
PATH: value.environmentPath,
|
|
26
|
-
AGENT_CODEX_BIN: value.codexPath,
|
|
27
|
-
CODEX_HOME: value.codexHome,
|
|
28
|
-
},
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
it("installs, reloads, and waits for a ready service", async () => {
|
|
32
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-install-"));
|
|
33
|
-
const paths = resolveServicePaths(home);
|
|
34
|
-
const value = config(home, paths.dataDir);
|
|
35
|
-
await createConfigFiles(value);
|
|
36
|
-
const calls = [];
|
|
37
|
-
let printCount = 0;
|
|
38
|
-
const runner = async (command, args) => {
|
|
39
|
-
calls.push([command, ...args]);
|
|
40
|
-
if (command === "plutil" && args[0] === "-convert") {
|
|
41
|
-
await writeFile(args[3], "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
|
|
42
|
-
}
|
|
43
|
-
if (command === "launchctl" && args[0] === "print") {
|
|
44
|
-
printCount++;
|
|
45
|
-
return { code: printCount === 1 ? 0 : 1, stdout: "state = exited", stderr: "" };
|
|
46
|
-
}
|
|
47
|
-
if (command === "launchctl" && args[0] === "bootstrap") {
|
|
48
|
-
await writePrivateJson(paths.statePath, {
|
|
49
|
-
status: "ready", pid: 777, version: value.version, startedAt: value.installedAt,
|
|
50
|
-
updatedAt: value.installedAt, accessUrl: "https://ready.example/web",
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
return { code: 0, stdout: "", stderr: "" };
|
|
54
|
-
};
|
|
55
|
-
const result = await new MacLaunchAgent({ homeDir: home, uid: 501, runner, readyTimeoutMs: 10 }).install(value);
|
|
56
|
-
expect(result.state).toMatchObject({ status: "ready", pid: 777 });
|
|
57
|
-
expect(calls).toContainEqual(["launchctl", "bootout", "gui/501/com.agentroam.service"]);
|
|
58
|
-
expect(calls).toContainEqual(["launchctl", "bootstrap", "gui/501", paths.plistPath]);
|
|
59
|
-
expect((await stat(paths.plistPath)).mode & 0o777).toBe(0o600);
|
|
60
|
-
expect(JSON.parse(await readFile(paths.configPath, "utf8"))).toMatchObject({ roots: value.roots });
|
|
61
|
-
});
|
|
62
|
-
it("waits for the previous launchd job and process before bootstrapping a replacement", async () => {
|
|
63
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-reinstall-"));
|
|
64
|
-
const paths = resolveServicePaths(home);
|
|
65
|
-
const value = config(home, paths.dataDir);
|
|
66
|
-
await createConfigFiles(value);
|
|
67
|
-
await mkdir(paths.launchAgentsDir, { recursive: true });
|
|
68
|
-
await writeFile(paths.plistPath, "old plist");
|
|
69
|
-
await writePrivateJson(paths.configPath, { ...value, version: "0.2.0-preview.8" });
|
|
70
|
-
await writePrivateJson(paths.statePath, {
|
|
71
|
-
status: "ready", pid: 700, version: "0.2.0-preview.8", startedAt: "x", updatedAt: "x",
|
|
72
|
-
});
|
|
73
|
-
await writePrivateText(resolve(paths.dataDir, "data/keep.txt"), "keep");
|
|
74
|
-
await writePrivateText(paths.stdoutPath, "keep logs");
|
|
75
|
-
let loaded = true;
|
|
76
|
-
let processChecks = 0;
|
|
77
|
-
const lifecycle = [];
|
|
78
|
-
const runner = async (command, args) => {
|
|
79
|
-
if (command === "plutil" && args[0] === "-convert") {
|
|
80
|
-
await writeFile(args[3], "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
|
|
81
|
-
}
|
|
82
|
-
if (command === "launchctl" && args[0] === "print") {
|
|
83
|
-
return { code: loaded ? 0 : 1, stdout: loaded ? "state = running" : "", stderr: "" };
|
|
84
|
-
}
|
|
85
|
-
if (command === "launchctl" && args[0] === "bootout")
|
|
86
|
-
lifecycle.push("bootout");
|
|
87
|
-
if (command === "launchctl" && args[0] === "bootstrap") {
|
|
88
|
-
lifecycle.push("bootstrap");
|
|
89
|
-
await writePrivateJson(paths.statePath, {
|
|
90
|
-
status: "ready", pid: 701, version: value.version, startedAt: "y", updatedAt: "y",
|
|
91
|
-
accessUrl: "https://ready.example/web",
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
return { code: 0, stdout: "", stderr: "" };
|
|
95
|
-
};
|
|
96
|
-
const processExists = vi.fn(() => {
|
|
97
|
-
processChecks++;
|
|
98
|
-
if (processChecks === 1)
|
|
99
|
-
return true;
|
|
100
|
-
loaded = false;
|
|
101
|
-
lifecycle.push("process-exit");
|
|
102
|
-
return false;
|
|
103
|
-
});
|
|
104
|
-
const result = await new MacLaunchAgent({
|
|
105
|
-
homeDir: home,
|
|
106
|
-
uid: 501,
|
|
107
|
-
runner,
|
|
108
|
-
processExists,
|
|
109
|
-
readyTimeoutMs: 20,
|
|
110
|
-
stopTimeoutMs: 50,
|
|
111
|
-
pollIntervalMs: 1,
|
|
112
|
-
}).install(value);
|
|
113
|
-
expect(result.state).toMatchObject({ status: "ready", pid: 701 });
|
|
114
|
-
expect(lifecycle).toEqual(["bootout", "process-exit", "bootstrap"]);
|
|
115
|
-
expect(processExists).toHaveBeenCalledWith(700);
|
|
116
|
-
expect(await readFile(resolve(paths.dataDir, "data/keep.txt"), "utf8")).toBe("keep");
|
|
117
|
-
expect(await readFile(paths.stdoutPath, "utf8")).toBe("keep logs");
|
|
118
|
-
});
|
|
119
|
-
it("waits for launchd to remove a previous job when no runtime pid is available", async () => {
|
|
120
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-reinstall-no-pid-"));
|
|
121
|
-
const paths = resolveServicePaths(home);
|
|
122
|
-
const value = config(home, paths.dataDir);
|
|
123
|
-
await createConfigFiles(value);
|
|
124
|
-
let printCount = 0;
|
|
125
|
-
let bootedOut = false;
|
|
126
|
-
let bootstrapPrintCount = 0;
|
|
127
|
-
const runner = async (command, args) => {
|
|
128
|
-
if (command === "plutil" && args[0] === "-convert") {
|
|
129
|
-
await writeFile(args[3], "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
|
|
130
|
-
}
|
|
131
|
-
if (command === "launchctl" && args[0] === "print") {
|
|
132
|
-
printCount++;
|
|
133
|
-
const loaded = !bootedOut || printCount < 3;
|
|
134
|
-
return { code: loaded ? 0 : 1, stdout: loaded ? "state = exited" : "", stderr: "" };
|
|
135
|
-
}
|
|
136
|
-
if (command === "launchctl" && args[0] === "bootout")
|
|
137
|
-
bootedOut = true;
|
|
138
|
-
if (command === "launchctl" && args[0] === "bootstrap") {
|
|
139
|
-
bootstrapPrintCount = printCount;
|
|
140
|
-
await writePrivateJson(paths.statePath, {
|
|
141
|
-
status: "ready", pid: 701, version: value.version, startedAt: "y", updatedAt: "y",
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
return { code: 0, stdout: "", stderr: "" };
|
|
145
|
-
};
|
|
146
|
-
await new MacLaunchAgent({
|
|
147
|
-
homeDir: home,
|
|
148
|
-
uid: 501,
|
|
149
|
-
runner,
|
|
150
|
-
readyTimeoutMs: 20,
|
|
151
|
-
stopTimeoutMs: 50,
|
|
152
|
-
pollIntervalMs: 1,
|
|
153
|
-
}).install(value);
|
|
154
|
-
expect(bootstrapPrintCount).toBeGreaterThanOrEqual(3);
|
|
155
|
-
});
|
|
156
|
-
it("does not bootstrap while the previous launchd job is still loaded", async () => {
|
|
157
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-reinstall-timeout-"));
|
|
158
|
-
const paths = resolveServicePaths(home);
|
|
159
|
-
const value = config(home, paths.dataDir);
|
|
160
|
-
await createConfigFiles(value);
|
|
161
|
-
const calls = [];
|
|
162
|
-
const runner = async (command, args) => {
|
|
163
|
-
calls.push(`${command} ${args[0] ?? ""}`);
|
|
164
|
-
if (command === "plutil" && args[0] === "-convert") {
|
|
165
|
-
await writeFile(args[3], "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
|
|
166
|
-
}
|
|
167
|
-
if (command === "launchctl" && args[0] === "print") {
|
|
168
|
-
return { code: 0, stdout: "state = exited", stderr: "" };
|
|
169
|
-
}
|
|
170
|
-
return { code: 0, stdout: "", stderr: "" };
|
|
171
|
-
};
|
|
172
|
-
await expect(new MacLaunchAgent({
|
|
173
|
-
homeDir: home,
|
|
174
|
-
uid: 501,
|
|
175
|
-
runner,
|
|
176
|
-
stopTimeoutMs: 0,
|
|
177
|
-
pollIntervalMs: 1,
|
|
178
|
-
}).install(value)).rejects.toThrow("did not unload after launchctl bootout");
|
|
179
|
-
expect(calls).not.toContain("launchctl bootstrap");
|
|
180
|
-
});
|
|
181
|
-
it("reports ready URLs only while the installed job is running", async () => {
|
|
182
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-url-"));
|
|
183
|
-
const paths = resolveServicePaths(home);
|
|
184
|
-
const value = config(home, paths.dataDir);
|
|
185
|
-
await mkdir(paths.launchAgentsDir, { recursive: true });
|
|
186
|
-
await writeFile(paths.plistPath, "plist");
|
|
187
|
-
await writePrivateJson(paths.configPath, value);
|
|
188
|
-
await writePrivateJson(paths.statePath, {
|
|
189
|
-
status: "ready", pid: 777, version: value.version, startedAt: value.installedAt,
|
|
190
|
-
updatedAt: value.installedAt, accessUrl: "https://ready.example/web",
|
|
191
|
-
});
|
|
192
|
-
await writePrivateText(paths.urlPath, "https://ready.example/web\n");
|
|
193
|
-
const running = async () => ({ code: 0, stdout: "state = running", stderr: "" });
|
|
194
|
-
expect(await new MacLaunchAgent({ homeDir: home, uid: 501, runner: running }).url())
|
|
195
|
-
.toBe("https://ready.example/web");
|
|
196
|
-
await writePrivateText(paths.urlPath, "https://stale.example/web\n");
|
|
197
|
-
await expect(new MacLaunchAgent({ homeDir: home, uid: 501, runner: running }).url()).rejects.toThrow("stale");
|
|
198
|
-
});
|
|
199
|
-
it("starts and stops an installed service while preserving registration", async () => {
|
|
200
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-start-stop-"));
|
|
201
|
-
const paths = resolveServicePaths(home);
|
|
202
|
-
const value = config(home, paths.dataDir);
|
|
203
|
-
await mkdir(paths.launchAgentsDir, { recursive: true });
|
|
204
|
-
await writeFile(paths.plistPath, "plist");
|
|
205
|
-
await writePrivateJson(paths.configPath, value);
|
|
206
|
-
await writePrivateJson(paths.statePath, {
|
|
207
|
-
status: "stopped", pid: 700, version: value.version, startedAt: "x", updatedAt: "x",
|
|
208
|
-
});
|
|
209
|
-
let loaded = false;
|
|
210
|
-
let processRunning = false;
|
|
211
|
-
const calls = [];
|
|
212
|
-
const runner = async (command, args) => {
|
|
213
|
-
calls.push([command, ...args]);
|
|
214
|
-
if (command === "launchctl" && args[0] === "print") {
|
|
215
|
-
return { code: loaded ? 0 : 1, stdout: loaded ? "state = running" : "", stderr: "" };
|
|
216
|
-
}
|
|
217
|
-
if (command === "launchctl" && args[0] === "bootstrap") {
|
|
218
|
-
loaded = true;
|
|
219
|
-
processRunning = true;
|
|
220
|
-
await writePrivateJson(paths.statePath, {
|
|
221
|
-
status: "ready", pid: 701, version: value.version, startedAt: "y", updatedAt: "y",
|
|
222
|
-
accessUrl: "https://ready.example/web",
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
if (command === "launchctl" && args[0] === "bootout") {
|
|
226
|
-
loaded = false;
|
|
227
|
-
processRunning = false;
|
|
228
|
-
}
|
|
229
|
-
return { code: 0, stdout: "", stderr: "" };
|
|
230
|
-
};
|
|
231
|
-
const launchAgent = new MacLaunchAgent({
|
|
232
|
-
homeDir: home,
|
|
233
|
-
uid: 501,
|
|
234
|
-
runner,
|
|
235
|
-
processExists: () => processRunning,
|
|
236
|
-
readyTimeoutMs: 50,
|
|
237
|
-
stopTimeoutMs: 50,
|
|
238
|
-
pollIntervalMs: 1,
|
|
239
|
-
});
|
|
240
|
-
const state = await launchAgent.start();
|
|
241
|
-
expect(state).toMatchObject({ status: "ready", pid: 701 });
|
|
242
|
-
expect((await launchAgent.status()).definition).toBe(paths.plistPath);
|
|
243
|
-
await launchAgent.stop();
|
|
244
|
-
expect(calls).toContainEqual(["launchctl", "bootstrap", "gui/501", paths.plistPath]);
|
|
245
|
-
expect(calls).toContainEqual(["launchctl", "bootout", "gui/501/com.agentroam.service"]);
|
|
246
|
-
expect(await readFile(paths.plistPath, "utf8")).toBe("plist");
|
|
247
|
-
expect(JSON.parse(await readFile(paths.configPath, "utf8"))).toMatchObject({ version: value.version });
|
|
248
|
-
});
|
|
249
|
-
it("uninstalls service control files but preserves application data and logs", async () => {
|
|
250
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-uninstall-"));
|
|
251
|
-
const paths = resolveServicePaths(home);
|
|
252
|
-
const value = config(home, paths.dataDir);
|
|
253
|
-
await mkdir(paths.launchAgentsDir, { recursive: true });
|
|
254
|
-
await writeFile(paths.plistPath, "plist");
|
|
255
|
-
await writePrivateJson(paths.configPath, value);
|
|
256
|
-
await writePrivateJson(paths.statePath, { status: "starting", pid: 1, version: value.version, startedAt: "x", updatedAt: "x" });
|
|
257
|
-
await writePrivateText(paths.urlPath, "url\n");
|
|
258
|
-
await writePrivateText(resolve(paths.dataDir, "data/keep.txt"), "keep");
|
|
259
|
-
await writePrivateText(paths.stdoutPath, "keep logs");
|
|
260
|
-
const calls = [];
|
|
261
|
-
let processRunning = true;
|
|
262
|
-
let exitWrite = null;
|
|
263
|
-
const runner = async (command, args) => {
|
|
264
|
-
calls.push([command, ...args]);
|
|
265
|
-
if (command === "launchctl" && args[0] === "bootout") {
|
|
266
|
-
exitWrite = writePrivateJson(paths.statePath, {
|
|
267
|
-
status: "stopped", pid: 1, version: value.version, startedAt: "x", updatedAt: "x",
|
|
268
|
-
}).then(() => { processRunning = false; });
|
|
269
|
-
}
|
|
270
|
-
return { code: 0, stdout: "", stderr: "" };
|
|
271
|
-
};
|
|
272
|
-
const processExists = vi.fn(() => processRunning);
|
|
273
|
-
const result = await new MacLaunchAgent({
|
|
274
|
-
homeDir: home,
|
|
275
|
-
uid: 501,
|
|
276
|
-
runner,
|
|
277
|
-
processExists,
|
|
278
|
-
pollIntervalMs: 1,
|
|
279
|
-
stopTimeoutMs: 100,
|
|
280
|
-
}).uninstall();
|
|
281
|
-
await exitWrite;
|
|
282
|
-
expect(result).toEqual({ removed: true, preservedDataDir: paths.dataDir });
|
|
283
|
-
expect(calls).toContainEqual(["launchctl", "bootout", "gui/501/com.agentroam.service"]);
|
|
284
|
-
expect(processExists).toHaveBeenCalled();
|
|
285
|
-
await expect(readFile(paths.configPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
286
|
-
await expect(readFile(paths.statePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
287
|
-
expect(await readFile(resolve(paths.dataDir, "data/keep.txt"), "utf8")).toBe("keep");
|
|
288
|
-
expect(await readFile(paths.stdoutPath, "utf8")).toBe("keep logs");
|
|
289
|
-
});
|
|
290
|
-
it("rejects an npx cache entrypoint", async () => {
|
|
291
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-npx-"));
|
|
292
|
-
const value = config(home, resolve(home, ".agentroam"));
|
|
293
|
-
value.cliPath = resolve(home, ".npm/_npx/hash/node_modules/agentroam/bin/agentroam.mjs");
|
|
294
|
-
await expect(new MacLaunchAgent({ homeDir: home }).install(value)).rejects.toThrow("npm install -g");
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
function config(home, dataDir) {
|
|
298
|
-
const nodePath = resolve(home, "bin/node");
|
|
299
|
-
const cliPath = resolve(home, "lib/agentroam/bin/agentroam.mjs");
|
|
300
|
-
const roots = [resolve(home, "workspace one"), resolve(home, "workspace-two")];
|
|
301
|
-
return {
|
|
302
|
-
version: "0.2.0-preview.9",
|
|
303
|
-
nodePath,
|
|
304
|
-
cliPath,
|
|
305
|
-
environmentPath: `${resolve(home, "bin")}:/usr/bin:/bin:/usr/sbin:/sbin`,
|
|
306
|
-
codexPath: resolve(home, "bin/codex"),
|
|
307
|
-
codexHome: resolve(home, ".codex"),
|
|
308
|
-
roots,
|
|
309
|
-
port: 3001,
|
|
310
|
-
relay: "custom",
|
|
311
|
-
tunnelCommand: "relay --port {port}",
|
|
312
|
-
localOnly: false,
|
|
313
|
-
dataDir,
|
|
314
|
-
installedAt: "2026-09-03T00:00:00.000Z",
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
async function createConfigFiles(value) {
|
|
318
|
-
await Promise.all([
|
|
319
|
-
writePrivateText(value.nodePath, "node"),
|
|
320
|
-
writePrivateText(value.cliPath, "cli"),
|
|
321
|
-
...(value.codexPath ? [writePrivateText(value.codexPath, "codex")] : []),
|
|
322
|
-
...value.roots.map((root) => mkdir(root, { recursive: true })),
|
|
323
|
-
]);
|
|
324
|
-
await chmod(value.nodePath, 0o700);
|
|
325
|
-
if (value.codexPath)
|
|
326
|
-
await chmod(value.codexPath, 0o700);
|
|
327
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { access, mkdtemp, readFile } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { ServiceRuntimeReporter } from "./runtime-state.js";
|
|
6
|
-
import { readServiceState, resolveServicePaths, writePrivateJson } from "./service-files.js";
|
|
7
|
-
describe("ServiceRuntimeReporter", () => {
|
|
8
|
-
it("publishes starting, ready, and stopped state without leaving a stale URL", async () => {
|
|
9
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-service-state-"));
|
|
10
|
-
const paths = resolveServicePaths(home);
|
|
11
|
-
let tick = 0;
|
|
12
|
-
const reporter = new ServiceRuntimeReporter(paths, "0.2.0-preview.9", 123, () => new Date(1_000 + tick++));
|
|
13
|
-
await reporter.starting();
|
|
14
|
-
expect(await readServiceState(paths)).toMatchObject({ status: "starting", pid: 123, version: "0.2.0-preview.9" });
|
|
15
|
-
await expect(access(paths.urlPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
16
|
-
await reporter.localReady("http://127.0.0.1:49157");
|
|
17
|
-
expect(await readServiceState(paths)).toMatchObject({ status: "starting", localUrl: "http://127.0.0.1:49157", pid: 123 });
|
|
18
|
-
await expect(access(paths.urlPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
19
|
-
await reporter.ready({
|
|
20
|
-
localUrl: "http://127.0.0.1:3000",
|
|
21
|
-
publicUrl: "https://example.trycloudflare.com",
|
|
22
|
-
accessUrl: "https://example.trycloudflare.com/web?pair=secret",
|
|
23
|
-
provider: "cloudflare",
|
|
24
|
-
});
|
|
25
|
-
expect(await readServiceState(paths)).toMatchObject({ status: "ready", provider: "cloudflare" });
|
|
26
|
-
expect(await readFile(paths.urlPath, "utf8")).toBe("https://example.trycloudflare.com/web?pair=secret\n");
|
|
27
|
-
expect(await reporter.stopped()).toBe(true);
|
|
28
|
-
expect(await readServiceState(paths)).toMatchObject({ status: "stopped", pid: 123 });
|
|
29
|
-
await expect(access(paths.urlPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
30
|
-
});
|
|
31
|
-
it("does not let an older process clear a newer process state", async () => {
|
|
32
|
-
const home = await mkdtemp(resolve(tmpdir(), "agentroam-service-state-race-"));
|
|
33
|
-
const paths = resolveServicePaths(home);
|
|
34
|
-
const reporter = new ServiceRuntimeReporter(paths, "old", 123);
|
|
35
|
-
await writePrivateJson(paths.statePath, {
|
|
36
|
-
status: "ready",
|
|
37
|
-
pid: 456,
|
|
38
|
-
version: "new",
|
|
39
|
-
startedAt: "2026-09-03T00:00:00.000Z",
|
|
40
|
-
updatedAt: "2026-09-03T00:00:01.000Z",
|
|
41
|
-
accessUrl: "https://new.example/web",
|
|
42
|
-
});
|
|
43
|
-
expect(await reporter.stopped()).toBe(false);
|
|
44
|
-
expect(await readServiceState(paths)).toMatchObject({ status: "ready", pid: 456 });
|
|
45
|
-
});
|
|
46
|
-
});
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
2
|
-
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { buildServiceEnvironmentPath, runServiceCommand } from "./service-command.js";
|
|
4
|
-
describe("runServiceCommand", () => {
|
|
5
|
-
it.each(["22.22.0", "24.0.0", "25.8.0", "26.0.0"])("keeps the selected Node %s when installing a service", async (nodeVersion) => {
|
|
6
|
-
const install = vi.fn(async () => ({ definition: "/test/service.plist" }));
|
|
7
|
-
const nodePath = `/runtimes/node-${nodeVersion}/bin/node`;
|
|
8
|
-
const codexResolver = vi.fn(async () => ({ executable: "/tools/codex", version: "test", source: "global" }));
|
|
9
|
-
await runServiceCommand(options("install"), {
|
|
10
|
-
platform: "darwin", arch: "arm64", nodeVersion, nodePath,
|
|
11
|
-
cliPath: "/agentroam.mjs", version: "test", environment: { PATH: "/usr/bin" },
|
|
12
|
-
codexResolver, controller: controller({ install }), log: vi.fn(),
|
|
13
|
-
});
|
|
14
|
-
expect(install).toHaveBeenCalledWith(expect.objectContaining({ nodePath }));
|
|
15
|
-
expect(codexResolver).toHaveBeenCalledWith(expect.objectContaining({ nodeExecutable: nodePath }));
|
|
16
|
-
});
|
|
17
|
-
it("rejects a below-minimum service runtime before installation", async () => {
|
|
18
|
-
const install = vi.fn();
|
|
19
|
-
await expect(runServiceCommand(options("install"), {
|
|
20
|
-
platform: "darwin", nodePath: "/node", nodeVersion: "22.21.9", cliPath: "/agentroam.mjs",
|
|
21
|
-
version: "test", controller: controller({ install }),
|
|
22
|
-
})).rejects.toThrow("Node.js >=22.22.0");
|
|
23
|
-
expect(install).not.toHaveBeenCalled();
|
|
24
|
-
});
|
|
25
|
-
it("installs the service using absolute runtime context and parsed start options", async () => {
|
|
26
|
-
const install = vi.fn(async (config) => ({
|
|
27
|
-
paths: { plistPath: "/Users/test/Library/LaunchAgents/com.agentroam.service.plist" },
|
|
28
|
-
state: { status: "ready", accessUrl: "https://ready.example/web", localUrl: "http://127.0.0.1:4317/" },
|
|
29
|
-
definition: "/Users/test/Library/LaunchAgents/com.agentroam.service.plist",
|
|
30
|
-
}));
|
|
31
|
-
const log = vi.fn();
|
|
32
|
-
await runServiceCommand(options("install"), {
|
|
33
|
-
platform: "darwin",
|
|
34
|
-
nodePath: "/node",
|
|
35
|
-
cliPath: "/agentroam.mjs",
|
|
36
|
-
version: "0.2.0-preview.9",
|
|
37
|
-
nodeVersion: "22.22.0",
|
|
38
|
-
environment: { PATH: "/nvm/versions/node/v22.22.0/bin:/usr/bin", CODEX_HOME: "/Users/test/.custom-codex" },
|
|
39
|
-
codexResolver: vi.fn(async () => ({ executable: "/nvm/versions/node/v22.22.0/bin/codex", version: "0.153.0", source: "global" })),
|
|
40
|
-
now: () => new Date("2026-09-03T00:00:00.000Z"),
|
|
41
|
-
log,
|
|
42
|
-
controller: controller({ install }),
|
|
43
|
-
pairingPrinter: vi.fn(async () => { }),
|
|
44
|
-
});
|
|
45
|
-
expect(install).toHaveBeenCalledWith(expect.objectContaining({
|
|
46
|
-
nodePath: "/node",
|
|
47
|
-
cliPath: "/agentroam.mjs",
|
|
48
|
-
roots: [resolve("workspace")],
|
|
49
|
-
version: "0.2.0-preview.9",
|
|
50
|
-
environmentPath: "/:/nvm/versions/node/v22.22.0/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
51
|
-
codexPath: "/nvm/versions/node/v22.22.0/bin/codex",
|
|
52
|
-
codexHome: "/Users/test/.custom-codex",
|
|
53
|
-
installedAt: "2026-09-03T00:00:00.000Z",
|
|
54
|
-
}));
|
|
55
|
-
expect(log).toHaveBeenCalledWith("Open: https://ready.example/web");
|
|
56
|
-
expect(log).toHaveBeenCalledWith("远程桌面授权地址:http://127.0.0.1:4317/web");
|
|
57
|
-
expect(log).toHaveBeenCalledWith(expect.stringContaining("屏幕录制"));
|
|
58
|
-
});
|
|
59
|
-
it("prints a scannable QR code after the URL on interactive terminals", async () => {
|
|
60
|
-
const log = vi.fn();
|
|
61
|
-
await runServiceCommand({ ...options("start"), qr: true }, {
|
|
62
|
-
platform: "darwin",
|
|
63
|
-
nodePath: "/node",
|
|
64
|
-
cliPath: "/agentroam.mjs",
|
|
65
|
-
version: "test",
|
|
66
|
-
isTTY: true,
|
|
67
|
-
log,
|
|
68
|
-
controller: controller({ start: vi.fn(async () => ({ status: "ready", accessUrl: "https://ready.example/web" })) }),
|
|
69
|
-
});
|
|
70
|
-
const openIndex = log.mock.calls.findIndex(([line]) => line === "Open: https://ready.example/web");
|
|
71
|
-
expect(openIndex).toBeGreaterThanOrEqual(0);
|
|
72
|
-
const qr = log.mock.calls[openIndex + 2]?.[0];
|
|
73
|
-
expect(qr).toMatch(/[▀▄█]/);
|
|
74
|
-
expect(qr).toContain("\n");
|
|
75
|
-
});
|
|
76
|
-
it("keeps service output QR-free when piped or when --no-qr is set", async () => {
|
|
77
|
-
const log = vi.fn();
|
|
78
|
-
const start = vi.fn(async () => ({ status: "ready", accessUrl: "https://ready.example/web" }));
|
|
79
|
-
await runServiceCommand({ ...options("start"), qr: true }, {
|
|
80
|
-
platform: "darwin",
|
|
81
|
-
nodePath: "/node",
|
|
82
|
-
cliPath: "/agentroam.mjs",
|
|
83
|
-
version: "test",
|
|
84
|
-
isTTY: false,
|
|
85
|
-
log,
|
|
86
|
-
controller: controller({ start }),
|
|
87
|
-
});
|
|
88
|
-
await runServiceCommand(options("start"), {
|
|
89
|
-
platform: "darwin",
|
|
90
|
-
nodePath: "/node",
|
|
91
|
-
cliPath: "/agentroam.mjs",
|
|
92
|
-
version: "test",
|
|
93
|
-
isTTY: true,
|
|
94
|
-
log,
|
|
95
|
-
controller: controller({ start }),
|
|
96
|
-
});
|
|
97
|
-
const lines = log.mock.calls.map(([line]) => line);
|
|
98
|
-
expect(lines).toContain("Open: https://ready.example/web");
|
|
99
|
-
expect(lines.some((line) => /[▀▄█]/.test(String(line)))).toBe(false);
|
|
100
|
-
});
|
|
101
|
-
it("pins the selected Node directory ahead of the inherited service PATH", () => {
|
|
102
|
-
expect(buildServiceEnvironmentPath("/Users/test/.nvm/versions/node/v22.22.2/bin/node", "/usr/bin:/Users/test/.nvm/versions/node/v22.22.2/bin", "darwin")).toBe("/Users/test/.nvm/versions/node/v22.22.2/bin:/usr/bin:/bin:/usr/sbin:/sbin");
|
|
103
|
-
});
|
|
104
|
-
it("reports status without mutating an uninstalled service", async () => {
|
|
105
|
-
const log = vi.fn();
|
|
106
|
-
await runServiceCommand(options("status"), {
|
|
107
|
-
platform: "darwin",
|
|
108
|
-
nodePath: "/node",
|
|
109
|
-
cliPath: "/agentroam.mjs",
|
|
110
|
-
version: "test",
|
|
111
|
-
log,
|
|
112
|
-
controller: controller({ status: vi.fn(async () => ({ installed: false })) }),
|
|
113
|
-
});
|
|
114
|
-
expect(log).toHaveBeenCalledWith("AgentRoam service: not installed");
|
|
115
|
-
});
|
|
116
|
-
it("starts and stops a Windows service through the same command contract", async () => {
|
|
117
|
-
const start = vi.fn(async () => ({ status: "ready", accessUrl: "https://ready.example/web" }));
|
|
118
|
-
const stop = vi.fn(async () => undefined);
|
|
119
|
-
const log = vi.fn();
|
|
120
|
-
await runServiceCommand(options("start"), {
|
|
121
|
-
platform: "win32",
|
|
122
|
-
nodePath: "C:\\node.exe",
|
|
123
|
-
cliPath: "C:\\agentroam.mjs",
|
|
124
|
-
version: "test",
|
|
125
|
-
controller: controller({ start }),
|
|
126
|
-
log,
|
|
127
|
-
});
|
|
128
|
-
await runServiceCommand(options("stop"), {
|
|
129
|
-
platform: "win32",
|
|
130
|
-
nodePath: "C:\\node.exe",
|
|
131
|
-
cliPath: "C:\\agentroam.mjs",
|
|
132
|
-
version: "test",
|
|
133
|
-
controller: controller({ stop }),
|
|
134
|
-
log,
|
|
135
|
-
});
|
|
136
|
-
expect(start).toHaveBeenCalledOnce();
|
|
137
|
-
expect(stop).toHaveBeenCalledOnce();
|
|
138
|
-
expect(log).toHaveBeenCalledWith("✓ AgentRoam service started");
|
|
139
|
-
expect(log).toHaveBeenCalledWith("✓ AgentRoam service stopped");
|
|
140
|
-
});
|
|
141
|
-
it("rejects service management outside supported platforms", async () => {
|
|
142
|
-
await expect(runServiceCommand(options("status"), {
|
|
143
|
-
platform: "linux",
|
|
144
|
-
nodePath: "/node",
|
|
145
|
-
cliPath: "/agentroam.mjs",
|
|
146
|
-
version: "test",
|
|
147
|
-
})).rejects.toThrow("supported platforms are macOS and Windows");
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
function controller(overrides) {
|
|
151
|
-
return {
|
|
152
|
-
install: vi.fn(),
|
|
153
|
-
start: vi.fn(),
|
|
154
|
-
stop: vi.fn(),
|
|
155
|
-
status: vi.fn(),
|
|
156
|
-
url: vi.fn(),
|
|
157
|
-
logs: vi.fn(),
|
|
158
|
-
restart: vi.fn(),
|
|
159
|
-
uninstall: vi.fn(),
|
|
160
|
-
...overrides,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
function options(serviceAction) {
|
|
164
|
-
return {
|
|
165
|
-
command: "service",
|
|
166
|
-
serviceAction,
|
|
167
|
-
unlockServiceAction: null,
|
|
168
|
-
roots: [resolve("workspace")],
|
|
169
|
-
port: null,
|
|
170
|
-
relay: "auto",
|
|
171
|
-
tunnelCommand: null,
|
|
172
|
-
localOnly: false,
|
|
173
|
-
qr: false,
|
|
174
|
-
dataDir: resolve("data"),
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
it.each(['install', 'start', 'restart', 'status'])('prints the actual Windows local address for %s before tunnel readiness', async (action) => {
|
|
178
|
-
const state = { status: 'starting', localUrl: 'http://127.0.0.1:49157', pid: 123 };
|
|
179
|
-
const log = vi.fn();
|
|
180
|
-
const control = controller({
|
|
181
|
-
install: vi.fn(async () => ({ state, definition: 'task' })), start: vi.fn(async () => state), restart: vi.fn(async () => state),
|
|
182
|
-
status: vi.fn(async () => ({ installed: true, running: true, state, config: null })),
|
|
183
|
-
});
|
|
184
|
-
await runServiceCommand(options(action), { platform: 'win32', arch: 'x64', nodePath: 'C:\\node.exe', cliPath: 'C:\\agentroam.mjs', version: 'test', nodeVersion: '22.22.0', controller: control, log,
|
|
185
|
-
codexResolver: vi.fn(async () => ({ executable: 'C:\\codex.exe', version: 'test', source: 'global' })) });
|
|
186
|
-
expect(log).toHaveBeenCalledWith('远程桌面授权地址:http://127.0.0.1:49157/web');
|
|
187
|
-
expect(log).toHaveBeenCalledWith(expect.stringContaining('保持 Windows 已登录'));
|
|
188
|
-
expect(log.mock.calls.some(([line]) => line.includes('屏幕录制') || line.includes('辅助功能'))).toBe(false);
|
|
189
|
-
});
|