grokbot-control 0.0.0-stage → 0.1.0

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.
Files changed (122) hide show
  1. package/README.md +160 -2
  2. package/dist/auth/lock.d.ts +14 -0
  3. package/dist/auth/lock.js +71 -0
  4. package/dist/auth/provider.d.ts +89 -0
  5. package/dist/auth/provider.js +884 -0
  6. package/dist/auth/store.d.ts +25 -0
  7. package/dist/auth/store.js +106 -0
  8. package/dist/auth/types.d.ts +138 -0
  9. package/dist/auth/types.js +1 -0
  10. package/dist/cli/config.d.ts +26 -0
  11. package/dist/cli/config.js +98 -0
  12. package/dist/cli/help.d.ts +1 -0
  13. package/dist/cli/help.js +83 -0
  14. package/dist/cli/io.d.ts +6 -0
  15. package/dist/cli/io.js +61 -0
  16. package/dist/cli/main.d.ts +2 -0
  17. package/dist/cli/main.js +275 -0
  18. package/dist/cli/sync.d.ts +4 -0
  19. package/dist/cli/sync.js +105 -0
  20. package/dist/connect/client.d.ts +32 -0
  21. package/dist/connect/client.js +257 -0
  22. package/dist/dto/result.d.ts +33 -0
  23. package/dist/dto/result.js +29 -0
  24. package/dist/dto/types.d.ts +63 -0
  25. package/dist/dto/types.js +2 -0
  26. package/dist/errors.d.ts +31 -0
  27. package/dist/errors.js +64 -0
  28. package/dist/index.d.ts +13 -0
  29. package/dist/index.js +11 -0
  30. package/dist/mcp/main.d.ts +2 -0
  31. package/dist/mcp/main.js +82 -0
  32. package/dist/mcp/tools.d.ts +24 -0
  33. package/dist/mcp/tools.js +408 -0
  34. package/dist/mcp/zod-json.d.ts +2 -0
  35. package/dist/mcp/zod-json.js +4 -0
  36. package/dist/ops/auth.d.ts +48 -0
  37. package/dist/ops/auth.js +89 -0
  38. package/dist/ops/bot.d.ts +52 -0
  39. package/dist/ops/bot.js +211 -0
  40. package/dist/ops/limits.d.ts +10 -0
  41. package/dist/ops/limits.js +31 -0
  42. package/dist/ops/map.d.ts +7 -0
  43. package/dist/ops/map.js +57 -0
  44. package/dist/ops/message.d.ts +33 -0
  45. package/dist/ops/message.js +118 -0
  46. package/dist/ops/plane.d.ts +26 -0
  47. package/dist/ops/plane.js +33 -0
  48. package/dist/ops/progress.d.ts +34 -0
  49. package/dist/ops/progress.js +92 -0
  50. package/dist/ops/session.d.ts +13 -0
  51. package/dist/ops/session.js +34 -0
  52. package/dist/ops/sidebar.d.ts +70 -0
  53. package/dist/ops/sidebar.js +205 -0
  54. package/dist/sync/attachments.d.ts +76 -0
  55. package/dist/sync/attachments.js +274 -0
  56. package/dist/sync/core.d.ts +92 -0
  57. package/dist/sync/core.js +1027 -0
  58. package/dist/sync/decode.d.ts +83 -0
  59. package/dist/sync/decode.js +229 -0
  60. package/dist/sync/download-policy.d.ts +3 -0
  61. package/dist/sync/download-policy.js +103 -0
  62. package/dist/sync/fs.d.ts +24 -0
  63. package/dist/sync/fs.js +252 -0
  64. package/dist/sync/hydrate.d.ts +43 -0
  65. package/dist/sync/hydrate.js +472 -0
  66. package/dist/sync/render.d.ts +5 -0
  67. package/dist/sync/render.js +100 -0
  68. package/dist/testing/mock-persist.d.ts +3 -0
  69. package/dist/testing/mock-persist.js +45 -0
  70. package/dist/testing/mock-transport.d.ts +18 -0
  71. package/dist/testing/mock-transport.js +202 -0
  72. package/docs/acceptance.md +19 -0
  73. package/docs/architecture.md +128 -0
  74. package/docs/auth-lock.md +87 -0
  75. package/docs/implementation-plan.md +14 -0
  76. package/docs/implementation-status.md +92 -0
  77. package/docs/proto-drift.md +9 -0
  78. package/docs/protocol/README.md +34 -0
  79. package/docs/protocol/attachment-pagination-0.79.3.md +32 -0
  80. package/docs/protocol/auth-flow.md +51 -0
  81. package/docs/protocol/baseline-manifest.md +72 -0
  82. package/docs/protocol/baselines/full-schema-diff-0.78.4-to-0.79.2.txt +239 -0
  83. package/docs/protocol/baselines/full-schema-diff-0.78.4-to-0.79.3.txt +240 -0
  84. package/docs/protocol/baselines/full-schema-diff-0.79.2-to-0.79.3.txt +23 -0
  85. package/docs/protocol/baselines/schema-0.79.3/README.md +7 -0
  86. package/docs/protocol/baselines/schema-0.79.3/agent.v1.proto +5184 -0
  87. package/docs/protocol/baselines/schema-0.79.3/aiserver.v1.proto +18182 -0
  88. package/docs/protocol/baselines/schema-0.79.3/aiserver.v1_2.proto +54 -0
  89. package/docs/protocol/baselines/schema-0.79.3/anyrun.v1.proto +37 -0
  90. package/docs/protocol/baselines/schema-0.79.3/internapi.v1.proto +35 -0
  91. package/docs/protocol/baselines/schema-0.79.3/snapshot.json.gz +0 -0
  92. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.AgentStoreService.proto +34 -0
  93. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.AiService.proto +17 -0
  94. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.AnalyticsService.proto +17 -0
  95. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.BackgroundComposerService.proto +19 -0
  96. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.DashboardService.proto +101 -0
  97. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.GrokBotService.proto +473 -0
  98. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.SandBoxService.proto +64 -0
  99. package/docs/protocol/baselines/used-surface-0.78.4.json +3694 -0
  100. package/docs/protocol/baselines/used-surface-0.79.2.json +3835 -0
  101. package/docs/protocol/baselines/used-surface-0.79.3.json +3835 -0
  102. package/docs/protocol/baselines/used-surface-diff-0.78.4-to-0.79.2.md +34 -0
  103. package/docs/protocol/baselines/used-surface-diff-0.78.4-to-0.79.3.md +33 -0
  104. package/docs/protocol/compat-matrix.md +20 -0
  105. package/docs/protocol/static-restore.md +64 -0
  106. package/docs/protocol/surface-map.md +39 -0
  107. package/docs/protocol/transcript-body-0.79.3.md +98 -0
  108. package/docs/protocol/update-runbook.md +30 -0
  109. package/docs/protocol-map.md +106 -0
  110. package/docs/references.md +55 -0
  111. package/docs/requirements.md +26 -0
  112. package/docs/review.md +36 -0
  113. package/docs/sync-format.md +98 -0
  114. package/package.json +60 -4
  115. package/vendor/proto-gen/NOTICE.md +23 -0
  116. package/vendor/proto-gen/agent.v1_pb.js +9503 -0
  117. package/vendor/proto-gen/aiserver.v1_2_pb.js +105 -0
  118. package/vendor/proto-gen/aiserver.v1_pb.js +33163 -0
  119. package/vendor/proto-gen/anyrun.v1_pb.js +64 -0
  120. package/vendor/proto-gen/internapi.v1_pb.js +60 -0
  121. package/vendor/proto-gen/package.json +10 -0
  122. package/vendor/proto-gen/svc_aiserver.v1.GrokBotService_connect.js +4193 -0
