@runuai/host 0.9.113 → 0.9.114

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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Lane `xai-oauth` — Grok subscription sign-ins (`grok login --device-auth`).
3
+ *
4
+ * `auth.json` is keyed by `<issuer>::<client-id>`; each entry carries
5
+ * `key` (the bearer the CLI sends), `refresh_token`, `expires_at` (ISO),
6
+ * `oidc_issuer` and `oidc_client_id`. Refresh is a standard OIDC
7
+ * `grant_type=refresh_token` against `https://auth.x.ai/oauth2/token`
8
+ * (confirmed from the issuer's discovery document, 2026-09-09; refresh_token
9
+ * is among its supported grants).
10
+ *
11
+ * ASSUMPTION, recorded until observed on a live refresh: `key` is the OIDC
12
+ * access token, so a refresh replaces it with `access_token`. The lane never
13
+ * writes on an answer that lacks `access_token`, so a wrong assumption costs
14
+ * a failed refresh (retried, then the reactive path), never a corrupted
15
+ * sign-in.
16
+ */
17
+
18
+ import {
19
+ BROKER_REFRESH_PLACEHOLDER,
20
+ isTerminalOAuthError,
21
+ parseObjectFile,
22
+ type CredentialFiles,
23
+ type CredentialHeader,
24
+ type LaneHttp,
25
+ type RefreshOutcome,
26
+ type TokenLane,
27
+ } from "./types";
28
+
29
+ export const XAI_ISSUER = "https://auth.x.ai";
30
+ export const XAI_TOKEN_ENDPOINT = `${XAI_ISSUER}/oauth2/token`;
31
+ const HOUR_MS = 60 * 60 * 1000;
32
+
33
+ interface Entry {
34
+ key: string;
35
+ doc: Record<string, unknown>;
36
+ }
37
+
38
+ function entriesOf(files: CredentialFiles): { doc: Record<string, unknown>; entries: Entry[] } | null {
39
+ const doc = parseObjectFile(files["auth.json"]);
40
+ if (!doc) return null;
41
+ const entries: Entry[] = [];
42
+ for (const [key, value] of Object.entries(doc)) {
43
+ if (value && typeof value === "object" && !Array.isArray(value)) {
44
+ entries.push({ key, doc: value as Record<string, unknown> });
45
+ }
46
+ }
47
+ return { doc, entries };
48
+ }
49
+
50
+ function expiryOf(entry: Record<string, unknown>): number | null {
51
+ const raw = entry.expires_at;
52
+ if (typeof raw !== "string") return null;
53
+ const ms = Date.parse(raw);
54
+ return Number.isFinite(ms) ? ms : null;
55
+ }
56
+
57
+ export const xaiOAuthLane: TokenLane = {
58
+ id: "xai-oauth",
59
+ endpoints: [XAI_TOKEN_ENDPOINT],
60
+ // Observed 2026-09-09: the token lives SIX hours (create_time → expires_at).
61
+ // Refresh with three hours left — the hourly cycle then renews it at about
62
+ // four hours of age — and every four hours when expiry cannot be read.
63
+ schedule: { leadMs: 3 * HOUR_MS, everyMs: 4 * HOUR_MS },
64
+ reactiveSignals: [/invalid_grant/i, /token (?:has )?expired/i, /log ?in again/i, /unauthorized/i],
65
+
66
+ matches(header: CredentialHeader, files: CredentialFiles): boolean {
67
+ if (header.kind !== "grok" || header.authKind !== "config-dir") return false;
68
+ const parsed = entriesOf(files);
69
+ return (
70
+ parsed !== null && parsed.entries.some((e) => typeof e.doc.refresh_token === "string")
71
+ );
72
+ },
73
+
74
+ expiresAt(files: CredentialFiles): number | null {
75
+ const parsed = entriesOf(files);
76
+ if (!parsed) return null;
77
+ const known = parsed.entries.map((e) => expiryOf(e.doc)).filter((v): v is number => v !== null);
78
+ return known.length ? Math.min(...known) : null;
79
+ },
80
+
81
+ brokeredCopy(files: CredentialFiles): CredentialFiles {
82
+ const parsed = entriesOf(files);
83
+ if (!parsed) return files;
84
+ let changed = false;
85
+ for (const entry of parsed.entries) {
86
+ if (
87
+ typeof entry.doc.refresh_token === "string" &&
88
+ entry.doc.refresh_token !== BROKER_REFRESH_PLACEHOLDER
89
+ ) {
90
+ entry.doc.refresh_token = BROKER_REFRESH_PLACEHOLDER;
91
+ changed = true;
92
+ }
93
+ }
94
+ return changed ? { ...files, "auth.json": `${JSON.stringify(parsed.doc, null, 2)}\n` } : files;
95
+ },
96
+
97
+ async refresh(files: CredentialFiles, http: LaneHttp, now: number): Promise<RefreshOutcome> {
98
+ const parsed = entriesOf(files);
99
+ if (!parsed) return { ok: false, terminal: true, reason: "auth.json is not readable" };
100
+ let refreshed = 0;
101
+ let expiresAt: number | null = null;
102
+ for (const entry of parsed.entries) {
103
+ const current = entry.doc.refresh_token;
104
+ if (typeof current !== "string" || current === BROKER_REFRESH_PLACEHOLDER) continue;
105
+ const issuer = typeof entry.doc.oidc_issuer === "string" ? entry.doc.oidc_issuer : XAI_ISSUER;
106
+ if (issuer.replace(/\/$/, "") !== XAI_ISSUER) {
107
+ return { ok: false, terminal: true, reason: "unexpected issuer" };
108
+ }
109
+ const clientId = entry.doc.oidc_client_id;
110
+ if (typeof clientId !== "string" || !clientId) {
111
+ return { ok: false, terminal: true, reason: "entry has no client id" };
112
+ }
113
+ let res;
114
+ try {
115
+ res = await http.postForm(XAI_TOKEN_ENDPOINT, {
116
+ grant_type: "refresh_token",
117
+ refresh_token: current,
118
+ client_id: clientId,
119
+ });
120
+ } catch (err) {
121
+ return {
122
+ ok: false,
123
+ terminal: false,
124
+ reason: `request failed: ${err instanceof Error ? err.message : String(err)}`,
125
+ };
126
+ }
127
+ if (res.status < 200 || res.status >= 300) {
128
+ return {
129
+ ok: false,
130
+ terminal: isTerminalOAuthError(res.status, res.json),
131
+ reason: `HTTP ${res.status}`,
132
+ };
133
+ }
134
+ const body =
135
+ res.json && typeof res.json === "object" && !Array.isArray(res.json)
136
+ ? (res.json as Record<string, unknown>)
137
+ : null;
138
+ const access = body?.access_token;
139
+ if (typeof access !== "string" || !access) {
140
+ return { ok: false, terminal: false, reason: "response missing access_token" };
141
+ }
142
+ entry.doc.key = access;
143
+ if (typeof body?.refresh_token === "string" && body.refresh_token) {
144
+ entry.doc.refresh_token = body.refresh_token;
145
+ }
146
+ const expiresIn = body?.expires_in;
147
+ if (typeof expiresIn === "number" && Number.isFinite(expiresIn)) {
148
+ const at = now + expiresIn * 1000;
149
+ entry.doc.expires_at = new Date(at).toISOString();
150
+ expiresAt = expiresAt === null ? at : Math.min(expiresAt, at);
151
+ }
152
+ refreshed += 1;
153
+ }
154
+ if (refreshed === 0) return { ok: false, terminal: true, reason: "no refresh token to use" };
155
+ return {
156
+ ok: true,
157
+ files: { ...files, "auth.json": `${JSON.stringify(parsed.doc, null, 2)}\n` },
158
+ expiresAt,
159
+ };
160
+ },
161
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.113",
3
+ "version": "0.9.114",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
@@ -76,10 +76,12 @@
76
76
  "zod": "^3.23.8"
77
77
  },
