@runuai/host 0.9.45 → 0.9.47

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.
@@ -17,12 +17,29 @@ export const STABLE_RELEASE_MANIFEST_URL =
17
17
  * Provisioning a key is necessary but not sufficient to advertise or serve
18
18
  * the one-line installer. The release coordinator flips this only in the same
19
19
  * reviewed change that publishes the complete, version-matched four-platform
20
- * release. Keeping it false while this stack is under review makes accidental
21
- * key/feed provisioning fail closed.
20
+ * release. Flipped 2026-08-19 in the change that provisioned
21
+ * `uai-release-2026-a` and wired auto-release to sign and publish the
22
+ * four-platform feed on every host version bump.
22
23
  */
23
- export const HOST_INSTALLER_RELEASE_PROMOTED = false;
24
+ export const HOST_INSTALLER_RELEASE_PROMOTED = true;
24
25
 
26
+ /**
27
+ * `uai-release-2026-a`: minted by the operator 2026-08-19 (Ed25519; private
28
+ * key lives in Infisical and reaches CI as UAI_RELEASE_SIGNING_KEY). Rotation
29
+ * is additive: pin the successor key beside this one, publish releases signed
30
+ * by it, and retire this entry only after every supported host updated past
31
+ * a release that pins both.
32
+ */
25
33
  export const PINNED_RELEASE_MANIFEST_KEYS: ReadonlyMap<
26
34
  string,
27
35
  PinnedReleaseManifestKey
28
- > = new Map();
36
+ > = new Map([
37
+ [
38
+ "uai-release-2026-a",
39
+ {
40
+ publicKey: `-----BEGIN PUBLIC KEY-----
41
+ MCowBQYDK2VwAyEAeCBzaLTNYNGkI59T+vNu2M/DMTeTsmfABDtwN9IDejY=
42
+ -----END PUBLIC KEY-----`,
43
+ },
44
+ ],
45
+ ]);
@@ -0,0 +1,159 @@
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
+
54
+ // The pin arrives inside a signature-verified payload; this grammar is
55
+ // defense-in-depth so no parsed field can smuggle registry ports, tags,
56
+ // or shell-significant bytes into an engine command line.
57
+ const REF_PATTERN = /^[a-z0-9]+(?:[a-z0-9._-]*[a-z0-9])?(?:\/[a-z0-9]+(?:[a-z0-9._-]*[a-z0-9])?)+$/;
58
+ const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
59
+ const CONTEXT_HASH_PATTERN = /^[a-f0-9]{32}$/;
60
+
61
+ /** Strict parse of a pin document; null on ANY deviation. */
62
+ export function parseStandardImagePin(raw: string): StandardImagePin | null {
63
+ let parsed: unknown;
64
+ try {
65
+ parsed = JSON.parse(raw);
66
+ } catch {
67
+ return null;
68
+ }
69
+ if (typeof parsed !== "object" || parsed === null) return null;
70
+ const { ref, digest, contextHash } = parsed as Record<string, unknown>;
71
+ if (
72
+ typeof ref !== "string" ||
73
+ typeof digest !== "string" ||
74
+ typeof contextHash !== "string" ||
75
+ !REF_PATTERN.test(ref) ||
76
+ !DIGEST_PATTERN.test(digest) ||
77
+ !CONTEXT_HASH_PATTERN.test(contextHash)
78
+ ) {
79
+ return null;
80
+ }
81
+ return { ref, digest, contextHash };
82
+ }
83
+
84
+ /** The exact engine argv for pulling and tagging the pinned image. Docker
85
+ * and the Apple `container` CLI differ only in the `image` subcommand prefix. */
86
+ export function standardImagePullCommands(
87
+ pin: StandardImagePin,
88
+ targetTag: string,
89
+ apple: boolean,
90
+ ): { pull: string[]; tag: string[] } {
91
+ const source = `${pin.ref}@${pin.digest}`;
92
+ return apple
93
+ ? {
94
+ pull: ["image", "pull", source],
95
+ tag: ["image", "tag", source, targetTag],
96
+ }
97
+ : { pull: ["pull", source], tag: ["tag", source, targetTag] };
98
+ }
99
+
100
+ /**
101
+ * Content-hash a standard-image build context: every file under the context
102
+ * dir, sorted by relative path, plus an `extra` string carrying the build-arg
103
+ * configuration (part of the image identity). Null when the context can't be
104
+ * read — callers keep whatever image exists.
105
+ */
106
+ export async function hashStandardImageContext(
107
+ contextDir: string,
108
+ extra = "",
109
+ ): Promise<string | null> {
110
+ try {
111
+ const files: string[] = [];
112
+ const walk = async (dir: string, prefix: string): Promise<void> => {
113
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
114
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
115
+ if (entry.isDirectory()) await walk(join(dir, entry.name), rel);
116
+ else files.push(rel);
117
+ }
118
+ };
119
+ await walk(contextDir, "");
120
+ files.sort();
121
+ const hash = createHash("sha256");
122
+ for (const rel of files) {
123
+ hash.update(rel);
124
+ hash.update("\0");
125
+ hash.update(await readFile(join(contextDir, rel)));
126
+ hash.update("\0");
127
+ }
128
+ hash.update(extra);
129
+ hash.update("\0");
130
+ return hash.digest("hex").slice(0, 32);
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /** Everything CI needs to build/push/pin one release image, or null when the
137
+ * context is unreadable (a broken checkout must fail the release loudly). */
138
+ export async function standardImageReleaseIdentity(
139
+ contextDir: string,
140
+ ): Promise<{
141
+ ref: string;
142
+ tag: string;
143
+ contextHash: string;
144
+ label: string;
145
+ buildArgs: readonly string[];
146
+ } | null> {
147
+ const contextHash = await hashStandardImageContext(
148
+ contextDir,
149
+ STANDARD_IMAGE_SUPERSET_EXTRA,
150
+ );
151
+ if (contextHash === null) return null;
152
+ return {
153
+ ref: STANDARD_IMAGE_RELEASE_REF,
154
+ tag: `ctx-${contextHash}`,
155
+ contextHash,
156
+ label: `${CONTEXT_HASH_LABEL}=${contextHash}`,
157
+ buildArgs: STANDARD_IMAGE_SUPERSET_BUILD_ARGS,
158
+ };
159
+ }
@@ -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,14 @@ 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
+ } from "./standard-image-pin";
39
46
 
