@runuai/host 0.9.46 → 0.9.48

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.
@@ -592,6 +592,69 @@ export function addEngineAccount(
592
592
  };
593
593
  }
594
594
 
595
+ /**
596
+ * ADR-116: register an EXTRA Codex account from a browser-login capture — the
597
+ * verbatim `auth.json` a `codex login` flow produced (OAuth tokens, not an
598
+ * API key), written into the same isolated `~/.codex-acct-<id>` layout the
599
+ * pasted-key path uses. Callers pass the file CONTENTS; nothing is logged.
600
+ */
601
+ export function addCodexAccountFromAuthJson(
602
+ label: string,
603
+ authJson: string,
604
+ seams: Partial<AccountSeams> = {},
605
+ ): AddAccountResult {
606
+ const s = withDefaults(seams);
607
+ const trimmedLabel = label.trim();
608
+ if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
609
+ try {
610
+ const parsed: unknown = JSON.parse(authJson);
611
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
612
+ throw new Error("not an object");
613
+ }
614
+ } catch {
615
+ return { ok: false, message: "The captured Codex credential is not valid." };
616
+ }
617
+
618
+ const id = newId();
619
+ const d = KINDS.codex as KindDescriptor;
620
+ const hostDir = (d.extraHostDir as (o: string, i: string) => string)(
621
+ s.ownerHome(),
622
+ id,
623
+ );
624
+ try {
625
+ const file = join(hostDir, "auth.json");
626
+ mkdirSync(hostDir, { recursive: true });
627
+ writeFileSync(file, authJson.endsWith("\n") ? authJson : `${authJson}\n`, {
628
+ mode: 0o600,
629
+ });
630
+ try {
631
+ chmodSync(file, 0o600);
632
+ } catch {
633
+ /* best effort */
634
+ }
635
+ } catch (err) {
636
+ return {
637
+ ok: false,
638
+ message: `Could not write the account config: ${
639
+ err instanceof Error ? err.message : String(err)
640
+ }`,
641
+ };
642
+ }
643
+ getDb()
644
+ .insert(schema.engineAccounts)
645
+ .values({
646
+ id,
647
+ kind: "codex",
648
+ label: trimmedLabel,
649
+ authKind: "config-dir",
650
+ secretEnc: null,
651
+ configDir: hostDir,
652
+ createdAt: Date.now(),
653
+ })
654
+ .run();
655
+ return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
656
+ }
657
+
595
658
  /** Remove an EXTRA account (idempotent). The synthesized default can't be
596
659
  * removed here (use the engine disconnect flow for the legacy slot). The
597
660
  * isolated host config dir of a config-dir account is deleted too. */
