@awebai/oats 0.23.2 → 0.24.1
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 +224 -391
- package/bin/oats-pi-sdk-host.mjs +17 -0
- package/bin/oats.mjs +470 -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 +109 -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/capabilities.md +4 -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/design/2026-09-20-redesign-program-board.md +70 -0
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +287 -0
- package/docs/design/2026-09-20-workspace-onboarding-public.md +124 -0
- package/docs/design/README.md +42 -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/first-team.md +1 -1
- package/docs/knowledge-theory.md +353 -111
- package/docs/knowledge.md +10 -1
- package/docs/layers.md +89 -354
- 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/official-marketplace.md +79 -0
- package/docs/packages.md +4 -0
- package/docs/portable.schema.json +2512 -0
- package/docs/provider-check-input.schema.json +7 -0
- package/docs/release-notes/v0.24.0.md +104 -0
- package/docs/release-notes/v0.24.1.md +17 -0
- package/docs/schedules.md +126 -14
- package/docs/soul.schema.json +82 -0
- package/docs/souls-and-instances.md +20 -7
- package/docs/workspace-adoption.md +285 -0
- package/docs/workspaces.md +154 -0
- package/injects/oats-portable.md +16 -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 +961 -566
- 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 +249 -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 +2 -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 +11 -9
- package/skills/oats-packages/SKILL.md +12 -8
- package/skills/oats-portable/SKILL.md +115 -0
- package/skills/oats-portable-artifacts/SKILL.md +63 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/** Existing materialized-capability data contract: layout/identity, lock-row
|
|
2
|
+
* validation and installation provenance. No scope discovery, lock writes,
|
|
3
|
+
* acquisition, approval, resolver or lifecycle dispatch. */
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { isAbsolute, join } from "node:path";
|
|
6
|
+
import { oatsError } from "./errors.mjs";
|
|
7
|
+
|
|
8
|
+
/** Scope-relative capability store subtrees. */
|
|
9
|
+
export const CAPABILITIES_DIRNAME = join(".agents", "capabilities");
|
|
10
|
+
export const INSTALLED_SUBDIR = "installed";
|
|
11
|
+
|
|
12
|
+
/** THE identity grammar for a MATERIALIZED (revised-v2) capability.
|
|
13
|
+
*
|
|
14
|
+
* A materialized capability id is not merely a label: it becomes a DIRECTORY
|
|
15
|
+
* NAME directly under `installed/`, so anything that can steer a filesystem
|
|
16
|
+
* join must be impossible before the join happens. The grammar is deliberately
|
|
17
|
+
* the package-id grammar — namespaced dots are fine, and `/`, `\`, `..`,
|
|
18
|
+
* absolute forms, `@`, and percent-encoded spellings are all outside it.
|
|
19
|
+
*
|
|
20
|
+
* The LEGACY v1 / owned / `from: path:` grammar (loadManifestAt) stays looser
|
|
21
|
+
* on purpose: those artifacts are named by `basename()` of their source, never
|
|
22
|
+
* by the declared id, so the id never reaches a path there. Tightening it would
|
|
23
|
+
* strand already-published standalone capabilities. */
|
|
24
|
+
export const CAPABILITY_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
25
|
+
export const isMaterializedCapabilityId = (id) => typeof id === "string" && CAPABILITY_ID_RE.test(id);
|
|
26
|
+
/** Why a capability id was refused, in one sentence, for every caller's own
|
|
27
|
+
* typed error (the lock parser raises invalid-lock, manifest validation raises
|
|
28
|
+
* invalid-package-manifest — the code each consumer already branches on). */
|
|
29
|
+
export const capabilityIdViolation = (id) =>
|
|
30
|
+
`${JSON.stringify(id)} is not a valid capability identity — expected ${CAPABILITY_ID_RE.source} (a namespaced id such as "oats.okf"; path separators, "..", absolute paths, "@" and encoded forms are refused because the id names a directory under ${INSTALLED_SUBDIR}/)`;
|
|
31
|
+
|
|
32
|
+
/** Generated provenance file inside every materialized artifact. It is INSIDE
|
|
33
|
+
* the hashed tree, so tampering with it is integrity drift; the lock stays
|
|
34
|
+
* authoritative. */
|
|
35
|
+
export const CAPABILITY_INSTALLATION_FILE = ".oats-installation.json";
|
|
36
|
+
|
|
37
|
+
export const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
38
|
+
/** Own-property PRESENCE of any of these on a package row is forbidden
|
|
39
|
+
* transitional evidence (contract §4.1) — never truthiness and never array
|
|
40
|
+
* length, so an empty `capabilities: []` or a dependency-free old row still
|
|
41
|
+
* classifies. Package-row `path`/`dependencies` are NEVER tells: the current
|
|
42
|
+
* shape retains both. */
|
|
43
|
+
export const TRANSITIONAL_ROW_FIELDS = ["capabilities", "trustedCapabilities", "depsIntegrity"];
|
|
44
|
+
|
|
45
|
+
/** Normalize a configured package path to its canonical form, or throw.
|
|
46
|
+
*
|
|
47
|
+
* Canonical form is a POSIX-relative path with no redundant or trailing
|
|
48
|
+
* separators; every spelling of the repository root ("", ".", "./", "./.")
|
|
49
|
+
* normalizes to the single canonical "." so a root selection round-trips
|
|
50
|
+
* identically through spec → lock → JSON → doctor/list/update (contract §4).
|
|
51
|
+
*
|
|
52
|
+
* Fail-closed: absolute paths, Windows drive paths, host-ambient "~" spellings,
|
|
53
|
+
* backslash separators (ambiguous — a backslash is a legal POSIX filename
|
|
54
|
+
* character, so accepting it as a separator would make containment checks
|
|
55
|
+
* disagree with the filesystem) and NUL are rejected as invalid-source; ".."
|
|
56
|
+
* traversal is path-escape. Returns undefined ONLY for an absent value, so the
|
|
57
|
+
* caller can apply the source-appropriate default. */
|
|
58
|
+
export function normalizePackagePath(raw, { where = "package path", code = "invalid-source" } = {}) {
|
|
59
|
+
// ABSENT means absent. A present `null` (JSON's way of spelling a malformed
|
|
60
|
+
// value) is a violation, not a fall-through to the caller's default — a
|
|
61
|
+
// catalog entry that says `"path": null` must fail, not silently install
|
|
62
|
+
// DEFAULT_PACKAGE_PATH.
|
|
63
|
+
if (raw === undefined) return undefined;
|
|
64
|
+
if (typeof raw !== "string") throw oatsError(code, `${where} must be a string (got ${Array.isArray(raw) ? "array" : raw === null ? "null" : typeof raw})`);
|
|
65
|
+
const s = raw.trim();
|
|
66
|
+
if (s.includes("\0")) throw oatsError(code, `${where} contains a NUL byte`);
|
|
67
|
+
if (s.startsWith("~")) throw oatsError(code, `${where} "${s}" is a host-ambient path — package paths are repository-relative`);
|
|
68
|
+
if (s.includes("\\")) throw oatsError(code, `${where} "${s}" uses backslashes — package paths are POSIX-relative (use "/")`);
|
|
69
|
+
if (/^[A-Za-z]:[/\\]/.test(s)) throw oatsError(code, `${where} "${s}" is an absolute drive path — package paths are repository-relative`);
|
|
70
|
+
if (isAbsolute(s)) throw oatsError(code, `${where} "${s}" is absolute — package paths are repository-relative`);
|
|
71
|
+
const segments = s.split("/").filter((seg) => seg !== "" && seg !== ".");
|
|
72
|
+
if (segments.includes("..")) throw oatsError("path-escape", `${where} "${s}" escapes the source root with ".."`);
|
|
73
|
+
return segments.length ? segments.join("/") : ".";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Parse a lock entry's `source` against the EXACT normalized grammar the
|
|
77
|
+
* writer produces. Strict on purpose: `updatePackage` turns this back into a
|
|
78
|
+
* source spec, so a payload that merely "starts with catalog:" but is not a
|
|
79
|
+
* valid catalog id gets RECLASSIFIED downstream — `catalog:../evil` would be
|
|
80
|
+
* re-parsed as a host-relative local path and acquired from the operator's
|
|
81
|
+
* filesystem. A lock also never carries a `#<path>` fragment: the selected
|
|
82
|
+
* root is the entry's own `path` field, and a fragment here would produce a
|
|
83
|
+
* double-fragment spec on update. */
|
|
84
|
+
export function parseLockSource(src) {
|
|
85
|
+
const s = String(src || "");
|
|
86
|
+
const bad = (why) => oatsError("invalid-source", `unknown lock source "${src}" — ${why}`);
|
|
87
|
+
if (s.includes("#")) throw bad(`lock sources carry no "#<path>" fragment; the selected package root is the entry's "path" field`);
|
|
88
|
+
if (s.startsWith("path:")) {
|
|
89
|
+
const p = s.slice(5);
|
|
90
|
+
if (!p) throw bad("empty path source");
|
|
91
|
+
if (!isAbsolute(p)) throw bad("path source must be an absolute directory (the writer always resolves it)");
|
|
92
|
+
return { kind: "path", path: p, normalized: s };
|
|
93
|
+
}
|
|
94
|
+
if (s.startsWith("catalog:")) {
|
|
95
|
+
const body = s.slice(8);
|
|
96
|
+
// Split at the FIRST "@", mirroring the public parser's regex: the catalog
|
|
97
|
+
// id grammar cannot contain "@", so everything after the first one is the
|
|
98
|
+
// selector. Splitting at the LAST "@" misreads a legitimate ref spelling
|
|
99
|
+
// such as `oats.okf@release@candidate` — which the writer does produce —
|
|
100
|
+
// as the id `oats.okf@release`.
|
|
101
|
+
const at = body.indexOf("@");
|
|
102
|
+
const id = at > 0 ? body.slice(0, at) : body;
|
|
103
|
+
const selector = at > 0 ? body.slice(at + 1) : undefined;
|
|
104
|
+
if (!PACKAGE_ID_RE.test(id)) throw bad(`"${id}" is not a valid official catalog id`);
|
|
105
|
+
if (at > 0 && !selector) throw bad("empty catalog selector");
|
|
106
|
+
return { kind: "catalog", id, selector, normalized: s };
|
|
107
|
+
}
|
|
108
|
+
if (s.startsWith("git:")) {
|
|
109
|
+
const body = s.slice(4);
|
|
110
|
+
const at = body.lastIndexOf("@") > body.lastIndexOf("/") ? body.lastIndexOf("@") : -1;
|
|
111
|
+
const url = at > 0 ? body.slice(0, at) : body;
|
|
112
|
+
const ref = at > 0 ? body.slice(at + 1) : undefined;
|
|
113
|
+
if (!url) throw bad("empty git url");
|
|
114
|
+
if (at > 0 && !ref) throw bad("empty git ref");
|
|
115
|
+
if (!/^(https?:\/\/|file:\/\/|git@|ssh:\/\/|git:\/\/)/.test(url)) throw bad(`"${url}" is not an http(s)/ssh/file/git URL`);
|
|
116
|
+
return { kind: "git", url, ref, normalized: s };
|
|
117
|
+
}
|
|
118
|
+
throw oatsError("invalid-source", `unknown lock source "${src}"`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Semantic lock-entry validation for a PACKAGE row (runtime API addendum §4):
|
|
122
|
+
* source/commit pairing, canonical path, dependency references (incl. self and
|
|
123
|
+
* cycle over the locked graph), digest shapes, uniqueness. Run BEFORE restore,
|
|
124
|
+
* trust/approval, update/remove/migration planning, the locked-template reader,
|
|
125
|
+
* and doctor/list consumption. Fails closed with code "invalid-lock" carrying
|
|
126
|
+
* file/package provenance; never normalizes or auto-repairs on read. */
|
|
127
|
+
export function validateLockEntry(packageId, entry, allPackages = {}, opts = {}) {
|
|
128
|
+
const where = opts.file ? ` (${opts.file})` : "";
|
|
129
|
+
const bad = (msg) => oatsError("invalid-lock", `lock entry for package "${packageId}"${where} is invalid: ${msg}`, [{ package: packageId, file: opts.file, violation: msg }]);
|
|
130
|
+
if (!entry || typeof entry !== "object") throw bad("not an object");
|
|
131
|
+
for (const k of ["source", "path", "version", "commit", "integrity"]) if (!entry[k] || typeof entry[k] !== "string") throw bad(`missing ${k}`);
|
|
132
|
+
// The selected package root is a STRICT separate field (contract §1.1) stored
|
|
133
|
+
// in canonical form only — a lock is never normalized or repaired on read, so
|
|
134
|
+
// a non-canonical spelling ("./sub", "sub/", "") is invalid, not silently
|
|
135
|
+
// accepted. That is what makes the root representation round-trip.
|
|
136
|
+
{
|
|
137
|
+
let canonical;
|
|
138
|
+
try { canonical = normalizePackagePath(entry.path, { where: "path", code: "invalid-lock" }); }
|
|
139
|
+
catch (e) { throw bad(`invalid path ${JSON.stringify(entry.path)} — ${e.message}`); }
|
|
140
|
+
if (canonical !== entry.path) throw bad(`path ${JSON.stringify(entry.path)} is not in canonical form (expected ${JSON.stringify(canonical)})`);
|
|
141
|
+
}
|
|
142
|
+
if (!/^sha256-[0-9a-f]{64}$/.test(entry.integrity)) throw bad(`malformed integrity "${entry.integrity}"`);
|
|
143
|
+
// Present-but-wrong-typed optional fields are invalid — default ONLY when absent.
|
|
144
|
+
// `dependencies` is ALWAYS recorded (empty array when none), so a reader never
|
|
145
|
+
// has to distinguish absent from empty.
|
|
146
|
+
if (!Array.isArray(entry.dependencies)) throw bad("dependencies must be an array (empty when the package has none)");
|
|
147
|
+
for (const d of entry.dependencies) if (typeof d !== "string" || !PACKAGE_ID_RE.test(d)) throw bad(`dependencies contains an invalid package id ${JSON.stringify(d)}`);
|
|
148
|
+
if (new Set(entry.dependencies).size !== entry.dependencies.length) throw bad("dependencies contains duplicates");
|
|
149
|
+
// Package rows lock transport only. A capability list, a trust list or a
|
|
150
|
+
// dependency-closure digest here is the unsupported transitional shape — the
|
|
151
|
+
// central parser rejects those documents outright (contract §4.1); this is
|
|
152
|
+
// the entry-level backstop for a row reaching validation another way.
|
|
153
|
+
for (const gone of TRANSITIONAL_ROW_FIELDS) {
|
|
154
|
+
if (Object.hasOwn(entry, gone)) throw bad(`"${gone}" is a transitional package-root field — package rows lock transport only (capabilities and trust live on capability rows)`);
|
|
155
|
+
}
|
|
156
|
+
let src;
|
|
157
|
+
try { src = parseLockSource(entry.source); } catch { throw bad(`unrecognized source "${entry.source}"`); }
|
|
158
|
+
if (src.kind === "path" && !src.path) throw bad("empty path source");
|
|
159
|
+
if (src.kind === "git" && !src.url) throw bad("empty git source");
|
|
160
|
+
if (src.kind === "catalog" && !src.id) throw bad("empty catalog source");
|
|
161
|
+
if (src.kind === "path") {
|
|
162
|
+
if (entry.commit !== "local") throw bad(`path source requires commit "local", got "${entry.commit}"`);
|
|
163
|
+
// Local acquisition is exact-directory: the source string already names the
|
|
164
|
+
// package root, so the only valid contained path is the root itself.
|
|
165
|
+
if (entry.path !== ".") throw bad(`path source requires path "." (local sources are exact directories), got ${JSON.stringify(entry.path)}`);
|
|
166
|
+
}
|
|
167
|
+
else if (!/^[0-9a-f]{40}$/.test(entry.commit)) throw bad(`${src.kind} source requires an exact 40-hex commit, got "${entry.commit}"`);
|
|
168
|
+
for (const d of entry.dependencies || []) {
|
|
169
|
+
if (d === packageId) throw bad(`self-dependency "${d}"`);
|
|
170
|
+
// Object.hasOwn: a dependency literally named "constructor"/"__proto__"
|
|
171
|
+
// must not pass via Object.prototype.
|
|
172
|
+
if (!Object.hasOwn(allPackages, d)) throw bad(`dependency "${d}" is not locked in the same packages map`);
|
|
173
|
+
}
|
|
174
|
+
// Cycle over the locked dependency graph reachable from this entry.
|
|
175
|
+
const visiting = new Set();
|
|
176
|
+
const visited = new Set();
|
|
177
|
+
const walk = (id, chain) => {
|
|
178
|
+
if (visited.has(id)) return;
|
|
179
|
+
if (visiting.has(id)) throw bad(`dependency cycle in the locked graph: ${[...chain, id].join(" → ")}`);
|
|
180
|
+
visiting.add(id);
|
|
181
|
+
const deps = Object.hasOwn(allPackages, id) && Array.isArray(allPackages[id]?.dependencies) ? allPackages[id].dependencies : [];
|
|
182
|
+
for (const d of deps) if (Object.hasOwn(allPackages, d) || d === packageId) walk(d, [...chain, id]);
|
|
183
|
+
visiting.delete(id); visited.add(id);
|
|
184
|
+
};
|
|
185
|
+
walk(packageId, []);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Semantic validation of one CAPABILITY row against the whole document
|
|
190
|
+
* (contract §4). The `package` back-reference must name a locked package: it is
|
|
191
|
+
* the single provider truth, so a dangling reference would leave a materialized
|
|
192
|
+
* artifact with no provenance to restore or verify it from. */
|
|
193
|
+
export function validateCapabilityLockEntry(capabilityId, entry, allPackages = {}, opts = {}) {
|
|
194
|
+
const where = opts.file ? ` (${opts.file})` : "";
|
|
195
|
+
const bad = (msg) => oatsError("invalid-lock", `capability lock entry for "${capabilityId}"${where} is invalid: ${msg}`, [{ package: capabilityId, file: opts.file, violation: msg }]);
|
|
196
|
+
if (typeof capabilityId !== "string" || !capabilityId) throw bad("empty capability id");
|
|
197
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw bad("not an object");
|
|
198
|
+
for (const k of ["version", "package", "path", "integrity"]) if (!entry[k] || typeof entry[k] !== "string") throw bad(`missing ${k}`);
|
|
199
|
+
if (!PACKAGE_ID_RE.test(entry.package)) throw bad(`provider package ${JSON.stringify(entry.package)} is not a valid package identity`);
|
|
200
|
+
if (!Object.hasOwn(allPackages, entry.package)) throw bad(`provider package "${entry.package}" is not locked in the same packages map`);
|
|
201
|
+
{
|
|
202
|
+
let canonical;
|
|
203
|
+
try { canonical = normalizePackagePath(entry.path, { where: "path", code: "invalid-lock" }); }
|
|
204
|
+
catch (e) { throw bad(`invalid path ${JSON.stringify(entry.path)} — ${e.message}`); }
|
|
205
|
+
if (canonical !== entry.path) throw bad(`path ${JSON.stringify(entry.path)} is not in canonical form (expected ${JSON.stringify(canonical)})`);
|
|
206
|
+
}
|
|
207
|
+
if (!/^sha256-[0-9a-f]{64}$/.test(entry.integrity)) throw bad(`malformed integrity "${entry.integrity}"`);
|
|
208
|
+
if (typeof entry.trusted !== "boolean") throw bad(`"trusted" must be a boolean (got ${JSON.stringify(entry.trusted)})`);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Read an artifact's provenance and check it AGREES with the lock rows it was
|
|
213
|
+
* projected from. Disagreement is invalid-lock: the artifact and the lock claim
|
|
214
|
+
* different origins, and neither may silently win. (A modified file also fails
|
|
215
|
+
* integrity, but this gives the precise diagnosis.) */
|
|
216
|
+
export function verifyCapabilityInstallation(dir, capabilityId, capRow, pkgRow) {
|
|
217
|
+
const file = join(dir, CAPABILITY_INSTALLATION_FILE);
|
|
218
|
+
if (!existsSync(file)) throw oatsError("invalid-lock", `materialized capability ${capabilityId} has no ${CAPABILITY_INSTALLATION_FILE} provenance — reproject it with \`oats install\``, [{ package: capabilityId, file }]);
|
|
219
|
+
let doc;
|
|
220
|
+
try { doc = JSON.parse(readFileSync(file, "utf8")); }
|
|
221
|
+
catch (e) { throw oatsError("invalid-lock", `materialized capability ${capabilityId} has malformed ${CAPABILITY_INSTALLATION_FILE}: ${e.message}`, [{ package: capabilityId, file }]); }
|
|
222
|
+
const expected = {
|
|
223
|
+
schemaVersion: 1, capability: capabilityId, version: capRow.version, package: capRow.package,
|
|
224
|
+
packageVersion: pkgRow.version, source: pkgRow.source, commit: pkgRow.commit,
|
|
225
|
+
packagePath: pkgRow.path, capabilityPath: capRow.path,
|
|
226
|
+
};
|
|
227
|
+
for (const [k, want] of Object.entries(expected)) {
|
|
228
|
+
if (doc?.[k] !== want) throw oatsError("invalid-lock", `materialized capability ${capabilityId}: ${CAPABILITY_INSTALLATION_FILE} "${k}" is ${JSON.stringify(doc?.[k])} but the lock records ${JSON.stringify(want)}`, [{ package: capabilityId, file, violation: k }]);
|
|
229
|
+
}
|
|
230
|
+
return doc;
|
|
231
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The existing captured loader action wire, shared with provider invocation
|
|
2
|
+
* validation. This is a data codec, not a dispatch table or provider policy. */
|
|
3
|
+
import { canonicalJson } from "./portable-values.mjs";
|
|
4
|
+
import { objectAt, stringAt } from "./portable-shape.mjs";
|
|
5
|
+
import { oatsError } from "./errors.mjs";
|
|
6
|
+
|
|
7
|
+
export function validateCapturedAction(action) {
|
|
8
|
+
canonicalJson(action);
|
|
9
|
+
if (!["inspect", "compose", "command", "operation", "hook"].includes(action?.kind)) throw oatsError("unsupported-action", "captured action is not supported by this loader");
|
|
10
|
+
if (["inspect", "compose"].includes(action.kind)) objectAt(action, ["kind"], ["kind"]);
|
|
11
|
+
else if (action.kind === "command") {
|
|
12
|
+
objectAt(action, ["kind", "capability", "namespace", "name"], ["kind", "name"]);
|
|
13
|
+
if ((action.capability === undefined) === (action.namespace === undefined)) throw oatsError("invalid-declaration", "command needs exactly one capability or namespace");
|
|
14
|
+
} else {
|
|
15
|
+
const fields = action.kind === "hook" ? ["kind", "capability", "name"] : ["kind", "slot", "name"];
|
|
16
|
+
objectAt(action, fields, fields);
|
|
17
|
+
}
|
|
18
|
+
for (const key of ["name", "namespace", "capability", "slot"]) if (action[key] !== undefined) stringAt(action[key], `/action/${key}`);
|
|
19
|
+
if (action.kind === "operation" && !["knowledge", "messaging", "tasks"].includes(action.slot)) throw oatsError("invalid-declaration", "captured operation slot is invalid");
|
|
20
|
+
return action;
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Generic incarnation/admitted-intent identities. Composition references are
|
|
2
|
+
* deliberately not accepted as incarnation or logical-request identities. */
|
|
3
|
+
import { canonicalJson } from "./portable-values.mjs";
|
|
4
|
+
import { objectAt, stringAt } from "./portable-shape.mjs";
|
|
5
|
+
import { oatsError } from "./errors.mjs";
|
|
6
|
+
|
|
7
|
+
export function validateIncarnationId(value) {
|
|
8
|
+
stringAt(value, "/incarnationId", { pattern: /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/ });
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateIntentRef(value) {
|
|
13
|
+
canonicalJson(value, { maxBytes: 4096, maxDepth: 4, maxEntries: 16 });
|
|
14
|
+
objectAt(value, ["schemaVersion", "executionId", "incarnationId", "attempt"], ["schemaVersion", "executionId", "incarnationId", "attempt"]);
|
|
15
|
+
if (value.schemaVersion !== 1) throw oatsError("unsupported-wire-version", "unsupported captured intent version");
|
|
16
|
+
stringAt(value.executionId, "/executionId", { pattern: /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/ });
|
|
17
|
+
validateIncarnationId(value.incarnationId);
|
|
18
|
+
if (!Number.isSafeInteger(value.attempt) || value.attempt < 1) throw oatsError("invalid-declaration", "intent attempt must be a positive integer");
|
|
19
|
+
return value;
|
|
20
|
+
}
|