agentroam 0.2.0-preview.8 → 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.
- package/README.md +42 -8
- package/bin/agentroam.mjs +10 -1
- package/bin/node-preflight.mjs +138 -0
- package/dist/args.js +14 -1
- package/dist/args.test.js +27 -0
- package/dist/cli.js +59 -7
- package/dist/cloudflared/bundled-asset.test.js +2 -2
- package/dist/codex-runtime-manager.js +293 -0
- package/dist/codex-runtime-manager.test.js +192 -0
- package/dist/install-script-contract.test.js +41 -0
- package/dist/node-preflight.test.js +109 -0
- package/dist/node-runtime-manager.js +251 -0
- package/dist/node-runtime-manager.test.js +184 -0
- package/dist/opencode-runtime-manager.js +207 -0
- package/dist/opencode-runtime-manager.test.js +79 -0
- package/dist/platform-packages.js +1 -1
- package/dist/platform.js +8 -4
- package/dist/platform.test.js +17 -2
- package/dist/power/sleep-inhibitor.js +151 -0
- package/dist/power/sleep-inhibitor.test.js +104 -0
- package/dist/runtime-manager.js +50 -2
- package/dist/runtime-manager.test.js +38 -0
- package/dist/service/macos-launch-agent.js +250 -0
- package/dist/service/macos-launch-agent.test.js +197 -0
- package/dist/service/runtime-state.js +40 -0
- package/dist/service/runtime-state.test.js +43 -0
- package/dist/service/service-command.js +100 -0
- package/dist/service/service-command.test.js +102 -0
- package/dist/service/service-controller.js +27 -0
- package/dist/service/service-files.js +78 -0
- package/dist/service/service-files.test.js +48 -0
- package/dist/service/windows-service-host.js +53 -0
- package/dist/service/windows-service-host.test.js +46 -0
- package/dist/service/windows-task-service.js +275 -0
- package/dist/service/windows-task-service.test.js +95 -0
- package/dist/tunnel/cloudflare-provider.js +4 -1
- package/dist/tunnel/cloudflare-provider.test.js +13 -1
- package/dist/tunnel/public-readiness.js +1 -1
- package/dist/tunnel/relay-orchestrator.js +4 -0
- package/dist/tunnel/relay-orchestrator.test.js +11 -0
- package/install/README.md +45 -0
- package/install/install-agentroam.ps1 +175 -0
- package/install/install-agentroam.sh +199 -0
- package/package.json +8 -7
|
@@ -0,0 +1,293 @@
|
|
|
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
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export const CODEX_RUNTIME_VERSION = "0.153.0";
|
|
8
|
+
const CODEX_PACKAGE = `@openai/codex@${CODEX_RUNTIME_VERSION}`;
|
|
9
|
+
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
10
|
+
const DEFAULT_LOCK_WAIT_MS = 190_000;
|
|
11
|
+
const DEFAULT_STALE_LOCK_MS = 5 * 60_000;
|
|
12
|
+
const defaultDependencies = {
|
|
13
|
+
async run(command, args, timeoutMs) {
|
|
14
|
+
try {
|
|
15
|
+
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
timeout: timeoutMs,
|
|
18
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
});
|
|
21
|
+
return { stdout, stderr };
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const value = error;
|
|
25
|
+
const details = [value.message, value.stderr?.trim(), value.stdout?.trim()]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join(": ");
|
|
28
|
+
throw new Error(details || "process failed", { cause: error });
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
sleep: (delayMs) => new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)),
|
|
32
|
+
now: () => Date.now(),
|
|
33
|
+
};
|
|
34
|
+
export async function resolveCodexRuntime(options) {
|
|
35
|
+
const environment = options.environment ?? process.env;
|
|
36
|
+
const platform = options.platform ?? process.platform;
|
|
37
|
+
const dependencies = { ...defaultDependencies, ...options.dependencies };
|
|
38
|
+
const explicit = environment.AGENT_CODEX_BIN?.trim();
|
|
39
|
+
if (explicit) {
|
|
40
|
+
const absolute = platform === "win32" ? win32.isAbsolute(explicit) : isAbsolute(explicit);
|
|
41
|
+
if (!absolute) {
|
|
42
|
+
throw new Error("AGENT_CODEX_BIN must be an absolute path");
|
|
43
|
+
}
|
|
44
|
+
return validateCodex(explicit, "explicit", platform, dependencies);
|
|
45
|
+
}
|
|
46
|
+
const global = await findCompatibleGlobalCodex(environment.PATH, platform, dependencies);
|
|
47
|
+
if (global)
|
|
48
|
+
return global;
|
|
49
|
+
const managedRoot = resolve(options.dataDir, "runtimes", "codex", CODEX_RUNTIME_VERSION);
|
|
50
|
+
const managed = await validateManagedRuntime(managedRoot, options.target, platform, dependencies);
|
|
51
|
+
if (managed)
|
|
52
|
+
return managed;
|
|
53
|
+
options.onProgress?.(`Installing managed Codex ${CODEX_RUNTIME_VERSION}...`);
|
|
54
|
+
return installManagedCodex({
|
|
55
|
+
...options,
|
|
56
|
+
environment,
|
|
57
|
+
platform,
|
|
58
|
+
dependencies,
|
|
59
|
+
managedRoot,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export async function resolveNpmExecutor(options = {}) {
|
|
63
|
+
const environment = options.environment ?? process.env;
|
|
64
|
+
const platform = options.platform ?? process.platform;
|
|
65
|
+
const canAccess = options.canAccess ?? pathAccessible;
|
|
66
|
+
const npmExecPath = environment.npm_execpath?.trim();
|
|
67
|
+
const npmExecPathIsAbsolute = npmExecPath
|
|
68
|
+
&& (platform === "win32" ? win32.isAbsolute(npmExecPath) : isAbsolute(npmExecPath));
|
|
69
|
+
if (npmExecPath && npmExecPathIsAbsolute && await canAccess(npmExecPath)) {
|
|
70
|
+
return {
|
|
71
|
+
command: options.nodeExecutable ?? process.execPath,
|
|
72
|
+
argsPrefix: [npmExecPath],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const names = platform === "win32" ? ["npm.cmd", "npm.exe", "npm"] : ["npm"];
|
|
76
|
+
const pathDelimiter = platform === "win32" ? ";" : delimiter;
|
|
77
|
+
for (const directory of (environment.PATH ?? "").split(pathDelimiter).filter(Boolean)) {
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
const candidate = platform === "win32" ? win32.resolve(directory, name) : resolve(directory, name);
|
|
80
|
+
if (!(await canAccess(candidate)))
|
|
81
|
+
continue;
|
|
82
|
+
if (platform === "win32" && name === "npm.cmd") {
|
|
83
|
+
const npmCliCandidates = [
|
|
84
|
+
win32.resolve(directory, "node_modules", "npm", "bin", "npm-cli.js"),
|
|
85
|
+
win32.resolve(directory, "..", "node_modules", "npm", "bin", "npm-cli.js"),
|
|
86
|
+
];
|
|
87
|
+
for (const npmCliPath of npmCliCandidates) {
|
|
88
|
+
if (await canAccess(npmCliPath)) {
|
|
89
|
+
return {
|
|
90
|
+
command: options.nodeExecutable ?? process.execPath,
|
|
91
|
+
argsPrefix: [npmCliPath],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
return { command: candidate, argsPrefix: [] };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw new Error("Codex runtime installer unavailable: npm was not found");
|
|
101
|
+
}
|
|
102
|
+
export function managedCodexBinaryCandidates(runtimeRoot, target) {
|
|
103
|
+
const platformInfo = codexPlatformInfo(target);
|
|
104
|
+
const vendorRoot = resolve(runtimeRoot, "node_modules", "@openai", platformInfo.packageName, "vendor", platformInfo.vendorTarget);
|
|
105
|
+
return [
|
|
106
|
+
resolve(vendorRoot, "bin", platformInfo.executable),
|
|
107
|
+
resolve(vendorRoot, "codex", platformInfo.executable),
|
|
108
|
+
resolve(vendorRoot, platformInfo.executable),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
export function parseCodexVersion(output) {
|
|
112
|
+
const match = output.match(/(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/);
|
|
113
|
+
return match?.[1] ?? null;
|
|
114
|
+
}
|
|
115
|
+
async function findCompatibleGlobalCodex(pathValue, platform, dependencies) {
|
|
116
|
+
const names = platform === "win32" ? ["codex.exe", "codex"] : ["codex"];
|
|
117
|
+
const pathDelimiter = platform === "win32" ? ";" : delimiter;
|
|
118
|
+
for (const directory of (pathValue ?? "").split(pathDelimiter).filter(Boolean)) {
|
|
119
|
+
for (const name of names) {
|
|
120
|
+
const candidate = platform === "win32" ? win32.resolve(directory, name) : resolve(directory, name);
|
|
121
|
+
if (!(await pathAccessible(candidate, platform !== "win32")))
|
|
122
|
+
continue;
|
|
123
|
+
try {
|
|
124
|
+
return await validateCodex(candidate, "global", platform, dependencies);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// An incompatible user installation is left untouched; try managed Codex.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
async function installManagedCodex(options) {
|
|
134
|
+
const parent = resolve(options.dataDir, "runtimes", "codex");
|
|
135
|
+
const lockPath = `${options.managedRoot}.lock`;
|
|
136
|
+
const lockWaitMs = options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
|
|
137
|
+
const staleLockMs = options.staleLockMs ?? DEFAULT_STALE_LOCK_MS;
|
|
138
|
+
await mkdir(parent, { recursive: true });
|
|
139
|
+
const lock = await acquireInstallLock(lockPath, options.managedRoot, options.target, options.platform, options.dependencies, lockWaitMs, staleLockMs);
|
|
140
|
+
if (lock.completed)
|
|
141
|
+
return lock.completed;
|
|
142
|
+
let temporaryRoot = null;
|
|
143
|
+
try {
|
|
144
|
+
const existing = await validateManagedRuntime(options.managedRoot, options.target, options.platform, options.dependencies);
|
|
145
|
+
if (existing)
|
|
146
|
+
return existing;
|
|
147
|
+
temporaryRoot = await mkdtemp(resolve(parent, `.${CODEX_RUNTIME_VERSION}-${process.pid}-`));
|
|
148
|
+
const npm = await resolveNpmExecutor({
|
|
149
|
+
environment: options.environment,
|
|
150
|
+
platform: options.platform,
|
|
151
|
+
nodeExecutable: options.nodeExecutable,
|
|
152
|
+
});
|
|
153
|
+
await options.dependencies.run(npm.command, [
|
|
154
|
+
...npm.argsPrefix,
|
|
155
|
+
"install",
|
|
156
|
+
"--ignore-scripts",
|
|
157
|
+
"--no-audit",
|
|
158
|
+
"--no-fund",
|
|
159
|
+
`--registry=${NPM_REGISTRY}`,
|
|
160
|
+
"--prefix",
|
|
161
|
+
temporaryRoot,
|
|
162
|
+
CODEX_PACKAGE,
|
|
163
|
+
], 180_000);
|
|
164
|
+
const temporary = await validateManagedRuntime(temporaryRoot, options.target, options.platform, options.dependencies);
|
|
165
|
+
if (!temporary) {
|
|
166
|
+
throw new Error(`Codex runtime validation failed after installing ${CODEX_PACKAGE}`);
|
|
167
|
+
}
|
|
168
|
+
await rename(temporaryRoot, options.managedRoot);
|
|
169
|
+
temporaryRoot = null;
|
|
170
|
+
const installed = await validateManagedRuntime(options.managedRoot, options.target, options.platform, options.dependencies);
|
|
171
|
+
if (!installed)
|
|
172
|
+
throw new Error("Codex runtime validation failed after activation");
|
|
173
|
+
return installed;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
const concurrent = await validateManagedRuntime(options.managedRoot, options.target, options.platform, options.dependencies);
|
|
177
|
+
if (concurrent)
|
|
178
|
+
return concurrent;
|
|
179
|
+
throw new Error(`Unable to install managed Codex ${CODEX_RUNTIME_VERSION}: ${describeError(error)}`, {
|
|
180
|
+
cause: error,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
if (temporaryRoot)
|
|
185
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
186
|
+
await lock.handle.close().catch(() => undefined);
|
|
187
|
+
await rm(lockPath, { force: true });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function acquireInstallLock(lockPath, managedRoot, target, platform, dependencies, waitMs, staleMs) {
|
|
191
|
+
const deadline = dependencies.now() + waitMs;
|
|
192
|
+
while (true) {
|
|
193
|
+
try {
|
|
194
|
+
const handle = await open(lockPath, "wx", 0o600);
|
|
195
|
+
await handle.writeFile(`${process.pid}\n${dependencies.now()}\n`);
|
|
196
|
+
return { handle };
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error.code !== "EEXIST")
|
|
200
|
+
throw error;
|
|
201
|
+
const completed = await validateManagedRuntime(managedRoot, target, platform, dependencies);
|
|
202
|
+
if (completed)
|
|
203
|
+
return { completed };
|
|
204
|
+
try {
|
|
205
|
+
const lockStat = await stat(lockPath);
|
|
206
|
+
if (dependencies.now() - lockStat.mtimeMs > staleMs) {
|
|
207
|
+
await rm(lockPath, { force: true });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (dependencies.now() >= deadline) {
|
|
215
|
+
throw new Error(`Timed out waiting for Codex runtime install lock: ${lockPath}`);
|
|
216
|
+
}
|
|
217
|
+
await dependencies.sleep(Math.min(250, Math.max(1, deadline - dependencies.now())));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function validateManagedRuntime(runtimeRoot, target, platform, dependencies) {
|
|
222
|
+
for (const candidate of managedCodexBinaryCandidates(runtimeRoot, target)) {
|
|
223
|
+
if (!isWithin(runtimeRoot, candidate) || !(await pathAccessible(candidate)))
|
|
224
|
+
continue;
|
|
225
|
+
try {
|
|
226
|
+
if (platform !== "win32")
|
|
227
|
+
await chmod(candidate, 0o755);
|
|
228
|
+
return await validateCodex(candidate, "managed", platform, dependencies);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
async function validateCodex(executable, source, _platform, dependencies) {
|
|
237
|
+
if (!(await pathAccessible(executable))) {
|
|
238
|
+
throw new Error(`Codex executable not found: ${executable}`);
|
|
239
|
+
}
|
|
240
|
+
const versionResult = await dependencies.run(executable, ["--version"], 5_000);
|
|
241
|
+
const version = parseCodexVersion(`${versionResult.stdout}\n${versionResult.stderr}`);
|
|
242
|
+
if (!version)
|
|
243
|
+
throw new Error(`Unable to parse Codex version from ${executable}`);
|
|
244
|
+
if (version !== CODEX_RUNTIME_VERSION) {
|
|
245
|
+
throw new Error(`Codex ${version} at ${executable} is incompatible; AgentRoam requires ${CODEX_RUNTIME_VERSION}`);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await dependencies.run(executable, ["app-server", "--help"], 5_000);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
throw new Error(`Codex app-server validation failed at ${executable}: ${describeError(error)}`, {
|
|
252
|
+
cause: error,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return { executable, version, source };
|
|
256
|
+
}
|
|
257
|
+
function codexPlatformInfo(target) {
|
|
258
|
+
if (target === "darwin-arm64") {
|
|
259
|
+
return {
|
|
260
|
+
packageName: "codex-darwin-arm64",
|
|
261
|
+
vendorTarget: "aarch64-apple-darwin",
|
|
262
|
+
executable: "codex",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (target === "darwin-amd64") {
|
|
266
|
+
return {
|
|
267
|
+
packageName: "codex-darwin-x64",
|
|
268
|
+
vendorTarget: "x86_64-apple-darwin",
|
|
269
|
+
executable: "codex",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
packageName: "codex-win32-x64",
|
|
274
|
+
vendorTarget: "x86_64-pc-windows-msvc",
|
|
275
|
+
executable: "codex.exe",
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
async function pathAccessible(path, executable = false) {
|
|
279
|
+
try {
|
|
280
|
+
await access(path, executable ? constants.X_OK : constants.F_OK);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function isWithin(root, candidate) {
|
|
288
|
+
const fromRoot = relative(resolve(root), resolve(candidate));
|
|
289
|
+
return fromRoot !== "" && !fromRoot.startsWith("..") && !isAbsolute(fromRoot);
|
|
290
|
+
}
|
|
291
|
+
function describeError(error) {
|
|
292
|
+
return error instanceof Error ? error.message : String(error);
|
|
293
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, resolve, win32 } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { CODEX_RUNTIME_VERSION, managedCodexBinaryCandidates, parseCodexVersion, resolveCodexRuntime, resolveNpmExecutor, } from "./codex-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 Codex runtime", () => {
|
|
11
|
+
it("parses the official version output", () => {
|
|
12
|
+
expect(parseCodexVersion("codex-cli 0.153.0")).toBe("0.153.0");
|
|
13
|
+
expect(parseCodexVersion("codex-cli next")).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
it("uses a compatible explicit absolute executable first", async () => {
|
|
16
|
+
const root = await temporaryRoot();
|
|
17
|
+
const executable = await fakeExecutable(resolve(root, "codex"));
|
|
18
|
+
const run = vi.fn(async (_command, args) => ({
|
|
19
|
+
stdout: args[0] === "--version" ? "codex-cli 0.153.0\n" : "app-server help\n",
|
|
20
|
+
stderr: "",
|
|
21
|
+
}));
|
|
22
|
+
await expect(resolveCodexRuntime({
|
|
23
|
+
dataDir: resolve(root, "data"),
|
|
24
|
+
target: "darwin-arm64",
|
|
25
|
+
environment: { AGENT_CODEX_BIN: executable, PATH: "" },
|
|
26
|
+
platform: "darwin",
|
|
27
|
+
dependencies: { run },
|
|
28
|
+
})).resolves.toEqual({
|
|
29
|
+
executable,
|
|
30
|
+
version: CODEX_RUNTIME_VERSION,
|
|
31
|
+
source: "explicit",
|
|
32
|
+
});
|
|
33
|
+
expect(run.mock.calls.map((call) => call[1])).toEqual([["--version"], ["app-server", "--help"]]);
|
|
34
|
+
});
|
|
35
|
+
it("rejects an incompatible explicit executable instead of silently replacing it", async () => {
|
|
36
|
+
const root = await temporaryRoot();
|
|
37
|
+
const executable = await fakeExecutable(resolve(root, "codex"));
|
|
38
|
+
await expect(resolveCodexRuntime({
|
|
39
|
+
dataDir: resolve(root, "data"),
|
|
40
|
+
target: "darwin-arm64",
|
|
41
|
+
environment: { AGENT_CODEX_BIN: executable, PATH: "" },
|
|
42
|
+
platform: "darwin",
|
|
43
|
+
dependencies: {
|
|
44
|
+
run: async () => ({ stdout: "codex-cli 0.154.0\n", stderr: "" }),
|
|
45
|
+
},
|
|
46
|
+
})).rejects.toThrow("requires 0.153.0");
|
|
47
|
+
});
|
|
48
|
+
it("skips an incompatible PATH executable and reuses the managed runtime", async () => {
|
|
49
|
+
const root = await temporaryRoot();
|
|
50
|
+
const global = await fakeExecutable(resolve(root, "bin", "codex"));
|
|
51
|
+
const managedRoot = resolve(root, "data", "runtimes", "codex", CODEX_RUNTIME_VERSION);
|
|
52
|
+
const managed = await fakeExecutable(managedCodexBinaryCandidates(managedRoot, "darwin-arm64")[0]);
|
|
53
|
+
const run = vi.fn(async (command, args) => ({
|
|
54
|
+
stdout: args[0] === "--version"
|
|
55
|
+
? command === global ? "codex-cli 0.152.0\n" : "codex-cli 0.153.0\n"
|
|
56
|
+
: "help\n",
|
|
57
|
+
stderr: "",
|
|
58
|
+
}));
|
|
59
|
+
await expect(resolveCodexRuntime({
|
|
60
|
+
dataDir: resolve(root, "data"),
|
|
61
|
+
target: "darwin-arm64",
|
|
62
|
+
environment: { PATH: resolve(root, "bin") },
|
|
63
|
+
platform: "darwin",
|
|
64
|
+
dependencies: { run },
|
|
65
|
+
})).resolves.toEqual({ executable: managed, version: CODEX_RUNTIME_VERSION, source: "managed" });
|
|
66
|
+
expect(run.mock.calls.some((call) => call[1].includes("install"))).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
it("installs the locked official package into a versioned runtime directory", async () => {
|
|
69
|
+
const root = await temporaryRoot();
|
|
70
|
+
const npmExecPath = await fakeExecutable(resolve(root, "npm-cli.js"));
|
|
71
|
+
let installs = 0;
|
|
72
|
+
const run = vi.fn(async (command, args) => {
|
|
73
|
+
if (command === "/test/node" && args.includes("install")) {
|
|
74
|
+
installs += 1;
|
|
75
|
+
const prefix = args[args.indexOf("--prefix") + 1];
|
|
76
|
+
await fakeExecutable(managedCodexBinaryCandidates(prefix, "darwin-arm64")[0]);
|
|
77
|
+
expect(args).toContain("@openai/codex@0.153.0");
|
|
78
|
+
expect(args).toContain("--registry=https://registry.npmjs.org");
|
|
79
|
+
return { stdout: "installed", stderr: "" };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
stdout: args[0] === "--version" ? "codex-cli 0.153.0\n" : "help\n",
|
|
83
|
+
stderr: "",
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
const resolution = await resolveCodexRuntime({
|
|
87
|
+
dataDir: resolve(root, "data"),
|
|
88
|
+
target: "darwin-arm64",
|
|
89
|
+
environment: { PATH: "", npm_execpath: npmExecPath },
|
|
90
|
+
platform: "darwin",
|
|
91
|
+
nodeExecutable: "/test/node",
|
|
92
|
+
dependencies: { run },
|
|
93
|
+
});
|
|
94
|
+
expect(installs).toBe(1);
|
|
95
|
+
expect(resolution.executable).toContain(`/runtimes/codex/${CODEX_RUNTIME_VERSION}/`);
|
|
96
|
+
expect(resolution.source).toBe("managed");
|
|
97
|
+
});
|
|
98
|
+
it("serializes concurrent first-start installs", async () => {
|
|
99
|
+
const root = await temporaryRoot();
|
|
100
|
+
const npmExecPath = await fakeExecutable(resolve(root, "npm-cli.js"));
|
|
101
|
+
let installs = 0;
|
|
102
|
+
const run = async (command, args) => {
|
|
103
|
+
if (command === "/test/node" && args.includes("install")) {
|
|
104
|
+
installs += 1;
|
|
105
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 30));
|
|
106
|
+
const prefix = args[args.indexOf("--prefix") + 1];
|
|
107
|
+
await fakeExecutable(managedCodexBinaryCandidates(prefix, "darwin-arm64")[0]);
|
|
108
|
+
return { stdout: "installed", stderr: "" };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
stdout: args[0] === "--version" ? "codex-cli 0.153.0\n" : "help\n",
|
|
112
|
+
stderr: "",
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
const options = {
|
|
116
|
+
dataDir: resolve(root, "data"),
|
|
117
|
+
target: "darwin-arm64",
|
|
118
|
+
environment: { PATH: "", npm_execpath: npmExecPath },
|
|
119
|
+
platform: "darwin",
|
|
120
|
+
nodeExecutable: "/test/node",
|
|
121
|
+
dependencies: { run },
|
|
122
|
+
};
|
|
123
|
+
const [first, second] = await Promise.all([
|
|
124
|
+
resolveCodexRuntime(options),
|
|
125
|
+
resolveCodexRuntime(options),
|
|
126
|
+
]);
|
|
127
|
+
expect(installs).toBe(1);
|
|
128
|
+
expect(second.executable).toBe(first.executable);
|
|
129
|
+
});
|
|
130
|
+
it("cleans temporary installs and the lock after npm failure", async () => {
|
|
131
|
+
const root = await temporaryRoot();
|
|
132
|
+
const npmExecPath = await fakeExecutable(resolve(root, "npm-cli.js"));
|
|
133
|
+
await expect(resolveCodexRuntime({
|
|
134
|
+
dataDir: resolve(root, "data"),
|
|
135
|
+
target: "darwin-arm64",
|
|
136
|
+
environment: { PATH: "", npm_execpath: npmExecPath },
|
|
137
|
+
platform: "darwin",
|
|
138
|
+
nodeExecutable: "/test/node",
|
|
139
|
+
dependencies: {
|
|
140
|
+
run: async () => { throw new Error("network unavailable"); },
|
|
141
|
+
},
|
|
142
|
+
})).rejects.toThrow("network unavailable");
|
|
143
|
+
expect(await readdir(resolve(root, "data", "runtimes", "codex"))).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe("Codex npm executor", () => {
|
|
147
|
+
it("runs npm_execpath through the current Node executable", async () => {
|
|
148
|
+
await expect(resolveNpmExecutor({
|
|
149
|
+
environment: { npm_execpath: "/tools/npm-cli.js", PATH: "" },
|
|
150
|
+
nodeExecutable: "/runtime/node",
|
|
151
|
+
canAccess: async (path) => path === "/tools/npm-cli.js",
|
|
152
|
+
})).resolves.toEqual({ command: "/runtime/node", argsPrefix: ["/tools/npm-cli.js"] });
|
|
153
|
+
});
|
|
154
|
+
it("finds a directly executable npm.exe on Windows PATH", async () => {
|
|
155
|
+
const expected = win32.resolve("C:\\tools", "npm.exe");
|
|
156
|
+
await expect(resolveNpmExecutor({
|
|
157
|
+
environment: { PATH: ["C:\\missing", "C:\\tools"].join(";") },
|
|
158
|
+
platform: "win32",
|
|
159
|
+
canAccess: async (path) => path === expected,
|
|
160
|
+
})).resolves.toEqual({ command: expected, argsPrefix: [] });
|
|
161
|
+
});
|
|
162
|
+
it("runs a standard Windows npm.cmd installation through npm-cli.js", async () => {
|
|
163
|
+
const npmCmd = win32.resolve("C:\\Program Files\\nodejs", "npm.cmd");
|
|
164
|
+
const npmCli = win32.resolve("C:\\Program Files\\nodejs", "node_modules", "npm", "bin", "npm-cli.js");
|
|
165
|
+
await expect(resolveNpmExecutor({
|
|
166
|
+
environment: { PATH: "C:\\Program Files\\nodejs" },
|
|
167
|
+
platform: "win32",
|
|
168
|
+
nodeExecutable: "C:\\Program Files\\nodejs\\node.exe",
|
|
169
|
+
canAccess: async (path) => path === npmCmd || path === npmCli,
|
|
170
|
+
})).resolves.toEqual({
|
|
171
|
+
command: "C:\\Program Files\\nodejs\\node.exe",
|
|
172
|
+
argsPrefix: [npmCli],
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
it("maps official macOS and Windows optional-package binaries", () => {
|
|
176
|
+
expect(managedCodexBinaryCandidates("/runtime", "darwin-arm64")[0])
|
|
177
|
+
.toContain("codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex");
|
|
178
|
+
expect(managedCodexBinaryCandidates("/runtime", "windows-amd64")[0])
|
|
179
|
+
.toContain("codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
async function temporaryRoot() {
|
|
183
|
+
const root = await mkdtemp(resolve(tmpdir(), "agentroam-codex-runtime-"));
|
|
184
|
+
temporaryDirectories.push(root);
|
|
185
|
+
return root;
|
|
186
|
+
}
|
|
187
|
+
async function fakeExecutable(path) {
|
|
188
|
+
await mkdir(dirname(path), { recursive: true });
|
|
189
|
+
await writeFile(path, "fake\n");
|
|
190
|
+
await chmod(path, 0o755);
|
|
191
|
+
return path;
|
|
192
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { MANAGED_NODE_VERSION, NODE_RUNTIME_ASSETS } from "./node-runtime-manager.js";
|
|
6
|
+
import { AGENTROAM_VERSION } from "./platform-packages.js";
|
|
7
|
+
const installRoot = resolve(import.meta.dirname, "../install");
|
|
8
|
+
describe("standalone installer contracts", () => {
|
|
9
|
+
it("keeps the macOS installer aligned with the runtime manifest", async () => {
|
|
10
|
+
const scriptPath = resolve(installRoot, "install-agentroam.sh");
|
|
11
|
+
const script = await readFile(scriptPath, "utf8");
|
|
12
|
+
expect(script).toContain(`NODE_VERSION="${MANAGED_NODE_VERSION}"`);
|
|
13
|
+
expect(script).toContain(`AGENTROAM_VERSION="${AGENTROAM_VERSION}"`);
|
|
14
|
+
expect(script).toContain(NODE_RUNTIME_ASSETS["darwin-arm64"].archive);
|
|
15
|
+
expect(script).toContain(NODE_RUNTIME_ASSETS["darwin-arm64"].sha256);
|
|
16
|
+
expect(script).toContain("https://registry.npmjs.org");
|
|
17
|
+
expect(script).toContain("$HOME/.agentroam");
|
|
18
|
+
expect(script).toContain("$HOME/.local/bin");
|
|
19
|
+
expect(script).toContain('AGENTROAM_ROOT');
|
|
20
|
+
expect(script).toContain('AGENTROAM_INSTALL_SKIP_SERVICE');
|
|
21
|
+
expect(script).toContain('service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR"');
|
|
22
|
+
expect(script).toContain("refusing to expose the entire home directory implicitly");
|
|
23
|
+
expect(script).not.toMatch(/\b(?:brew|sudo|nvm|fnm|volta)\b/);
|
|
24
|
+
execFileSync("sh", ["-n", scriptPath]);
|
|
25
|
+
});
|
|
26
|
+
it("keeps the Windows installer aligned with the runtime manifest", async () => {
|
|
27
|
+
const script = await readFile(resolve(installRoot, "install-agentroam.ps1"), "utf8");
|
|
28
|
+
expect(script).toContain(`$NodeVersion = "${MANAGED_NODE_VERSION}"`);
|
|
29
|
+
expect(script).toContain(`$AgentRoamVersion = "${AGENTROAM_VERSION}"`);
|
|
30
|
+
expect(script).toContain(NODE_RUNTIME_ASSETS["windows-amd64"].archive);
|
|
31
|
+
expect(script).toContain(NODE_RUNTIME_ASSETS["windows-amd64"].sha256);
|
|
32
|
+
expect(script).toContain("https://registry.npmjs.org");
|
|
33
|
+
expect(script).toContain(".agentroam\\bin");
|
|
34
|
+
expect(script).toContain('[Environment]::SetEnvironmentVariable("Path", $UpdatedPath, "User")');
|
|
35
|
+
expect(script).toContain("$env:AGENTROAM_ROOT");
|
|
36
|
+
expect(script).toContain("$env:AGENTROAM_INSTALL_SKIP_SERVICE");
|
|
37
|
+
expect(script).toContain("service install --root $ServiceRoot --data-dir $DataDir");
|
|
38
|
+
expect(script).toContain("Refusing to expose the entire home directory implicitly");
|
|
39
|
+
expect(script).not.toMatch(/\b(?:winget|choco|scoop|Start-Process\s+.*RunAs)\b/i);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
// @ts-expect-error The preflight intentionally remains plain ESM for Node 18 bootstrap.
|
|
5
|
+
import { parsePreflightDataDir, runNodePreflight } from "../bin/node-preflight.mjs";
|
|
6
|
+
describe("Node launcher preflight", () => {
|
|
7
|
+
it("passes through any Node 22 patch without installing", async () => {
|
|
8
|
+
const ensureNode = vi.fn();
|
|
9
|
+
await expect(runNodePreflight({ nodeVersion: "22.1.0", ensureNode })).resolves.toEqual({ handled: false });
|
|
10
|
+
expect(ensureNode).not.toHaveBeenCalled();
|
|
11
|
+
});
|
|
12
|
+
it.each(["18.20.0", "20.18.0", "24.0.0", "25.1.0"])("re-executes Node %s through the managed runtime", async (nodeVersion) => {
|
|
13
|
+
const child = fakeChild(0, null);
|
|
14
|
+
const ensureNode = vi.fn(async () => ({ executable: "/managed/node", version: "22.22.0" }));
|
|
15
|
+
const spawnChild = vi.fn((_executable, _args, _options) => child);
|
|
16
|
+
const result = await runNodePreflight({
|
|
17
|
+
argv: ["doctor", "--data-dir", "./custom"],
|
|
18
|
+
nodeVersion,
|
|
19
|
+
platform: "darwin",
|
|
20
|
+
arch: "arm64",
|
|
21
|
+
launcherPath: "/launcher/agentroam.mjs",
|
|
22
|
+
environment: { PATH: "" },
|
|
23
|
+
ensureNode,
|
|
24
|
+
findSystemNode22: async () => null,
|
|
25
|
+
spawnChild,
|
|
26
|
+
processHost: fakeProcessHost(),
|
|
27
|
+
});
|
|
28
|
+
expect(result).toEqual({ handled: true, exitCode: 0, signal: null });
|
|
29
|
+
expect(ensureNode).toHaveBeenCalledWith(expect.objectContaining({
|
|
30
|
+
dataDir: resolve("./custom"),
|
|
31
|
+
target: "darwin-arm64",
|
|
32
|
+
}));
|
|
33
|
+
expect(spawnChild).toHaveBeenCalledWith("/managed/node", ["/launcher/agentroam.mjs", "doctor", "--data-dir", "./custom"], expect.objectContaining({
|
|
34
|
+
stdio: "inherit",
|
|
35
|
+
env: expect.objectContaining({ AGENTROAM_MANAGED_NODE: "22.22.0" }),
|
|
36
|
+
}));
|
|
37
|
+
});
|
|
38
|
+
it("reuses another visible system Node 22", async () => {
|
|
39
|
+
const ensureNode = vi.fn();
|
|
40
|
+
const spawnChild = vi.fn((_executable, _args, _options) => fakeChild(7, null));
|
|
41
|
+
await expect(runNodePreflight({
|
|
42
|
+
argv: ["version"],
|
|
43
|
+
nodeVersion: "25.0.0",
|
|
44
|
+
platform: "win32",
|
|
45
|
+
arch: "x64",
|
|
46
|
+
environment: { PATH: "" },
|
|
47
|
+
findSystemNode22: async () => "C:\\Node22\\node.exe",
|
|
48
|
+
ensureNode,
|
|
49
|
+
spawnChild,
|
|
50
|
+
processHost: fakeProcessHost(),
|
|
51
|
+
})).resolves.toMatchObject({ handled: true, exitCode: 7 });
|
|
52
|
+
expect(ensureNode).not.toHaveBeenCalled();
|
|
53
|
+
expect(spawnChild.mock.calls[0][0]).toBe("C:\\Node22\\node.exe");
|
|
54
|
+
});
|
|
55
|
+
it("rejects a failed managed re-entry instead of looping", async () => {
|
|
56
|
+
await expect(runNodePreflight({
|
|
57
|
+
nodeVersion: "25.0.0",
|
|
58
|
+
environment: { AGENTROAM_MANAGED_NODE: "22.22.0" },
|
|
59
|
+
})).rejects.toMatchObject({ message: expect.stringContaining("re-entry failed"), exitCode: 2 });
|
|
60
|
+
});
|
|
61
|
+
it("directs old Node versions to the standalone installer", async () => {
|
|
62
|
+
await expect(runNodePreflight({
|
|
63
|
+
nodeVersion: "16.20.0",
|
|
64
|
+
platform: "darwin",
|
|
65
|
+
environment: {},
|
|
66
|
+
})).rejects.toThrow("install-agentroam.sh");
|
|
67
|
+
await expect(runNodePreflight({
|
|
68
|
+
nodeVersion: "12.0.0",
|
|
69
|
+
platform: "win32",
|
|
70
|
+
environment: {},
|
|
71
|
+
})).rejects.toThrow("install-agentroam.ps1");
|
|
72
|
+
});
|
|
73
|
+
it("forwards termination signals and propagates a child signal", async () => {
|
|
74
|
+
const processHost = fakeProcessHost();
|
|
75
|
+
const child = new EventEmitter();
|
|
76
|
+
child.kill = vi.fn();
|
|
77
|
+
const running = runNodePreflight({
|
|
78
|
+
nodeVersion: "20.0.0",
|
|
79
|
+
platform: "darwin",
|
|
80
|
+
arch: "arm64",
|
|
81
|
+
environment: { PATH: "" },
|
|
82
|
+
findSystemNode22: async () => "/node22",
|
|
83
|
+
spawnChild: () => child,
|
|
84
|
+
processHost,
|
|
85
|
+
});
|
|
86
|
+
await new Promise((resolveTurn) => setImmediate(resolveTurn));
|
|
87
|
+
processHost.emit("SIGTERM");
|
|
88
|
+
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
89
|
+
child.emit("exit", null, "SIGTERM");
|
|
90
|
+
await expect(running).resolves.toEqual({ handled: true, exitCode: null, signal: "SIGTERM" });
|
|
91
|
+
});
|
|
92
|
+
it("parses data directory overrides before the CLI import", () => {
|
|
93
|
+
expect(parsePreflightDataDir(["start", "--data-dir", "./data"], {}, "/home/me")).toBe(resolve("./data"));
|
|
94
|
+
expect(parsePreflightDataDir([], { AGENTROAM_DATA_DIR: "/custom" }, "/home/me")).toBe("/custom");
|
|
95
|
+
expect(parsePreflightDataDir([], {}, "/home/me")).toBe(resolve("/home/me", ".agentroam"));
|
|
96
|
+
expect(() => parsePreflightDataDir(["--data-dir"], {}, "/home/me")).toThrow("missing value");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
function fakeChild(exitCode, signal) {
|
|
100
|
+
const child = new EventEmitter();
|
|
101
|
+
child.kill = vi.fn();
|
|
102
|
+
setImmediate(() => child.emit("exit", exitCode, signal));
|
|
103
|
+
return child;
|
|
104
|
+
}
|
|
105
|
+
function fakeProcessHost() {
|
|
106
|
+
const host = new EventEmitter();
|
|
107
|
+
host.stderr = { write: vi.fn() };
|
|
108
|
+
return host;
|
|
109
|
+
}
|