@@ -17,6 +17,7 @@ import {
17
17
  mkdtemp,
18
18
  open,
19
19
  readdir,
20
+ readFile,
20
21
  realpath,
21
22
  rename,
22
23
  rm,
@@ -26,6 +27,7 @@ import { homedir, tmpdir } from "node:os";
26
27
  import { basename, dirname, join, resolve } from "node:path";
27
28
 
28
29
  import {
30
+ isEngineAccountLabel,
29
31
  isHostOpId,
30
32
  MAX_ENGINE_LOGIN_QUERY_CHARS,
31
33
  MAX_ENGINE_LOGIN_TEXT_CHARS,
@@ -35,6 +37,10 @@ import {
35
37
  type EngineLoginEventFrame,
36
38
  type EngineLoginKind,
37
39
  } from "../src/protocol";
40
+ import {
41
+ addCodexAccountFromAuthJson,
42
+ addEngineAccount,
43
+ } from "./engine-accounts";
38
44
  import { reinjectCodexRunningTasks } from "./codex-auth";
39
45
  import {
40
46
  appleContainerRuntimeBinding,
@@ -125,6 +131,9 @@ export interface EngineLoginSeams {
125
131
  reconcileStaleContainers(): Promise<void>;
126
132
  persistClaudeToken(token: string): Promise<void>;
127
133
  persistCodexAuth(sourceFile: string): Promise<void>;
134
+ /** ADR-116: labeled extra-account captures (never the default slots). */
135
+ addClaudeAccount(label: string, token: string): { ok: boolean; message: string };
136
+ addCodexAccount(label: string, authJson: string): { ok: boolean; message: string };
128
137
  refreshEngineAccountAgents(engine: "claude"): Promise<void>;
129
138
  reinjectCodexRunningTasks(): Promise<void>;
130
139
  ownerHome(): string;
@@ -178,6 +187,9 @@ interface LoginOperation {
178
187
  callbackUsed: boolean;
179
188
  pendingInput: string | null;
180
189
  callbackTarget: CodexCallbackTarget | null;
190
+ /** ADR-116: when set, the captured credential lands in a labeled extra
191
+ * account instead of the default slot. */
192
+ accountLabel: string | null;
181
193
  }
182
194
 
183
195
  const defaultTimers: EngineLoginTimers = {
@@ -253,6 +265,10 @@ function defaultSeams(options: EngineLoginManagerOptions): EngineLoginSeams {
253
265
  reconcileStaleEngineLoginResources({ tempRoot }),
254
266
  persistClaudeToken: (token) => persistClaudeTokenAtomic(token),
255
267
  persistCodexAuth: (sourceFile) => persistCodexAuthAtomic(sourceFile),
268
+ addClaudeAccount: (label, token) =>
269
+ addEngineAccount("claude", label, { token }),
270
+ addCodexAccount: (label, authJson) =>
271
+ addCodexAccountFromAuthJson(label, authJson),
256
272
  refreshEngineAccountAgents: options.refreshEngineAccountAgents,
257
273
  reinjectCodexRunningTasks: () => reinjectCodexRunningTasks(),
258
274
  ownerHome,
@@ -319,8 +335,16 @@ export class EngineLoginManager {
319
335
  opId: string,
320
336
  engine: EngineLoginKind,
321
337
  emit: EmitEngineLoginEvent,
338
+ label?: string,
322
339
  ): boolean {
323
340
  if (!isHostOpId(opId)) return false;
341
+ if (label !== undefined && !isEngineAccountLabel(label)) {
342
+ safeEmit(
343
+ emit,
344
+ failedEvent(opId, engine, "start_failed", "The account label is invalid."),
345
+ );
346
+ return false;
347
+ }
324
348
  if (this.operations.has(opId)) {
325
349
  safeEmit(emit, failedEvent(opId, engine, "unavailable", "Login operation already exists."));
326
350
  return false;
@@ -366,6 +390,7 @@ export class EngineLoginManager {
366
390
  callbackUsed: false,
367
391
  pendingInput: null,
368
392
  callbackTarget: null,
393
+ accountLabel: label ?? null,
369
394
  };
370
395
  this.operations.set(opId, operation);
371
396
  this.engineOperations.set(engine, operation);
@@ -687,6 +712,28 @@ export class EngineLoginManager {
687
712
  token: string,
688
713
  ): Promise<void> {
689
714
  let terminal: EngineLoginEventFrame;
715
+ if (operation.accountLabel !== null) {
716
+ // ADR-116: labeled capture — an extra account, not the default slot.
717
+ // Extras are picked up at the next agent spawn / rotation, so no
718
+ // running-agent refresh is needed (mirrors the local accounts API).
719
+ const added = this.seams.addClaudeAccount(operation.accountLabel, token);
720
+ terminal = added.ok
721
+ ? {
722
+ kind: "engine.login.event",
723
+ opId: operation.opId,
724
+ engine: operation.engine,
725
+ phase: "succeeded",
726
+ message: added.message,
727
+ }
728
+ : failedEvent(
729
+ operation.opId,
730
+ operation.engine,
731
+ "persist_failed",
732
+ added.message,
733
+ );
734
+ await this.finishCommit(operation, terminal);
735
+ return;
736
+ }
690
737
  try {
691
738
  await this.seams.persistClaudeToken(token);
692
739
  } catch {
@@ -730,6 +777,39 @@ export class EngineLoginManager {
730
777
  const tempDir = operation.tempDir;
731
778
  if (!tempDir) return;
732
779
  let terminal: EngineLoginEventFrame;
780
+ if (operation.accountLabel !== null) {
781
+ // ADR-116: labeled capture — the auth.json this login produced becomes
782
+ // an isolated extra account instead of overwriting ~/.codex.
783
+ let added: { ok: boolean; message: string };
784
+ try {
785
+ const contents = await readFile(
786
+ join(tempDir, "codex", "auth.json"),
787
+ "utf8",
788
+ );
789
+ added = this.seams.addCodexAccount(operation.accountLabel, contents);
790
+ } catch {
791
+ added = {
792
+ ok: false,
793
+ message: "Codex authorized, but its credential could not be read.",
794
+ };
795
+ }
796
+ terminal = added.ok
797
+ ? {
798
+ kind: "engine.login.event",
799
+ opId: operation.opId,
800
+ engine: operation.engine,
801
+ phase: "succeeded",
802
+ message: added.message,
803
+ }
804
+ : failedEvent(
805
+ operation.opId,
806
+ operation.engine,
807
+ "persist_failed",
808
+ added.message,
809
+ );
810
+ await this.finishCommit(operation, terminal);
811
+ return;
812
+ }
733
813
  try {
734
814
  await this.seams.persistCodexAuth(
735
815
  join(tempDir, "codex", "auth.json"),
@@ -770,6 +850,10 @@ export class EngineLoginManager {
770
850
  await operation.finishPromise;
771
851
  return;
772
852
  }
853
+ // ADR-116: attach the CLI's last visible line — a remote operator staring
854
+ // at "Waiting for the host…" (live 2026-08-20, apple-container) otherwise
855
+ // has zero evidence of where the flow died.
856
+ const evidence = lastSignificantOutputLine(operation.outputTail);
773
857
  this.detach(operation);
774
858
  safeEmit(operation.emit, {
775
859
  kind: "engine.login.event",
@@ -777,7 +861,9 @@ export class EngineLoginManager {
777
861
  engine: operation.engine,
778
862
  phase: "timed_out",
779
863
  errorCode: "timeout",
780
- message: "Login timed out.",
864
+ message: evidence
865
+ ? `Login timed out. Last output: ${evidence}`
866
+ : "Login timed out.",
781
867
  });
782
868
  await this.ensureSettlement(operation);
783
869
  }
@@ -916,6 +1002,22 @@ export function createEngineLoginManager(
916
1002
  return new EngineLoginManager(options);
917
1003
  }
918
1004
 
1005
+ /** The last visible, sanitized line of the login CLI's output — bounded and
1006
+ * secret-checked so it can ride a terminal event message (ADR-116). Anything
1007
+ * token-shaped disqualifies the whole line rather than risking a partial. */
1008
+ function lastSignificantOutputLine(tail: string): string | null {
1009
+ const lines = tail
1010
+ .replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "")
1011
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "")
1012
+ .split("\n")
1013
+ .map((line) => line.trim())
1014
+ .filter((line) => line.length > 3);
1015
+ const last = lines.at(-1);
1016
+ if (!last) return null;
1017
+ if (/sk-ant-|Bearer |[A-Za-z0-9_-]{40,}/.test(last)) return null;
1018
+ return last.slice(0, 200);
1019
+ }
1020
+
919
1021
  function safeEmit(
920
1022
  emit: EmitEngineLoginEvent,
921
1023
  event: EngineLoginEventFrame,
@@ -0,0 +1,204 @@
1
+ /**
2
+ * ADR-114: the standard image is prebuilt in CI and pulled by digest.
3
+ *
4
+ * This module is the shared identity/grammar core, deliberately free of any
5
+ * host runtime import so the release-side identity script and the unit tests
6
+ * can load it without dragging the container-runtime graph along. Everything
7
+ * here is consumed from three places:
8
+ *
9
+ * - CI (`host-agent/scripts/release/standard-image-identity.ts`) computes
10
+ * the superset context hash and the registry tag to build/push.
11
+ * - Release packaging (`scripts/host-runtime/stage.mjs`) embeds a validated
12
+ * pin file in the signed payload (its .mjs copy of the grammar cites this
13
+ * module as authoritative).
14
+ * - The host (`standard-image.ts`) reads the packaged pin and pulls by
15
+ * digest instead of building, falling back to the local build.
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { readdir, readFile } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+
22
+ /** Image label carrying the build-context content hash (rebuild trigger). */
23
+ export const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
24
+
25
+ /** The public registry repository CI publishes the prebuilt image to. */
26
+ export const STANDARD_IMAGE_RELEASE_REF = "ghcr.io/runuai/uai-standard";
27
+
28
+ /** Pin file basename; packaged at `agent/images/<basename>` in the payload,
29
+ * one directory above the build context so it never perturbs the hash. */
30
+ export const STANDARD_IMAGE_PIN_BASENAME = "standard-image.pin.json";
31
+
32
+ /**
33
+ * The prebuilt image is the SUPERSET: every optional engine baked in. The
34
+ * layers are additive and non-fatal, a disabled engine is simply never
35
+ * invoked, and superset-vs-config means one published image serves every
36
+ * host — and toggling an engine on stops forcing a host-side rebuild.
37
+ */
38
+ export const STANDARD_IMAGE_SUPERSET_EXTRA =
39
+ "kimi=1;grok=1;cursor=1;opencode=1";
40
+
41
+ export const STANDARD_IMAGE_SUPERSET_BUILD_ARGS: readonly string[] = [
42
+ "INSTALL_KIMI=1",
43
+ "INSTALL_GROK=1",
44
+ "INSTALL_CURSOR=1",
45
+ "INSTALL_OPENCODE=1",
46
+ ];
47
+
48
+ export interface StandardImagePin {
49
+ readonly ref: string;
50
+ readonly digest: string;
51
+ readonly contextHash: string;
52
+ /**
53
+ * Per-architecture image digests. A host pulls ITS OWN architecture's
54
+ * digest — pulling the manifest-list digest stores a two-variant image on
55
+ * the Apple runtime, and task-up's apple guard refuses any task image with
56
+ * more than one variant (found live on the Air's first task, 2026-08-20).
57
+ * Optional so a list-only pin still works on Docker via `digest`.
58
+ */
59
+ readonly platforms?: { readonly arm64?: string; readonly x64?: string };
60
+ }
61
+
62
+ // The pin arrives inside a signature-verified payload; this grammar is
63
+ // defense-in-depth so no parsed field can smuggle registry ports, tags,
64
+ // or shell-significant bytes into an engine command line.
65
+ const REF_PATTERN = /^[a-z0-9]+(?:[a-z0-9._-]*[a-z0-9])?(?:\/[a-z0-9]+(?:[a-z0-9._-]*[a-z0-9])?)+$/;
66
+ const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
67
+ const CONTEXT_HASH_PATTERN = /^[a-f0-9]{32}$/;
68
+
69
+ /** Strict parse of a pin document; null on ANY deviation. */
70
+ export function parseStandardImagePin(raw: string): StandardImagePin | null {
71
+ let parsed: unknown;
72
+ try {
73
+ parsed = JSON.parse(raw);
74
+ } catch {
75
+ return null;
76
+ }
77
+ if (typeof parsed !== "object" || parsed === null) return null;
78
+ const { ref, digest, contextHash, platforms } = parsed as Record<
79
+ string,
80
+ unknown
81
+ >;
82
+ if (
83
+ typeof ref !== "string" ||
84
+ typeof digest !== "string" ||
85
+ typeof contextHash !== "string" ||
86
+ !REF_PATTERN.test(ref) ||
87
+ !DIGEST_PATTERN.test(digest) ||
88
+ !CONTEXT_HASH_PATTERN.test(contextHash)
89
+ ) {
90
+ return null;
91
+ }
92
+ let parsedPlatforms: { arm64?: string; x64?: string } | undefined;
93
+ if (platforms !== undefined) {
94
+ if (typeof platforms !== "object" || platforms === null) return null;
95
+ parsedPlatforms = {};
96
+ for (const arch of ["arm64", "x64"] as const) {
97
+ const value = (platforms as Record<string, unknown>)[arch];
98
+ if (value === undefined) continue;
99
+ if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) return null;
100
+ parsedPlatforms[arch] = value;
101
+ }
102
+ }
103
+ return {
104
+ ref,
105
+ digest,
106
+ contextHash,
107
+ ...(parsedPlatforms ? { platforms: parsedPlatforms } : {}),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * The digest a given host should pull: its own architecture's single-variant
113
+ * image when the pin carries one, else the manifest list. Docker resolves a
114
+ * list to the native variant; the Apple runtime stores the whole index, which
115
+ * task-up's one-variant guard then refuses — so arch digests win.
116
+ */
117
+ export function standardImagePullDigest(
118
+ pin: StandardImagePin,
119
+ arch: string = process.arch,
120
+ ): string {
121
+ if (arch === "arm64" || arch === "x64") {
122
+ const platformDigest = pin.platforms?.[arch];
123
+ if (platformDigest !== undefined) return platformDigest;
124
+ }
125
+ return pin.digest;
126
+ }
127
+
128
+ /** The exact engine argv for pulling and tagging the pinned image. Docker
129
+ * and the Apple `container` CLI differ only in the `image` subcommand prefix. */
130
+ export function standardImagePullCommands(
131
+ pin: StandardImagePin,
132
+ targetTag: string,
133
+ apple: boolean,
134
+ arch: string = process.arch,
135
+ ): { pull: string[]; tag: string[] } {
136
+ const source = `${pin.ref}@${standardImagePullDigest(pin, arch)}`;
137
+ return apple
138
+ ? {
139
+ pull: ["image", "pull", source],
140
+ tag: ["image", "tag", source, targetTag],
141
+ }
142
+ : { pull: ["pull", source], tag: ["tag", source, targetTag] };
143
+ }
144
+
145
+ /**
146
+ * Content-hash a standard-image build context: every file under the context
147
+ * dir, sorted by relative path, plus an `extra` string carrying the build-arg
148
+ * configuration (part of the image identity). Null when the context can't be
149
+ * read — callers keep whatever image exists.
150
+ */
151
+ export async function hashStandardImageContext(
152
+ contextDir: string,
153
+ extra = "",
154
+ ): Promise<string | null> {
155
+ try {
156
+ const files: string[] = [];
157
+ const walk = async (dir: string, prefix: string): Promise<void> => {
158
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
159
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
160
+ if (entry.isDirectory()) await walk(join(dir, entry.name), rel);
161
+ else files.push(rel);
162
+ }
163
+ };
164
+ await walk(contextDir, "");
165
+ files.sort();
166
+ const hash = createHash("sha256");
167
+ for (const rel of files) {
168
+ hash.update(rel);
169
+ hash.update("\0");
170
+ hash.update(await readFile(join(contextDir, rel)));
171
+ hash.update("\0");
172
+ }
173
+ hash.update(extra);
174
+ hash.update("\0");
175
+ return hash.digest("hex").slice(0, 32);
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /** Everything CI needs to build/push/pin one release image, or null when the
182
+ * context is unreadable (a broken checkout must fail the release loudly). */
183
+ export async function standardImageReleaseIdentity(
184
+ contextDir: string,
185
+ ): Promise<{
186
+ ref: string;
187
+ tag: string;
188
+ contextHash: string;
189
+ label: string;
190
+ buildArgs: readonly string[];
191
+ } | null> {
192
+ const contextHash = await hashStandardImageContext(
193
+ contextDir,
194
+ STANDARD_IMAGE_SUPERSET_EXTRA,
195
+ );
196
+ if (contextHash === null) return null;
197
+ return {
198
+ ref: STANDARD_IMAGE_RELEASE_REF,
199
+ tag: `ctx-${contextHash}`,
200
+ contextHash,
201
+ label: `${CONTEXT_HASH_LABEL}=${contextHash}`,
202
+ buildArgs: STANDARD_IMAGE_SUPERSET_BUILD_ARGS,
203
+ };
204
+ }
@@ -17,9 +17,8 @@
17
17
  */
18
18
 
19
19
  import { spawn } from "node:child_process";
20
- import { createHash } from "node:crypto";
21
20
  import { readFileSync, writeFileSync } from "node:fs";
22
- import { readdir, readFile } from "node:fs/promises";
21
+ import { readFile } from "node:fs/promises";
23
22
  import { dirname, join, resolve } from "node:path";
24
23
  import { fileURLToPath } from "node:url";
25
24
 
@@ -36,6 +35,15 @@ import {
36
35
  pinnedContainerRuntimeProvider,
37
36
  } from "./container-runtime";
38
37
  import { KeyedPromiseTail } from "./keyed-promise-tail";
38
+ import {
39
+ CONTEXT_HASH_LABEL,
40
+ hashStandardImageContext,
41
+ parseStandardImagePin,
42
+ STANDARD_IMAGE_PIN_BASENAME,
43
+ type StandardImagePin,
44
+ standardImagePullCommands,
45
+ standardImagePullDigest,
46
+ } from "./standard-image-pin";
39
47
 
40
48
  /** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
41
49
  export const STANDARD_IMAGE_TAG = "uai-standard:dev";
@@ -90,14 +98,6 @@ export function standardRuntimes(): Array<{
90
98
  }));
91
99
  }
92
100
 
93
- /** Image label carrying the build-context content hash (rebuild trigger). */
94
- const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
95
-
96
- /**
97
- * Content-hash the build context (every file under images/standard, sorted
98
- * by relative path). Null when the context can't be read — the caller then
99
- * keeps whatever image exists.
100
- */
101
101
  /**
102
102
  * Which OPTIONAL agent CLIs the operator has actually configured — so the
103
103
  * image installs only those, not every engine on every host. Delegates to
@@ -130,34 +130,36 @@ function optionalEngineConfigurationKey(
130
130
  return JSON.stringify(engines);
131
131
  }
132
132
 
133
- async function hashBuildContext(extra = ""): Promise<string | null> {
133
+ // Build args (which optional engines are installed) are part of the image
134
+ // identity — a config change must invalidate the label so it rebuilds. The
135
+ // walk-and-hash itself lives in standard-image-pin.ts so CI computes the
136
+ // identical identity (ADR-114).
137
+ function hashBuildContext(extra = ""): Promise<string | null> {
138
+ return hashStandardImageContext(standardImageDir(), extra);
139
+ }
140
+
141
+ /** The packaged digest pin (ADR-114) at `agent/images/standard-image.pin.json`,
142
+ * one level above the build context so it never perturbs the context hash.
143
+ * Absent in dev checkouts and npm installs — those keep the local build.
144
+ * `UAI_IMAGE_PULL=0` is the operator kill switch back to local semantics. */
145
+ async function readStandardImagePin(): Promise<StandardImagePin | null> {
146
+ if (process.env.UAI_IMAGE_PULL === "0") return null;
147
+ let raw: string;
134
148
  try {
135
- const root = standardImageDir();
136
- const files: string[] = [];
137
- const walk = async (dir: string, prefix: string): Promise<void> => {
138
- for (const entry of await readdir(dir, { withFileTypes: true })) {
139
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
140
- if (entry.isDirectory()) await walk(join(dir, entry.name), rel);
141
- else files.push(rel);
142
- }
143
- };
144
- await walk(root, "");
145
- files.sort();
146
- const hash = createHash("sha256");
147
- for (const rel of files) {
148
- hash.update(rel);
149
- hash.update("\0");
150
- hash.update(await readFile(join(root, rel)));
151
- hash.update("\0");
152
- }
153
- // Build args (which optional engines are installed) are part of the image
154
- // identity — a config change must invalidate the label so it rebuilds.
155
- hash.update(extra);
156
- hash.update("\0");
157
- return hash.digest("hex").slice(0, 32);
149
+ raw = await readFile(
150
+ resolve(standardImageDir(), "..", STANDARD_IMAGE_PIN_BASENAME),
151
+ "utf8",
152
+ );
158
153
  } catch {
159
154
  return null;
160
155
  }
156
+ const pin = parseStandardImagePin(raw);
157
+ if (pin === null) {
158
+ console.warn(
159
+ "[host-agent] packaged standard-image pin is malformed; using the local build path",
160
+ );
161
+ }
162
+ return pin;
161
163
  }
162
164
 
163
165
  /** Absolute path to the standard image build context. */
@@ -201,7 +203,18 @@ interface RunResult {
201
203
 
202
204
  const SHORT_DOCKER_TIMEOUT_MS = 30_000;
203
205
  const CLI_MAINTENANCE_TIMEOUT_MS = 10 * 60_000;
204
- const IMAGE_BUILD_TIMEOUT_MS = 15 * 60_000;
206
+ // Overridable ceiling: the hard-coded 15 minutes looped a fanless Air forever
207
+ // (live 2026-08-19 — every attempt died at 15:00 and the activation driver
208
+ // silently restarted it). Slow hardware needs a bigger window, not a loop.
209
+ const IMAGE_BUILD_TIMEOUT_MS = imageBuildTimeoutMs();
210
+ function imageBuildTimeoutMs(): number {
211
+ const minutes = Number(process.env.UAI_IMAGE_BUILD_TIMEOUT_MINUTES ?? "");
212
+ if (Number.isFinite(minutes) && minutes >= 1) {
213
+ return Math.min(minutes, 240) * 60_000;
214
+ }
215
+ return 15 * 60_000;
216
+ }
217
+ const IMAGE_PULL_TIMEOUT_MS = 15 * 60_000;
205
218
  const TERMINATE_GRACE_MS = 5_000;
206
219
  export const ASDF_MAINTENANCE_LOCK_PATH =
207
220
  "/opt/asdf-data/.uai-maintenance.lock";
@@ -1233,7 +1246,59 @@ async function ensureStandardImageInner(
1233
1246
  };
1234
1247
  }
1235
1248
  if (inspect.code !== 0) labeledHash = null;
1236
- if (inspect.code === 0 && contextHash !== null && labeledHash === contextHash) {
1249
+
1250
+ // ADR-114: when the payload pins a prebuilt digest, the pin decides image
1251
+ // identity — the label must equal the PIN's context hash (the CI superset
1252
+ // hash, not this host's per-config hash, which would disagree by design
1253
+ // and rebuild forever). Any pull-path failure falls through to the local
1254
+ // build below, so offline and registry-down hosts behave exactly as today.
1255
+ const pin = await readStandardImagePin();
1256
+ if (pin !== null) {
1257
+ if (inspect.code === 0 && labeledHash === pin.contextHash) {
1258
+ console.log(
1259
+ `[host-agent] standard image ${STANDARD_IMAGE_TAG} current (pinned)`,
1260
+ );
1261
+ imageReady = true;
1262
+ } else {
1263
+ const pullDigest = standardImagePullDigest(pin);
1264
+ console.log(
1265
+ `[host-agent] pulling standard image ${pin.ref}@${pullDigest}`,
1266
+ );
1267
+ const commands = standardImagePullCommands(
1268
+ pin,
1269
+ STANDARD_IMAGE_TAG,
1270
+ engine.apple,
1271
+ );
1272
+ const pulled = await run(
1273
+ engine.command,
1274
+ commands.pull,
1275
+ IMAGE_PULL_TIMEOUT_MS,
1276
+ );
1277
+ if (pulled.code === 0) {
1278
+ const tagged = await run(engine.command, commands.tag);
1279
+ if (tagged.code === 0) {
1280
+ console.log(
1281
+ `[host-agent] pulled standard image ${STANDARD_IMAGE_TAG} (${pullDigest.slice(0, 19)}…)`,
1282
+ );
1283
+ imageReady = true;
1284
+ } else {
1285
+ console.warn(
1286
+ "[host-agent] pulled standard image could not be tagged; " +
1287
+ `falling back to a local build. ${tagged.stderr.trim()}`,
1288
+ );
1289
+ }
1290
+ } else {
1291
+ console.warn(
1292
+ "[host-agent] standard image pull failed; falling back to a " +
1293
+ `local build. ${pulled.stderr.trim()}`,
1294
+ );
1295
+ }
1296
+ }
1297
+ }
1298
+
1299
+ if (imageReady) {
1300
+ // Pinned image adopted — skip the local-hash decision entirely.
1301
+ } else if (inspect.code === 0 && contextHash !== null && labeledHash === contextHash) {
1237
1302
  console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} current`);
1238
1303
  imageReady = true;
1239
1304
  } else if (inspect.code === 0 && contextHash === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.46",
3
+ "version": "0.9.48",
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>",
@@ -667,9 +667,10 @@ git_host_control() (
667
667
  # This helper is for local repository bookkeeping and public/local remotes.
668
668
  # It must never inherit the operator's SSH agent or credential helper. Every
669
669
  # authenticated GitHub fetch goes through one of the explicit helpers above.
670
- GIT_CONFIG_COUNT=2 \
670
+ GIT_CONFIG_COUNT=3 \
671
671
  GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null \
672
672
  GIT_CONFIG_KEY_1=core.fsmonitor GIT_CONFIG_VALUE_1=false \
673
+ GIT_CONFIG_KEY_2=advice.defaultBranchName GIT_CONFIG_VALUE_2=false \
673
674
  GIT_SSH_COMMAND=/bin/false GIT_TERMINAL_PROMPT=0 git \
674
675
  -c credential.helper= \
675
676
  "$@"
package/src/index.ts CHANGED
@@ -101,6 +101,8 @@ export type {
101
101
  TaskUpResult,
102
102
  } from "../lib/agent";
103
103
 
104
+ import { removeEngineAccount } from "../lib/engine-accounts";
105
+
104
106
  export {
105
107
  parseMentions,
106
108
  resolveAddressing,
@@ -1029,6 +1031,22 @@ export const hostCommands: HostCommands = {
1029
1031
  });
1030
1032
  },
1031
1033
 
1034
+ // ADR-116: remove a labeled engine account. Delegates to the same internals
1035
+ // the local UI uses; `default:*` refusal and idempotency live there. The
1036
+ // dispatcher re-advertises capabilities after an ok result.
1037
+ async engineAccountRemove(ctx, input) {
1038
+ logCommand(ctx, "engineAccountRemove", input.id);
1039
+ try {
1040
+ const result = removeEngineAccount(input.id);
1041
+ if (!result.ok) {
1042
+ return ok({ removed: false, message: result.message });
1043
+ }
1044
+ return ok({ removed: true });
1045
+ } catch (err) {
1046
+ return failFromUnknown(err);
1047
+ }
1048
+ },
1049
+
1032
1050
  async channelResolvePermission(ctx, taskId, agentId, requestId, decision) {
1033
1051
  logCommand(ctx, "channelResolvePermission", taskId, agentId, requestId);
1034
1052
  try {
package/src/main.ts CHANGED
@@ -143,6 +143,7 @@ import {
143
143
  patchHostConfig,
144
144
  type HostConfigResult,
145
145
  } from "../lib/host-config";
146
+ import { listEngineAccounts } from "../lib/engine-accounts";
146
147
  import { createEngineLoginManager } from "../lib/engine-login";
147
148
  // Importing the real factory triggers the built-in adapters' register()
148
149
  // calls (claude, codex), so the registry is populated before we advertise.
@@ -164,9 +165,13 @@ import {
164
165
  GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
165
166
  HOST_MAINTENANCE_READINESS_PROTOCOL_FEATURE,
166
167
  HOST_CONFIG_PROTOCOL_FEATURE,
168
+ HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
167
169
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
168
170
  HOST_LOGS_PROTOCOL_FEATURE,
169
171
  HOST_TASK_INVENTORY_PROTOCOL_FEATURE,
172
+ MAX_ENGINE_ACCOUNT_LABEL_CHARS,
173
+ MAX_ENGINE_ACCOUNTS_ADVERTISED,
174
+ type EngineAccountSummary,
170
175
  MAX_HOST_TASK_INVENTORY_PAGE_SIZE,
171
176
  MCP_GATEWAY_HEALTH_PROTOCOL_FEATURE,
172
177
  SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
@@ -543,6 +548,7 @@ function buildCapabilities(): HostCapabilities {
543
548
  HOST_LOGS_PROTOCOL_FEATURE,
544
549
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
545
550
  HOST_CONFIG_PROTOCOL_FEATURE,
551
+ HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
546
552
  // The echo adapter cannot execute the in-task CLI. Advertising typed
547
553
  // dispatch in mock mode would let the composer create a Secretary that
548
554
  // has no way to wake crew.
@@ -558,10 +564,34 @@ function buildCapabilities(): HostCapabilities {
558
564
  maintenanceReady: areAgentClisReady(),
559
565
  mcpGateway: mcpGateway.state(),
560
566
  engineLogins: engineLoginManager.capabilities(),
567
+ engineAccounts: engineAccountSummaries(),
561
568
  githubUsers: connectedUserIds(),
562
569
  };
563
570
  }
564
571
 
572
+ /** ADR-116: the labeled-accounts advertisement — bounded, labels-only. */
573
+ function engineAccountSummaries(): EngineAccountSummary[] {
574
+ const summaries: EngineAccountSummary[] = [];
575
+ for (const kind of ["claude", "codex", "opencode"] as const) {
576
+ try {
577
+ for (const account of listEngineAccounts(kind)) {
578
+ summaries.push({
579
+ id: account.id,
580
+ kind,
581
+ label: account.label.slice(0, MAX_ENGINE_ACCOUNT_LABEL_CHARS),
582
+ authKind: account.authKind,
583
+ isDefault: account.isDefault,
584
+ });
585
+ }
586
+ } catch (error) {
587
+ console.warn(
588
+ `[host-agent] listing ${kind} accounts for capabilities failed: ${error instanceof Error ? error.message : String(error)}`,
589
+ );
590
+ }
591
+ }
592
+ return summaries.slice(0, MAX_ENGINE_ACCOUNTS_ADVERTISED);
593
+ }
594
+
565
595
  /**
566
596
  * Re-advertise capabilities on the active connection. Safe to call any time;
567
597
  * a no-op when no socket is connected/open (the next `connect()` re-sends on
@@ -1074,6 +1104,7 @@ async function connect(): Promise<void> {
1074
1104
  frame.opId,
1075
1105
  frame.engine,
1076
1106
  emitEngineLoginEvent,
1107
+ frame.label,
1077
1108
  );
1078
1109
  break;
1079
1110
  case "engine.login.input":
@@ -2093,7 +2124,7 @@ function sendTunnelData(
2093
2124
  }
2094
2125
  }
2095
2126
 
2096
- function dispatchCommand(
2127
+ async function dispatchCommand(
2097
2128
  ctx: CommandContext,
2098
2129
  command: keyof HostCommands,
2099
2130
  args: unknown[],
@@ -2163,7 +2194,34 @@ function dispatchCommand(
2163
2194
  expectString(args, 1),
2164
2195
  expectNumberArg(args, 2),
2165
2196
  );
2197
+ case "engineAccountRemove": {
2198
+ // ADR-116: mirror the local API's post-mutation behavior — the cloud's
2199
+ // account list self-heals through the capability re-advertisement.
2200
+ const removal = await hostCommands.engineAccountRemove(
2201
+ ctx,
2202
+ expectEngineAccountRemoveInput(args, 0),
2203
+ );
2204
+ if (removal.ok) sendCapabilities();
2205
+ return removal;
2206
+ }
2207
+ }
2208
+ }
2209
+
2210
+ function expectEngineAccountRemoveInput(
2211
+ args: unknown[],
2212
+ index: number,
2213
+ ): { id: string } {
2214
+ const value = args[index];
2215
+ if (
2216
+ !value ||
2217
+ typeof value !== "object" ||
2218
+ typeof (value as { id?: unknown }).id !== "string" ||
2219
+ (value as { id: string }).id.length === 0 ||
2220
+ (value as { id: string }).id.length > 256
2221
+ ) {
2222
+ throw new Error(`invalid command args: expected account id at ${index}`);
2166
2223
  }
2224
+ return { id: (value as { id: string }).id };
2167
2225
  }
2168
2226
 
2169
2227
  function expectNumberArg(args: unknown[], index: number): number {
@@ -2655,6 +2713,7 @@ function isHostCommand(command: string): command is keyof HostCommands {
2655
2713
  "channelInterrupt",
2656
2714
  "appendTranscript",
2657
2715
  "previewEnsure",
2716
+ "engineAccountRemove",
2658
2717
  ].includes(command);
2659
2718
  }
2660
2719
 
package/src/protocol.ts CHANGED
@@ -86,6 +86,12 @@ export const MCP_GATEWAY_HEALTH_PROTOCOL_FEATURE = "mcp-gateway-health-v1";
86
86
  export const HOST_LOGS_PROTOCOL_FEATURE = "host-logs-v1";
87
87
  export const HOST_ENGINE_LOGIN_PROTOCOL_FEATURE = "host-engine-login-v1";
88
88
  export const HOST_CONFIG_PROTOCOL_FEATURE = "host-config-v1";
89
+ /** ADR-116: labeled engine accounts on the cloud host page — account list in
90
+ * capabilities, account-targeted login (`label` on engine.login.start), and
91
+ * the engineAccountRemove command. */
92
+ export const HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE = "host-engine-accounts-v1";
93
+ export const MAX_ENGINE_ACCOUNT_LABEL_CHARS = 64;
94
+ export const MAX_ENGINE_ACCOUNTS_ADVERTISED = 48;
89
95
  export const MAX_HOST_OP_ID_CHARS = 128;
90
96
  export const MAX_HOST_LOG_LINES = 1_000;
91
97
  export const MAX_HOST_LOG_LINE_BYTES = 4 * 1_024;
@@ -282,6 +288,10 @@ export interface EngineLoginStartFrame {
282
288
  kind: "engine.login.start";
283
289
  opId: HostOpId;
284
290
  engine: EngineLoginKind;
291
+ /** ADR-116: capture into a labeled extra account instead of the default
292
+ * slot. Only sent to hosts advertising `host-engine-accounts-v1`; the v1
293
+ * exact-shape guard on older hosts rejects frames carrying it. */
294
+ label?: string;
285
295
  }
286
296
 
287
297
  /** The only generic one-time response channel. Long-lived tokens must never
@@ -343,6 +353,16 @@ export interface EngineLoginCapability {
343
353
  loginSupported: true;
344
354
  }
345
355
 
356
+ /** ADR-116: one labeled engine account, as advertised in capabilities.
357
+ * Operator-chosen label + shape metadata only — never credentials. */
358
+ export interface EngineAccountSummary {
359
+ id: string;
360
+ kind: "claude" | "codex" | "opencode";
361
+ label: string;
362
+ authKind: "env" | "config-dir";
363
+ isDefault: boolean;
364
+ }
365
+
346
366
  /** The exact local host settings ADR-100 permits over the bridge. */
347
367
  export interface HostConfigState {
348
368
  agentCliAutoupdate: boolean;
@@ -530,12 +550,26 @@ export function isHostLogsEndFrame(value: unknown): value is HostLogsEndFrame {
530
550
  export function isEngineLoginStartFrame(
531
551
  value: unknown,
532
552
  ): value is EngineLoginStartFrame {
533
- const frame = exactWireObject(value, ["kind", "opId", "engine"]);
553
+ const frame = exactWireObject(value, ["kind", "opId", "engine"], ["label"]);
534
554
  return Boolean(
535
555
  frame &&
536
556
  frame.kind === "engine.login.start" &&
537
557
  isHostOpId(frame.opId) &&
538
- isEngineLoginKind(frame.engine),
558
+ isEngineLoginKind(frame.engine) &&
559
+ (frame.label === undefined || isEngineAccountLabel(frame.label)),
560
+ );
561
+ }
562
+
563
+ /** ADR-116 account labels: operator-chosen display names, never secrets.
564
+ * Single-line, bounded, and visibly printable so they render safely in every
565
+ * surface (capability frames, UI chips, log lines). */
566
+ export function isEngineAccountLabel(value: unknown): value is string {
567
+ return (
568
+ typeof value === "string" &&
569
+ value.length >= 1 &&
570
+ value.length <= MAX_ENGINE_ACCOUNT_LABEL_CHARS &&
571
+ value.trim() === value &&
572
+ !/[\u0000-\u001f\u007f]/.test(value)
539
573
  );
540
574
  }
541
575
 
@@ -766,6 +800,9 @@ export interface HostCapabilities {
766
800
  mcpGateway?: McpGatewayCapability;
767
801
  /** ADR-100: feature-gated, secret-free remote login availability. */
768
802
  engineLogins?: EngineLoginCapability[];
803
+ /** ADR-116: labeled engine accounts (default + extras), re-advertised on
804
+ * every account mutation. Labels only — credentials never leave the host. */
805
+ engineAccounts?: EngineAccountSummary[];
769
806
  // ADR-033: cloud user ids that currently have a GitHub token ON THIS HOST —
770
807
  // the per-host gh-connected state shown on the host detail page. Re-advertised
771
808
  // whenever a token is added/removed. Optional: older hosts omit it.
@@ -1109,6 +1146,12 @@ export interface HostCommands {
1109
1146
  name: string,
1110
1147
  containerPort: number,
1111
1148
  ): Promise<HostCommandResult<{ hostPort: number | null }>>;
1149
+ /** ADR-116: remove a labeled engine account by id. Refuses `default:*`
1150
+ * exactly like the local API; re-advertises capabilities on success. */
1151
+ engineAccountRemove(
1152
+ ctx: CommandContext,
1153
+ input: { id: string },
1154
+ ): Promise<HostCommandResult<{ removed: boolean; message?: string }>>;
1112
1155
  cloneRepo(
1113
1156
  ctx: CommandContext,
1114
1157
  input: CloneRepoInput,