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,251 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { createReadStream, createWriteStream } from "node:fs";
4
+ import { access, chmod, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises";
5
+ import { get as httpsGet } from "node:https";
6
+ import { resolve } from "node:path";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { promisify } from "node:util";
9
+ const execFileAsync = promisify(execFile);
10
+ const NODE_DOWNLOAD_BASE_URL = "https://nodejs.org/dist/v22.22.0";
11
+ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
12
+ const DEFAULT_LOCK_WAIT_MS = 12 * 60_000;
13
+ const DEFAULT_STALE_LOCK_MS = 15 * 60_000;
14
+ const MAX_REDIRECTS = 5;
15
+ export const MANAGED_NODE_VERSION = "22.22.0";
16
+ export const NODE_RUNTIME_ASSETS = {
17
+ "darwin-arm64": {
18
+ archive: "node-v22.22.0-darwin-arm64.tar.xz",
19
+ sha256: "2bd596bbfc4a275ceb8721a5954ee97daea5ebe673e96a185ebd732f6fb023ac",
20
+ archiveRoot: "node-v22.22.0-darwin-arm64",
21
+ },
22
+ "windows-amd64": {
23
+ archive: "node-v22.22.0-win-x64.zip",
24
+ sha256: "c97fa376d2becdc8863fcd3ca2dd9a83a9f3468ee7ccf7a6d076ec66a645c77a",
25
+ archiveRoot: "node-v22.22.0-win-x64",
26
+ },
27
+ };
28
+ const defaultDependencies = {
29
+ download: downloadFile,
30
+ sha256: sha256File,
31
+ async run(command, args, timeoutMs) {
32
+ const { stdout, stderr } = await execFileAsync(command, args, {
33
+ encoding: "utf8",
34
+ timeout: timeoutMs,
35
+ maxBuffer: 4 * 1024 * 1024,
36
+ windowsHide: true,
37
+ });
38
+ return { stdout, stderr };
39
+ },
40
+ sleep: (delayMs) => new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)),
41
+ now: () => Date.now(),
42
+ };
43
+ export function detectNodeRuntimeTarget(platform = process.platform, arch = process.arch) {
44
+ if (platform === "darwin" && arch === "arm64")
45
+ return "darwin-arm64";
46
+ if (platform === "win32" && arch === "x64")
47
+ return "windows-amd64";
48
+ throw Object.assign(new Error(`managed Node.js is unsupported on ${platform}-${arch}`), {
49
+ exitCode: 2,
50
+ });
51
+ }
52
+ export function managedNodeExecutable(dataDir, target) {
53
+ const root = resolve(dataDir, "runtimes", "node", MANAGED_NODE_VERSION);
54
+ return target === "windows-amd64" ? resolve(root, "node.exe") : resolve(root, "bin", "node");
55
+ }
56
+ export function managedNodeNpmCli(dataDir, target) {
57
+ const root = resolve(dataDir, "runtimes", "node", MANAGED_NODE_VERSION);
58
+ return target === "windows-amd64"
59
+ ? resolve(root, "node_modules", "npm", "bin", "npm-cli.js")
60
+ : resolve(root, "lib", "node_modules", "npm", "bin", "npm-cli.js");
61
+ }
62
+ export async function ensureManagedNode(options) {
63
+ const target = options.target ?? detectNodeRuntimeTarget(options.platform, options.arch);
64
+ const dependencies = { ...defaultDependencies, ...options.dependencies };
65
+ const cached = await validateManagedNode(options.dataDir, target, dependencies);
66
+ if (cached)
67
+ return cached;
68
+ const parent = resolve(options.dataDir, "runtimes", "node");
69
+ const runtimeRoot = resolve(parent, MANAGED_NODE_VERSION);
70
+ const lockPath = `${runtimeRoot}.lock`;
71
+ await mkdir(parent, { recursive: true });
72
+ const lock = await acquireLock({
73
+ lockPath,
74
+ dataDir: options.dataDir,
75
+ target,
76
+ dependencies,
77
+ waitMs: options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS,
78
+ staleMs: options.staleLockMs ?? DEFAULT_STALE_LOCK_MS,
79
+ });
80
+ if (lock.completed)
81
+ return lock.completed;
82
+ let temporaryRoot = null;
83
+ try {
84
+ const afterLock = await validateManagedNode(options.dataDir, target, dependencies);
85
+ if (afterLock)
86
+ return afterLock;
87
+ const asset = NODE_RUNTIME_ASSETS[target];
88
+ temporaryRoot = await mkdtemp(resolve(parent, `.${MANAGED_NODE_VERSION}-${process.pid}-`));
89
+ const archivePath = resolve(temporaryRoot, asset.archive);
90
+ const extractRoot = resolve(temporaryRoot, "extract");
91
+ await mkdir(extractRoot);
92
+ options.onProgress?.(`Downloading Node.js ${MANAGED_NODE_VERSION} for ${target}...`);
93
+ await dependencies.download(`${NODE_DOWNLOAD_BASE_URL}/${asset.archive}`, archivePath, options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS);
94
+ const actualSha256 = await dependencies.sha256(archivePath);
95
+ if (actualSha256 !== asset.sha256) {
96
+ throw new Error(`Node.js archive checksum mismatch: expected ${asset.sha256}, got ${actualSha256}`);
97
+ }
98
+ options.onProgress?.(`Extracting Node.js ${MANAGED_NODE_VERSION}...`);
99
+ await extractArchive(target, archivePath, extractRoot, dependencies);
100
+ const entries = await readdir(extractRoot);
101
+ if (entries.length !== 1 || entries[0] !== asset.archiveRoot) {
102
+ throw new Error(`unexpected Node.js archive root: ${entries.join(", ") || "empty archive"}`);
103
+ }
104
+ const extractedRuntime = resolve(extractRoot, asset.archiveRoot);
105
+ const staged = await validateRuntimeRoot(extractedRuntime, target, dependencies);
106
+ if (!staged)
107
+ throw new Error("Node.js runtime validation failed after extraction");
108
+ await rm(runtimeRoot, { recursive: true, force: true });
109
+ await rename(extractedRuntime, runtimeRoot);
110
+ const installed = await validateManagedNode(options.dataDir, target, dependencies);
111
+ if (!installed)
112
+ throw new Error("Node.js runtime validation failed after activation");
113
+ return installed;
114
+ }
115
+ catch (error) {
116
+ const concurrent = await validateManagedNode(options.dataDir, target, dependencies);
117
+ if (concurrent)
118
+ return concurrent;
119
+ throw new Error(`Unable to install managed Node.js ${MANAGED_NODE_VERSION}: ${describeError(error)}`, {
120
+ cause: error,
121
+ });
122
+ }
123
+ finally {
124
+ if (temporaryRoot)
125
+ await rm(temporaryRoot, { recursive: true, force: true });
126
+ await lock.handle.close().catch(() => undefined);
127
+ await releaseOwnedLock(lockPath, lock.token);
128
+ }
129
+ }
130
+ async function validateManagedNode(dataDir, target, dependencies) {
131
+ return validateRuntimeRoot(resolve(dataDir, "runtimes", "node", MANAGED_NODE_VERSION), target, dependencies);
132
+ }
133
+ async function validateRuntimeRoot(runtimeRoot, target, dependencies) {
134
+ const executable = target === "windows-amd64"
135
+ ? resolve(runtimeRoot, "node.exe")
136
+ : resolve(runtimeRoot, "bin", "node");
137
+ const npmCli = target === "windows-amd64"
138
+ ? resolve(runtimeRoot, "node_modules", "npm", "bin", "npm-cli.js")
139
+ : resolve(runtimeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
140
+ if (!(await pathExists(executable)) || !(await pathExists(npmCli)))
141
+ return null;
142
+ try {
143
+ if (target === "darwin-arm64")
144
+ await chmod(executable, 0o755);
145
+ const result = await dependencies.run(executable, ["--version"], 5_000);
146
+ if (`${result.stdout}\n${result.stderr}`.trim() !== `v${MANAGED_NODE_VERSION}`)
147
+ return null;
148
+ return { executable, npmCli, version: MANAGED_NODE_VERSION, source: "managed" };
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ async function extractArchive(target, archivePath, extractRoot, dependencies) {
155
+ if (target === "darwin-arm64") {
156
+ await dependencies.run("/usr/bin/tar", ["-xJf", archivePath, "-C", extractRoot], 120_000);
157
+ return;
158
+ }
159
+ const script = "& { param($archive, $destination) Expand-Archive -LiteralPath $archive -DestinationPath $destination -Force }";
160
+ await dependencies.run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script, archivePath, extractRoot], 120_000);
161
+ }
162
+ async function acquireLock(options) {
163
+ const deadline = options.dependencies.now() + options.waitMs;
164
+ while (true) {
165
+ const token = `${process.pid}:${randomUUID()}`;
166
+ try {
167
+ const handle = await open(options.lockPath, "wx", 0o600);
168
+ await handle.writeFile(`${token}\n${options.dependencies.now()}\n`);
169
+ return { handle, token };
170
+ }
171
+ catch (error) {
172
+ if (error.code !== "EEXIST")
173
+ throw error;
174
+ const completed = await validateManagedNode(options.dataDir, options.target, options.dependencies);
175
+ if (completed)
176
+ return { completed };
177
+ try {
178
+ const details = await stat(options.lockPath);
179
+ if (options.dependencies.now() - details.mtimeMs > options.staleMs) {
180
+ await rm(options.lockPath, { force: true });
181
+ continue;
182
+ }
183
+ }
184
+ catch {
185
+ continue;
186
+ }
187
+ if (options.dependencies.now() >= deadline) {
188
+ throw new Error(`Timed out waiting for Node.js install lock: ${options.lockPath}`);
189
+ }
190
+ await options.dependencies.sleep(Math.min(250, Math.max(1, deadline - options.dependencies.now())));
191
+ }
192
+ }
193
+ }
194
+ async function releaseOwnedLock(lockPath, token) {
195
+ try {
196
+ if ((await readFile(lockPath, "utf8")).split("\n", 1)[0] === token) {
197
+ await rm(lockPath, { force: true });
198
+ }
199
+ }
200
+ catch {
201
+ // A missing or replaced lock no longer belongs to this installer.
202
+ }
203
+ }
204
+ async function downloadFile(url, destination, timeoutMs) {
205
+ await new Promise((resolveDownload, rejectDownload) => {
206
+ const request = (currentUrl, redirects) => {
207
+ const responseRequest = httpsGet(currentUrl, { headers: { "user-agent": "agentroam-node-bootstrap" } }, (response) => {
208
+ const status = response.statusCode ?? 0;
209
+ if (status >= 300 && status < 400 && response.headers.location) {
210
+ response.resume();
211
+ if (redirects >= MAX_REDIRECTS)
212
+ return rejectDownload(new Error("too many Node.js download redirects"));
213
+ const redirectUrl = new URL(response.headers.location, currentUrl);
214
+ if (redirectUrl.protocol !== "https:") {
215
+ rejectDownload(new Error(`refusing non-HTTPS Node.js redirect: ${redirectUrl.protocol}`));
216
+ return;
217
+ }
218
+ request(redirectUrl.toString(), redirects + 1);
219
+ return;
220
+ }
221
+ if (status !== 200) {
222
+ response.resume();
223
+ rejectDownload(new Error(`Node.js download failed: HTTP ${status}`));
224
+ return;
225
+ }
226
+ pipeline(response, createWriteStream(destination, { mode: 0o600 }))
227
+ .then(resolveDownload, rejectDownload);
228
+ });
229
+ responseRequest.setTimeout(timeoutMs, () => responseRequest.destroy(new Error("Node.js download timed out")));
230
+ responseRequest.once("error", rejectDownload);
231
+ };
232
+ request(url, 0);
233
+ });
234
+ }
235
+ async function sha256File(path) {
236
+ const hash = createHash("sha256");
237
+ await pipeline(createReadStream(path), hash);
238
+ return hash.digest("hex");
239
+ }
240
+ async function pathExists(path) {
241
+ try {
242
+ await access(path);
243
+ return true;
244
+ }
245
+ catch {
246
+ return false;
247
+ }
248
+ }
249
+ function describeError(error) {
250
+ return error instanceof Error ? error.message : String(error);
251
+ }
@@ -0,0 +1,184 @@
1
+ import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, resolve } from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { MANAGED_NODE_VERSION, NODE_RUNTIME_ASSETS, detectNodeRuntimeTarget, ensureManagedNode, managedNodeExecutable, managedNodeNpmCli, } from "./node-runtime-manager.js";
6
+ const temporaryDirectories = [];
7
+ afterEach(async () => {
8
+ await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
9
+ });
10
+ describe("managed Node.js runtime", () => {
11
+ it("uses the exact official manifest", () => {
12
+ expect(MANAGED_NODE_VERSION).toBe("22.22.0");
13
+ expect(NODE_RUNTIME_ASSETS["darwin-arm64"]).toEqual({
14
+ archive: "node-v22.22.0-darwin-arm64.tar.xz",
15
+ sha256: "2bd596bbfc4a275ceb8721a5954ee97daea5ebe673e96a185ebd732f6fb023ac",
16
+ archiveRoot: "node-v22.22.0-darwin-arm64",
17
+ });
18
+ expect(NODE_RUNTIME_ASSETS["windows-amd64"].archive).toBe("node-v22.22.0-win-x64.zip");
19
+ });
20
+ it("selects only supported managed targets", () => {
21
+ expect(detectNodeRuntimeTarget("darwin", "arm64")).toBe("darwin-arm64");
22
+ expect(detectNodeRuntimeTarget("win32", "x64")).toBe("windows-amd64");
23
+ expect(() => detectNodeRuntimeTarget("darwin", "x64")).toThrow("unsupported");
24
+ expect(() => detectNodeRuntimeTarget("linux", "x64")).toThrow("unsupported");
25
+ });
26
+ it("reuses a validated cached runtime", async () => {
27
+ const dataDir = await temporaryRoot();
28
+ await fakeRuntime(dataDir, "darwin-arm64");
29
+ const download = vi.fn();
30
+ const resolution = await ensureManagedNode({
31
+ dataDir,
32
+ target: "darwin-arm64",
33
+ dependencies: { download, run: versionRunner() },
34
+ });
35
+ expect(resolution.executable).toBe(managedNodeExecutable(dataDir, "darwin-arm64"));
36
+ expect(resolution.npmCli).toBe(managedNodeNpmCli(dataDir, "darwin-arm64"));
37
+ expect(download).not.toHaveBeenCalled();
38
+ });
39
+ it("downloads, extracts, validates, and atomically activates a runtime", async () => {
40
+ const dataDir = await temporaryRoot();
41
+ const download = vi.fn(async (_url, destination) => writeFile(destination, "archive"));
42
+ const run = extractionRunner("darwin-arm64");
43
+ const resolution = await ensureManagedNode({
44
+ dataDir,
45
+ target: "darwin-arm64",
46
+ dependencies: {
47
+ download,
48
+ sha256: async () => NODE_RUNTIME_ASSETS["darwin-arm64"].sha256,
49
+ run,
50
+ },
51
+ });
52
+ expect(download).toHaveBeenCalledWith("https://nodejs.org/dist/v22.22.0/node-v22.22.0-darwin-arm64.tar.xz", expect.any(String), 10 * 60_000);
53
+ expect(resolution.version).toBe(MANAGED_NODE_VERSION);
54
+ expect(await readFile(resolution.npmCli, "utf8")).toBe("npm");
55
+ });
56
+ it("rejects a checksum mismatch and cleans temporary state", async () => {
57
+ const dataDir = await temporaryRoot();
58
+ await expect(ensureManagedNode({
59
+ dataDir,
60
+ target: "darwin-arm64",
61
+ dependencies: {
62
+ download: async (_url, destination) => writeFile(destination, "bad"),
63
+ sha256: async () => "bad-checksum",
64
+ },
65
+ })).rejects.toThrow("checksum mismatch");
66
+ expect(await readdir(resolve(dataDir, "runtimes", "node"))).toEqual([]);
67
+ });
68
+ it("rejects an unexpected archive root", async () => {
69
+ const dataDir = await temporaryRoot();
70
+ await expect(ensureManagedNode({
71
+ dataDir,
72
+ target: "darwin-arm64",
73
+ dependencies: {
74
+ download: async (_url, destination) => writeFile(destination, "archive"),
75
+ sha256: async () => NODE_RUNTIME_ASSETS["darwin-arm64"].sha256,
76
+ run: async (command, args) => {
77
+ if (command === "/usr/bin/tar")
78
+ await mkdir(resolve(args[args.indexOf("-C") + 1], "wrong-root"));
79
+ return { stdout: "", stderr: "" };
80
+ },
81
+ },
82
+ })).rejects.toThrow("unexpected Node.js archive root");
83
+ });
84
+ it("rejects a runtime that reports the wrong version", async () => {
85
+ const dataDir = await temporaryRoot();
86
+ const run = extractionRunner("darwin-arm64", "v22.21.0");
87
+ await expect(ensureManagedNode({
88
+ dataDir,
89
+ target: "darwin-arm64",
90
+ dependencies: {
91
+ download: async (_url, destination) => writeFile(destination, "archive"),
92
+ sha256: async () => NODE_RUNTIME_ASSETS["darwin-arm64"].sha256,
93
+ run,
94
+ },
95
+ })).rejects.toThrow("validation failed");
96
+ });
97
+ it("serializes concurrent installs", async () => {
98
+ const dataDir = await temporaryRoot();
99
+ let downloads = 0;
100
+ const dependencies = {
101
+ download: async (_url, destination) => {
102
+ downloads += 1;
103
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 30));
104
+ await writeFile(destination, "archive");
105
+ },
106
+ sha256: async () => NODE_RUNTIME_ASSETS["darwin-arm64"].sha256,
107
+ run: extractionRunner("darwin-arm64"),
108
+ };
109
+ const [first, second] = await Promise.all([
110
+ ensureManagedNode({ dataDir, target: "darwin-arm64", dependencies }),
111
+ ensureManagedNode({ dataDir, target: "darwin-arm64", dependencies }),
112
+ ]);
113
+ expect(downloads).toBe(1);
114
+ expect(second.executable).toBe(first.executable);
115
+ });
116
+ it("recovers a stale lock", async () => {
117
+ const dataDir = await temporaryRoot();
118
+ const lockPath = resolve(dataDir, "runtimes", "node", `${MANAGED_NODE_VERSION}.lock`);
119
+ await mkdir(dirname(lockPath), { recursive: true });
120
+ await writeFile(lockPath, "stale");
121
+ await utimes(lockPath, new Date(0), new Date(0));
122
+ const resolution = await ensureManagedNode({
123
+ dataDir,
124
+ target: "darwin-arm64",
125
+ staleLockMs: 1,
126
+ dependencies: {
127
+ download: async (_url, destination) => writeFile(destination, "archive"),
128
+ sha256: async () => NODE_RUNTIME_ASSETS["darwin-arm64"].sha256,
129
+ run: extractionRunner("darwin-arm64"),
130
+ },
131
+ });
132
+ expect(resolution.version).toBe(MANAGED_NODE_VERSION);
133
+ });
134
+ it("uses the Windows executable and npm layout", async () => {
135
+ const dataDir = await temporaryRoot();
136
+ await fakeRuntime(dataDir, "windows-amd64");
137
+ await expect(ensureManagedNode({
138
+ dataDir,
139
+ target: "windows-amd64",
140
+ dependencies: { run: versionRunner() },
141
+ })).resolves.toMatchObject({
142
+ executable: managedNodeExecutable(dataDir, "windows-amd64"),
143
+ npmCli: managedNodeNpmCli(dataDir, "windows-amd64"),
144
+ });
145
+ });
146
+ });
147
+ async function temporaryRoot() {
148
+ const root = await mkdtemp(resolve(tmpdir(), "agentroam-node-runtime-"));
149
+ temporaryDirectories.push(root);
150
+ return root;
151
+ }
152
+ async function fakeRuntime(dataDir, target) {
153
+ const executable = managedNodeExecutable(dataDir, target);
154
+ const npmCli = managedNodeNpmCli(dataDir, target);
155
+ await mkdir(dirname(executable), { recursive: true });
156
+ await mkdir(dirname(npmCli), { recursive: true });
157
+ await writeFile(executable, "node");
158
+ await writeFile(npmCli, "npm");
159
+ }
160
+ function versionRunner(version = `v${MANAGED_NODE_VERSION}`) {
161
+ return async () => ({ stdout: `${version}\n`, stderr: "" });
162
+ }
163
+ function extractionRunner(target, version = `v${MANAGED_NODE_VERSION}`) {
164
+ return vi.fn(async (command, args) => {
165
+ if (command === "/usr/bin/tar" || command === "powershell.exe") {
166
+ const extractRoot = command === "/usr/bin/tar"
167
+ ? args[args.indexOf("-C") + 1]
168
+ : args.at(-1);
169
+ const archiveRoot = resolve(extractRoot, NODE_RUNTIME_ASSETS[target].archiveRoot);
170
+ const executable = target === "windows-amd64"
171
+ ? resolve(archiveRoot, "node.exe")
172
+ : resolve(archiveRoot, "bin", "node");
173
+ const npmCli = target === "windows-amd64"
174
+ ? resolve(archiveRoot, "node_modules", "npm", "bin", "npm-cli.js")
175
+ : resolve(archiveRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
176
+ await mkdir(dirname(executable), { recursive: true });
177
+ await mkdir(dirname(npmCli), { recursive: true });
178
+ await writeFile(executable, "node");
179
+ await writeFile(npmCli, "npm");
180
+ return { stdout: "", stderr: "" };
181
+ }
182
+ return { stdout: `${version}\n`, stderr: "" };
183
+ });
184
+ }
@@ -0,0 +1,207 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, chmod, mkdir, mkdtemp, open, rename, rm, stat } from "node:fs/promises";
4
+ import { delimiter, isAbsolute, relative, resolve, win32 } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { resolveNpmExecutor } from "./codex-runtime-manager.js";
7
+ const execFileAsync = promisify(execFile);
8
+ export const OPENCODE_RUNTIME_VERSION = "1.18.27";
9
+ const OPENCODE_PACKAGE = `opencode-ai@${OPENCODE_RUNTIME_VERSION}`;
10
+ const NPM_REGISTRY = "https://registry.npmjs.org";
11
+ const defaults = {
12
+ async run(command, args, timeoutMs) {
13
+ try {
14
+ const { stdout, stderr } = await execFileAsync(command, args, {
15
+ encoding: "utf8",
16
+ timeout: timeoutMs,
17
+ maxBuffer: 4 * 1024 * 1024,
18
+ windowsHide: true,
19
+ });
20
+ return { stdout, stderr };
21
+ }
22
+ catch (error) {
23
+ const value = error;
24
+ throw new Error([value.message, value.stderr, value.stdout].filter(Boolean).join(": "), { cause: error });
25
+ }
26
+ },
27
+ sleep: (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)),
28
+ now: () => Date.now(),
29
+ };
30
+ export async function resolveOpenCodeRuntime(options) {
31
+ const environment = options.environment ?? process.env;
32
+ const platform = options.platform ?? process.platform;
33
+ const dependencies = { ...defaults, ...options.dependencies };
34
+ const explicit = environment.AGENT_OPENCODE_BIN?.trim();
35
+ if (explicit) {
36
+ const absolute = platform === "win32" ? win32.isAbsolute(explicit) : isAbsolute(explicit);
37
+ if (!absolute)
38
+ throw new Error("AGENT_OPENCODE_BIN must be an absolute path");
39
+ return validate(explicit, "explicit", platform, dependencies);
40
+ }
41
+ const global = await findGlobal(environment.PATH, platform, dependencies);
42
+ if (global)
43
+ return global;
44
+ const managedRoot = resolve(options.dataDir, "runtimes", "opencode", OPENCODE_RUNTIME_VERSION);
45
+ const managed = await validateManaged(managedRoot, platform, dependencies);
46
+ if (managed)
47
+ return managed;
48
+ options.onProgress?.(`Installing managed OpenCode ${OPENCODE_RUNTIME_VERSION}...`);
49
+ return install({ ...options, environment, platform, dependencies, managedRoot });
50
+ }
51
+ export function parseOpenCodeVersion(output) {
52
+ return output.match(/(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
53
+ }
54
+ export function managedOpenCodeBinaryCandidates(root, platform) {
55
+ return platform === "win32"
56
+ ? [
57
+ resolve(root, "node_modules", "opencode-ai", "bin", "opencode.exe"),
58
+ resolve(root, "node_modules", ".bin", "opencode.cmd"),
59
+ resolve(root, "node_modules", ".bin", "opencode.exe"),
60
+ ]
61
+ : [
62
+ resolve(root, "node_modules", "opencode-ai", "bin", "opencode.exe"),
63
+ resolve(root, "node_modules", ".bin", "opencode"),
64
+ ];
65
+ }
66
+ async function findGlobal(pathValue, platform, dependencies) {
67
+ const names = platform === "win32" ? ["opencode.exe", "opencode.cmd", "opencode"] : ["opencode"];
68
+ const separator = platform === "win32" ? ";" : delimiter;
69
+ for (const directory of (pathValue ?? "").split(separator).filter(Boolean)) {
70
+ for (const name of names) {
71
+ const candidate = platform === "win32" ? win32.resolve(directory, name) : resolve(directory, name);
72
+ if (!(await accessible(candidate, platform !== "win32")))
73
+ continue;
74
+ try {
75
+ return await validate(candidate, "global", platform, dependencies);
76
+ }
77
+ catch {
78
+ // Leave incompatible user installations untouched and use managed OpenCode.
79
+ }
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+ async function install(options) {
85
+ const parent = resolve(options.dataDir, "runtimes", "opencode");
86
+ const lockPath = `${options.managedRoot}.lock`;
87
+ await mkdir(parent, { recursive: true });
88
+ const lock = await acquireLock(lockPath, options.managedRoot, options.platform, options.dependencies, options.lockWaitMs ?? 190_000, options.staleLockMs ?? 300_000);
89
+ if (lock.completed)
90
+ return lock.completed;
91
+ let temporary = null;
92
+ try {
93
+ const existing = await validateManaged(options.managedRoot, options.platform, options.dependencies);
94
+ if (existing)
95
+ return existing;
96
+ temporary = await mkdtemp(resolve(parent, `.${OPENCODE_RUNTIME_VERSION}-${process.pid}-`));
97
+ const npm = await resolveNpmExecutor({
98
+ environment: options.environment,
99
+ platform: options.platform,
100
+ nodeExecutable: options.nodeExecutable,
101
+ });
102
+ await options.dependencies.run(npm.command, [
103
+ ...npm.argsPrefix,
104
+ "install",
105
+ "--no-audit",
106
+ "--no-fund",
107
+ `--registry=${NPM_REGISTRY}`,
108
+ "--prefix",
109
+ temporary,
110
+ OPENCODE_PACKAGE,
111
+ ], 180_000);
112
+ const validated = await validateManaged(temporary, options.platform, options.dependencies);
113
+ if (!validated)
114
+ throw new Error(`validation failed after installing ${OPENCODE_PACKAGE}`);
115
+ await rename(temporary, options.managedRoot);
116
+ temporary = null;
117
+ const activated = await validateManaged(options.managedRoot, options.platform, options.dependencies);
118
+ if (!activated)
119
+ throw new Error("validation failed after activation");
120
+ return activated;
121
+ }
122
+ catch (error) {
123
+ const concurrent = await validateManaged(options.managedRoot, options.platform, options.dependencies);
124
+ if (concurrent)
125
+ return concurrent;
126
+ throw new Error(`Unable to install managed OpenCode ${OPENCODE_RUNTIME_VERSION}: ${describe(error)}`, { cause: error });
127
+ }
128
+ finally {
129
+ if (temporary)
130
+ await rm(temporary, { recursive: true, force: true });
131
+ await lock.handle.close().catch(() => undefined);
132
+ await rm(lockPath, { force: true });
133
+ }
134
+ }
135
+ async function acquireLock(lockPath, managedRoot, platform, dependencies, waitMs, staleMs) {
136
+ const deadline = dependencies.now() + waitMs;
137
+ while (true) {
138
+ try {
139
+ const handle = await open(lockPath, "wx", 0o600);
140
+ await handle.writeFile(`${process.pid}\n${dependencies.now()}\n`);
141
+ return { handle };
142
+ }
143
+ catch (error) {
144
+ if (error.code !== "EEXIST")
145
+ throw error;
146
+ const completed = await validateManaged(managedRoot, platform, dependencies);
147
+ if (completed)
148
+ return { completed };
149
+ try {
150
+ if (dependencies.now() - (await stat(lockPath)).mtimeMs > staleMs) {
151
+ await rm(lockPath, { force: true });
152
+ continue;
153
+ }
154
+ }
155
+ catch {
156
+ continue;
157
+ }
158
+ if (dependencies.now() >= deadline)
159
+ throw new Error(`Timed out waiting for OpenCode runtime install lock: ${lockPath}`);
160
+ await dependencies.sleep(Math.min(250, Math.max(1, deadline - dependencies.now())));
161
+ }
162
+ }
163
+ }
164
+ async function validateManaged(root, platform, dependencies) {
165
+ for (const executable of managedOpenCodeBinaryCandidates(root, platform)) {
166
+ if (!within(root, executable) || !(await accessible(executable)))
167
+ continue;
168
+ try {
169
+ if (platform !== "win32")
170
+ await chmod(executable, 0o755);
171
+ return await validate(executable, "managed", platform, dependencies);
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+ async function validate(executable, source, _platform, dependencies) {
180
+ if (!(await accessible(executable)))
181
+ throw new Error(`OpenCode executable not found: ${executable}`);
182
+ const result = await dependencies.run(executable, ["--version"], 5_000);
183
+ const version = parseOpenCodeVersion(`${result.stdout}\n${result.stderr}`);
184
+ if (!version)
185
+ throw new Error(`Unable to parse OpenCode version from ${executable}`);
186
+ if (version !== OPENCODE_RUNTIME_VERSION) {
187
+ throw new Error(`OpenCode ${version} at ${executable} is incompatible; AgentRoam requires ${OPENCODE_RUNTIME_VERSION}`);
188
+ }
189
+ await dependencies.run(executable, ["serve", "--help"], 5_000);
190
+ return { executable, version, source };
191
+ }
192
+ async function accessible(path, executable = false) {
193
+ try {
194
+ await access(path, executable ? constants.X_OK : constants.F_OK);
195
+ return true;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
201
+ function within(root, path) {
202
+ const value = relative(resolve(root), resolve(path));
203
+ return value !== "" && !value.startsWith("..") && !isAbsolute(value);
204
+ }
205
+ function describe(error) {
206
+ return error instanceof Error ? error.message : String(error);
207
+ }