@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,109 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { validateHybridContract } from "./hybrid-contracts.js";
|
|
3
|
+
import { mergeModelListing } from "./model-discovery.js";
|
|
4
|
+
import { readBoundedReleaseFile } from "./release-source.js";
|
|
5
|
+
// Reviewed adapter fallbacks and retained eval configurations, not vendor
|
|
6
|
+
// availability promises or recommended defaults. Missing facts stay unknown;
|
|
7
|
+
// historical eval qualification/capacity claims are deliberately not imported.
|
|
8
|
+
const seed = {
|
|
9
|
+
schema_id: "dd-flow/model-catalog@1", version: "1.1.0",
|
|
10
|
+
entries: [{ harness: "agy", provider: "google", model: "gemini-3.1-pro-high",
|
|
11
|
+
settings: { reasoning: ["unknown"], mode: ["unknown"] },
|
|
12
|
+
provenance: { kind: "reviewed-seed", source: "https://github.com/deksden-com/dd-flow-cli/blob/2ba2ce2364705cf59fdc48bdd5dc5b37fb7cd784/src/harness-runtime/lib/dd-agy.mjs#L7",
|
|
13
|
+
reviewed_at: "2026-10-07T00:00:00Z" }, availability: "not-observed", qualification: { scope: "seed-only", evidence: null } },
|
|
14
|
+
{ harness: "grok", provider: "xai", model: "grok-4.6",
|
|
15
|
+
settings: { reasoning: ["high"], mode: ["bypassPermissions"] },
|
|
16
|
+
provenance: { kind: "reviewed-seed", source: "https://github.com/deksden-com/dd-flow-cli/blob/2ba2ce2364705cf59fdc48bdd5dc5b37fb7cd784/src/harness-runtime/lib/dd-grok.mjs#L48",
|
|
17
|
+
reviewed_at: "2026-10-07T00:00:00Z" }, availability: "not-observed", qualification: { scope: "seed-only", evidence: null } },
|
|
18
|
+
{ harness: "codex", provider: "unknown", model: "gpt-6.1-sol",
|
|
19
|
+
settings: { reasoning: ["high"], mode: ["unknown"] },
|
|
20
|
+
provenance: { kind: "reviewed-seed", source: "https://github.com/deksden-com/dd-eval/blob/9715970544096fc0288ac8e18f95d4e35997be54/profiles/codex-desktop-gpt-6-1-sol-high.json",
|
|
21
|
+
reviewed_at: "2026-10-07T00:00:00Z" }, availability: "not-observed", qualification: { scope: "seed-only", evidence: null } },
|
|
22
|
+
{ harness: "zcode", provider: "builtin:zai-coding-plan", model: "GLM-5.3",
|
|
23
|
+
settings: { reasoning: ["high"], mode: ["yolo"] },
|
|
24
|
+
provenance: { kind: "reviewed-seed", source: "https://github.com/deksden-com/dd-eval/blob/9715970544096fc0288ac8e18f95d4e35997be54/profiles/zcode-acp-zai-glm-5-3-high.json",
|
|
25
|
+
reviewed_at: "2026-10-07T00:00:00Z" }, availability: "not-observed", qualification: { scope: "seed-only", evidence: null } },
|
|
26
|
+
{ harness: "opencode", provider: "opencode", model: "big-pickle",
|
|
27
|
+
settings: { reasoning: ["default"], mode: ["build"] },
|
|
28
|
+
provenance: { kind: "reviewed-seed", source: "https://github.com/deksden-com/dd-eval/blob/9715970544096fc0288ac8e18f95d4e35997be54/profiles/opencode-server-opencode-big-pickle-default.json",
|
|
29
|
+
reviewed_at: "2026-10-07T00:00:00Z" }, availability: "not-observed", qualification: { scope: "seed-only", evidence: null } },
|
|
30
|
+
{ harness: "droid", provider: "openai", model: "gpt-5.6-sol",
|
|
31
|
+
settings: { reasoning: ["high"], mode: ["auto-high"] },
|
|
32
|
+
provenance: { kind: "reviewed-seed", source: "https://github.com/deksden-com/dd-eval/blob/9715970544096fc0288ac8e18f95d4e35997be54/profiles/droid-cli-openai-gpt-5-6-sol-high.json",
|
|
33
|
+
reviewed_at: "2026-10-07T00:00:00Z" }, availability: "not-observed", qualification: { scope: "seed-only", evidence: null } }]
|
|
34
|
+
};
|
|
35
|
+
const families = ["codex", "zcode", "agy", "opencode", "grok", "droid"];
|
|
36
|
+
const identifier = (value, max = 128) => typeof value === "string" && value.length <= max && /^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/.test(value);
|
|
37
|
+
const providerId = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
|
|
38
|
+
function invalid() {
|
|
39
|
+
throw new AppError("model_catalog_input", "Model catalog input requires reviewed seed data and an explicit observation scope; see dd-flow models --help", 2, { effect: "no_effect", help: "models" });
|
|
40
|
+
}
|
|
41
|
+
function readJson(file) {
|
|
42
|
+
let bytes;
|
|
43
|
+
try {
|
|
44
|
+
bytes = readBoundedReleaseFile(file, 4 * 1024 * 1024);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new AppError("model_input_unavailable", "Model input must be a bounded readable regular file; see dd-flow models --help", 1, { effect: "no_effect", help: "models" });
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return invalid();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function readModelSeed(file) {
|
|
57
|
+
const input = file === undefined ? structuredClone(seed) : readJson(file);
|
|
58
|
+
if (validateHybridContract("model-catalog", input).length)
|
|
59
|
+
invalid();
|
|
60
|
+
const catalog = input;
|
|
61
|
+
if (catalog.entries.length > 10_000 || catalog.entries.some(entry => {
|
|
62
|
+
let source;
|
|
63
|
+
try {
|
|
64
|
+
source = new URL(entry.provenance.source);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
const reviewed = entry.provenance.reviewed_at;
|
|
70
|
+
return entry.qualification.scope !== "seed-only" || !providerId(entry.provider) || !identifier(entry.model, 256)
|
|
71
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(reviewed) || !Number.isFinite(Date.parse(reviewed))
|
|
72
|
+
|| new Date(reviewed).toISOString() !== reviewed.replace(/Z$/, reviewed.includes(".") ? "Z" : ".000Z")
|
|
73
|
+
|| source.protocol !== "https:" || source.username !== "" || source.password !== "" || source.search !== ""
|
|
74
|
+
|| entry.provenance.source.length > 2048
|
|
75
|
+
|| [...entry.settings.reasoning, ...entry.settings.mode].some(value => !identifier(value, 64));
|
|
76
|
+
}))
|
|
77
|
+
invalid();
|
|
78
|
+
return structuredClone(catalog);
|
|
79
|
+
}
|
|
80
|
+
/** Offline reader only. A supplied snapshot is an observation, not account authorization. */
|
|
81
|
+
export function listOperatorModels(input) {
|
|
82
|
+
if (input.harness !== undefined && !families.includes(input.harness) || input.provider !== undefined && !providerId(input.provider)
|
|
83
|
+
|| input.model !== undefined && !identifier(input.model, 256)
|
|
84
|
+
|| input.accountSha256 !== undefined && !/^[a-f0-9]{64}$/.test(input.accountSha256)
|
|
85
|
+
|| (input.accountSha256 !== undefined || input.observationFile !== undefined) && (!input.harness || !input.provider || !input.accountSha256))
|
|
86
|
+
invalid();
|
|
87
|
+
// Validate all selectors before opening operator files.
|
|
88
|
+
const catalog = readModelSeed(input.seedFile);
|
|
89
|
+
let entries, reason, observedAt = null;
|
|
90
|
+
if (input.accountSha256) {
|
|
91
|
+
const scope = { harness: input.harness, provider: input.provider, account_sha256: input.accountSha256 };
|
|
92
|
+
const observation = input.observationFile === undefined ? undefined : readJson(input.observationFile);
|
|
93
|
+
const merged = mergeModelListing(catalog, scope, observation);
|
|
94
|
+
entries = merged.entries.map(entry => ({ harness: scope.harness, provider: scope.provider, ...entry }));
|
|
95
|
+
reason = merged.reason;
|
|
96
|
+
observedAt = merged.observed_at;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
entries = catalog.entries.filter(entry => (!input.harness || entry.harness === input.harness) && (!input.provider || entry.provider === input.provider))
|
|
100
|
+
.map(entry => ({ harness: entry.harness, provider: entry.provider, model: entry.model, seed: structuredClone(entry),
|
|
101
|
+
availability: "unknown", account_access: "unknown", qualified: "unknown" }));
|
|
102
|
+
reason = "account_scope_not_selected";
|
|
103
|
+
}
|
|
104
|
+
if (input.model)
|
|
105
|
+
entries = entries.filter(entry => entry.model === input.model);
|
|
106
|
+
return { catalog_version: catalog.version, observed_at: observedAt, reason, entries,
|
|
107
|
+
families: families.map(harness => ({ harness, seed_status: catalog.entries.some(entry => entry.harness === harness) ? "reviewed_entries" : "not_curated",
|
|
108
|
+
account_access: "unknown", qualified: "unknown" })) };
|
|
109
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
}
|