@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.
- package/lib/release-trust.ts +21 -4
- package/lib/standard-image-pin.ts +159 -0
- package/lib/standard-image.ts +99 -36
- package/package.json +3 -3
package/lib/release-trust.ts
CHANGED
|
@@ -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.
|
|
21
|
-
*
|
|
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 =
|
|
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
|
+
}
|
package/lib/standard-image.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Uai host — runs ephemeral AI
|
|
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": "
|
|
6
|
+
"author": "Uai Tech <team@runuai.com>",
|
|
7
7
|
"homepage": "https://github.com/runuai/uai#readme",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|