@deksden-com/dd-flow-cli 0.9.0-beta.136 → 0.9.0-beta.137
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 +8 -0
- package/dist/build-info.json +3 -3
- package/dist/harness-runtime/DD-ZCODE.md +13 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +3 -3
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +14 -1
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +36 -1
- package/dist/harness-runtime/lib/dd-zcode.mjs +7 -3
- package/dist/harness-runtime/lib/opencode-plugin.mjs +3 -1
- package/dist/services/controller-child-observer.js +99 -0
- package/dist/services/controller-fanout.js +9 -2
- package/dist/services/run-controller.js +83 -8
- package/dist/services/stage-lifecycle.js +2 -1
- package/dist/services/stage-report-renderer.js +13 -0
- package/dist/services/vnext-fanout.js +66 -1
- package/dist/services/vnext-protocolize.js +60 -2
- package/package.json +1 -1
- package/dist/cli/command-metadata.js +0 -144
- package/dist/cli/command-option-descriptions.js +0 -186
- package/dist/cli/completion-cli.js +0 -70
- package/dist/cli/completion.js +0 -57
- package/dist/cli/error-output.js +0 -52
- package/dist/cli/executor-onboarding.js +0 -71
- package/dist/cli/harnesses.js +0 -84
- package/dist/cli/models.js +0 -39
- package/dist/cli/operator-profiles.js +0 -61
- package/dist/cli/output-options.js +0 -35
- package/dist/cli/response-file.js +0 -69
- package/dist/cli/setup.js +0 -147
- package/dist/services/cli-upgrade-allowlist.js +0 -27
- package/dist/services/discovery/agy-discovery.js +0 -2
- package/dist/services/discovery/codex-discovery.js +0 -2
- package/dist/services/discovery/droid-discovery.js +0 -2
- package/dist/services/discovery/grok-discovery.js +0 -2
- package/dist/services/discovery/opencode-discovery.js +0 -2
- package/dist/services/discovery/static.js +0 -31
- package/dist/services/discovery/zcode-discovery.js +0 -2
- package/dist/services/executor-onboarding.js +0 -56
- package/dist/services/harness-discovery.js +0 -29
- package/dist/services/harness-doctor.js +0 -75
- package/dist/services/harness-probe-effects.js +0 -51
- package/dist/services/harness-version-probe.js +0 -100
- package/dist/services/hybrid-contracts.js +0 -455
- package/dist/services/installation-lock.js +0 -92
- package/dist/services/model-catalog.js +0 -109
- package/dist/services/model-discovery.js +0 -46
- package/dist/services/operator-profiles.js +0 -236
- package/dist/services/release-archive-producer.js +0 -52
- package/dist/services/release-archive.js +0 -143
- package/dist/services/release-cache.js +0 -142
- package/dist/services/release-compatibility.js +0 -18
- package/dist/services/release-download.js +0 -67
- package/dist/services/release-envelope.js +0 -104
- package/dist/services/release-manifest.js +0 -45
- package/dist/services/release-payload.js +0 -114
- package/dist/services/release-producer.js +0 -63
- package/dist/services/release-provenance.js +0 -160
- package/dist/services/release-registry.js +0 -66
- package/dist/services/release-source.js +0 -194
- package/dist/services/setup.js +0 -377
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { AppError } from "../shared/errors.js";
|
|
2
|
-
import { validateHybridContract } from "./hybrid-contracts.js";
|
|
3
|
-
const invalid = () => new AppError("model_discovery_input", "Model discovery input does not match its scoped contract", 2, { effect: "no_effect" });
|
|
4
|
-
export function validateModelScope(scope) {
|
|
5
|
-
if (!scope || typeof scope !== "object" || Array.isArray(scope)
|
|
6
|
-
|| ["harness", "provider", "account_sha256"].some(key => !Object.hasOwn(scope, key))
|
|
7
|
-
|| Object.keys(scope).some(key => !["harness", "provider", "account_sha256"].includes(key))
|
|
8
|
-
|| !["codex", "zcode", "agy", "opencode", "grok", "droid"].includes(scope.harness)
|
|
9
|
-
|| typeof scope.provider !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(scope.provider)
|
|
10
|
-
|| typeof scope.account_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(scope.account_sha256))
|
|
11
|
-
throw invalid();
|
|
12
|
-
}
|
|
13
|
-
/** Merge only a matching account/provider snapshot. Listed is not authorized or qualified. */
|
|
14
|
-
export function mergeModelListing(seed, scope, observation) {
|
|
15
|
-
validateModelScope(scope);
|
|
16
|
-
if (validateHybridContract("model-catalog", seed).length)
|
|
17
|
-
throw invalid();
|
|
18
|
-
if (observation !== undefined) {
|
|
19
|
-
if (!observation || typeof observation !== "object" || Array.isArray(observation)
|
|
20
|
-
|| ["scope", "observed_at", "status", "complete", "models"].some(key => !Object.hasOwn(observation, key)))
|
|
21
|
-
throw invalid();
|
|
22
|
-
validateModelScope(observation.scope);
|
|
23
|
-
if (Object.keys(observation).some(key => !["scope", "observed_at", "status", "complete", "models"].includes(key))
|
|
24
|
-
|| typeof observation.observed_at !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(observation.observed_at)
|
|
25
|
-
|| !Number.isFinite(Date.parse(observation.observed_at))
|
|
26
|
-
|| new Date(observation.observed_at).toISOString() !== observation.observed_at.replace(/Z$/, observation.observed_at.includes(".") ? "Z" : ".000Z")
|
|
27
|
-
|| !["listed", "unavailable"].includes(observation.status)
|
|
28
|
-
|| typeof observation.complete !== "boolean" || !Array.isArray(observation.models)
|
|
29
|
-
|| observation.models.length > 10_000 || observation.models.some(model => typeof model !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,255}$/.test(model))
|
|
30
|
-
|| new Set(observation.models).size !== observation.models.length
|
|
31
|
-
|| observation.status === "unavailable" && (observation.models.length > 0 || observation.complete))
|
|
32
|
-
throw invalid();
|
|
33
|
-
}
|
|
34
|
-
const matches = observation && observation.scope.harness === scope.harness && observation.scope.provider === scope.provider
|
|
35
|
-
&& observation.scope.account_sha256 === scope.account_sha256;
|
|
36
|
-
const listing = matches && observation.status === "listed" ? observation : undefined;
|
|
37
|
-
const entries = seed.entries.filter(entry => entry.harness === scope.harness && entry.provider === scope.provider);
|
|
38
|
-
const seeded = new Map(entries.map(entry => [entry.model, entry]));
|
|
39
|
-
const names = new Set([...seeded.keys(), ...(listing?.models ?? [])]);
|
|
40
|
-
return { scope: { ...scope }, observed_at: matches ? observation.observed_at : null,
|
|
41
|
-
reason: !observation ? "not_observed" : !matches ? "foreign_scope_ignored" : !listing ? "listing_unavailable" : "scoped_listing",
|
|
42
|
-
entries: [...names].sort().map(model => ({ model,
|
|
43
|
-
seed: seeded.has(model) ? structuredClone(seeded.get(model)) : null,
|
|
44
|
-
availability: listing?.models.includes(model) ? "listed" : listing?.complete ? "not_listed" : "unknown",
|
|
45
|
-
account_access: "unknown", qualified: "unknown" })) };
|
|
46
|
-
}
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { AppError } from "../shared/errors.js";
|
|
5
|
-
import { hybridDigest, validateHybridContract } from "./hybrid-contracts.js";
|
|
6
|
-
import { acquireInstallationLock } from "./installation-lock.js";
|
|
7
|
-
import { readBoundedReleaseFile } from "./release-source.js";
|
|
8
|
-
export function operatorSettingsPath(home) { return path.join(home, "profile-settings.json"); }
|
|
9
|
-
function profileError(code, message, exitCode = 2) {
|
|
10
|
-
return new AppError(code, `${message}; use dd-flow profiles --help`, exitCode, { phase: "profiles", effect: "no_effect", recoverable: true });
|
|
11
|
-
}
|
|
12
|
-
function completeSettings(input) {
|
|
13
|
-
const value = { ...input, profiles: input.profiles.map(entry => ({ ...entry, revision: hybridDigest(entry.profile) })) };
|
|
14
|
-
return { ...value, revision: hybridDigest(value) };
|
|
15
|
-
}
|
|
16
|
-
function admittedSettings(input) {
|
|
17
|
-
const issues = validateHybridContract("profile-settings", input);
|
|
18
|
-
if (issues.length)
|
|
19
|
-
throw profileError("profile_settings_invalid", "Profile settings violate the schema or aliases/revision contract");
|
|
20
|
-
const settings = input;
|
|
21
|
-
for (const { profile } of settings.profiles) {
|
|
22
|
-
if ([profile.provider, profile.model, profile.reasoning, profile.mode].some(value => !value.trim())) {
|
|
23
|
-
throw profileError("profile_settings_invalid", "Profile execution values must not be blank");
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return settings;
|
|
27
|
-
}
|
|
28
|
-
function readRegularJson(file) {
|
|
29
|
-
try {
|
|
30
|
-
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(readBoundedReleaseFile(file, 4 * 1024 * 1024)));
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
// Parser errors can contain submitted secrets. Never retain input text or cause.
|
|
34
|
-
throw profileError("profile_settings_invalid", "Profile input is unreadable or malformed JSON");
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/** First successful write imports valid legacy IDs; thereafter the settings file is the sole owner. */
|
|
38
|
-
export function readOperatorProfiles(home) {
|
|
39
|
-
const file = operatorSettingsPath(home);
|
|
40
|
-
if (fs.existsSync(file))
|
|
41
|
-
return admittedSettings(readRegularJson(file));
|
|
42
|
-
const profiles = [];
|
|
43
|
-
const legacy = path.join(home, "agent-profiles");
|
|
44
|
-
if (fs.existsSync(legacy)) {
|
|
45
|
-
for (const entry of fs.readdirSync(legacy).sort()) {
|
|
46
|
-
if (!entry.endsWith(".json"))
|
|
47
|
-
continue;
|
|
48
|
-
const profile = readRegularJson(path.join(legacy, entry));
|
|
49
|
-
const id = entry.slice(0, -5);
|
|
50
|
-
profiles.push({ id, revision: hybridDigest(profile), profile });
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return admittedSettings(completeSettings({ schema_id: "dd-flow/profile-settings@1", profiles,
|
|
54
|
-
aliases: {}, default_profile: null }));
|
|
55
|
-
}
|
|
56
|
-
export function resolveOperatorProfile(settings, id) {
|
|
57
|
-
const selected = id ?? settings.default_profile;
|
|
58
|
-
if (!selected)
|
|
59
|
-
throw profileError("agent_profile_missing", "Select an explicit profile or configure a default", 1);
|
|
60
|
-
const target = Object.hasOwn(settings.aliases, selected) ? settings.aliases[selected] : selected;
|
|
61
|
-
const entry = settings.profiles.find(item => item.id === target);
|
|
62
|
-
if (!entry)
|
|
63
|
-
throw profileError("agent_profile_missing", "Selected profile is unavailable", 1);
|
|
64
|
-
return structuredClone(entry.profile);
|
|
65
|
-
}
|
|
66
|
-
/** Used by explicit files and onboarding alike; extra credential fields fail before any write. */
|
|
67
|
-
export function prepareOperatorProfile(input) {
|
|
68
|
-
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
69
|
-
throw profileError("profile_settings_invalid", "Profile must be an object");
|
|
70
|
-
const sample = input;
|
|
71
|
-
const value = completeSettings({ schema_id: "dd-flow/profile-settings@1", profiles: [
|
|
72
|
-
{ id: sample?.id, revision: hybridDigest(input), profile: sample }
|
|
73
|
-
], aliases: {}, default_profile: null });
|
|
74
|
-
return admittedSettings(value).profiles[0].profile;
|
|
75
|
-
}
|
|
76
|
-
export function readOperatorProfileInput(file) {
|
|
77
|
-
return prepareOperatorProfile(readRegularJson(file));
|
|
78
|
-
}
|
|
79
|
-
function changedSettings(current, mutation) {
|
|
80
|
-
const next = structuredClone(current);
|
|
81
|
-
if (mutation.action === "create" || mutation.action === "edit") {
|
|
82
|
-
if (mutation.action === "create" && mutation.setDefault !== undefined && typeof mutation.setDefault !== "boolean") {
|
|
83
|
-
throw profileError("profile_settings_invalid", "Default selection must be an explicit boolean");
|
|
84
|
-
}
|
|
85
|
-
const profile = prepareOperatorProfile(mutation.profile);
|
|
86
|
-
const index = next.profiles.findIndex(entry => entry.id === profile.id);
|
|
87
|
-
if (mutation.action === "create" && index >= 0)
|
|
88
|
-
throw profileError("profile_conflict", "Profile ID already exists", 1);
|
|
89
|
-
if (mutation.action === "edit" && index < 0)
|
|
90
|
-
throw profileError("agent_profile_missing", "Cannot edit a missing profile", 1);
|
|
91
|
-
const entry = { id: profile.id, revision: hybridDigest(profile), profile };
|
|
92
|
-
if (index >= 0)
|
|
93
|
-
next.profiles[index] = entry;
|
|
94
|
-
else
|
|
95
|
-
next.profiles.push(entry);
|
|
96
|
-
if (mutation.action === "create" && mutation.setDefault === true)
|
|
97
|
-
next.default_profile = profile.id;
|
|
98
|
-
}
|
|
99
|
-
else if (mutation.action === "remove") {
|
|
100
|
-
if (!next.profiles.some(entry => entry.id === mutation.id))
|
|
101
|
-
throw profileError("agent_profile_missing", "Cannot remove a missing profile", 1);
|
|
102
|
-
if (Object.values(next.aliases).includes(mutation.id) || next.default_profile === mutation.id
|
|
103
|
-
|| next.default_profile !== null && next.aliases[next.default_profile] === mutation.id) {
|
|
104
|
-
throw profileError("profile_in_use", "Remove aliases and change the default before removing this profile", 1);
|
|
105
|
-
}
|
|
106
|
-
next.profiles = next.profiles.filter(entry => entry.id !== mutation.id);
|
|
107
|
-
}
|
|
108
|
-
else if (mutation.action === "alias") {
|
|
109
|
-
if (mutation.target === null) {
|
|
110
|
-
if (!Object.hasOwn(next.aliases, mutation.alias))
|
|
111
|
-
throw profileError("profile_alias_missing", "Alias is unavailable", 1);
|
|
112
|
-
if (next.default_profile === mutation.alias)
|
|
113
|
-
throw profileError("profile_in_use", "Change the default before removing its alias", 1);
|
|
114
|
-
delete next.aliases[mutation.alias];
|
|
115
|
-
}
|
|
116
|
-
else
|
|
117
|
-
Object.defineProperty(next.aliases, mutation.alias, { value: mutation.target, enumerable: true, writable: true, configurable: true });
|
|
118
|
-
}
|
|
119
|
-
else if (mutation.action === "set-default")
|
|
120
|
-
next.default_profile = mutation.id;
|
|
121
|
-
else
|
|
122
|
-
throw profileError("profile_settings_invalid", "Unknown profile mutation");
|
|
123
|
-
const { revision: _revision, ...content } = next;
|
|
124
|
-
void _revision;
|
|
125
|
-
return admittedSettings(completeSettings(content));
|
|
126
|
-
}
|
|
127
|
-
/** Pure preflight suitable for freezing into an installation request before any effects. */
|
|
128
|
-
export function prepareProfileCreation(settings, profile, setDefault) {
|
|
129
|
-
if (typeof setDefault !== "boolean")
|
|
130
|
-
throw profileError("profile_settings_invalid", "Default selection requires explicit consent");
|
|
131
|
-
const before = admittedSettings(structuredClone(settings)), admitted = prepareOperatorProfile(profile);
|
|
132
|
-
const after = changedSettings(before, { action: "create", profile: admitted, setDefault });
|
|
133
|
-
return { schema_id: "dd-flow/profile-creation-intent@1", before, after, profile: structuredClone(admitted), set_default: setDefault };
|
|
134
|
-
}
|
|
135
|
-
/** Exact frozen intent reconciliation: an already-applied write is not applied a second time.
|
|
136
|
-
* A later edit/default change is a conflict, never a reason to restore old settings.
|
|
137
|
-
*/
|
|
138
|
-
export function validateProfileCreationIntent(input) {
|
|
139
|
-
if (!input || typeof input !== "object" || Array.isArray(input)
|
|
140
|
-
|| Object.keys(input).some(key => !["schema_id", "before", "after", "profile", "set_default"].includes(key))
|
|
141
|
-
|| ["schema_id", "before", "after", "profile", "set_default"].some(key => !Object.hasOwn(input, key))
|
|
142
|
-
|| input.schema_id !== "dd-flow/profile-creation-intent@1")
|
|
143
|
-
throw profileError("profile_settings_invalid", "Invalid profile creation intent");
|
|
144
|
-
const expected = prepareProfileCreation(input.before, input.profile, input.set_default);
|
|
145
|
-
admittedSettings(input.after);
|
|
146
|
-
if (hybridDigest(expected) !== hybridDigest(input))
|
|
147
|
-
throw profileError("profile_settings_invalid", "Profile creation intent changed");
|
|
148
|
-
}
|
|
149
|
-
export function readProfileCreationIntent(file) {
|
|
150
|
-
try {
|
|
151
|
-
const input = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(readBoundedReleaseFile(file, 4 * 1024 * 1024)));
|
|
152
|
-
validateProfileCreationIntent(input);
|
|
153
|
-
return input;
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
throw profileError("profile_settings_invalid", "Profile creation intent must be a bounded valid regular JSON file");
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
export function commitProfileCreation(home, input) {
|
|
160
|
-
validateProfileCreationIntent(input);
|
|
161
|
-
const current = readOperatorProfiles(home);
|
|
162
|
-
if (current.revision === input.after.revision)
|
|
163
|
-
return { replayed: true, settings: current };
|
|
164
|
-
return { replayed: false, settings: mutateOperatorProfiles(home, input.before.revision, { action: "create", profile: input.profile, setDefault: input.set_default }) };
|
|
165
|
-
}
|
|
166
|
-
/** Compare-and-swap under an exclusive owned lock; no dual writer for legacy profile files. */
|
|
167
|
-
export function mutateOperatorProfiles(home, expectedRevision, mutation) {
|
|
168
|
-
const before = readOperatorProfiles(home);
|
|
169
|
-
if (before.revision !== expectedRevision)
|
|
170
|
-
throw profileError("profile_revision_conflict", "Profile settings changed; reread before retrying", 1);
|
|
171
|
-
const prepared = changedSettings(before, mutation); // Fully validate before creating the home/lock.
|
|
172
|
-
const serialized = JSON.stringify(prepared, null, 2) + "\n";
|
|
173
|
-
if (Buffer.byteLength(serialized) > 4 * 1024 * 1024)
|
|
174
|
-
throw profileError("profile_settings_invalid", "Profile settings exceed the bounded reader limit");
|
|
175
|
-
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
176
|
-
const lock = path.join(home, "profile-settings.lock");
|
|
177
|
-
// Profiles use storage-owner identity, not a setup request ledger. A dead
|
|
178
|
-
// writer may be recovered by a later CAS mutation, including after commit.
|
|
179
|
-
let lease;
|
|
180
|
-
try {
|
|
181
|
-
lease = acquireInstallationLock(lock, hybridDigest({ resource: "profile-settings", home: path.resolve(home) }));
|
|
182
|
-
}
|
|
183
|
-
catch {
|
|
184
|
-
throw profileError("profile_write_conflict", "Another profile writer or unreconciled lock exists", 1);
|
|
185
|
-
}
|
|
186
|
-
const temporary = path.join(home, `.profile-settings-${randomUUID()}.tmp`);
|
|
187
|
-
let ownsTemporary = false;
|
|
188
|
-
let renameAttempted = false;
|
|
189
|
-
let failure;
|
|
190
|
-
let result;
|
|
191
|
-
try {
|
|
192
|
-
if (readOperatorProfiles(home).revision !== expectedRevision)
|
|
193
|
-
throw profileError("profile_revision_conflict", "Profile settings changed while acquiring the lock", 1);
|
|
194
|
-
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
195
|
-
ownsTemporary = true;
|
|
196
|
-
try {
|
|
197
|
-
fs.writeFileSync(fd, serialized);
|
|
198
|
-
fs.fsyncSync(fd);
|
|
199
|
-
}
|
|
200
|
-
finally {
|
|
201
|
-
fs.closeSync(fd);
|
|
202
|
-
}
|
|
203
|
-
renameAttempted = true;
|
|
204
|
-
fs.renameSync(temporary, operatorSettingsPath(home));
|
|
205
|
-
result = readOperatorProfiles(home);
|
|
206
|
-
}
|
|
207
|
-
catch (error) {
|
|
208
|
-
failure = error instanceof AppError && !renameAttempted ? error : new AppError("profile_write_interrupted", "Profile write requires reconciliation; reread settings before retrying", 1, { phase: "profiles", effect: "unknown", recoverable: true,
|
|
209
|
-
next_action: "dd-flow profiles list --json" });
|
|
210
|
-
}
|
|
211
|
-
finally {
|
|
212
|
-
let cleanupFailed = false;
|
|
213
|
-
try {
|
|
214
|
-
if (ownsTemporary && fs.existsSync(temporary))
|
|
215
|
-
fs.unlinkSync(temporary);
|
|
216
|
-
}
|
|
217
|
-
catch {
|
|
218
|
-
cleanupFailed = true;
|
|
219
|
-
}
|
|
220
|
-
try {
|
|
221
|
-
lease.release();
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
cleanupFailed = true;
|
|
225
|
-
}
|
|
226
|
-
if (cleanupFailed) {
|
|
227
|
-
if (failure)
|
|
228
|
-
failure.details.cleanup = "not_confirmed";
|
|
229
|
-
else
|
|
230
|
-
failure = new AppError("profile_cleanup_failed", "Profile writer cleanup requires reconciliation", 1, { phase: "profiles", effect: "unknown", cleanup: "not_confirmed", next_action: "dd-flow profiles list --json" });
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
if (failure)
|
|
234
|
-
throw failure;
|
|
235
|
-
return result;
|
|
236
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { gzipSync } from "node:zlib";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import { AppError } from "../shared/errors.js";
|
|
4
|
-
import { inspectReleaseArchive } from "./release-archive.js";
|
|
5
|
-
/** Deterministic regular-file-only tar/gzip. Extraction admission is also applied
|
|
6
|
-
* to producer output; producing bytes never grants them installation authority.
|
|
7
|
-
*/
|
|
8
|
-
export function produceReleaseArchive(files) {
|
|
9
|
-
const refuse = () => { throw new AppError("release_producer", "Archive production refused", 1, { reason: "invalid_archive_input", effect: "no_effect" }); };
|
|
10
|
-
if (!files.length || files.length > 100_000)
|
|
11
|
-
refuse();
|
|
12
|
-
const chunks = [];
|
|
13
|
-
let expanded = 1024;
|
|
14
|
-
const append = (name, bytes, mode, type) => {
|
|
15
|
-
expanded += 512 + Math.ceil(bytes.length / 512) * 512;
|
|
16
|
-
if (expanded > 512 * 1024 * 1024)
|
|
17
|
-
refuse();
|
|
18
|
-
const header = Buffer.alloc(512);
|
|
19
|
-
header.write(name);
|
|
20
|
-
header.write(mode.toString(8).padStart(7, "0") + "\0", 100);
|
|
21
|
-
header.write("0000000\0", 108);
|
|
22
|
-
header.write("0000000\0", 116);
|
|
23
|
-
header.write(bytes.length.toString(8).padStart(11, "0") + "\0", 124);
|
|
24
|
-
header.write("00000000000\0", 136);
|
|
25
|
-
header.fill(32, 148, 156);
|
|
26
|
-
header[156] = type;
|
|
27
|
-
header.write("ustar\0", 257);
|
|
28
|
-
header.write("00", 263);
|
|
29
|
-
header.write(header.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(6, "0") + "\0 ", 148);
|
|
30
|
-
chunks.push(header, bytes, Buffer.alloc((512 - bytes.length % 512) % 512));
|
|
31
|
-
};
|
|
32
|
-
let index = 0;
|
|
33
|
-
for (const file of [...files].sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)))) {
|
|
34
|
-
const length = Buffer.byteLength(file.path);
|
|
35
|
-
if (!length || length > 4096 || !Buffer.isBuffer(file.bytes) || typeof file.executable !== "boolean")
|
|
36
|
-
refuse();
|
|
37
|
-
let name = file.path;
|
|
38
|
-
if (length > 100) {
|
|
39
|
-
const record = `path=${file.path}\n`;
|
|
40
|
-
let size = Buffer.byteLength(record) + 2;
|
|
41
|
-
while (size !== Buffer.byteLength(record) + String(size).length + 1)
|
|
42
|
-
size = Buffer.byteLength(record) + String(size).length + 1;
|
|
43
|
-
append(`PaxHeaders/${index}`, Buffer.from(`${size} ${record}`), 0o644, 120);
|
|
44
|
-
name = `entries/${index}`;
|
|
45
|
-
}
|
|
46
|
-
append(name, file.bytes, file.executable ? 0o755 : 0o644, 48);
|
|
47
|
-
index++;
|
|
48
|
-
}
|
|
49
|
-
const bytes = gzipSync(Buffer.concat([...chunks, Buffer.alloc(1024)]), { level: 9 });
|
|
50
|
-
inspectReleaseArchive({ url: "https://producer.invalid/archive", bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") }, bytes);
|
|
51
|
-
return bytes;
|
|
52
|
-
}
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { gunzipSync } from "node:zlib";
|
|
2
|
-
import { TextDecoder } from "node:util";
|
|
3
|
-
import { AppError } from "../shared/errors.js";
|
|
4
|
-
import { verifyReleaseArchive } from "./release-manifest.js";
|
|
5
|
-
const defaultLimits = { expandedBytes: 512 * 1024 * 1024, entries: 100_000 };
|
|
6
|
-
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
7
|
-
function refuse(reason) {
|
|
8
|
-
throw new AppError("archive_integrity", "Release archive cannot be extracted", 1, { reason, phase: "archive_validation", effect: "no_effect", help: "setup" });
|
|
9
|
-
}
|
|
10
|
-
function text(bytes) {
|
|
11
|
-
try {
|
|
12
|
-
return decoder.decode(bytes);
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
return refuse("invalid_utf8");
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
function field(bytes) {
|
|
19
|
-
const zero = bytes.indexOf(0);
|
|
20
|
-
if (zero >= 0 && bytes.subarray(zero).some(byte => byte !== 0))
|
|
21
|
-
refuse("invalid_header_string");
|
|
22
|
-
return text(zero < 0 ? bytes : bytes.subarray(0, zero));
|
|
23
|
-
}
|
|
24
|
-
function octal(bytes) {
|
|
25
|
-
const value = text(bytes).replace(/\0.*$/, "").trim();
|
|
26
|
-
if (!/^[0-7]+$/.test(value))
|
|
27
|
-
refuse("invalid_numeric_header");
|
|
28
|
-
const result = Number.parseInt(value, 8);
|
|
29
|
-
if (!Number.isSafeInteger(result))
|
|
30
|
-
refuse("oversized_numeric_header");
|
|
31
|
-
return result;
|
|
32
|
-
}
|
|
33
|
-
function containedPath(input, directory) {
|
|
34
|
-
const value = directory && input.endsWith("/") ? input.slice(0, -1) : input;
|
|
35
|
-
if (!value || value !== value.normalize("NFC") || /[\\:]/.test(value) ||
|
|
36
|
-
[...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127))
|
|
37
|
-
refuse("unsafe_path");
|
|
38
|
-
for (const part of value.split("/")) {
|
|
39
|
-
if (!part || part === "." || part === ".." || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))
|
|
40
|
-
refuse("unsafe_path");
|
|
41
|
-
}
|
|
42
|
-
return value;
|
|
43
|
-
}
|
|
44
|
-
function pax(bytes) {
|
|
45
|
-
const result = {};
|
|
46
|
-
let offset = 0;
|
|
47
|
-
while (offset < bytes.length) {
|
|
48
|
-
const space = bytes.indexOf(32, offset);
|
|
49
|
-
if (space < 0)
|
|
50
|
-
refuse("invalid_pax");
|
|
51
|
-
const lengthText = text(bytes.subarray(offset, space));
|
|
52
|
-
if (!/^[1-9][0-9]*$/.test(lengthText))
|
|
53
|
-
refuse("invalid_pax");
|
|
54
|
-
const length = Number(lengthText), end = offset + length;
|
|
55
|
-
if (!Number.isSafeInteger(length) || end > bytes.length || end <= space + 1 || bytes[end - 1] !== 10)
|
|
56
|
-
refuse("invalid_pax");
|
|
57
|
-
const record = text(bytes.subarray(space + 1, end - 1)), equal = record.indexOf("=");
|
|
58
|
-
if (equal < 1)
|
|
59
|
-
refuse("invalid_pax");
|
|
60
|
-
const key = record.slice(0, equal), value = record.slice(equal + 1);
|
|
61
|
-
if (Object.hasOwn(result, key))
|
|
62
|
-
refuse("duplicate_pax_key");
|
|
63
|
-
// Do not allow sparse files, links, custom extraction attributes or size overrides.
|
|
64
|
-
if (!["path", "mtime", "atime", "ctime", "uid", "gid", "uname", "gname"].includes(key))
|
|
65
|
-
refuse("unsupported_pax_key");
|
|
66
|
-
result[key] = value;
|
|
67
|
-
offset = end;
|
|
68
|
-
}
|
|
69
|
-
return result;
|
|
70
|
-
}
|
|
71
|
-
/** Validate the entire archive before writing any entry. Links and special files never enter staging. */
|
|
72
|
-
export function inspectReleaseArchive(identity, compressed, limits = defaultLimits) {
|
|
73
|
-
verifyReleaseArchive(identity, compressed);
|
|
74
|
-
if (!Number.isSafeInteger(limits.expandedBytes) || limits.expandedBytes < 1024 || !Number.isSafeInteger(limits.entries) || limits.entries < 1)
|
|
75
|
-
refuse("invalid_limits");
|
|
76
|
-
let tar;
|
|
77
|
-
try {
|
|
78
|
-
tar = gunzipSync(compressed, { maxOutputLength: limits.expandedBytes });
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
return refuse("invalid_or_oversized_gzip");
|
|
82
|
-
}
|
|
83
|
-
if (tar.length % 512 !== 0)
|
|
84
|
-
refuse("truncated_tar");
|
|
85
|
-
const entries = [], paths = new Map(), kinds = new Map();
|
|
86
|
-
let offset = 0, headers = 0, pending = null;
|
|
87
|
-
while (offset + 512 <= tar.length) {
|
|
88
|
-
const header = tar.subarray(offset, offset + 512);
|
|
89
|
-
if (header.every(byte => byte === 0)) {
|
|
90
|
-
if (pending || offset + 1024 > tar.length || tar.subarray(offset).some(byte => byte !== 0))
|
|
91
|
-
refuse("invalid_tar_trailer");
|
|
92
|
-
return entries;
|
|
93
|
-
}
|
|
94
|
-
if (++headers > limits.entries * 2)
|
|
95
|
-
refuse("too_many_headers");
|
|
96
|
-
const checksum = header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0);
|
|
97
|
-
if (octal(header.subarray(148, 156)) !== checksum)
|
|
98
|
-
refuse("header_checksum_mismatch");
|
|
99
|
-
const size = octal(header.subarray(124, 136)), mode = octal(header.subarray(100, 108));
|
|
100
|
-
const next = offset + 512 + Math.ceil(size / 512) * 512;
|
|
101
|
-
if (!Number.isSafeInteger(next) || next > tar.length)
|
|
102
|
-
refuse("truncated_entry");
|
|
103
|
-
const bytes = tar.subarray(offset + 512, offset + 512 + size);
|
|
104
|
-
const type = header[156];
|
|
105
|
-
if (type === 120) {
|
|
106
|
-
if (pending)
|
|
107
|
-
refuse("stacked_pax_headers");
|
|
108
|
-
pending = pax(bytes);
|
|
109
|
-
offset = next;
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (type !== 0 && type !== 48 && type !== 53)
|
|
113
|
-
refuse("unsupported_entry_type");
|
|
114
|
-
const directory = type === 53;
|
|
115
|
-
if (directory && size !== 0)
|
|
116
|
-
refuse("directory_has_data");
|
|
117
|
-
const prefix = field(header.subarray(345, 500));
|
|
118
|
-
const name = field(header.subarray(0, 100));
|
|
119
|
-
const entryPath = containedPath(pending?.path ?? (prefix ? `${prefix}/${name}` : name), directory);
|
|
120
|
-
pending = null;
|
|
121
|
-
const parts = entryPath.split("/");
|
|
122
|
-
for (let index = 1; index <= parts.length; index++) {
|
|
123
|
-
const partial = parts.slice(0, index).join("/"), folded = partial.toLowerCase();
|
|
124
|
-
const previous = paths.get(folded);
|
|
125
|
-
if (previous && previous !== partial)
|
|
126
|
-
refuse("case_collision");
|
|
127
|
-
paths.set(folded, partial);
|
|
128
|
-
if (index < parts.length && kinds.get(partial) === "file")
|
|
129
|
-
refuse("file_directory_collision");
|
|
130
|
-
if (index < parts.length && !kinds.has(partial))
|
|
131
|
-
kinds.set(partial, "implicit_directory");
|
|
132
|
-
}
|
|
133
|
-
const previous = kinds.get(entryPath);
|
|
134
|
-
if (previous && !(directory && previous === "implicit_directory"))
|
|
135
|
-
refuse("duplicate_or_conflicting_entry");
|
|
136
|
-
kinds.set(entryPath, directory ? "directory" : "file");
|
|
137
|
-
if (entries.length >= limits.entries)
|
|
138
|
-
refuse("too_many_entries");
|
|
139
|
-
entries.push({ path: entryPath, kind: directory ? "directory" : "file", mode: mode & 0o111 ? 0o755 : 0o644, bytes: Buffer.from(bytes) });
|
|
140
|
-
offset = next;
|
|
141
|
-
}
|
|
142
|
-
return refuse("missing_tar_trailer");
|
|
143
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|