@deksden-com/dd-flow-cli 0.9.0-beta.135 → 0.9.0-beta.136
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/CHANGELOG.md +6 -0
- package/README.md +2 -0
- package/dist/build-info.json +7 -7
- package/dist/cli/command-inputs.js +1 -0
- package/dist/cli/command-metadata.js +144 -0
- package/dist/cli/command-option-descriptions.js +186 -0
- package/dist/cli/completion-cli.js +70 -0
- package/dist/cli/completion.js +57 -0
- package/dist/cli/error-output.js +52 -0
- package/dist/cli/executor-onboarding.js +71 -0
- package/dist/cli/harnesses.js +84 -0
- package/dist/cli/models.js +39 -0
- package/dist/cli/operator-profiles.js +61 -0
- package/dist/cli/output-options.js +35 -0
- package/dist/cli/response-file.js +69 -0
- package/dist/cli/run-cli.js +5 -0
- package/dist/cli/setup.js +147 -0
- package/dist/services/cli-operation-classifier.js +4 -0
- package/dist/services/cli-upgrade-allowlist.js +27 -0
- package/dist/services/discovery/agy-discovery.js +2 -0
- package/dist/services/discovery/codex-discovery.js +2 -0
- package/dist/services/discovery/droid-discovery.js +2 -0
- package/dist/services/discovery/grok-discovery.js +2 -0
- package/dist/services/discovery/opencode-discovery.js +2 -0
- package/dist/services/discovery/static.js +31 -0
- package/dist/services/discovery/zcode-discovery.js +2 -0
- package/dist/services/engines.js +1 -1
- package/dist/services/executor-onboarding.js +56 -0
- package/dist/services/harness-discovery.js +29 -0
- package/dist/services/harness-doctor.js +75 -0
- package/dist/services/harness-probe-effects.js +51 -0
- package/dist/services/harness-version-probe.js +100 -0
- package/dist/services/hybrid-contracts.js +455 -0
- package/dist/services/installation-lock.js +92 -0
- package/dist/services/managed-processes.js +30 -0
- package/dist/services/model-catalog.js +109 -0
- package/dist/services/model-discovery.js +46 -0
- package/dist/services/operator-profiles.js +236 -0
- package/dist/services/release-archive-producer.js +52 -0
- package/dist/services/release-archive.js +143 -0
- package/dist/services/release-cache.js +142 -0
- package/dist/services/release-compatibility.js +18 -0
- package/dist/services/release-download.js +67 -0
- package/dist/services/release-envelope.js +104 -0
- package/dist/services/release-manifest.js +45 -0
- package/dist/services/release-payload.js +114 -0
- package/dist/services/release-producer.js +63 -0
- package/dist/services/release-provenance.js +160 -0
- package/dist/services/release-registry.js +66 -0
- package/dist/services/release-source.js +194 -0
- package/dist/services/run-controller.js +1 -0
- package/dist/services/run-health.js +149 -0
- package/dist/services/runs.js +61 -13
- package/dist/services/setup.js +377 -0
- package/dist/storage/database.js +18 -3
- package/dist/storage/writer-contract.js +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { inspectReleaseArchive } from "./release-archive.js";
|
|
6
|
+
import { verifyReleaseArchive } from "./release-manifest.js";
|
|
7
|
+
import { acquireInstallationLock } from "./installation-lock.js";
|
|
8
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
9
|
+
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
10
|
+
function refuse(reason) {
|
|
11
|
+
throw new AppError("cache_integrity", "Release cache cannot be published", 1, { reason, phase: "cache_publication", effect: "no_effect", help: "setup" });
|
|
12
|
+
}
|
|
13
|
+
function directory(target) {
|
|
14
|
+
if (fs.existsSync(target)) {
|
|
15
|
+
if (!fs.lstatSync(target).isDirectory())
|
|
16
|
+
refuse("unsafe_cache_directory");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const parent = path.dirname(target);
|
|
20
|
+
if (parent !== target)
|
|
21
|
+
directory(parent);
|
|
22
|
+
fs.mkdirSync(target, { mode: 0o700 });
|
|
23
|
+
}
|
|
24
|
+
function write(target, bytes, mode) {
|
|
25
|
+
const fd = fs.openSync(target, "wx", mode);
|
|
26
|
+
try {
|
|
27
|
+
fs.writeFileSync(fd, bytes);
|
|
28
|
+
fs.fchmodSync(fd, mode);
|
|
29
|
+
fs.fsyncSync(fd);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
fs.closeSync(fd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function retained(target, archive, entries) {
|
|
36
|
+
if (!fs.lstatSync(target).isDirectory())
|
|
37
|
+
refuse("unsafe_retained_cache");
|
|
38
|
+
const archiveFile = path.join(target, "archive.tgz");
|
|
39
|
+
if (!fs.lstatSync(archiveFile).isFile())
|
|
40
|
+
refuse("unsafe_retained_cache");
|
|
41
|
+
if (fs.readdirSync(target).sort().join("/") !== "archive.tgz/payload/receipt.json")
|
|
42
|
+
refuse("retained_cache_changed");
|
|
43
|
+
const marker = path.join(target, "receipt.json"), payload = path.join(target, "payload");
|
|
44
|
+
if (!fs.lstatSync(marker).isFile() || fs.readFileSync(marker, "utf8") !== JSON.stringify({ sha256: archive.sha256, bytes: archive.bytes }))
|
|
45
|
+
refuse("same_version_different_bytes");
|
|
46
|
+
verifyReleaseArchive(archive, fs.readFileSync(archiveFile));
|
|
47
|
+
const expected = new Map();
|
|
48
|
+
const directories = new Set();
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
expected.set(entry.path, entry);
|
|
51
|
+
const parts = entry.path.split("/");
|
|
52
|
+
for (let index = 1; index < parts.length; index++)
|
|
53
|
+
directories.add(parts.slice(0, index).join("/"));
|
|
54
|
+
}
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const visit = (root, relative) => {
|
|
57
|
+
if (!fs.lstatSync(root).isDirectory())
|
|
58
|
+
refuse("unsafe_retained_cache");
|
|
59
|
+
for (const name of fs.readdirSync(root)) {
|
|
60
|
+
const logical = relative ? `${relative}/${name}` : name, full = path.join(root, name), stat = fs.lstatSync(full);
|
|
61
|
+
const entry = expected.get(logical);
|
|
62
|
+
if (stat.isDirectory()) {
|
|
63
|
+
if (entry?.kind === "file" || (!entry && !directories.has(logical)))
|
|
64
|
+
refuse("retained_cache_changed");
|
|
65
|
+
if (entry)
|
|
66
|
+
seen.add(logical);
|
|
67
|
+
visit(full, logical);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
if (!stat.isFile() || entry?.kind !== "file" || stat.size !== entry.bytes.length || digest(fs.readFileSync(full)) !== digest(entry.bytes) || (stat.mode & 0o111) !== (entry.mode & 0o111))
|
|
71
|
+
refuse("retained_cache_changed");
|
|
72
|
+
seen.add(logical);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
visit(payload, "");
|
|
77
|
+
if (seen.size !== entries.length)
|
|
78
|
+
refuse("retained_cache_changed");
|
|
79
|
+
}
|
|
80
|
+
/** Verify retained compressed and expanded bytes without executing or activating them. */
|
|
81
|
+
export function verifyPublishedReleaseCache(target, archive) {
|
|
82
|
+
if (!fs.lstatSync(target).isDirectory())
|
|
83
|
+
refuse("unsafe_retained_cache");
|
|
84
|
+
const archiveFile = path.join(target, "archive.tgz");
|
|
85
|
+
if (!fs.lstatSync(archiveFile).isFile())
|
|
86
|
+
refuse("unsafe_retained_cache");
|
|
87
|
+
retained(target, archive, inspectReleaseArchive(archive, fs.readFileSync(archiveFile)));
|
|
88
|
+
}
|
|
89
|
+
/** Home-only immutable publication. It never activates a version or changes RUN bindings. */
|
|
90
|
+
export function publishReleaseCache(home, kind, version, archive, compressed) {
|
|
91
|
+
if (kind !== "canon" && kind !== "engine")
|
|
92
|
+
refuse("invalid_artifact_kind");
|
|
93
|
+
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(version))
|
|
94
|
+
refuse("invalid_version");
|
|
95
|
+
const entries = inspectReleaseArchive(archive, compressed);
|
|
96
|
+
const parent = path.join(path.resolve(home), "release-cache", kind), target = path.join(parent, version);
|
|
97
|
+
for (const candidate of [path.resolve(home), path.join(path.resolve(home), "release-cache"), parent]) {
|
|
98
|
+
if (fs.existsSync(candidate) && !fs.lstatSync(candidate).isDirectory())
|
|
99
|
+
refuse("unsafe_cache_directory");
|
|
100
|
+
}
|
|
101
|
+
// Validate before creating even the home, including on replay.
|
|
102
|
+
if (fs.existsSync(target)) {
|
|
103
|
+
directory(parent);
|
|
104
|
+
retained(target, archive, entries);
|
|
105
|
+
return { root: target, replayed: true };
|
|
106
|
+
}
|
|
107
|
+
directory(parent);
|
|
108
|
+
const lock = `${target}.lock`;
|
|
109
|
+
const lease = acquireInstallationLock(lock, hybridDigest({ kind, version, archive }));
|
|
110
|
+
let staging = null;
|
|
111
|
+
try {
|
|
112
|
+
if (fs.existsSync(target)) {
|
|
113
|
+
retained(target, archive, entries);
|
|
114
|
+
return { root: target, replayed: true };
|
|
115
|
+
}
|
|
116
|
+
staging = path.join(parent, `.staging-${lease.token}`);
|
|
117
|
+
fs.mkdirSync(staging, { mode: 0o700 });
|
|
118
|
+
const payload = path.join(staging, "payload");
|
|
119
|
+
fs.mkdirSync(payload, { mode: 0o700 });
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const destination = path.join(payload, entry.path);
|
|
122
|
+
if (entry.kind === "directory")
|
|
123
|
+
directory(destination);
|
|
124
|
+
else {
|
|
125
|
+
directory(path.dirname(destination));
|
|
126
|
+
write(destination, entry.bytes, entry.mode);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
write(path.join(staging, "receipt.json"), Buffer.from(JSON.stringify({ sha256: archive.sha256, bytes: archive.bytes })), 0o600);
|
|
130
|
+
write(path.join(staging, "archive.tgz"), compressed, 0o600);
|
|
131
|
+
retained(staging, archive, entries);
|
|
132
|
+
fs.renameSync(staging, target);
|
|
133
|
+
staging = null;
|
|
134
|
+
return { root: target, replayed: false };
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
// Only the unique staging directory and lock acquired by this call are owned here.
|
|
138
|
+
if (staging)
|
|
139
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
140
|
+
lease.release();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import semver from "semver";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
/** Registered catalog format revisions; these do not establish harness/model availability. */
|
|
4
|
+
export const installedCatalogVersions = Object.freeze({ harness: "1.0.0", model: "1.0.0", flow: "1.0.0" });
|
|
5
|
+
/** Compatibility comes from the installed consumer/catalog policy, never from release claims. */
|
|
6
|
+
export function createReleaseCompatibility(policy) {
|
|
7
|
+
if (!["stable", "preview", "development"].includes(policy.channel) || !semver.valid(policy.routerVersion) ||
|
|
8
|
+
["harness", "model", "flow"].some(family => !semver.valid(policy.catalogVersions[family]))) {
|
|
9
|
+
throw new AppError("release_compatibility", "Installed release compatibility policy is invalid", 1, { phase: "release_admission", effect: "no_effect", help: "setup" });
|
|
10
|
+
}
|
|
11
|
+
const routerVersion = policy.routerVersion;
|
|
12
|
+
return {
|
|
13
|
+
channel: policy.channel,
|
|
14
|
+
catalogVersions: { ...policy.catalogVersions },
|
|
15
|
+
routerCompatible: minimum => Boolean(semver.valid(minimum)) && semver.gte(routerVersion, minimum),
|
|
16
|
+
engineCompatible: (version, range) => Boolean(semver.valid(version) && semver.validRange(range)) && semver.satisfies(version, range)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { verifyReleaseArchive } from "./release-manifest.js";
|
|
3
|
+
function refuse(reason) {
|
|
4
|
+
throw new AppError("release_download", "Release artifact download failed", 1, { reason, phase: "artifact_download", effect: "no_effect", help: "setup" });
|
|
5
|
+
}
|
|
6
|
+
/** No provider login, credentials or redirect forwarding. Activation is a separate operation. */
|
|
7
|
+
export async function downloadReleaseArchive(identity, options) {
|
|
8
|
+
const maxBytes = options.maxBytes ?? 256 * 1024 * 1024;
|
|
9
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
10
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 ||
|
|
11
|
+
!Number.isSafeInteger(identity.bytes) || identity.bytes < 1 || identity.bytes > maxBytes || !/^[a-f0-9]{64}$/.test(identity.sha256))
|
|
12
|
+
refuse("invalid_download_limits_or_identity");
|
|
13
|
+
let url;
|
|
14
|
+
try {
|
|
15
|
+
url = new URL(identity.url);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return refuse("invalid_artifact_url");
|
|
19
|
+
}
|
|
20
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash || (url.port && url.port !== "443") || !options.allowedHosts.has(url.hostname))
|
|
21
|
+
refuse("unapproved_artifact_origin");
|
|
22
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
23
|
+
let response;
|
|
24
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
25
|
+
try {
|
|
26
|
+
response = await fetcher(url, { redirect: "error", signal, credentials: "omit" });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return refuse("network_unavailable_or_redirect");
|
|
30
|
+
}
|
|
31
|
+
if (!response.ok || !response.body) {
|
|
32
|
+
await response.body?.cancel();
|
|
33
|
+
return refuse("artifact_http_failure");
|
|
34
|
+
}
|
|
35
|
+
const length = response.headers.get("content-length");
|
|
36
|
+
if (length !== null && (!/^[0-9]+$/.test(length) || Number(length) !== identity.bytes)) {
|
|
37
|
+
await response.body.cancel();
|
|
38
|
+
return refuse("content_length_mismatch");
|
|
39
|
+
}
|
|
40
|
+
const reader = response.body.getReader(), chunks = [];
|
|
41
|
+
let received = 0;
|
|
42
|
+
try {
|
|
43
|
+
while (true) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done)
|
|
46
|
+
break;
|
|
47
|
+
received += value.byteLength;
|
|
48
|
+
if (received > identity.bytes || received > maxBytes) {
|
|
49
|
+
await reader.cancel();
|
|
50
|
+
return refuse("artifact_exceeds_declared_size");
|
|
51
|
+
}
|
|
52
|
+
chunks.push(Buffer.from(value));
|
|
53
|
+
options.onProgress?.({ received_bytes: received, expected_bytes: identity.bytes });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof AppError)
|
|
58
|
+
throw error;
|
|
59
|
+
return refuse("artifact_stream_interrupted");
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
reader.releaseLock();
|
|
63
|
+
}
|
|
64
|
+
const bytes = Buffer.concat(chunks, received);
|
|
65
|
+
verifyReleaseArchive(identity, bytes);
|
|
66
|
+
return bytes;
|
|
67
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash, createPublicKey, sign, verify } from "node:crypto";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { admitReleaseManifest } from "./release-manifest.js";
|
|
4
|
+
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
5
|
+
function refuse(reason) {
|
|
6
|
+
throw new AppError("release_authentication", "Release producer cannot be authenticated", 1, { reason, effect: "no_effect", phase: "release_authentication", help: "setup" });
|
|
7
|
+
}
|
|
8
|
+
function object(value, keys) {
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== keys.length ||
|
|
10
|
+
Object.keys(value).some(key => !keys.includes(key)))
|
|
11
|
+
refuse("invalid_envelope_shape");
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function decoded(value, max = 16 * 1024 * 1024) {
|
|
15
|
+
if (typeof value !== "string" || value.length > Math.ceil(max / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))
|
|
16
|
+
refuse("invalid_encoded_bytes");
|
|
17
|
+
const bytes = Buffer.from(value, "base64");
|
|
18
|
+
if (bytes.length > max || bytes.toString("base64") !== value)
|
|
19
|
+
refuse("invalid_encoded_bytes");
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
22
|
+
function json(bytes) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return refuse("invalid_signed_json");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function releaseProducerKeyId(publicKey) {
|
|
31
|
+
if (publicKey.type !== "public" || publicKey.asymmetricKeyType !== "ed25519")
|
|
32
|
+
refuse("unsupported_signing_key");
|
|
33
|
+
return digest(publicKey.export({ type: "spki", format: "der" }));
|
|
34
|
+
}
|
|
35
|
+
function admission(payload, compatibility, producer) {
|
|
36
|
+
const data = object(payload, ["schema_id", "manifest_base64", "source", "qualifications", "evidence"]);
|
|
37
|
+
if (data.schema_id !== "dd-flow/release-payload@1" || !Array.isArray(data.qualifications) || !Array.isArray(data.evidence) || data.qualifications.length > 10_000 || data.evidence.length > 10_000)
|
|
38
|
+
refuse("invalid_payload_shape");
|
|
39
|
+
const source = object(data.source, ["kind", "repository", "commit", "tag"]);
|
|
40
|
+
if (source.repository !== producer.repository || typeof source.commit !== "string" || !/^[a-f0-9]{40}$/.test(source.commit) ||
|
|
41
|
+
!["published_release", "source_checkout"].includes(String(source.kind)))
|
|
42
|
+
refuse("source_outside_producer_scope");
|
|
43
|
+
if (!producer.channels.has(compatibility.channel))
|
|
44
|
+
refuse("channel_outside_producer_scope");
|
|
45
|
+
const receipts = new Map();
|
|
46
|
+
const qualifiedRoutes = new Map(), evidence = new Map();
|
|
47
|
+
for (const input of data.qualifications) {
|
|
48
|
+
const fact = object(input, ["route", "sha256", "request_id", "project_root", "workspace", "canon_sha256", "engine_sha256"]);
|
|
49
|
+
if (typeof fact.route !== "string" || !/^(darwin-arm64|darwin-x64|linux-arm64|linux-x64|win32-x64)\/(codex|zcode|agy|opencode|grok|droid)\/[a-z][a-z0-9-]{0,47}\/(external|native)$/.test(fact.route) ||
|
|
50
|
+
[fact.sha256, fact.canon_sha256, fact.engine_sha256].some(value => typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) ||
|
|
51
|
+
[fact.request_id, fact.project_root, fact.workspace].some(value => typeof value !== "string" || !value.trim()))
|
|
52
|
+
refuse("invalid_qualification_fact");
|
|
53
|
+
if (qualifiedRoutes.has(fact.route))
|
|
54
|
+
refuse("duplicate_qualification_fact");
|
|
55
|
+
qualifiedRoutes.set(fact.route, fact.sha256);
|
|
56
|
+
receipts.set(fact.sha256, { kind: "route-qualification", requestId: fact.request_id, projectRoot: fact.project_root, workspace: fact.workspace });
|
|
57
|
+
}
|
|
58
|
+
for (const input of data.evidence) {
|
|
59
|
+
const item = object(input, ["path", "bytes_base64"]);
|
|
60
|
+
if (typeof item.path !== "string" || !/^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*(\/[a-zA-Z0-9_-][a-zA-Z0-9_.-]*)*$/.test(item.path) || evidence.has(item.path))
|
|
61
|
+
refuse("invalid_evidence_path");
|
|
62
|
+
evidence.set(item.path, decoded(item.bytes_base64));
|
|
63
|
+
}
|
|
64
|
+
const manifestBytes = decoded(data.manifest_base64, 1024 * 1024);
|
|
65
|
+
const authority = { ...compatibility, receipts, qualifiedRoutes, evidence };
|
|
66
|
+
const manifest = admitReleaseManifest(json(manifestBytes), authority);
|
|
67
|
+
for (const input of data.qualifications) {
|
|
68
|
+
if (input.canon_sha256 !== manifest.canon.archive.sha256 || input.engine_sha256 !== manifest.engine.archive.sha256)
|
|
69
|
+
refuse("qualification_artifact_mismatch");
|
|
70
|
+
}
|
|
71
|
+
if (source.commit !== manifest.canon.commit)
|
|
72
|
+
refuse("source_commit_mismatch");
|
|
73
|
+
if (source.kind === "source_checkout") {
|
|
74
|
+
if (manifest.channel !== "development" || source.tag !== null)
|
|
75
|
+
refuse("source_cannot_claim_published_release");
|
|
76
|
+
}
|
|
77
|
+
else if (source.tag !== `v${manifest.version}` || manifest.channel === "development")
|
|
78
|
+
refuse("published_release_identity_mismatch");
|
|
79
|
+
return { manifest, manifestBytes, authority, source: source };
|
|
80
|
+
}
|
|
81
|
+
/** Producer receives qualification facts from its owner ledger, never manufactures them from manifest references. */
|
|
82
|
+
export function produceReleaseEnvelope(payload, privateKey, compatibility) {
|
|
83
|
+
if (privateKey.type !== "private" || privateKey.asymmetricKeyType !== "ed25519")
|
|
84
|
+
refuse("unsupported_signing_key");
|
|
85
|
+
admission(payload, compatibility, { repository: payload.source.repository, channels: new Set([compatibility.channel]) });
|
|
86
|
+
const bytes = Buffer.from(JSON.stringify(payload));
|
|
87
|
+
if (bytes.length > 16 * 1024 * 1024)
|
|
88
|
+
refuse("oversized_payload");
|
|
89
|
+
return { schema_id: "dd-flow/release-envelope@1", producer_key_id: releaseProducerKeyId(createPublicKey(privateKey)),
|
|
90
|
+
payload_base64: bytes.toString("base64"), signature_base64: sign(null, bytes, privateKey).toString("base64") };
|
|
91
|
+
}
|
|
92
|
+
/** Trust keys and scope are selected independently; an envelope cannot nominate its own authority. */
|
|
93
|
+
export function authenticateReleaseEnvelope(input, trusted, compatibility) {
|
|
94
|
+
const envelope = object(input, ["schema_id", "producer_key_id", "payload_base64", "signature_base64"]);
|
|
95
|
+
if (envelope.schema_id !== "dd-flow/release-envelope@1" || typeof envelope.producer_key_id !== "string")
|
|
96
|
+
refuse("invalid_envelope_identity");
|
|
97
|
+
const producer = trusted.get(envelope.producer_key_id);
|
|
98
|
+
if (!producer || releaseProducerKeyId(producer.publicKey) !== envelope.producer_key_id)
|
|
99
|
+
refuse("unknown_producer");
|
|
100
|
+
const bytes = decoded(envelope.payload_base64), signature = decoded(envelope.signature_base64, 64);
|
|
101
|
+
if (signature.length !== 64 || !verify(null, bytes, producer.publicKey, signature))
|
|
102
|
+
refuse("signature_mismatch");
|
|
103
|
+
return admission(json(bytes), compatibility, producer);
|
|
104
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { validateHybridContract } from "./hybrid-contracts.js";
|
|
4
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
5
|
+
function refuse(reason) {
|
|
6
|
+
throw new AppError("release_integrity", "Release manifest cannot be admitted", 1, { reason, phase: "release_admission", effect: "no_effect", help: "setup" });
|
|
7
|
+
}
|
|
8
|
+
/** Pure admission: no download, activation, filesystem writes or qualification probes. */
|
|
9
|
+
export function admitReleaseManifest(input, authority) {
|
|
10
|
+
const issues = validateHybridContract("release-manifest", input, { receipts: authority.receipts });
|
|
11
|
+
if (issues.length)
|
|
12
|
+
refuse(issues[0].code);
|
|
13
|
+
const manifest = input;
|
|
14
|
+
if (manifest.channel !== authority.channel)
|
|
15
|
+
refuse("channel_mismatch");
|
|
16
|
+
if (!authority.routerCompatible(manifest.min_router_version))
|
|
17
|
+
refuse("router_incompatible");
|
|
18
|
+
if (!authority.engineCompatible(manifest.engine.version, manifest.engine.compatible_range))
|
|
19
|
+
refuse("engine_incompatible");
|
|
20
|
+
for (const family of ["harness", "model", "flow"]) {
|
|
21
|
+
if (manifest.catalog_versions[family] !== authority.catalogVersions[family])
|
|
22
|
+
refuse("catalog_incompatible");
|
|
23
|
+
}
|
|
24
|
+
for (const route of manifest.qualified_routes) {
|
|
25
|
+
const key = [route.platform, route.harness, route.flow, route.delegation].join("/");
|
|
26
|
+
if (authority.qualifiedRoutes.get(key) !== route.evidence.sha256)
|
|
27
|
+
refuse("route_qualification_mismatch");
|
|
28
|
+
}
|
|
29
|
+
for (const reference of [manifest.impact, ...manifest.qualified_routes.map(route => route.evidence)]) {
|
|
30
|
+
const bytes = authority.evidence.get(reference.path);
|
|
31
|
+
if (!bytes || sha256(bytes) !== reference.sha256)
|
|
32
|
+
refuse("evidence_bytes_mismatch");
|
|
33
|
+
}
|
|
34
|
+
// Return a detached tuple: callers cannot mutate the submitted object after admission.
|
|
35
|
+
return structuredClone(manifest);
|
|
36
|
+
}
|
|
37
|
+
/** Verify the actual transferred bytes before any archive parser or cache publication. */
|
|
38
|
+
export function verifyReleaseArchive(archive, bytes) {
|
|
39
|
+
if (!Number.isSafeInteger(archive.bytes) || archive.bytes < 1 || !/^[a-f0-9]{64}$/.test(archive.sha256))
|
|
40
|
+
refuse("invalid_archive_identity");
|
|
41
|
+
if (bytes.byteLength !== archive.bytes)
|
|
42
|
+
refuse("archive_size_mismatch");
|
|
43
|
+
if (sha256(bytes) !== archive.sha256)
|
|
44
|
+
refuse("archive_digest_mismatch");
|
|
45
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { TextDecoder } from "node:util";
|
|
2
|
+
import { valid } from "semver";
|
|
3
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { inspectReleaseArchive } from "./release-archive.js";
|
|
6
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
7
|
+
function refuse(reason) {
|
|
8
|
+
throw new AppError("release_integrity", "Release payload cannot be admitted", 1, { reason, phase: "payload_validation", effect: "no_effect", help: "setup" });
|
|
9
|
+
}
|
|
10
|
+
function root(entries, kind) {
|
|
11
|
+
const roots = new Set(entries.map(entry => entry.path.split("/")[0]));
|
|
12
|
+
if (roots.size !== 1)
|
|
13
|
+
refuse(`${kind}_payload_root_ambiguous`);
|
|
14
|
+
const selected = [...roots][0];
|
|
15
|
+
if (kind === "engine" && selected !== "package")
|
|
16
|
+
refuse("engine_payload_root_invalid");
|
|
17
|
+
if (entries.some(entry => entry.kind === "file" && entry.path === selected))
|
|
18
|
+
refuse(`${kind}_payload_root_invalid`);
|
|
19
|
+
return selected;
|
|
20
|
+
}
|
|
21
|
+
function text(entries, file) {
|
|
22
|
+
const entry = entries.find(candidate => candidate.path === file);
|
|
23
|
+
if (!entry || entry.kind !== "file")
|
|
24
|
+
refuse("payload_metadata_missing");
|
|
25
|
+
// Metadata is bounded separately from the expanded archive and never executed.
|
|
26
|
+
if (entry.bytes.length > 1024 * 1024)
|
|
27
|
+
refuse("payload_metadata_oversized");
|
|
28
|
+
try {
|
|
29
|
+
return decoder.decode(entry.bytes);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return refuse("payload_metadata_invalid_utf8");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function json(entries, file) {
|
|
36
|
+
let value;
|
|
37
|
+
try {
|
|
38
|
+
value = JSON.parse(text(entries, file));
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error instanceof AppError)
|
|
42
|
+
throw error;
|
|
43
|
+
return refuse("engine_payload_package_invalid");
|
|
44
|
+
}
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
46
|
+
refuse("engine_payload_package_invalid");
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
function validateCanonLayout(entries, archiveRoot) {
|
|
50
|
+
const markers = ["dd-flow/README.md", "mbb/index.md", "protocol/index.md"];
|
|
51
|
+
const candidates = [archiveRoot, `${archiveRoot}/.memory-bank`];
|
|
52
|
+
const present = (file) => entries.some(entry => entry.path === file && entry.kind === "file" && entry.bytes.length > 0);
|
|
53
|
+
const layouts = candidates.filter(candidate => markers.every(marker => present(`${candidate}/${marker}`)));
|
|
54
|
+
if (layouts.length !== 1)
|
|
55
|
+
refuse(layouts.length ? "canon_payload_layout_ambiguous" : "canon_payload_layout_incomplete");
|
|
56
|
+
const selected = layouts[0];
|
|
57
|
+
// Even an incomplete second layout can shadow files during later resolution.
|
|
58
|
+
if (candidates.some(candidate => candidate !== selected && markers.some(marker => present(`${candidate}/${marker}`))))
|
|
59
|
+
refuse("canon_payload_layout_ambiguous");
|
|
60
|
+
}
|
|
61
|
+
/** Check package identity from transferred bytes before creating the installation home.
|
|
62
|
+
* This is not catalog/schema or runtime qualification; those require their own evidence.
|
|
63
|
+
*/
|
|
64
|
+
export function validateReleasePayloadIdentity(manifest, canon, engine) {
|
|
65
|
+
const canonEntries = inspectReleaseArchive(manifest.canon.archive, canon);
|
|
66
|
+
const engineEntries = inspectReleaseArchive(manifest.engine.archive, engine);
|
|
67
|
+
const canonRoot = root(canonEntries, "canon"), engineRoot = root(engineEntries, "engine");
|
|
68
|
+
if (text(canonEntries, `${canonRoot}/VERSION`).trim() !== manifest.canon.version)
|
|
69
|
+
refuse("canon_payload_version_mismatch");
|
|
70
|
+
validateCanonLayout(canonEntries, canonRoot);
|
|
71
|
+
const metadata = json(engineEntries, `${engineRoot}/package.json`);
|
|
72
|
+
if (metadata.name !== manifest.engine.package)
|
|
73
|
+
refuse("engine_payload_package_mismatch");
|
|
74
|
+
if (metadata.version !== manifest.engine.version)
|
|
75
|
+
refuse("engine_payload_version_mismatch");
|
|
76
|
+
if (metadata.type !== "module")
|
|
77
|
+
refuse("engine_payload_module_type_invalid");
|
|
78
|
+
const bins = metadata.bin;
|
|
79
|
+
if (!bins || typeof bins !== "object" || Array.isArray(bins)
|
|
80
|
+
|| bins["dd-flow"] !== "dist/cli.js")
|
|
81
|
+
refuse("engine_payload_bin_invalid");
|
|
82
|
+
for (const target of Object.values(bins)) {
|
|
83
|
+
if (typeof target !== "string" || !/^dist\/[A-Za-z0-9_./-]+$/.test(target)
|
|
84
|
+
|| target.split("/").some(part => !part || part === "." || part === ".."))
|
|
85
|
+
refuse("engine_payload_bin_invalid");
|
|
86
|
+
const executable = engineEntries.find(entry => entry.path === `${engineRoot}/${target}`);
|
|
87
|
+
if (!executable || executable.kind !== "file" || executable.bytes.length === 0 || !(executable.mode & 0o111))
|
|
88
|
+
refuse("engine_payload_bin_missing");
|
|
89
|
+
}
|
|
90
|
+
const build = json(engineEntries, `${engineRoot}/dist/build-info.json`);
|
|
91
|
+
if (build.cli_package !== manifest.engine.package || build.cli_version !== manifest.engine.version
|
|
92
|
+
|| typeof build.cli_commit !== "string" || !/^[a-f0-9]{40}$/.test(build.cli_commit))
|
|
93
|
+
refuse("engine_payload_build_identity_mismatch");
|
|
94
|
+
const canonBuild = build.built_with_canon;
|
|
95
|
+
// Build provenance is not the installed canon selection. One immutable engine
|
|
96
|
+
// can serve multiple independently qualified canon tuples without being rebuilt.
|
|
97
|
+
if (!canonBuild || typeof canonBuild !== "object" || Array.isArray(canonBuild)
|
|
98
|
+
|| typeof canonBuild.version !== "string" || !valid(canonBuild.version)
|
|
99
|
+
|| typeof canonBuild.commit !== "string" || !/^[a-f0-9]{40}$/.test(canonBuild.commit))
|
|
100
|
+
refuse("engine_payload_canon_provenance_invalid");
|
|
101
|
+
const schemaFiles = { descriptor: "flow-descriptor", execution: "execution-settings", bundle: "execution-bundle" };
|
|
102
|
+
for (const family of ["descriptor", "execution", "bundle"]) {
|
|
103
|
+
const schema = json(engineEntries, `${engineRoot}/dist/schemas/${schemaFiles[family]}.schema.json`);
|
|
104
|
+
if (schema.$id !== manifest.schema_versions[family]
|
|
105
|
+
|| schema.$schema !== "https://json-schema.org/draft/2020-12/schema")
|
|
106
|
+
refuse("engine_payload_schema_identity_mismatch");
|
|
107
|
+
try {
|
|
108
|
+
new Ajv2020({ strict: true, validateFormats: false }).compile(schema);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return refuse("engine_payload_schema_invalid");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createPublicKey, randomUUID } from "node:crypto";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { hybridDigest } from "./hybrid-contracts.js";
|
|
6
|
+
import { acquireInstallationLock } from "./installation-lock.js";
|
|
7
|
+
import { authenticateReleaseEnvelope, produceReleaseEnvelope, releaseProducerKeyId } from "./release-envelope.js";
|
|
8
|
+
import { readLocalReleaseBundle } from "./release-source.js";
|
|
9
|
+
import { validateReleasePayloadIdentity } from "./release-payload.js";
|
|
10
|
+
function refuse(reason) {
|
|
11
|
+
throw new AppError("release_producer", "Release bundle cannot be published", 1, { reason, phase: "release_production", effect: "no_effect" });
|
|
12
|
+
}
|
|
13
|
+
/** Local immutable delivery only. Qualification facts must already come from their
|
|
14
|
+
* owner ledger; this producer never performs, synthesizes or upgrades qualification.
|
|
15
|
+
*/
|
|
16
|
+
export function publishReleaseBundle(destination, input) {
|
|
17
|
+
if (!input.payload || typeof input.payload !== "object" || !input.payload.source
|
|
18
|
+
|| typeof input.payload.source.repository !== "string")
|
|
19
|
+
refuse("invalid_producer_payload");
|
|
20
|
+
const envelope = produceReleaseEnvelope(input.payload, input.privateKey, input.compatibility);
|
|
21
|
+
const publicKey = createPublicKey(input.privateKey), producers = new Map([[releaseProducerKeyId(publicKey), {
|
|
22
|
+
publicKey, repository: input.payload.source.repository, channels: new Set([input.compatibility.channel])
|
|
23
|
+
}]]);
|
|
24
|
+
const admitted = authenticateReleaseEnvelope(envelope, producers, input.compatibility);
|
|
25
|
+
validateReleasePayloadIdentity(admitted.manifest, input.canonArchive, input.engineArchive);
|
|
26
|
+
const root = path.resolve(destination), parent = path.dirname(root);
|
|
27
|
+
if (root === parent || !fs.existsSync(parent) || !fs.lstatSync(parent).isDirectory())
|
|
28
|
+
refuse("invalid_output_parent");
|
|
29
|
+
const envelopeBytes = Buffer.from(JSON.stringify(envelope));
|
|
30
|
+
const lease = acquireInstallationLock(`${root}.lock`, hybridDigest({ envelope, canon: admitted.manifest.canon.archive, engine: admitted.manifest.engine.archive }));
|
|
31
|
+
let staging = null;
|
|
32
|
+
try {
|
|
33
|
+
if (fs.existsSync(root)) {
|
|
34
|
+
const retained = readLocalReleaseBundle(root, producers, input.compatibility);
|
|
35
|
+
if (fs.readdirSync(root).sort().join("/") !== "canon.tgz/engine.tgz/release-envelope.json"
|
|
36
|
+
|| !fs.readFileSync(path.join(root, "release-envelope.json")).equals(envelopeBytes)
|
|
37
|
+
|| !retained.canonArchive.equals(input.canonArchive) || !retained.engineArchive.equals(input.engineArchive))
|
|
38
|
+
refuse("immutable_bundle_conflict");
|
|
39
|
+
return { root, replayed: true, producer_key_id: envelope.producer_key_id };
|
|
40
|
+
}
|
|
41
|
+
staging = path.join(parent, `.release-staging-${randomUUID()}`);
|
|
42
|
+
fs.mkdirSync(staging, { mode: 0o700 });
|
|
43
|
+
for (const [name, bytes] of [["release-envelope.json", envelopeBytes], ["canon.tgz", input.canonArchive], ["engine.tgz", input.engineArchive]]) {
|
|
44
|
+
const fd = fs.openSync(path.join(staging, name), "wx", 0o600);
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(fd, bytes);
|
|
47
|
+
fs.fsyncSync(fd);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
fs.closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
readLocalReleaseBundle(staging, producers, input.compatibility);
|
|
54
|
+
fs.renameSync(staging, root);
|
|
55
|
+
staging = null;
|
|
56
|
+
return { root, replayed: false, producer_key_id: envelope.producer_key_id };
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (staging)
|
|
60
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
61
|
+
lease.release();
|
|
62
|
+
}
|
|
63
|
+
}
|