@elvatis_com/openclaw-cli-bridge-elvatis 0.2.22 → 0.2.23

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `0.2.22`
5
+ **Current version:** `0.2.23`
6
6
 
7
7
  ---
8
8
 
@@ -287,6 +287,9 @@ npm test # vitest run (45 tests)
287
287
 
288
288
  ## Changelog
289
289
 
290
+ ### v0.2.23
291
+ - **feat:** Proactive OAuth token management (`src/claude-auth.ts`) — the proxy now reads `~/.claude/.credentials.json` at startup, schedules a refresh 30 minutes before expiry, and calls `ensureClaudeToken()` before every `claude` subprocess invocation. On 401 responses, automatically retries once after refreshing. Eliminates the need for manual re-login after token expiry in headless/systemd deployments.
292
+
290
293
  ### v0.2.22
291
294
  - **fix:** `runClaude()` now detects expired/invalid OAuth tokens immediately (401 in stderr) and throws a clear actionable error instead of waiting for the 30s proxy timeout. Error message includes the exact re-login command.
292
295
 
package/SKILL.md CHANGED
@@ -53,4 +53,4 @@ Each command runs `openclaw models set <model>` atomically and replies with a co
53
53
 
54
54
  See `README.md` for full configuration reference and architecture diagram.
55
55
 