40
47
  /** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
41
48
  export const STANDARD_IMAGE_TAG = "uai-standard:dev";
@@ -90,14 +97,6 @@ export function standardRuntimes(): Array<{
90
97
  }));
91
98
  }
92
99
 
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
100
  /**
102
101
  * Which OPTIONAL agent CLIs the operator has actually configured — so the
103
102
  * image installs only those, not every engine on every host. Delegates to
@@ -130,34 +129,36 @@ function optionalEngineConfigurationKey(
130
129
  return JSON.stringify(engines);
131
130
  }
132
131
 
133
- async function hashBuildContext(extra = ""): Promise<string | null> {
132
+ // Build args (which optional engines are installed) are part of the image
133
+ // identity — a config change must invalidate the label so it rebuilds. The
134
+ // walk-and-hash itself lives in standard-image-pin.ts so CI computes the
135
+ // identical identity (ADR-114).
136
+ function hashBuildContext(extra = ""): Promise<string | null> {
137
+ return hashStandardImageContext(standardImageDir(), extra);
138
+ }
139
+
140
+ /** The packaged digest pin (ADR-114) at `agent/images/standard-image.pin.json`,
141
+ * one level above the build context so it never perturbs the context hash.
142
+ * Absent in dev checkouts and npm installs — those keep the local build.
143
+ * `UAI_IMAGE_PULL=0` is the operator kill switch back to local semantics. */
144
+ async function readStandardImagePin(): Promise<StandardImagePin | null> {
145
+ if (process.env.UAI_IMAGE_PULL === "0") return null;
146
+ let raw: string;
134
147
  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);
148
+ raw = await readFile(
149
+ resolve(standardImageDir(), "..", STANDARD_IMAGE_PIN_BASENAME),
150
+ "utf8",
151
+ );
158
152
  } catch {
159
153
  return null;
160
154
  }
155
+ const pin = parseStandardImagePin(raw);
156
+ if (pin === null) {
157
+ console.warn(
158
+ "[host-agent] packaged standard-image pin is malformed; using the local build path",
159
+ );
160
+ }
161
+ return pin;
161
162
  }
162
163
 
163
164
  /** Absolute path to the standard image build context. */
