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

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,247 @@
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 setInterval> | null = null;
51
+ let nextRefreshAt = 0; // epoch ms when the next refresh is due
52
+ let refreshInProgress: Promise<void> | null = null;
53
+ let log: (msg: string) => void = () => {};
54
+
55
+ // ──────────────────────────────────────────────────────────────────────────────
56
+ // Public API
57
+ // ──────────────────────────────────────────────────────────────────────────────
58
+
59
+ /** Configure the logger (call once at startup). */
60
+ export function setAuthLogger(logger: (msg: string) => void): void {
61
+ log = logger;
62
+ }
63
+
64
+ /**
65
+ * Stop the background token refresh interval.
66
+ * Call in plugin deactivate / proxy server close to avoid timer leaks.
67
+ */
68
+ export function stopTokenRefresh(): void {
69
+ if (refreshTimer) {
70
+ clearInterval(refreshTimer);
71
+ refreshTimer = null;
72
+ nextRefreshAt = 0;
73
+ log("[cli-bridge:auth] Token refresh scheduler stopped");
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Read the current token expiry from ~/.claude/.credentials.json.
79
+ * Returns null if the file doesn't exist or has no OAuth credentials
80
+ * (e.g. API-key users — they don't need token management).
81
+ */
82
+ export async function readTokenExpiry(): Promise<number | null> {
83
+ try {
84
+ const raw = await readFile(CREDENTIALS_PATH, "utf8");
85
+ const creds = JSON.parse(raw) as {
86
+ claudeAiOauth?: { expiresAt?: number };
87
+ };
88
+ const expiresAt = creds?.claudeAiOauth?.expiresAt;
89
+ return typeof expiresAt === "number" ? expiresAt : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Schedule a proactive token refresh 30 minutes before expiry.
97
+ * Call once at proxy startup. Safe to call multiple times (restarts the interval).
98
+ *
99
+ * Uses a 10-minute polling interval instead of a single long setTimeout so that
100
+ * the scheduler survives system sleep/resume without missing its window.
101
+ */
102
+ export async function scheduleTokenRefresh(): Promise<void> {
103
+ // Clear any existing interval before (re-)starting
104
+ stopTokenRefresh();
105
+
106
+ const expiresAt = await readTokenExpiry();
107
+ if (expiresAt === null) {
108
+ log("[cli-bridge:auth] No OAuth credentials found — skipping token scheduling (API key auth?)");
109
+ return;
110
+ }
111
+
112
+ const now = Date.now();
113
+ const msUntilExpiry = expiresAt - now;
114
+
115
+ if (msUntilExpiry <= 0) {
116
+ log("[cli-bridge:auth] Token already expired — refreshing now");
117
+ await refreshClaudeToken();
118
+ return;
119
+ }
120
+
121
+ if (msUntilExpiry <= REFRESH_BEFORE_EXPIRY_MS) {
122
+ // Expires within the next 30 min — refresh immediately
123
+ log(`[cli-bridge:auth] Token expires in ${Math.round(msUntilExpiry / 60000)}min — refreshing now`);
124
+ await refreshClaudeToken();
125
+ return;
126
+ }
127
+
128
+ // Set the target time for the first scheduled refresh (30 min before expiry)
129
+ nextRefreshAt = expiresAt - REFRESH_BEFORE_EXPIRY_MS;
130
+ const refreshInMin = Math.round((nextRefreshAt - now) / 60000);
131
+ log(`[cli-bridge:auth] Token valid for ${Math.round(msUntilExpiry / 60000)}min — refresh scheduled in ${refreshInMin}min`);
132
+
133
+ // Poll every 10 minutes instead of a single long setTimeout.
134
+ // This survives laptop sleep/resume without missing the refresh window.
135
+ const POLL_INTERVAL_MS = 10 * 60 * 1000;
136
+ refreshTimer = setInterval(async () => {
137
+ if (Date.now() < nextRefreshAt) return; // not yet due
138
+ log("[cli-bridge:auth] Scheduled token refresh triggered");
139
+ await refreshClaudeToken();
140
+ // Recompute next refresh target from the freshly written credentials
141
+ const newExpiry = await readTokenExpiry();
142
+ if (newExpiry) {
143
+ nextRefreshAt = newExpiry - REFRESH_BEFORE_EXPIRY_MS;
144
+ const nextInMin = Math.round((nextRefreshAt - Date.now()) / 60000);
145
+ log(`[cli-bridge:auth] Next refresh in ${nextInMin}min`);
146
+ }
147
+ }, POLL_INTERVAL_MS);
148
+
149
+ // Don't block process exit
150
+ if (refreshTimer.unref) refreshTimer.unref();
151
+ }
152
+
153
+ /**
154
+ * Ensure the Claude OAuth token is valid before making a CLI call.
155
+ * If the token expires within REFRESH_SYNC_WINDOW_MS, refreshes synchronously.
156
+ * No-op for API-key users (no credentials file).
157
+ */
158
+ export async function ensureClaudeToken(): Promise<void> {
159
+ const expiresAt = await readTokenExpiry();
160
+ if (expiresAt === null) return; // API key user, nothing to do
161
+
162
+ const msUntilExpiry = expiresAt - Date.now();
163
+
164
+ if (msUntilExpiry > REFRESH_SYNC_WINDOW_MS) return; // still valid, nothing to do
165
+
166
+ if (msUntilExpiry <= 0) {
167
+ log("[cli-bridge:auth] Token expired — refreshing before call");
168
+ } else {
169
+ log(`[cli-bridge:auth] Token expires in ${Math.round(msUntilExpiry / 1000)}s — refreshing before call`);
170
+ }
171
+
172
+ await refreshClaudeToken();
173
+ }
174
+
175
+ /**
176
+ * Run `claude -p "ping"` to force Claude Code to refresh its OAuth token.
177
+ * Claude Code automatically refreshes the access token on any API call.
178
+ * Deduplicates concurrent refresh attempts.
179
+ */
180
+ export async function refreshClaudeToken(): Promise<void> {
181
+ // Deduplicate concurrent refresh calls
182
+ if (refreshInProgress) {
183
+ await refreshInProgress;
184
+ return;
185
+ }
186
+
187
+ refreshInProgress = doRefresh();
188
+ try {
189
+ await refreshInProgress;
190
+ } finally {
191
+ refreshInProgress = null;
192
+ }
193
+ }
194
+
195
+ // ──────────────────────────────────────────────────────────────────────────────
196
+ // Internal
197
+ // ──────────────────────────────────────────────────────────────────────────────
198
+
199
+ async function doRefresh(): Promise<void> {
200
+ log("[cli-bridge:auth] Refreshing Claude OAuth token...");
201
+
202
+ const result = await runRefreshPing();
203
+
204
+ if (result.exitCode !== 0) {
205
+ const stderr = result.stderr || "(no output)";
206
+ if (stderr.includes("401") || stderr.includes("authentication_error") || stderr.includes("Invalid authentication credentials")) {
207
+ throw new Error(
208
+ "Claude CLI OAuth token refresh failed (401). " +
209
+ "Re-login required: run `claude auth logout && claude auth login` in a terminal."
210
+ );
211
+ }
212
+ // Non-auth errors (network blip etc.) — log but don't throw, let the actual call fail naturally
213
+ log(`[cli-bridge:auth] Token refresh exited ${result.exitCode}: ${stderr.slice(0, 200)}`);
214
+ return;
215
+ }
216
+
217
+ // Re-read expiry and update the next refresh target for the running interval
218
+ const newExpiry = await readTokenExpiry();
219
+ if (newExpiry) {
220
+ const validForMin = Math.round((newExpiry - Date.now()) / 60000);
221
+ log(`[cli-bridge:auth] Token refreshed — valid for ${validForMin}min`);
222
+ nextRefreshAt = newExpiry - REFRESH_BEFORE_EXPIRY_MS;
223
+ }
224
+ }
225
+
226
+ function runRefreshPing(): Promise<{ stdout: string; stderr: string; exitCode: number }> {
227
+ return new Promise((resolve) => {
228
+ const env: Record<string, string> = { NO_COLOR: "1", TERM: "dumb" };
229
+ for (const key of ["HOME", "PATH", "USER", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]) {
230
+ const v = process.env[key];
231
+ if (v) env[key] = v;
232
+ }
233
+
234
+ const proc = spawn(
235
+ "claude",
236
+ ["-p", "ping", "--output-format", "text", "--permission-mode", "plan", "--tools", ""],
237
+ { timeout: REFRESH_TIMEOUT_MS, env }
238
+ );
239
+
240
+ let stdout = "";
241
+ let stderr = "";
242
+ proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
243
+ proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
244
+ proc.on("close", (code) => resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 0 }));
245
+ proc.on("error", (err) => resolve({ stdout: "", stderr: err.message, exitCode: 1 }));
246
+ });
247
+ }
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, stopTokenRefresh } from "./claude-auth.js";
14
15
 
15
16
  export interface ProxyServerOptions {
16
17
  port: number;
@@ -76,11 +77,19 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
76
77
  });
77
78
  });
78
79
 
80
+ // Stop the token refresh interval when the server closes (timer-leak prevention)
81
+ server.on("close", () => {
82
+ stopTokenRefresh();
83
+ });
84
+
79
85
  server.on("error", (err) => reject(err));
80
86
  server.listen(opts.port, "127.0.0.1", () => {
81
87
  opts.log(
82
88
  `[cli-bridge] proxy server listening on http://127.0.0.1:${opts.port}`
83
89
  );
90
+ // Start proactive OAuth token refresh scheduler for Claude Code CLI.
91
+ setAuthLogger(opts.log);
92
+ void scheduleTokenRefresh();
84
93
  resolve(server);
85
94
  });
86
95
  });