offrouter-adapter-codex 0.2.1 → 0.3.0

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.
@@ -1,551 +0,0 @@
1
- import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
- import { constants } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { afterEach, describe, expect, it } from "vitest";
6
- import { loadConfig } from "offrouter-core";
7
- import {
8
- CODEX_CONFIG_REL,
9
- CODEX_INSTALL_STATE_REL,
10
- installCodexProfile,
11
- patchConfigForMcp,
12
- type CodexInstallOptions,
13
- type CodexInstallResult,
14
- } from "./install.js";
15
- import {
16
- listAllowlistedPersonalCodexProfiles,
17
- resolveCodexProfileDir,
18
- } from "./profile.js";
19
- import { rollbackCodexProfile } from "./rollback.js";
20
-
21
- const tempRoots: string[] = [];
22
-
23
- async function makeTempHome(prefix: string): Promise<string> {
24
- const root = await mkdtemp(join(tmpdir(), prefix));
25
- tempRoots.push(root);
26
- return root;
27
- }
28
-
29
- async function exists(path: string): Promise<boolean> {
30
- try {
31
- await access(path, constants.F_OK);
32
- return true;
33
- } catch {
34
- return false;
35
- }
36
- }
37
-
38
- async function writeAllowlist(
39
- offrouterHome: string,
40
- profiles: string[],
41
- ): Promise<void> {
42
- await mkdir(offrouterHome, { recursive: true });
43
- const listed = profiles.map((p) => JSON.stringify(p)).join(", ");
44
- await writeFile(
45
- join(offrouterHome, "config.toml"),
46
- `allowlisted_profiles = [${listed}]\n`,
47
- "utf8",
48
- );
49
- }
50
-
51
- function baseEnv(home: string): NodeJS.ProcessEnv {
52
- return {
53
- ...process.env,
54
- HOME: home,
55
- OFFROUTER_HOME: join(home, ".offrouter"),
56
- OFFROUTER_CODEX_PROFILES_DIR: join(home, "codex-profiles"),
57
- // Never inherit real workspace trust / profile for these unit tests
58
- OFFROUTER_PROFILE: undefined,
59
- OFFROUTER_WORKSPACE_TRUSTED: undefined,
60
- };
61
- }
62
-
63
- async function installOpts(
64
- home: string,
65
- profile: string,
66
- overrides: Partial<CodexInstallOptions> = {},
67
- ): Promise<CodexInstallOptions> {
68
- const env = baseEnv(home);
69
- const config = await loadConfig({
70
- env,
71
- homedir: () => home,
72
- });
73
- return {
74
- profile,
75
- env,
76
- homedir: () => home,
77
- config,
78
- ...overrides,
79
- };
80
- }
81
-
82
- afterEach(async () => {
83
- while (tempRoots.length > 0) {
84
- const root = tempRoots.pop();
85
- if (root) {
86
- await rm(root, { recursive: true, force: true });
87
- }
88
- }
89
- });
90
-
91
- describe("resolveCodexProfileDir", () => {
92
- it("uses OFFROUTER_CODEX_PROFILES_DIR/<profile> when set", () => {
93
- const home = "/tmp/not-used-home";
94
- const dir = resolveCodexProfileDir("codex-personal", {
95
- env: {
96
- OFFROUTER_CODEX_PROFILES_DIR: "/tmp/profiles-root",
97
- },
98
- homedir: () => home,
99
- });
100
- expect(dir).toBe(join("/tmp/profiles-root", "codex-personal"));
101
- });
102
-
103
- it("maps profile id codex to ~/.codex and codex-personal to ~/.codex-personal", () => {
104
- const home = "/tmp/fake-home-map";
105
- expect(
106
- resolveCodexProfileDir("codex", {
107
- env: {},
108
- homedir: () => home,
109
- }),
110
- ).toBe(join(home, ".codex"));
111
- expect(
112
- resolveCodexProfileDir("codex-personal", {
113
- env: {},
114
- homedir: () => home,
115
- }),
116
- ).toBe(join(home, ".codex-personal"));
117
- });
118
-
119
- it("maps profile id codex to CODEX_HOME when set", () => {
120
- const home = "/tmp/fake-home-codex";
121
- expect(
122
- resolveCodexProfileDir("codex", {
123
- env: { CODEX_HOME: "/tmp/custom-codex-home" },
124
- homedir: () => home,
125
- }),
126
- ).toBe("/tmp/custom-codex-home");
127
- });
128
-
129
- it("rejects unsafe profile ids before resolving profile paths", () => {
130
- expect(() =>
131
- resolveCodexProfileDir("../codex-personal", {
132
- env: {
133
- OFFROUTER_CODEX_PROFILES_DIR: "/tmp/profiles-root",
134
- },
135
- homedir: () => "/tmp/fake-home",
136
- }),
137
- ).toThrow(/unsafe profile id/i);
138
- });
139
- });
140
-
141
- describe("installCodexProfile", () => {
142
- it("dry-run shows exact file changes and does not write files", async () => {
143
- const home = await makeTempHome("lr-codex-install-dry-");
144
- await writeAllowlist(join(home, ".offrouter"), ["codex-personal"]);
145
- const opts = await installOpts(home, "codex-personal", { dryRun: true });
146
- const result = await installCodexProfile(opts);
147
-
148
- expect(result.ok).toBe(true);
149
- expect(result.dryRun).toBe(true);
150
- expect(result.profile).toBe("codex-personal");
151
- expect(result.changes.length).toBeGreaterThan(0);
152
-
153
- const profileDir = resolveCodexProfileDir("codex-personal", opts);
154
- const configPath = join(profileDir, CODEX_CONFIG_REL);
155
- const statePath = join(profileDir, CODEX_INSTALL_STATE_REL);
156
-
157
- expect(
158
- result.changes.some((c) => c.relativePath === CODEX_CONFIG_REL),
159
- ).toBe(true);
160
- const configChange = result.changes.find(
161
- (c) => c.relativePath === CODEX_CONFIG_REL,
162
- );
163
- expect(configChange?.kind).toBe("create");
164
- expect(await exists(configPath)).toBe(false);
165
- expect(await exists(statePath)).toBe(false);
166
- expect(await exists(join(home, ".codex"))).toBe(false);
167
- });
168
-
169
- it("install writes only selected profile and not sibling work/default dirs", async () => {
170
- const home = await makeTempHome("lr-codex-install-scope-");
171
- await writeAllowlist(join(home, ".offrouter"), ["codex-personal"]);
172
-
173
- // Pre-create sibling profile dirs with markers so we can detect writes.
174
- const profilesRoot = join(home, "codex-profiles");
175
- await mkdir(join(profilesRoot, "codex-work"), { recursive: true });
176
- await mkdir(join(profilesRoot, "codex"), { recursive: true });
177
- await writeFile(
178
- join(profilesRoot, "codex-work", "marker.txt"),
179
- "work-untouched",
180
- "utf8",
181
- );
182
- await writeFile(
183
- join(profilesRoot, "codex", "marker.txt"),
184
- "default-untouched",
185
- "utf8",
186
- );
187
- await mkdir(join(home, ".codex"), { recursive: true });
188
- await writeFile(
189
- join(home, ".codex", "marker.txt"),
190
- "global-untouched",
191
- "utf8",
192
- );
193
-
194
- const opts = await installOpts(home, "codex-personal");
195
- const result = await installCodexProfile(opts);
196
- expect(result.ok).toBe(true);
197
- expect(result.dryRun).toBe(false);
198
-
199
- const profileDir = resolveCodexProfileDir("codex-personal", opts);
200
- const configPath = join(profileDir, CODEX_CONFIG_REL);
201
- expect(await exists(configPath)).toBe(true);
202
- const configRaw = await readFile(configPath, "utf8");
203
- expect(configRaw).toContain("[mcp_servers.offrouter]");
204
- expect(configRaw).toContain('command = "offrouter"');
205
- expect(configRaw).toContain('args = [ "mcp", "serve" ]');
206
-
207
- // Sibling profile and real home ~/.codex markers untouched
208
- expect(
209
- await readFile(join(profilesRoot, "codex-work", "marker.txt"), "utf8"),
210
- ).toBe("work-untouched");
211
- expect(
212
- await readFile(join(profilesRoot, "codex", "marker.txt"), "utf8"),
213
- ).toBe("default-untouched");
214
- expect(await readFile(join(home, ".codex", "marker.txt"), "utf8")).toBe(
215
- "global-untouched",
216
- );
217
- expect(await exists(join(profilesRoot, "codex-work", "config.toml"))).toBe(
218
- false,
219
- );
220
- expect(await exists(join(home, ".codex", "config.toml"))).toBe(false);
221
-
222
- // State lives inside target profile only
223
- const statePath = join(profileDir, CODEX_INSTALL_STATE_REL);
224
- expect(await exists(statePath)).toBe(true);
225
- const state = JSON.parse(await readFile(statePath, "utf8")) as {
226
- profile: string;
227
- harness: string;
228
- files: Record<string, { previous: string | null }>;
229
- };
230
- expect(state.profile).toBe("codex-personal");
231
- expect(state.harness).toBe("codex");
232
- expect(state.files[CODEX_CONFIG_REL]).toBeDefined();
233
- });
234
-
235
- it("refuses denied work profiles", async () => {
236
- const home = await makeTempHome("lr-codex-install-work-");
237
- // Even if someone allowlists a work profile, policy still denies *-work.
238
- await writeAllowlist(join(home, ".offrouter"), ["codex-work"]);
239
- const opts = await installOpts(home, "codex-work", { dryRun: true });
240
- const result = await installCodexProfile(opts);
241
- expect(result.ok).toBe(false);
242
- expect(result.errorCode).toBe("work_profile_denied");
243
- expect(result.message.toLowerCase()).toMatch(/work|denied/);
244
- expect(
245
- await exists(join(home, "codex-profiles", "codex-work", "config.toml")),
246
- ).toBe(false);
247
- });
248
-
249
- it("refuses work-like profile ids such as codex-work-staging even if allowlisted", async () => {
250
- const home = await makeTempHome("lr-codex-install-work-staging-");
251
- // Core policy only hard-denies ids ending in -work, so codex-work-staging
252
- // would otherwise pass routeRequest. The installer must still refuse it.
253
- await writeAllowlist(join(home, ".offrouter"), ["codex-work-staging"]);
254
- const opts = await installOpts(home, "codex-work-staging", {
255
- dryRun: true,
256
- });
257
- const result = await installCodexProfile(opts);
258
- expect(result.ok).toBe(false);
259
- expect(result.errorCode).toBe("work_profile_denied");
260
- expect(result.message.toLowerCase()).toContain("work");
261
- expect(
262
- await exists(
263
- join(home, "codex-profiles", "codex-work-staging", "config.toml"),
264
- ),
265
- ).toBe(false);
266
- });
267
-
268
- it("refuses unallowlisted personal profiles", async () => {
269
- const home = await makeTempHome("lr-codex-install-unallow-");
270
- await writeAllowlist(join(home, ".offrouter"), []); // nothing allowlisted
271
- const opts = await installOpts(home, "codex-personal", { dryRun: true });
272
- const result = await installCodexProfile(opts);
273
- expect(result.ok).toBe(false);
274
- expect(result.errorCode).toBe("profile_not_allowlisted");
275
- expect(result.message.toLowerCase()).toMatch(
276
- /allowlist|not allowlisted|explicitly/,
277
- );
278
- });
279
-
280
- it("refuses unsafe profile ids with a structured install error", async () => {
281
- const home = await makeTempHome("lr-codex-install-unsafe-");
282
- await writeAllowlist(join(home, ".offrouter"), ["../codex-personal"]);
283
- const opts = await installOpts(home, "../codex-personal", {
284
- dryRun: true,
285
- });
286
- const result = await installCodexProfile(opts);
287
- expect(result.ok).toBe(false);
288
- expect(result.errorCode).toBe("install_error");
289
- expect(result.profileDir).toBe("");
290
- expect(result.message.toLowerCase()).toContain("unsafe profile id");
291
- expect(await exists(join(home, "skills"))).toBe(false);
292
- });
293
-
294
- it("refuses allowlisted non-Codex profiles for Codex install", async () => {
295
- const home = await makeTempHome("lr-codex-install-non-codex-");
296
- await writeAllowlist(join(home, ".offrouter"), ["claude-personal"]);
297
- const opts = await installOpts(home, "claude-personal", { dryRun: true });
298
- const result = await installCodexProfile(opts);
299
- expect(result.ok).toBe(false);
300
- expect(result.errorCode).toBe("install_error");
301
- expect(result.message.toLowerCase()).toContain("codex install");
302
- expect(
303
- await exists(join(home, "codex-profiles", "claude-personal", "config.toml")),
304
- ).toBe(false);
305
- });
306
-
307
- it("install --all-personal --dry-run returns a matrix of allowlisted personal Codex profiles", async () => {
308
- const home = await makeTempHome("lr-codex-install-all-");
309
- await writeAllowlist(join(home, ".offrouter"), [
310
- "codex",
311
- "codex-personal",
312
- "codex-hobby",
313
- "claude-personal",
314
- "codex-work",
315
- ]);
316
- const env = baseEnv(home);
317
- const config = await loadConfig({ env, homedir: () => home });
318
- const profiles = listAllowlistedPersonalCodexProfiles(config);
319
- expect(profiles).toEqual(
320
- expect.arrayContaining(["codex-personal", "codex-hobby"]),
321
- );
322
- expect(profiles).not.toContain("codex");
323
- expect(profiles).not.toContain("codex-work");
324
- expect(profiles).not.toContain("claude-personal");
325
-
326
- const matrix: CodexInstallResult[] = [];
327
- for (const profile of profiles) {
328
- matrix.push(
329
- await installCodexProfile({
330
- profile,
331
- env,
332
- homedir: () => home,
333
- config,
334
- dryRun: true,
335
- }),
336
- );
337
- }
338
- expect(matrix.every((r) => r.ok && r.dryRun)).toBe(true);
339
- expect(matrix.map((r) => r.profile).sort()).toEqual(
340
- ["codex-hobby", "codex-personal"].sort(),
341
- );
342
- expect(
343
- matrix.every((r) =>
344
- r.changes.some((c) => c.kind === "create" || c.kind === "update"),
345
- ),
346
- ).toBe(true);
347
- });
348
-
349
- it("rollback restores previous config.toml", async () => {
350
- const home = await makeTempHome("lr-codex-install-rb-");
351
- await writeAllowlist(join(home, ".offrouter"), ["codex-personal"]);
352
-
353
- const opts = await installOpts(home, "codex-personal");
354
- const profileDir = resolveCodexProfileDir("codex-personal", opts);
355
- const configPath = join(profileDir, CODEX_CONFIG_REL);
356
- await mkdir(profileDir, { recursive: true });
357
- await writeFile(configPath, 'editor = "vim"\n', "utf8");
358
-
359
- const installResult = await installCodexProfile(opts);
360
- expect(installResult.ok).toBe(true);
361
- const afterInstall = await readFile(configPath, "utf8");
362
- expect(afterInstall).toContain("[mcp_servers.offrouter]");
363
- expect(afterInstall).toContain('editor = "vim"');
364
-
365
- // Preserve a sibling file inside .offrouter
366
- const siblingStateFile = join(profileDir, ".offrouter", "notes.json");
367
- await writeFile(siblingStateFile, '{"keep":true}\n', "utf8");
368
-
369
- const rb = await rollbackCodexProfile({
370
- profile: "codex-personal",
371
- env: opts.env,
372
- homedir: opts.homedir,
373
- dryRun: false,
374
- });
375
- expect(rb.ok).toBe(true);
376
- expect(await readFile(configPath, "utf8")).toBe('editor = "vim"\n');
377
-
378
- const statePath = join(profileDir, CODEX_INSTALL_STATE_REL);
379
- expect(await exists(statePath)).toBe(false);
380
- expect(await readFile(siblingStateFile, "utf8")).toBe('{"keep":true}\n');
381
- });
382
-
383
- it("refuses to reinstall when prior install state is invalid", async () => {
384
- const home = await makeTempHome("lr-codex-install-corrupt-state-");
385
- await writeAllowlist(join(home, ".offrouter"), ["codex-personal"]);
386
- const opts = await installOpts(home, "codex-personal");
387
- const profileDir = resolveCodexProfileDir("codex-personal", opts);
388
- const configPath = join(profileDir, CODEX_CONFIG_REL);
389
-
390
- const first = await installCodexProfile(opts);
391
- expect(first.ok).toBe(true);
392
- await writeFile(configPath, "# user edit\n", "utf8");
393
- await writeFile(
394
- join(profileDir, CODEX_INSTALL_STATE_REL),
395
- "{not valid json",
396
- "utf8",
397
- );
398
-
399
- const second = await installCodexProfile(opts);
400
- expect(second.ok).toBe(false);
401
- expect(second.message.toLowerCase()).toContain("invalid install state");
402
- expect(await readFile(configPath, "utf8")).toBe("# user edit\n");
403
- });
404
-
405
- it("rollback refuses install state paths outside the profile root (path traversal)", async () => {
406
- const home = await makeTempHome("lr-codex-install-rb-escape-");
407
- const env = baseEnv(home);
408
- const profile = "codex-personal";
409
- const profileDir = resolveCodexProfileDir(profile, {
410
- env,
411
- homedir: () => home,
412
- });
413
- const outsidePath = join(home, "outside.txt");
414
- const statePath = join(profileDir, CODEX_INSTALL_STATE_REL);
415
- await writeFile(outsidePath, "keep\n", "utf8");
416
- await mkdir(join(profileDir, ".offrouter"), { recursive: true });
417
- await writeFile(
418
- statePath,
419
- `${JSON.stringify(
420
- {
421
- version: 1,
422
- profile,
423
- harness: "codex",
424
- installedAt: new Date(0).toISOString(),
425
- configRelative: CODEX_CONFIG_REL,
426
- files: {
427
- "../../outside.txt": { previous: null },
428
- },
429
- },
430
- null,
431
- 2,
432
- )}\n`,
433
- "utf8",
434
- );
435
-
436
- const rb = await rollbackCodexProfile({
437
- profile,
438
- env,
439
- homedir: () => home,
440
- });
441
- expect(rb.ok).toBe(false);
442
- expect(rb.errorCode).toBe("invalid_install_state");
443
- expect(await readFile(outsidePath, "utf8")).toBe("keep\n");
444
- expect(await exists(statePath)).toBe(true);
445
- });
446
-
447
- it("repeated install is idempotent and preserves original rollback", async () => {
448
- const home = await makeTempHome("lr-codex-install-idemp-");
449
- await writeAllowlist(join(home, ".offrouter"), ["codex-personal"]);
450
- const opts = await installOpts(home, "codex-personal");
451
- const profileDir = resolveCodexProfileDir("codex-personal", opts);
452
- const configPath = join(profileDir, CODEX_CONFIG_REL);
453
-
454
- await mkdir(profileDir, { recursive: true });
455
- await writeFile(configPath, 'model = "o3"\n', "utf8");
456
-
457
- const first = await installCodexProfile(opts);
458
- const second = await installCodexProfile(opts);
459
- expect(first.ok).toBe(true);
460
- expect(second.ok).toBe(true);
461
-
462
- const after = await readFile(configPath, "utf8");
463
- expect(after).toContain("[mcp_servers.offrouter]");
464
- // Unrelated key is preserved across the TOML round-trip.
465
- expect(after).toContain('model = "o3"');
466
-
467
- // State still tracks original contents for true rollback
468
- const state = JSON.parse(
469
- await readFile(join(profileDir, CODEX_INSTALL_STATE_REL), "utf8"),
470
- ) as { files: Record<string, { previous: string | null }> };
471
- expect(state.files[CODEX_CONFIG_REL]?.previous).toContain("model");
472
-
473
- const rb = await rollbackCodexProfile({
474
- profile: "codex-personal",
475
- env: opts.env,
476
- homedir: opts.homedir,
477
- });
478
- expect(rb.ok).toBe(true);
479
- expect(await readFile(configPath, "utf8")).toBe('model = "o3"\n');
480
- });
481
- });
482
-
483
- describe("patchConfigForMcp", () => {
484
- it("creates a fresh config from a missing file", () => {
485
- const out = patchConfigForMcp(null);
486
- expect(out).toContain("[mcp_servers.offrouter]");
487
- expect(out).toContain('command = "offrouter"');
488
- expect(out).toContain('args = [ "mcp", "serve" ]');
489
- });
490
-
491
- it("creates a fresh config from an empty or whitespace-only file", () => {
492
- for (const empty of ["", " ", "\n\n"]) {
493
- const out = patchConfigForMcp(empty);
494
- expect(out).toContain("[mcp_servers.offrouter]");
495
- expect(out).toContain('command = "offrouter"');
496
- }
497
- });
498
-
499
- it("preserves unrelated keys when installing", () => {
500
- const existing =
501
- 'editor = "vim"\n' +
502
- 'model = "o3"\n' +
503
- "\n" +
504
- "[mcp_servers.other]\n" +
505
- 'command = "other-tool"\n' +
506
- 'args = ["run"]\n';
507
- const out = patchConfigForMcp(existing);
508
- // OffRouter entry is present.
509
- expect(out).toContain("[mcp_servers.offrouter]");
510
- expect(out).toContain('command = "offrouter"');
511
- expect(out).toContain('args = [ "mcp", "serve" ]');
512
- // Unrelated keys and the other MCP server survive the round-trip.
513
- expect(out).toContain('editor = "vim"');
514
- expect(out).toContain('model = "o3"');
515
- expect(out).toContain("[mcp_servers.other]");
516
- expect(out).toContain('command = "other-tool"');
517
- });
518
-
519
- it("overwrites a conflicting offrouter MCP entry", () => {
520
- const existing =
521
- "[mcp_servers.offrouter]\n" +
522
- 'command = "old-tool"\n' +
523
- 'args = ["different"]\n';
524
- const out = patchConfigForMcp(existing);
525
- expect(out).toContain('command = "offrouter"');
526
- expect(out).toContain('args = [ "mcp", "serve" ]');
527
- // The old command must be gone (overwritten, not appended).
528
- expect(out).not.toContain('command = "old-tool"');
529
- expect(out).not.toContain("different");
530
- });
531
-
532
- it("is idempotent when already installed correctly (detected by value)", () => {
533
- // Matching command/args but different quote/whitespace style than the
534
- // canonical block: still detected as already installed, bytes untouched.
535
- const existing =
536
- "[mcp_servers.offrouter]\n" +
537
- 'command = "offrouter"\n' +
538
- 'args = ["mcp", "serve"]\n';
539
- expect(patchConfigForMcp(existing)).toBe(existing);
540
- });
541
-
542
- it("treats a offrouter entry with wrong args as conflicting", () => {
543
- const existing =
544
- "[mcp_servers.offrouter]\n" +
545
- 'command = "offrouter"\n' +
546
- 'args = ["serve"]\n';
547
- const out = patchConfigForMcp(existing);
548
- expect(out).toContain('args = [ "mcp", "serve" ]');
549
- expect(out).not.toContain('args = ["serve"]');
550
- });
551
- });