clauderipple 0.9.1 → 0.9.2

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.
@@ -918,6 +918,42 @@ test('keep-warm forks near expiry, refreshes the memo and stops once on a null f
918
918
  expect(stats.text).toContain('Warm: 1 pings')
919
919
  })
920
920
 
921
+ test('a cache-reading ping keeps the card, a real reply and /clear drop it, and the demo draws it', async ($, on) => {
922
+ const b = boundaries(on, { keepWarm: true })
923
+ http(on)
924
+ judgeStub(on)
925
+ stepStub(on)
926
+ on('model.fork', () => ({ value: { isAnswered: true, text: 'ok', usage: USAGE } }))
927
+ await start($)
928
+ await step($)
929
+ await complete($)
930
+ await b.clock.advance(3375000)
931
+ // The ping read 40000 cache tokens: the card tells it, with the memo's minutes left.
932
+ const ui = await band($)
933
+ expect(await ui.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeDefined()
934
+ await ui.unmount()
935
+ // A prompt counts as having seen the card; the next is a real reply, which clears the count.
936
+ await submit($)
937
+ await step($)
938
+ await complete($)
939
+ const after = await band($)
940
+ expect(await after.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeUndefined()
941
+ await after.unmount()
942
+ await b.clock.advance(3375000)
943
+ await b.clock.settle()
944
+ const again = await band($)
945
+ expect(await again.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeDefined()
946
+ await again.unmount()
947
+ await $.classic.SessionStart({ source: 'clear' })
948
+ const cleared = await band($)
949
+ expect(await cleared.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeUndefined()
950
+ await cleared.unmount()
951
+ // The demo shows the card with sample values, whatever the state.
952
+ await command($, 'kept')
953
+ const demo = await band($)
954
+ expect(await demo.find({ type: 'Text', text: /held 312k tokens in cache/ })).toBeDefined()
955
+ })
956
+
921
957
  test('fork turn.step is not effort-steered, busy or main-request cost', async ($, on) => {
922
958
  const b = boundaries(on, { keepWarm: true })
923
959
  http(on)
@@ -941,6 +977,23 @@ test('fork turn.step is not effort-steered, busy or main-request cost', async ($
941
977
  expect(stats.text).toContain('Warm: 1 pings')
942
978
  })
943
979
 
980
+ test('the flame turns keep-warm off for this chat only; Settings stay as they are', async ($, on) => {
981
+ const b = boundaries(on, { keepWarm: true })
982
+ http(on)
983
+ judgeStub(on, '{"effort":"high","why":"scope"}')
984
+ stepStub(on)
985
+ let forks = 0
986
+ on('model.fork', async () => { forks++; return { value: { isAnswered: true, text: 'ok', usage: USAGE } } })
987
+ await start($)
988
+ await submit($)
989
+ await step($)
990
+ await complete($)
991
+ await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'warm' })
992
+ await b.clock.advance(3375000)
993
+ expect(forks).toBe(0)
994
+ expect(b.router().settings.keepWarm).toBe(true)
995
+ })
996
+
944
997
  test('Haiku compaction returns replacement messages and forwards the keep note', async ($, on) => {
945
998
  boundaries(on)
946
999
  http(on)
@@ -265,6 +265,7 @@ test('a ko frame resolves temporary Korean text in labels and hover cards', asyn
265
265
 
266
266
  const ALERTS: AlertView[] = [
267
267
  { kind: 'cold', tokens: 182_000, idleMinutes: 74 },
268
+ { kind: 'kept', pings: 2, tokens: 312_000, minutesLeft: 47 },
268
269
  { kind: 'swamp', percent: 63, tokens: 126_000 },
269
270
  { kind: 'hot', limitKind: 'five_hour', percent: 86, resetsAt: '2026-10-10T15:40:00', save: false },
270
271
  { kind: 'judgeDown', judge: 'jev', reason: 'key rejected (401)' },
@@ -297,14 +298,17 @@ test('alert choices reach their callbacks', async ($, on) => {
297
298
  setHandoffKind: fn('kind'), setHandoffAfter: fn('after'), go: fn('go'), copy: fn('copy'), openNew: fn('open-new'),
298
299
  }
299
300
  drawPane(on, 'cold', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[0]!, actions))
300
- drawPane(on, 'hot', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[2]!, actions))
301
+ drawPane(on, 'kept', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[1]!, actions))
302
+ drawPane(on, 'hot', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[3]!, actions))
301
303
  drawPane(on, 'note', (el, surface) => renderAlert(el, frameOf(surface), { kind: 'compactNote', draft: '' }, actions))
302
- drawPane(on, 'choice', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[10]!, actions))
304
+ drawPane(on, 'choice', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[11]!, actions))
303
305
  for (const surface of SURFACES) {
304
306
  const cold = await mount($, 'cold', surface)
305
307
  await cold.press({ key: 'compact' })
306
308
  await cold.press({ key: 'handoff' })
307
309
  await cold.press({ key: 'dismiss' })
310
+ const kept = await mount($, 'kept', surface)
311
+ await kept.press({ key: 'dismiss' })
308
312
  const hot = await mount($, 'hot', surface)
309
313
  await hot.press({ key: 'save' })
310
314
  const note = await mount($, 'note', surface)
@@ -314,7 +318,7 @@ test('alert choices reach their callbacks', async ($, on) => {
314
318
  await choice.press({ key: 'after-copy' })
315
319
  await choice.press({ key: 'go' })
316
320
  }
317
- const once = ['compact', 'handoff', 'dismiss', 'save', 'compact:["keep the API decision"]', 'kind:["full"]', 'after:["copy"]', 'go']
321
+ const once = ['compact', 'handoff', 'dismiss', 'dismiss', 'save', 'compact:["keep the API decision"]', 'kind:["full"]', 'after:["copy"]', 'go']
318
322
  expect(calls).toEqual([...once, ...once])
319
323
  })
320
324
 
@@ -354,9 +358,9 @@ test('each alert shows 리플이 with the face of its state, its gauge in 5% ste
354
358
  expect(mascots(await terminal.drawn()).length).toBe(0)
355
359
  await terminal.unmount()
356
360
  }
357
- // handoffDone and compacted share the pleased face, compactNote and handoffChoice the asking
358
- // one; every other state has its own.
359
- expect(new Set(sources).size).toBe(ALERTS.length - 2)
361
+ // handoffDone, compacted and kept share the pleased face, compactNote and handoffChoice the
362
+ // asking one; every other state has its own.
363
+ expect(new Set(sources).size).toBe(ALERTS.length - 3)
360
364
  const source = async (id: string) => {
361
365
  const ui = await mount($, id, 'desktop')
362
366
  const s = String(mascots(await ui.drawn())[0]?.props?.source)
@@ -301,12 +301,15 @@ declare module 'claude-code' {
301
301
  agents: AgentRec[]
302
302
  cache: CacheMemo | null
303
303
  warm: WarmState
304
+ /** Keep-warm for this chat alone, set by the band's flame; null follows Settings. */
305
+ warmHere: boolean | null
304
306
  context: { percent: number; tokens: number; window: number } | null
305
307
  hot: Hot | null
306
308
  hotHidden: number | null
307
309
  saveUntil: number | null
308
310
  swampHidden: number | null
309
311
  coldHidden: boolean
312
+ kept: { pings: number; tokens: number } | null
310
313
  judgeDown: string | null
311
314
  advice: string | null
312
315
  handoff: { stage: HandoffStage; kind: HandoffKind; text?: string } | null
@@ -345,7 +348,7 @@ declare module 'claude-code' {
345
348
  judgeRuns: JudgeRun[]
346
349
  judgeDownNetwork: boolean
347
350
  limit: Hot | null
348
- demo: 'cold' | 'swamp' | 'hot' | 'down' | null
351
+ demo: 'cold' | 'swamp' | 'hot' | 'down' | 'kept' | null
349
352
  }
350
353
  }
351
354
  }
@@ -23,7 +23,7 @@ import path from "node:path";
23
23
  import http from "node:http";
24
24
  import { execFile, spawn } from "node:child_process";
25
25
  import { fileURLToPath } from "node:url";
26
- import { adminPort, homeDir, searchProviderFor, validate } from "./config.js";
26
+ import { adminPort, headerValueProblem, homeDir, searchProviderFor, validate } from "./config.js";
27
27
  export { adminPort } from "./config.js";
28
28
  import { generatedAgents } from "./agents.js";
29
29
  import { isJudgeOnlyModel } from "./model-policy.js";
@@ -33,6 +33,7 @@ import { normalizeSettings } from "../../mod/hooks/settings.js";
33
33
  import { chatPickerModels } from "./picker.js";
34
34
  import { PRESETS } from "./presets.js";
35
35
  import { resolveCompatibleCaps } from "./compat.js";
36
+ import { redactErrorText } from "./redact.js";
36
37
  import { CHATGPT_FALLBACK_MODELS } from "./providers/chatgpt/catalog.js";
37
38
  import { measureModel, refusedByPlan } from "./capabilities.js";
38
39
  import { catalogEntry } from "./catalog.js";
@@ -53,6 +54,9 @@ import { exhaustedForMs, IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from
53
54
  import { ANTIGRAVITY_WARNING, staticAntigravityModels } from "./providers/google/antigravity.js";
54
55
  import { readGoogleAccounts, removeGoogleAccount, summarize as summarizeGoogleAccount, updateGoogleAccount } from "./providers/google/accounts.js";
55
56
  import { GoogleOAuthSession } from "./providers/google/login.js";
57
+ import { GROK_AUTH_HINT, grokAuthPath, grokHome, readGrokToken, sharedGrokAuth } from "./providers/grok/auth.js";
58
+ import { GROK_DEFAULT_URL, GROK_EFFORT_LEVELS, GROK_FALLBACK_MODELS, grokModelsFromListing } from "./providers/grok/catalog.js";
59
+ import { GROK_WARNING, grokHeaders } from "./providers/grok/index.js";
56
60
  const MAX_BODY = 1024 * 1024;
57
61
  /** `/api/image` carries reference images inline, so it gets more room than a config save. */
58
62
  const MAX_IMAGE_BODY = 32 * 1024 * 1024;
@@ -157,6 +161,12 @@ export function effortLevels(cfg) {
157
161
  providers[name] = { default: ANTHROPIC_EFFORT_LEVELS };
158
162
  continue;
159
163
  }
164
+ if (provider.type === "grok") {
165
+ // The proxy's listing gives each model its ladder (the probe saves it); a model it has not
166
+ // described gets the common one, which is also what the adapter sends it.
167
+ providers[name] = { default: [...GROK_EFFORT_LEVELS], ...(Object.keys(modelLevels).length ? { models: modelLevels } : {}) };
168
+ continue;
169
+ }
160
170
  const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
161
171
  providers[name] = {
162
172
  default: resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, provider.caps).effortLevels,
@@ -282,6 +292,14 @@ export function chatgptSignedIn(mode) {
282
292
  const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
283
293
  return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
284
294
  }
295
+ /**
296
+ * Whether the Grok CLI's session is there and not yet expired. A session near its end still counts:
297
+ * the router asks the CLI to refresh it before the next turn.
298
+ */
299
+ export function grokSignedIn(p) {
300
+ const token = readGrokToken(grokAuthPath(p.home ?? grokHome()));
301
+ return token !== null && (token.expiresAt === 0 || token.expiresAt > Date.now());
302
+ }
285
303
  /**
286
304
  * An Antigravity provider is only as usable as its accounts: none signed in, or every one waiting on
287
305
  * Google's verification page, answers nothing however reachable the host is.
@@ -409,7 +427,7 @@ async function buildStatus(deps, opts = {}) {
409
427
  // Keyed in the order the config lists them. Assigning inside the Promise.all callbacks ordered
410
428
  // them by whichever TCP check answered first, so the Health list reshuffled on every poll.
411
429
  const checked = await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
412
- const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.type === "google" ? (p.url ?? "https://generativelanguage.googleapis.com") : p.url;
430
+ const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.type === "google" ? (p.url ?? "https://generativelanguage.googleapis.com") : p.type === "grok" ? (p.url ?? GROK_DEFAULT_URL) : p.url;
413
431
  let reachable = false;
414
432
  try {
415
433
  const u = new URL(url);
@@ -444,6 +462,7 @@ async function buildStatus(deps, opts = {}) {
444
462
  // Reaching the host says nothing about being able to use it: a chatgpt provider with no
445
463
  // credentials is not "connected", and calling it that sends the user off believing it works.
446
464
  ...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
465
+ ...(p.type === "grok" ? { needsLogin: !grokSignedIn(p) } : {}),
447
466
  ...(p.type === "google" && p.auth === "antigravity" ? googleHealth(deps.google?.().accounts?.()[name] ?? []) : {}),
448
467
  ...(p.type === "anthropic"
449
468
  ? {
@@ -843,6 +862,53 @@ async function probeGoogleAntigravity(deps) {
843
862
  const models = named ? await deps.google?.().models?.(named).catch(() => null) ?? null : null;
844
863
  return { ok: true, auth: "ok", models: models ?? staticAntigravityModels() };
845
864
  }
865
+ /**
866
+ * `{type:"grok"}`: whether the Grok CLI's session is usable, and the models its chat proxy lists.
867
+ * The session goes through the same `GrokAuth` the adapter uses — one near its end is refreshed
868
+ * through the CLI here too — and the listing is asked with the headers live traffic sends, since a
869
+ * test that presents another auth set certifies a path nobody uses (§5).
870
+ */
871
+ async function probeGrok(body, log, probeFetch = fetchWithTimeout) {
872
+ const text = (value) => typeof value === "string" && value ? value : undefined;
873
+ const home = text(body.home) ?? grokHome();
874
+ const cli = text(body.cli);
875
+ const clientVersion = text(body.clientVersion);
876
+ const fallback = (result) => ({ ...result, models: GROK_FALLBACK_MODELS, modelsSource: "fallback", warning: GROK_WARNING });
877
+ const versionProblem = clientVersion ? headerValueProblem(clientVersion) : null;
878
+ if (versionProblem)
879
+ return fallback({ ok: false, auth: "unknown", error: `clientVersion cannot be sent as a header: ${versionProblem}` });
880
+ // The adapter's own instance for this session, so a probe joins a refresh a turn started (and the
881
+ // reverse) instead of spawning a second CLI.
882
+ const auth = sharedGrokAuth({ home, ...(cli ? { cli } : {}), log });
883
+ let token;
884
+ try {
885
+ token = await auth.token();
886
+ }
887
+ catch (error) {
888
+ return fallback({ ok: false, auth: "missing", error: errorText(error) });
889
+ }
890
+ const base = (text(body.url) ?? GROK_DEFAULT_URL).replace(/\/+$/, "");
891
+ const { "x-grok-model-override": _route, ...headers } = grokHeaders(token.key, "", await auth.clientVersion(clientVersion));
892
+ let response;
893
+ try {
894
+ response = await probeFetch(`${base}/models`, { headers });
895
+ }
896
+ catch (error) {
897
+ // fetch quotes a header value it refuses, which here is the session token.
898
+ return fallback({ ok: false, auth: "unreachable", error: redactErrorText(errorText(error), [token.key]) });
899
+ }
900
+ if (response.status === 401 || response.status === 403) {
901
+ return fallback({ ok: false, auth: "bad-key", error: `the proxy refused the Grok session (HTTP ${response.status}): ${GROK_AUTH_HINT}` });
902
+ }
903
+ if (!response.ok) {
904
+ const detail = redactErrorText(await response.text().catch(() => ""), [token.key]);
905
+ return fallback({ ok: false, auth: "unknown", error: `HTTP ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ""}` });
906
+ }
907
+ const models = grokModelsFromListing(await response.json().catch(() => null));
908
+ return models.length
909
+ ? { ok: true, auth: "ok", models, modelsSource: "catalog", warning: GROK_WARNING }
910
+ : fallback({ ok: true, auth: "ok" });
911
+ }
846
912
  function chatCompletionsUrl(base) {
847
913
  return `${base.replace(/\/+$/, "")}/chat/completions`;
848
914
  }
@@ -1532,6 +1598,10 @@ export function startAdmin(deps) {
1532
1598
  });
1533
1599
  return;
1534
1600
  }
1601
+ if (probe.type === "grok") {
1602
+ sendJson(res, 200, await probeGrok(probe, deps.log, deps.probeFetch));
1603
+ return;
1604
+ }
1535
1605
  if (probe.type === "google") {
1536
1606
  if (probe.auth === "antigravity") {
1537
1607
  sendJson(res, 200, await probeGoogleAntigravity(deps));
@@ -15,6 +15,7 @@ import path from "node:path";
15
15
  import { adminPort, homeDir } from "./config.js";
16
16
  import { declaredBy } from "./routing.js";
17
17
  import { isJudgeOnlyModel } from "./model-policy.js";
18
+ import { GROK_EFFORT_LEVELS } from "./providers/grok/catalog.js";
18
19
  import { shortName } from "../../cli/src/hooks/agent-title.js";
19
20
  /** Where Claude Code's Agent tool reads its worker definitions. */
20
21
  export function defaultAgentDir() {
@@ -145,6 +146,9 @@ export function agentNameFor(modelId) {
145
146
  return modelId.replace(/[^a-z0-9-]/g, "-");
146
147
  }
147
148
  function capsEffortLevels(provider) {
149
+ // A grok provider has no caps: its adapter sends the common ladder to a model the listing left out.
150
+ if (provider.type === "grok")
151
+ return [...GROK_EFFORT_LEVELS];
148
152
  return provider.caps?.effortLevels;
149
153
  }
150
154
  /**
@@ -153,6 +153,9 @@ export function headerValueProblem(value) {
153
153
  // A newline would split the header; a stray one usually means a copied line rather than a key.
154
154
  if (code === 0x0a || code === 0x0d)
155
155
  return `there is a line break at character ${i + 1} — paste the key on its own, without the line around it`;
156
+ // fetch refuses a NUL too, so a value that passes here would only fail when sent.
157
+ if (code === 0x00)
158
+ return `there is a NUL character at character ${i + 1} — paste the key on its own`;
156
159
  }
157
160
  return null;
158
161
  }
@@ -429,6 +432,27 @@ export function validate(c) {
429
432
  errors.push(`provider ${name}: ${MODELS_SHAPE}`);
430
433
  }
431
434
  }
435
+ else if (p.type === "grok") {
436
+ for (const field of ["home", "cli", "clientVersion"]) {
437
+ if (p[field] !== undefined && (typeof p[field] !== "string" || !p[field]))
438
+ errors.push(`provider ${name}: ${field} must be a non-empty string`);
439
+ }
440
+ // Sent as `x-grok-client-version`, so it has to survive being a header value.
441
+ const versionProblem = typeof p.clientVersion === "string" && p.clientVersion ? headerValueProblem(p.clientVersion) : null;
442
+ if (versionProblem)
443
+ errors.push(`provider ${name}: clientVersion cannot be sent as a header: ${versionProblem}`);
444
+ if (p.url !== undefined && !/^https?:\/\//.test(p.url))
445
+ errors.push(`provider ${name}: url must start with http:// or https://`);
446
+ if (p.models !== undefined) {
447
+ if (!validModels(p.models))
448
+ errors.push(`provider ${name}: ${MODELS_SHAPE}`);
449
+ // `providerFor` turns a model with its own wire, url or auth header into an openai-compatible
450
+ // provider, which would go out without the session headers this one exists to add.
451
+ else if (p.models.some((m) => m.wire !== undefined || m.url !== undefined || m.authHeader !== undefined)) {
452
+ errors.push(`provider ${name}: a grok model cannot set wire, url or authHeader`);
453
+ }
454
+ }
455
+ }
432
456
  else {
433
457
  errors.push(`provider ${name}: unknown type "${p.type}"`);
434
458
  }
@@ -332,8 +332,8 @@ export class OpenAiIngress {
332
332
  // is an OpenAI wire, which the refusal below would otherwise turn away on the provider's type
333
333
  // alone. One subscription is one provider, so the model decides.
334
334
  const provider = providerFor(configured, route.model);
335
- if (provider.type === "chatgpt" || provider.type === "openai-compatible" || provider.type === "google") {
336
- const label = provider.type === "chatgpt" ? "ChatGPT" : provider.type === "google" ? "Google" : "OpenAI-compatible";
335
+ if (provider.type === "chatgpt" || provider.type === "openai-compatible" || provider.type === "google" || provider.type === "grok") {
336
+ const label = provider.type === "chatgpt" ? "ChatGPT" : provider.type === "google" ? "Google" : provider.type === "grok" ? "Grok" : "OpenAI-compatible";
337
337
  const bytes = sendJson(res, 400, openAiError(`${label} provider is not available through OpenAI ingress`, "invalid_request_error", "unsupported_provider"));
338
338
  finish(400, bytes, { note: `model ${requested} -> ${provider.type} provider unsupported` });
339
339
  return;
@@ -0,0 +1,289 @@
1
+ // Grok subscription sign-in, borrowed from the Grok CLI. Nothing here signs anyone in.
2
+ //
3
+ // <grok home>/auth.json written by `grok login` ($GROK_HOME, else ~/.grok). One entry per issuer,
4
+ // keyed `https://auth.x.ai::<id>`; each has `key` (the session token, sent
5
+ // as a bearer), `expires_at` (RFC 3339) and a `refresh_token`.
6
+ //
7
+ // Read-only, for the same reason as `borrow-codex`: the CLI refreshes its grant under a lock of its
8
+ // own (`auth lock` in ~/.grok/logs), and if that refresh rotates the refresh token — not measured —
9
+ // refreshing it from here would race the CLI and could sign it out. Measured 2026-10-08 on CLI 1.0.46: a session token lives six hours, and a running
10
+ // CLI refreshes it about five minutes before expiry (`GROK_AUTH_EARLY_INVALIDATION_SECS`, default 300).
11
+ // With no CLI running the file simply goes stale. So when the token is near its end the router asks
12
+ // the CLI to do it: `grok models` with that same variable set to our own threshold makes the CLI see
13
+ // the token as expiring, refresh it under its lock, and write the file — which we then read again.
14
+ // `grok models` was measured to do exactly this (expiry moved six hours, file rewritten).
15
+ import { execFile } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ /** `x-grok-client-version` when the CLI cannot be asked. The proxy refuses a request without one (426). */
20
+ export const GROK_CLIENT_VERSION_FALLBACK = "1.0.46";
21
+ /** What the user can do about a missing or rejected session, appended to the errors they read. */
22
+ export const GROK_AUTH_HINT = "sign in with `grok login`, or run `grok` once so the CLI refreshes its session";
23
+ export function grokHome(env = process.env) {
24
+ return env.GROK_HOME ? env.GROK_HOME : path.join(os.homedir(), ".grok");
25
+ }
26
+ export function grokAuthPath(home) {
27
+ return path.join(home, "auth.json");
28
+ }
29
+ /**
30
+ * The session token in `file`: of the entries carrying one, the one that expires last. The entry key
31
+ * is the issuer plus an id, and the CLI's own README reads a different key than the one it writes
32
+ * (`https://accounts.x.ai/sign-in` vs `https://auth.x.ai::…`, 1.0.46), so no key is assumed.
33
+ */
34
+ export function readGrokToken(file) {
35
+ let doc;
36
+ try {
37
+ doc = JSON.parse(fs.readFileSync(file, "utf8"));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ if (!doc || typeof doc !== "object" || Array.isArray(doc))
43
+ return null;
44
+ let best = null;
45
+ for (const value of Object.values(doc)) {
46
+ if (!value || typeof value !== "object")
47
+ continue;
48
+ const entry = value;
49
+ // A key that cannot be a header value would only fail inside fetch, which quotes it in the error.
50
+ if (typeof entry.key !== "string" || !entry.key || /[\0\r\n]|[^\0-\xff]/.test(entry.key))
51
+ continue;
52
+ const expiresAt = typeof entry.expires_at === "string" ? Date.parse(entry.expires_at) || 0 : 0;
53
+ if (best && expiresAt <= best.expiresAt)
54
+ continue;
55
+ best = { key: entry.key, expiresAt, ...(typeof entry.email === "string" ? { email: entry.email } : {}) };
56
+ }
57
+ return best;
58
+ }
59
+ function executable(file) {
60
+ try {
61
+ fs.accessSync(file, fs.constants.X_OK);
62
+ return fs.statSync(file).isFile();
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ /**
69
+ * The `grok` binary. The router runs under launchd with PATH=/usr/bin:/bin:/usr/sbin:/sbin (measured
70
+ * 2026-10-08), where the CLI never is, so the installer's own location comes before PATH.
71
+ */
72
+ export function findGrokCli(home, explicit, env = process.env) {
73
+ if (explicit)
74
+ return executable(explicit) ? explicit : null;
75
+ const name = process.platform === "win32" ? "grok.exe" : "grok";
76
+ const candidates = [
77
+ path.join(home, "bin", name),
78
+ ...(env.PATH ?? "").split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, name)),
79
+ path.join(env.HOME || os.homedir(), ".local", "bin", name),
80
+ ];
81
+ return candidates.find(executable) ?? null;
82
+ }
83
+ const runCli = (file, args, env, timeoutMs) => new Promise((resolve, reject) => {
84
+ execFile(file, args, { env, timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 }, (error, stdout) => {
85
+ if (error)
86
+ reject(error);
87
+ else
88
+ resolve(String(stdout));
89
+ });
90
+ });
91
+ export class GrokAuthError extends Error {
92
+ }
93
+ /**
94
+ * Bounded by the CLI's own: it waits up to 25s for its auth lock (`auth lock: attempting acquire
95
+ * (timeout=25000ms)`), so a refresh that cannot finish in 30s is not going to.
96
+ */
97
+ const REFRESH_TIMEOUT_MS = 30_000;
98
+ /**
99
+ * A rejected token is not ours to keep, so the refresh that follows one treats whatever is there as
100
+ * expiring. A day is past any session's six hours, and is the value measured to make the CLI refresh.
101
+ */
102
+ const FORCE_REFRESH_SECS = 24 * 3600;
103
+ /**
104
+ * A proxy that keeps refusing a freshly minted token is refusing the account, not the token, and
105
+ * forcing a refresh for every turn would only churn the grant. The CLI's README states the same rule
106
+ * for its own credential helpers: a token rejected within 30s of being fetched is not fetched again.
107
+ */
108
+ const FORCED_REFRESH_GAP_MS = 60_000;
109
+ /**
110
+ * A proactive refresh that ran and still left the token short (the CLI failed, or the IdP is down) is
111
+ * not run again for every turn in the minutes the token has left: each run can take 30s.
112
+ */
113
+ const REFRESH_RETRY_GAP_MS = 60_000;
114
+ export class GrokAuth {
115
+ file;
116
+ opts;
117
+ refreshAheadMs;
118
+ run;
119
+ now;
120
+ env;
121
+ cached;
122
+ refreshing;
123
+ lastForcedAt = -Infinity;
124
+ lastAttemptAt = -Infinity;
125
+ version;
126
+ constructor(opts) {
127
+ this.opts = opts;
128
+ this.file = grokAuthPath(opts.home);
129
+ this.refreshAheadMs = opts.refreshAheadMs ?? 10 * 60_000;
130
+ this.run = opts.run ?? runCli;
131
+ this.now = opts.now ?? Date.now;
132
+ this.env = opts.env ?? process.env;
133
+ }
134
+ /** The token on disk, read again only when the file changes. One stat per call. */
135
+ current() {
136
+ let st;
137
+ try {
138
+ st = fs.statSync(this.file);
139
+ }
140
+ catch {
141
+ this.cached = undefined;
142
+ return null;
143
+ }
144
+ if (this.cached && this.cached.mtimeMs === st.mtimeMs && this.cached.size === st.size)
145
+ return this.cached.token;
146
+ const token = readGrokToken(this.file);
147
+ this.cached = { mtimeMs: st.mtimeMs, size: st.size, token };
148
+ return token;
149
+ }
150
+ usable(token, marginMs) {
151
+ return token !== null && (token.expiresAt === 0 || token.expiresAt - this.now() > marginMs);
152
+ }
153
+ /**
154
+ * A token with at least `refreshAheadMs` to live, asking the CLI for a fresh one when the file's has
155
+ * less. A token that is merely close to its end is still used when the CLI cannot refresh it: it
156
+ * works until it expires, and refusing it early would fail a turn that could have run.
157
+ */
158
+ async token() {
159
+ const now = this.current();
160
+ if (this.usable(now, this.refreshAheadMs))
161
+ return now;
162
+ if (now === null && !fs.existsSync(this.file))
163
+ throw new GrokAuthError(`no Grok session at ${this.file}: ${GROK_AUTH_HINT}`);
164
+ // A refresh already running is joined. Otherwise one that failed less than a minute ago is not
165
+ // run again, whether or not the token has expired since: with the IdP down every turn would
166
+ // spawn the CLI for nothing. The file is still read again, in case the CLI fixed it on its own.
167
+ if (this.refreshing)
168
+ await this.refreshing;
169
+ else if (this.now() - this.lastAttemptAt >= REFRESH_RETRY_GAP_MS)
170
+ await this.refresh(Math.ceil(this.refreshAheadMs / 1000));
171
+ const after = this.current();
172
+ if (this.usable(after, 0))
173
+ return after;
174
+ throw new GrokAuthError(`the Grok session in ${this.file} has expired: ${GROK_AUTH_HINT}`);
175
+ }
176
+ /**
177
+ * After the proxy refused `key`: whether a different, live token is available now. The file is read
178
+ * first, since the CLI may already have rotated it; only when it still holds the refused token is
179
+ * the CLI asked to refresh, and that refresh is forced — the token was refused, however long it had.
180
+ */
181
+ async rejected(key) {
182
+ const replaced = () => {
183
+ const now = this.current();
184
+ return now !== null && now.key !== key && this.usable(now, 0);
185
+ };
186
+ if (replaced())
187
+ return true;
188
+ // Concurrent turns refused together share the refresh the first one started, rather than the
189
+ // second one finding the once-a-minute limit spent and failing.
190
+ if (this.refreshing) {
191
+ await this.refreshing;
192
+ if (replaced())
193
+ return true;
194
+ }
195
+ if (this.now() - this.lastForcedAt < FORCED_REFRESH_GAP_MS)
196
+ return false;
197
+ this.lastForcedAt = this.now();
198
+ await this.refresh(FORCE_REFRESH_SECS);
199
+ return replaced();
200
+ }
201
+ /** One refresh at a time: concurrent turns that all find the token stale share the CLI run. */
202
+ refresh(earlySecs) {
203
+ if (this.refreshing)
204
+ return this.refreshing;
205
+ this.lastAttemptAt = this.now();
206
+ const cli = findGrokCli(this.opts.home, this.opts.cli, this.env);
207
+ if (!cli) {
208
+ this.opts.log.warn(`grok: session needs a refresh but no grok CLI was found${this.opts.cli ? ` at ${this.opts.cli}` : ""}`);
209
+ return Promise.resolve();
210
+ }
211
+ const env = {
212
+ ...this.env,
213
+ GROK_AUTH_EARLY_INVALIDATION_SECS: String(earlySecs),
214
+ // The CLI reads its session from $GROK_HOME; a home given in the config has to reach it.
215
+ ...(this.opts.home !== grokHome(this.env) ? { GROK_HOME: this.opts.home } : {}),
216
+ };
217
+ const started = this.now();
218
+ this.refreshing = this.run(cli, ["models"], env, REFRESH_TIMEOUT_MS)
219
+ .then(() => this.opts.log.info(`grok: session refreshed through the CLI in ${this.now() - started}ms`))
220
+ .catch((error) => this.opts.log.warn(`grok: CLI session refresh failed: ${error.message.split("\n")[0]?.slice(0, 200)}`))
221
+ // The CLI rewrites the file with a token of the same length, possibly within the mtime's
222
+ // resolution, so the read after a refresh must not trust the stat-keyed cache.
223
+ .finally(() => { this.refreshing = undefined; this.cached = undefined; });
224
+ return this.refreshing;
225
+ }
226
+ /**
227
+ * `x-grok-client-version`: the installed CLI's own version, so the proxy sees the client the token
228
+ * was issued to. Read again when the binary changes: an update either replaces the file the symlink
229
+ * names or rewrites the binary in place, so the key is the resolved path with its mtime and size.
230
+ */
231
+ clientVersion(pinned = this.opts.clientVersion) {
232
+ if (pinned)
233
+ return Promise.resolve(pinned);
234
+ const cli = findGrokCli(this.opts.home, this.opts.cli, this.env);
235
+ let binary = "";
236
+ try {
237
+ if (cli) {
238
+ const real = fs.realpathSync(cli);
239
+ const st = fs.statSync(real);
240
+ binary = `${real}\0${st.mtimeMs}\0${st.size}`;
241
+ }
242
+ }
243
+ catch {
244
+ binary = cli ?? ""; /* keep the unresolved name */
245
+ }
246
+ if (this.version && this.version.binary === binary)
247
+ return this.version.value;
248
+ const value = !cli
249
+ ? Promise.resolve(GROK_CLIENT_VERSION_FALLBACK)
250
+ : this.run(cli, ["--version"], this.env, 10_000)
251
+ .then((out) => /\b(\d+\.\d+\.\d+)\b/.exec(out)?.[1] ?? GROK_CLIENT_VERSION_FALLBACK)
252
+ .catch((error) => {
253
+ this.opts.log.warn(`grok: \`grok --version\` failed (${error.message.split("\n")[0]?.slice(0, 120)}); sending ${GROK_CLIENT_VERSION_FALLBACK}`);
254
+ return GROK_CLIENT_VERSION_FALLBACK;
255
+ });
256
+ this.version = { binary, value };
257
+ return value;
258
+ }
259
+ }
260
+ const shared = new Map();
261
+ /**
262
+ * The `GrokAuth` for one Grok home and binary, made once per process. The refresh is single-flight
263
+ * and backed off per instance, so the dashboard's probes and the turns that use the same session
264
+ * have to share one, or two of them spawn two CLI runs against the same lock. A pinned client version
265
+ * is not part of it: that is passed to `clientVersion()` by whoever sends the header.
266
+ */
267
+ export function sharedGrokAuth(opts) {
268
+ const real = (file) => {
269
+ try {
270
+ return fs.realpathSync(file);
271
+ }
272
+ catch {
273
+ return file; /* not there yet: the resolved path names it */
274
+ }
275
+ };
276
+ const home = real(path.resolve(opts.home));
277
+ // The binary that would actually run, so a config that names the CLI the lookup would find anyway
278
+ // still shares the instance of one that leaves it out.
279
+ const cli = findGrokCli(home, opts.cli, opts.env);
280
+ const key = JSON.stringify([home, cli ? real(cli) : opts.cli ?? ""]);
281
+ let auth = shared.get(key);
282
+ if (!auth) {
283
+ // Built on the resolved home: one created through a symlink must not follow the link elsewhere
284
+ // later while still being found under the old target.
285
+ auth = new GrokAuth({ ...opts, home });
286
+ shared.set(key, auth);
287
+ }
288
+ return auth;
289
+ }