@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 +4 -1
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/claude-auth.ts +223 -0
- package/src/cli-runner.ts +21 -6
- package/src/proxy-server.ts +4 -0
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.
|
|
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
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"name": "OpenClaw CLI Bridge",
|
|
4
|
-
"version": "0.2.
|
|
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.
|
|
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
|
-
|
|
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 } 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
|
});
|