agentroam 0.2.0-preview.7 → 0.2.0-preview.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +42 -8
  2. package/bin/agentroam.mjs +10 -1
  3. package/bin/node-preflight.mjs +138 -0
  4. package/dist/args.js +14 -1
  5. package/dist/args.test.js +27 -0
  6. package/dist/cli.js +59 -7
  7. package/dist/cloudflared/bundled-asset.test.js +2 -2
  8. package/dist/codex-runtime-manager.js +293 -0
  9. package/dist/codex-runtime-manager.test.js +192 -0
  10. package/dist/install-script-contract.test.js +41 -0
  11. package/dist/node-preflight.test.js +109 -0
  12. package/dist/node-runtime-manager.js +251 -0
  13. package/dist/node-runtime-manager.test.js +184 -0
  14. package/dist/opencode-runtime-manager.js +207 -0
  15. package/dist/opencode-runtime-manager.test.js +79 -0
  16. package/dist/platform-packages.js +1 -1
  17. package/dist/platform.js +8 -4
  18. package/dist/platform.test.js +17 -2
  19. package/dist/power/sleep-inhibitor.js +151 -0
  20. package/dist/power/sleep-inhibitor.test.js +104 -0
  21. package/dist/runtime-manager.js +50 -2
  22. package/dist/runtime-manager.test.js +38 -0
  23. package/dist/service/macos-launch-agent.js +250 -0
  24. package/dist/service/macos-launch-agent.test.js +197 -0
  25. package/dist/service/runtime-state.js +40 -0
  26. package/dist/service/runtime-state.test.js +43 -0
  27. package/dist/service/service-command.js +100 -0
  28. package/dist/service/service-command.test.js +102 -0
  29. package/dist/service/service-controller.js +27 -0
  30. package/dist/service/service-files.js +78 -0
  31. package/dist/service/service-files.test.js +48 -0
  32. package/dist/service/windows-service-host.js +53 -0
  33. package/dist/service/windows-service-host.test.js +46 -0
  34. package/dist/service/windows-task-service.js +275 -0
  35. package/dist/service/windows-task-service.test.js +95 -0
  36. package/dist/tunnel/cloudflare-provider.js +4 -1
  37. package/dist/tunnel/cloudflare-provider.test.js +13 -1
  38. package/dist/tunnel/public-readiness.js +1 -1
  39. package/dist/tunnel/relay-orchestrator.js +4 -0
  40. package/dist/tunnel/relay-orchestrator.test.js +11 -0
  41. package/install/README.md +45 -0
  42. package/install/install-agentroam.ps1 +175 -0
  43. package/install/install-agentroam.sh +199 -0
  44. package/package.json +8 -7
