@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.
- package/.ai/handoff/NEXT_ACTIONS.md +4 -1
- package/.ai/handoff/STATUS.md +3 -1
- package/.github/ISSUE_TEMPLATE/bug_report.md +19 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +14 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +11 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/auto-publish.yml +68 -0
- package/.github/workflows/codeql.yml +40 -0
- package/CODE_OF_CONDUCT.md +38 -0
- package/CONTRIBUTING.md +15 -126
- package/LICENSE +216 -0
- package/README.md +24 -5
- package/SECURITY.md +17 -0
- package/index.ts +28 -0
- package/package.json +4 -3
- package/src/codex-auth-import.ts +127 -0
- package/src/proxy-server.ts +4 -0
- package/src/session-manager.ts +42 -3
- package/src/workdir.ts +108 -0
- package/test/codex-auth-import.test.ts +244 -0
- package/test/session-manager.test.ts +108 -1
- package/test/workdir.test.ts +152 -0
|
@@ -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
|
+
});
|