@elvatis_com/openclaw-cli-bridge-elvatis 2.0.0 → 2.1.2
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 +31 -3
- 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
package/SECURITY.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a Vulnerability
|
|
4
|
+
|
|
5
|
+
If you believe you have found a security vulnerability in this project, please report it responsibly:
|
|
6
|
+
|
|
7
|
+
1. **Do not open a public issue.** Instead, send an email to **security@elvatis.com** with:
|
|
8
|
+
- A clear description of the vulnerability
|
|
9
|
+
- Steps to reproduce
|
|
10
|
+
- Expected and actual behavior
|
|
11
|
+
- Any PoC code or attachments (zip) if safe to share
|
|
12
|
+
|
|
13
|
+
2. We will acknowledge receipt within **48 hours** and provide a timeline for fixes.
|
|
14
|
+
|
|
15
|
+
3. Do not publicly disclose the issue until we have had a reasonable time to address it.
|
|
16
|
+
|
|
17
|
+
We appreciate responsible disclosure.
|
package/index.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* /cli-gemini3 → vllm/cli-gemini/gemini-3-pro-preview (Gemini CLI proxy)
|
|
18
18
|
* /cli-codex → openai-codex/gpt-5.3-codex (Codex CLI OAuth, direct API)
|
|
19
19
|
* /cli-codex54 → openai-codex/gpt-5.4 (Codex CLI OAuth, direct API)
|
|
20
|
+
* /cli-opencode → vllm/opencode/default (OpenCode CLI proxy)
|
|
21
|
+
* /cli-pi → vllm/pi/default (Pi CLI proxy)
|
|
20
22
|
* /cli-back → restore model that was active before last /cli-* switch
|
|
21
23
|
* /cli-test [model] → one-shot proxy health check (does NOT switch global model)
|
|
22
24
|
* /cli-list → list all registered CLI bridge models with commands
|
|
@@ -57,6 +59,7 @@ import {
|
|
|
57
59
|
DEFAULT_MODEL as CODEX_DEFAULT_MODEL,
|
|
58
60
|
readCodexCredentials,
|
|
59
61
|
} from "./src/codex-auth.js";
|
|
62
|
+
import { importCodexAuth } from "./src/codex-auth-import.js";
|
|
60
63
|
import { startProxyServer } from "./src/proxy-server.js";
|
|
61
64
|
import { patchOpencllawConfig } from "./src/config-patcher.js";
|
|
62
65
|
import {
|
|
@@ -759,6 +762,10 @@ const CLI_MODEL_COMMANDS = [
|
|
|
759
762
|
{ name: "cli-codex52", model: "openai-codex/gpt-5.2-codex", description: "GPT-5.2 Codex (Codex CLI auth)", label: "GPT-5.2 Codex" },
|
|
760
763
|
{ name: "cli-codex54", model: "openai-codex/gpt-5.4", description: "GPT-5.4 (Codex CLI auth)", label: "GPT-5.4" },
|
|
761
764
|
{ name: "cli-codex-mini", model: "openai-codex/gpt-5.1-codex-mini", description: "GPT-5.1 Codex Mini (Codex CLI auth)", label: "GPT-5.1 Codex Mini" },
|
|
765
|
+
// ── OpenCode CLI (via local proxy) ─────────────────────────────────────────
|
|
766
|
+
{ name: "cli-opencode", model: "vllm/opencode/default", description: "OpenCode (CLI)", label: "OpenCode (CLI)" },
|
|
767
|
+
// ── Pi CLI (via local proxy) ─────────────────────────────────────────────────
|
|
768
|
+
{ name: "cli-pi", model: "vllm/pi/default", description: "Pi (CLI)", label: "Pi (CLI)" },
|
|
762
769
|
// ── BitNet local inference (via local proxy → llama-server) ─────────────────
|
|
763
770
|
{ name: "cli-bitnet", model: "vllm/local-bitnet/bitnet-2b", description: "BitNet b1.58 2B (local CPU, no API key)", label: "BitNet 2B (local)" },
|
|
764
771
|
] as const;
|
|
@@ -1236,6 +1243,11 @@ const plugin = {
|
|
|
1236
1243
|
refreshOAuth: async (cred: ProviderAuthContext) => {
|
|
1237
1244
|
try {
|
|
1238
1245
|
const fresh = await readCodexCredentials(codexAuthPath);
|
|
1246
|
+
// Also update the agent auth store with refreshed tokens
|
|
1247
|
+
void importCodexAuth({
|
|
1248
|
+
codexAuthPath,
|
|
1249
|
+
log: (msg) => api.logger.info(`[cli-bridge:codex-refresh] ${msg}`),
|
|
1250
|
+
});
|
|
1239
1251
|
return {
|
|
1240
1252
|
...cred,
|
|
1241
1253
|
access: fresh.accessToken,
|
|
@@ -1249,6 +1261,22 @@ const plugin = {
|
|
|
1249
1261
|
});
|
|
1250
1262
|
|
|
1251
1263
|
api.logger.info("[cli-bridge] openai-codex provider registered");
|
|
1264
|
+
|
|
1265
|
+
// Auto-import Codex CLI credentials into the agent auth store (Issue #2).
|
|
1266
|
+
// This ensures `openai-codex/*` models work immediately without manual
|
|
1267
|
+
// `openclaw models auth login`. Runs async, non-blocking.
|
|
1268
|
+
void importCodexAuth({
|
|
1269
|
+
codexAuthPath,
|
|
1270
|
+
log: (msg) => api.logger.info(`[cli-bridge:codex-import] ${msg}`),
|
|
1271
|
+
}).then((result) => {
|
|
1272
|
+
if (result.imported) {
|
|
1273
|
+
api.logger.info("[cli-bridge] Codex auth auto-imported into agent auth store ✅");
|
|
1274
|
+
} else if (result.skipped) {
|
|
1275
|
+
api.logger.info("[cli-bridge] Codex auth already current in agent auth store");
|
|
1276
|
+
} else if (result.error) {
|
|
1277
|
+
api.logger.warn(`[cli-bridge] Codex auth import failed: ${result.error}`);
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1252
1280
|
}
|
|
1253
1281
|
|
|
1254
1282
|
// ── Phase 2: CLI request proxy ─────────────────────────────────────────────
|
|
@@ -2242,7 +2270,7 @@ const plugin = {
|
|
|
2242
2270
|
}
|
|
2243
2271
|
const expiryLine = expiry ? `\n\n🕐 Cookie expiry: ${formatChatGPTExpiry(expiry)}` : "";
|
|
2244
2272
|
|
|
2245
|
-
return { text: `✅ ChatGPT session ready!\n\nModels available:\n• \`vllm/web-chatgpt/gpt-4o\`\n• \`vllm/web-chatgpt/gpt-4o-mini\`\n• \`vllm/web-chatgpt/gpt-
|
|
2273
|
+
return { text: `✅ ChatGPT session ready!\n\nModels available:\n• \`vllm/web-chatgpt/gpt-4o\`\n• \`vllm/web-chatgpt/gpt-4o-mini\`\n• \`vllm/web-chatgpt/gpt-4.1\`\n• \`vllm/web-chatgpt/gpt-4.1-mini\`\n• \`vllm/web-chatgpt/o3\`\n• \`vllm/web-chatgpt/o4-mini\`\n• \`vllm/web-chatgpt/gpt-5\`\n• \`vllm/web-chatgpt/gpt-5-mini\`${expiryLine}` };
|
|
2246
2274
|
},
|
|
2247
2275
|
} satisfies OpenClawPluginCommandDefinition);
|
|
2248
2276
|
|
|
@@ -2260,7 +2288,7 @@ const plugin = {
|
|
|
2260
2288
|
if (editor) {
|
|
2261
2289
|
const expiry = loadChatGPTExpiry();
|
|
2262
2290
|
const expiryLine = expiry ? `\n🕐 ${formatChatGPTExpiry(expiry)}` : "";
|
|
2263
|
-
return { text: `✅ chatgpt.com session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-chatgpt/gpt-4o, gpt-4o-mini, gpt-
|
|
2291
|
+
return { text: `✅ chatgpt.com session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-chatgpt/gpt-4o, gpt-4o-mini, gpt-4.1, gpt-4.1-mini, o3, o4-mini, gpt-5, gpt-5-mini${expiryLine}` };
|
|
2264
2292
|
}
|
|
2265
2293
|
} catch { /* fall through */ }
|
|
2266
2294
|
chatgptContext = null;
|
|
@@ -2339,7 +2367,7 @@ const plugin = {
|
|
|
2339
2367
|
return page.locator("#prompt-textarea").isVisible().catch(() => false);
|
|
2340
2368
|
} catch { chatgptContext = null; return false; }
|
|
2341
2369
|
},
|
|
2342
|
-
models: "web-chatgpt/gpt-4o, gpt-4o-mini, gpt-4.1, o3, o4-mini, gpt-5, gpt-5-mini",
|
|
2370
|
+
models: "web-chatgpt/gpt-4o, gpt-4o-mini, gpt-4.1, gpt-4.1-mini, o3, o4-mini, gpt-5, gpt-5-mini",
|
|
2343
2371
|
loginCmd: "/chatgpt-login",
|
|
2344
2372
|
expiry: () => { const e = loadChatGPTExpiry(); return e ? formatChatGPTExpiry(e) : null; },
|
|
2345
2373
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"openclaw": {
|
|
@@ -25,5 +25,6 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"playwright": "^1.58.2"
|
|
28
|
-
}
|
|
29
|
-
|
|
28
|
+
},
|
|
29
|
+
"license": "Apache-2.0"
|
|
30
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-auth-import.ts
|
|
3
|
+
*
|
|
4
|
+
* Auto-imports Codex CLI OAuth credentials from ~/.codex/auth.json into
|
|
5
|
+
* OpenClaw's agent auth store (~/.openclaw/agents/main/agent/auth-profiles.json).
|
|
6
|
+
*
|
|
7
|
+
* This solves Issue #2: the provider is registered but actual API calls fail
|
|
8
|
+
* because the auth store doesn't have the credentials. The user shouldn't need
|
|
9
|
+
* to run `openclaw models auth login` manually when Codex CLI is already logged in.
|
|
10
|
+
*
|
|
11
|
+
* Strategy:
|
|
12
|
+
* 1. Read credentials from ~/.codex/auth.json (via codex-auth.ts)
|
|
13
|
+
* 2. Read the existing auth-profiles.json
|
|
14
|
+
* 3. Upsert the "openai-codex:default" profile with fresh tokens
|
|
15
|
+
* 4. Write back atomically
|
|
16
|
+
*
|
|
17
|
+
* This runs on plugin startup and on OAuth refresh.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join, dirname } from "node:path";
|
|
23
|
+
import { readCodexCredentials, DEFAULT_CODEX_AUTH_PATH } from "./codex-auth.js";
|
|
24
|
+
|
|
25
|
+
/** Default path to the OpenClaw agent auth store. */
|
|
26
|
+
const DEFAULT_AUTH_STORE_PATH = join(
|
|
27
|
+
homedir(),
|
|
28
|
+
".openclaw",
|
|
29
|
+
"agents",
|
|
30
|
+
"main",
|
|
31
|
+
"agent",
|
|
32
|
+
"auth-profiles.json"
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/** Auth profile entry format (matches OpenClaw's auth-profiles.json schema). */
|
|
36
|
+
interface AuthProfile {
|
|
37
|
+
type: "oauth" | "token";
|
|
38
|
+
provider: string;
|
|
39
|
+
access?: string;
|
|
40
|
+
refresh?: string;
|
|
41
|
+
expires?: number;
|
|
42
|
+
email?: string;
|
|
43
|
+
accountId?: string;
|
|
44
|
+
token?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface AuthStore {
|
|
48
|
+
version: number;
|
|
49
|
+
profiles: Record<string, AuthProfile>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Import Codex CLI credentials into the OpenClaw agent auth store.
|
|
54
|
+
*
|
|
55
|
+
* Returns an object describing the result:
|
|
56
|
+
* - imported: true if credentials were written
|
|
57
|
+
* - skipped: true if credentials are already up-to-date
|
|
58
|
+
* - error: error message if import failed
|
|
59
|
+
*/
|
|
60
|
+
export async function importCodexAuth(opts?: {
|
|
61
|
+
codexAuthPath?: string;
|
|
62
|
+
authStorePath?: string;
|
|
63
|
+
log?: (msg: string) => void;
|
|
64
|
+
}): Promise<{ imported: boolean; skipped: boolean; error?: string }> {
|
|
65
|
+
const codexAuthPath = opts?.codexAuthPath ?? DEFAULT_CODEX_AUTH_PATH;
|
|
66
|
+
const authStorePath = opts?.authStorePath ?? DEFAULT_AUTH_STORE_PATH;
|
|
67
|
+
const log = opts?.log ?? (() => {});
|
|
68
|
+
|
|
69
|
+
// Step 1: Read Codex CLI credentials
|
|
70
|
+
let creds;
|
|
71
|
+
try {
|
|
72
|
+
creds = await readCodexCredentials(codexAuthPath);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const msg = `Codex auth not available: ${(err as Error).message}`;
|
|
75
|
+
log(msg);
|
|
76
|
+
return { imported: false, skipped: false, error: msg };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Step 2: Read existing auth store (or create skeleton)
|
|
80
|
+
let store: AuthStore;
|
|
81
|
+
try {
|
|
82
|
+
if (existsSync(authStorePath)) {
|
|
83
|
+
store = JSON.parse(readFileSync(authStorePath, "utf8")) as AuthStore;
|
|
84
|
+
} else {
|
|
85
|
+
store = { version: 1, profiles: {} };
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const msg = `Cannot read auth store at ${authStorePath}: ${(err as Error).message}`;
|
|
89
|
+
log(msg);
|
|
90
|
+
return { imported: false, skipped: false, error: msg };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Step 3: Check if update is needed
|
|
94
|
+
const profileKey = "openai-codex:default";
|
|
95
|
+
const existing = store.profiles[profileKey];
|
|
96
|
+
|
|
97
|
+
if (
|
|
98
|
+
existing &&
|
|
99
|
+
existing.access === creds.accessToken &&
|
|
100
|
+
existing.refresh === (creds.refreshToken ?? existing.refresh)
|
|
101
|
+
) {
|
|
102
|
+
log(`Codex auth already up-to-date in ${profileKey}`);
|
|
103
|
+
return { imported: false, skipped: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Step 4: Upsert the profile
|
|
107
|
+
store.profiles[profileKey] = {
|
|
108
|
+
type: "oauth",
|
|
109
|
+
provider: "openai-codex",
|
|
110
|
+
access: creds.accessToken,
|
|
111
|
+
...(creds.refreshToken ? { refresh: creds.refreshToken } : {}),
|
|
112
|
+
...(creds.expiresAt ? { expires: creds.expiresAt } : {}),
|
|
113
|
+
...(creds.email ? { email: creds.email } : {}),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Step 5: Write back atomically
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(dirname(authStorePath), { recursive: true });
|
|
119
|
+
writeFileSync(authStorePath, JSON.stringify(store, null, 4) + "\n", "utf8");
|
|
120
|
+
log(`Codex auth imported into ${profileKey}`);
|
|
121
|
+
return { imported: true, skipped: false };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const msg = `Failed to write auth store: ${(err as Error).message}`;
|
|
124
|
+
log(msg);
|
|
125
|
+
return { imported: false, skipped: false, error: msg };
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/proxy-server.ts
CHANGED
|
@@ -149,6 +149,10 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
|
|
|
149
149
|
opts.log(
|
|
150
150
|
`[cli-bridge] proxy server listening on http://127.0.0.1:${opts.port}`
|
|
151
151
|
);
|
|
152
|
+
// unref() so the proxy server does not keep the Node.js event loop alive
|
|
153
|
+
// when openclaw doctor or other short-lived CLI commands load plugins.
|
|
154
|
+
// The gateway's own main loop keeps the process alive during normal operation.
|
|
155
|
+
server.unref();
|
|
152
156
|
// Start proactive OAuth token refresh scheduler for Claude Code CLI.
|
|
153
157
|
setAuthLogger(opts.log);
|
|
154
158
|
void scheduleTokenRefresh();
|
package/src/session-manager.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { existsSync } from "node:fs";
|
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { execSync } from "node:child_process";
|
|
17
17
|
import { formatPrompt, type ChatMessage } from "./cli-runner.js";
|
|
18
|
+
import { createIsolatedWorkdir, cleanupWorkdir, sweepOrphanedWorkdirs } from "./workdir.js";
|
|
18
19
|
|
|
19
20
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
20
21
|
// Types
|
|
@@ -30,6 +31,8 @@ export interface SessionEntry {
|
|
|
30
31
|
exitCode: number | null;
|
|
31
32
|
model: string;
|
|
32
33
|
status: SessionStatus;
|
|
34
|
+
/** Isolated workdir created for this session (null if caller provided explicit workdir). */
|
|
35
|
+
isolatedWorkdir: string | null;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export interface SessionInfo {
|
|
@@ -38,11 +41,20 @@ export interface SessionInfo {
|
|
|
38
41
|
status: SessionStatus;
|
|
39
42
|
startTime: number;
|
|
40
43
|
exitCode: number | null;
|
|
44
|
+
/** Isolated workdir path (null if not using workdir isolation). */
|
|
45
|
+
isolatedWorkdir: string | null;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export interface SpawnOptions {
|
|
44
49
|
workdir?: string;
|
|
45
50
|
timeout?: number;
|
|
51
|
+
/**
|
|
52
|
+
* If true, create an isolated temp directory for this session.
|
|
53
|
+
* The directory is automatically cleaned up when the session exits or is killed.
|
|
54
|
+
* Ignored if `workdir` is explicitly set.
|
|
55
|
+
* Default: false (uses per-runner defaults: tmpdir for gemini, homedir for others).
|
|
56
|
+
*/
|
|
57
|
+
isolateWorkdir?: boolean;
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -102,7 +114,15 @@ export class SessionManager {
|
|
|
102
114
|
const sessionId = randomBytes(8).toString("hex");
|
|
103
115
|
const prompt = formatPrompt(messages);
|
|
104
116
|
|
|
105
|
-
|
|
117
|
+
// Workdir isolation: create a temp dir if requested and no explicit workdir given
|
|
118
|
+
let isolatedDir: string | null = null;
|
|
119
|
+
const effectiveOpts = { ...opts };
|
|
120
|
+
if (opts.isolateWorkdir && !opts.workdir) {
|
|
121
|
+
isolatedDir = createIsolatedWorkdir();
|
|
122
|
+
effectiveOpts.workdir = isolatedDir;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const { cmd, args, cwd, useStdin } = this.resolveCliCommand(model, prompt, effectiveOpts);
|
|
106
126
|
|
|
107
127
|
const proc = spawn(cmd, args, {
|
|
108
128
|
env: buildMinimalEnv(),
|
|
@@ -118,6 +138,7 @@ export class SessionManager {
|
|
|
118
138
|
exitCode: null,
|
|
119
139
|
model,
|
|
120
140
|
status: "running",
|
|
141
|
+
isolatedWorkdir: isolatedDir,
|
|
121
142
|
};
|
|
122
143
|
|
|
123
144
|
if (useStdin) {
|
|
@@ -132,11 +153,19 @@ export class SessionManager {
|
|
|
132
153
|
proc.on("close", (code) => {
|
|
133
154
|
entry.exitCode = code ?? 0;
|
|
134
155
|
if (entry.status === "running") entry.status = "exited";
|
|
156
|
+
// Auto-cleanup isolated workdir on process exit
|
|
157
|
+
if (entry.isolatedWorkdir) {
|
|
158
|
+
cleanupWorkdir(entry.isolatedWorkdir);
|
|
159
|
+
}
|
|
135
160
|
});
|
|
136
161
|
|
|
137
162
|
proc.on("error", () => {
|
|
138
163
|
if (entry.status === "running") entry.status = "exited";
|
|
139
164
|
entry.exitCode = entry.exitCode ?? 1;
|
|
165
|
+
// Auto-cleanup isolated workdir on error too
|
|
166
|
+
if (entry.isolatedWorkdir) {
|
|
167
|
+
cleanupWorkdir(entry.isolatedWorkdir);
|
|
168
|
+
}
|
|
140
169
|
});
|
|
141
170
|
|
|
142
171
|
this.sessions.set(sessionId, entry);
|
|
@@ -196,12 +225,13 @@ export class SessionManager {
|
|
|
196
225
|
status: entry.status,
|
|
197
226
|
startTime: entry.startTime,
|
|
198
227
|
exitCode: entry.exitCode,
|
|
228
|
+
isolatedWorkdir: entry.isolatedWorkdir,
|
|
199
229
|
});
|
|
200
230
|
}
|
|
201
231
|
return result;
|
|
202
232
|
}
|
|
203
233
|
|
|
204
|
-
/** Remove sessions older than SESSION_TTL_MS. Kill running ones first. */
|
|
234
|
+
/** Remove sessions older than SESSION_TTL_MS. Kill running ones first. Clean up isolated workdirs. */
|
|
205
235
|
cleanup(): void {
|
|
206
236
|
const now = Date.now();
|
|
207
237
|
for (const [sessionId, entry] of this.sessions) {
|
|
@@ -210,9 +240,15 @@ export class SessionManager {
|
|
|
210
240
|
entry.proc.kill("SIGTERM");
|
|
211
241
|
entry.status = "killed";
|
|
212
242
|
}
|
|
243
|
+
// Clean up isolated workdir if it wasn't cleaned on exit
|
|
244
|
+
if (entry.isolatedWorkdir) {
|
|
245
|
+
cleanupWorkdir(entry.isolatedWorkdir);
|
|
246
|
+
}
|
|
213
247
|
this.sessions.delete(sessionId);
|
|
214
248
|
}
|
|
215
249
|
}
|
|
250
|
+
// Sweep orphaned workdirs from crashed sessions
|
|
251
|
+
sweepOrphanedWorkdirs();
|
|
216
252
|
}
|
|
217
253
|
|
|
218
254
|
/** Stop the cleanup timer (for graceful shutdown). */
|
|
@@ -221,12 +257,15 @@ export class SessionManager {
|
|
|
221
257
|
clearInterval(this.cleanupTimer);
|
|
222
258
|
this.cleanupTimer = null;
|
|
223
259
|
}
|
|
224
|
-
// Kill all running sessions
|
|
260
|
+
// Kill all running sessions and clean up their workdirs
|
|
225
261
|
for (const [, entry] of this.sessions) {
|
|
226
262
|
if (entry.status === "running") {
|
|
227
263
|
entry.proc.kill("SIGTERM");
|
|
228
264
|
entry.status = "killed";
|
|
229
265
|
}
|
|
266
|
+
if (entry.isolatedWorkdir) {
|
|
267
|
+
cleanupWorkdir(entry.isolatedWorkdir);
|
|
268
|
+
}
|
|
230
269
|
}
|
|
231
270
|
}
|
|
232
271
|
|
package/src/workdir.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workdir.ts
|
|
3
|
+
*
|
|
4
|
+
* Workdir isolation for CLI agent spawns (Issue #6).
|
|
5
|
+
*
|
|
6
|
+
* Creates a unique temporary directory per agent session and cleans it up
|
|
7
|
+
* after the session completes. This prevents agents from interfering with
|
|
8
|
+
* each other or polluting the user's home directory.
|
|
9
|
+
*
|
|
10
|
+
* Each isolated workdir is created under a base directory:
|
|
11
|
+
* <base>/cli-bridge-<randomHex>/
|
|
12
|
+
*
|
|
13
|
+
* Default base: os.tmpdir() (e.g. /tmp/)
|
|
14
|
+
* Override via OPENCLAW_CLI_BRIDGE_WORKDIR_BASE env var.
|
|
15
|
+
*
|
|
16
|
+
* Cleanup is best-effort: directories are removed when the session ends,
|
|
17
|
+
* and a periodic sweep removes any orphaned dirs older than 1 hour.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdtempSync, rmSync, readdirSync, statSync, existsSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
/** Prefix for all isolated workdir directories. */
|
|
25
|
+
const WORKDIR_PREFIX = "cli-bridge-";
|
|
26
|
+
|
|
27
|
+
/** Max age for orphaned workdirs before cleanup sweep removes them (ms). */
|
|
28
|
+
const ORPHAN_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
|
29
|
+
|
|
30
|
+
/** Get the base directory for isolated workdirs. */
|
|
31
|
+
export function getWorkdirBase(): string {
|
|
32
|
+
return process.env.OPENCLAW_CLI_BRIDGE_WORKDIR_BASE ?? tmpdir();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create an isolated temporary directory for an agent session.
|
|
37
|
+
* Returns the absolute path to the new directory.
|
|
38
|
+
*
|
|
39
|
+
* The directory is created with a random suffix to ensure uniqueness:
|
|
40
|
+
* /tmp/cli-bridge-a1b2c3d4/
|
|
41
|
+
*/
|
|
42
|
+
export function createIsolatedWorkdir(base?: string): string {
|
|
43
|
+
const dir = mkdtempSync(join(base ?? getWorkdirBase(), WORKDIR_PREFIX));
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Clean up an isolated workdir by removing it and all contents.
|
|
49
|
+
* Returns true if removed successfully, false if it didn't exist or failed.
|
|
50
|
+
*
|
|
51
|
+
* Safety: only removes directories that match the cli-bridge- prefix.
|
|
52
|
+
*/
|
|
53
|
+
export function cleanupWorkdir(dirPath: string): boolean {
|
|
54
|
+
if (!dirPath || !dirPath.includes(WORKDIR_PREFIX)) {
|
|
55
|
+
return false; // safety: refuse to remove dirs that don't match our prefix
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
rmSync(dirPath, { recursive: true, force: true });
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Sweep orphaned workdirs older than ORPHAN_MAX_AGE_MS.
|
|
67
|
+
* Scans the base directory for cli-bridge-* dirs and removes stale ones.
|
|
68
|
+
* Returns the number of dirs removed.
|
|
69
|
+
*/
|
|
70
|
+
export function sweepOrphanedWorkdirs(base?: string): number {
|
|
71
|
+
const baseDir = base ?? getWorkdirBase();
|
|
72
|
+
let removed = 0;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const entries = readdirSync(baseDir);
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (!entry.startsWith(WORKDIR_PREFIX)) continue;
|
|
80
|
+
|
|
81
|
+
const fullPath = join(baseDir, entry);
|
|
82
|
+
try {
|
|
83
|
+
const stat = statSync(fullPath);
|
|
84
|
+
if (stat.isDirectory() && (now - stat.mtimeMs) > ORPHAN_MAX_AGE_MS) {
|
|
85
|
+
rmSync(fullPath, { recursive: true, force: true });
|
|
86
|
+
removed++;
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Skip entries we can't stat (race condition, permissions)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Base dir doesn't exist or not readable
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Ensure a directory exists, creating it if needed.
|
|
101
|
+
* Returns the path.
|
|
102
|
+
*/
|
|
103
|
+
export function ensureDir(dirPath: string): string {
|
|
104
|
+
if (!existsSync(dirPath)) {
|
|
105
|
+
mkdirSync(dirPath, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
return dirPath;
|
|
108
|
+
}
|