@runuai/host 0.9.13 → 0.9.42
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/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +69 -4
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3070 -223
- package/lib/preview-sidecar.ts +57 -13
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +945 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +443 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1463 -109
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +871 -50
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- package/src/ui/types.ts +37 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
createPublicKey,
|
|
4
|
+
KeyObject,
|
|
5
|
+
timingSafeEqual,
|
|
6
|
+
verify as verifySignature,
|
|
7
|
+
} from "node:crypto";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Signed host-runtime release manifests (ADR-099).
|
|
11
|
+
*
|
|
12
|
+
* The manifest is an untrusted network input. Keep this module deliberately
|
|
13
|
+
* independent of the downloader: callers must pin the public keys in their
|
|
14
|
+
* shipped code, verify the manifest bytes here, and verify the downloaded
|
|
15
|
+
* artifact before unpacking it.
|
|
16
|
+
*
|
|
17
|
+
* Bootstrap is intentionally different from self-update: a fresh POSIX-sh
|
|
18
|
+
* machine has no portable Ed25519 verifier. The cloud `/install` route uses
|
|
19
|
+
* this Node module, checks the manifest version against its deployed host
|
|
20
|
+
* version, and embeds all four authenticated artifact tuples in a no-store
|
|
21
|
+
* script. The shell selects with uname and checks size/sha256. Once bundled
|
|
22
|
+
* Node exists, the updater verifies the signed manifest here directly.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const RELEASE_MANIFEST_SCHEMA_VERSION = 1 as const;
|
|
26
|
+
export const MAX_RELEASE_MANIFEST_BYTES = 64 * 1024;
|
|
27
|
+
export const MAX_RELEASE_ARTIFACT_BYTES = 512 * 1024 * 1024;
|
|
28
|
+
/**
|
|
29
|
+
* The source repository is private. Runtime artifacts belong in a dedicated,
|
|
30
|
+
* release-artifact-only public feed so a fresh machine can download them
|
|
31
|
+
* without a GitHub credential. The artifact still contains the same host
|
|
32
|
+
* sources/assets published to npm; the separate feed excludes private Git
|
|
33
|
+
* history and unrelated monorepo content. Provisioning it is intentionally
|
|
34
|
+
* separate from this code change.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_RELEASE_DOWNLOAD_BASE =
|
|
37
|
+
"https://github.com/runuai/uai-host-releases/releases/download";
|
|
38
|
+
|
|
39
|
+
export const RELEASE_PLATFORMS = Object.freeze([
|
|
40
|
+
"darwin-arm64",
|
|
41
|
+
"darwin-x64",
|
|
42
|
+
"linux-arm64",
|
|
43
|
+
"linux-x64",
|
|
44
|
+
] as const);
|
|
45
|
+
|
|
46
|
+
export type ReleasePlatform = (typeof RELEASE_PLATFORMS)[number];
|
|
47
|
+
|
|
48
|
+
export interface ReleaseArtifact {
|
|
49
|
+
readonly url: string;
|
|
50
|
+
/** Exact byte length of the final compressed tarball. */
|
|
51
|
+
readonly size: number;
|
|
52
|
+
/** Lowercase sha256 of the final compressed tarball bytes. */
|
|
53
|
+
readonly sha256: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ReleaseArtifacts = Readonly<
|
|
57
|
+
Record<ReleasePlatform, ReleaseArtifact>
|
|
58
|
+
>;
|
|
59
|
+
|
|
60
|
+
export interface ReleaseManifestPayload {
|
|
61
|
+
readonly schemaVersion: typeof RELEASE_MANIFEST_SCHEMA_VERSION;
|
|
62
|
+
readonly keyId: string;
|
|
63
|
+
readonly version: string;
|
|
64
|
+
readonly files: ReleaseArtifacts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SignedReleaseManifest extends ReleaseManifestPayload {
|
|
68
|
+
/** Canonical RFC 4648 base64. Ed25519 signatures are exactly 64 bytes. */
|
|
69
|
+
readonly signature: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A caller-owned trust anchor. This list must be compiled into the installer
|
|
74
|
+
* or runtime; a key delivered beside the manifest is not trusted.
|
|
75
|
+
*/
|
|
76
|
+
export interface PinnedReleaseManifestKey {
|
|
77
|
+
readonly publicKey: string | Buffer | KeyObject;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ReleaseManifestVerificationOptions {
|
|
81
|
+
/** Map keys are the pinned key ids; manifest-provided key material is ignored. */
|
|
82
|
+
readonly trustedKeys: ReadonlyMap<string, PinnedReleaseManifestKey>;
|
|
83
|
+
readonly releaseBaseUrl?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ReleaseArtifactIntegrity {
|
|
87
|
+
readonly size: number;
|
|
88
|
+
/** A lowercase hex digest or the raw 32-byte sha256 digest. */
|
|
89
|
+
readonly sha256: string | Uint8Array;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type ReleaseManifestErrorCode =
|
|
93
|
+
| "manifest_too_large"
|
|
94
|
+
| "invalid_encoding"
|
|
95
|
+
| "invalid_json"
|
|
96
|
+
| "invalid_schema"
|
|
97
|
+
| "invalid_trust_store"
|
|
98
|
+
| "unknown_key"
|
|
99
|
+
| "invalid_signature"
|
|
100
|
+
| "artifact_too_large"
|
|
101
|
+
| "artifact_size_mismatch"
|
|
102
|
+
| "artifact_sha256_mismatch";
|
|
103
|
+
|
|
104
|
+
export class ReleaseManifestError extends Error {
|
|
105
|
+
override readonly name = "ReleaseManifestError";
|
|
106
|
+
|
|
107
|
+
constructor(
|
|
108
|
+
readonly code: ReleaseManifestErrorCode,
|
|
109
|
+
message: string,
|
|
110
|
+
) {
|
|
111
|
+
super(message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const SIGNATURE_CONTEXT = "uai-host-release-manifest/v1\n";
|
|
116
|
+
const KEY_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
|
117
|
+
const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
|
118
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
119
|
+
const BASE64_ED25519_PATTERN = /^[A-Za-z0-9+/]{86}==$/;
|
|
120
|
+
const MAX_JSON_DEPTH = 32;
|
|
121
|
+
|
|
122
|
+
function fail(code: ReleaseManifestErrorCode, message: string): never {
|
|
123
|
+
throw new ReleaseManifestError(code, message);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function releaseArtifactFilename(
|
|
127
|
+
version: string,
|
|
128
|
+
platform: ReleasePlatform,
|
|
129
|
+
): string {
|
|
130
|
+
assertVersion(version);
|
|
131
|
+
if (!isReleasePlatform(platform)) {
|
|
132
|
+
fail("invalid_schema", `unsupported release platform: ${String(platform)}`);
|
|
133
|
+
}
|
|
134
|
+
return `uai-host-v${version}-${platform}.tar.gz`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The only release asset URL accepted by the host installer/updater. */
|
|
138
|
+
export function releaseArtifactUrl(
|
|
139
|
+
version: string,
|
|
140
|
+
platform: ReleasePlatform,
|
|
141
|
+
releaseBaseUrl = DEFAULT_RELEASE_DOWNLOAD_BASE,
|
|
142
|
+
): string {
|
|
143
|
+
const filename = releaseArtifactFilename(version, platform);
|
|
144
|
+
const base = validateReleaseBaseUrl(releaseBaseUrl);
|
|
145
|
+
return `${base}/host-tarball-v${version}/${filename}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Produce the one canonical byte sequence covered by the signature.
|
|
150
|
+
* The context prefix prevents a valid signature from being reused by a
|
|
151
|
+
* different protocol that happens to serialize the same JSON.
|
|
152
|
+
*/
|
|
153
|
+
export function canonicalReleaseManifestBytes(
|
|
154
|
+
payload: ReleaseManifestPayload,
|
|
155
|
+
releaseBaseUrl = DEFAULT_RELEASE_DOWNLOAD_BASE,
|
|
156
|
+
): Buffer {
|
|
157
|
+
const normalized = validatePayload(payload, releaseBaseUrl);
|
|
158
|
+
const json = JSON.stringify(normalized);
|
|
159
|
+
return Buffer.from(`${SIGNATURE_CONTEXT}${json}`, "utf8");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Parse and authenticate raw response bytes. No property of the returned
|
|
164
|
+
* object is exposed until strict schema and Ed25519 verification both pass.
|
|
165
|
+
*/
|
|
166
|
+
export function verifyReleaseManifest(
|
|
167
|
+
input: Uint8Array,
|
|
168
|
+
options: ReleaseManifestVerificationOptions,
|
|
169
|
+
): SignedReleaseManifest {
|
|
170
|
+
const parsed = parseUntrustedJson(input);
|
|
171
|
+
const releaseBaseUrl = validateReleaseBaseUrl(
|
|
172
|
+
options.releaseBaseUrl ?? DEFAULT_RELEASE_DOWNLOAD_BASE,
|
|
173
|
+
);
|
|
174
|
+
const manifest = validateSignedManifest(parsed, releaseBaseUrl);
|
|
175
|
+
const key = resolvePinnedKey(manifest.keyId, options.trustedKeys);
|
|
176
|
+
const signature = decodeSignature(manifest.signature);
|
|
177
|
+
const signingBytes = canonicalReleaseManifestBytes(manifest, releaseBaseUrl);
|
|
178
|
+
|
|
179
|
+
let valid = false;
|
|
180
|
+
try {
|
|
181
|
+
valid = verifySignature(null, signingBytes, key, signature);
|
|
182
|
+
} catch {
|
|
183
|
+
fail("invalid_signature", "release manifest signature verification failed");
|
|
184
|
+
}
|
|
185
|
+
if (!valid) {
|
|
186
|
+
fail("invalid_signature", "release manifest signature verification failed");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return freezeManifest(manifest);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Select only a platform from the closed release platform set. */
|
|
193
|
+
export function releaseArtifactForPlatform(
|
|
194
|
+
manifest: SignedReleaseManifest,
|
|
195
|
+
platform: string,
|
|
196
|
+
): ReleaseArtifact {
|
|
197
|
+
if (!isReleasePlatform(platform)) {
|
|
198
|
+
fail("invalid_schema", `unsupported release platform: ${platform}`);
|
|
199
|
+
}
|
|
200
|
+
return manifest.files[platform];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Verify the exact bytes fetched for an artifact before any extraction.
|
|
205
|
+
* Size is checked before hashing so a declared artifact cannot silently gain
|
|
206
|
+
* trailing data.
|
|
207
|
+
*/
|
|
208
|
+
export function verifyReleaseArtifactBytes(
|
|
209
|
+
artifact: ReleaseArtifact,
|
|
210
|
+
bytes: Uint8Array,
|
|
211
|
+
): void {
|
|
212
|
+
const expected = validateArtifactShape(artifact, "artifact");
|
|
213
|
+
if (bytes.byteLength > MAX_RELEASE_ARTIFACT_BYTES) {
|
|
214
|
+
fail(
|
|
215
|
+
"artifact_too_large",
|
|
216
|
+
`release artifact exceeds ${MAX_RELEASE_ARTIFACT_BYTES} bytes`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (bytes.byteLength !== expected.size) {
|
|
220
|
+
fail(
|
|
221
|
+
"artifact_size_mismatch",
|
|
222
|
+
`release artifact size mismatch: expected ${expected.size}, received ${bytes.byteLength}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const digest = createHash("sha256").update(bytes).digest();
|
|
226
|
+
verifyReleaseArtifactIntegrity(expected, {
|
|
227
|
+
size: bytes.byteLength,
|
|
228
|
+
sha256: digest,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Streaming-friendly final gate. A downloader can count bytes and update a
|
|
234
|
+
* sha256 hash as it writes to a temporary file, then call this without ever
|
|
235
|
+
* buffering the artifact in memory.
|
|
236
|
+
*/
|
|
237
|
+
export function verifyReleaseArtifactIntegrity(
|
|
238
|
+
artifact: ReleaseArtifact,
|
|
239
|
+
actual: ReleaseArtifactIntegrity,
|
|
240
|
+
): void {
|
|
241
|
+
const expected = validateArtifactShape(artifact, "artifact");
|
|
242
|
+
if (
|
|
243
|
+
!Number.isSafeInteger(actual.size) ||
|
|
244
|
+
actual.size < 0 ||
|
|
245
|
+
actual.size > MAX_RELEASE_ARTIFACT_BYTES
|
|
246
|
+
) {
|
|
247
|
+
fail(
|
|
248
|
+
"artifact_too_large",
|
|
249
|
+
`release artifact exceeds ${MAX_RELEASE_ARTIFACT_BYTES} bytes`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (actual.size !== expected.size) {
|
|
253
|
+
fail(
|
|
254
|
+
"artifact_size_mismatch",
|
|
255
|
+
`release artifact size mismatch: expected ${expected.size}, received ${actual.size}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const expectedDigest = Buffer.from(expected.sha256, "hex");
|
|
260
|
+
const actualDigest = decodeArtifactDigest(actual.sha256);
|
|
261
|
+
if (!timingSafeEqual(actualDigest, expectedDigest)) {
|
|
262
|
+
fail("artifact_sha256_mismatch", "release artifact sha256 mismatch");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function isReleasePlatform(value: string): value is ReleasePlatform {
|
|
267
|
+
return (RELEASE_PLATFORMS as readonly string[]).includes(value);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function validateSignedManifest(
|
|
271
|
+
value: unknown,
|
|
272
|
+
releaseBaseUrl: string,
|
|
273
|
+
): SignedReleaseManifest {
|
|
274
|
+
const object = strictObject(
|
|
275
|
+
value,
|
|
276
|
+
["schemaVersion", "keyId", "version", "files", "signature"],
|
|
277
|
+
"manifest",
|
|
278
|
+
);
|
|
279
|
+
const payload = validatePayload(object, releaseBaseUrl);
|
|
280
|
+
const signature = object.signature;
|
|
281
|
+
if (
|
|
282
|
+
typeof signature !== "string" ||
|
|
283
|
+
!BASE64_ED25519_PATTERN.test(signature)
|
|
284
|
+
) {
|
|
285
|
+
fail(
|
|
286
|
+
"invalid_schema",
|
|
287
|
+
"manifest.signature must be canonical base64 for a 64-byte Ed25519 signature",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return { ...payload, signature };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function validatePayload(
|
|
294
|
+
value: unknown,
|
|
295
|
+
releaseBaseUrl: string,
|
|
296
|
+
): ReleaseManifestPayload {
|
|
297
|
+
const object = strictObject(
|
|
298
|
+
value,
|
|
299
|
+
["schemaVersion", "keyId", "version", "files"],
|
|
300
|
+
"manifest payload",
|
|
301
|
+
// A signed envelope has one additional field. Its payload is still
|
|
302
|
+
// normalized from exactly the four authenticated fields below.
|
|
303
|
+
value !== null &&
|
|
304
|
+
typeof value === "object" &&
|
|
305
|
+
Object.prototype.hasOwnProperty.call(value, "signature")
|
|
306
|
+
? ["signature"]
|
|
307
|
+
: [],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
if (object.schemaVersion !== RELEASE_MANIFEST_SCHEMA_VERSION) {
|
|
311
|
+
fail(
|
|
312
|
+
"invalid_schema",
|
|
313
|
+
`manifest.schemaVersion must be ${RELEASE_MANIFEST_SCHEMA_VERSION}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const keyId = object.keyId;
|
|
318
|
+
if (typeof keyId !== "string" || !KEY_ID_PATTERN.test(keyId)) {
|
|
319
|
+
fail("invalid_schema", "manifest.keyId is invalid");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const version = object.version;
|
|
323
|
+
if (typeof version !== "string") {
|
|
324
|
+
fail("invalid_schema", "manifest.version must be a string");
|
|
325
|
+
}
|
|
326
|
+
assertVersion(version);
|
|
327
|
+
|
|
328
|
+
const files = strictObject(
|
|
329
|
+
object.files,
|
|
330
|
+
[...RELEASE_PLATFORMS],
|
|
331
|
+
"manifest.files",
|
|
332
|
+
);
|
|
333
|
+
const normalizedFiles = Object.create(null) as Record<
|
|
334
|
+
ReleasePlatform,
|
|
335
|
+
ReleaseArtifact
|
|
336
|
+
>;
|
|
337
|
+
|
|
338
|
+
for (const platform of RELEASE_PLATFORMS) {
|
|
339
|
+
const artifact = validateArtifactShape(
|
|
340
|
+
files[platform],
|
|
341
|
+
`manifest.files.${platform}`,
|
|
342
|
+
);
|
|
343
|
+
const expectedUrl = releaseArtifactUrl(version, platform, releaseBaseUrl);
|
|
344
|
+
if (artifact.url !== expectedUrl) {
|
|
345
|
+
fail(
|
|
346
|
+
"invalid_schema",
|
|
347
|
+
`manifest.files.${platform}.url must be ${expectedUrl}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
normalizedFiles[platform] = artifact;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
schemaVersion: RELEASE_MANIFEST_SCHEMA_VERSION,
|
|
355
|
+
keyId,
|
|
356
|
+
version,
|
|
357
|
+
files: normalizedFiles,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function validateArtifactShape(value: unknown, path: string): ReleaseArtifact {
|
|
362
|
+
const object = strictObject(value, ["url", "size", "sha256"], path);
|
|
363
|
+
const { url, size, sha256 } = object;
|
|
364
|
+
if (typeof url !== "string") {
|
|
365
|
+
fail("invalid_schema", `${path}.url must be a string`);
|
|
366
|
+
}
|
|
367
|
+
if (
|
|
368
|
+
typeof size !== "number" ||
|
|
369
|
+
!Number.isSafeInteger(size) ||
|
|
370
|
+
size <= 0 ||
|
|
371
|
+
size > MAX_RELEASE_ARTIFACT_BYTES
|
|
372
|
+
) {
|
|
373
|
+
fail(
|
|
374
|
+
"invalid_schema",
|
|
375
|
+
`${path}.size must be a positive safe integer no larger than ${MAX_RELEASE_ARTIFACT_BYTES}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (typeof sha256 !== "string" || !SHA256_PATTERN.test(sha256)) {
|
|
379
|
+
fail("invalid_schema", `${path}.sha256 must be 64 lowercase hex digits`);
|
|
380
|
+
}
|
|
381
|
+
return { url, size, sha256 };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function assertVersion(version: string): void {
|
|
385
|
+
if (version.length > 64 || !VERSION_PATTERN.test(version)) {
|
|
386
|
+
fail(
|
|
387
|
+
"invalid_schema",
|
|
388
|
+
"release version must be a canonical stable semantic version",
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function strictObject(
|
|
394
|
+
value: unknown,
|
|
395
|
+
expectedKeys: readonly string[],
|
|
396
|
+
path: string,
|
|
397
|
+
allowedAdditionalKeys: readonly string[] = [],
|
|
398
|
+
): Record<string, unknown> {
|
|
399
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
400
|
+
fail("invalid_schema", `${path} must be an object`);
|
|
401
|
+
}
|
|
402
|
+
const object = value as Record<string, unknown>;
|
|
403
|
+
const actualKeys = Object.keys(object);
|
|
404
|
+
const allowed = new Set([...expectedKeys, ...allowedAdditionalKeys]);
|
|
405
|
+
if (
|
|
406
|
+
actualKeys.length !== expectedKeys.length + allowedAdditionalKeys.length ||
|
|
407
|
+
actualKeys.some((key) => !allowed.has(key)) ||
|
|
408
|
+
expectedKeys.some((key) => !Object.prototype.hasOwnProperty.call(object, key)) ||
|
|
409
|
+
allowedAdditionalKeys.some(
|
|
410
|
+
(key) => !Object.prototype.hasOwnProperty.call(object, key),
|
|
411
|
+
)
|
|
412
|
+
) {
|
|
413
|
+
fail(
|
|
414
|
+
"invalid_schema",
|
|
415
|
+
`${path} must contain exactly: ${[...expectedKeys, ...allowedAdditionalKeys].join(", ")}`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return object;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function resolvePinnedKey(
|
|
422
|
+
keyId: string,
|
|
423
|
+
pinnedKeys: ReadonlyMap<string, PinnedReleaseManifestKey>,
|
|
424
|
+
): KeyObject {
|
|
425
|
+
for (const pinnedKeyId of pinnedKeys.keys()) {
|
|
426
|
+
if (!KEY_ID_PATTERN.test(pinnedKeyId)) {
|
|
427
|
+
fail(
|
|
428
|
+
"invalid_trust_store",
|
|
429
|
+
"release manifest trust store contains an invalid key id",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const pinned = pinnedKeys.get(keyId);
|
|
435
|
+
if (!pinned) {
|
|
436
|
+
fail("unknown_key", `release manifest key is not trusted: ${keyId}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let key: KeyObject;
|
|
440
|
+
try {
|
|
441
|
+
if (pinned.publicKey instanceof KeyObject) {
|
|
442
|
+
key = pinned.publicKey;
|
|
443
|
+
} else {
|
|
444
|
+
const encoded = Buffer.isBuffer(pinned.publicKey)
|
|
445
|
+
? pinned.publicKey.toString("utf8")
|
|
446
|
+
: pinned.publicKey;
|
|
447
|
+
if (!encoded.includes("-----BEGIN PUBLIC KEY-----")) {
|
|
448
|
+
fail("invalid_trust_store", `pinned release key is invalid: ${keyId}`);
|
|
449
|
+
}
|
|
450
|
+
key = createPublicKey(encoded);
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
fail("invalid_trust_store", `pinned release key is invalid: ${keyId}`);
|
|
454
|
+
}
|
|
455
|
+
if (key.type !== "public" || key.asymmetricKeyType !== "ed25519") {
|
|
456
|
+
fail("invalid_trust_store", `pinned release key is not Ed25519: ${keyId}`);
|
|
457
|
+
}
|
|
458
|
+
return key;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function validateReleaseBaseUrl(value: string): string {
|
|
462
|
+
if (typeof value !== "string" || value.length > 2_048) {
|
|
463
|
+
fail("invalid_trust_store", "release download base URL is invalid");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let parsed: URL;
|
|
467
|
+
try {
|
|
468
|
+
parsed = new URL(value);
|
|
469
|
+
} catch {
|
|
470
|
+
fail("invalid_trust_store", "release download base URL is invalid");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const pathSegments = parsed.pathname.split("/").filter(Boolean);
|
|
474
|
+
if (
|
|
475
|
+
parsed.protocol !== "https:" ||
|
|
476
|
+
parsed.username !== "" ||
|
|
477
|
+
parsed.password !== "" ||
|
|
478
|
+
parsed.port !== "" ||
|
|
479
|
+
parsed.search !== "" ||
|
|
480
|
+
parsed.hash !== "" ||
|
|
481
|
+
parsed.hostname === "localhost" ||
|
|
482
|
+
parsed.hostname.endsWith(".localhost") ||
|
|
483
|
+
parsed.hostname.includes(":") ||
|
|
484
|
+
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(parsed.hostname) ||
|
|
485
|
+
!parsed.hostname.includes(".") ||
|
|
486
|
+
parsed.pathname === "/" ||
|
|
487
|
+
parsed.pathname.endsWith("/") ||
|
|
488
|
+
parsed.pathname.includes("//") ||
|
|
489
|
+
pathSegments.some((segment) =>
|
|
490
|
+
segment === "." || segment === ".." || segment.includes("%")
|
|
491
|
+
) ||
|
|
492
|
+
value !== `${parsed.origin}${parsed.pathname}`
|
|
493
|
+
) {
|
|
494
|
+
fail(
|
|
495
|
+
"invalid_trust_store",
|
|
496
|
+
"release download base must be a canonical remote HTTPS URL",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return value;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function decodeArtifactDigest(value: string | Uint8Array): Buffer {
|
|
503
|
+
if (typeof value === "string") {
|
|
504
|
+
if (!SHA256_PATTERN.test(value)) {
|
|
505
|
+
fail("artifact_sha256_mismatch", "release artifact sha256 mismatch");
|
|
506
|
+
}
|
|
507
|
+
return Buffer.from(value, "hex");
|
|
508
|
+
}
|
|
509
|
+
if (value.byteLength !== 32) {
|
|
510
|
+
fail("artifact_sha256_mismatch", "release artifact sha256 mismatch");
|
|
511
|
+
}
|
|
512
|
+
return Buffer.from(value);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function decodeSignature(encoded: string): Buffer {
|
|
516
|
+
const decoded = Buffer.from(encoded, "base64");
|
|
517
|
+
if (
|
|
518
|
+
decoded.byteLength !== 64 ||
|
|
519
|
+
decoded.toString("base64") !== encoded
|
|
520
|
+
) {
|
|
521
|
+
fail("invalid_schema", "manifest.signature is not canonical base64");
|
|
522
|
+
}
|
|
523
|
+
return decoded;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function freezeManifest(manifest: SignedReleaseManifest): SignedReleaseManifest {
|
|
527
|
+
const files = Object.create(null) as Record<
|
|
528
|
+
ReleasePlatform,
|
|
529
|
+
ReleaseArtifact
|
|
530
|
+
>;
|
|
531
|
+
for (const platform of RELEASE_PLATFORMS) {
|
|
532
|
+
files[platform] = Object.freeze({ ...manifest.files[platform] });
|
|
533
|
+
}
|
|
534
|
+
Object.freeze(files);
|
|
535
|
+
return Object.freeze({ ...manifest, files });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* JSON.parse accepts duplicate object names and silently keeps the last one.
|
|
540
|
+
* A signed security document cannot permit that ambiguity, so use a small,
|
|
541
|
+
* bounded JSON parser that rejects duplicates before schema validation.
|
|
542
|
+
*/
|
|
543
|
+
function parseUntrustedJson(input: Uint8Array): unknown {
|
|
544
|
+
if (input.byteLength > MAX_RELEASE_MANIFEST_BYTES) {
|
|
545
|
+
fail(
|
|
546
|
+
"manifest_too_large",
|
|
547
|
+
`release manifest exceeds ${MAX_RELEASE_MANIFEST_BYTES} bytes`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let source: string;
|
|
552
|
+
try {
|
|
553
|
+
source = new TextDecoder("utf-8", { fatal: true }).decode(input);
|
|
554
|
+
} catch {
|
|
555
|
+
fail("invalid_encoding", "release manifest is not valid UTF-8");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
return new DuplicateSafeJsonParser(source).parse();
|
|
560
|
+
} catch (error) {
|
|
561
|
+
if (error instanceof ReleaseManifestError) throw error;
|
|
562
|
+
fail("invalid_json", "release manifest is not valid JSON");
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
class DuplicateSafeJsonParser {
|
|
567
|
+
private index = 0;
|
|
568
|
+
|
|
569
|
+
constructor(private readonly source: string) {}
|
|
570
|
+
|
|
571
|
+
parse(): unknown {
|
|
572
|
+
this.skipWhitespace();
|
|
573
|
+
const value = this.parseValue(0);
|
|
574
|
+
this.skipWhitespace();
|
|
575
|
+
if (this.index !== this.source.length) this.invalid();
|
|
576
|
+
return value;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private parseValue(depth: number): unknown {
|
|
580
|
+
if (depth > MAX_JSON_DEPTH) this.invalid();
|
|
581
|
+
const char = this.source[this.index];
|
|
582
|
+
if (char === "{") return this.parseObject(depth + 1);
|
|
583
|
+
if (char === "[") return this.parseArray(depth + 1);
|
|
584
|
+
if (char === '"') return this.parseString();
|
|
585
|
+
if (char === "t") return this.parseLiteral("true", true);
|
|
586
|
+
if (char === "f") return this.parseLiteral("false", false);
|
|
587
|
+
if (char === "n") return this.parseLiteral("null", null);
|
|
588
|
+
if (char === "-" || (char !== undefined && /[0-9]/.test(char))) {
|
|
589
|
+
return this.parseNumber();
|
|
590
|
+
}
|
|
591
|
+
return this.invalid();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private parseObject(depth: number): Record<string, unknown> {
|
|
595
|
+
const result = Object.create(null) as Record<string, unknown>;
|
|
596
|
+
const keys = new Set<string>();
|
|
597
|
+
this.index += 1;
|
|
598
|
+
this.skipWhitespace();
|
|
599
|
+
if (this.consume("}")) return result;
|
|
600
|
+
|
|
601
|
+
while (true) {
|
|
602
|
+
if (this.source[this.index] !== '"') this.invalid();
|
|
603
|
+
const key = this.parseString();
|
|
604
|
+
if (keys.has(key)) {
|
|
605
|
+
fail("invalid_json", `release manifest contains duplicate key: ${key}`);
|
|
606
|
+
}
|
|
607
|
+
keys.add(key);
|
|
608
|
+
this.skipWhitespace();
|
|
609
|
+
if (!this.consume(":")) this.invalid();
|
|
610
|
+
this.skipWhitespace();
|
|
611
|
+
result[key] = this.parseValue(depth);
|
|
612
|
+
this.skipWhitespace();
|
|
613
|
+
if (this.consume("}")) return result;
|
|
614
|
+
if (!this.consume(",")) this.invalid();
|
|
615
|
+
this.skipWhitespace();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private parseArray(depth: number): unknown[] {
|
|
620
|
+
const result: unknown[] = [];
|
|
621
|
+
this.index += 1;
|
|
622
|
+
this.skipWhitespace();
|
|
623
|
+
if (this.consume("]")) return result;
|
|
624
|
+
|
|
625
|
+
while (true) {
|
|
626
|
+
result.push(this.parseValue(depth));
|
|
627
|
+
this.skipWhitespace();
|
|
628
|
+
if (this.consume("]")) return result;
|
|
629
|
+
if (!this.consume(",")) this.invalid();
|
|
630
|
+
this.skipWhitespace();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private parseString(): string {
|
|
635
|
+
const start = this.index;
|
|
636
|
+
this.index += 1;
|
|
637
|
+
while (this.index < this.source.length) {
|
|
638
|
+
const char = this.source[this.index];
|
|
639
|
+
if (char === '"') {
|
|
640
|
+
this.index += 1;
|
|
641
|
+
try {
|
|
642
|
+
return JSON.parse(this.source.slice(start, this.index)) as string;
|
|
643
|
+
} catch {
|
|
644
|
+
return this.invalid();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (char === "\\") {
|
|
648
|
+
this.index += 1;
|
|
649
|
+
const escape = this.source[this.index];
|
|
650
|
+
if (escape === "u") {
|
|
651
|
+
const hex = this.source.slice(this.index + 1, this.index + 5);
|
|
652
|
+
if (!/^[a-fA-F0-9]{4}$/.test(hex)) return this.invalid();
|
|
653
|
+
this.index += 5;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (escape === undefined || !'"\\/bfnrt'.includes(escape)) {
|
|
657
|
+
return this.invalid();
|
|
658
|
+
}
|
|
659
|
+
this.index += 1;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
if (char === undefined || char.charCodeAt(0) < 0x20) {
|
|
663
|
+
return this.invalid();
|
|
664
|
+
}
|
|
665
|
+
this.index += 1;
|
|
666
|
+
}
|
|
667
|
+
return this.invalid();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private parseNumber(): number {
|
|
671
|
+
const rest = this.source.slice(this.index);
|
|
672
|
+
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(rest);
|
|
673
|
+
if (!match) return this.invalid();
|
|
674
|
+
this.index += match[0].length;
|
|
675
|
+
const value = Number(match[0]);
|
|
676
|
+
if (!Number.isFinite(value)) return this.invalid();
|
|
677
|
+
return value;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private parseLiteral<T>(token: string, value: T): T {
|
|
681
|
+
if (this.source.slice(this.index, this.index + token.length) !== token) {
|
|
682
|
+
return this.invalid();
|
|
683
|
+
}
|
|
684
|
+
this.index += token.length;
|
|
685
|
+
return value;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private consume(char: string): boolean {
|
|
689
|
+
if (this.source[this.index] !== char) return false;
|
|
690
|
+
this.index += 1;
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private skipWhitespace(): void {
|
|
695
|
+
while (
|
|
696
|
+
this.source[this.index] === " " ||
|
|
697
|
+
this.source[this.index] === "\t" ||
|
|
698
|
+
this.source[this.index] === "\n" ||
|
|
699
|
+
this.source[this.index] === "\r"
|
|
700
|
+
) {
|
|
701
|
+
this.index += 1;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private invalid(): never {
|
|
706
|
+
fail("invalid_json", "release manifest is not valid JSON");
|
|
707
|
+
}
|
|
708
|
+
}
|