@awebai/oats 0.23.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/bin/oats-pi-sdk-host.mjs +17 -0
- package/bin/oats.mjs +442 -51
- package/capabilities/oats-okf/bin/oats-okf-binding.mjs +14 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +79 -11
- package/capabilities/oats-okf/lib/binding-wire.mjs +268 -0
- package/capabilities/oats-okf/lib/captured-worker.mjs +101 -0
- package/capabilities/oats-okf/lib/config.mjs +2 -1
- package/capabilities/oats-okf/lib/inspection.mjs +16 -1
- package/capabilities/oats-okf/lib/invocation-context.mjs +111 -0
- package/capabilities/oats-okf/lib/invocation-shape.mjs +135 -0
- package/capabilities/oats-okf/lib/io.mjs +1 -1
- package/capabilities/oats-okf/lib/portable-binding.mjs +199 -0
- package/capabilities/oats-okf/lib/source-contract.mjs +46 -0
- package/capabilities/oats-okf/lib/sources.mjs +123 -3
- package/capabilities/oats-okf/lib/stores.mjs +104 -25
- package/capabilities/oats-okf/lib/worker.mjs +69 -10
- package/capabilities/oats-okf/oats.json +35 -7
- package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +87 -0
- package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +113 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +23 -5
- package/capabilities/oats-okf/skills/okf/SKILL.md +42 -1
- package/docs/artifact-approvals.schema.json +7 -0
- package/docs/capability-manifest.schema.json +37 -66
- package/docs/captured-invocation-context.schema.json +7 -0
- package/docs/captured-resolution.schema.json +7 -0
- package/docs/design/2026-09-14-artifact-retention-contract.md +190 -0
- package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +708 -0
- package/docs/design/2026-09-14-portable-souls-contract-amendments.md +85 -0
- package/docs/design/2026-09-14-portable-souls-explainer.md +750 -0
- package/docs/design/2026-09-15-captured-dispatch.md +127 -0
- package/docs/design/2026-09-15-captured-resolution-records.md +143 -0
- package/docs/design/2026-09-15-package-preparation.md +100 -0
- package/docs/design/2026-09-15-portable-data-contract.md +121 -0
- package/docs/design/2026-09-15-portable-declarations.md +189 -0
- package/docs/design/2026-09-15-portable-souls-handoff.md +150 -0
- package/docs/design/2026-09-15-portable-souls-implementation.md +417 -0
- package/docs/design/2026-09-15-selection-lock-and-approval.md +122 -0
- package/docs/design/2026-09-15-source-observation.md +119 -0
- package/docs/design/2026-09-16-captured-admission.md +77 -0
- package/docs/design/2026-09-16-captured-helper-dispatch.md +105 -0
- package/docs/design/2026-09-16-captured-launch-inputs.md +42 -0
- package/docs/design/2026-09-16-command-profile-preparation.md +86 -0
- package/docs/design/2026-09-16-fresh-install-first-rollout.md +47 -0
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +277 -0
- package/docs/design/2026-09-16-knowledge-capability-contract.md +61 -0
- package/docs/design/2026-09-16-messaging-capability-contract.md +59 -0
- package/docs/design/2026-09-16-portable-migration-evidence.md +156 -0
- package/docs/design/2026-09-16-portable-onboarding.md +177 -0
- package/docs/design/2026-09-16-prepare-request-transport.md +26 -0
- package/docs/design/2026-09-16-provider-binding-codecs.md +98 -0
- package/docs/design/2026-09-16-provider-binding-wire.md +247 -0
- package/docs/design/2026-09-17-capability-helper-input-contract.md +95 -0
- package/docs/design/2026-09-17-captured-backend-parity.md +53 -0
- package/docs/design/2026-09-17-captured-native-start.md +58 -0
- package/docs/design/2026-09-17-portable-boundary-hookup.md +19 -0
- package/docs/design/2026-09-17-portable-boundary-resources.md +52 -0
- package/docs/design/2026-09-17-public-captured-start.md +108 -0
- package/docs/design/2026-09-17-public-prepare-request.md +90 -0
- package/docs/design/2026-09-18-captured-pi-host.md +205 -0
- package/docs/design/2026-09-18-first-cut-release-checklist.md +131 -0
- package/docs/design/2026-09-18-herdr-protocol-compatibility.md +60 -0
- package/docs/desktop-cli-api.md +5 -2
- package/docs/execution-capsule.schema.json +108 -0
- package/docs/execution-targets.md +20 -7
- package/docs/oats-lock-v3.schema.json +7 -0
- package/docs/oats-member.schema.json +38 -0
- package/docs/oats-workspace.schema.json +68 -0
- package/docs/portable.schema.json +2512 -0
- package/docs/provider-check-input.schema.json +7 -0
- package/docs/release-notes/v0.23.2.md +49 -0
- package/docs/release-notes/v0.24.0.md +104 -0
- package/docs/schedules.md +126 -14
- package/docs/soul.schema.json +82 -0
- package/injects/oats-portable.md +17 -0
- package/injects/portable-instance-boundary.md +39 -0
- package/injects/portable-work-directory.md +29 -0
- package/lib/artifact-approvals.mjs +120 -0
- package/lib/artifact-tree.mjs +141 -0
- package/lib/capability-artifacts.mjs +179 -0
- package/lib/capability-execution.mjs +15 -0
- package/lib/capability-inputs.mjs +39 -0
- package/lib/capability-provenance.mjs +231 -0
- package/lib/captured-action-shape.mjs +21 -0
- package/lib/captured-admission-shape.mjs +20 -0
- package/lib/captured-binding-file.mjs +36 -0
- package/lib/captured-dispatch.mjs +66 -0
- package/lib/captured-instance-index.mjs +277 -0
- package/lib/captured-invocation-context.mjs +130 -0
- package/lib/captured-launch-request.mjs +46 -0
- package/lib/captured-operation-process.mjs +15 -0
- package/lib/captured-pi-custody.mjs +29 -0
- package/lib/captured-pi-host.mjs +167 -0
- package/lib/captured-pi-outcome.mjs +172 -0
- package/lib/captured-resolutions.mjs +275 -0
- package/lib/captured-scaffold.mjs +87 -0
- package/lib/captured-selector.mjs +28 -0
- package/lib/captured-session-backend.mjs +52 -0
- package/lib/captured-source-receipt-file.mjs +72 -0
- package/lib/config-data.mjs +104 -0
- package/lib/core.mjs +918 -562
- package/lib/errors.mjs +7 -0
- package/lib/helper-injection-policy.mjs +98 -0
- package/lib/herdr.mjs +18 -7
- package/lib/instruction-composition.mjs +31 -0
- package/lib/legacy-lock-codec.mjs +106 -0
- package/lib/manifest-settings.mjs +84 -0
- package/lib/package-closure.mjs +48 -0
- package/lib/package-materialization.mjs +83 -0
- package/lib/pi-sdk-host.mjs +229 -0
- package/lib/portable-artifacts.mjs +115 -0
- package/lib/portable-choices.mjs +82 -0
- package/lib/portable-composition.mjs +136 -0
- package/lib/portable-digest.mjs +105 -0
- package/lib/portable-files.mjs +26 -0
- package/lib/portable-identity.mjs +40 -0
- package/lib/portable-lock.mjs +117 -0
- package/lib/portable-migration-artifacts.mjs +135 -0
- package/lib/portable-migration-evidence.mjs +305 -0
- package/lib/portable-migration-store.mjs +199 -0
- package/lib/portable-migration.mjs +104 -0
- package/lib/portable-onboarding-acceptance.mjs +66 -0
- package/lib/portable-onboarding-request.mjs +49 -0
- package/lib/portable-onboarding.mjs +230 -0
- package/lib/portable-package-preparation.mjs +188 -0
- package/lib/portable-policy.mjs +44 -0
- package/lib/portable-shape.mjs +35 -0
- package/lib/portable-soul.mjs +38 -0
- package/lib/portable-state.mjs +80 -0
- package/lib/portable-values.mjs +181 -0
- package/lib/prepare-composition.mjs +151 -0
- package/lib/prepared-bindings.mjs +78 -0
- package/lib/prepared-resources.mjs +127 -0
- package/lib/provider-binding-broker.mjs +59 -0
- package/lib/provider-binding-wire.mjs +110 -0
- package/lib/provider-binding.mjs +22 -0
- package/lib/repository-observation.mjs +226 -0
- package/lib/resolution-shape.mjs +393 -0
- package/lib/schedule-capsule.mjs +206 -0
- package/lib/schedule.mjs +259 -38
- package/lib/servers.mjs +15 -0
- package/lib/soul-constraints.mjs +40 -0
- package/lib/source-projection.mjs +84 -0
- package/lib/source-spec.mjs +189 -0
- package/lib/workspace-definition.mjs +126 -0
- package/lib/workspace-discovery.mjs +146 -0
- package/package-catalog.json +1 -1
- package/package.json +3 -2
- package/packages/record/lib/capture-cc.mjs +14 -6
- package/packages/record/lib/formats.mjs +14 -3
- package/packages/record/lib/native-history.mjs +277 -7
- package/packages/record/lib/session-snapshot.mjs +25 -5
- package/packages/record/lib/sessions-for-home.mjs +30 -13
- package/skills/oats/SKILL.md +12 -7
- package/skills/oats-config/SKILL.md +12 -9
- package/skills/oats-packages/SKILL.md +12 -8
- package/skills/oats-portable/SKILL.md +116 -0
- package/skills/oats-portable-artifacts/SKILL.md +63 -0
- package/skills/oats-portable-setup/SKILL.md +69 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Explicit new digest domains. Existing v1 package/artifact functions remain
|
|
2
|
+
* literal in their owning modules; this module never reinterprets legacy hashes. */
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { oatsError } from "./errors.mjs";
|
|
7
|
+
import { canonicalJson, compareUtf8, dataLimits, decodeUtf8 } from "./portable-values.mjs";
|
|
8
|
+
|
|
9
|
+
export const TREE_FORMAT = "oats.tree-exec.v1";
|
|
10
|
+
export const PACKAGE_FORMAT = "oats.package-payload-exec.v1";
|
|
11
|
+
export const JSON_FORMAT = "oats.json.v1";
|
|
12
|
+
export const BYTES_FORMAT = "oats.bytes.v1";
|
|
13
|
+
const digest = (format, data) => ({ format, value: `sha256-${createHash("sha256").update(data).digest("hex")}` });
|
|
14
|
+
|
|
15
|
+
export function jsonIntegrity(value, options) { return digest(JSON_FORMAT, canonicalJson(value, options)); }
|
|
16
|
+
export function bytesIntegrity(value) {
|
|
17
|
+
if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
|
|
18
|
+
throw oatsError("invalid-declaration", "raw-byte integrity requires bytes");
|
|
19
|
+
}
|
|
20
|
+
return digest(BYTES_FORMAT, value);
|
|
21
|
+
}
|
|
22
|
+
export function validateIntegrity(value, formats = [TREE_FORMAT, PACKAGE_FORMAT, JSON_FORMAT, BYTES_FORMAT]) {
|
|
23
|
+
const invalid = () => { throw oatsError("invalid-artifact-reference", "expected explicit supported digest format and full SHA-256"); };
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
|
|
25
|
+
const prototype = Object.getPrototypeOf(value), fields = Object.getOwnPropertyDescriptors(value);
|
|
26
|
+
if ((prototype !== null && prototype !== Object.prototype) || Reflect.ownKeys(fields).length !== 2) invalid();
|
|
27
|
+
for (const key of ["format", "value"]) {
|
|
28
|
+
if (!Object.hasOwn(fields, key) || !Object.hasOwn(fields[key], "value") || !fields[key].enumerable) invalid();
|
|
29
|
+
}
|
|
30
|
+
if (!formats.includes(fields.format.value) || typeof fields.value.value !== "string"
|
|
31
|
+
|| !/^sha256-[a-f0-9]{64}$/.test(fields.value.value)) invalid();
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino && a.size === b.size
|
|
36
|
+
&& a.mode === b.mode && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
37
|
+
|
|
38
|
+
/** Domain NUL; then UTF-8-path-sorted records:
|
|
39
|
+
* F || uint64(path bytes) || path || owner-exec byte || uint64(content bytes) || content
|
|
40
|
+
* S || uint64(path bytes) || path || uint64(target bytes) || literal target
|
|
41
|
+
* Length framing prevents the legacy binary record-boundary ambiguity.
|
|
42
|
+
* Empty directories and non-owner-execute mode bits do not enter identity.
|
|
43
|
+
* Containment remains the retention caller's responsibility; links aren't followed. */
|
|
44
|
+
export function treeIntegrity(root, { format = TREE_FORMAT, ...options } = {}) {
|
|
45
|
+
if (![TREE_FORMAT, PACKAGE_FORMAT].includes(format)) {
|
|
46
|
+
throw oatsError("unsupported-wire-version", "unsupported portable tree digest format");
|
|
47
|
+
}
|
|
48
|
+
const limits = dataLimits({ maxBytes: 256 * 1024 * 1024, ...options });
|
|
49
|
+
const entries = [], hash = createHash("sha256");
|
|
50
|
+
let visited = 0, bytes = 0;
|
|
51
|
+
const account = (length) => {
|
|
52
|
+
bytes += length;
|
|
53
|
+
if (bytes > limits.maxBytes) throw oatsError("resource-limit", "artifact digest byte limit exceeded");
|
|
54
|
+
};
|
|
55
|
+
const length = (size) => {
|
|
56
|
+
const value = Buffer.alloc(8);
|
|
57
|
+
value.writeBigUInt64BE(BigInt(size));
|
|
58
|
+
return value;
|
|
59
|
+
};
|
|
60
|
+
const field = (data) => { account(8 + data.length); hash.update(length(data.length)); hash.update(data); };
|
|
61
|
+
const walk = (dir, parts) => {
|
|
62
|
+
if (parts.length > limits.maxDepth) throw oatsError("resource-limit", "artifact tree depth limit exceeded");
|
|
63
|
+
const names = readdirSync(dir, { encoding: "buffer" });
|
|
64
|
+
for (const raw of names) {
|
|
65
|
+
if (++visited > limits.maxEntries) throw oatsError("resource-limit", "artifact tree entry limit exceeded");
|
|
66
|
+
const name = decodeUtf8(raw, "artifact filename");
|
|
67
|
+
if (format === PACKAGE_FORMAT && (name === "node_modules" || (!parts.length && name === "oats-lock.json"))) continue;
|
|
68
|
+
const path = join(dir, name), relative = [...parts, name].join("/");
|
|
69
|
+
const stat = lstatSync(path);
|
|
70
|
+
if (stat.isSymbolicLink()) entries.push({ path, relative, stat, kind: "S" });
|
|
71
|
+
else if (stat.isDirectory()) walk(path, [...parts, name]);
|
|
72
|
+
else if (stat.isFile()) entries.push({ path, relative, stat, kind: "F" });
|
|
73
|
+
else throw oatsError("invalid-artifact", "unsupported artifact tree entry");
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const rootStat = lstatSync(root);
|
|
77
|
+
if (!rootStat.isDirectory()) throw oatsError("invalid-artifact", "artifact digest root must be a real directory");
|
|
78
|
+
walk(root, []);
|
|
79
|
+
hash.update(`${format}\0`);
|
|
80
|
+
for (const entry of entries.sort((a, b) => compareUtf8(a.relative, b.relative))) {
|
|
81
|
+
account(1); hash.update(entry.kind); field(Buffer.from(entry.relative, "utf8"));
|
|
82
|
+
if (entry.kind === "S") {
|
|
83
|
+
const target = readlinkSync(entry.path, { encoding: "buffer" });
|
|
84
|
+
decodeUtf8(target, "artifact symlink target");
|
|
85
|
+
field(target);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const fd = openSync(entry.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
89
|
+
try {
|
|
90
|
+
const before = fstatSync(fd);
|
|
91
|
+
if (!before.isFile() || !sameFile(entry.stat, before)) {
|
|
92
|
+
throw oatsError("integrity-drift", "artifact input changed during digest");
|
|
93
|
+
}
|
|
94
|
+
account(1 + 8 + before.size);
|
|
95
|
+
hash.update(Buffer.from([(before.mode & 0o100) !== 0 ? 1 : 0]));
|
|
96
|
+
hash.update(length(before.size));
|
|
97
|
+
const content = readFileSync(fd);
|
|
98
|
+
if (content.length !== before.size || !sameFile(before, fstatSync(fd))) {
|
|
99
|
+
throw oatsError("integrity-drift", "artifact input changed during digest");
|
|
100
|
+
}
|
|
101
|
+
hash.update(content);
|
|
102
|
+
} finally { closeSync(fd); }
|
|
103
|
+
}
|
|
104
|
+
return { format, value: `sha256-${hash.digest("hex")}` };
|
|
105
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Bounded descriptor-backed reads shared by private portable metadata stores. */
|
|
2
|
+
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
3
|
+
import { oatsError } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
export function readPortableBytes(path, { missingCode = "resource-not-found", invalidCode = "invalid-declaration", allowMissing = false } = {}) {
|
|
6
|
+
let stat;
|
|
7
|
+
try { stat = lstatSync(path); }
|
|
8
|
+
catch (error) {
|
|
9
|
+
if (error.code !== "ENOENT") throw error;
|
|
10
|
+
if (allowMissing) return null;
|
|
11
|
+
throw oatsError(missingCode, "portable metadata is absent");
|
|
12
|
+
}
|
|
13
|
+
if (!stat.isFile()) throw oatsError(invalidCode, "portable metadata must be a regular file");
|
|
14
|
+
if (stat.size > 8 * 1024 * 1024) throw oatsError("resource-limit", "portable metadata byte limit exceeded");
|
|
15
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
16
|
+
try {
|
|
17
|
+
const before = fstatSync(fd);
|
|
18
|
+
if (!before.isFile() || before.dev !== stat.dev || before.ino !== stat.ino || before.size !== stat.size) throw oatsError("integrity-drift", "portable metadata changed during opening");
|
|
19
|
+
const buffer = Buffer.alloc(stat.size + 1);
|
|
20
|
+
let count = 0, size;
|
|
21
|
+
while (count < buffer.length && (size = readSync(fd, buffer, count, buffer.length - count, null)) > 0) count += size;
|
|
22
|
+
const after = fstatSync(fd);
|
|
23
|
+
if (count !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) throw oatsError("integrity-drift", "portable metadata changed during reading");
|
|
24
|
+
return buffer.subarray(0, count);
|
|
25
|
+
} finally { closeSync(fd); }
|
|
26
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Qualified identity value types, not a proof of hosting/provider ownership.
|
|
2
|
+
* Discovery obtains facts from the authenticated host adapter, never repo YAML. */
|
|
3
|
+
import { canonicalJson } from "./portable-values.mjs";
|
|
4
|
+
import { jsonIntegrity } from "./portable-digest.mjs";
|
|
5
|
+
import { parseLockedSource3, parseRepositorySource, portablePath } from "./source-spec.mjs";
|
|
6
|
+
import { invalidShape, objectAt, stringAt } from "./portable-shape.mjs";
|
|
7
|
+
|
|
8
|
+
export function validateRepositoryIdentity(value) {
|
|
9
|
+
canonicalJson(value, { maxBytes: 64 * 1024, maxDepth: 8, maxEntries: 64 });
|
|
10
|
+
objectAt(value, value?.kind === "provider-repository" ? ["kind", "provider", "host", "id"] : ["kind", "remote"], ["kind"]);
|
|
11
|
+
if (value.kind === "provider-repository") {
|
|
12
|
+
for (const name of ["provider", "host", "id"]) stringAt(value[name], `/${name}`);
|
|
13
|
+
if (!/^[a-z][a-z0-9.-]*$/.test(value.provider) || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(value.host)) {
|
|
14
|
+
invalidShape("", "provider and host identity must be canonical");
|
|
15
|
+
}
|
|
16
|
+
} else if (value.kind === "canonical-remote") {
|
|
17
|
+
stringAt(value.remote, "/remote");
|
|
18
|
+
if (parseRepositorySource(value.remote).normalized !== value.remote) invalidShape("/remote", "repository identity remote is not canonical");
|
|
19
|
+
} else invalidShape("/kind", "unsupported repository identity");
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
export function validateSoulIdentity(value) {
|
|
23
|
+
canonicalJson(value, { maxBytes: 64 * 1024, maxDepth: 12, maxEntries: 128 });
|
|
24
|
+
objectAt(value, value?.kind === "git-soul" ? ["kind", "repository", "exportPath"] : ["kind", "source", "exportPath"], ["kind", "exportPath"]);
|
|
25
|
+
if (value.kind === "git-soul") validateRepositoryIdentity(value.repository);
|
|
26
|
+
else if (value.kind === "local-soul") {
|
|
27
|
+
if (parseLockedSource3(value.source, ".").kind !== "path") invalidShape("/source", "local soul identity requires an explicit local source");
|
|
28
|
+
} else invalidShape("/kind", "unsupported soul identity");
|
|
29
|
+
portablePath(value.exportPath, { allowRoot: value.kind === "local-soul" });
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
export function validateWorkspaceIdentity(value) {
|
|
33
|
+
canonicalJson(value, { maxBytes: 64 * 1024, maxDepth: 12, maxEntries: 128 });
|
|
34
|
+
objectAt(value, ["repository", "path"], ["repository", "path"]);
|
|
35
|
+
validateRepositoryIdentity(value.repository);
|
|
36
|
+
if (value.path !== "oats-workspace.yaml") invalidShape("/path", "workspace identity names oats-workspace.yaml");
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
export function sameIdentity(a, b) { return canonicalJson(a) === canonicalJson(b); }
|
|
40
|
+
export function soulIdentityKey(value) { validateSoulIdentity(value); return jsonIntegrity(value).value; }
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Lock v3 is mutable input to future preparation, NEVER historical dispatch
|
|
2
|
+
* authority. Existing v1/v2 readers and stores are not migrated by this module. */
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalJson, parseStrictJson } from "./portable-values.mjs";
|
|
5
|
+
import { JSON_FORMAT, jsonIntegrity, validateIntegrity } from "./portable-digest.mjs";
|
|
6
|
+
import { validateArtifactSet, validateOrigin } from "./resolution-shape.mjs";
|
|
7
|
+
import { objectAt, stringAt } from "./portable-shape.mjs";
|
|
8
|
+
import { parseLockedSource3 } from "./source-spec.mjs";
|
|
9
|
+
import { readPortableBytes } from "./portable-files.mjs";
|
|
10
|
+
import { portableScope, withPortableStateWrite, writeGuardedPortableDocument } from "./portable-state.mjs";
|
|
11
|
+
import { oatsError } from "./errors.mjs";
|
|
12
|
+
|
|
13
|
+
const invalid = (message) => { throw oatsError("invalid-lock", message); };
|
|
14
|
+
export const emptyLock3 = () => ({ lockfileVersion: 3, artifactSets: {}, selections: {} });
|
|
15
|
+
export function requestKey3(request) {
|
|
16
|
+
canonicalJson(request);
|
|
17
|
+
objectAt(request, ["source", "path"], ["source", "path"]);
|
|
18
|
+
parseLockedSource3(request.source, request.path);
|
|
19
|
+
return jsonIntegrity(request).value;
|
|
20
|
+
}
|
|
21
|
+
export function artifactSetKey3(set) { validateArtifactSet(set); return jsonIntegrity(set).value; }
|
|
22
|
+
function validateProblem(problem) {
|
|
23
|
+
objectAt(problem, ["code", "message", "origins", "target", "details"], ["code", "message", "origins"]);
|
|
24
|
+
stringAt(problem.code, "/freshness/problems/code"); stringAt(problem.message, "/freshness/problems/message");
|
|
25
|
+
if (!Array.isArray(problem.origins)) invalid("refresh problem origins must be an array");
|
|
26
|
+
problem.origins.forEach(validateOrigin);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateLock3(lock) {
|
|
30
|
+
try {
|
|
31
|
+
canonicalJson(lock);
|
|
32
|
+
if (lock?.lockfileVersion === 1 || lock?.lockfileVersion === 2) {
|
|
33
|
+
throw oatsError("migration-required", "legacy lock requires its explicit evidence/migration reader; it is not lock v3");
|
|
34
|
+
}
|
|
35
|
+
objectAt(lock, ["lockfileVersion", "artifactSets", "selections"], ["lockfileVersion", "artifactSets", "selections"]);
|
|
36
|
+
if (lock.lockfileVersion !== 3) throw oatsError("unsupported-wire-version", "unsupported selection-lock version");
|
|
37
|
+
objectAt(lock.artifactSets, null, []); objectAt(lock.selections, null, []);
|
|
38
|
+
const indexes = new Map(), checked = new Set();
|
|
39
|
+
for (const [key, set] of Object.entries(lock.artifactSets)) {
|
|
40
|
+
if (artifactSetKey3(set) !== key) invalid("artifact-set key differs from its canonical content");
|
|
41
|
+
const requests = new Map();
|
|
42
|
+
for (const [id, pkg] of Object.entries(set.packages)) {
|
|
43
|
+
const request = jsonIntegrity({ source: pkg.source, path: pkg.path }).value;
|
|
44
|
+
if (!requests.has(request)) requests.set(request, []);
|
|
45
|
+
requests.get(request).push(id);
|
|
46
|
+
}
|
|
47
|
+
indexes.set(key, requests);
|
|
48
|
+
}
|
|
49
|
+
for (const [key, selection] of Object.entries(lock.selections)) {
|
|
50
|
+
objectAt(selection, ["request", "current", "available", "freshness"], ["request", "current", "available", "freshness"]);
|
|
51
|
+
if (requestKey3(selection.request) !== key) invalid("selection key differs from its normalized source request");
|
|
52
|
+
for (const name of ["current", "available"]) {
|
|
53
|
+
const selected = selection[name];
|
|
54
|
+
if (selected === null) continue;
|
|
55
|
+
stringAt(selected, `/selections/${name}`);
|
|
56
|
+
if (!Object.hasOwn(lock.artifactSets, selected)) invalid("selection references an absent artifact set");
|
|
57
|
+
const roots = indexes.get(selected).get(key);
|
|
58
|
+
if (roots?.length !== 1) invalid("selection has no unique root package matching its request");
|
|
59
|
+
if (checked.has(`${selected}:${key}`)) continue;
|
|
60
|
+
const set = lock.artifactSets[selected], reached = new Set([roots[0]]), pending = [roots[0]];
|
|
61
|
+
while (pending.length) {
|
|
62
|
+
for (const dependency of set.packages[pending.pop()].dependencies) {
|
|
63
|
+
if (!reached.has(dependency)) { reached.add(dependency); pending.push(dependency); }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (reached.size !== Object.keys(set.packages).length) invalid("selected set includes packages outside the requested dependency closure");
|
|
67
|
+
for (const row of Object.values(set.capabilities)) if (row.origin.kind !== "package") invalid("package selection cannot smuggle a direct local capability");
|
|
68
|
+
checked.add(`${selected}:${key}`);
|
|
69
|
+
}
|
|
70
|
+
const freshness = selection.freshness;
|
|
71
|
+
objectAt(freshness, ["state", "observedAt", "problems"], ["state", "observedAt", "problems"]);
|
|
72
|
+
if (!["refreshed", "offline", "failed", "not-checked"].includes(freshness.state)) invalid("invalid freshness state");
|
|
73
|
+
if (freshness.observedAt !== null) {
|
|
74
|
+
stringAt(freshness.observedAt, "/freshness/observedAt");
|
|
75
|
+
const date = new Date(freshness.observedAt);
|
|
76
|
+
if (!Number.isFinite(date.getTime()) || date.toISOString() !== freshness.observedAt) invalid("observation timestamp must be canonical UTC ISO time");
|
|
77
|
+
}
|
|
78
|
+
if (freshness.state === "refreshed" && (freshness.observedAt === null || selection.available === null)) invalid("successful refresh needs an observed available set and timestamp");
|
|
79
|
+
if (!Array.isArray(freshness.problems)) invalid("refresh problems must be an array");
|
|
80
|
+
freshness.problems.forEach(validateProblem);
|
|
81
|
+
}
|
|
82
|
+
return lock;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (["invalid-declaration", "invalid-source", "invalid-artifact-reference"].includes(error.code)) {
|
|
85
|
+
const failure = oatsError("invalid-lock", error.message); failure.cause = error; throw failure;
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Explicit scope only. Absent differs from empty-v1, unsupported or damaged.
|
|
92
|
+
* Integrity is the CAS token, not software verification or executable consent. */
|
|
93
|
+
export function readLock3(scope) {
|
|
94
|
+
const path = join(portableScope(scope), "oats-lock.json");
|
|
95
|
+
const bytes = readPortableBytes(path, { allowMissing: true, invalidCode: "invalid-lock" });
|
|
96
|
+
if (bytes === null) return { lock: null, integrity: null };
|
|
97
|
+
const lock = parseStrictJson(bytes);
|
|
98
|
+
validateLock3(lock);
|
|
99
|
+
if (!bytes.equals(Buffer.from(canonicalJson(lock)))) invalid("selection lock must use canonical JSON bytes");
|
|
100
|
+
return { lock, integrity: jsonIntegrity(lock) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** All new writers share a cooperative guard and compare the complete snapshot.
|
|
104
|
+
* No implicit legacy migration, row merging, approval, tree cleanup or live cutover. */
|
|
105
|
+
export function writeLock3(scope, expectedPreviousIntegrity, next) {
|
|
106
|
+
if (expectedPreviousIntegrity !== null) validateIntegrity(expectedPreviousIntegrity, [JSON_FORMAT]);
|
|
107
|
+
validateLock3(next);
|
|
108
|
+
return withPortableStateWrite(scope, (context) => {
|
|
109
|
+
const { deployment } = context;
|
|
110
|
+
const previous = readLock3(deployment);
|
|
111
|
+
if (canonicalJson(previous.integrity) !== canonicalJson(expectedPreviousIntegrity)) throw oatsError("selection-changed", "selection lock changed; repeat explicit preparation");
|
|
112
|
+
const integrity = jsonIntegrity(next);
|
|
113
|
+
if (canonicalJson(previous.integrity) === canonicalJson(integrity)) return { lock: next, integrity, status: "kept" };
|
|
114
|
+
writeGuardedPortableDocument(context, join(deployment, "oats-lock.json"), next, { absent: previous.lock === null });
|
|
115
|
+
return { lock: next, integrity, status: "written" };
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** Read-only verification of explicit historical materialized-v2 artifact
|
|
2
|
+
* candidates. Verification proves the old lock/artifact relation and observes a
|
|
3
|
+
* new owner-exec digest today; it does not prove per-home selection, historical
|
|
4
|
+
* modes, retention, reconstruction or approval. */
|
|
5
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
6
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { capabilityArtifactIntegrity } from "./artifact-tree.mjs";
|
|
8
|
+
import { CAPABILITY_INSTALLATION_FILE, isMaterializedCapabilityId } from "./capability-provenance.mjs";
|
|
9
|
+
import { bytesIntegrity, TREE_FORMAT, treeIntegrity } from "./portable-digest.mjs";
|
|
10
|
+
import { readPortableBytes } from "./portable-files.mjs";
|
|
11
|
+
import { parseStrictJson } from "./portable-values.mjs";
|
|
12
|
+
import { portableScope } from "./portable-state.mjs";
|
|
13
|
+
import { verifyHistoricalLockCandidate } from "./portable-migration-evidence.mjs";
|
|
14
|
+
import { oatsError } from "./errors.mjs";
|
|
15
|
+
|
|
16
|
+
export const LEGACY_CAPABILITY_FORMAT = "oats.capability-legacy.v1";
|
|
17
|
+
export const MATERIALIZED_CAPABILITY_FORMAT = "oats.capability-artifact.v1";
|
|
18
|
+
|
|
19
|
+
function verifyLegacyManifestIdentity(dir, capability, row) {
|
|
20
|
+
const path = join(dir, "oats.json"), bytes = readPortableBytes(path, { missingCode: "invalid-lock", invalidCode: "invalid-lock" });
|
|
21
|
+
let document;
|
|
22
|
+
try { document = parseStrictJson(bytes); }
|
|
23
|
+
catch (error) { const failure = oatsError("invalid-lock", `historical oats.json is malformed: ${error.message}`); failure.cause = error; throw failure; }
|
|
24
|
+
if (document?.capability !== capability || document?.version !== row.version) {
|
|
25
|
+
throw oatsError("invalid-lock", "historical oats.json identity/version differs from the literal v1 lock row");
|
|
26
|
+
}
|
|
27
|
+
return { path: "oats.json", integrity: bytesIntegrity(bytes) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function verifyInstallationProvenance(dir, capability, row, pkg) {
|
|
31
|
+
const path = join(dir, CAPABILITY_INSTALLATION_FILE);
|
|
32
|
+
const bytes = readPortableBytes(path, { missingCode: "invalid-lock", invalidCode: "invalid-lock" });
|
|
33
|
+
let document;
|
|
34
|
+
try { document = parseStrictJson(bytes); }
|
|
35
|
+
catch (error) { const failure = oatsError("invalid-lock", `historical ${CAPABILITY_INSTALLATION_FILE} is malformed: ${error.message}`); failure.cause = error; throw failure; }
|
|
36
|
+
const expected = { schemaVersion: 1, capability, version: row.version, package: row.package,
|
|
37
|
+
packageVersion: pkg.version, source: pkg.source, commit: pkg.commit,
|
|
38
|
+
packagePath: pkg.path, capabilityPath: row.path };
|
|
39
|
+
for (const [key, value] of Object.entries(expected)) if (document?.[key] !== value) {
|
|
40
|
+
throw oatsError("invalid-lock", `historical ${CAPABILITY_INSTALLATION_FILE} differs from the verified lock rows at ${key}`);
|
|
41
|
+
}
|
|
42
|
+
return { path: CAPABILITY_INSTALLATION_FILE, integrity: bytesIntegrity(bytes) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function explicitArtifactDirectory(scope, input) {
|
|
46
|
+
if (typeof input !== "string" || !input || input.includes("\0")) throw oatsError("invalid-evidence", "historical artifact target must be a non-empty path without NUL");
|
|
47
|
+
const deployment = portableScope(scope), lexical = resolve(deployment, input);
|
|
48
|
+
const part = relative(deployment, lexical);
|
|
49
|
+
if (!part || part === ".." || part.startsWith(`..${sep}`) || isAbsolute(part)) throw oatsError("path-escape", "historical artifact target is outside the explicit deployment");
|
|
50
|
+
let actual;
|
|
51
|
+
try { actual = realpathSync(lexical); }
|
|
52
|
+
catch (error) { if (error.code === "ENOENT") throw oatsError("artifact-not-found", "historical artifact candidate is absent"); throw error; }
|
|
53
|
+
if (actual !== lexical || !lstatSync(actual).isDirectory()) throw oatsError("invalid-artifact", "historical artifact target must be a real non-symlinked directory");
|
|
54
|
+
return { deployment, dir: actual, path: part.split(sep).join("/") };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Verify one explicit v1/v2 flat artifact against one explicit verified
|
|
58
|
+
* lock. V1's literal digest implementation is supplied as an acyclic callback;
|
|
59
|
+
* the migration module never imports core or silently substitutes the v2 hash. */
|
|
60
|
+
export function verifyHistoricalCapabilityCandidate(scope, inventory, { lockPath, capability, artifactDir }, adapters = {}) {
|
|
61
|
+
const { legacyLockDecoder, legacyCapabilityDigest } = adapters;
|
|
62
|
+
if (!isMaterializedCapabilityId(capability)) throw oatsError("invalid-evidence", "invalid historical capability identity");
|
|
63
|
+
const lock = verifyHistoricalLockCandidate(scope, inventory, lockPath, { legacyLockDecoder });
|
|
64
|
+
const row = lock.version === 1 ? lock.legacyCapabilities[capability] : lock.capabilities[capability];
|
|
65
|
+
if (!row) throw oatsError("migration-held", "historical lock does not name this capability candidate");
|
|
66
|
+
const target = explicitArtifactDirectory(scope, artifactDir);
|
|
67
|
+
if (lock.version === 1) {
|
|
68
|
+
if (typeof legacyCapabilityDigest !== "function") throw oatsError("migration-held", "legacy v1 capability candidate requires its literal historical digest verifier");
|
|
69
|
+
const manifest = verifyLegacyManifestIdentity(target.dir, capability, row);
|
|
70
|
+
const observedIntegrity = treeIntegrity(target.dir, { format: TREE_FORMAT });
|
|
71
|
+
const historical = legacyCapabilityDigest(target.dir);
|
|
72
|
+
if (historical !== row.integrity) throw oatsError("integrity-drift", "historical legacy artifact differs from its literal v1 lock digest");
|
|
73
|
+
const finalHistorical = legacyCapabilityDigest(target.dir), finalObserved = treeIntegrity(target.dir, { format: TREE_FORMAT });
|
|
74
|
+
const finalManifest = verifyLegacyManifestIdentity(target.dir, capability, row);
|
|
75
|
+
if (finalHistorical !== historical || finalObserved.value !== observedIntegrity.value
|
|
76
|
+
|| finalManifest.integrity.value !== manifest.integrity.value) {
|
|
77
|
+
throw oatsError("integrity-drift", "historical legacy artifact or manifest changed while its migration witness was measured");
|
|
78
|
+
}
|
|
79
|
+
return { schemaVersion: 1, lock: { path: lock.path, integrity: lock.integrity }, capability: { id: capability, row }, package: null,
|
|
80
|
+
artifact: { path: target.path, historicalIntegrity: { format: LEGACY_CAPABILITY_FORMAT, value: historical },
|
|
81
|
+
observedIntegrity, manifest, modeEvidence: "observed-at-migration" },
|
|
82
|
+
selectionAuthority: "none", approvalAuthority: "none", retentionStatus: "not-retained", evidence: lock.evidence };
|
|
83
|
+
}
|
|
84
|
+
const pkg = lock.packages[row.package];
|
|
85
|
+
if (!pkg) throw oatsError("invalid-lock", "historical capability candidate has no verified provider package row");
|
|
86
|
+
// Preflight bounded/no-follow provenance before the unbounded historical
|
|
87
|
+
// hasher can read it, then require the same exact bytes after both digest
|
|
88
|
+
// measurements. The old hash algorithm itself remains literal and unchanged.
|
|
89
|
+
const provenance = verifyInstallationProvenance(target.dir, capability, row, pkg);
|
|
90
|
+
const observedIntegrity = treeIntegrity(target.dir, { format: TREE_FORMAT });
|
|
91
|
+
const historical = capabilityArtifactIntegrity(target.dir);
|
|
92
|
+
if (historical !== row.integrity) throw oatsError("integrity-drift", "historical materialized artifact differs from its literal v2 lock digest");
|
|
93
|
+
const finalHistorical = capabilityArtifactIntegrity(target.dir);
|
|
94
|
+
const finalObserved = treeIntegrity(target.dir, { format: TREE_FORMAT });
|
|
95
|
+
const finalProvenance = verifyInstallationProvenance(target.dir, capability, row, pkg);
|
|
96
|
+
if (finalHistorical !== historical || finalObserved.value !== observedIntegrity.value
|
|
97
|
+
|| finalProvenance.integrity.value !== provenance.integrity.value) {
|
|
98
|
+
throw oatsError("integrity-drift", "historical artifact or provenance changed while its migration witness was measured");
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
schemaVersion: 1,
|
|
102
|
+
lock: { path: lock.path, integrity: lock.integrity },
|
|
103
|
+
capability: { id: capability, row }, package: { id: row.package, row: pkg },
|
|
104
|
+
artifact: { path: target.path,
|
|
105
|
+
historicalIntegrity: { format: MATERIALIZED_CAPABILITY_FORMAT, value: historical },
|
|
106
|
+
observedIntegrity, provenance,
|
|
107
|
+
modeEvidence: "observed-at-migration" },
|
|
108
|
+
selectionAuthority: "none", approvalAuthority: "none", retentionStatus: "not-retained",
|
|
109
|
+
evidence: lock.evidence,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Establish only that one unchanged home metadata document named one exact v2
|
|
114
|
+
* artifact digest. This is per-capability selection evidence, not a complete
|
|
115
|
+
* composition, source/resource closure or transferred approval. */
|
|
116
|
+
export function verifyHistoricalHomeCapabilityCandidate(scope, inventory, request, adapters = {}) {
|
|
117
|
+
if (typeof request?.homePath !== "string" || !request.homePath) throw oatsError("invalid-evidence", "historical home candidate needs an explicit inventory path");
|
|
118
|
+
const artifact = verifyHistoricalCapabilityCandidate(scope, inventory, request, adapters);
|
|
119
|
+
const home = inventory.homes.find((entry) => entry.path === request.homePath);
|
|
120
|
+
if (!home || home.document.state !== "present") throw oatsError("migration-held", "historical home evidence is absent or invalid");
|
|
121
|
+
if (home.executionBinding.state === "valid-shape") throw oatsError("migration-held", "home already carries a captured execution binding; verify that authority instead of reconstructing from legacy rows");
|
|
122
|
+
const matches = home.capabilities.filter((entry) => entry.id === request.capability);
|
|
123
|
+
if (matches.length !== 1 || matches[0].integrity !== artifact.artifact.historicalIntegrity.value) {
|
|
124
|
+
throw oatsError("migration-held", "home metadata does not uniquely bind this capability to the verified historical artifact digest");
|
|
125
|
+
}
|
|
126
|
+
const runtime = matches[0];
|
|
127
|
+
return { schemaVersion: 1, home: { path: home.path, document: home.document }, artifact,
|
|
128
|
+
association: "capability-runtime-integrity-match", completeness: "partial",
|
|
129
|
+
legacyTrustedClaim: runtime.trustedClaim, approvalAuthority: "none",
|
|
130
|
+
unresolved: [
|
|
131
|
+
"source revision and retained soul projection",
|
|
132
|
+
"complete managed resources, helpers and runtime packages",
|
|
133
|
+
"new-format exact-artifact approval",
|
|
134
|
+
] };
|
|
135
|
+
}
|