@@ -201,7 +202,18 @@ interface RunResult {
201
202
 
202
203
  const SHORT_DOCKER_TIMEOUT_MS = 30_000;
203
204
  const CLI_MAINTENANCE_TIMEOUT_MS = 10 * 60_000;
204
- const IMAGE_BUILD_TIMEOUT_MS = 15 * 60_000;
205
+ // Overridable ceiling: the hard-coded 15 minutes looped a fanless Air forever
206
+ // (live 2026-08-19 — every attempt died at 15:00 and the activation driver
207
+ // silently restarted it). Slow hardware needs a bigger window, not a loop.
208
+ const IMAGE_BUILD_TIMEOUT_MS = imageBuildTimeoutMs();
209
+ function imageBuildTimeoutMs(): number {
210
+ const minutes = Number(process.env.UAI_IMAGE_BUILD_TIMEOUT_MINUTES ?? "");
211
+ if (Number.isFinite(minutes) && minutes >= 1) {
212
+ return Math.min(minutes, 240) * 60_000;
213
+ }
214
+ return 15 * 60_000;
215
+ }
216
+ const IMAGE_PULL_TIMEOUT_MS = 15 * 60_000;
205
217
  const TERMINATE_GRACE_MS = 5_000;
206
218
  export const ASDF_MAINTENANCE_LOCK_PATH =
207
219
  "/opt/asdf-data/.uai-maintenance.lock";
@@ -1233,7 +1245,58 @@ async function ensureStandardImageInner(
1233
1245
  };
1234
1246
  }
1235
1247
  if (inspect.code !== 0) labeledHash = null;
1236
- if (inspect.code === 0 && contextHash !== null && labeledHash === contextHash) {
1248
+
1249
+ // ADR-114: when the payload pins a prebuilt digest, the pin decides image
1250
+ // identity — the label must equal the PIN's context hash (the CI superset
1251
+ // hash, not this host's per-config hash, which would disagree by design
1252
+ // and rebuild forever). Any pull-path failure falls through to the local
1253
+ // build below, so offline and registry-down hosts behave exactly as today.
1254
+ const pin = await readStandardImagePin();
1255
+ if (pin !== null) {
1256
+ if (inspect.code === 0 && labeledHash === pin.contextHash) {
1257
+ console.log(
1258
+ `[host-agent] standard image ${STANDARD_IMAGE_TAG} current (pinned)`,
1259
+ );
1260
+ imageReady = true;
1261
+ } else {
1262
+ console.log(
1263
+ `[host-agent] pulling standard image ${pin.ref}@${pin.digest}`,
1264
+ );
1265
+ const commands = standardImagePullCommands(
1266
+ pin,
1267
+ STANDARD_IMAGE_TAG,
1268
+ engine.apple,
1269
+ );
1270
+ const pulled = await run(
1271
+ engine.command,
1272
+ commands.pull,
1273
+ IMAGE_PULL_TIMEOUT_MS,
1274
+ );
1275
+ if (pulled.code === 0) {
1276
+ const tagged = await run(engine.command, commands.tag);
1277
+ if (tagged.code === 0) {
1278
+ console.log(
1279
+ `[host-agent] pulled standard image ${STANDARD_IMAGE_TAG} (${pin.digest.slice(0, 19)}…)`,
1280
+ );
1281
+ imageReady = true;
1282
+ } else {
1283
+ console.warn(
1284
+ "[host-agent] pulled standard image could not be tagged; " +
1285
+ `falling back to a local build. ${tagged.stderr.trim()}`,
1286
+ );
1287
+ }
1288
+ } else {
1289
+ console.warn(
1290
+ "[host-agent] standard image pull failed; falling back to a " +
1291
+ `local build. ${pulled.stderr.trim()}`,
1292
+ );
1293
+ }
1294
+ }
1295
+ }
1296
+
1297
+ if (imageReady) {
1298
+ // Pinned image adopted — skip the local-hash decision entirely.
1299
+ } else if (inspect.code === 0 && contextHash !== null && labeledHash === contextHash) {
1237
1300
  console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} current`);
1238
1301
  imageReady = true;
1239
1302
  } else if (inspect.code === 0 && contextHash === null) {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.45",
4
- "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
3
+ "version": "0.9.47",
4
+ "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
- "author": "Diogo Perillo <diogo.perillo@gmail.com>",
6
+ "author": "Uai Tech <team@runuai.com>",
7
7
  "homepage": "https://github.com/runuai/uai#readme",
8
8
  "repository": {
9
9
  "type": "git",