@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,120 @@
|
|
|
1
|
+
/** Exact-artifact local approval authority. Presence/catalog/legacy trust and
|
|
2
|
+
* captured booleans never grant approval. No current selection-lock lookup. */
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalJson, parseStrictJson } from "./portable-values.mjs";
|
|
5
|
+
import { jsonIntegrity } from "./portable-digest.mjs";
|
|
6
|
+
import { objectAt, versionAt } from "./portable-shape.mjs";
|
|
7
|
+
import { validateArtifactRef, validateOrigin } from "./resolution-shape.mjs";
|
|
8
|
+
import { verifyResolutionInputs, verifyRetainedCapability } from "./captured-resolutions.mjs";
|
|
9
|
+
import { verifyPortableArtifact } from "./portable-artifacts.mjs";
|
|
10
|
+
import { readLock3 } from "./portable-lock.mjs";
|
|
11
|
+
import { executableSurfaceOf, hasExecutableSurface } from "./capability-execution.mjs";
|
|
12
|
+
import { isMaterializedCapabilityId } from "./capability-provenance.mjs";
|
|
13
|
+
import { readPortableBytes } from "./portable-files.mjs";
|
|
14
|
+
import { portableStateDirectory, withPortableStateWrite, writeGuardedPortableDocument } from "./portable-state.mjs";
|
|
15
|
+
import { oatsError } from "./errors.mjs";
|
|
16
|
+
|
|
17
|
+
const emptyLedger = () => ({ schemaVersion: 1, capabilities: Object.create(null) });
|
|
18
|
+
const invalid = (message) => { throw oatsError("invalid-approval", message); };
|
|
19
|
+
export function artifactApprovalKey(artifact) {
|
|
20
|
+
validateArtifactRef(artifact);
|
|
21
|
+
if (artifact.kind !== "capability") invalid("capability approval cannot authorize an unrelated resource bundle");
|
|
22
|
+
return `${artifact.integrity.format}:${artifact.integrity.value}`;
|
|
23
|
+
}
|
|
24
|
+
export function validateApprovalLedger(ledger) {
|
|
25
|
+
canonicalJson(ledger);
|
|
26
|
+
objectAt(ledger, ["schemaVersion", "capabilities"], ["schemaVersion", "capabilities"]);
|
|
27
|
+
versionAt(ledger.schemaVersion); objectAt(ledger.capabilities, null, []);
|
|
28
|
+
for (const [id, entries] of Object.entries(ledger.capabilities)) {
|
|
29
|
+
if (!isMaterializedCapabilityId(id)) invalid("invalid approval capability identity");
|
|
30
|
+
objectAt(entries, null, []);
|
|
31
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
32
|
+
objectAt(entry, ["artifact", "approved", "provenance"], ["artifact", "approved", "provenance"]);
|
|
33
|
+
if (artifactApprovalKey(entry.artifact) !== key || entry.artifact.capability !== id || entry.approved !== true) invalid("approval key, artifact or capability differs");
|
|
34
|
+
if (!Array.isArray(entry.provenance) || !entry.provenance.length) invalid("approval requires explicit operator provenance");
|
|
35
|
+
for (const origin of entry.provenance) {
|
|
36
|
+
validateOrigin(origin);
|
|
37
|
+
if (origin.kind !== "operator" || origin.document.kind !== "operator") invalid("approval cannot inherit source or legacy authority");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return ledger;
|
|
42
|
+
}
|
|
43
|
+
export function readApprovalLedger(scope) {
|
|
44
|
+
const root = portableStateDirectory(scope);
|
|
45
|
+
const bytes = root === null ? null : readPortableBytes(join(root, "approvals.json"), { allowMissing: true, invalidCode: "invalid-approval" });
|
|
46
|
+
if (bytes === null) return { ledger: emptyLedger(), integrity: null };
|
|
47
|
+
const ledger = parseStrictJson(bytes);
|
|
48
|
+
validateApprovalLedger(ledger);
|
|
49
|
+
if (!bytes.equals(Buffer.from(canonicalJson(ledger)))) invalid("approval ledger must use canonical JSON bytes");
|
|
50
|
+
return { ledger, integrity: jsonIntegrity(ledger) };
|
|
51
|
+
}
|
|
52
|
+
function approved(ledger, artifact) {
|
|
53
|
+
const key = artifactApprovalKey(artifact);
|
|
54
|
+
return Object.hasOwn(ledger.capabilities, artifact.capability) && Object.hasOwn(ledger.capabilities[artifact.capability], key);
|
|
55
|
+
}
|
|
56
|
+
function manifestFor(verified, id) {
|
|
57
|
+
return verified.manifests.get(id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Diagnostic projection for this record's capabilities, not an action permit.
|
|
61
|
+
* Dedicated helper records get their own approval check when used. */
|
|
62
|
+
export function inspectCapturedApprovals(scope, reference) {
|
|
63
|
+
const verified = verifyResolutionInputs(scope, reference), { ledger } = readApprovalLedger(scope);
|
|
64
|
+
return { reference, capabilities: evaluateCapturedApprovals(verified, ledger) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shared evaluation over already verified inputs and a freshly read ledger.
|
|
68
|
+
* This remains data; the action loader decides which surfaces the action uses. */
|
|
69
|
+
export function evaluateCapturedApprovals(verified, ledger) {
|
|
70
|
+
validateApprovalLedger(ledger);
|
|
71
|
+
return Object.entries(verified.record.artifacts.capabilities).map(([id, row]) => {
|
|
72
|
+
const manifest = manifestFor(verified, id), required = hasExecutableSurface(manifest);
|
|
73
|
+
return { artifact: row.artifact, required, surface: executableSurfaceOf(manifest),
|
|
74
|
+
status: !required ? "not-required" : approved(ledger, row.artifact) ? "approved" : "approval-required" };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function grantApproval(context, artifact, manifest, origin) {
|
|
79
|
+
const key = artifactApprovalKey(artifact), id = artifact.capability;
|
|
80
|
+
const { ledger, integrity } = readApprovalLedger(context.deployment);
|
|
81
|
+
if (!hasExecutableSurface(manifest)) return { artifact, status: "not-required" };
|
|
82
|
+
if (approved(ledger, artifact)) return { artifact, status: "already-approved" };
|
|
83
|
+
if (!Object.hasOwn(ledger.capabilities, id)) ledger.capabilities[id] = Object.create(null);
|
|
84
|
+
ledger.capabilities[id][key] = { artifact, approved: true, provenance: [origin] };
|
|
85
|
+
validateApprovalLedger(ledger);
|
|
86
|
+
writeGuardedPortableDocument(context, join(context.root, "approvals.json"), ledger, { absent: integrity === null });
|
|
87
|
+
return { artifact, status: "approved" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Prospective approval must be possible BEFORE running an executable provider
|
|
91
|
+
* codec to complete a resolution. Address an exact retained artifact set, never
|
|
92
|
+
* today's selected capability ID or a fabricated partial resolution. */
|
|
93
|
+
export function approveAvailableCapability(scope, setKey, id, origin, validateManifest) {
|
|
94
|
+
validateOrigin(origin);
|
|
95
|
+
if (origin.kind !== "operator" || origin.document.kind !== "operator") invalid("approval requires an explicit operator input");
|
|
96
|
+
if (typeof setKey !== "string" || !/^sha256-[a-f0-9]{64}$/.test(setKey) || typeof id !== "string") invalid("invalid artifact-set approval target");
|
|
97
|
+
if (typeof validateManifest !== "function") throw new TypeError("prospective approval requires the complete kernel manifest codec");
|
|
98
|
+
return withPortableStateWrite(scope, (context) => {
|
|
99
|
+
const { lock } = readLock3(context.deployment);
|
|
100
|
+
const set = lock?.artifactSets[setKey];
|
|
101
|
+
if (!set || !Object.hasOwn(set.capabilities, id)) invalid("capability is not in that exact retained artifact set");
|
|
102
|
+
const artifact = set.capabilities[id].artifact;
|
|
103
|
+
const root = verifyPortableArtifact(context.deployment, artifact).dir;
|
|
104
|
+
const manifest = verifyRetainedCapability(root, set, id, validateManifest(root));
|
|
105
|
+
return grantApproval(context, artifact, manifest, origin);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Explicit approval writer. Called by the explicit operator approval operation,
|
|
110
|
+
* not preparation/discovery. Approval still does not qualify provider/host readiness
|
|
111
|
+
* or replace full manifest/launch compilation at the eventual action boundary. */
|
|
112
|
+
export function approveCapturedCapability(scope, reference, id, origin) {
|
|
113
|
+
validateOrigin(origin);
|
|
114
|
+
if (origin.kind !== "operator" || origin.document.kind !== "operator") invalid("approval requires an explicit operator input");
|
|
115
|
+
return withPortableStateWrite(scope, (context) => {
|
|
116
|
+
const verified = verifyResolutionInputs(context.deployment, reference);
|
|
117
|
+
if (typeof id !== "string" || !Object.hasOwn(verified.record.artifacts.capabilities, id)) invalid("approval capability is not selected in this captured record");
|
|
118
|
+
return grantApproval(context, verified.record.artifacts.capabilities[id].artifact, manifestFor(verified, id), origin);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** Tree mechanics only. No acquisition, selection, trust, lock or lifecycle
|
|
2
|
+
* policy. Callers supply the verifier used during publication. */
|
|
3
|
+
import {
|
|
4
|
+
chmodSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync,
|
|
5
|
+
readdirSync, readlinkSync, renameSync, rmdirSync, rmSync, symlinkSync,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import { dirname, join, relative } from "node:path";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { oatsError } from "./errors.mjs";
|
|
10
|
+
|
|
11
|
+
/** Recursively copy a tree the way `cpSync(..., { recursive: true })` would —
|
|
12
|
+
* except catchably.
|
|
13
|
+
*
|
|
14
|
+
* Node 22's recursive `cpSync` performs its recursion in native code, and on
|
|
15
|
+
* macOS an unreadable directory inside the tree surfaces as an uncaught libc++
|
|
16
|
+
* `filesystem_error` that TERMINATES THE PROCESS. No JS `catch` or `finally`
|
|
17
|
+
* runs, so a transaction using it can never clean up staging or roll back the
|
|
18
|
+
* store, the lock and the ignore file. Every package-, capability- and
|
|
19
|
+
* user-shaped tree in the engine therefore goes through this hand-walk instead,
|
|
20
|
+
* where an EACCES is an ordinary throwable error.
|
|
21
|
+
*
|
|
22
|
+
* Semantics chosen to be safe rather than maximally faithful:
|
|
23
|
+
* - deterministic traversal (sorted entries), so two copies of one tree hash
|
|
24
|
+
* identically;
|
|
25
|
+
* - symlinks are recreated VERBATIM — never followed, never rewritten — because
|
|
26
|
+
* the bytes about to be hashed must be the bytes the author wrote;
|
|
27
|
+
* - FIFOs, sockets and device nodes are rejected fail-closed: they are not
|
|
28
|
+
* distributable content, and copying them has no defined meaning here;
|
|
29
|
+
* - directory modes are applied AFTER their children, so a read-only source
|
|
30
|
+
* directory cannot block writing its own contents. */
|
|
31
|
+
export function copyTreeSafe(src, dest) {
|
|
32
|
+
const st = lstatSync(src);
|
|
33
|
+
if (st.isSymbolicLink()) { symlinkSync(readlinkSync(src), dest); return; }
|
|
34
|
+
if (st.isFile()) { copyFileSync(src, dest); chmodSync(dest, st.mode & 0o7777); return; }
|
|
35
|
+
if (!st.isDirectory()) {
|
|
36
|
+
throw oatsError("invalid-source", `${src} is not a regular file, directory or symlink (${st.isFIFO() ? "FIFO" : st.isSocket() ? "socket" : st.isBlockDevice() || st.isCharacterDevice() ? "device node" : "unsupported file type"}) — package and capability trees carry distributable content only`);
|
|
37
|
+
}
|
|
38
|
+
mkdirSync(dest, { recursive: true });
|
|
39
|
+
for (const e of readdirSync(src, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
40
|
+
copyTreeSafe(join(src, e.name), join(dest, e.name));
|
|
41
|
+
}
|
|
42
|
+
chmodSync(dest, st.mode & 0o7777);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Stable integrity of a MATERIALIZED capability artifact: every byte under
|
|
46
|
+
* `.agents/capabilities/installed/<id>/`, with NO exclusions — capability source,
|
|
47
|
+
* the materialized runtime closure (node_modules), and the generated
|
|
48
|
+
* `.oats-installation.json` provenance file all count. This is the only digest
|
|
49
|
+
* executable trust binds to, which is why a separate dependency digest does not
|
|
50
|
+
* exist at capability level: the closure is inside the artifact, so tampering
|
|
51
|
+
* with a dependency is ordinary artifact drift. */
|
|
52
|
+
export function capabilityArtifactIntegrity(dir) {
|
|
53
|
+
const hash = createHash("sha256");
|
|
54
|
+
const walk = (d) => {
|
|
55
|
+
for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
56
|
+
const p = join(d, e.name);
|
|
57
|
+
if (e.isDirectory()) walk(p);
|
|
58
|
+
else if (e.isFile()) { hash.update(relative(dir, p)); hash.update("\0file\0"); hash.update(readFileSync(p)); hash.update("\0"); }
|
|
59
|
+
else if (e.isSymbolicLink()) { hash.update(relative(dir, p)); hash.update("\0symlink\0"); hash.update(readlinkSync(p)); hash.update("\0"); }
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
walk(dir);
|
|
63
|
+
return `sha256-${hash.digest("hex")}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ONLY for this preparer's unpublished staging, never source/destination trees.
|
|
67
|
+
// copyTreeSafe preserves read-only directories after copying their children; rm's
|
|
68
|
+
// force option does not make those directories removable. Restore owner access
|
|
69
|
+
// top-down, using lstat at every entry so even directory symlinks stay untouched.
|
|
70
|
+
// Files need no chmod to unlink them. Successful publication transfers the whole
|
|
71
|
+
// staging root to the destination; the caller must not clean that root afterwards.
|
|
72
|
+
export function removeOwnedStaging(staging) {
|
|
73
|
+
const makeRemovable = (path) => {
|
|
74
|
+
let st;
|
|
75
|
+
try { st = lstatSync(path); }
|
|
76
|
+
catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
77
|
+
if (!st.isDirectory()) return;
|
|
78
|
+
if ((st.mode & 0o700) !== 0o700) chmodSync(path, (st.mode & 0o7777) | 0o700);
|
|
79
|
+
for (const name of readdirSync(path)) makeRemovable(join(path, name));
|
|
80
|
+
};
|
|
81
|
+
makeRemovable(staging);
|
|
82
|
+
rmSync(staging, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Copy into same-parent staging, verify, then publish by rename.
|
|
86
|
+
* The staged root is a sibling of the destination: Darwin can refuse moving a
|
|
87
|
+
* read-only directory between parents even when both parents are writable. A
|
|
88
|
+
* sibling rename preserves the copied root's mode without rewriting its `..`.
|
|
89
|
+
* The caller preflights the source/destination and owns existing-entry policy.
|
|
90
|
+
* `verify` is synchronous and must throw on invalid copied or competing bytes;
|
|
91
|
+
* this mechanical helper knows nothing about the kind of artifact or its lock.
|
|
92
|
+
* A competing nonempty tree is reused only after verification. Rename is NOT a
|
|
93
|
+
* general no-replace primitive: an empty destination appearing after caller
|
|
94
|
+
* preflight can be replaced. Normal errors remove owned staging; cleanup failure
|
|
95
|
+
* throws with its stagingPath, preserving the primary code/provenance and cause
|
|
96
|
+
* in an AggregateError when both fail. No success receipt hides failed cleanup.
|
|
97
|
+
* Hostile concurrent mutation, crash recovery and power-loss durability are not
|
|
98
|
+
* promised. */
|
|
99
|
+
export function publishArtifactTree(sourceDir, dir, verify) {
|
|
100
|
+
const staging = mkdtempSync(join(dirname(dir), ".staging-"));
|
|
101
|
+
const tree = staging;
|
|
102
|
+
let failed = false, published = false, primary;
|
|
103
|
+
try {
|
|
104
|
+
// Preserve mechanical copy semantics for file/link roots too; retention's
|
|
105
|
+
// verifier owns the directory/containment policy. Only remove our empty slot.
|
|
106
|
+
if (!lstatSync(sourceDir).isDirectory()) rmdirSync(tree);
|
|
107
|
+
copyTreeSafe(sourceDir, tree);
|
|
108
|
+
verify(tree); // source may have changed during copy
|
|
109
|
+
try { renameSync(tree, dir); }
|
|
110
|
+
catch (e) {
|
|
111
|
+
// Darwin may report EACCES rather than ENOTEMPTY for a read-only winner.
|
|
112
|
+
// Permission denial alone proves nothing: require an observed destination,
|
|
113
|
+
// then let the caller verify its exact bytes, shape and provenance. If it
|
|
114
|
+
// is absent, preserve the original denial; damage is never repaired.
|
|
115
|
+
if (e.code === "EACCES") {
|
|
116
|
+
try { lstatSync(dir); }
|
|
117
|
+
catch (lookup) { if (lookup.code === "ENOENT") throw e; throw lookup; }
|
|
118
|
+
} else if (e.code !== "EEXIST" && e.code !== "ENOTEMPTY") throw e;
|
|
119
|
+
verify(dir);
|
|
120
|
+
return "kept";
|
|
121
|
+
}
|
|
122
|
+
published = true;
|
|
123
|
+
return "retained";
|
|
124
|
+
} catch (error) {
|
|
125
|
+
failed = true;
|
|
126
|
+
primary = error;
|
|
127
|
+
throw error;
|
|
128
|
+
} finally {
|
|
129
|
+
try { if (!published) removeOwnedStaging(staging); }
|
|
130
|
+
catch (cleanup) {
|
|
131
|
+
const diagnostic = `artifact staging cleanup failed at ${staging}: ${cleanup?.message ?? cleanup}`;
|
|
132
|
+
const error = failed
|
|
133
|
+
? new AggregateError([primary, cleanup], `${primary?.message ?? primary}; ${diagnostic}`, { cause: primary })
|
|
134
|
+
: new Error(diagnostic, { cause: cleanup });
|
|
135
|
+
error.code = failed ? primary?.code : cleanup?.code;
|
|
136
|
+
if (failed && primary?.provenance !== undefined) error.provenance = primary.provenance;
|
|
137
|
+
error.stagingPath = staging;
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** Immutable retention of already materialized capabilities. Acquisition,
|
|
2
|
+
* selection, approval and instance dispatch remain the caller's responsibility.
|
|
3
|
+
* See docs/design/2026-09-14-artifact-retention-contract.md. */
|
|
4
|
+
import {
|
|
5
|
+
linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync,
|
|
6
|
+
readlinkSync, realpathSync, rmSync, writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { capabilityArtifactIntegrity, publishArtifactTree } from "./artifact-tree.mjs";
|
|
10
|
+
import { oatsError } from "./errors.mjs";
|
|
11
|
+
import {
|
|
12
|
+
CAPABILITIES_DIRNAME, isMaterializedCapabilityId, validateCapabilityLockEntry,
|
|
13
|
+
validateLockEntry, verifyCapabilityInstallation,
|
|
14
|
+
} from "./capability-provenance.mjs";
|
|
15
|
+
|
|
16
|
+
const INTEGRITY = /^sha256-[0-9a-f]{64}$/;
|
|
17
|
+
const STORE_PARTS = [...CAPABILITIES_DIRNAME.split(sep), "artifacts"];
|
|
18
|
+
const exists = (path) => {
|
|
19
|
+
try { return lstatSync(path); }
|
|
20
|
+
catch (e) { if (e.code === "ENOENT") return null; throw e; }
|
|
21
|
+
};
|
|
22
|
+
const outside = (root, path) => {
|
|
23
|
+
const rel = relative(root, path);
|
|
24
|
+
return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The complete digest, not a short display prefix, identifies a revision. */
|
|
28
|
+
export function retainedCapabilityDir(scope, capability, integrity) {
|
|
29
|
+
if (!isMaterializedCapabilityId(capability) || typeof integrity !== "string" || !INTEGRITY.test(integrity)) {
|
|
30
|
+
throw oatsError("invalid-artifact-reference", "retained capability requires a valid capability ID and full sha256 integrity");
|
|
31
|
+
}
|
|
32
|
+
return join(resolve(scope), CAPABILITIES_DIRNAME, "artifacts", capability, integrity);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lockRows(capability, lock) {
|
|
36
|
+
if (!lock?.capabilities || !Object.hasOwn(lock.capabilities, capability) || !lock.packages) {
|
|
37
|
+
throw oatsError("invalid-lock", `no captured capability/package lock for ${capability}`);
|
|
38
|
+
}
|
|
39
|
+
for (const [id, row] of Object.entries(lock.packages)) validateLockEntry(id, row, lock.packages);
|
|
40
|
+
const row = lock.capabilities[capability];
|
|
41
|
+
validateCapabilityLockEntry(capability, row, lock.packages);
|
|
42
|
+
return { row, pkg: lock.packages[row.package] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A retained tree must remain usable after its source disappears. In particular,
|
|
46
|
+
// an absolute symlink pointing back into today's source is not a portable link.
|
|
47
|
+
export function assertRetainableTree(root) {
|
|
48
|
+
const st = exists(root);
|
|
49
|
+
if (!st) throw oatsError("artifact-not-found", `artifact tree is absent: ${root}`);
|
|
50
|
+
if (!st.isDirectory()) throw oatsError("invalid-artifact", `artifact root must be a directory: ${root}`);
|
|
51
|
+
const boundary = realpathSync(root);
|
|
52
|
+
const walk = (dir) => {
|
|
53
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
54
|
+
const path = join(dir, e.name);
|
|
55
|
+
if (e.isDirectory()) walk(path);
|
|
56
|
+
else if (e.isSymbolicLink()) {
|
|
57
|
+
let target;
|
|
58
|
+
try { target = realpathSync(path); }
|
|
59
|
+
catch { throw oatsError("artifact-not-contained", `broken artifact symlink: ${path}`); }
|
|
60
|
+
if (isAbsolute(readlinkSync(path)) || outside(boundary, target)) {
|
|
61
|
+
throw oatsError("artifact-not-contained", `artifact symlink must stay relative and inside its retained tree: ${path}`);
|
|
62
|
+
}
|
|
63
|
+
} else if (!e.isFile()) {
|
|
64
|
+
throw oatsError("invalid-artifact", `artifact contains an unsupported file type: ${path}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
walk(root);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function verifyTree(dir, capability, row, pkg) {
|
|
72
|
+
assertRetainableTree(dir);
|
|
73
|
+
const actual = capabilityArtifactIntegrity(dir);
|
|
74
|
+
if (actual !== row.integrity) {
|
|
75
|
+
throw oatsError("integrity-drift", `retained capability ${capability}: expected ${row.integrity}, got ${actual} at ${dir}`);
|
|
76
|
+
}
|
|
77
|
+
verifyCapabilityInstallation(dir, capability, row, pkg);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function ensureStoreIgnore(dir) {
|
|
81
|
+
const ignore = join(dir, ".gitignore");
|
|
82
|
+
const verify = () => {
|
|
83
|
+
if (!exists(ignore)?.isFile() || readFileSync(ignore, "utf8") !== "*\n") {
|
|
84
|
+
throw oatsError("invalid-artifact-store", `managed artifact ignore file must contain exactly '*': ${ignore}`);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
if (exists(ignore)) { verify(); return; }
|
|
88
|
+
|
|
89
|
+
// Exclusive creation at the public name exposes an empty/partial file. Write
|
|
90
|
+
// and close in an owned, same-filesystem directory, then link atomically with
|
|
91
|
+
// no replacement. EEXIST is evidence to verify, never permission to repair.
|
|
92
|
+
// No retries, hostile-host isolation or power-loss durability are promised;
|
|
93
|
+
// a crash can leave dot-prefixed metadata staging for explicit cleanup.
|
|
94
|
+
const staging = mkdtempSync(join(dir, ".gitignore-"));
|
|
95
|
+
let failed = false, primary;
|
|
96
|
+
try {
|
|
97
|
+
const temp = join(staging, ".gitignore");
|
|
98
|
+
writeFileSync(temp, "*\n", { flag: "wx" });
|
|
99
|
+
try { linkSync(temp, ignore); }
|
|
100
|
+
catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
101
|
+
verify();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
failed = true;
|
|
104
|
+
primary = error;
|
|
105
|
+
throw error;
|
|
106
|
+
} finally {
|
|
107
|
+
// Only our metadata staging. Never unlink the public name (even on error),
|
|
108
|
+
// traverse another producer's staging, or change source/published modes.
|
|
109
|
+
try { rmSync(staging, { recursive: true, force: true }); }
|
|
110
|
+
catch (cleanup) {
|
|
111
|
+
const diagnostic = `artifact ignore staging cleanup failed at ${staging}: ${cleanup?.message ?? cleanup}`;
|
|
112
|
+
const error = failed
|
|
113
|
+
? new AggregateError([primary, cleanup], `${primary?.message ?? primary}; ${diagnostic}`, { cause: primary })
|
|
114
|
+
: new Error(diagnostic, { cause: cleanup });
|
|
115
|
+
error.code = failed ? primary?.code : cleanup?.code;
|
|
116
|
+
if (failed && primary?.provenance !== undefined) error.provenance = primary.provenance;
|
|
117
|
+
error.stagingPath = staging;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The scope itself may be a user's symlink. Managed descendants must be real
|
|
124
|
+
// directories, so a stale/broken store link cannot redirect publication.
|
|
125
|
+
function storePath(scope, capability, create) {
|
|
126
|
+
let dir;
|
|
127
|
+
try { dir = realpathSync(scope); }
|
|
128
|
+
catch (e) {
|
|
129
|
+
if (e.code === "ENOENT") throw oatsError("artifact-not-found", `artifact scope is absent: ${scope}`);
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
for (const [index, part] of [...STORE_PARTS, capability].entries()) {
|
|
133
|
+
dir = join(dir, part);
|
|
134
|
+
if (create) {
|
|
135
|
+
try { mkdirSync(dir); }
|
|
136
|
+
catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
137
|
+
}
|
|
138
|
+
const st = exists(dir);
|
|
139
|
+
if (!st && !create) throw oatsError("artifact-not-found", `artifact store path is absent: ${dir}`);
|
|
140
|
+
if (!st || !st.isDirectory()) throw oatsError("invalid-artifact-store", `artifact store component must be a directory: ${dir}`);
|
|
141
|
+
if (index === STORE_PARTS.length - 1 && create) {
|
|
142
|
+
// Owned store metadata, outside every hashed artifact. Write before any
|
|
143
|
+
// staging payload; no Git command or change to authored directories.
|
|
144
|
+
ensureStoreIgnore(dir);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return dir;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Verify an exact retained revision using captured provenance. No current lock,
|
|
151
|
+
* source repository, trust approval or network lookup participates. */
|
|
152
|
+
export function verifyRetainedCapability(scope, capability, lock) {
|
|
153
|
+
const { row, pkg } = lockRows(capability, lock);
|
|
154
|
+
retainedCapabilityDir(scope, capability, row.integrity); // validate before path use
|
|
155
|
+
const dir = join(storePath(scope, capability, false), row.integrity);
|
|
156
|
+
verifyTree(dir, capability, row, pkg);
|
|
157
|
+
return { capability, integrity: row.integrity, dir };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Publish once by same-filesystem rename. Never repairs or replaces a retained
|
|
161
|
+
* revision; a damaged existing entry requires explicit operator intervention.
|
|
162
|
+
* `lock` carries validated package/capability provenance from one resolution.
|
|
163
|
+
* Its trust flags are not grants from this API, and are never written here. */
|
|
164
|
+
export function retainCapabilityArtifact(scope, sourceDir, capability, lock) {
|
|
165
|
+
const { row, pkg } = lockRows(capability, lock);
|
|
166
|
+
retainedCapabilityDir(scope, capability, row.integrity);
|
|
167
|
+
verifyTree(sourceDir, capability, row, pkg);
|
|
168
|
+
const root = storePath(scope, capability, true);
|
|
169
|
+
const dir = join(root, row.integrity);
|
|
170
|
+
const receipt = { capability, integrity: row.integrity, dir };
|
|
171
|
+
if (exists(dir)) {
|
|
172
|
+
verifyTree(dir, capability, row, pkg);
|
|
173
|
+
return { ...receipt, status: "kept" };
|
|
174
|
+
}
|
|
175
|
+
// Refuse recursive copies if a caller supplied an ancestor of the store.
|
|
176
|
+
if (!outside(realpathSync(sourceDir), root)) throw oatsError("invalid-artifact", "artifact source contains its destination store");
|
|
177
|
+
const status = publishArtifactTree(sourceDir, dir, (tree) => verifyTree(tree, capability, row, pkg));
|
|
178
|
+
return { ...receipt, status };
|
|
179
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The single existing executable-surface definition, shared with captured
|
|
2
|
+
* approval diagnostics. This projection does not validate/compile a manifest
|
|
3
|
+
* or permit execution; the action adapter must validate the full contract. */
|
|
4
|
+
export function executableSurfaceOf(manifest) {
|
|
5
|
+
return {
|
|
6
|
+
commands: Object.keys(manifest?.commands || {}),
|
|
7
|
+
hooks: Object.keys(manifest?.hooks || {}),
|
|
8
|
+
environment: [...(manifest?.environment || [])],
|
|
9
|
+
...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function hasExecutableSurface(manifest) {
|
|
13
|
+
const s = executableSurfaceOf(manifest);
|
|
14
|
+
return s.commands.length > 0 || s.hooks.length > 0 || s.environment.length > 0;
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Capability-owned helper instruction policy and selected supplemental inputs.
|
|
2
|
+
* Shape only: no source lookup, binding, readiness, side effects or fallback. */
|
|
3
|
+
import { canonicalJson } from './portable-values.mjs';
|
|
4
|
+
import { objectAt, invalidShape } from './portable-shape.mjs';
|
|
5
|
+
import { portablePath } from './source-spec.mjs';
|
|
6
|
+
|
|
7
|
+
export function validateHelperInjection(value) {
|
|
8
|
+
canonicalJson(value);
|
|
9
|
+
const file = value?.mode === 'file';
|
|
10
|
+
objectAt(value, file ? ['version', 'mode', 'path'] : ['version', 'mode'], file ? ['version', 'mode', 'path'] : ['version', 'mode']);
|
|
11
|
+
if (value.version !== 1 || !['inherit', 'omit', 'file'].includes(value.mode)) invalidShape('/helperInjection', 'unsupported helper instruction policy');
|
|
12
|
+
if (file) portablePath(value.path);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function validateHookInputs(value) {
|
|
17
|
+
canonicalJson(value);
|
|
18
|
+
objectAt(value, ['sourceReceipt'], [], '/inputs');
|
|
19
|
+
if (Object.hasOwn(value, 'sourceReceipt')) {
|
|
20
|
+
objectAt(value.sourceReceipt, ['version'], ['version'], '/inputs/sourceReceipt');
|
|
21
|
+
if (value.sourceReceipt.version !== 1) invalidShape('/inputs/sourceReceipt/version', 'unsupported source receipt input version');
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Presence is explicit. In particular, absent/empty inputs are NOT consent;
|
|
27
|
+
* this validator does not infer whether a provider depends on that input. */
|
|
28
|
+
export function validateCapabilityInputDeclarations(manifest) {
|
|
29
|
+
canonicalJson(manifest);
|
|
30
|
+
objectAt(manifest, null, []);
|
|
31
|
+
if (Object.hasOwn(manifest, 'helperInjection')) validateHelperInjection(manifest.helperInjection);
|
|
32
|
+
if (Object.hasOwn(manifest, 'hooks')) {
|
|
33
|
+
objectAt(manifest.hooks, null, [], '/hooks');
|
|
34
|
+
for (const hook of Object.values(manifest.hooks)) {
|
|
35
|
+
if (hook !== null && typeof hook === 'object' && Object.hasOwn(hook, 'inputs')) validateHookInputs(hook.inputs);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return manifest;
|
|
39
|
+
}
|