@@ -0,0 +1,79 @@
1
+ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { OPENCODE_RUNTIME_VERSION, managedOpenCodeBinaryCandidates, parseOpenCodeVersion, resolveOpenCodeRuntime, } from "./opencode-runtime-manager.js";
6
+ const roots = [];
7
+ afterEach(async () => {
8
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
9
+ });
10
+ async function executable(name = "opencode") {
11
+ const root = await mkdtemp(join(tmpdir(), "agentroam-opencode-"));
12
+ roots.push(root);
13
+ const path = join(root, name);
14
+ await writeFile(path, "#!/bin/sh\nexit 0\n");
15
+ await chmod(path, 0o755);
16
+ return { root, path };
17
+ }
18
+ describe("resolveOpenCodeRuntime", () => {
19
+ it("prefers a compatible absolute override", async () => {
20
+ const binary = await executable();
21
+ const run = vi.fn(async (_command, args) => ({
22
+ stdout: args[0] === "--version" ? `opencode ${OPENCODE_RUNTIME_VERSION}` : "help",
23
+ stderr: "",
24
+ }));
25
+ await expect(resolveOpenCodeRuntime({
26
+ dataDir: binary.root,
27
+ target: "darwin-arm64",
28
+ environment: { AGENT_OPENCODE_BIN: binary.path, PATH: "" },
29
+ platform: "darwin",
30
+ dependencies: { run },
31
+ })).resolves.toEqual({ executable: binary.path, version: OPENCODE_RUNTIME_VERSION, source: "explicit" });
32
+ expect(run).toHaveBeenCalledWith(binary.path, ["serve", "--help"], 5_000);
33
+ });
34
+ it("rejects relative explicit overrides", async () => {
35
+ await expect(resolveOpenCodeRuntime({
36
+ dataDir: "/data",
37
+ target: "darwin-arm64",
38
+ environment: { AGENT_OPENCODE_BIN: "opencode", PATH: "" },
39
+ platform: "darwin",
40
+ })).rejects.toThrow("absolute path");
41
+ });
42
+ it("uses a compatible PATH executable", async () => {
43
+ const binary = await executable();
44
+ await expect(resolveOpenCodeRuntime({
45
+ dataDir: join(binary.root, "data"),
46
+ target: "darwin-arm64",
47
+ environment: { PATH: binary.root },
48
+ platform: "darwin",
49
+ dependencies: { run: async () => ({ stdout: OPENCODE_RUNTIME_VERSION, stderr: "" }) },
50
+ })).resolves.toMatchObject({ executable: binary.path, source: "global" });
51
+ });
52
+ it("reuses a validated managed installation", async () => {
53
+ const root = await mkdtemp(join(tmpdir(), "agentroam-opencode-managed-"));
54
+ roots.push(root);
55
+ const managedRoot = join(root, "runtimes", "opencode", OPENCODE_RUNTIME_VERSION);
56
+ const candidate = managedOpenCodeBinaryCandidates(managedRoot, "darwin")[0];
57
+ await mkdir(join(candidate, ".."), { recursive: true });
58
+ await writeFile(candidate, "#!/bin/sh\nexit 0\n");
59
+ await chmod(candidate, 0o755);
60
+ await expect(resolveOpenCodeRuntime({
61
+ dataDir: root,
62
+ target: "darwin-arm64",
63
+ environment: { PATH: "" },
64
+ platform: "darwin",
65
+ dependencies: { run: async () => ({ stdout: OPENCODE_RUNTIME_VERSION, stderr: "" }) },
66
+ })).resolves.toEqual({ executable: candidate, version: OPENCODE_RUNTIME_VERSION, source: "managed" });
67
+ });
68
+ });
69
+ describe("OpenCode runtime helpers", () => {
70
+ it("parses only semantic versions", () => {
71
+ expect(parseOpenCodeVersion("opencode 1.18.27")).toBe("1.18.27");
72
+ expect(parseOpenCodeVersion("dev")).toBeNull();
73
+ });
74
+ it("keeps managed binary candidates under the runtime root", () => {
75
+ expect(managedOpenCodeBinaryCandidates("/data/runtime", "darwin")[0])
76
+ .toBe("/data/runtime/node_modules/opencode-ai/bin/opencode.exe");
77
+ expect(managedOpenCodeBinaryCandidates("C:\\runtime", "win32").some((path) => path.endsWith("opencode.cmd"))).toBe(true);
78
+ });
79
+ });
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname } from "node:path";
4
- export const AGENTROAM_VERSION = "0.2.0-preview.7";
4
+ export const AGENTROAM_VERSION = "0.2.0-preview.9";
5
5
  export const PLATFORM_PACKAGES = {
6
6
  "darwin-arm64": {
7
7
  runtime: "agentroam-runtime-darwin-arm64",
package/dist/platform.js CHANGED
@@ -1,7 +1,4 @@
1
- export function detectPlatform(platform = process.platform, arch = process.arch, nodeVersion = process.versions.node) {
2
- const major = Number(nodeVersion.split(".")[0]);
3
- if (major !== 22)
4
- throw Object.assign(new Error(`Node.js 22 required (current ${nodeVersion})`), { exitCode: 2 });
1
+ export function detectPlatformTarget(platform = process.platform, arch = process.arch) {
5
2
  if (platform === "darwin" && arch === "arm64")
6
3
  return "darwin-arm64";
7
4
  if (platform === "darwin" && arch === "x64")
@@ -10,3 +7,10 @@ export function detectPlatform(platform = process.platform, arch = process.arch,
10
7
  return "windows-amd64";
11
8
  throw Object.assign(new Error(`unsupported platform: ${platform}-${arch}`), { exitCode: 2 });
12
9
  }
10
+ export function detectPlatform(platform = process.platform, arch = process.arch, nodeVersion = process.versions.node) {
11
+ const major = Number(nodeVersion.split(".")[0]);
12
+ if (major !== 22) {
13
+ throw Object.assign(new Error(`Node.js 22 required (current ${nodeVersion})`), { exitCode: 2 });
14
+ }
15
+ return detectPlatformTarget(platform, arch);
16
+ }
@@ -1,3 +1,18 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { detectPlatform } from "./platform.js";
3
- describe("detectPlatform", () => { it("maps supported targets", () => { expect(detectPlatform("darwin", "arm64", "22.1.0")).toBe("darwin-arm64"); expect(detectPlatform("win32", "x64", "22.1.0")).toBe("windows-amd64"); }); it("rejects unsupported versions", () => expect(() => detectPlatform("darwin", "arm64", "23.0.0")).toThrow("Node.js 22")); });
2
+ import { detectPlatform, detectPlatformTarget } from "./platform.js";
3
+ describe("detectPlatform", () => {
4
+ it("maps supported targets", () => {
5
+ expect(detectPlatform("darwin", "arm64", "22.1.0")).toBe("darwin-arm64");
6
+ expect(detectPlatform("win32", "x64", "22.1.0")).toBe("windows-amd64");
7
+ });
8
+ it("detects a target before Node version validation", () => {
9
+ expect(detectPlatformTarget("darwin", "arm64")).toBe("darwin-arm64");
10
+ expect(detectPlatformTarget("win32", "x64")).toBe("windows-amd64");
11
+ });
12
+ it("rejects unsupported versions", () => {
13
+ expect(() => detectPlatform("darwin", "arm64", "23.0.0")).toThrow("Node.js 22");
14
+ });
15
+ it("rejects unsupported platforms", () => {
16
+ expect(() => detectPlatformTarget("linux", "x64")).toThrow("unsupported platform");
17
+ });
18
+ });
@@ -0,0 +1,151 @@
1
+ import { spawn } from "node:child_process";
2
+ export async function acquireSleepInhibitor(options = {}) {
3
+ const platform = options.platform ?? process.platform;
4
+ const pid = options.pid ?? process.pid;
5
+ const spawnProcess = options.spawnProcess ?? spawn;
6
+ const readyTimeoutMs = options.readyTimeoutMs ?? 5_000;
7
+ const stopTimeoutMs = options.stopTimeoutMs ?? 2_000;
8
+ if (platform === "darwin") {
9
+ const child = spawnProcess("/usr/bin/caffeinate", ["-i", "-w", String(pid)], {
10
+ stdio: "ignore",
11
+ windowsHide: true,
12
+ });
13
+ await waitForSpawn(child, readyTimeoutMs, "macOS idle-sleep inhibitor");
14
+ return monitorChild(child, stopTimeoutMs, "macOS idle-sleep inhibitor");
15
+ }
16
+ if (platform === "win32") {
17
+ const script = buildWindowsSleepInhibitorScript(pid);
18
+ const child = spawnProcess("powershell.exe", [
19
+ "-NoProfile",
20
+ "-NonInteractive",
21
+ "-ExecutionPolicy", "Bypass",
22
+ "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64"),
23
+ ], {
24
+ stdio: ["ignore", "pipe", "pipe"],
25
+ windowsHide: true,
26
+ });
27
+ await waitForReadyLine(child, readyTimeoutMs);
28
+ return monitorChild(child, stopTimeoutMs, "Windows idle-sleep inhibitor");
29
+ }
30
+ throw new Error(`idle-sleep prevention is unsupported on ${platform}`);
31
+ }
32
+ export function buildWindowsSleepInhibitorScript(parentPid) {
33
+ return String.raw `
34
+ $ErrorActionPreference = "Stop"
35
+ $source = @'
36
+ using System;
37
+ using System.Runtime.InteropServices;
38
+ public static class AgentRoamPower {
39
+ [DllImport("kernel32.dll", SetLastError = true)]
40
+ public static extern uint SetThreadExecutionState(uint esFlags);
41
+ }
42
+ '@
43
+ Add-Type -TypeDefinition $source
44
+ $continuous = [uint32]::Parse("80000000", [Globalization.NumberStyles]::HexNumber)
45
+ $systemRequired = [uint32]1
46
+ $result = [AgentRoamPower]::SetThreadExecutionState($continuous -bor $systemRequired)
47
+ if ($result -eq 0) { throw "SetThreadExecutionState failed" }
48
+ [Console]::Out.WriteLine("AGENTROAM_SLEEP_INHIBITOR_READY")
49
+ try {
50
+ Wait-Process -Id ${parentPid}
51
+ } finally {
52
+ [void][AgentRoamPower]::SetThreadExecutionState($continuous)
53
+ }
54
+ `;
55
+ }
56
+ function waitForSpawn(child, timeoutMs, label) {
57
+ return new Promise((resolveReady, rejectReady) => {
58
+ const timeout = setTimeout(() => finish(new Error(`${label} did not start within ${timeoutMs}ms`)), timeoutMs);
59
+ const onError = (error) => finish(error);
60
+ const onExit = (code, signal) => {
61
+ finish(new Error(`${label} exited before readiness (${formatExit(code, signal)})`));
62
+ };
63
+ const onSpawn = () => finish();
64
+ const finish = (error) => {
65
+ clearTimeout(timeout);
66
+ child.removeListener("error", onError);
67
+ child.removeListener("exit", onExit);
68
+ child.removeListener("spawn", onSpawn);
69
+ if (error)
70
+ rejectReady(error);
71
+ else
72
+ resolveReady();
73
+ };
74
+ child.once("error", onError);
75
+ child.once("exit", onExit);
76
+ child.once("spawn", onSpawn);
77
+ });
78
+ }
79
+ function waitForReadyLine(child, timeoutMs) {
80
+ return new Promise((resolveReady, rejectReady) => {
81
+ let stdout = "";
82
+ let stderr = "";
83
+ const timeout = setTimeout(() => {
84
+ finish(new Error(`Windows idle-sleep inhibitor did not become ready within ${timeoutMs}ms`));
85
+ }, timeoutMs);
86
+ const onStdout = (chunk) => {
87
+ stdout += chunk.toString();
88
+ if (stdout.includes("AGENTROAM_SLEEP_INHIBITOR_READY"))
89
+ finish();
90
+ };
91
+ const onStderr = (chunk) => { stderr += chunk.toString(); };
92
+ const onError = (error) => finish(error);
93
+ const onExit = (code, signal) => {
94
+ const detail = stderr.trim();
95
+ finish(new Error(`Windows idle-sleep inhibitor exited before readiness (${formatExit(code, signal)})${detail ? `: ${detail}` : ""}`));
96
+ };
97
+ const finish = (error) => {
98
+ clearTimeout(timeout);
99
+ child.stdout?.removeListener("data", onStdout);
100
+ child.stderr?.removeListener("data", onStderr);
101
+ child.removeListener("error", onError);
102
+ child.removeListener("exit", onExit);
103
+ if (error)
104
+ rejectReady(error);
105
+ else
106
+ resolveReady();
107
+ };
108
+ child.stdout?.on("data", onStdout);
109
+ child.stderr?.on("data", onStderr);
110
+ child.once("error", onError);
111
+ child.once("exit", onExit);
112
+ });
113
+ }
114
+ function monitorChild(child, stopTimeoutMs, label) {
115
+ let releasing = false;
116
+ let released = false;
117
+ let resolveLost;
118
+ const lost = new Promise((resolvePromise) => { resolveLost = resolvePromise; });
119
+ const exited = new Promise((resolveExit) => {
120
+ child.once("exit", (code, signal) => {
121
+ if (!releasing)
122
+ resolveLost(new Error(`${label} stopped unexpectedly (${formatExit(code, signal)})`));
123
+ resolveExit();
124
+ });
125
+ child.once("error", (error) => {
126
+ if (!releasing)
127
+ resolveLost(error);
128
+ });
129
+ });
130
+ return {
131
+ lost,
132
+ async release() {
133
+ if (released)
134
+ return;
135
+ released = true;
136
+ releasing = true;
137
+ if (child.exitCode !== null || child.signalCode !== null)
138
+ return;
139
+ child.kill("SIGTERM");
140
+ await Promise.race([exited, delay(stopTimeoutMs)]);
141
+ if (child.exitCode === null && child.signalCode === null)
142
+ child.kill("SIGKILL");
143
+ },
144
+ };
145
+ }
146
+ function formatExit(code, signal) {
147
+ return signal ? `signal ${signal}` : `exit ${code ?? 1}`;
148
+ }
149
+ function delay(timeoutMs) {
150
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, timeoutMs));
151
+ }
@@ -0,0 +1,104 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { PassThrough } from "node:stream";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { acquireSleepInhibitor, buildWindowsSleepInhibitorScript } from "./sleep-inhibitor.js";
5
+ function fakeChild(withPipes = false) {
6
+ const child = new EventEmitter();
7
+ Object.assign(child, {
8
+ pid: 9001,
9
+ exitCode: null,
10
+ signalCode: null,
11
+ stdout: withPipes ? new PassThrough() : null,
12
+ stderr: withPipes ? new PassThrough() : null,
13
+ kill: vi.fn((signal) => {
14
+ Object.assign(child, { signalCode: signal });
15
+ queueMicrotask(() => child.emit("exit", null, signal));
16
+ return true;
17
+ }),
18
+ });
19
+ return child;
20
+ }
21
+ describe("sleep inhibitor", () => {
22
+ it("uses caffeinate to prevent idle sleep without preventing display sleep", async () => {
23
+ const child = fakeChild();
24
+ const spawnProcess = vi.fn(() => {
25
+ queueMicrotask(() => child.emit("spawn"));
26
+ return child;
27
+ });
28
+ const inhibitor = await acquireSleepInhibitor({
29
+ platform: "darwin",
30
+ pid: 4321,
31
+ spawnProcess: spawnProcess,
32
+ readyTimeoutMs: 50,
33
+ stopTimeoutMs: 50,
34
+ });
35
+ expect(spawnProcess).toHaveBeenCalledWith("/usr/bin/caffeinate", ["-i", "-w", "4321"], {
36
+ stdio: "ignore",
37
+ windowsHide: true,
38
+ });
39
+ const calls = spawnProcess.mock.calls;
40
+ expect(calls[0][1]).not.toContain("-d");
41
+ await inhibitor.release();
42
+ await inhibitor.release();
43
+ expect(child.kill).toHaveBeenCalledTimes(1);
44
+ });
45
+ it("uses only the Windows system-required execution state", async () => {
46
+ const child = fakeChild(true);
47
+ const spawnProcess = vi.fn(() => {
48
+ queueMicrotask(() => {
49
+ child.emit("spawn");
50
+ child.stdout.write("AGENTROAM_SLEEP_INHIBITOR_READY\n");
51
+ });
52
+ return child;
53
+ });
54
+ const inhibitor = await acquireSleepInhibitor({
55
+ platform: "win32",
56
+ pid: 7654,
57
+ spawnProcess: spawnProcess,
58
+ readyTimeoutMs: 50,
59
+ stopTimeoutMs: 50,
60
+ });
61
+ const calls = spawnProcess.mock.calls;
62
+ const args = calls[0][1];
63
+ const script = Buffer.from(args[args.indexOf("-EncodedCommand") + 1], "base64").toString("utf16le");
64
+ expect(script).toContain("SetThreadExecutionState");
65
+ expect(script).toContain("$systemRequired = [uint32]1");
66
+ expect(script).toContain("Wait-Process -Id 7654");
67
+ expect(script).not.toContain("ES_DISPLAY_REQUIRED");
68
+ await inhibitor.release();
69
+ });
70
+ it("reports a helper that stops after acquisition", async () => {
71
+ const child = fakeChild();
72
+ const spawnProcess = vi.fn(() => {
73
+ queueMicrotask(() => child.emit("spawn"));
74
+ return child;
75
+ });
76
+ const inhibitor = await acquireSleepInhibitor({
77
+ platform: "darwin",
78
+ spawnProcess: spawnProcess,
79
+ readyTimeoutMs: 50,
80
+ });
81
+ Object.assign(child, { exitCode: 3 });
82
+ child.emit("exit", 3, null);
83
+ await expect(inhibitor.lost).resolves.toMatchObject({ message: expect.stringContaining("stopped unexpectedly") });
84
+ });
85
+ it("rejects unsupported platforms and early helper failures", async () => {
86
+ await expect(acquireSleepInhibitor({ platform: "linux" })).rejects.toThrow("unsupported on linux");
87
+ const child = fakeChild();
88
+ const spawnProcess = vi.fn(() => {
89
+ queueMicrotask(() => child.emit("error", new Error("missing caffeinate")));
90
+ return child;
91
+ });
92
+ await expect(acquireSleepInhibitor({
93
+ platform: "darwin",
94
+ spawnProcess: spawnProcess,
95
+ readyTimeoutMs: 50,
96
+ })).rejects.toThrow("missing caffeinate");
97
+ });
98
+ it("builds a Windows helper that clears its assertion in finally", () => {
99
+ const script = buildWindowsSleepInhibitorScript(123);
100
+ expect(script).toContain("$continuous -bor $systemRequired");
101
+ expect(script).toContain("finally");
102
+ expect(script).toContain("SetThreadExecutionState($continuous)");
103
+ });
104
+ });
@@ -1,25 +1,35 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import { delimiter, resolve } from "node:path";
3
+ import { resolveCodexRuntime, } from "./codex-runtime-manager.js";
3
4
  import { repairNativeRuntimePermissions } from "./native-runtime.js";
5
+ import { resolveOpenCodeRuntime, } from "./opencode-runtime-manager.js";
4
6
  import { resolvePlatformRuntime } from "./platform-packages.js";
5
7
  import { findAvailablePort } from "./port.js";
6
8
  import { ProcessSupervisor } from "./process-supervisor.js";
7
9
  export class RuntimeManager {
8
10
  supervisor;
9
- constructor(supervisor = new ProcessSupervisor()) {
11
+ codexResolver;
12
+ report;
13
+ opencodeResolver;
14
+ constructor(supervisor = new ProcessSupervisor(), codexResolver = resolveCodexRuntime, report = (message) => process.stderr.write(`${message}\n`), opencodeResolver = resolveOpenCodeRuntime) {
10
15
  this.supervisor = supervisor;
16
+ this.codexResolver = codexResolver;
17
+ this.report = report;
18
+ this.opencodeResolver = opencodeResolver;
11
19
  }
12
20
  async start(options, pairing, target) {
13
21
  const port = await findAvailablePort(options.port);
14
22
  const dataDir = resolve(options.dataDir);
15
23
  await Promise.all(["data", "bin", "cache", "logs"].map((name) => mkdir(resolve(dataDir, name), { recursive: true })));
24
+ const codexEnvironment = await prepareCodexRuntimeEnvironment(process.env, { dataDir, target }, this.codexResolver, this.report);
25
+ const childEnvironment = await prepareOpenCodeRuntimeEnvironment(codexEnvironment, { dataDir, target }, this.opencodeResolver, this.report);
16
26
  const { runtimeRoot } = resolvePlatformRuntime(target);
17
27
  await repairNativeRuntimePermissions(runtimeRoot);
18
28
  const gateway = resolve(runtimeRoot, "ws-server.mjs");
19
29
  const child = this.supervisor.spawn(process.execPath, [gateway], {
20
30
  cwd: runtimeRoot,
21
31
  env: {
22
- ...process.env,
32
+ ...childEnvironment,
23
33
  NODE_ENV: "production",
24
34
  NEXT_DIST_DIR: ".next",
25
35
  PORT: String(port),
@@ -50,6 +60,44 @@ export class RuntimeManager {
50
60
  };
51
61
  }
52
62
  }
63
+ export async function prepareCodexRuntimeEnvironment(environment, options, resolver = resolveCodexRuntime, report = (message) => process.stderr.write(`${message}\n`)) {
64
+ const childEnvironment = { ...environment };
65
+ report(`Checking Codex ${options.target} runtime...`);
66
+ try {
67
+ const resolution = await resolver({
68
+ ...options,
69
+ environment,
70
+ onProgress: report,
71
+ });
72
+ childEnvironment.AGENT_CODEX_BIN = resolution.executable;
73
+ delete childEnvironment.AGENT_CODEX_RUNTIME_ERROR;
74
+ report(`Codex ${resolution.version} (${resolution.source}): ${resolution.executable}`);
75
+ }
76
+ catch (error) {
77
+ delete childEnvironment.AGENT_CODEX_BIN;
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ childEnvironment.AGENT_CODEX_RUNTIME_ERROR = message;
80
+ report(`Codex unavailable; AgentRoam will continue without Codex sessions: ${message}`);
81
+ }
82
+ return childEnvironment;
83
+ }
84
+ export async function prepareOpenCodeRuntimeEnvironment(environment, options, resolver = resolveOpenCodeRuntime, report = (message) => process.stderr.write(`${message}\n`)) {
85
+ const childEnvironment = { ...environment };
86
+ report(`Checking OpenCode ${options.target} runtime...`);
87
+ try {
88
+ const resolution = await resolver({ ...options, environment, onProgress: report });
89
+ childEnvironment.AGENT_OPENCODE_BIN = resolution.executable;
90
+ delete childEnvironment.AGENT_OPENCODE_RUNTIME_ERROR;
91
+ report(`OpenCode ${resolution.version} (${resolution.source}): ${resolution.executable}`);
92
+ }
93
+ catch (error) {
94
+ delete childEnvironment.AGENT_OPENCODE_BIN;
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ childEnvironment.AGENT_OPENCODE_RUNTIME_ERROR = message;
97
+ report(`OpenCode unavailable; AgentRoam will continue without OpenCode sessions: ${message}`);
98
+ }
99
+ return childEnvironment;
100
+ }
53
101
  async function waitForHealth(url, child, timeoutMs) {
54
102
  const end = Date.now() + timeoutMs;
55
103
  while (Date.now() < end) {
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { prepareCodexRuntimeEnvironment, prepareOpenCodeRuntimeEnvironment } from "./runtime-manager.js";
3
+ describe("prepareCodexRuntimeEnvironment", () => {
4
+ it("injects the resolved absolute Codex executable", async () => {
5
+ const report = vi.fn();
6
+ const environment = await prepareCodexRuntimeEnvironment({ PATH: "/bin" }, { dataDir: "/data", target: "windows-amd64" }, async () => ({
7
+ executable: "D:\\AgentRoam\\codex.exe",
8
+ version: "0.153.0",
9
+ source: "managed",
10
+ }), report);
11
+ expect(environment.AGENT_CODEX_BIN).toBe("D:\\AgentRoam\\codex.exe");
12
+ expect(environment.AGENT_CODEX_RUNTIME_ERROR).toBeUndefined();
13
+ expect(report).toHaveBeenCalledWith(expect.stringContaining("managed"));
14
+ });
15
+ it("removes an unusable inherited override and keeps startup available", async () => {
16
+ const report = vi.fn();
17
+ const environment = await prepareCodexRuntimeEnvironment({ PATH: "/bin", AGENT_CODEX_BIN: "/missing/codex" }, { dataDir: "/data", target: "darwin-arm64" }, async () => { throw new Error("download failed"); }, report);
18
+ expect(environment.AGENT_CODEX_BIN).toBeUndefined();
19
+ expect(environment.AGENT_CODEX_RUNTIME_ERROR).toBe("download failed");
20
+ expect(environment.PATH).toBe("/bin");
21
+ expect(report).toHaveBeenLastCalledWith(expect.stringContaining("continue without Codex sessions"));
22
+ });
23
+ });
24
+ describe("prepareOpenCodeRuntimeEnvironment", () => {
25
+ it("injects OpenCode independently from Codex", async () => {
26
+ const environment = await prepareOpenCodeRuntimeEnvironment({ PATH: "/bin", AGENT_CODEX_BIN: "/managed/codex" }, { dataDir: "/data", target: "darwin-arm64" }, async () => ({ executable: "/managed/opencode", version: "1.18.27", source: "managed" }), vi.fn());
27
+ expect(environment).toMatchObject({
28
+ AGENT_CODEX_BIN: "/managed/codex",
29
+ AGENT_OPENCODE_BIN: "/managed/opencode",
30
+ });
31
+ });
32
+ it("records OpenCode failure without removing Codex", async () => {
33
+ const environment = await prepareOpenCodeRuntimeEnvironment({ AGENT_CODEX_BIN: "/managed/codex", AGENT_OPENCODE_BIN: "/bad/opencode" }, { dataDir: "/data", target: "darwin-arm64" }, async () => { throw new Error("OpenCode download failed"); }, vi.fn());
34
+ expect(environment.AGENT_CODEX_BIN).toBe("/managed/codex");
35
+ expect(environment.AGENT_OPENCODE_BIN).toBeUndefined();
36
+ expect(environment.AGENT_OPENCODE_RUNTIME_ERROR).toBe("OpenCode download failed");
37
+ });
38
+ });