@runuai/host 0.8.0 → 0.8.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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Codex credential re-injection — the symmetric partner to the GitHub reinject
3
+ * in {@link ./github-tokens} (ADR-027/033 → host 0.8.1).
4
+ *
5
+ * The host owner's `~/.codex` is docker-cp'd into each task container ONCE, at
6
+ * task-up (`scripts/agent/task-up.sh`). When the owner re-logs into Codex —
7
+ * the refresh token gets revoked (a login elsewhere) and `codex login` rewrites
8
+ * `~/.codex/auth.json` — already-running containers keep their stale copy, so a
9
+ * re-login otherwise only helps NEW tasks. This re-copies the fresh `~/.codex`
10
+ * into the running task containers so live tasks self-heal.
11
+ *
12
+ * Two triggers, covering both re-login paths:
13
+ * - {@link watchCodexAuth} — fs.watch on `~/.codex`, for `codex login` run
14
+ * from a terminal while the host stays up.
15
+ * - {@link reinjectCodexRunningTasks} at host start — for the desktop
16
+ * "Connect Codex" flow, which writes `~/.codex` then restarts the host
17
+ * (durable sessions reattach to containers that still hold the old creds).
18
+ *
19
+ * Codex auth is host-wide (the operator's single `~/.codex`, not per-user), so
20
+ * this targets ALL running tasks — matching task-up, which copies it into every
21
+ * container regardless of roster.
22
+ */
23
+ import { existsSync, watch } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ import { isNull } from "drizzle-orm";
28
+
29
+ import { getDb, schema } from "./db";
30
+ import { dockerCli } from "./docker-exec";
31
+
32
+ /** The exact set task-up.sh copies into `/home/node/.codex`. */
33
+ const CODEX_ITEMS = [
34
+ "auth.json",
35
+ "config.toml",
36
+ "AGENTS.md",
37
+ "version.json",
38
+ "installation_id",
39
+ "rules",
40
+ ] as const;
41
+ const EXEC_TIMEOUT_MS = 15_000;
42
+
43
+ /** Injectable seams (defaults hit the real DB / docker / fs) so the copy logic
44
+ * is testable without either. */
45
+ export interface CodexDeps {
46
+ exec?: (args: string[]) => Promise<{ status: number | null; stderr: string }>;
47
+ runningContainers?: () => string[];
48
+ fileExists?: (path: string) => boolean;
49
+ }
50
+
51
+ function ownerCodexDir(): string {
52
+ return join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex");
53
+ }
54
+
55
+ const defaultExec: NonNullable<CodexDeps["exec"]> = async (args) => {
56
+ const res = await dockerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
57
+ return { status: res.status, stderr: res.stderr };
58
+ };
59
+
60
+ function defaultRunningContainers(): string[] {
61
+ return getDb()
62
+ .select({ taskId: schema.hostTasks.taskId })
63
+ .from(schema.hostTasks)
64
+ .where(isNull(schema.hostTasks.endedAt))
65
+ .all()
66
+ .map((r) => `task-${r.taskId}-app-1`);
67
+ }
68
+
69
+ /**
70
+ * Re-copy `~/.codex/<items>` into one container — mirrors task-up.sh exactly:
71
+ * ensure the dir (root), cp each item that exists, chown back to node. Best
72
+ * effort; a stopped/removed container makes the exec fail and is caught by the
73
+ * caller.
74
+ */
75
+ async function copyCodexInto(container: string, deps: CodexDeps): Promise<void> {
76
+ const exec = deps.exec ?? defaultExec;
77
+ const exists = deps.fileExists ?? existsSync;
78
+ const dir = ownerCodexDir();
79
+ await exec(["exec", "-u", "root", container, "mkdir", "-p", "/home/node/.codex"]);
80
+ for (const item of CODEX_ITEMS) {
81
+ const src = join(dir, item);
82
+ if (!exists(src)) continue;
83
+ await exec(["cp", src, `${container}:/home/node/.codex/`]);
84
+ }
85
+ await exec([
86
+ "exec",
87
+ "-u",
88
+ "root",
89
+ container,
90
+ "chown",
91
+ "-R",
92
+ "node:node",
93
+ "/home/node/.codex",
94
+ ]);
95
+ }
96
+
97
+ /**
98
+ * Re-copy the freshly (re)logged-in `~/.codex` into every running task
99
+ * container on this host. No-op when there is no `~/.codex/auth.json` (nothing
100
+ * to inject) or no running tasks. Best-effort per container.
101
+ */
102
+ export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<void> {
103
+ const exists = deps.fileExists ?? existsSync;
104
+ if (!exists(join(ownerCodexDir(), "auth.json"))) return;
105
+ const containers = (deps.runningContainers ?? defaultRunningContainers)();
106
+ if (containers.length === 0) return;
107
+ console.log(`[codex] re-copying ~/.codex into ${containers.length} running task(s)`);
108
+ for (const container of containers) {
109
+ try {
110
+ await copyCodexInto(container, deps);
111
+ } catch (err) {
112
+ console.warn(
113
+ `[codex] reinject into ${container} failed: ${err instanceof Error ? err.message : err}`,
114
+ );
115
+ }
116
+ }
117
+ }
118
+
119
+ let watcher: ReturnType<typeof watch> | null = null;
120
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
121
+
122
+ /**
123
+ * Watch `~/.codex` for `auth.json` changes (a re-login) and re-inject into
124
+ * running tasks, debounced (a login writes several files). Idempotent; a
125
+ * missing `~/.codex` dir is a no-op — the host-start reinject already handles
126
+ * a first login that flips Codex available. UAI_CODEX_REINJECT=0 disables it.
127
+ */
128
+ export function watchCodexAuth(): void {
129
+ if (watcher || process.env.UAI_CODEX_REINJECT === "0") return;
130
+ const dir = ownerCodexDir();
131
+ if (!existsSync(dir)) return;
132
+ try {
133
+ watcher = watch(dir, (_event, filename) => {
134
+ // filename is null on some platforms — then we can't tell, so proceed.
135
+ if (filename && filename !== "auth.json") return;
136
+ if (debounceTimer) clearTimeout(debounceTimer);
137
+ debounceTimer = setTimeout(() => void reinjectCodexRunningTasks(), 2_000);
138
+ debounceTimer.unref?.();
139
+ });
140
+ } catch (err) {
141
+ console.warn(
142
+ `[codex] could not watch ${dir}: ${err instanceof Error ? err.message : err}`,
143
+ );
144
+ }
145
+ }
146
+
147
+ /** Test/teardown hook. */
148
+ export function stopWatchingCodexAuth(): void {
149
+ watcher?.close();
150
+ watcher = null;
151
+ if (debounceTimer) {
152
+ clearTimeout(debounceTimer);
153
+ debounceTimer = null;
154
+ }
155
+ }
@@ -56,6 +56,11 @@ export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string
56
56
  .onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
57
57
  .run();
58
58
  notifyGithubChange();
59
+ // A reconnect must reach ALREADY-RUNNING containers, not just new tasks:
60
+ // each container's gh config is written once, at task setup, so a fresh
61
+ // grant otherwise sits unused until /retry-gh. Re-inject into the user's
62
+ // live tasks now. Fire-and-forget — never blocks the token store.
63
+ reinjectRunningTasks(frame.userId);
59
64
  return { ok: true };
60
65
  } catch (err) {
61
66
  return { ok: false, error: err instanceof Error ? err.message : "store failed" };
@@ -170,6 +175,20 @@ function activeTaskIdsForUser(userId: string): string[] {
170
175
  .map((r) => r.taskId);
171
176
  }
172
177
 
178
+ /**
179
+ * Re-mint + inject a (re)connected token into the user's live tasks — so a
180
+ * reconnect on Account applies to running containers, not only new tasks. Clear
181
+ * any armed retry chain first (a fresh grant must not be swallowed by an outage
182
+ * backoff, and `setupTaskGithub`'s duplicate-chain guard would no-op it) then
183
+ * run setup fresh. Fire-and-forget per task; best-effort by construction.
184
+ */
185
+ export function reinjectRunningTasks(userId: string): void {
186
+ for (const taskId of activeTaskIdsForUser(userId)) {
187
+ clearRefresh(taskId);
188
+ void setupTaskGithub(taskId, userId);
189
+ }
190
+ }
191
+
173
192
  // --- access-token exchange --------------------------------------------------
174
193
 
175
194
  function cloudHttpBase(): string {
@@ -434,6 +453,37 @@ function isRevokedTokenError(reason: string): boolean {
434
453
  return /revoked|invalid_grant|bad_refresh/i.test(reason);
435
454
  }
436
455
 
456
+ /**
457
+ * True when the failure names a genuinely bad credential — GitHub answered the
458
+ * validation with 401/403 ("Bad credentials"), so the token really is wrong and
459
+ * only a reconnect fixes it. Distinct from a 5xx blip (see below).
460
+ */
461
+ function isBadCredentialError(reason: string): boolean {
462
+ return /validating token: HTTP 40[13]\b|Bad credentials|401 Unauthorized|403 Forbidden/i.test(
463
+ reason,
464
+ );
465
+ }
466
+
467
+ /**
468
+ * True when the failure is a GitHub-side / network blip that a retry will heal
469
+ * — the API returned 5xx (as happens when `gh auth login` validates the token
470
+ * against api.github.com during an outage), a rate-limit, or the fetch itself
471
+ * failed. Such a failure must NOT be surfaced as "authentication expired —
472
+ * reconnect": the token is fine, GitHub is briefly unavailable. A genuine bad
473
+ * credential (401/403) is explicitly excluded so it still routes to reconnect.
474
+ */
475
+ export function isTransientGithubError(reason: string): boolean {
476
+ if (isBadCredentialError(reason)) return false;
477
+ return (
478
+ /HTTP 5\d\d|Service Unavailable|Bad Gateway|Gateway Time-?out|server error|rate limit|too many requests|secondary rate|unavailable|unreachable/i.test(
479
+ reason,
480
+ ) ||
481
+ /\bfetch failed\b|timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|ENOTFOUND|EAI_AGAIN|socket hang up|\bnetwork\b/i.test(
482
+ reason,
483
+ )
484
+ );
485
+ }
486
+
437
487
  async function runRefresh(taskId: string, userId: string): Promise<void> {
438
488
  try {
439
489
  const tok = await requestAccessToken(userId);
@@ -602,10 +652,16 @@ export async function setupTaskGithub(
602
652
  authExpiredHandler?.(taskId, userId, reason);
603
653
  return false;
604
654
  }
655
+ // A retry chain already armed for this task means a sibling entry point
656
+ // (task-up and channel-ensure both call setup) already reported this and
657
+ // owns recovery — don't post a duplicate note or start a competing chain.
658
+ // `/retry-gh` clears the chain first, so a manual retry is never swallowed.
659
+ if (attempt === 0 && retryTimers.has(taskId)) return false;
605
660
  // Transient: self-heal with bounded backoff. Post the chat note only on
606
661
  // the FIRST failure of a chain — each retry re-enters this catch, and six
607
662
  // "gh auth" notes for one outage is noise. Exhaustion posts its own note
608
- // (in scheduleGithubRetry).
663
+ // (in scheduleGithubRetry). The handler classifies the reason (transient
664
+ // 5xx blip vs. genuine expiry) and words the note accordingly.
609
665
  if (attempt === 0) authExpiredHandler?.(taskId, userId, reason);
610
666
  scheduleGithubRetry(taskId, userId, attempt, deps);
611
667
  return false;
@@ -34,7 +34,7 @@ import {
34
34
  } from "./agents/types";
35
35
  import { ACTIVE_STATUSES } from "./task-status";
36
36
  import { getHostTask, upsertHostTask } from "./runtime-state";
37
- import { setupTaskGithub } from "./github-tokens";
37
+ import { clearRefresh, setupTaskGithub } from "./github-tokens";
38
38
  import { setupTaskGitIdentity } from "./git-identity";
39
39
  import { dockerCli } from "./docker-exec";
40
40
  import {
@@ -571,6 +571,9 @@ class Orchestrator {
571
571
  return;
572
572
  }
573
573
  this.emitSystemNote(taskId, "gh: retrying authentication…");
574
+ // Cancel any armed auto-retry chain so this manual attempt runs fresh
575
+ // (setup's duplicate-chain guard would otherwise no-op it).
576
+ clearRefresh(taskId);
574
577
  const ok = await setupTaskGithub(taskId, owner);
575
578
  this.emitSystemNote(
576
579
  taskId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
package/src/main.ts CHANGED
@@ -26,11 +26,13 @@ import { getHostTask } from "../lib/runtime-state";
26
26
  import { getOrchestrator } from "../lib/orchestrator";
27
27
  import {
28
28
  connectedUserIds,
29
+ isTransientGithubError,
29
30
  onConnectClear,
30
31
  onConnectSet,
31
32
  onGithubChange,
32
33
  setAuthExpiredHandler,
33
34
  } from "../lib/github-tokens";
35
+ import { reinjectCodexRunningTasks, watchCodexAuth } from "../lib/codex-auth";
34
36
  import {
35
37
  deleteKey as deleteSshKey,
36
38
  ensureKeyForUser as ensureSshKeyForUser,
@@ -135,18 +137,30 @@ interface PausableSource {
135
137
 
136
138
  console.log(`[host-agent] starting host ${hostId}`);
137
139
  migrateHostDb();
138
- // When a task's GitHub token can't be refreshed (revoked, disconnected,
139
- // expired), surface it in the task channel so the user can re-grant (ADR-027).
140
+ // Surface a task's GitHub setup failure in its channel (ADR-027). Classify the
141
+ // reason first: a GitHub-side 5xx / network blip (e.g. `gh auth login`
142
+ // validating the token during a github.com outage) is transient and self-heals
143
+ // on the bounded retry — telling the user their auth "expired" and to reconnect
144
+ // would be wrong and alarming. Only a genuine expiry/revocation asks for a
145
+ // reconnect. Both cases keep retrying in the background regardless.
140
146
  setAuthExpiredHandler((taskId, _userId, reason) => {
141
- getOrchestrator().emitSystemNote(
142
- taskId,
143
- `gh authentication expired (reason: ${reason}). Reconnect GitHub on ` +
144
- `Account, then run /retry-gh in this task to restore.`,
145
- );
147
+ const note = isTransientGithubError(reason)
148
+ ? `GitHub is temporarily unavailable — ${reason}. Uai is retrying ` +
149
+ `automatically; no action needed unless this persists (then run ` +
150
+ `/retry-gh).`
151
+ : `gh authentication expired (reason: ${reason}). Reconnect GitHub on ` +
152
+ `Account, then run /retry-gh in this task to restore.`;
153
+ getOrchestrator().emitSystemNote(taskId, note);
146
154
  });
147
155
  // Best-effort: build the standard image + asdf volume if missing. Logs and
148
156
  // continues on failure (e.g. docker unavailable) so the host still boots.
149
157
  void ensureStandardImage();
158
+ // Codex creds are docker-cp'd into containers at task-up. A re-login (revoked
159
+ // token → `codex login`) otherwise reaches only NEW tasks — re-copy into
160
+ // running tasks on start (covers the desktop "Connect Codex", which restarts
161
+ // the host) and watch ~/.codex for future logins (a terminal `codex login`).
162
+ void reinjectCodexRunningTasks();
163
+ watchCodexAuth();
150
164
  connect();
151
165
  // Local browser UI (ADR-028) — same single process, alongside the WSS client.
152
166
  // Best-effort: a UI bind failure must not take the host service down.