@elvatis_com/openclaw-cli-bridge-elvatis 2.0.0 → 2.1.1

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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Unit tests for Codex auth import (Issue #2).
3
+ *
4
+ * Uses real temp files instead of mocks for reliable FS testing.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
8
+ import { writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { importCodexAuth } from "../src/codex-auth-import.js";
12
+
13
+ const TEST_BASE = join(tmpdir(), `codex-import-test-${process.pid}`);
14
+ const CODEX_AUTH_PATH = join(TEST_BASE, "codex", "auth.json");
15
+ const AUTH_STORE_PATH = join(TEST_BASE, "openclaw", "auth-profiles.json");
16
+
17
+ function writeCodexAuth(data: Record<string, unknown>) {
18
+ mkdirSync(join(TEST_BASE, "codex"), { recursive: true });
19
+ writeFileSync(CODEX_AUTH_PATH, JSON.stringify(data));
20
+ }
21
+
22
+ function writeAuthStore(data: Record<string, unknown>) {
23
+ mkdirSync(join(TEST_BASE, "openclaw"), { recursive: true });
24
+ writeFileSync(AUTH_STORE_PATH, JSON.stringify(data, null, 4));
25
+ }
26
+
27
+ function readAuthStore(): Record<string, unknown> {
28
+ return JSON.parse(readFileSync(AUTH_STORE_PATH, "utf8"));
29
+ }
30
+
31
+ beforeEach(() => {
32
+ mkdirSync(TEST_BASE, { recursive: true });
33
+ });
34
+
35
+ afterEach(() => {
36
+ try {
37
+ rmSync(TEST_BASE, { recursive: true, force: true });
38
+ } catch { /* ignore */ }
39
+ });
40
+
41
+ describe("importCodexAuth()", () => {
42
+ it("imports Codex OAuth tokens into auth store", async () => {
43
+ writeCodexAuth({
44
+ auth_mode: "oauth",
45
+ tokens: {
46
+ access_token: "test-access-token",
47
+ refresh_token: "test-refresh-token",
48
+ },
49
+ last_refresh: new Date().toISOString(),
50
+ });
51
+
52
+ const result = await importCodexAuth({
53
+ codexAuthPath: CODEX_AUTH_PATH,
54
+ authStorePath: AUTH_STORE_PATH,
55
+ });
56
+
57
+ expect(result.imported).toBe(true);
58
+ expect(result.skipped).toBe(false);
59
+ expect(result.error).toBeUndefined();
60
+
61
+ const store = readAuthStore() as { profiles: Record<string, { access: string; refresh: string }> };
62
+ expect(store.profiles["openai-codex:default"]).toBeDefined();
63
+ expect(store.profiles["openai-codex:default"].access).toBe("test-access-token");
64
+ expect(store.profiles["openai-codex:default"].refresh).toBe("test-refresh-token");
65
+ });
66
+
67
+ it("imports API key when OAuth tokens are not available", async () => {
68
+ writeCodexAuth({
69
+ auth_mode: "api-key",
70
+ OPENAI_API_KEY: "sk-test-key-123",
71
+ });
72
+
73
+ const result = await importCodexAuth({
74
+ codexAuthPath: CODEX_AUTH_PATH,
75
+ authStorePath: AUTH_STORE_PATH,
76
+ });
77
+
78
+ expect(result.imported).toBe(true);
79
+ const store = readAuthStore() as { profiles: Record<string, { access: string }> };
80
+ expect(store.profiles["openai-codex:default"].access).toBe("sk-test-key-123");
81
+ });
82
+
83
+ it("creates auth store directory if it does not exist", async () => {
84
+ writeCodexAuth({
85
+ auth_mode: "oauth",
86
+ tokens: { access_token: "new-token" },
87
+ });
88
+
89
+ const deepStorePath = join(TEST_BASE, "deep", "nested", "auth-profiles.json");
90
+ const result = await importCodexAuth({
91
+ codexAuthPath: CODEX_AUTH_PATH,
92
+ authStorePath: deepStorePath,
93
+ });
94
+
95
+ expect(result.imported).toBe(true);
96
+ expect(existsSync(deepStorePath)).toBe(true);
97
+ });
98
+
99
+ it("preserves existing profiles in auth store", async () => {
100
+ writeCodexAuth({
101
+ auth_mode: "oauth",
102
+ tokens: { access_token: "codex-token" },
103
+ });
104
+ writeAuthStore({
105
+ version: 1,
106
+ profiles: {
107
+ "anthropic:default": {
108
+ type: "token",
109
+ provider: "anthropic",
110
+ token: "sk-ant-test",
111
+ },
112
+ },
113
+ });
114
+
115
+ const result = await importCodexAuth({
116
+ codexAuthPath: CODEX_AUTH_PATH,
117
+ authStorePath: AUTH_STORE_PATH,
118
+ });
119
+
120
+ expect(result.imported).toBe(true);
121
+ const store = readAuthStore() as { profiles: Record<string, { token?: string; access?: string }> };
122
+ // Codex profile added
123
+ expect(store.profiles["openai-codex:default"].access).toBe("codex-token");
124
+ // Anthropic profile preserved
125
+ expect(store.profiles["anthropic:default"].token).toBe("sk-ant-test");
126
+ });
127
+
128
+ it("skips import when tokens are already up-to-date", async () => {
129
+ writeCodexAuth({
130
+ auth_mode: "oauth",
131
+ tokens: { access_token: "same-token", refresh_token: "same-refresh" },
132
+ });
133
+ writeAuthStore({
134
+ version: 1,
135
+ profiles: {
136
+ "openai-codex:default": {
137
+ type: "oauth",
138
+ provider: "openai-codex",
139
+ access: "same-token",
140
+ refresh: "same-refresh",
141
+ },
142
+ },
143
+ });
144
+
145
+ const result = await importCodexAuth({
146
+ codexAuthPath: CODEX_AUTH_PATH,
147
+ authStorePath: AUTH_STORE_PATH,
148
+ });
149
+
150
+ expect(result.imported).toBe(false);
151
+ expect(result.skipped).toBe(true);
152
+ });
153
+
154
+ it("updates when access token has changed", async () => {
155
+ writeCodexAuth({
156
+ auth_mode: "oauth",
157
+ tokens: { access_token: "new-token", refresh_token: "new-refresh" },
158
+ });
159
+ writeAuthStore({
160
+ version: 1,
161
+ profiles: {
162
+ "openai-codex:default": {
163
+ type: "oauth",
164
+ provider: "openai-codex",
165
+ access: "old-token",
166
+ refresh: "old-refresh",
167
+ },
168
+ },
169
+ });
170
+
171
+ const result = await importCodexAuth({
172
+ codexAuthPath: CODEX_AUTH_PATH,
173
+ authStorePath: AUTH_STORE_PATH,
174
+ });
175
+
176
+ expect(result.imported).toBe(true);
177
+ const store = readAuthStore() as { profiles: Record<string, { access: string }> };
178
+ expect(store.profiles["openai-codex:default"].access).toBe("new-token");
179
+ });
180
+
181
+ it("returns error when codex auth file does not exist", async () => {
182
+ const result = await importCodexAuth({
183
+ codexAuthPath: join(TEST_BASE, "nonexistent", "auth.json"),
184
+ authStorePath: AUTH_STORE_PATH,
185
+ });
186
+
187
+ expect(result.imported).toBe(false);
188
+ expect(result.skipped).toBe(false);
189
+ expect(result.error).toContain("Cannot read Codex auth file");
190
+ });
191
+
192
+ it("returns error when codex auth has no token", async () => {
193
+ writeCodexAuth({
194
+ auth_mode: "oauth",
195
+ // No tokens, no API key
196
+ });
197
+
198
+ const result = await importCodexAuth({
199
+ codexAuthPath: CODEX_AUTH_PATH,
200
+ authStorePath: AUTH_STORE_PATH,
201
+ });
202
+
203
+ expect(result.imported).toBe(false);
204
+ expect(result.error).toContain("No access token found");
205
+ });
206
+
207
+ it("includes expiry when last_refresh is present", async () => {
208
+ const now = new Date();
209
+ writeCodexAuth({
210
+ auth_mode: "oauth",
211
+ tokens: { access_token: "token-with-expiry" },
212
+ last_refresh: now.toISOString(),
213
+ });
214
+
215
+ const result = await importCodexAuth({
216
+ codexAuthPath: CODEX_AUTH_PATH,
217
+ authStorePath: AUTH_STORE_PATH,
218
+ });
219
+
220
+ expect(result.imported).toBe(true);
221
+ const store = readAuthStore() as { profiles: Record<string, { expires: number }> };
222
+ const profile = store.profiles["openai-codex:default"];
223
+ // Expiry should be ~1h after last_refresh
224
+ expect(profile.expires).toBeGreaterThan(now.getTime());
225
+ expect(profile.expires).toBeLessThanOrEqual(now.getTime() + 3600 * 1000 + 1000);
226
+ });
227
+
228
+ it("calls log function when provided", async () => {
229
+ writeCodexAuth({
230
+ auth_mode: "oauth",
231
+ tokens: { access_token: "logged-token" },
232
+ });
233
+
234
+ const logs: string[] = [];
235
+ await importCodexAuth({
236
+ codexAuthPath: CODEX_AUTH_PATH,
237
+ authStorePath: AUTH_STORE_PATH,
238
+ log: (msg) => logs.push(msg),
239
+ });
240
+
241
+ expect(logs.length).toBeGreaterThan(0);
242
+ expect(logs.some(l => l.includes("imported"))).toBe(true);
243
+ });
244
+ });
@@ -32,10 +32,13 @@ function makeFakeProc(): ChildProcess & {
32
32
  }
33
33
 
34
34
  // vi.hoisted variables are available inside vi.mock factories
35
- const { mockSpawn, mockExecSync, latestProcRef } = vi.hoisted(() => ({
35
+ const { mockSpawn, mockExecSync, latestProcRef, mockCreateIsolatedWorkdir, mockCleanupWorkdir, mockSweepOrphanedWorkdirs } = vi.hoisted(() => ({
36
36
  mockSpawn: vi.fn(),
37
37
  mockExecSync: vi.fn(),
38
38
  latestProcRef: { current: null as any },
39
+ mockCreateIsolatedWorkdir: vi.fn(() => "/tmp/cli-bridge-fake123"),
40
+ mockCleanupWorkdir: vi.fn(() => true),
41
+ mockSweepOrphanedWorkdirs: vi.fn(() => 0),
39
42
  }));
40
43
 
41
44
  vi.mock("node:child_process", async (importOriginal) => {
@@ -52,6 +55,13 @@ vi.mock("../src/claude-auth.js", () => ({
52
55
  setAuthLogger: vi.fn(),
53
56
  }));
54
57
 
58
+ // Mock workdir module to prevent real FS operations in unit tests
59
+ vi.mock("../src/workdir.js", () => ({
60
+ createIsolatedWorkdir: mockCreateIsolatedWorkdir,
61
+ cleanupWorkdir: mockCleanupWorkdir,
62
+ sweepOrphanedWorkdirs: mockSweepOrphanedWorkdirs,
63
+ }));
64
+
55
65
  // Now import SessionManager (uses the mocked spawn)
56
66
  import { SessionManager } from "../src/session-manager.js";
57
67
 
@@ -335,5 +345,102 @@ describe("SessionManager", () => {
335
345
  expect(proc1.kill).toHaveBeenCalledWith("SIGTERM");
336
346
  expect(proc2.kill).toHaveBeenCalledWith("SIGTERM");
337
347
  });
348
+
349
+ it("cleans up isolated workdirs on stop", () => {
350
+ mockCleanupWorkdir.mockClear();
351
+ mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "a" }], { isolateWorkdir: true });
352
+
353
+ mgr.stop();
354
+
355
+ expect(mockCleanupWorkdir).toHaveBeenCalledWith("/tmp/cli-bridge-fake123");
356
+ });
357
+ });
358
+
359
+ // ── workdir isolation (Issue #6) ───────────────────────────────────────
360
+
361
+ describe("workdir isolation", () => {
362
+ beforeEach(() => {
363
+ mockCreateIsolatedWorkdir.mockClear();
364
+ mockCleanupWorkdir.mockClear();
365
+ mockSweepOrphanedWorkdirs.mockClear();
366
+ });
367
+
368
+ it("creates an isolated workdir when isolateWorkdir is true", () => {
369
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], { isolateWorkdir: true });
370
+ expect(mockCreateIsolatedWorkdir).toHaveBeenCalledTimes(1);
371
+
372
+ // The session should use the created workdir as cwd
373
+ expect(mockSpawn).toHaveBeenCalledWith(
374
+ "gemini",
375
+ expect.any(Array),
376
+ expect.objectContaining({ cwd: "/tmp/cli-bridge-fake123" })
377
+ );
378
+
379
+ // Session info should include the isolated workdir
380
+ const list = mgr.list();
381
+ const session = list.find(s => s.sessionId === id);
382
+ expect(session?.isolatedWorkdir).toBe("/tmp/cli-bridge-fake123");
383
+ });
384
+
385
+ it("does not create isolated workdir when isolateWorkdir is false", () => {
386
+ mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
387
+ expect(mockCreateIsolatedWorkdir).not.toHaveBeenCalled();
388
+ });
389
+
390
+ it("does not create isolated workdir when explicit workdir is provided", () => {
391
+ mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], {
392
+ isolateWorkdir: true,
393
+ workdir: "/explicit/dir",
394
+ });
395
+ expect(mockCreateIsolatedWorkdir).not.toHaveBeenCalled();
396
+
397
+ // Should use the explicit workdir
398
+ expect(mockSpawn).toHaveBeenCalledWith(
399
+ "gemini",
400
+ expect.any(Array),
401
+ expect.objectContaining({ cwd: "/explicit/dir" })
402
+ );
403
+ });
404
+
405
+ it("cleans up isolated workdir when process exits", () => {
406
+ mockCleanupWorkdir.mockClear();
407
+ mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], { isolateWorkdir: true });
408
+
409
+ // Simulate process exit
410
+ latestProcRef.current._emit("close", 0);
411
+
412
+ expect(mockCleanupWorkdir).toHaveBeenCalledWith("/tmp/cli-bridge-fake123");
413
+ });
414
+
415
+ it("cleans up isolated workdir on process error", () => {
416
+ mockCleanupWorkdir.mockClear();
417
+ mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], { isolateWorkdir: true });
418
+
419
+ // Simulate process error
420
+ latestProcRef.current._emit("error", new Error("spawn ENOENT"));
421
+
422
+ expect(mockCleanupWorkdir).toHaveBeenCalledWith("/tmp/cli-bridge-fake123");
423
+ });
424
+
425
+ it("cleans up isolated workdir during cleanup sweep", () => {
426
+ mockCleanupWorkdir.mockClear();
427
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], { isolateWorkdir: true });
428
+
429
+ // Make the session old enough for cleanup
430
+ const sessions = (mgr as any).sessions as Map<string, any>;
431
+ sessions.get(id)!.startTime = Date.now() - 31 * 60 * 1000;
432
+
433
+ mgr.cleanup();
434
+
435
+ expect(mockCleanupWorkdir).toHaveBeenCalledWith("/tmp/cli-bridge-fake123");
436
+ expect(mockSweepOrphanedWorkdirs).toHaveBeenCalled();
437
+ });
438
+
439
+ it("session without isolation has null isolatedWorkdir", () => {
440
+ const id = mgr.spawn("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }]);
441
+ const list = mgr.list();
442
+ const session = list.find(s => s.sessionId === id);
443
+ expect(session?.isolatedWorkdir).toBeNull();
444
+ });
338
445
  });