78
78
  "devDependencies": {
79
+ "@aws-sdk/client-ssm": "3.899.0",
79
80
  "@types/node": "^22.9.0",
80
81
  "@types/ws": "^8.18.1",
81
82
  "@typescript-eslint/eslint-plugin": "^8.59.4",
82
83
  "@typescript-eslint/parser": "^8.59.4",
84
+ "esbuild": "0.25.8",
83
85
  "eslint": "^9.14.0",
84
86
  "typescript": "^5.6.3",
85
87
  "vitest": "^2.1.4"
@@ -2828,6 +2828,22 @@ if crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2828
2828
  /usr/bin/mkdir -p /home/node/.codex >/dev/null; then
2829
2829
  for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
2830
2830
  if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
2831
+ # ADR-135: the task's copy must not carry the rotating refresh token —
2832
+ # N copies racing to refresh it revoke the whole family. Replace it with
2833
+ # the broker placeholder (the field must exist; codex refuses a missing
2834
+ # one). The host is the only refresher and re-pushes on refresh.
2835
+ if [ "$codex_item" = auth.json ] && command -v jq >/dev/null 2>&1; then
2836
+ brokered_auth="$(mktemp "${TMPDIR:-/tmp}/uai-codex-auth.XXXXXX")"
2837
+ if jq 'if (.tokens|type) == "object" and (.tokens.refresh_token|type) == "string" then .tokens.refresh_token = "uai-broker-managed-no-refresh" else . end' \
2838
+ "$UAI_OWNER_HOME/.codex/auth.json" > "$brokered_auth" 2>/dev/null && [ -s "$brokered_auth" ]; then
2839
+ crt cp "$brokered_auth" "$app_container":/home/node/.codex/auth.json >/dev/null \
2840
+ || log "ERROR: crt cp of brokered .codex/auth.json failed; Codex in this task may not authenticate"
2841
+ rm -f "$brokered_auth"
2842
+ continue
2843
+ fi
2844
+ rm -f "$brokered_auth"
2845
+ log "warning: could not broker .codex/auth.json; copying it verbatim"
2846
+ fi
2831
2847
  crt cp "$UAI_OWNER_HOME/.codex/$codex_item" \
2832
2848
  "$app_container":/home/node/.codex/ >/dev/null \
2833
2849
  || log "ERROR: crt cp of .codex/$codex_item failed; Codex in this task may not authenticate"
@@ -2865,11 +2881,25 @@ crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2865
2881
 
2866
2882
  # Copy Grok subscription/config into a task-private /home/node/.grok. The Linux
2867
2883
  # `grok` binary is baked into the image; here we copy the arch-independent
2868
- # auth + config (auth.json is an OIDC token the CLI refreshes in-container).
2884
+ # auth + config. auth.json is an OIDC token: the task's copy gets the broker
2885
+ # placeholder in every entry's refresh_token (ADR-135) so it can never rotate
2886
+ # the family; the host is the only refresher.
2869
2887
  crt exec -u root "${managed_exec_env[@]}" "$app_container" \
2870
2888
  /usr/bin/mkdir -p /home/node/.grok >/dev/null 2>&1 || true
2871
2889
  for grok_item in auth.json config.toml models_cache.json agent_id; do
2872
2890
  if [ -e "$UAI_OWNER_HOME/.grok/$grok_item" ]; then
2891
+ if [ "$grok_item" = auth.json ] && command -v jq >/dev/null 2>&1; then
2892
+ brokered_auth="$(mktemp "${TMPDIR:-/tmp}/uai-grok-auth.XXXXXX")"
2893
+ if jq 'with_entries(if (.value|type) == "object" and (.value.refresh_token|type) == "string" then .value.refresh_token = "uai-broker-managed-no-refresh" else . end)' \
2894
+ "$UAI_OWNER_HOME/.grok/auth.json" > "$brokered_auth" 2>/dev/null && [ -s "$brokered_auth" ]; then
2895
+ crt cp "$brokered_auth" "$app_container":/home/node/.grok/auth.json >/dev/null 2>&1 \
2896
+ || log "warning: crt cp of brokered .grok/auth.json failed; grok may need re-login"
2897
+ rm -f "$brokered_auth"
2898
+ continue
2899
+ fi
2900
+ rm -f "$brokered_auth"
2901
+ log "warning: could not broker .grok/auth.json; copying it verbatim"
2902
+ fi
2873
2903
  crt cp "$UAI_OWNER_HOME/.grok/$grok_item" \
2874
2904
  "$app_container":/home/node/.grok/ >/dev/null 2>&1 \
2875
2905
  || log "warning: crt cp of .grok/$grok_item failed; grok may need re-login"
package/src/main.ts CHANGED
@@ -57,7 +57,13 @@ import {
57
57
  deleteUserSshIdentity,
58
58
  removeTaskSshIdentityFromContainer,
59
59
  } from "../lib/git-identity";
60
- import { reinjectCodexRunningTasks, watchCodexAuth } from "../lib/codex-auth";
60
+ import {
61
+ reinjectCodexRunningTasks,
62
+ reinjectGrokRunningTasks,
63
+ watchCodexAuth,
64
+ } from "../lib/codex-auth";
65
+ import { startCredentialStorePickup } from "../lib/credential-store-pickup";
66
+ import { configureTokenBroker, startTokenBrokerLoop } from "../lib/token-broker-local";
61
67
  import {
62
68
  ensureKeyForUser as ensureSshKeyForUser,
63
69
  getPublicKey as getSshPublicKey,
@@ -459,6 +465,17 @@ void reconcileEngineAccountsFromStore({ hostId })
459
465
  `[engine-accounts] credential store reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
460
466
  );
461
467
  });
468
+ // ADR-135: one refresher per rotating credential. Owned host → the in-process
469
+ // loop refreshes and re-pushes; Uai-operated host → the broker Lambda
470
+ // refreshes and this host picks up changed parameter versions.
471
+ const repushBrokeredCopies = async (kind: string): Promise<void> => {
472
+ if (kind === "codex") await reinjectCodexRunningTasks();
473
+ if (kind === "grok") await reinjectGrokRunningTasks();
474
+ await getOrchestrator().refreshEngineAccountCopies(kind);
475
+ };
476
+ configureTokenBroker({ repush: repushBrokeredCopies });
477
+ startTokenBrokerLoop();
478
+ startCredentialStorePickup({ repush: repushBrokeredCopies });
462
479
  void reconcileUserCredentialsFromStore()
463
480
  .then((r) => {
464
481
  if (r.skipped) return;