@runuai/host 0.9.76 → 0.9.77

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.
@@ -37,6 +37,7 @@ import { and, eq } from "drizzle-orm";
37
37
 
38
38
  import { getDb, schema } from "../db";
39
39
  import { taskWorkspaceDir } from "../env";
40
+ import { machineSessionAuthorityEnv } from "../runtime-authority";
40
41
  import { requireContainerRuntimeOperational } from "../runtime-guard";
41
42
  import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
42
43
  import { DurableProcess, runnerScriptPath } from "./durable-proc";
@@ -124,11 +125,19 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
124
125
  // must not trip over, and the host-FS durable flow below polls files a
125
126
  // machine does not share. Machine durability uses the ssh-tail backend.
126
127
  if (opts.environment.descriptor.locator.provider === "machine") {
128
+ // The session env was assembled with the CONTAINER runtime authority
129
+ // (asdf/corepack pins to shared read-only volumes) — on a machine those
130
+ // paths do not exist and the pins actively break package managers.
131
+ // Swap in the machine authority; credentials and task env pass through.
132
+ const machineOpts: AgentTransportOptions = {
133
+ ...opts,
134
+ explicitEnv: machineSessionAuthorityEnv(opts.explicitEnv),
135
+ };
127
136
  if (!durableEnabled()) {
128
- clearCurrentSession(opts.taskId, opts.agentId);
129
- return directEnvironmentTransport(opts);
137
+ clearCurrentSession(machineOpts.taskId, machineOpts.agentId);
138
+ return directEnvironmentTransport(machineOpts);
130
139
  }
131
- return machineDurableTransport(opts);
140
+ return machineDurableTransport(machineOpts);
132
141
  }
133
142
  // Session creation can happen after channel/task lifecycle queues drain, well
134
143
  // after the command-level runtime preflight. Recheck at the actual attach or
package/lib/codex-auth.ts CHANGED
@@ -201,6 +201,61 @@ export async function injectCodexIntoContainer(
201
201
  }
202
202
  }
203
203
 
204
+ /**
205
+ * ADR-121: deliver the DEFAULT Codex slot into a MACHINE task over the
206
+ * environment transport. The legacy paths (task-up docker cp, recovery
207
+ * inject, the reinject sweep) all speak docker grammar, so a machine task's
208
+ * codex agent ran without auth.json and looped 'Reconnecting' (live
209
+ * 2026-08-27, first EC2 task). Files land as the ssh user (node), so no
210
+ * chown pass is needed. Returns true when there was nothing to inject.
211
+ */
212
+ export async function injectCodexIntoMachine(
213
+ environment: {
214
+ exec(request: {
215
+ argv: readonly [string, ...string[]];
216
+ timeoutMs: number;
217
+ maxOutputBytes: number;
218
+ }): Promise<{ exitCode: number | null; stderr: Uint8Array }>;
219
+ copy(request: {
220
+ direction: "into";
221
+ source: string;
222
+ destination: string;
223
+ }): Promise<void>;
224
+ },
225
+ deps: CodexDeps = {},
226
+ ): Promise<boolean> {
227
+ const exists = deps.fileExists ?? existsSync;
228
+ const dir = ownerCodexDir();
229
+ if (!exists(join(dir, "auth.json"))) return true; // nothing to inject
230
+ try {
231
+ const made = await environment.exec({
232
+ argv: ["/bin/mkdir", "-p", "/home/node/.codex"],
233
+ timeoutMs: EXEC_TIMEOUT_MS,
234
+ maxOutputBytes: 64 * 1024,
235
+ });
236
+ if (made.exitCode !== 0) {
237
+ throw new Error(
238
+ `mkdir /home/node/.codex exited ${made.exitCode}: ${Buffer.from(made.stderr).toString("utf8").trim()}`,
239
+ );
240
+ }
241
+ for (const item of CODEX_ITEMS) {
242
+ const source = join(dir, item);
243
+ if (!exists(source)) continue;
244
+ await environment.copy({
245
+ direction: "into",
246
+ source,
247
+ destination: `/home/node/.codex/${item}`,
248
+ });
249
+ }
250
+ return true;
251
+ } catch (err) {
252
+ console.error(
253
+ `[codex] machine inject: ${err instanceof Error ? err.message : err} — Codex in this task will fail until the next ensure retries`,
254
+ );
255
+ return false;
256
+ }
257
+ }
258
+
204
259
  /**
205
260
  * Refresh the freshly (re)logged-in Codex files in every task container that
206
261
  * is both DB-active AND confirmed running by docker. `config.toml` is excluded:
@@ -67,6 +67,49 @@ export const RUNTIME_AUTHORITY_ENV: Readonly<Record<string, string>> =
67
67
  ENV: "",
68
68
  });
69
69
 
70
+ /**
71
+ * ADR-121: the MACHINE flavor of the authority env. A machine is a
72
+ * single-task trust domain — the container constants above defend SHARED
73
+ * host resources (the read-only asdf/corepack volume, uai-npm prefixes)
74
+ * that do not exist on a machine, and carrying them anyway pinned corepack
75
+ * to an unwritable /opt path with the network off, so `pnpm install`
76
+ * failed out of the box (live 2026-08-27, first EC2 task — the agent had
77
+ * to hand-repair its own environment). Keep the hygiene (loader/env
78
+ * clearing, prompt-free corepack), drop the container-topology pins.
79
+ */
80
+ export const MACHINE_RUNTIME_AUTHORITY_ENV: Readonly<Record<string, string>> =
81
+ Object.freeze({
82
+ PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/node/.local/bin",
83
+ HOME: "/home/node",
84
+ XDG_CONFIG_HOME: "/home/node/.config",
85
+ XDG_DATA_HOME: "/home/node/.local/share",
86
+ XDG_CACHE_HOME: "/home/node/.cache",
87
+ COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
88
+ LD_PRELOAD: "",
89
+ LD_AUDIT: "",
90
+ LD_LIBRARY_PATH: "",
91
+ NODE_OPTIONS: "",
92
+ NODE_PATH: "",
93
+ PS4: "",
94
+ BASH_ENV: "",
95
+ ENV: "",
96
+ });
97
+
98
+ /** Rewrite a session env assembled for a CONTAINER into its machine form:
99
+ * every container-authority key is dropped, then the machine authority is
100
+ * overlaid. Non-authority keys (engine credentials, task tokens, preview
101
+ * env) pass through untouched. */
102
+ export function machineSessionAuthorityEnv(
103
+ env: Readonly<Record<string, string>> | undefined,
104
+ ): Record<string, string> {
105
+ const out: Record<string, string> = {};
106
+ for (const [key, value] of Object.entries(env ?? {})) {
107
+ if (key in RUNTIME_AUTHORITY_ENV) continue;
108
+ out[key] = value;
109
+ }
110
+ return { ...out, ...MACHINE_RUNTIME_AUTHORITY_ENV };
111
+ }
112
+
70
113
  /** Docker `exec` options for the fixed authority environment. Callers append
71
114
  * this after every less-trusted `-e` option and immediately before the
72
115
  * container name. Container admission additionally rejects noncanonical
@@ -27,6 +27,7 @@ import type {
27
27
  TaskUpCredentials,
28
28
  TaskUpResult,
29
29
  } from "../agent";
30
+ import { injectCodexIntoMachine } from "../codex-auth";
30
31
  import { sharedRoot } from "../shared-files";
31
32
  import {
32
33
  MACHINE_TASK_ENVIRONMENT_PROVIDER,
@@ -524,6 +525,19 @@ export function createMachineHostTaskEnvironmentProvider(
524
525
  });
525
526
  if (sharedWarning) warnings.push(sharedWarning);
526
527
 
528
+ // Codex default-slot credentials: the legacy delivery paths are all
529
+ // docker grammar; machines get theirs over the transport. Best-effort
530
+ // — a failed inject degrades codex agents, never the task.
531
+ if (input.task.agents.some((agent) => agent.kind === "codex")) {
532
+ await injectCodexIntoMachine(handle).catch((error: unknown) => {
533
+ console.warn(
534
+ `[machine] task ${request.taskId}: codex inject failed: ${
535
+ error instanceof Error ? error.message : String(error)
536
+ }`,
537
+ );
538
+ });
539
+ }
540
+
527
541
  // Editor pane: code-server on the machine (same flags as uai-init's
528
542
  // container launch), idempotent across provision re-runs and resume.
529
543
  // Best-effort — a missing binary degrades the Editor tab, never the task.
@@ -14,8 +14,9 @@
14
14
  * is complete now so nothing about the shape is provisional.
15
15
  */
