pullfrog 0.1.65 → 0.1.66

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.
@@ -22,3 +22,5 @@ export { createLeapingProgressComment, deleteProgressCommentApi, getProgressComm
22
22
  export type { RunStatusCheckConclusion, RunStatusCheckOctokit, } from "../utils/runStatusCheck.ts";
23
23
  export { APPROVAL_CHECK_NAME, createRunStatusCheck, finalizeRunStatusCheck, RUN_STATUS_CHECK_NAME, runStatusCheckNeedsFinalizing, } from "../utils/runStatusCheck.ts";
24
24
  export { isValidTimeString, parseTimeString, TIMEOUT_DISABLED, } from "../utils/time.ts";
25
+ export type { XaiAuthBody } from "../utils/xaiOAuth.ts";
26
+ export { parseXaiAuthBody, refreshXaiAuthBody, stringifyXaiAuthBody, } from "../utils/xaiOAuth.ts";
package/dist/internal.js CHANGED
@@ -221,6 +221,11 @@ var providers = {
221
221
  xai: provider({
222
222
  displayName: "xAI",
223
223
  envVars: ["XAI_API_KEY"],
224
+ // CLI-only, like CODEX_AUTH_JSON: a Grok subscription chain minted by
225
+ // `pullfrog auth grok`. Excluded from every paste/prompt surface because
226
+ // the refresh token rotates on every use, so a hand-pasted blob is stale
227
+ // the moment it is saved. See wiki/grok-auth.md.
228
+ managedCredentials: ["GROK_AUTH_JSON"],
224
229
  models: {
225
230
  grok: {
226
231
  displayName: "Grok",
@@ -464,7 +469,13 @@ var providers = {
464
469
  },
465
470
  "kimi-k2": {
466
471
  displayName: "Kimi K2",
467
- resolve: "opencode/kimi-k2.7-code",
472
+ // k2.7-code is UNDEPLOYED on Zen — still listed in /zen/v1/models, but
473
+ // the endpoint answers 400 `[NOT_FOUND] Model not found, inaccessible,
474
+ // and/or not deployed` (measured 2026-08-28; k2.6 200 on the same key).
475
+ // opencode-go is no escape: its own kimi-k2 resolves to the SAME model
476
+ // id. k2.6 is the newest build Zen will actually serve, and the mirror
477
+ // guard permits the step down because k2.7-code is in ZEN_UNDEPLOYED.
478
+ resolve: "opencode/kimi-k2.6",
468
479
  openRouterResolve: "openrouter/moonshotai/kimi-k2.7-code"
469
480
  },
470
481
  // slug pins the m2 line for DB stability; resolve tracks the current m2.7.
@@ -1541,17 +1552,48 @@ function stripExistingFooter(body) {
1541
1552
  return body.substring(0, dividerIndex).trimEnd();
1542
1553
  }
1543
1554
 
1544
- // utils/codexOAuth.ts
1545
- var CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
1546
- var CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
1555
+ // utils/oauthShared.ts
1547
1556
  var OAuthInvalidGrantError = class extends Error {
1548
1557
  status;
1549
- constructor(status, body) {
1550
- super(`Codex token refresh failed: ${status} ${body}`);
1558
+ /** whether the provider said this refresh chain is permanently unusable, so
1559
+ * the rotation core may latch it out of use until the user re-mints. */
1560
+ chainIsDead;
1561
+ constructor(provider2, status, body, chainIsDead) {
1562
+ super(`${provider2} token refresh failed: ${status} ${body}`);
1551
1563
  this.name = "OAuthInvalidGrantError";
1552
1564
  this.status = status;
1565
+ this.chainIsDead = chainIsDead;
1553
1566
  }
1554
1567
  };
1568
+ function parseOAuthErrorBody(body) {
1569
+ try {
1570
+ const parsed = JSON.parse(body);
1571
+ if (parsed && typeof parsed === "object") return parsed;
1572
+ } catch {
1573
+ }
1574
+ return null;
1575
+ }
1576
+ function decodeJwtExpMs(token) {
1577
+ const parts = token.split(".");
1578
+ if (parts.length !== 3) return null;
1579
+ let payload;
1580
+ try {
1581
+ payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
1582
+ } catch {
1583
+ return null;
1584
+ }
1585
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) return null;
1586
+ return payload.exp * 1e3;
1587
+ }
1588
+
1589
+ // utils/codexOAuth.ts
1590
+ var CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
1591
+ var CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
1592
+ function codexChainIsDead(body) {
1593
+ const err = parseOAuthErrorBody(body)?.error;
1594
+ if (!err || typeof err !== "object") return false;
1595
+ return "code" in err && err.code === "token_expired";
1596
+ }
1555
1597
  async function refreshCodexAuthBody(body) {
1556
1598
  const response = await fetch(CODEX_OAUTH_TOKEN_URL, {
1557
1599
  method: "POST",
@@ -1566,7 +1608,7 @@ async function refreshCodexAuthBody(body) {
1566
1608
  if (!response.ok) {
1567
1609
  const text = await response.text().catch(() => "");
1568
1610
  if (response.status >= 400 && response.status < 500) {
1569
- throw new OAuthInvalidGrantError(response.status, text);
1611
+ throw new OAuthInvalidGrantError("Codex", response.status, text, codexChainIsDead(text));
1570
1612
  }
1571
1613
  throw new Error(`Codex token refresh failed: ${response.status} ${text}`);
1572
1614
  }
@@ -1584,18 +1626,6 @@ async function refreshCodexAuthBody(body) {
1584
1626
  last_refresh: (/* @__PURE__ */ new Date()).toISOString()
1585
1627
  };
1586
1628
  }
1587
- function decodeJwtExpMs(token) {
1588
- const parts = token.split(".");
1589
- if (parts.length !== 3) return null;
1590
- let payload;
1591
- try {
1592
- payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
1593
- } catch {
1594
- return null;
1595
- }
1596
- if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) return null;
1597
- return payload.exp * 1e3;
1598
- }
1599
1629
  function parseCodexAuthBody(raw) {
1600
1630
  let parsed;
1601
1631
  try {
@@ -1869,7 +1899,12 @@ function disableCheckLine(owner, repo) {
1869
1899
  const url = `https://pullfrog.com/console/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}#auto-review-prs`;
1870
1900
  return `
1871
1901
 
1872
- [Turn off this check \u2192](${url}) \u2014 it reports run status only and gates nothing unless you required it in branch protection.`;
1902
+ This check reports run status only and gates nothing unless you required it in branch protection. [Turn off the run status check \u2192](${url})`;
1903
+ }
1904
+ function logsLine(detailsUrl) {
1905
+ return detailsUrl ? `
1906
+
1907
+ [View the run logs \u2192](${detailsUrl})` : "";
1873
1908
  }
1874
1909
  var TERMINAL_OUTPUT = {
1875
1910
  success: {
@@ -1878,7 +1913,7 @@ var TERMINAL_OUTPUT = {
1878
1913
  },
1879
1914
  failure: {
1880
1915
  title: "Pullfrog run failed",
1881
- summary: "The Pullfrog run failed. See the run logs for details."
1916
+ summary: "The Pullfrog run failed."
1882
1917
  },
1883
1918
  cancelled: {
1884
1919
  title: "Pullfrog run cancelled",
@@ -1886,11 +1921,11 @@ var TERMINAL_OUTPUT = {
1886
1921
  },
1887
1922
  timed_out: {
1888
1923
  title: "Pullfrog run timed out",
1889
- summary: "The Pullfrog run exceeded its timeout. See the run logs for details."
1924
+ summary: "The Pullfrog run exceeded its timeout."
1890
1925
  },
1891
1926
  action_required: {
1892
1927
  title: "Pullfrog run needs attention",
1893
- summary: "The Pullfrog run stopped and needs attention. See the run logs for details."
1928
+ summary: "The Pullfrog run stopped and needs attention."
1894
1929
  },
1895
1930
  neutral: {
1896
1931
  title: "Pullfrog run finished",
@@ -1906,8 +1941,11 @@ function terminalOutput(params) {
1906
1941
  const review = params.reviewUrl ? `
1907
1942
 
1908
1943
  [View the review Pullfrog posted \u2192](${params.reviewUrl})` : "";
1909
- const disable = params.conclusion === "success" ? "" : disableCheckLine(params.owner, params.repo);
1910
- return { title: base.title, summary: base.summary + review + disable };
1944
+ if (params.conclusion === "success") return { title: base.title, summary: base.summary + review };
1945
+ return {
1946
+ title: base.title,
1947
+ summary: base.summary + logsLine(params.detailsUrl) + review + disableCheckLine(params.owner, params.repo)
1948
+ };
1911
1949
  }
1912
1950
  async function createRunStatusCheck(params) {
1913
1951
  const existing = await params.octokit.rest.checks.listForRef({
@@ -1944,6 +1982,7 @@ async function finalizeRunStatusCheck(params) {
1944
1982
  conclusion: params.conclusion,
1945
1983
  owner: params.owner,
1946
1984
  repo: params.repo,
1985
+ detailsUrl: params.detailsUrl,
1947
1986
  reviewUrl: params.reviewUrl
1948
1987
  })
1949
1988
  };
@@ -1977,6 +2016,69 @@ function parseTimeString(input) {
1977
2016
  function isValidTimeString(input) {
1978
2017
  return parseTimeString(input) !== null;
1979
2018
  }
2019
+
2020
+ // utils/xaiOAuth.ts
2021
+ var XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
2022
+ var XAI_OAUTH_TOKEN_URL = "https://auth.x.ai/oauth2/token";
2023
+ function xaiChainIsDead(body) {
2024
+ return parseOAuthErrorBody(body)?.error === "invalid_grant";
2025
+ }
2026
+ async function refreshXaiAuthBody(body) {
2027
+ const response = await fetch(XAI_OAUTH_TOKEN_URL, {
2028
+ method: "POST",
2029
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2030
+ body: new URLSearchParams({
2031
+ grant_type: "refresh_token",
2032
+ refresh_token: body.tokens.refresh_token,
2033
+ client_id: XAI_OAUTH_CLIENT_ID
2034
+ }).toString(),
2035
+ signal: AbortSignal.timeout(1e4)
2036
+ });
2037
+ if (!response.ok) {
2038
+ const text = await response.text().catch(() => "");
2039
+ if (response.status >= 400 && response.status < 500) {
2040
+ throw new OAuthInvalidGrantError("Grok", response.status, text, xaiChainIsDead(text));
2041
+ }
2042
+ throw new Error(`Grok token refresh failed: ${response.status} ${text}`);
2043
+ }
2044
+ const tokens = await response.json();
2045
+ return {
2046
+ auth_mode: "grok",
2047
+ tokens: {
2048
+ access_token: tokens.access_token,
2049
+ // xAI rotates on every use, but tolerate a server that echoes nothing
2050
+ // rather than writing an empty refresh token that bricks the chain.
2051
+ refresh_token: tokens.refresh_token || body.tokens.refresh_token
2052
+ },
2053
+ last_refresh: (/* @__PURE__ */ new Date()).toISOString()
2054
+ };
2055
+ }
2056
+ function parseXaiAuthBody(raw) {
2057
+ let parsed;
2058
+ try {
2059
+ parsed = JSON.parse(raw);
2060
+ } catch {
2061
+ return null;
2062
+ }
2063
+ if (!parsed || typeof parsed !== "object") return null;
2064
+ const v = parsed;
2065
+ if (v.auth_mode !== "grok") return null;
2066
+ const tokens = v.tokens;
2067
+ if (!tokens || typeof tokens !== "object") return null;
2068
+ const t = tokens;
2069
+ if (typeof t.access_token !== "string" || t.access_token.length === 0) return null;
2070
+ if (typeof t.refresh_token !== "string" || t.refresh_token.length === 0) return null;
2071
+ return {
2072
+ auth_mode: "grok",
2073
+ ...typeof v.refresh_rejected_at === "string" ? { refresh_rejected_at: v.refresh_rejected_at } : {},
2074
+ tokens: { access_token: t.access_token, refresh_token: t.refresh_token },
2075
+ ...typeof v.last_refresh === "string" ? { last_refresh: v.last_refresh } : {}
2076
+ };
2077
+ }
2078
+ function stringifyXaiAuthBody(body) {
2079
+ return `${JSON.stringify(body, null, 2)}
2080
+ `;
2081
+ }
1980
2082
  export {
1981
2083
  APPROVAL_CHECK_NAME,
1982
2084
  AUTO_EFFICIENT,
@@ -2024,9 +2126,11 @@ export {
2024
2126
  parseEffortPosition,
2025
2127
  parseModel,
2026
2128
  parseTimeString,
2129
+ parseXaiAuthBody,
2027
2130
  providers,
2028
2131
  pullfrogMcpName,
2029
2132
  refreshCodexAuthBody,
2133
+ refreshXaiAuthBody,
2030
2134
  resolveAutoTier,
2031
2135
  resolveCliModel,
2032
2136
  resolveDisplayAlias,
@@ -2038,6 +2142,7 @@ export {
2038
2142
  rungLabel,
2039
2143
  rungPosition,
2040
2144
  stringifyCodexAuthBody,
2145
+ stringifyXaiAuthBody,
2041
2146
  stripExistingFooter,
2042
2147
  truncateAtLineBoundary,
2043
2148
  updateProgressComment,
@@ -33,6 +33,27 @@ export interface InstalledCodexAuth {
33
33
  * `process.env.XDG_DATA_HOME` so every opencode subprocess discovers the
34
34
  * auth.json; no refresh, no DB interaction. */
35
35
  export declare function installCodexAuth(): InstalledCodexAuth | null;
36
+ export interface InstalledXaiAuth {
37
+ /** absolute path of the auth.json we wrote — the post-hook diffs it. */
38
+ authPath: string;
39
+ /** value to set as XDG_DATA_HOME for the OpenCode subprocess. */
40
+ xdgDataHome: string;
41
+ /** refresh_token at materialization time. opencode's XaiAuthPlugin rotates
42
+ * in-process on a long run, so the post-hook compares against this to decide
43
+ * whether anything needs writing back. */
44
+ originalRefresh: string;
45
+ }
46
+ /** materialize GROK_AUTH_JSON from env into opencode's auth.json.
47
+ *
48
+ * opencode ships xAI Grok OAuth natively (`XaiAuthPlugin`, added upstream
49
+ * 2026-05-21, present in our pinned 1.18.5) against the same public
50
+ * Grok-CLI OAuth client we mint with, so the stored chain drops straight in.
51
+ * The plugin sends the token to `api.x.ai/v1` — it deliberately sets no
52
+ * baseURL — so there is no CLI proxy and no client-version header in play.
53
+ *
54
+ * returns null when the env var is absent or malformed; caller treats null as
55
+ * "no grok subscription auth, fall through to XAI_API_KEY". */
56
+ export declare function installXaiAuth(): InstalledXaiAuth | null;
36
57
  export interface InstalledCodexHome {
37
58
  /** value to set as CODEX_HOME for the codex subprocess. holds auth.json,
38
59
  * config.toml and the session rollouts the resume path reads. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure-stdlib (fetch + Buffer) Codex OAuth refresh + JWT exp decoding.
2
+ * Pure-stdlib (fetch) Codex OAuth refresh.
3
3
  *
4
4
  * Lives here (not in codexAuth.ts) so the Next.js server side can import it
5
5
  * via pullfrog/internal without dragging in node:child_process / spawn /
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * See wiki/codex-auth.md for the end-to-end refresh lifecycle.
11
11
  */
12
+ import { decodeJwtExpMs, OAuthInvalidGrantError } from "./oauthShared.ts";
13
+ export { decodeJwtExpMs, OAuthInvalidGrantError };
12
14
  export interface CodexAuthBody {
13
15
  auth_mode: "chatgpt";
14
16
  tokens: {
@@ -19,8 +21,10 @@ export interface CodexAuthBody {
19
21
  };
20
22
  last_refresh?: string;
21
23
  /**
22
- * ISO timestamp of an `invalid_grant` rejection. OpenAI rotates the refresh
23
- * token on every use, so a rejection is PERMANENT — without a latch the
24
+ * ISO timestamp of a rejection OpenAI attributed to the token itself
25
+ * (`error.code: "token_expired"` it does not emit RFC 6749's
26
+ * `invalid_grant` here). OpenAI rotates the refresh
27
+ * token on every use, so such a rejection is PERMANENT — without a latch the
24
28
  * server re-issued the identical doomed refresh on every run (455 futile
25
29
  * round trips in 7 days, one per run, each holding a Postgres row lock across
26
30
  * a 10s external call). Cleared implicitly: `pullfrog auth codex` and
@@ -35,13 +39,6 @@ export interface CodexAuthBody {
35
39
  * refreshed against this client_id. */
36
40
  export declare const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
37
41
  export declare const CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
38
- /** thrown when the OAuth provider rejects the refresh token (4xx). callers
39
- * can distinguish "race-lost / token revoked" from network errors via
40
- * `instanceof OAuthInvalidGrantError`. */
41
- export declare class OAuthInvalidGrantError extends Error {
42
- readonly status: number;
43
- constructor(status: number, body: string);
44
- }
45
42
  /** force one refresh round-trip against the OAuth provider. returns the
46
43
  * rotated Codex-shaped blob (the auth.json body verbatim). does NOT persist
47
44
  * — caller is responsible for writing back to wherever the token lives.
@@ -54,16 +51,6 @@ export declare class OAuthInvalidGrantError extends Error {
54
51
  * always releases and queued callers get a turn instead of timing out on
55
52
  * the tx wrapper. Real OAuth latency is sub-second; 10s is generous. */
56
53
  export declare function refreshCodexAuthBody(body: CodexAuthBody): Promise<CodexAuthBody>;
57
- /** decode the access_token's JWT payload and return its `exp` claim in ms
58
- * since epoch. returns null if the token isn't a parseable JWT or has no
59
- * `exp` claim — caller falls back to "treat as expired".
60
- *
61
- * We don't verify the JWT signature (we'd need OpenAI's JWKS); we're only
62
- * using the claim as a freshness hint. The actual auth check happens
63
- * server-side at OpenAI when the token is used — trusting a fake JWT here
64
- * would just delay the inevitable 401 from OpenAI. No security boundary
65
- * at this decode step. */
66
- export declare function decodeJwtExpMs(token: string): number | null;
67
54
  /** parse + validate a Codex auth.json body from its JSON-string form.
68
55
  * returns null on any shape mismatch — caller treats as "no codex auth". */
69
56
  export declare function parseCodexAuthBody(raw: string): CodexAuthBody | null;
@@ -0,0 +1,44 @@
1
+ /** One provider's post-run writeback plan, handed from the opencode harness to
2
+ * `entryPost.ts` via `core.saveState`. Type-only across that boundary, so it
3
+ * does not violate entryPost's stdlib-only import rule. */
4
+ export interface OAuthWriteback {
5
+ /** Pullfrog secret to PUT the rotated chain back into. */
6
+ secretName: "CODEX_AUTH_JSON" | "GROK_AUTH_JSON";
7
+ /** opencode auth.json provider key this chain lives under. */
8
+ provider: "openai" | "xai";
9
+ authPath: string;
10
+ originalRefresh: string;
11
+ /** codex only — opencode's auth entry has no slot for it. see below. */
12
+ originalIdToken?: string | undefined;
13
+ }
14
+ /** Detect a mid-run Codex OAuth rotation from an on-disk auth.json and render
15
+ * it in the Codex CLI shape the Pullfrog secret store holds. Returns null when
16
+ * the file carries no usable OAuth entry or the refresh token is unchanged from
17
+ * `originalRefresh`. Lives in its own module so `entryPost.ts` can import it
18
+ * without pulling in `codexHome.ts` (which imports `./cli.ts` and node fs
19
+ * helpers).
20
+ *
21
+ * Two on-disk shapes reach here, one per harness:
22
+ * - codex CLI — `{auth_mode, tokens: {id_token, access_token, refresh_token}}`,
23
+ * already the storage shape, so it round-trips verbatim.
24
+ * - opencode — `{openai: {type: "oauth", access, refresh, accountId}}`, which
25
+ * has no slot for `id_token`. The caller passes the pre-run value through
26
+ * `originalIdToken` so the rotated blob keeps it: the codex CLI REFUSES an
27
+ * auth.json without `tokens.id_token` (`missing field 'id_token'`), so
28
+ * dropping it here would silently disqualify the account from ever running
29
+ * on the codex harness. `id_token` is an identity claim that a refresh does
30
+ * not rotate — the server-side `refreshCodexAuthBody` carries the old one
31
+ * forward the same way. */
32
+ export declare function detectCodexRefresh(params: {
33
+ authFileContent: string;
34
+ originalRefresh: string;
35
+ originalIdToken?: string | undefined;
36
+ }): string | null;
37
+ /** Detect a mid-run Grok OAuth rotation and render it in the storage shape.
38
+ * Simpler than the Codex twin: opencode's `xai` entry carries the whole chain,
39
+ * there is no second on-disk shape to reconcile and no identity claim to
40
+ * re-attach. Returns null when the entry is missing or unrotated. */
41
+ export declare function detectXaiRefresh(params: {
42
+ authFileContent: string;
43
+ originalRefresh: string;
44
+ }): string | null;
@@ -42,6 +42,12 @@ export type GitHubAppPermissions = {
42
42
  };
43
43
  type AcquireTokenOptions = {
44
44
  repos?: string[] | undefined;
45
+ /**
46
+ * handle to the server-persisted cross-repo grant, required whenever `repos`
47
+ * names anything beyond the primary. the server bounds the request against
48
+ * the sets IT stored, so this cannot widen the token by itself.
49
+ */
50
+ xrepoGrant?: string | undefined;
45
51
  permissions?: GitHubAppPermissions;
46
52
  /**
47
53
  * stashed OIDC credentials for minting after restricted mode deletes
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Provider-agnostic OAuth primitives shared by the Codex and Grok chains.
3
+ * Pure stdlib (Buffer) so both the action runtime and the Next.js server can
4
+ * import it via pullfrog/internal.
5
+ *
6
+ * Both chains mint JWT access tokens whose `exp` we read as a freshness hint,
7
+ * and both rotate their refresh token on every use — so both need the same
8
+ * "the provider rejected this permanently" signal.
9
+ */
10
+ /**
11
+ * Thrown when an OAuth provider rejects a refresh token (4xx).
12
+ *
13
+ * `chainIsDead` is decided by the CALLER, because the two providers we talk to
14
+ * disagree about how to say it and only the caller knows its own dialect:
15
+ * xAI answers RFC 6749 (`{"error":"invalid_grant"}`) while OpenAI nests an
16
+ * object and discriminates on `error.code` (`token_expired`). A rejection we
17
+ * cannot classify — a CDN error page in front of the token endpoint, say — is
18
+ * NOT dead: latching there would retire every customer's working credential
19
+ * over a transient edge event.
20
+ */
21
+ export declare class OAuthInvalidGrantError extends Error {
22
+ readonly status: number;
23
+ /** whether the provider said this refresh chain is permanently unusable, so
24
+ * the rotation core may latch it out of use until the user re-mints. */
25
+ readonly chainIsDead: boolean;
26
+ constructor(provider: string, status: number, body: string, chainIsDead: boolean);
27
+ }
28
+ /** parse a token-endpoint error body, or null when it is not a JSON object.
29
+ * Each provider's refresh reads its own discriminator out of the result. */
30
+ export declare function parseOAuthErrorBody(body: string): Record<string, unknown> | null;
31
+ /** decode a JWT payload's `exp` claim and return it in ms since epoch.
32
+ * returns null if the token isn't a parseable JWT or has no `exp` claim —
33
+ * caller falls back to "treat as expired".
34
+ *
35
+ * We don't verify the signature (we'd need the issuer's JWKS); we're only
36
+ * using the claim as a freshness hint. The real auth check happens at the
37
+ * provider when the token is used, so trusting a forged JWT here would just
38
+ * delay the inevitable 401. No security boundary at this decode step. */
39
+ export declare function decodeJwtExpMs(token: string): number | null;
@@ -19,6 +19,7 @@ export declare const JsonPayload: import("arktype/internal/variants/object.ts").
19
19
  write: string[];
20
20
  unavailable?: string[];
21
21
  } | undefined;
22
+ xrepoGrant?: string | undefined;
22
23
  timeout?: string | undefined;
23
24
  progressComment?: {
24
25
  id: string;
@@ -66,6 +67,7 @@ export declare function resolvePayload(resolvedPromptInput: ResolvedPromptInput,
66
67
  write: string[];
67
68
  unavailable?: string[];
68
69
  } | undefined;
70
+ xrepoGrant: string | undefined;
69
71
  timeout: string | undefined;
70
72
  cwd: string | undefined;
71
73
  progressComment: {
@@ -29,6 +29,12 @@ type ResolveTokensParams = {
29
29
  push: PushPermission;
30
30
  authorPermission: AuthorPermission | undefined;
31
31
  xrepo?: XrepoConfig | undefined;
32
+ /**
33
+ * handle to the cross-repo grant the server persisted for this run. required
34
+ * alongside `xrepo`: the mint endpoint refuses a `repos` list without one,
35
+ * because the runner's list is attacker-controlled on a manual dispatch.
36
+ */
37
+ xrepoGrant?: string | undefined;
32
38
  /**
33
39
  * OIDC credentials stashed by main.ts before the restricted-mode env wipe —
34
40
  * the mid-run MCP token refresh mints from this snapshot (#891). null when
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pure-stdlib (fetch) xAI/Grok OAuth: device-code login + refresh.
3
+ *
4
+ * Lives here (not in a CLI module) so the Next.js server side can import it
5
+ * via pullfrog/internal without dragging in node:child_process. Used by:
6
+ * - action/commands/auth.ts (`pullfrog auth grok` device-code login)
7
+ * - action/utils/codexHome.ts (materialize into opencode's auth.json)
8
+ * - utils/xaiSecretRotation.ts (server-side rotation at run-context)
9
+ *
10
+ * We talk to xAI's OAuth endpoints directly rather than shelling out to the
11
+ * Grok CLI the way `auth codex` shells out to `codex login`. The Grok CLI is
12
+ * a curl|bash install that nothing else in the flow needs, and the device
13
+ * grant is ~60 lines of fetch — requiring a second CLI to obtain a
14
+ * credential opencode consumes natively would be gratuitous.
15
+ *
16
+ * See wiki/grok-auth.md.
17
+ */
18
+ export interface XaiAuthBody {
19
+ auth_mode: "grok";
20
+ tokens: {
21
+ access_token: string;
22
+ refresh_token: string;
23
+ };
24
+ last_refresh?: string;
25
+ /**
26
+ * ISO timestamp of an `invalid_grant` rejection. xAI rotates the refresh
27
+ * token on every use, so a rejection is PERMANENT until the user re-runs
28
+ * `pullfrog auth grok`. Without the latch the server re-issues the same
29
+ * doomed refresh on every run, each holding a Postgres row lock across an
30
+ * external call — the failure mode measured for Codex in
31
+ * [#1101](https://github.com/pullfrog/app/issues/1101). Cleared implicitly:
32
+ * `pullfrog auth grok` and `PUT /api/runtime/secret` write a fresh blob
33
+ * without it, which re-arms rotation.
34
+ */
35
+ refresh_rejected_at?: string;
36
+ }
37
+ /** Public Grok-CLI OAuth client. Identical to the `oidc_client_id` the Grok
38
+ * CLI writes into its own `~/.grok/auth.json` and to opencode's XaiAuthPlugin
39
+ * CLIENT_ID — one chain, so a token minted here refreshes anywhere. */
40
+ export declare const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
41
+ export declare const XAI_OAUTH_TOKEN_URL = "https://auth.x.ai/oauth2/token";
42
+ export declare const XAI_DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
43
+ export declare const XAI_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
44
+ /** `api:access` is the scope that lets the subscription token authorize
45
+ * api.x.ai — without it the credential logs in but cannot infer. */
46
+ export declare const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
47
+ /** force one refresh round-trip against xAI. returns the rotated blob; does
48
+ * NOT persist — the caller writes it back wherever the token lives.
49
+ *
50
+ * The 10s timeout matters server-side: `maybeRotateXaiSecret` holds a DB row
51
+ * lock across this call, so the cap keeps us inside the enclosing transaction
52
+ * budget and guarantees queued callers get a turn. Real latency is sub-second. */
53
+ export declare function refreshXaiAuthBody(body: XaiAuthBody): Promise<XaiAuthBody>;
54
+ /** parse + validate a stored Grok blob. returns null on any shape mismatch —
55
+ * caller treats null as "no grok auth". */
56
+ export declare function parseXaiAuthBody(raw: string): XaiAuthBody | null;
57
+ /** serialize to the canonical stored form. */
58
+ export declare function stringifyXaiAuthBody(body: XaiAuthBody): string;
59
+ export interface XaiDeviceCode {
60
+ deviceCode: string;
61
+ userCode: string;
62
+ /** pre-filled URL when xAI supplies one, else the bare verification URL. */
63
+ verificationUrl: string;
64
+ intervalMs: number;
65
+ expiresAtMs: number;
66
+ }
67
+ /** RFC 8628 step 1: ask xAI for a device code. The user approves in a browser
68
+ * on any device — no loopback callback server, so this works unchanged from a
69
+ * container, an SSH session, or a locked-down workstation. */
70
+ export declare function startXaiDeviceAuth(): Promise<XaiDeviceCode>;
71
+ /** RFC 8628 step 2: long-poll the token endpoint until the user approves.
72
+ * Honors the spec's `authorization_pending` / `slow_down` back-off. Resolves
73
+ * with the minted chain, or throws on denial / expiry. */
74
+ export declare function pollXaiDeviceAuth(device: XaiDeviceCode): Promise<XaiAuthBody>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pullfrog",
3
- "version": "0.1.65",
3
+ "version": "0.1.66",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "pullfrog": "dist/cli.mjs",