@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/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 ─────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
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
+ }
@@ -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();
@@ -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
- const { cmd, args, cwd, useStdin } = this.resolveCliCommand(model, prompt, opts);
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
+ }