@elvatis_com/openclaw-cli-bridge-elvatis 0.2.23 → 0.2.26
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 +19 -1
- package/SKILL.md +1 -1
- package/index.ts +274 -10
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -2
- package/src/claude-auth.ts +40 -16
- package/src/grok-client.ts +428 -0
- package/src/grok-session.ts +195 -0
- package/src/proxy-server.ts +74 -4
- package/test/grok-proxy.test.ts +301 -0
- package/test/grok-session.test.ts +133 -0
package/index.ts
CHANGED
|
@@ -48,6 +48,16 @@ import {
|
|
|
48
48
|
} from "./src/codex-auth.js";
|
|
49
49
|
import { startProxyServer } from "./src/proxy-server.js";
|
|
50
50
|
import { patchOpencllawConfig } from "./src/config-patcher.js";
|
|
51
|
+
import {
|
|
52
|
+
loadSession,
|
|
53
|
+
deleteSession,
|
|
54
|
+
isSessionExpiredByAge,
|
|
55
|
+
verifySession,
|
|
56
|
+
runInteractiveLogin,
|
|
57
|
+
createContextFromSession,
|
|
58
|
+
DEFAULT_SESSION_PATH,
|
|
59
|
+
} from "./src/grok-session.js";
|
|
60
|
+
import type { BrowserContext, Browser } from "playwright";
|
|
51
61
|
|
|
52
62
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
53
63
|
// Types derived from SDK (not re-exported by the package)
|
|
@@ -66,6 +76,46 @@ interface CliPluginConfig {
|
|
|
66
76
|
proxyPort?: number;
|
|
67
77
|
proxyApiKey?: string;
|
|
68
78
|
proxyTimeoutMs?: number;
|
|
79
|
+
grokSessionPath?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
// Grok web-session state (module-level, persists across commands)
|
|
84
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
let grokBrowser: Browser | null = null;
|
|
87
|
+
let grokContext: BrowserContext | null = null;
|
|
88
|
+
|
|
89
|
+
async function launchGrokBrowser(): Promise<Browser> {
|
|
90
|
+
const { chromium } = await import("playwright");
|
|
91
|
+
return chromium.launch({ headless: false });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function tryRestoreGrokSession(
|
|
95
|
+
sessionPath: string,
|
|
96
|
+
log: (msg: string) => void
|
|
97
|
+
): Promise<boolean> {
|
|
98
|
+
const saved = loadSession(sessionPath);
|
|
99
|
+
if (!saved || isSessionExpiredByAge(saved)) {
|
|
100
|
+
log("[cli-bridge:grok] no valid saved session");
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
if (!grokBrowser) grokBrowser = await launchGrokBrowser();
|
|
105
|
+
const ctx = await createContextFromSession(grokBrowser, saved);
|
|
106
|
+
const check = await verifySession(ctx, log);
|
|
107
|
+
if (!check.valid) {
|
|
108
|
+
log(`[cli-bridge:grok] saved session invalid: ${check.reason}`);
|
|
109
|
+
await ctx.close().catch(() => {});
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
grokContext = ctx;
|
|
113
|
+
log("[cli-bridge:grok] session restored ✅");
|
|
114
|
+
return true;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
log(`[cli-bridge:grok] session restore error: ${(err as Error).message}`);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
69
119
|
}
|
|
70
120
|
|
|
71
121
|
const DEFAULT_PROXY_PORT = 31337;
|
|
@@ -201,14 +251,53 @@ const CLI_MODEL_COMMANDS = [
|
|
|
201
251
|
const CLI_TEST_DEFAULT_MODEL = "cli-claude/claude-sonnet-4-6";
|
|
202
252
|
|
|
203
253
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
204
|
-
//
|
|
254
|
+
// Staged-switch state file
|
|
255
|
+
// Stores a pending model switch that has not yet been applied.
|
|
256
|
+
// Written by /cli-* (default), applied by /cli-apply or /cli-* --now.
|
|
257
|
+
// Located at ~/.openclaw/cli-bridge-pending.json
|
|
205
258
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
206
|
-
|
|
259
|
+
const PENDING_FILE = join(homedir(), ".openclaw", "cli-bridge-pending.json");
|
|
260
|
+
|
|
261
|
+
interface CliBridgePending {
|
|
262
|
+
model: string;
|
|
263
|
+
label: string;
|
|
264
|
+
requestedAt: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readPending(): CliBridgePending | null {
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(readFileSync(PENDING_FILE, "utf8")) as CliBridgePending;
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function writePending(pending: CliBridgePending): void {
|
|
276
|
+
try {
|
|
277
|
+
mkdirSync(join(homedir(), ".openclaw"), { recursive: true });
|
|
278
|
+
writeFileSync(PENDING_FILE, JSON.stringify(pending, null, 2) + "\n", "utf8");
|
|
279
|
+
} catch {
|
|
280
|
+
// non-fatal
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function clearPending(): void {
|
|
285
|
+
try {
|
|
286
|
+
const { unlinkSync } = require("node:fs");
|
|
287
|
+
unlinkSync(PENDING_FILE);
|
|
288
|
+
} catch {
|
|
289
|
+
// non-fatal — file may not exist
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
294
|
+
// Helper: immediately apply the model switch (no safety checks)
|
|
295
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
296
|
+
async function applyModelSwitch(
|
|
207
297
|
api: OpenClawPluginApi,
|
|
208
298
|
model: string,
|
|
209
299
|
label: string,
|
|
210
300
|
): Promise<PluginCommandResult> {
|
|
211
|
-
// Save current model BEFORE switching so /cli-back can restore it
|
|
212
301
|
const current = readCurrentModel();
|
|
213
302
|
if (current && current !== model) {
|
|
214
303
|
writeState({ previousModel: current });
|
|
@@ -227,9 +316,13 @@ async function switchModel(
|
|
|
227
316
|
return { text: `❌ Failed to switch to ${label}: ${err}` };
|
|
228
317
|
}
|
|
229
318
|
|
|
319
|
+
clearPending();
|
|
230
320
|
api.logger.info(`[cli-bridge] switched model → ${model}`);
|
|
231
321
|
return {
|
|
232
|
-
text:
|
|
322
|
+
text:
|
|
323
|
+
`✅ Switched to **${label}**\n` +
|
|
324
|
+
`\`${model}\`\n\n` +
|
|
325
|
+
`Use \`/cli-back\` to restore previous model.`,
|
|
233
326
|
};
|
|
234
327
|
} catch (err) {
|
|
235
328
|
const msg = (err as Error).message;
|
|
@@ -238,6 +331,53 @@ async function switchModel(
|
|
|
238
331
|
}
|
|
239
332
|
}
|
|
240
333
|
|
|
334
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
335
|
+
// Helper: staged switch (default behavior)
|
|
336
|
+
//
|
|
337
|
+
// ⚠️ SAFETY: /cli-* mid-session bricht den aktiven Agenten.
|
|
338
|
+
//
|
|
339
|
+
// `openclaw models set` ist ein **sofortiger, globaler Switch**.
|
|
340
|
+
// Der laufende Agent verliert seinen Kontext — Tool-Calls werden nicht
|
|
341
|
+
// ausgeführt, Planfiles werden nicht geschrieben, keine Rückmeldung.
|
|
342
|
+
//
|
|
343
|
+
// Default: Switch wird nur gespeichert (nicht angewendet).
|
|
344
|
+
// Mit --now: sofortiger Switch (nur zwischen Sessions verwenden!).
|
|
345
|
+
// Mit /cli-apply: gespeicherten Switch anwenden (nach Session-Ende).
|
|
346
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
347
|
+
async function switchModel(
|
|
348
|
+
api: OpenClawPluginApi,
|
|
349
|
+
model: string,
|
|
350
|
+
label: string,
|
|
351
|
+
forceNow: boolean,
|
|
352
|
+
): Promise<PluginCommandResult> {
|
|
353
|
+
// --now: sofortiger Switch, volle Verantwortung beim User
|
|
354
|
+
if (forceNow) {
|
|
355
|
+
api.logger.warn(`[cli-bridge] --now switch to ${model} (immediate, session may break)`);
|
|
356
|
+
return applyModelSwitch(api, model, label);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Default: staged switch — speichern, warnen, nicht anwenden
|
|
360
|
+
const current = readCurrentModel();
|
|
361
|
+
|
|
362
|
+
if (current === model) {
|
|
363
|
+
return { text: `ℹ️ Already on **${label}**\n\`${model}\`` };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
writePending({ model, label, requestedAt: new Date().toISOString() });
|
|
367
|
+
api.logger.info(`[cli-bridge] staged switch → ${model} (pending, not applied yet)`);
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
text:
|
|
371
|
+
`📋 **Model switch staged: ${label}**\n` +
|
|
372
|
+
`\`${model}\`\n\n` +
|
|
373
|
+
`⚠️ **NOT applied yet** — switching mid-session breaks the active agent:\n` +
|
|
374
|
+
`tool calls fail silently, plan files don't get written, no feedback.\n\n` +
|
|
375
|
+
`**To apply:**\n` +
|
|
376
|
+
`• \`/cli-apply\` — apply after finishing your current task\n` +
|
|
377
|
+
`• \`/cli-* --now\` — force immediate switch (only between sessions!)`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
241
381
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
242
382
|
// Helper: fire a one-shot test request directly at the proxy (no global switch)
|
|
243
383
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -303,7 +443,7 @@ function proxyTestRequest(
|
|
|
303
443
|
const plugin = {
|
|
304
444
|
id: "openclaw-cli-bridge-elvatis",
|
|
305
445
|
name: "OpenClaw CLI Bridge",
|
|
306
|
-
version: "0.2.
|
|
446
|
+
version: "0.2.26",
|
|
307
447
|
description:
|
|
308
448
|
"Phase 1: openai-codex auth bridge. " +
|
|
309
449
|
"Phase 2: HTTP proxy for gemini/claude CLIs. " +
|
|
@@ -317,6 +457,10 @@ const plugin = {
|
|
|
317
457
|
const apiKey = cfg.proxyApiKey ?? DEFAULT_PROXY_API_KEY;
|
|
318
458
|
const timeoutMs = cfg.proxyTimeoutMs ?? 120_000;
|
|
319
459
|
const codexAuthPath = cfg.codexAuthPath ?? DEFAULT_CODEX_AUTH_PATH;
|
|
460
|
+
const grokSessionPath = cfg.grokSessionPath ?? DEFAULT_SESSION_PATH;
|
|
461
|
+
|
|
462
|
+
// ── Grok session restore (non-blocking) ───────────────────────────────────
|
|
463
|
+
void tryRestoreGrokSession(grokSessionPath, (msg) => api.logger.info(msg));
|
|
320
464
|
|
|
321
465
|
// ── Phase 1: openai-codex auth bridge ─────────────────────────────────────
|
|
322
466
|
if (enableCodex) {
|
|
@@ -413,6 +557,7 @@ const plugin = {
|
|
|
413
557
|
timeoutMs,
|
|
414
558
|
log: (msg) => api.logger.info(msg),
|
|
415
559
|
warn: (msg) => api.logger.warn(msg),
|
|
560
|
+
getGrokContext: () => grokContext,
|
|
416
561
|
});
|
|
417
562
|
proxyServer = server;
|
|
418
563
|
api.logger.info(
|
|
@@ -436,6 +581,7 @@ const plugin = {
|
|
|
436
581
|
port, apiKey, timeoutMs,
|
|
437
582
|
log: (msg) => api.logger.info(msg),
|
|
438
583
|
warn: (msg) => api.logger.warn(msg),
|
|
584
|
+
getGrokContext: () => grokContext,
|
|
439
585
|
});
|
|
440
586
|
proxyServer = server;
|
|
441
587
|
api.logger.info(`[cli-bridge] proxy ready on :${port} (retry)`);
|
|
@@ -483,11 +629,13 @@ const plugin = {
|
|
|
483
629
|
const { name, model, description, label } = entry;
|
|
484
630
|
api.registerCommand({
|
|
485
631
|
name,
|
|
486
|
-
description
|
|
632
|
+
description: `${description}. Pass --now to apply immediately (only between sessions!).`,
|
|
633
|
+
acceptsArgs: true,
|
|
487
634
|
requireAuth: false,
|
|
488
635
|
handler: async (ctx: PluginCommandContext): Promise<PluginCommandResult> => {
|
|
489
|
-
|
|
490
|
-
|
|
636
|
+
const forceNow = (ctx.args ?? "").trim().toLowerCase() === "--now";
|
|
637
|
+
api.logger.info(`[cli-bridge] /${name} by ${ctx.senderId ?? "?"} forceNow=${forceNow}`);
|
|
638
|
+
return switchModel(api, model, label, forceNow);
|
|
491
639
|
},
|
|
492
640
|
} satisfies OpenClawPluginCommandDefinition);
|
|
493
641
|
}
|
|
@@ -495,11 +643,14 @@ const plugin = {
|
|
|
495
643
|
// ── Phase 3b: /cli-back — restore previous model ──────────────────────────
|
|
496
644
|
api.registerCommand({
|
|
497
645
|
name: "cli-back",
|
|
498
|
-
description: "Restore the model
|
|
646
|
+
description: "Restore the model active before the last /cli-* switch. Clears any pending staged switch.",
|
|
499
647
|
requireAuth: false,
|
|
500
648
|
handler: async (ctx: PluginCommandContext): Promise<PluginCommandResult> => {
|
|
501
649
|
api.logger.info(`[cli-bridge] /cli-back by ${ctx.senderId ?? "?"}`);
|
|
502
650
|
|
|
651
|
+
// Clear any pending staged switch
|
|
652
|
+
clearPending();
|
|
653
|
+
|
|
503
654
|
const state = readState();
|
|
504
655
|
if (!state?.previousModel) {
|
|
505
656
|
return { text: "ℹ️ No previous model saved. Use `/cli-sonnet` etc. to switch first." };
|
|
@@ -529,6 +680,57 @@ const plugin = {
|
|
|
529
680
|
},
|
|
530
681
|
} satisfies OpenClawPluginCommandDefinition);
|
|
531
682
|
|
|
683
|
+
// ── Phase 3b2: /cli-apply — apply staged model switch ─────────────────────
|
|
684
|
+
api.registerCommand({
|
|
685
|
+
name: "cli-apply",
|
|
686
|
+
description: "Apply a staged /cli-* model switch. Use this AFTER finishing your current task.",
|
|
687
|
+
requireAuth: false,
|
|
688
|
+
handler: async (ctx: PluginCommandContext): Promise<PluginCommandResult> => {
|
|
689
|
+
api.logger.info(`[cli-bridge] /cli-apply by ${ctx.senderId ?? "?"}`);
|
|
690
|
+
|
|
691
|
+
const pending = readPending();
|
|
692
|
+
if (!pending) {
|
|
693
|
+
const current = readCurrentModel();
|
|
694
|
+
return {
|
|
695
|
+
text:
|
|
696
|
+
`ℹ️ No staged switch pending.\n` +
|
|
697
|
+
`Current model: \`${current ?? "unknown"}\`\n\n` +
|
|
698
|
+
`Use \`/cli-sonnet\`, \`/cli-opus\` etc. to stage a switch.`,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
api.logger.info(`[cli-bridge] applying staged switch → ${pending.model}`);
|
|
703
|
+
return applyModelSwitch(api, pending.model, pending.label);
|
|
704
|
+
},
|
|
705
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
706
|
+
|
|
707
|
+
// ── Phase 3b3: /cli-pending — show staged switch ───────────────────────────
|
|
708
|
+
api.registerCommand({
|
|
709
|
+
name: "cli-pending",
|
|
710
|
+
description: "Show the currently staged model switch (if any).",
|
|
711
|
+
requireAuth: false,
|
|
712
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
713
|
+
const pending = readPending();
|
|
714
|
+
const current = readCurrentModel();
|
|
715
|
+
if (!pending) {
|
|
716
|
+
return {
|
|
717
|
+
text:
|
|
718
|
+
`✅ No pending switch.\n` +
|
|
719
|
+
`Current model: \`${current ?? "unknown"}\``,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
text:
|
|
724
|
+
`📋 **Staged switch pending:**\n` +
|
|
725
|
+
`→ \`${pending.model}\` (${pending.label})\n` +
|
|
726
|
+
`Requested: ${pending.requestedAt}\n\n` +
|
|
727
|
+
`Current: \`${current ?? "unknown"}\`\n\n` +
|
|
728
|
+
`Run \`/cli-apply\` to apply after finishing your current task.\n` +
|
|
729
|
+
`Run \`/cli-sonnet --now\` etc. to discard and switch immediately.`,
|
|
730
|
+
};
|
|
731
|
+
},
|
|
732
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
733
|
+
|
|
532
734
|
// ── Phase 3c: /cli-test — one-shot proxy ping, no global model switch ──────
|
|
533
735
|
api.registerCommand({
|
|
534
736
|
name: "cli-test",
|
|
@@ -605,22 +807,84 @@ const plugin = {
|
|
|
605
807
|
}
|
|
606
808
|
lines.push("");
|
|
607
809
|
}
|
|
810
|
+
const pending = readPending();
|
|
811
|
+
const pendingNote = pending ? ` ← pending: ${pending.label}` : "";
|
|
812
|
+
|
|
608
813
|
lines.push("*Utility*");
|
|
609
|
-
lines.push(
|
|
814
|
+
lines.push(` /cli-apply Apply staged switch${pendingNote}`);
|
|
815
|
+
lines.push(" /cli-pending Show staged switch (if any)");
|
|
816
|
+
lines.push(" /cli-back Restore previous model + clear staged");
|
|
610
817
|
lines.push(" /cli-test [model] Health check (no model switch)");
|
|
611
818
|
lines.push(" /cli-list This overview");
|
|
612
819
|
lines.push("");
|
|
820
|
+
lines.push("*Switching safely:*");
|
|
821
|
+
lines.push(" /cli-sonnet → stages switch (safe, apply later)");
|
|
822
|
+
lines.push(" /cli-sonnet --now → immediate switch (only between sessions!)");
|
|
823
|
+
lines.push("");
|
|
613
824
|
lines.push(`Proxy: \`127.0.0.1:${port}\``);
|
|
614
825
|
|
|
615
826
|
return { text: lines.join("\n") };
|
|
616
827
|
},
|
|
617
828
|
} satisfies OpenClawPluginCommandDefinition);
|
|
618
829
|
|
|
830
|
+
// ── Phase 4: Grok web-session commands ────────────────────────────────────
|
|
831
|
+
|
|
832
|
+
api.registerCommand({
|
|
833
|
+
name: "grok-login",
|
|
834
|
+
description: "Open browser to log in to grok.com (X/Twitter account)",
|
|
835
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
836
|
+
if (grokContext) {
|
|
837
|
+
return { text: "✅ Already logged in to grok.com. Use /grok-logout first to re-authenticate." };
|
|
838
|
+
}
|
|
839
|
+
api.logger.info("[cli-bridge:grok] starting interactive login...");
|
|
840
|
+
try {
|
|
841
|
+
if (!grokBrowser) grokBrowser = await launchGrokBrowser();
|
|
842
|
+
const session = await runInteractiveLogin(grokBrowser, grokSessionPath, (msg) => api.logger.info(msg));
|
|
843
|
+
grokContext = await createContextFromSession(grokBrowser, session);
|
|
844
|
+
return { text: "✅ Logged in to grok.com!\n\nGrok models available:\n• `vllm/web-grok/grok-3`\n• `vllm/web-grok/grok-3-fast`\n• `vllm/web-grok/grok-3-mini`\n\nUse `/cli-grok` to switch." };
|
|
845
|
+
} catch (err) {
|
|
846
|
+
return { text: `❌ Login failed: ${(err as Error).message}` };
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
850
|
+
|
|
851
|
+
api.registerCommand({
|
|
852
|
+
name: "grok-status",
|
|
853
|
+
description: "Check grok.com session status",
|
|
854
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
855
|
+
if (!grokContext) {
|
|
856
|
+
return { text: "❌ No active grok.com session\nRun `/grok-login` to authenticate." };
|
|
857
|
+
}
|
|
858
|
+
const check = await verifySession(grokContext, (msg) => api.logger.info(msg));
|
|
859
|
+
if (check.valid) {
|
|
860
|
+
return { text: `✅ grok.com session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-grok/grok-3, web-grok/grok-3-fast, web-grok/grok-3-mini, web-grok/grok-3-mini-fast` };
|
|
861
|
+
}
|
|
862
|
+
grokContext = null;
|
|
863
|
+
return { text: `❌ Session expired: ${check.reason}\nRun \`/grok-login\` to re-authenticate.` };
|
|
864
|
+
},
|
|
865
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
866
|
+
|
|
867
|
+
api.registerCommand({
|
|
868
|
+
name: "grok-logout",
|
|
869
|
+
description: "Clear saved grok.com session",
|
|
870
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
871
|
+
if (grokContext) {
|
|
872
|
+
await grokContext.close().catch(() => {});
|
|
873
|
+
grokContext = null;
|
|
874
|
+
}
|
|
875
|
+
deleteSession(grokSessionPath);
|
|
876
|
+
return { text: "✅ Logged out from grok.com. Session file deleted." };
|
|
877
|
+
},
|
|
878
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
879
|
+
|
|
619
880
|
const allCommands = [
|
|
620
881
|
...CLI_MODEL_COMMANDS.map((c) => `/${c.name}`),
|
|
621
882
|
"/cli-back",
|
|
622
883
|
"/cli-test",
|
|
623
884
|
"/cli-list",
|
|
885
|
+
"/grok-login",
|
|
886
|
+
"/grok-status",
|
|
887
|
+
"/grok-logout",
|
|
624
888
|
];
|
|
625
889
|
api.logger.info(`[cli-bridge] registered ${allCommands.length} commands: ${allCommands.join(", ")}`);
|
|
626
890
|
},
|
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.26",
|
|
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.26",
|
|
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": {
|
|
@@ -18,5 +18,8 @@
|
|
|
18
18
|
"@types/node": "^25.3.2",
|
|
19
19
|
"typescript": "^5.9.3",
|
|
20
20
|
"vitest": "^4.0.18"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"playwright": "^1.58.2"
|
|
21
24
|
}
|
|
22
|
-
}
|
|
25
|
+
}
|
package/src/claude-auth.ts
CHANGED
|
@@ -47,7 +47,8 @@ const CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
|
|
|
47
47
|
// State
|
|
48
48
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
49
49
|
|
|
50
|
-
let refreshTimer: ReturnType<typeof
|
|
50
|
+
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
51
|
+
let nextRefreshAt = 0; // epoch ms when the next refresh is due
|
|
51
52
|
let refreshInProgress: Promise<void> | null = null;
|
|
52
53
|
let log: (msg: string) => void = () => {};
|
|
53
54
|
|
|
@@ -60,6 +61,19 @@ export function setAuthLogger(logger: (msg: string) => void): void {
|
|
|
60
61
|
log = logger;
|
|
61
62
|
}
|
|
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
|
+
|
|
63
77
|
/**
|
|
64
78
|
* Read the current token expiry from ~/.claude/.credentials.json.
|
|
65
79
|
* Returns null if the file doesn't exist or has no OAuth credentials
|
|
@@ -80,13 +94,14 @@ export async function readTokenExpiry(): Promise<number | null> {
|
|
|
80
94
|
|
|
81
95
|
/**
|
|
82
96
|
* Schedule a proactive token refresh 30 minutes before expiry.
|
|
83
|
-
* Call once at proxy startup. Safe to call multiple times (
|
|
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.
|
|
84
101
|
*/
|
|
85
102
|
export async function scheduleTokenRefresh(): Promise<void> {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
refreshTimer = null;
|
|
89
|
-
}
|
|
103
|
+
// Clear any existing interval before (re-)starting
|
|
104
|
+
stopTokenRefresh();
|
|
90
105
|
|
|
91
106
|
const expiresAt = await readTokenExpiry();
|
|
92
107
|
if (expiresAt === null) {
|
|
@@ -96,7 +111,6 @@ export async function scheduleTokenRefresh(): Promise<void> {
|
|
|
96
111
|
|
|
97
112
|
const now = Date.now();
|
|
98
113
|
const msUntilExpiry = expiresAt - now;
|
|
99
|
-
const msUntilRefresh = msUntilExpiry - REFRESH_BEFORE_EXPIRY_MS;
|
|
100
114
|
|
|
101
115
|
if (msUntilExpiry <= 0) {
|
|
102
116
|
log("[cli-bridge:auth] Token already expired — refreshing now");
|
|
@@ -104,22 +118,33 @@ export async function scheduleTokenRefresh(): Promise<void> {
|
|
|
104
118
|
return;
|
|
105
119
|
}
|
|
106
120
|
|
|
107
|
-
if (
|
|
121
|
+
if (msUntilExpiry <= REFRESH_BEFORE_EXPIRY_MS) {
|
|
108
122
|
// Expires within the next 30 min — refresh immediately
|
|
109
123
|
log(`[cli-bridge:auth] Token expires in ${Math.round(msUntilExpiry / 60000)}min — refreshing now`);
|
|
110
124
|
await refreshClaudeToken();
|
|
111
125
|
return;
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
|
|
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);
|
|
115
131
|
log(`[cli-bridge:auth] Token valid for ${Math.round(msUntilExpiry / 60000)}min — refresh scheduled in ${refreshInMin}min`);
|
|
116
132
|
|
|
117
|
-
|
|
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
|
|
118
138
|
log("[cli-bridge:auth] Scheduled token refresh triggered");
|
|
119
139
|
await refreshClaudeToken();
|
|
120
|
-
//
|
|
121
|
-
await
|
|
122
|
-
|
|
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);
|
|
123
148
|
|
|
124
149
|
// Don't block process exit
|
|
125
150
|
if (refreshTimer.unref) refreshTimer.unref();
|
|
@@ -189,13 +214,12 @@ async function doRefresh(): Promise<void> {
|
|
|
189
214
|
return;
|
|
190
215
|
}
|
|
191
216
|
|
|
192
|
-
// Re-read expiry and
|
|
217
|
+
// Re-read expiry and update the next refresh target for the running interval
|
|
193
218
|
const newExpiry = await readTokenExpiry();
|
|
194
219
|
if (newExpiry) {
|
|
195
220
|
const validForMin = Math.round((newExpiry - Date.now()) / 60000);
|
|
196
221
|
log(`[cli-bridge:auth] Token refreshed — valid for ${validForMin}min`);
|
|
197
|
-
|
|
198
|
-
void scheduleTokenRefresh();
|
|
222
|
+
nextRefreshAt = newExpiry - REFRESH_BEFORE_EXPIRY_MS;
|
|
199
223
|
}
|
|
200
224
|
}
|
|
201
225
|
|