56
- **Version:** 0.2.22
56
+ **Version:** 0.2.23
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "0.2.22",
4
+ "version": "0.2.23",
5
5
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
6
6
  "providers": [
7
7
  "openai-codex"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
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": {
@@ -0,0 +1,223 @@
1
+ /**
2
+ * claude-auth.ts
3
+ *
4
+ * Proactive OAuth token management for the Claude Code CLI.
5
+ *
6
+ * Problem: Claude Code stores its claude.ai OAuth token in
7
+ * ~/.claude/.credentials.json. The access token expires every ~8-12 hours.
8
+ * When the gateway runs as a systemd service without a browser, the normal
9
+ * interactive refresh flow never triggers — the token silently expires and
10
+ * every CLI call returns 401.
11
+ *
12
+ * Solution: This module reads the credentials file, tracks expiry, and
13
+ * proactively refreshes the token by running `claude -p "ping"` before it
14
+ * expires. It also retries once on 401 errors.
15
+ *
16
+ * Design:
17
+ * - scheduleTokenRefresh() — call once at proxy startup; sets an internal
18
+ * timer that fires 30 min before expiry
19
+ * - ensureClaudeToken() — call before every claude CLI invocation;
20
+ * triggers an immediate refresh if token is
21
+ * expired or expires within the next 5 minutes
22
+ * - refreshClaudeToken() — runs `claude -p "ping"` to force token refresh
23
+ * (Claude Code auto-refreshes on any API call)
24
+ */
25
+
26
+ import { readFile } from "node:fs/promises";
27
+ import { homedir } from "node:os";
28
+ import { join } from "node:path";
29
+ import { spawn } from "node:child_process";
30
+
31
+ // ──────────────────────────────────────────────────────────────────────────────
32
+ // Config
33
+ // ──────────────────────────────────────────────────────────────────────────────
34
+
35
+ /** Refresh this many ms before the token actually expires. */
36
+ const REFRESH_BEFORE_EXPIRY_MS = 30 * 60 * 1000; // 30 min
37
+
38
+ /** If token expires within this window, refresh synchronously before the call. */
39
+ const REFRESH_SYNC_WINDOW_MS = 5 * 60 * 1000; // 5 min
40
+
41
+ /** Max time to wait for a refresh ping. */
42
+ const REFRESH_TIMEOUT_MS = 30_000;
43
+
44
+ const CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
45
+
46
+ // ──────────────────────────────────────────────────────────────────────────────
47
+ // State
48
+ // ──────────────────────────────────────────────────────────────────────────────
49
+
50
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
51
+ let refreshInProgress: Promise<void> | null = null;
52
+ let log: (msg: string) => void = () => {};
53
+
54
+ // ──────────────────────────────────────────────────────────────────────────────
55
+ // Public API
56
+ // ──────────────────────────────────────────────────────────────────────────────
57
+
58
+ /** Configure the logger (call once at startup). */
59
+ export function setAuthLogger(logger: (msg: string) => void): void {
60
+ log = logger;
61
+ }
62
+
63
+ /**
64
+ * Read the current token expiry from ~/.claude/.credentials.json.
65
+ * Returns null if the file doesn't exist or has no OAuth credentials
66
+ * (e.g. API-key users — they don't need token management).
67
+ */
68
+ export async function readTokenExpiry(): Promise<number | null> {
69
+ try {
70
+ const raw = await readFile(CREDENTIALS_PATH, "utf8");
71
+ const creds = JSON.parse(raw) as {
72
+ claudeAiOauth?: { expiresAt?: number };
73
+ };
74
+ const expiresAt = creds?.claudeAiOauth?.expiresAt;
75
+ return typeof expiresAt === "number" ? expiresAt : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Schedule a proactive token refresh 30 minutes before expiry.
83
+ * Call once at proxy startup. Safe to call multiple times (clears old timer).
84
+ */
85
+ export async function scheduleTokenRefresh(): Promise<void> {
86
+ if (refreshTimer) {
87
+ clearTimeout(refreshTimer);
88
+ refreshTimer = null;
89
+ }
90
+
91
+ const expiresAt = await readTokenExpiry();
92
+ if (expiresAt === null) {
93
+ log("[cli-bridge:auth] No OAuth credentials found — skipping token scheduling (API key auth?)");
94
+ return;
95
+ }
96
+
97
+ const now = Date.now();
98
+ const msUntilExpiry = expiresAt - now;
99
+ const msUntilRefresh = msUntilExpiry - REFRESH_BEFORE_EXPIRY_MS;
100
+
101
+ if (msUntilExpiry <= 0) {
102
+ log("[cli-bridge:auth] Token already expired — refreshing now");
103
+ await refreshClaudeToken();
104
+ return;
105
+ }
106
+
107
+ if (msUntilRefresh <= 0) {
108
+ // Expires within the next 30 min — refresh immediately
109
+ log(`[cli-bridge:auth] Token expires in ${Math.round(msUntilExpiry / 60000)}min — refreshing now`);
110
+ await refreshClaudeToken();
111
+ return;
112
+ }
113
+
114
+ const refreshInMin = Math.round(msUntilRefresh / 60000);
115
+ log(`[cli-bridge:auth] Token valid for ${Math.round(msUntilExpiry / 60000)}min — refresh scheduled in ${refreshInMin}min`);
116
+
117
+ refreshTimer = setTimeout(async () => {
118
+ log("[cli-bridge:auth] Scheduled token refresh triggered");
119
+ await refreshClaudeToken();
120
+ // Re-schedule for the next cycle after refresh
121
+ await scheduleTokenRefresh();
122
+ }, msUntilRefresh);
123
+
124
+ // Don't block process exit
125
+ if (refreshTimer.unref) refreshTimer.unref();
126
+ }
127
+
128
+ /**
129
+ * Ensure the Claude OAuth token is valid before making a CLI call.
130
+ * If the token expires within REFRESH_SYNC_WINDOW_MS, refreshes synchronously.
131
+ * No-op for API-key users (no credentials file).
132
+ */
133
+ export async function ensureClaudeToken(): Promise<void> {
134
+ const expiresAt = await readTokenExpiry();
135
+ if (expiresAt === null) return; // API key user, nothing to do
136
+
137
+ const msUntilExpiry = expiresAt - Date.now();
138
+
139
+ if (msUntilExpiry > REFRESH_SYNC_WINDOW_MS) return; // still valid, nothing to do
140
+
141
+ if (msUntilExpiry <= 0) {
142
+ log("[cli-bridge:auth] Token expired — refreshing before call");
143
+ } else {
144
+ log(`[cli-bridge:auth] Token expires in ${Math.round(msUntilExpiry / 1000)}s — refreshing before call`);
145
+ }
146
+
147
+ await refreshClaudeToken();
148
+ }
149
+
150
+ /**
151
+ * Run `claude -p "ping"` to force Claude Code to refresh its OAuth token.
152
+ * Claude Code automatically refreshes the access token on any API call.
153
+ * Deduplicates concurrent refresh attempts.
154
+ */
155
+ export async function refreshClaudeToken(): Promise<void> {
156
+ // Deduplicate concurrent refresh calls
157
+ if (refreshInProgress) {
158
+ await refreshInProgress;
159
+ return;
160
+ }
161
+
162
+ refreshInProgress = doRefresh();
163
+ try {
164
+ await refreshInProgress;
165
+ } finally {
166
+ refreshInProgress = null;
167
+ }
168
+ }
169
+
170
+ // ──────────────────────────────────────────────────────────────────────────────
171
+ // Internal
172
+ // ──────────────────────────────────────────────────────────────────────────────
173
+
174
+ async function doRefresh(): Promise<void> {
175
+ log("[cli-bridge:auth] Refreshing Claude OAuth token...");
176
+
177
+ const result = await runRefreshPing();
178
+
179
+ if (result.exitCode !== 0) {
180
+ const stderr = result.stderr || "(no output)";
181
+ if (stderr.includes("401") || stderr.includes("authentication_error") || stderr.includes("Invalid authentication credentials")) {
182
+ throw new Error(
183
+ "Claude CLI OAuth token refresh failed (401). " +
184
+ "Re-login required: run `claude auth logout && claude auth login` in a terminal."
185
+ );
186
+ }
187
+ // Non-auth errors (network blip etc.) — log but don't throw, let the actual call fail naturally
188
+ log(`[cli-bridge:auth] Token refresh exited ${result.exitCode}: ${stderr.slice(0, 200)}`);
189
+ return;
190
+ }
191
+
192
+ // Re-read expiry and reschedule timer
193
+ const newExpiry = await readTokenExpiry();
194
+ if (newExpiry) {
195
+ const validForMin = Math.round((newExpiry - Date.now()) / 60000);
196
+ log(`[cli-bridge:auth] Token refreshed — valid for ${validForMin}min`);
197
+ // Reschedule the proactive timer for the new expiry
198
+ void scheduleTokenRefresh();
199
+ }
200
+ }
201
+
202
+ function runRefreshPing(): Promise<{ stdout: string; stderr: string; exitCode: number }> {
203
+ return new Promise((resolve) => {
204
+ const env: Record<string, string> = { NO_COLOR: "1", TERM: "dumb" };
205
+ for (const key of ["HOME", "PATH", "USER", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]) {
206
+ const v = process.env[key];
207
+ if (v) env[key] = v;
208
+ }
209
+
210
+ const proc = spawn(
211
+ "claude",
212
+ ["-p", "ping", "--output-format", "text", "--permission-mode", "plan", "--tools", ""],
213
+ { timeout: REFRESH_TIMEOUT_MS, env }
214
+ );
215
+
216
+ let stdout = "";
217
+ let stderr = "";
218
+ proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
219
+ proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
220
+ proc.on("close", (code) => resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 0 }));
221
+ proc.on("error", (err) => resolve({ stdout: "", stderr: err.message, exitCode: 1 }));
222
+ });
223
+ }
package/src/cli-runner.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { spawn } from "node:child_process";
16
16
  import { tmpdir, homedir } from "node:os";
17
+ import { ensureClaudeToken, refreshClaudeToken } from "./claude-auth.js";
17
18
 
18
19
  /** Max messages to include in the prompt sent to the CLI. */
19
20
  const MAX_MESSAGES = 20;
@@ -253,6 +254,10 @@ export async function runClaude(
253
254
  modelId: string,
254
255
  timeoutMs: number
255
256
  ): Promise<string> {
257
+ // Proactively refresh OAuth token if it's about to expire (< 5 min remaining).
258
+ // No-op for API-key users.
259
+ await ensureClaudeToken();
260
+
256
261
  const model = stripPrefix(modelId);
257
262
  const args = [
258
263
  "-p",
@@ -261,17 +266,27 @@ export async function runClaude(
261
266
  "--tools", "",
262
267
  "--model", model,
263
268
  ];
269
+
264
270
  const result = await runCli("claude", args, prompt, timeoutMs);
265
271
 
272
+ // On 401: attempt one token refresh + retry before giving up.
266
273
  if (result.exitCode !== 0 && result.stdout.length === 0) {
267
274
  const stderr = result.stderr || "(no output)";
268
- // Detect expired/invalid OAuth token early and give a clear actionable message
269
- // instead of letting the caller time out after 30s.
270
275
  if (stderr.includes("401") || stderr.includes("Invalid authentication credentials") || stderr.includes("authentication_error")) {
271
- throw new Error(
272
- "Claude CLI OAuth token expired or invalid. " +
273
- "Re-login required: run `claude auth logout && claude auth login` in a terminal, then retry."
274
- );
276
+ // Refresh and retry once
277
+ await refreshClaudeToken();
278
+ const retry = await runCli("claude", args, prompt, timeoutMs);
279
+ if (retry.exitCode !== 0 && retry.stdout.length === 0) {
280
+ const retryStderr = retry.stderr || "(no output)";
281
+ if (retryStderr.includes("401") || retryStderr.includes("authentication_error") || retryStderr.includes("Invalid authentication credentials")) {
282
+ throw new Error(
283
+ "Claude CLI OAuth token refresh failed. " +
284
+ "Re-login required: run `claude auth logout && claude auth login` in a terminal."
285
+ );
286
+ }
287
+ throw new Error(`claude exited ${retry.exitCode} (after token refresh): ${retryStderr}`);
288
+ }
289
+ return retry.stdout;
275
290
  }
276
291
  throw new Error(`claude exited ${result.exitCode}: ${stderr}`);
277
292
  }
@@ -11,6 +11,7 @@
11
11
  import http from "node:http";
12
12
  import { randomBytes } from "node:crypto";
13
13
  import { type ChatMessage, routeToCliRunner } from "./cli-runner.js";
14
+ import { scheduleTokenRefresh, setAuthLogger } from "./claude-auth.js";
14
15
 
15
16
  export interface ProxyServerOptions {
16
17
  port: number;
@@ -81,6 +82,9 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
81
82
  opts.log(
82
83
  `[cli-bridge] proxy server listening on http://127.0.0.1:${opts.port}`
83
84
  );
85
+ // Start proactive OAuth token refresh scheduler for Claude Code CLI.
86
+ setAuthLogger(opts.log);
87
+ void scheduleTokenRefresh();
84
88
  resolve(server);
85
89
  });
86
90
  });