339
446
  });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Unit tests for workdir isolation (Issue #6).
3
+ */
4
+
5
+ import { describe, it, expect, afterEach } from "vitest";
6
+ import { existsSync, writeFileSync, mkdirSync, statSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join, basename } from "node:path";
9
+ import {
10
+ createIsolatedWorkdir,
11
+ cleanupWorkdir,
12
+ sweepOrphanedWorkdirs,
13
+ getWorkdirBase,
14
+ } from "../src/workdir.js";
15
+
16
+ // Track created dirs for cleanup in case tests fail
17
+ const createdDirs: string[] = [];
18
+
19
+ afterEach(() => {
20
+ for (const dir of createdDirs) {
21
+ try {
22
+ const { rmSync } = require("node:fs");
23
+ rmSync(dir, { recursive: true, force: true });
24
+ } catch { /* ignore */ }
25
+ }
26
+ createdDirs.length = 0;
27
+ });
28
+
29
+ describe("createIsolatedWorkdir()", () => {
30
+ it("creates a directory that exists", () => {
31
+ const dir = createIsolatedWorkdir();
32
+ createdDirs.push(dir);
33
+ expect(existsSync(dir)).toBe(true);
34
+ });
35
+
36
+ it("creates a directory with the cli-bridge- prefix", () => {
37
+ const dir = createIsolatedWorkdir();
38
+ createdDirs.push(dir);
39
+ expect(basename(dir)).toMatch(/^cli-bridge-/);
40
+ });
41
+
42
+ it("creates unique directories on repeated calls", () => {
43
+ const dir1 = createIsolatedWorkdir();
44
+ const dir2 = createIsolatedWorkdir();
45
+ createdDirs.push(dir1, dir2);
46
+ expect(dir1).not.toBe(dir2);
47
+ });
48
+
49
+ it("accepts a custom base directory", () => {
50
+ const customBase = join(tmpdir(), "workdir-test-base");
51
+ mkdirSync(customBase, { recursive: true });
52
+ createdDirs.push(customBase);
53
+
54
+ const dir = createIsolatedWorkdir(customBase);
55
+ createdDirs.push(dir);
56
+ expect(dir.startsWith(customBase)).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe("cleanupWorkdir()", () => {
61
+ it("removes an existing workdir", () => {
62
+ const dir = createIsolatedWorkdir();
63
+ // Create a file inside to verify recursive removal
64
+ writeFileSync(join(dir, "test.txt"), "hello");
65
+ expect(existsSync(dir)).toBe(true);
66
+
67
+ const result = cleanupWorkdir(dir);
68
+ expect(result).toBe(true);
69
+ expect(existsSync(dir)).toBe(false);
70
+ });
71
+
72
+ it("returns false for non-existent directory", () => {
73
+ const result = cleanupWorkdir(join(tmpdir(), "cli-bridge-nonexistent"));
74
+ // rmSync with force:true doesn't throw for non-existent
75
+ expect(result).toBe(true);
76
+ });
77
+
78
+ it("refuses to remove directories without cli-bridge- prefix", () => {
79
+ const result = cleanupWorkdir("/tmp/some-other-dir");
80
+ expect(result).toBe(false);
81
+ });
82
+
83
+ it("refuses to remove empty string", () => {
84
+ const result = cleanupWorkdir("");
85
+ expect(result).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe("sweepOrphanedWorkdirs()", () => {
90
+ it("removes workdirs older than the threshold", () => {
91
+ const base = join(tmpdir(), "sweep-test-" + Date.now());
92
+ mkdirSync(base, { recursive: true });
93
+ createdDirs.push(base);
94
+
95
+ // Create a fake old workdir
96
+ const oldDir = join(base, "cli-bridge-oldone");
97
+ mkdirSync(oldDir);
98
+ // Set mtime to 2 hours ago
99
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
100
+ const { utimesSync } = require("node:fs");
101
+ utimesSync(oldDir, twoHoursAgo, twoHoursAgo);
102
+
103
+ const removed = sweepOrphanedWorkdirs(base);
104
+ expect(removed).toBe(1);
105
+ expect(existsSync(oldDir)).toBe(false);
106
+ });
107
+
108
+ it("does not remove recent workdirs", () => {
109
+ const base = join(tmpdir(), "sweep-test-recent-" + Date.now());
110
+ mkdirSync(base, { recursive: true });
111
+ createdDirs.push(base);
112
+
113
+ const recentDir = join(base, "cli-bridge-recent");
114
+ mkdirSync(recentDir);
115
+
116
+ const removed = sweepOrphanedWorkdirs(base);
117
+ expect(removed).toBe(0);
118
+ expect(existsSync(recentDir)).toBe(true);
119
+ });
120
+
121
+ it("ignores non-cli-bridge directories", () => {
122
+ const base = join(tmpdir(), "sweep-test-ignore-" + Date.now());
123
+ mkdirSync(base, { recursive: true });
124
+ createdDirs.push(base);
125
+
126
+ const otherDir = join(base, "other-dir");
127
+ mkdirSync(otherDir);
128
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
129
+ const { utimesSync } = require("node:fs");
130
+ utimesSync(otherDir, twoHoursAgo, twoHoursAgo);
131
+
132
+ const removed = sweepOrphanedWorkdirs(base);
133
+ expect(removed).toBe(0);
134
+ expect(existsSync(otherDir)).toBe(true);
135
+ });
136
+
137
+ it("returns 0 for non-existent base", () => {
138
+ const removed = sweepOrphanedWorkdirs("/nonexistent/path");
139
+ expect(removed).toBe(0);
140
+ });
141
+ });
142
+
143
+ describe("getWorkdirBase()", () => {
144
+ it("returns tmpdir by default", () => {
145
+ // Clear env var if set
146
+ const prev = process.env.OPENCLAW_CLI_BRIDGE_WORKDIR_BASE;
147
+ delete process.env.OPENCLAW_CLI_BRIDGE_WORKDIR_BASE;
148
+ expect(getWorkdirBase()).toBe(tmpdir());
149
+ // Restore
150
+ if (prev !== undefined) process.env.OPENCLAW_CLI_BRIDGE_WORKDIR_BASE = prev;
151
+ });
152
+ });