@runuai/host 0.8.1 → 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.1",
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
@@ -32,6 +32,7 @@ import {
32
32
  onGithubChange,
33
33
  setAuthExpiredHandler,
34
34
  } from "../lib/github-tokens";
35
+ import { reinjectCodexRunningTasks, watchCodexAuth } from "../lib/codex-auth";
35
36
  import {
36
37
  deleteKey as deleteSshKey,
37
38
  ensureKeyForUser as ensureSshKeyForUser,
@@ -154,6 +155,12 @@ setAuthExpiredHandler((taskId, _userId, reason) => {
154
155
  // Best-effort: build the standard image + asdf volume if missing. Logs and
155
156
  // continues on failure (e.g. docker unavailable) so the host still boots.
156
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();
157
164
  connect();
158
165
  // Local browser UI (ADR-028) — same single process, alongside the WSS client.
159
166
  // Best-effort: a UI bind failure must not take the host service down.