@@ -0,0 +1,884 @@
1
+ import crypto from "node:crypto";
2
+ import { GbcError } from "../errors.js";
3
+ import { FileCredentialStore, withCredentialLock } from "./store.js";
4
+ /**
5
+ * Independent Cursor browser login (product-custom challenge + poll; NOT RFC 8628).
6
+ *
7
+ * Protocol source: STATIC reverse engineering of Grok Bot Linux 0.79.2 / 0.79.3
8
+ * (/workspace/grokbot-linux-auth-analysis.md; evidence/main-app.pretty.cjs = A,
9
+ * main-core.pretty.cjs = C). Not official public API documentation.
10
+ * - A:38644-38657 Rdn: verifier = base64url(32 random bytes); challenge =
11
+ * base64url(SHA256(verifier string)); uuid = randomUUID();
12
+ * `${website}/loginDeepControl?challenge&uuid&mode=login&redirectTarget[&supportsSelectedTeamLogin=true]`
13
+ * - A:38720-38816 Aze/Odn: GET `${api}/auth/poll?uuid=..&verifier=..`; 404 = pending;
14
+ * max 150 tries, 1s * 1.2^i capped 10s; 3 consecutive non-404 failures stop;
15
+ * 403 {error:"sign_in_policy_violation"} = policy denial.
16
+ * - A:38584-38593 Cdn: success body {accessToken, refreshToken}; A:38605-38622 mze:
17
+ * xAI identity body {xaiAccessToken,...} -> UNSUPPORTED here (different refresh path).
18
+ * - A:56108-56125: refresh POST new URL("/oauth/token", backend) JSON
19
+ * {client_id, grant_type:"refresh_token", refresh_token}; A:54459 response
20
+ * {access_token?, refresh_token?, shouldLogout?, error?}; refresh_token defaults to old.
21
+ * - C:35811 KLe: refresh 300s before JWT exp.
22
+ * client_id: the App selects a public client identifier per backend (C:35866-35869).
23
+ * This tool does NOT embed it; refresh requires explicit GBT_OAUTH_CLIENT_ID config
24
+ * or a client_id persisted at login / confirmed migration.
25
+ */
26
+ export const DEFAULT_BACKENDS = [
27
+ "https://api2.cursor.sh",
28
+ "https://api3.cursor.sh",
29
+ "https://agentn.global.api5.cursor.sh",
30
+ ];
31
+ export const DEFAULT_WEBSITE = "https://cursor.com";
32
+ export const POLICY_VIOLATION = "sign_in_policy_violation";
33
+ export const REFRESH_EARLY_MS = 300_000;
34
+ export const REFRESH_DEADLINE_MS = 20_000;
35
+ export const PENDING_TTL_MS = 15 * 60_000;
36
+ const POLL_BASE_MS = 1000;
37
+ const POLL_FACTOR = 1.2;
38
+ const POLL_CAP_MS = 10_000;
39
+ const POLL_MAX_TRIES = 150;
40
+ const POLL_MAX_CONSECUTIVE_ERRORS = 3;
41
+ function b64url(buf) {
42
+ return buf.toString("base64url");
43
+ }
44
+ export function generateLoginChallenge() {
45
+ const verifier = b64url(crypto.randomBytes(32));
46
+ const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
47
+ return { verifier, challenge, uuid: crypto.randomUUID() };
48
+ }
49
+ export function buildLoginUrl(website, p) {
50
+ const u = new URL("/loginDeepControl", website);
51
+ u.searchParams.set("challenge", p.challenge);
52
+ u.searchParams.set("uuid", p.uuid);
53
+ u.searchParams.set("mode", "login");
54
+ u.searchParams.set("redirectTarget", p.redirectTarget);
55
+ return u.toString();
56
+ }
57
+ /** JWT payload (unverified decode — only for exp/email display; never trust for auth / client_id). */
58
+ export function decodeJwtPayload(token) {
59
+ const part = token.split(".")[1];
60
+ if (!part)
61
+ return null;
62
+ try {
63
+ const obj = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
64
+ return typeof obj === "object" && obj ? obj : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ function defaultSleep(ms, signal) {
71
+ return new Promise((resolve) => {
72
+ if (signal?.aborted)
73
+ return resolve();
74
+ const t = setTimeout(() => {
75
+ signal?.removeEventListener("abort", onAbort);
76
+ resolve();
77
+ }, ms);
78
+ const onAbort = () => {
79
+ clearTimeout(t);
80
+ resolve();
81
+ };
82
+ signal?.addEventListener("abort", onAbort, { once: true });
83
+ });
84
+ }
85
+ function stripSlash(s) {
86
+ return s.replace(/\/+$/, "");
87
+ }
88
+ export function refreshEndpointFor(backend) {
89
+ return new URL("/oauth/token", stripSlash(backend) + "/").toString();
90
+ }
91
+ /** Classify stored / JWT exp. Never invents an expiry. */
92
+ export function classifyExpiry(expires_at_ms, nowMs) {
93
+ if (expires_at_ms === undefined || expires_at_ms === null) {
94
+ return { status: { ok: false, reason: "missing_exp" }, needsRefresh: true, stillValid: false };
95
+ }
96
+ if (!Number.isSafeInteger(expires_at_ms) || expires_at_ms <= 0) {
97
+ return { status: { ok: false, reason: "illegal_exp" }, needsRefresh: true, stillValid: false };
98
+ }
99
+ const stillValid = expires_at_ms > nowMs;
100
+ const needsRefresh = expires_at_ms - nowMs < REFRESH_EARLY_MS;
101
+ return { status: { ok: true, expires_at_ms }, needsRefresh, stillValid };
102
+ }
103
+ function metaFromCred(c, refreshed, reason, note) {
104
+ return {
105
+ refreshed,
106
+ reason,
107
+ kind: c.kind,
108
+ backend: c.backend,
109
+ expires_at_ms: c.expires_at_ms,
110
+ revision: c.revision ?? 0,
111
+ account_label: c.account_label,
112
+ session_id: c.session_id,
113
+ credential_source: c.credential_source,
114
+ login_kind: c.login_kind,
115
+ client_id_bound: Boolean(c.client_id),
116
+ refresh_endpoint: c.refresh_endpoint,
117
+ note,
118
+ };
119
+ }
120
+ export function createAuthProvider(opts) {
121
+ const backend = stripSlash(opts.backend);
122
+ const trusted = Boolean(opts.trustCustomBackend) || process.env.GBT_TRUST_BACKEND === "1";
123
+ if (!DEFAULT_BACKENDS.includes(backend) && !trusted) {
124
+ throw new GbcError("denied", `backend ${backend} is not in the default allowlist; pass trustCustomBackend / --trust-backend or GBT_TRUST_BACKEND=1 (exactly "1")`, { exitCode: 7, hint: "Prevents sending tokens to an untrusted host (RFC9700-style binding)." });
125
+ }
126
+ const website = stripSlash(opts.website ?? process.env.GBT_WEBSITE_URL ?? DEFAULT_WEBSITE);
127
+ if (website !== DEFAULT_WEBSITE && !trusted) {
128
+ throw new GbcError("denied", `login website ${website} is not ${DEFAULT_WEBSITE}; requires trust (GBT_TRUST_BACKEND=1)`, {
129
+ exitCode: 7,
130
+ });
131
+ }
132
+ const kind = opts.kind;
133
+ const store = opts.store ?? new FileCredentialStore();
134
+ const http = opts.http ?? { fetch: globalThis.fetch.bind(globalThis) };
135
+ const now = opts.now ?? (() => Date.now());
136
+ const sleep = opts.sleep ?? defaultSleep;
137
+ const redirectTarget = opts.redirectTarget ?? process.env.GBT_LOGIN_REDIRECT_TARGET ?? "cli";
138
+ const refreshUrl = refreshEndpointFor(backend);
139
+ const lockKey = `${kind}|${backend}`;
140
+ const refreshDeadlineMs = Math.min(opts.refreshDeadlineMs ?? REFRESH_DEADLINE_MS, REFRESH_DEADLINE_MS);
141
+ /** In-process singleflight for refresh/getAccessToken (full exchange, never aborted mid-flight). */
142
+ let inflight;
143
+ /** Soft cooldown after 429 / 5xx / network, keyed by session_id so a re-login is not blocked. */
144
+ const cooldownBySession = new Map();
145
+ /**
146
+ * After a caller-visible refresh timeout the IdP exchange may still complete (or may already
147
+ * have rotated the refresh token). Remember the spent RT briefly so a follow-up invalid_grant
148
+ * does not wipe credentials that a still-running exchange (or peer) is about to replace.
149
+ */
150
+ let ambiguousExchange;
151
+ const sessionKey = (sessionId) => sessionId ?? "";
152
+ const cooldownFor = (sessionId, diskMs) => Math.max(cooldownBySession.get(sessionKey(sessionId)) ?? 0, diskMs ?? 0);
153
+ const setCooldown = (sessionId, until) => {
154
+ cooldownBySession.set(sessionKey(sessionId), until);
155
+ };
156
+ const clearCooldown = (sessionId) => {
157
+ cooldownBySession.delete(sessionKey(sessionId));
158
+ };
159
+ const markAmbiguous = (sessionId, refreshToken) => {
160
+ ambiguousExchange = { session_id: sessionId, refresh_token: refreshToken, until_ms: now() + 120_000 };
161
+ };
162
+ const isAmbiguousProtect = (sessionId, refreshToken) => Boolean(ambiguousExchange &&
163
+ refreshToken &&
164
+ ambiguousExchange.refresh_token === refreshToken &&
165
+ ambiguousExchange.session_id === sessionId &&
166
+ ambiguousExchange.until_ms > now());
167
+ const clearAmbiguousIf = (sessionId, refreshToken) => {
168
+ if (ambiguousExchange &&
169
+ ambiguousExchange.refresh_token === refreshToken &&
170
+ ambiguousExchange.session_id === sessionId) {
171
+ ambiguousExchange = undefined;
172
+ }
173
+ };
174
+ const configuredClientId = () => opts.clientId ?? process.env.GBT_OAUTH_CLIENT_ID?.trim() ?? "";
175
+ const migrateConfirmed = () => process.env.GBT_AUTH_CONFIRM_MIGRATE === "1";
176
+ const requireCursor = (what) => {
177
+ if (kind !== "cursor") {
178
+ throw new GbcError("unsupported", `${what}: auth kind "${kind}" is not supported (xAI identity uses a different issuer/refresh path)`, {
179
+ exitCode: 9,
180
+ hint: "Use --auth-kind cursor. Do not mix xAI and Cursor credentials.",
181
+ });
182
+ }
183
+ };
184
+ /** Never let the verifier leak through an error message. */
185
+ const scrub = (msg, secrets) => secrets.reduce((m, s) => (s ? m.split(s).join("[redacted]") : m), msg);
186
+ function resolveClientId(cur) {
187
+ const fromCred = cur?.client_id?.trim() ?? "";
188
+ const fromCfg = configuredClientId();
189
+ if (fromCred) {
190
+ if (fromCfg && fromCfg !== fromCred) {
191
+ throw new GbcError("auth_refresh_client_mismatch", "GBT_OAUTH_CLIENT_ID does not match the client_id bound on the stored credential", {
192
+ exitCode: 4,
193
+ hint: "Unset GBT_OAUTH_CLIENT_ID to use the bound id, or re-login / confirm migrate carefully. Credentials kept.",
194
+ details: { rpc_dispatched: false, can_fallback: true },
195
+ });
196
+ }
197
+ return { clientId: fromCred, persist: false };
198
+ }
199
+ if (fromCfg) {
200
+ // Explicit env/opts is configuration. Legacy files without provenance also accept
201
+ // GBT_AUTH_CONFIRM_MIGRATE=1 as an explicit migrate confirmation (optional).
202
+ if (!cur?.client_id && !cur?.credential_source && !migrateConfirmed()) {
203
+ // Still allow: configuring GBT_OAUTH_CLIENT_ID alone is explicit enough.
204
+ }
205
+ return { clientId: fromCfg, persist: true };
206
+ }
207
+ throw new GbcError("auth_refresh_unconfigured", "token refresh needs a public OAuth client_id (GBT_OAUTH_CLIENT_ID or login-persisted binding); none configured", {
208
+ exitCode: 4,
209
+ hint: "Set GBT_OAUTH_CLIENT_ID, re-login so client_id is persisted, or set GBT_AUTH_CONFIRM_MIGRATE=1 after configuring. Credentials were kept. client_id is never guessed from JWT.",
210
+ details: { rpc_dispatched: false, can_fallback: true },
211
+ });
212
+ }
213
+ function credFromTokens(access, refresh, prev, source, loginKind, sessionId, clientId) {
214
+ const jwt = decodeJwtPayload(access);
215
+ let expires_at_ms;
216
+ if (jwt && typeof jwt.exp === "number" && Number.isFinite(jwt.exp) && jwt.exp > 0) {
217
+ if (Number.isSafeInteger(jwt.exp * 1000))
218
+ expires_at_ms = jwt.exp * 1000;
219
+ }
220
+ const nextRev = (prev?.revision ?? 0) + 1;
221
+ return {
222
+ kind,
223
+ backend,
224
+ access_token: access,
225
+ refresh_token: refresh ?? prev?.refresh_token,
226
+ expires_at_ms,
227
+ account_label: (typeof jwt?.email === "string" ? jwt.email : undefined) ?? prev?.account_label,
228
+ updated_at_ms: now(),
229
+ revision: nextRev,
230
+ login_kind: loginKind,
231
+ credential_source: source,
232
+ refresh_endpoint: refreshUrl,
233
+ client_id: clientId || prev?.client_id,
234
+ issuer: typeof jwt?.iss === "string" ? jwt.iss : prev?.issuer,
235
+ account_subject: typeof jwt?.sub === "string" ? jwt.sub : prev?.account_subject,
236
+ session_id: sessionId ?? prev?.session_id,
237
+ };
238
+ }
239
+ function loadPendingOrThrow() {
240
+ const p = store.loadPending(kind, backend);
241
+ if (!p) {
242
+ throw new GbcError("auth", "no pending login; run `gbc auth login-begin` first", { exitCode: 4 });
243
+ }
244
+ if (p.expires_at_ms <= now()) {
245
+ store.clearPending(kind, backend);
246
+ throw new GbcError("auth_login_expired", "pending login expired; run `gbc auth login-begin` again", { exitCode: 4 });
247
+ }
248
+ return p;
249
+ }
250
+ async function pollOnceRaw(p, signal) {
251
+ const url = `${backend}/auth/poll?uuid=${encodeURIComponent(p.uuid)}&verifier=${encodeURIComponent(p.verifier)}`;
252
+ let res;
253
+ try {
254
+ res = await http.fetch(url, { method: "GET", headers: { "Content-Type": "application/json" }, signal });
255
+ }
256
+ catch {
257
+ return { done: false, transient: true };
258
+ }
259
+ if (res.status === 404)
260
+ return { done: false, transient: false, status: 404 };
261
+ if (res.status === 403) {
262
+ const body = (await res.json().catch(() => undefined));
263
+ if (body?.error === POLICY_VIOLATION) {
264
+ store.clearPending(kind, backend);
265
+ throw new GbcError("denied", "sign-in blocked by organization policy (sign_in_policy_violation)", { exitCode: 7 });
266
+ }
267
+ return { done: false, transient: true, status: 403 };
268
+ }
269
+ if (!res.ok)
270
+ return { done: false, transient: true, status: res.status };
271
+ const body = (await res.json().catch(() => undefined));
272
+ if (body && typeof body.xaiAccessToken === "string") {
273
+ store.clearPending(kind, backend);
274
+ throw new GbcError("unsupported", "login returned an xAI identity session; this tool only supports Cursor tokens", {
275
+ exitCode: 9,
276
+ hint: "xAI identity refresh (auth.grok.* /oauth2/token) is not implemented. Do not mix xAI/Cursor.",
277
+ });
278
+ }
279
+ const access = body?.accessToken;
280
+ const refresh = body?.refreshToken;
281
+ if (typeof access !== "string" || access.length === 0 || (refresh !== undefined && typeof refresh !== "string")) {
282
+ return { done: false, transient: true, status: 200 };
283
+ }
284
+ // Persist under credential lock so a concurrent refresh cannot overwrite a fresh login.
285
+ const result = await withCredentialLock(lockKey, async () => {
286
+ const prev = store.load(kind, backend);
287
+ const cid = configuredClientId() || prev?.client_id;
288
+ const session_id = crypto.randomUUID();
289
+ const cred = credFromTokens(access, refresh, prev, "login_poll", "browser_deep_control", session_id, cid || undefined);
290
+ store.save(cred);
291
+ store.clearPending(kind, backend);
292
+ return {
293
+ done: true,
294
+ result: {
295
+ status: "completed",
296
+ kind,
297
+ backend,
298
+ account_label: cred.account_label,
299
+ expires_at_ms: cred.expires_at_ms,
300
+ revision: cred.revision,
301
+ session_id: cred.session_id,
302
+ },
303
+ };
304
+ });
305
+ return result;
306
+ }
307
+ function refreshDecision(cur, force) {
308
+ if (force)
309
+ return { needs: true, reason: "forced" };
310
+ const exp = classifyExpiry(cur.expires_at_ms, now());
311
+ if (!exp.status.ok)
312
+ return { needs: true, reason: exp.status.reason };
313
+ if (exp.needsRefresh)
314
+ return { needs: true, reason: "near_expiry" };
315
+ return { needs: false, reason: "already_fresh" };
316
+ }
317
+ async function doRefreshLocked(force, rejectedAccessToken) {
318
+ requireCursor("refresh");
319
+ const snap = await withCredentialLock(lockKey, async () => {
320
+ const cur = store.load(kind, backend);
321
+ if (!cur)
322
+ throw new GbcError("auth", "not logged in", { exitCode: 4 });
323
+ // 401 recovery: if disk already rotated away from the rejected bearer, adopt it.
324
+ if (force && rejectedAccessToken && cur.access_token && cur.access_token !== rejectedAccessToken) {
325
+ return metaFromCred(cur, false, "peer_refreshed", "rejected access token already replaced on disk; adopting without force rotation");
326
+ }
327
+ const decision = refreshDecision(cur, force);
328
+ if (!decision.needs) {
329
+ return metaFromCred(cur, false, "already_fresh", "token still fresh enough; no refresh sent");
330
+ }
331
+ const cooldownUntil = cooldownFor(cur.session_id, cur.refresh_cooldown_until_ms);
332
+ if (cooldownUntil > now()) {
333
+ const exp = classifyExpiry(cur.expires_at_ms, now());
334
+ if (exp.stillValid) {
335
+ return metaFromCred(cur, false, decision.reason, "refresh cooldown active; returning still-valid token");
336
+ }
337
+ throw new GbcError("auth_refresh_cooldown", "token refresh in cooldown and access token is not valid", {
338
+ exitCode: 8,
339
+ retryable: true,
340
+ details: { rpc_dispatched: false, cooldown_until_ms: cooldownUntil },
341
+ });
342
+ }
343
+ if (!cur.refresh_token) {
344
+ throw new GbcError("auth", "no refresh_token stored; log in again", { exitCode: 4, details: { rpc_dispatched: false } });
345
+ }
346
+ if (!cur.client_id && !cur.credential_source && !configuredClientId()) {
347
+ throw new GbcError("auth_refresh_unconfigured", "legacy credential has no client_id/provenance; set GBT_OAUTH_CLIENT_ID (and optionally GBT_AUTH_CONFIRM_MIGRATE=1)", {
348
+ exitCode: 4,
349
+ hint: "Credentials kept. client_id is never guessed from JWT.",
350
+ details: { rpc_dispatched: false, can_fallback: classifyExpiry(cur.expires_at_ms, now()).stillValid },
351
+ });
352
+ }
353
+ if (!cur.client_id && !configuredClientId() && !migrateConfirmed()) {
354
+ throw new GbcError("auth_refresh_unconfigured", "credential has no bound client_id; configure GBT_OAUTH_CLIENT_ID or GBT_AUTH_CONFIRM_MIGRATE=1 after setting it", {
355
+ exitCode: 4,
356
+ details: { rpc_dispatched: false, can_fallback: classifyExpiry(cur.expires_at_ms, now()).stillValid },
357
+ });
358
+ }
359
+ const endpoint = new URL(refreshUrl);
360
+ if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password ||
361
+ (cur.refresh_endpoint && cur.refresh_endpoint !== refreshUrl)) {
362
+ throw new GbcError("auth_refresh_unconfigured", "refresh endpoint binding must be trusted HTTPS and match stored provenance", {
363
+ exitCode: 4,
364
+ details: { rpc_dispatched: false, can_fallback: classifyExpiry(cur.expires_at_ms, now()).stillValid },
365
+ });
366
+ }
367
+ const { clientId, persist } = resolveClientId(cur);
368
+ return {
369
+ cur,
370
+ decision,
371
+ clientId,
372
+ persist,
373
+ expectedRevision: cur.revision ?? 0,
374
+ expectedSession: cur.session_id,
375
+ expectedSubject: cur.account_subject,
376
+ refreshToken: cur.refresh_token,
377
+ };
378
+ });
379
+ if ("refreshed" in snap)
380
+ return snap;
381
+ const { cur, decision, clientId, persist, expectedRevision, expectedSession, expectedSubject, refreshToken } = snap;
382
+ const saveCooldown = async (until) => {
383
+ setCooldown(expectedSession, until);
384
+ await withCredentialLock(lockKey, async () => {
385
+ const current = store.load(kind, backend);
386
+ if (current && current.session_id === expectedSession && (current.revision ?? 0) === expectedRevision) {
387
+ store.save({ ...current, refresh_cooldown_until_ms: until });
388
+ }
389
+ });
390
+ };
391
+ // Do NOT abort the IdP exchange on the caller deadline — the server may already have
392
+ // rotated the refresh token (Grok Build refresh_chain / BoundedRefresh semantics).
393
+ let res;
394
+ try {
395
+ res = await http.fetch(refreshUrl, {
396
+ method: "POST",
397
+ headers: { "content-type": "application/json" },
398
+ body: JSON.stringify({ client_id: clientId, grant_type: "refresh_token", refresh_token: refreshToken }),
399
+ redirect: "error",
400
+ });
401
+ }
402
+ catch (e) {
403
+ const aborted = e?.name === "AbortError";
404
+ const until = now() + (aborted ? 5_000 : 10_000);
405
+ await saveCooldown(until);
406
+ const exp = classifyExpiry(cur.expires_at_ms, now());
407
+ // Fetch was dispatched; a transport abort/reset after send is result-unknown, not "not sent".
408
+ throw new GbcError(aborted ? "auth_refresh_timeout" : "auth_refresh_network", aborted
409
+ ? "token refresh aborted after dispatch; credentials kept (outcome unknown)"
410
+ : "token refresh network error; credentials kept", {
411
+ exitCode: 8,
412
+ retryable: true,
413
+ outcome: "unknown",
414
+ details: { rpc_dispatched: "unknown", can_fallback: exp.stillValid, revision: expectedRevision },
415
+ });
416
+ }
417
+ if (res.status >= 300 && res.status < 400) {
418
+ throw new GbcError("auth_refresh_redirect", `token refresh refused redirect HTTP ${res.status}; credentials kept`, {
419
+ exitCode: 4,
420
+ details: { rpc_dispatched: true, can_fallback: classifyExpiry(cur.expires_at_ms, now()).stillValid },
421
+ });
422
+ }
423
+ if (res.status === 429) {
424
+ const ra = res.headers.get("retry-after");
425
+ let wait = 30_000;
426
+ if (ra) {
427
+ const sec = Number(ra);
428
+ if (Number.isFinite(sec) && sec >= 0)
429
+ wait = Math.min(Math.floor(sec * 1000), 300_000);
430
+ else {
431
+ const when = Date.parse(ra);
432
+ if (Number.isFinite(when))
433
+ wait = Math.min(Math.max(0, when - now()), 300_000);
434
+ }
435
+ }
436
+ await saveCooldown(now() + wait);
437
+ const exp = classifyExpiry(cur.expires_at_ms, now());
438
+ throw new GbcError("auth_refresh_rate_limited", `token refresh rate-limited (429); credentials kept; retry after ${wait}ms`, {
439
+ exitCode: 8,
440
+ retryable: true,
441
+ details: { rpc_dispatched: true, can_fallback: exp.stillValid, retry_after_ms: wait, revision: expectedRevision },
442
+ });
443
+ }
444
+ // Body decode is part of the exchange; never abandon mid-read for the caller budget.
445
+ const parsed = await res.json().catch(() => undefined);
446
+ const payload = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
447
+ const terminal = payload?.shouldLogout === true || payload?.error === POLICY_VIOLATION || payload?.error === "invalid_grant";
448
+ if (!res.ok && !terminal) {
449
+ if (res.status >= 500) {
450
+ await saveCooldown(now() + 10_000);
451
+ const exp = classifyExpiry(cur.expires_at_ms, now());
452
+ throw new GbcError("auth_refresh_failed", `token refresh failed HTTP ${res.status}; credentials kept`, {
453
+ exitCode: 8,
454
+ retryable: true,
455
+ details: { rpc_dispatched: true, can_fallback: exp.stillValid, revision: expectedRevision },
456
+ });
457
+ }
458
+ throw new GbcError("auth_refresh_failed", `token refresh failed HTTP ${res.status}; credentials kept`, {
459
+ exitCode: 4,
460
+ details: { rpc_dispatched: true, can_fallback: classifyExpiry(cur.expires_at_ms, now()).stillValid },
461
+ });
462
+ }
463
+ if (!payload || (payload.shouldLogout !== undefined && typeof payload.shouldLogout !== "boolean") ||
464
+ (payload.error !== undefined && typeof payload.error !== "string")) {
465
+ throw new GbcError("auth_refresh_malformed", "invalid refresh response schema; credentials kept", {
466
+ exitCode: 4,
467
+ details: { rpc_dispatched: true, can_fallback: classifyExpiry(cur.expires_at_ms, now()).stillValid },
468
+ });
469
+ }
470
+ const body = payload;
471
+ return withCredentialLock(lockKey, async () => {
472
+ if (terminal) {
473
+ const latest = store.load(kind, backend);
474
+ if (latest && (latest.session_id !== expectedSession || latest.account_subject !== expectedSubject)) {
475
+ throw new GbcError("auth_revoked", "terminal response belongs to an old session; new login preserved", { exitCode: 4, details: { revoked: true } });
476
+ }
477
+ if (latest &&
478
+ latest.revision !== undefined &&
479
+ latest.revision > expectedRevision &&
480
+ latest.access_token &&
481
+ latest.access_token !== cur.access_token) {
482
+ const peerExp = classifyExpiry(latest.expires_at_ms, now());
483
+ if (peerExp.stillValid) {
484
+ clearAmbiguousIf(expectedSession, refreshToken);
485
+ return metaFromCred(latest, false, "peer_refreshed", "shouldLogout ignored; newer local pair rescued under lock");
486
+ }
487
+ }
488
+ if (!latest || latest.session_id !== expectedSession || (latest.revision ?? 0) !== expectedRevision) {
489
+ throw new GbcError("auth_revoked", "stale refresh cannot revoke changed session", { exitCode: 4, details: { revoked: true } });
490
+ }
491
+ // After a caller-visible timeout the IdP may already have rotated; a follow-up
492
+ // invalid_grant must not wipe credentials while the ambiguous window is open.
493
+ if (body.error === "invalid_grant" && isAmbiguousProtect(expectedSession, refreshToken)) {
494
+ throw new GbcError("auth_refresh_timeout", "invalid_grant after ambiguous refresh timeout; credentials kept pending background/reconcile", {
495
+ exitCode: 8,
496
+ retryable: true,
497
+ outcome: "unknown",
498
+ details: { rpc_dispatched: "unknown", can_fallback: classifyExpiry(latest.expires_at_ms, now()).stillValid, revoked: false },
499
+ });
500
+ }
501
+ store.clear(kind, backend);
502
+ clearAmbiguousIf(expectedSession, refreshToken);
503
+ if (body.error === POLICY_VIOLATION) {
504
+ throw new GbcError("denied", "session revoked by organization policy; local credential removed", {
505
+ exitCode: 7,
506
+ details: { rpc_dispatched: true, revoked: true },
507
+ });
508
+ }
509
+ throw new GbcError("auth_revoked", "server requested logout; local credential removed. Log in again.", {
510
+ exitCode: 4,
511
+ details: { rpc_dispatched: true, revoked: true },
512
+ });
513
+ }
514
+ if (typeof body.access_token !== "string" || body.access_token.trim().length === 0) {
515
+ throw new GbcError("auth_refresh_malformed", "token refresh response had no access_token; credentials kept", {
516
+ exitCode: 4,
517
+ details: { rpc_dispatched: true, can_fallback: classifyExpiry(cur.expires_at_ms, now()).stillValid },
518
+ });
519
+ }
520
+ if (body.refresh_token !== undefined && typeof body.refresh_token !== "string") {
521
+ throw new GbcError("auth_refresh_malformed", "token refresh response had invalid refresh_token; credentials kept", {
522
+ exitCode: 4,
523
+ details: { rpc_dispatched: true, can_fallback: classifyExpiry(cur.expires_at_ms, now()).stillValid },
524
+ });
525
+ }
526
+ // P3-3: refuse to persist an access token whose JWT sub differs from the expected account.
527
+ const newSub = decodeJwtPayload(body.access_token)?.sub;
528
+ if (expectedSubject && typeof newSub === "string" && newSub.length > 0 && newSub !== expectedSubject) {
529
+ throw new GbcError("auth_refresh_subject_mismatch", "refresh returned an access token for a different account subject; credentials kept", {
530
+ exitCode: 4,
531
+ details: { rpc_dispatched: true, can_fallback: classifyExpiry(cur.expires_at_ms, now()).stillValid },
532
+ });
533
+ }
534
+ const latest = store.load(kind, backend);
535
+ if (!latest) {
536
+ throw new GbcError("auth_revoked", "credential disappeared during refresh (logout won); not saving rotated pair", {
537
+ exitCode: 4,
538
+ details: { rpc_dispatched: true, revoked: true },
539
+ });
540
+ }
541
+ if (latest.session_id !== expectedSession || latest.account_subject !== expectedSubject) {
542
+ throw new GbcError("auth_revoked", "login/account changed during refresh; retry explicitly", { exitCode: 4, details: { revoked: true } });
543
+ }
544
+ if ((latest.revision ?? 0) !== expectedRevision) {
545
+ clearAmbiguousIf(expectedSession, refreshToken);
546
+ return metaFromCred(latest, false, "peer_refreshed", "revision changed during refresh; kept newer credential");
547
+ }
548
+ if (expectedSession && latest.session_id && latest.session_id !== expectedSession) {
549
+ return metaFromCred(latest, false, "peer_refreshed", "session_id changed during refresh; kept newer login");
550
+ }
551
+ if (expectedSubject && latest.account_subject && latest.account_subject !== expectedSubject) {
552
+ return metaFromCred(latest, false, "peer_refreshed", "account subject changed during refresh; kept newer login");
553
+ }
554
+ const next = credFromTokens(body.access_token, body.refresh_token, latest, persist && !latest.client_id ? "migrated" : "refresh", latest.login_kind ?? "browser_deep_control", latest.session_id, persist ? clientId : latest.client_id ?? clientId);
555
+ if (!store.saveIfRevision(next, expectedRevision)) {
556
+ const again = store.load(kind, backend);
557
+ if (again)
558
+ return metaFromCred(again, false, "peer_refreshed", "CAS lost during save; kept on-disk credential");
559
+ throw new GbcError("auth_revoked", "credential cleared during refresh save", { exitCode: 4, details: { revoked: true } });
560
+ }
561
+ clearCooldown(expectedSession);
562
+ clearAmbiguousIf(expectedSession, refreshToken);
563
+ return metaFromCred(next, true, decision.reason);
564
+ });
565
+ }
566
+ function raceCallerDeadline(exchange, baseline) {
567
+ let timer;
568
+ const timeout = new Promise((_, reject) => {
569
+ timer = setTimeout(() => {
570
+ const exp = classifyExpiry(baseline?.expires_at_ms, now());
571
+ if (baseline?.refresh_token)
572
+ markAmbiguous(baseline.session_id, baseline.refresh_token);
573
+ reject(new GbcError("auth_refresh_timeout", "token refresh timed out (20s); exchange continues in background; credentials kept", {
574
+ exitCode: 8,
575
+ retryable: true,
576
+ outcome: "unknown",
577
+ details: {
578
+ rpc_dispatched: "unknown",
579
+ can_fallback: exp.stillValid,
580
+ revision: baseline?.revision ?? 0,
581
+ },
582
+ }));
583
+ }, refreshDeadlineMs);
584
+ });
585
+ return Promise.race([exchange, timeout]).finally(() => clearTimeout(timer));
586
+ }
587
+ function refreshSingleflight(force, signal, rejectedAccessToken) {
588
+ const baseline = store.load(kind, backend);
589
+ const startExchange = (forceFlag, rejected) => {
590
+ if (inflight)
591
+ return inflight;
592
+ const entryBaseline = store.load(kind, backend);
593
+ const p = withCredentialLock(`singleflight|${lockKey}`, async () => {
594
+ const latest = store.load(kind, backend);
595
+ if (!latest || entryBaseline?.session_id !== latest.session_id) {
596
+ throw new GbcError("auth_revoked", "session changed while waiting for refresh; retry explicitly", {
597
+ exitCode: 4,
598
+ details: { revoked: true },
599
+ });
600
+ }
601
+ if (rejected && latest.access_token && latest.access_token !== rejected) {
602
+ return metaFromCred(latest, false, "peer_refreshed", "rejected access token already replaced while waiting");
603
+ }
604
+ if ((latest.revision ?? 0) !== (entryBaseline?.revision ?? 0) &&
605
+ classifyExpiry(latest.expires_at_ms, now()).stillValid) {
606
+ return metaFromCred(latest, false, "peer_refreshed");
607
+ }
608
+ return doRefreshLocked(forceFlag, rejected);
609
+ }, { waitMs: 30000, signal }).finally(() => {
610
+ if (inflight === p)
611
+ inflight = undefined;
612
+ });
613
+ inflight = p;
614
+ // Callers that timed out via raceCallerDeadline must not leave an unhandled rejection.
615
+ void p.catch(() => { });
616
+ return p;
617
+ };
618
+ const run = async () => {
619
+ if (inflight) {
620
+ const joined = await raceCallerDeadline(inflight, baseline);
621
+ if (!force)
622
+ return joined;
623
+ const after = store.load(kind, backend);
624
+ if (rejectedAccessToken && after?.access_token && after.access_token !== rejectedAccessToken) {
625
+ return metaFromCred(after, false, "peer_refreshed", "rejected token replaced by in-flight refresh");
626
+ }
627
+ if (joined.refreshed)
628
+ return joined;
629
+ if (joined.reason === "already_fresh") {
630
+ // Force must not reuse a non-force already_fresh no-op (Grok Build semantics).
631
+ return raceCallerDeadline(startExchange(true, rejectedAccessToken), after ?? baseline);
632
+ }
633
+ return joined;
634
+ }
635
+ return raceCallerDeadline(startExchange(force, rejectedAccessToken), baseline);
636
+ };
637
+ return run();
638
+ }
639
+ return {
640
+ kind,
641
+ backend,
642
+ async getAccessToken(getOpts) {
643
+ const force = Boolean(getOpts?.force);
644
+ const c = store.load(kind, backend);
645
+ if (!c)
646
+ return undefined;
647
+ const decision = refreshDecision(c, force);
648
+ if (!decision.needs)
649
+ return c.access_token;
650
+ // P2-1 / 04c1893: without client_id, do not fail still-valid tokens just because refresh is unconfigured.
651
+ if (!force && !c.client_id && !configuredClientId()) {
652
+ const exp = classifyExpiry(c.expires_at_ms, now());
653
+ if (exp.stillValid)
654
+ return c.access_token;
655
+ if (!exp.status.ok && typeof c.access_token === "string" && c.access_token.trim().length > 0) {
656
+ // Missing/illegal exp and no way to refresh: keep using the opaque bearer (logged via note path only).
657
+ return c.access_token;
658
+ }
659
+ }
660
+ try {
661
+ await refreshSingleflight(force);
662
+ const after = store.load(kind, backend);
663
+ return after?.access_token;
664
+ }
665
+ catch (e) {
666
+ if (!(e instanceof GbcError))
667
+ throw e;
668
+ const details = (e.details ?? {});
669
+ if (details.revoked || e.code === "auth_revoked" || e.code === "denied") {
670
+ // Never fall back to a revoked token.
671
+ throw e;
672
+ }
673
+ const again = store.load(kind, backend);
674
+ if (!again)
675
+ throw e;
676
+ // If peer saved a new pair under lock, use it.
677
+ if ((again.revision ?? 0) > (c.revision ?? 0) && again.access_token) {
678
+ const peerExp = classifyExpiry(again.expires_at_ms, now());
679
+ if (peerExp.stillValid || !peerExp.needsRefresh)
680
+ return again.access_token;
681
+ }
682
+ const exp = classifyExpiry(again.expires_at_ms, now());
683
+ const sameRev = (again.revision ?? 0) === (c.revision ?? 0);
684
+ const fallbackCodes = new Set([
685
+ "auth_refresh_network",
686
+ "auth_refresh_timeout",
687
+ "auth_refresh_failed",
688
+ "auth_refresh_rate_limited",
689
+ "auth_refresh_cooldown",
690
+ "auth_refresh_unconfigured",
691
+ "auth_refresh_malformed",
692
+ "auth_refresh_redirect",
693
+ "auth_refresh_client_mismatch",
694
+ "auth_refresh_subject_mismatch",
695
+ ]);
696
+ // Config / response anomalies are not retryable but must still fall back when the
697
+ // access token is unexpired (regression vs requiring e.retryable).
698
+ if (fallbackCodes.has(e.code) && details.can_fallback !== false && sameRev) {
699
+ if (exp.stillValid)
700
+ return again.access_token;
701
+ const configCodes = new Set([
702
+ "auth_refresh_unconfigured",
703
+ "auth_refresh_client_mismatch",
704
+ "auth_refresh_malformed",
705
+ "auth_refresh_redirect",
706
+ "auth_refresh_subject_mismatch",
707
+ ]);
708
+ if (configCodes.has(e.code) &&
709
+ !exp.status.ok &&
710
+ typeof again.access_token === "string" &&
711
+ again.access_token.trim().length > 0) {
712
+ return again.access_token;
713
+ }
714
+ }
715
+ throw e;
716
+ }
717
+ },
718
+ async beginLogin() {
719
+ requireCursor("login");
720
+ const { verifier, challenge, uuid } = generateLoginChallenge();
721
+ const created = now();
722
+ const pending = {
723
+ v: 1,
724
+ kind,
725
+ backend,
726
+ website,
727
+ uuid,
728
+ verifier,
729
+ redirect_target: redirectTarget,
730
+ created_at_ms: created,
731
+ expires_at_ms: created + PENDING_TTL_MS,
732
+ };
733
+ store.savePending(pending);
734
+ return {
735
+ authorization_url: buildLoginUrl(website, { challenge, uuid, redirectTarget }),
736
+ uuid,
737
+ expires_at_ms: pending.expires_at_ms,
738
+ note: "Open the URL in a browser and sign in, then run `gbc auth complete`. The verifier is stored only in a 0600 pending file and is never printed.",
739
+ };
740
+ },
741
+ async pollLogin() {
742
+ requireCursor("poll");
743
+ const p = loadPendingOrThrow();
744
+ const step = await pollOnceRaw(p);
745
+ if (step.done)
746
+ return step.result;
747
+ if (step.transient) {
748
+ throw new GbcError("auth_poll_failed", `login poll failed${step.status ? ` HTTP ${step.status}` : " (network)"}`, {
749
+ exitCode: 8,
750
+ retryable: true,
751
+ });
752
+ }
753
+ return { status: "pending", expires_at_ms: p.expires_at_ms, note: "Not signed in yet (404 pending)." };
754
+ },
755
+ async completeLogin(o) {
756
+ requireCursor("complete");
757
+ const p = loadPendingOrThrow();
758
+ const deadline = Math.min(p.expires_at_ms, now() + (o?.timeoutMs ?? 300_000));
759
+ let errors = 0;
760
+ for (let i = 0; i < POLL_MAX_TRIES; i++) {
761
+ if (o?.signal?.aborted)
762
+ throw new GbcError("canceled", "login polling canceled (pending login kept)", { exitCode: 10, outcome: "unknown" });
763
+ let step;
764
+ try {
765
+ step = await pollOnceRaw(p, o?.signal);
766
+ }
767
+ catch (e) {
768
+ if (e instanceof GbcError)
769
+ throw e;
770
+ throw new GbcError("auth_poll_failed", scrub(String(e), [p.verifier]), { exitCode: 8, retryable: true });
771
+ }
772
+ if (step.done)
773
+ return step.result;
774
+ if (step.transient) {
775
+ errors++;
776
+ if (errors >= POLL_MAX_CONSECUTIVE_ERRORS) {
777
+ throw new GbcError("auth_poll_failed", `login poll failed ${errors} times in a row${step.status ? ` (last HTTP ${step.status})` : ""}; pending login kept`, {
778
+ exitCode: 8,
779
+ retryable: true,
780
+ });
781
+ }
782
+ }
783
+ else
784
+ errors = 0;
785
+ const wait = Math.min(POLL_BASE_MS * POLL_FACTOR ** i, POLL_CAP_MS);
786
+ if (now() + wait > deadline)
787
+ break;
788
+ await sleep(wait, o?.signal);
789
+ }
790
+ if (p.expires_at_ms <= now())
791
+ store.clearPending(kind, backend);
792
+ return { status: "pending", expires_at_ms: p.expires_at_ms, note: "Timed out waiting for browser sign-in; run `gbc auth complete` again." };
793
+ },
794
+ async cancelLogin() {
795
+ return { had_pending: store.clearPending(kind, backend) };
796
+ },
797
+ refresh: (o) => refreshSingleflight(Boolean(o?.force), o?.signal, o?.rejectedAccessToken),
798
+ async logout() {
799
+ return withCredentialLock(lockKey, async () => ({
800
+ removed_credential: store.clear(kind, backend),
801
+ removed_pending: store.clearPending(kind, backend),
802
+ }));
803
+ },
804
+ async whoamiLocal() {
805
+ const c = store.load(kind, backend);
806
+ let pending = false;
807
+ try {
808
+ const p = store.loadPending(kind, backend);
809
+ pending = Boolean(p && p.expires_at_ms > now());
810
+ }
811
+ catch {
812
+ pending = false;
813
+ }
814
+ return {
815
+ kind,
816
+ backend,
817
+ account_label: c?.account_label,
818
+ has_token: Boolean(c?.access_token),
819
+ expires_at_ms: c?.expires_at_ms,
820
+ refresh_configured: Boolean(configuredClientId() || c?.client_id),
821
+ pending_login: pending,
822
+ revision: c?.revision,
823
+ session_id: c?.session_id,
824
+ credential_source: c?.credential_source,
825
+ login_kind: c?.login_kind,
826
+ client_id_bound: Boolean(c?.client_id),
827
+ refresh_endpoint: c?.refresh_endpoint,
828
+ disk_refresh: true,
829
+ static_token: false,
830
+ };
831
+ },
832
+ };
833
+ }
834
+ /** Injected token provider for tests / mock — never logs the token; never disk-refreshes. */
835
+ export function staticTokenProvider(opts) {
836
+ const unsupported = async () => {
837
+ throw new GbcError("unsupported", "static/mock token provider has no login/refresh flow (no disk refresh)", { exitCode: 9 });
838
+ };
839
+ return {
840
+ kind: opts.kind,
841
+ backend: opts.backend,
842
+ async getAccessToken(getOpts) {
843
+ if (getOpts?.force) {
844
+ throw new GbcError("unsupported", "static/mock token provider cannot force-refresh; re-provide the token or log in", {
845
+ exitCode: 9,
846
+ details: { rpc_dispatched: false, disk_refresh: false },
847
+ });
848
+ }
849
+ const jwt = decodeJwtPayload(opts.accessToken);
850
+ if (jwt && typeof jwt.exp === "number" && Number.isFinite(jwt.exp) && jwt.exp * 1000 <= Date.now()) {
851
+ throw new GbcError("auth", "static token expired; re-provide a token or log in (no disk refresh)", {
852
+ exitCode: 4,
853
+ details: { rpc_dispatched: false, disk_refresh: false },
854
+ });
855
+ }
856
+ return opts.accessToken;
857
+ },
858
+ beginLogin: unsupported,
859
+ pollLogin: unsupported,
860
+ completeLogin: unsupported,
861
+ async cancelLogin() {
862
+ return { had_pending: false };
863
+ },
864
+ refresh: unsupported,
865
+ async logout() {
866
+ return { removed_credential: false, removed_pending: false };
867
+ },
868
+ async whoamiLocal() {
869
+ return {
870
+ kind: opts.kind,
871
+ backend: opts.backend,
872
+ has_token: true,
873
+ account_label: "static",
874
+ refresh_configured: false,
875
+ pending_login: false,
876
+ client_id_bound: false,
877
+ disk_refresh: false,
878
+ static_token: true,
879
+ login_kind: "static",
880
+ credential_source: "static",
881
+ };
882
+ },
883
+ };
884
+ }