16
16
 
17
- import { spawn as nodeSpawn } from "node:child_process";
18
- import { readFileSync, writeFileSync } from "node:fs";
17
+ import { execFile, spawn as nodeSpawn } from "node:child_process";
18
+ import { readFileSync, statSync, writeFileSync } from "node:fs";
19
+ import { promisify } from "node:util";
19
20
 
20
21
  import type {
21
22
  MachineInfo,
@@ -386,6 +387,46 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
386
387
  }
387
388
  const target = await this.#target();
388
389
  if (request.direction === "into") {
390
+ // Directory sources tar-stream over the transport (engine-account
391
+ // homes — live 2026-08-27: codex's auth.json never reached the
392
+ // machine because readFileSync(dir) threw EISDIR and the account
393
+ // materialization died). Single files keep the direct cat path.
394
+ if (statSync(request.source).isDirectory()) {
395
+ const tarball = await promisify(execFile)(
396
+ "tar",
397
+ ["-C", request.source, "-cf", "-", "."],
398
+ { encoding: "buffer", maxBuffer: 256 * 1024 * 1024 },
399
+ );
400
+ const extract = await capturedCliExec(
401
+ this.#deps.spawn,
402
+ "ssh",
403
+ machineEnvironmentSshArgs(
404
+ target,
405
+ {
406
+ argv: [
407
+ "/bin/sh",
408
+ "-c",
409
+ 'mkdir -p "$1" && tar -C "$1" -xf -',
410
+ "copy",
411
+ request.destination,
412
+ ],
413
+ },
414
+ "interactive",
415
+ ),
416
+ {
417
+ argv: ["/bin/sh"],
418
+ stdin: tarball.stdout,
419
+ timeoutMs: 300_000,
420
+ maxOutputBytes: 1024 * 1024,
421
+ },
422
+ );
423
+ if (extract.exitCode !== 0) {
424
+ throw new Error(
425
+ `machine directory copy failed: ${Buffer.from(extract.stderr).toString("utf8").trim()}`,
426
+ );
427
+ }
428
+ return;
429
+ }
389
430
  const bytes = readFileSync(request.source);
390
431
  const result = await capturedCliExec(
391
432
  this.#deps.spawn,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.76",
3
+ "version": "0.9.77",
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>",