@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.
- package/.ai/handoff/DASHBOARD.md +32 -19
- package/.ai/handoff/LOG.md +111 -38
- package/.ai/handoff/MANIFEST.json +49 -126
- package/.ai/handoff/NEXT_ACTIONS.md +21 -22
- package/.ai/handoff/STATUS.md +76 -48
- package/.ai/handoff/TRUST.md +40 -51
- package/README.md +15 -1
- package/SKILL.md +1 -1
- package/index.ts +165 -10
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/src/claude-auth.ts +247 -0
- package/src/cli-runner.ts +21 -6
- package/src/proxy-server.ts +9 -0
|
@@ -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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
}
|
package/src/proxy-server.ts
CHANGED
|
@@ -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
|
});
|