@awebai/oats 0.23.2 → 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.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
package/lib/core.mjs
CHANGED
|
@@ -35,11 +35,65 @@ import { accessSync, constants as fsConstants } from "node:fs";
|
|
|
35
35
|
import { homedir, tmpdir } from "node:os";
|
|
36
36
|
import { createHash, randomUUID } from "node:crypto";
|
|
37
37
|
import { fileURLToPath } from "node:url";
|
|
38
|
-
import { initializeNativeHistory, prepareNativeStart } from "../packages/record/lib/native-history.mjs";
|
|
38
|
+
import { initializeNativeHistory, prepareNativeStart, nativeHistoryPath } from "../packages/record/lib/native-history.mjs";
|
|
39
|
+
import { PI_SDK_HOST, isPiSdkHost, validatePiHostRecipe, piHostArgv, resolvePiSdkEntry } from "./pi-sdk-host.mjs";
|
|
40
|
+
import { capturedPiSessionDirectory, requireCapturedPiRecordSupport, inspectCapturedPiRoot, prepareCapturedPiStart } from "./captured-pi-custody.mjs";
|
|
39
41
|
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
40
42
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
41
43
|
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
|
|
42
44
|
|
|
45
|
+
import { oatsError } from "./errors.mjs";
|
|
46
|
+
const CAPTURED_NATIVE_START = Symbol("captured-native-start");
|
|
47
|
+
import { renderInstructionText } from "./instruction-composition.mjs";
|
|
48
|
+
import { loadCapturedAction } from "./captured-dispatch.mjs";
|
|
49
|
+
import { materializeCapturedDirectoryScaffold, validateCapturedDirectoryTarget } from "./captured-scaffold.mjs";
|
|
50
|
+
import { buildCapturedInvocationContext, withCapturedInvocationContextFile } from "./captured-invocation-context.mjs";
|
|
51
|
+
import { readCapturedInstanceIndex, readCapturedInstanceMetadata, readCapturedInstanceAuthority, assertCapturedInstanceCustody,
|
|
52
|
+
recordCapturedCustodyFailure, markCapturedNativeScaffold, failCapturedNativeIntent, observeCapturedNativeTarget, setCapturedInstanceStatus, admitCapturedInstanceAction, beginCapturedIntent, settleCapturedIntent } from "./captured-instance-index.mjs";
|
|
53
|
+
import { validateExecutionBinding } from "./schedule-capsule.mjs";
|
|
54
|
+
import { validateIntentRef } from "./captured-admission-shape.mjs";
|
|
55
|
+
export { readCapturedInstanceIndex, readCapturedInstanceMetadata, beginCapturedIntent, settleCapturedIntent };
|
|
56
|
+
export { buildCapturedInvocationContext, withCapturedInvocationContextFile };
|
|
57
|
+
import { approveAvailableCapability as approveAvailableArtifact } from "./artifact-approvals.mjs";
|
|
58
|
+
import { prepareComposition } from "./prepare-composition.mjs";
|
|
59
|
+
import { completePreparedResources } from "./prepared-resources.mjs";
|
|
60
|
+
import { validateCapturedLaunchRequest } from "./captured-launch-request.mjs";
|
|
61
|
+
import { validateCapabilityInputDeclarations } from "./capability-inputs.mjs";
|
|
62
|
+
import { validateCapturedSessionBackend, validateCapturedSessionTarget, assertCapturedSessionPlacement } from "./captured-session-backend.mjs";
|
|
63
|
+
import { createRepositoryTransaction } from "./repository-observation.mjs";
|
|
64
|
+
import { readLock3 } from "./portable-lock.mjs";
|
|
65
|
+
import { portableScope, portableStateDirectory } from "./portable-state.mjs";
|
|
66
|
+
import { objectAt } from "./portable-shape.mjs";
|
|
67
|
+
import { validateBindingInterface } from "./provider-binding.mjs";
|
|
68
|
+
import { invokeProviderBinding } from "./provider-binding-broker.mjs";
|
|
69
|
+
import { withCapturedBindingFile } from "./captured-binding-file.mjs";
|
|
70
|
+
import { validateCapturedSourceReceipt, withCapturedSourceReceiptFile } from "./captured-source-receipt-file.mjs";
|
|
71
|
+
export { withCapturedBindingFile, validateCapturedSourceReceipt, withCapturedSourceReceiptFile };
|
|
72
|
+
import { executableSurfaceOf, hasExecutableSurface } from "./capability-execution.mjs";
|
|
73
|
+
import { createCapabilityMaterializer } from "./package-materialization.mjs";
|
|
74
|
+
import { resolvePackageClosure } from "./package-closure.mjs";
|
|
75
|
+
import { canonicalJson, parseStrictJson, decodeUtf8 } from "./portable-values.mjs";
|
|
76
|
+
import { treeIntegrity } from "./portable-digest.mjs";
|
|
77
|
+
import { readPortableBytes } from "./portable-files.mjs";
|
|
78
|
+
import { capabilityArtifactIntegrity, copyTreeSafe, removeOwnedStaging } from "./artifact-tree.mjs";
|
|
79
|
+
import {
|
|
80
|
+
CAPABILITIES_DIRNAME, INSTALLED_SUBDIR,
|
|
81
|
+
isMaterializedCapabilityId, capabilityIdViolation, CAPABILITY_INSTALLATION_FILE,
|
|
82
|
+
normalizePackagePath, parseLockSource, PACKAGE_ID_RE,
|
|
83
|
+
validateLockEntry, validateCapabilityLockEntry, verifyCapabilityInstallation,
|
|
84
|
+
} from "./capability-provenance.mjs";
|
|
85
|
+
import { decodeLegacyLockBytes, legacyCapabilityEntryViolation } from "./legacy-lock-codec.mjs";
|
|
86
|
+
// Keep the existing public core surface; internal data helpers are not re-exported.
|
|
87
|
+
export { oatsError } from "./errors.mjs";
|
|
88
|
+
export { legacyCapabilityEntryViolation } from "./legacy-lock-codec.mjs";
|
|
89
|
+
export { capabilityArtifactIntegrity, copyTreeSafe } from "./artifact-tree.mjs";
|
|
90
|
+
export {
|
|
91
|
+
CAPABILITIES_DIRNAME, INSTALLED_SUBDIR, CAPABILITY_ID_RE,
|
|
92
|
+
isMaterializedCapabilityId, capabilityIdViolation, CAPABILITY_INSTALLATION_FILE,
|
|
93
|
+
normalizePackagePath, validateLockEntry, validateCapabilityLockEntry,
|
|
94
|
+
verifyCapabilityInstallation,
|
|
95
|
+
} from "./capability-provenance.mjs";
|
|
96
|
+
|
|
43
97
|
export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
|
|
44
98
|
/** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
|
|
45
99
|
* must satisfy, so the retry cannot skip Git cleanup on an unrecognised value. */
|
|
@@ -711,16 +765,20 @@ function bindingObject(value) {
|
|
|
711
765
|
function hookDeclaration(value) {
|
|
712
766
|
if (typeof value === "string") return { command: value, required: false };
|
|
713
767
|
if (value && typeof value === "object" && typeof value.command === "string") {
|
|
714
|
-
return { command: value.command, required: value.required === true };
|
|
768
|
+
return { command: value.command, required: value.required === true, ...(Object.hasOwn(value, "inputs") ? { inputs: value.inputs } : {}) };
|
|
715
769
|
}
|
|
716
770
|
return undefined;
|
|
717
771
|
}
|
|
772
|
+
function manifestHookDeclarations(manifest) {
|
|
773
|
+
return Object.fromEntries(Object.entries(manifest?.hooks || {}).flatMap(([event, value]) => {
|
|
774
|
+
const declaration = hookDeclaration(value);
|
|
775
|
+
return APPROVED_HOOKS.has(event) && declaration ? [[event, declaration.command]] : [];
|
|
776
|
+
}));
|
|
777
|
+
}
|
|
718
778
|
function manifestHookCommands(manifest) {
|
|
719
779
|
const out = {};
|
|
720
|
-
for (const [ev,
|
|
721
|
-
const
|
|
722
|
-
if (!APPROVED_HOOKS.has(ev) || !decl) continue;
|
|
723
|
-
const [script, ...args] = decl.command.split(/\s+/);
|
|
780
|
+
for (const [ev, command] of Object.entries(manifestHookDeclarations(manifest))) {
|
|
781
|
+
const [script, ...args] = command.split(/\s+/);
|
|
724
782
|
const abs = manifestPath(manifest, script);
|
|
725
783
|
if (abs) out[ev] = ["node", shq(abs), ...args].join(" ");
|
|
726
784
|
}
|
|
@@ -849,11 +907,7 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
849
907
|
if (![...enabledValues][0]) continue;
|
|
850
908
|
// A declared value set is enforced for an ACTIVE capability: a misspelling
|
|
851
909
|
// must not switch a conditional requirement off by matching nothing.
|
|
852
|
-
|
|
853
|
-
if (decl && typeof decl === "object" && Array.isArray(decl.values) && settings[key] !== undefined && !decl.values.some((v) => String(v) === String(settings[key]))) {
|
|
854
|
-
throw new Error(`capability setting ${id}.${key} is ${JSON.stringify(settings[key])}, not one of ${decl.values.map((v) => JSON.stringify(v)).join(", ")}`);
|
|
855
|
-
}
|
|
856
|
-
}
|
|
910
|
+
assertCapabilitySettingValues(manifest, settings);
|
|
857
911
|
const compatibility = capabilityCompatibility(manifest);
|
|
858
912
|
if (!compatibility.compatible) throw new Error(`capability "${id}" requires OATS ${compatibility.range}; running ${compatibility.version}`);
|
|
859
913
|
const trust = capabilityTrust(manifest, contextDir);
|
|
@@ -988,33 +1042,10 @@ const OATS_HOME_DIR = process.env.OATS_HOME_DIR || join(homedir(), ".oats");
|
|
|
988
1042
|
/** Legacy pre-v0.8 laptop acquisition root — kept only so doctor can warn about it. */
|
|
989
1043
|
export const LEGACY_HOME_CAPABILITIES_DIR = join(OATS_HOME_DIR, "capabilities");
|
|
990
1044
|
export const OATS_LOCK_FILE = "oats-lock.json";
|
|
991
|
-
/** Scope-relative capability store subtrees. */
|
|
992
|
-
export const CAPABILITIES_DIRNAME = join(".agents", "capabilities");
|
|
993
|
-
export const INSTALLED_SUBDIR = "installed";
|
|
994
1045
|
export const OWNED_SUBDIR = "owned";
|
|
995
1046
|
export const installedCapabilitiesDir = (level) => join(level, CAPABILITIES_DIRNAME, INSTALLED_SUBDIR);
|
|
996
1047
|
export const ownedCapabilitiesDir = (level) => join(level, CAPABILITIES_DIRNAME, OWNED_SUBDIR);
|
|
997
1048
|
|
|
998
|
-
/** THE identity grammar for a MATERIALIZED (revised-v2) capability.
|
|
999
|
-
*
|
|
1000
|
-
* A materialized capability id is not merely a label: it becomes a DIRECTORY
|
|
1001
|
-
* NAME directly under `installed/`, so anything that can steer a filesystem
|
|
1002
|
-
* join must be impossible before the join happens. The grammar is deliberately
|
|
1003
|
-
* the package-id grammar — namespaced dots are fine, and `/`, `\`, `..`,
|
|
1004
|
-
* absolute forms, `@`, and percent-encoded spellings are all outside it.
|
|
1005
|
-
*
|
|
1006
|
-
* The LEGACY v1 / owned / `from: path:` grammar (loadManifestAt) stays looser
|
|
1007
|
-
* on purpose: those artifacts are named by `basename()` of their source, never
|
|
1008
|
-
* by the declared id, so the id never reaches a path there. Tightening it would
|
|
1009
|
-
* strand already-published standalone capabilities. */
|
|
1010
|
-
export const CAPABILITY_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
1011
|
-
export const isMaterializedCapabilityId = (id) => typeof id === "string" && CAPABILITY_ID_RE.test(id);
|
|
1012
|
-
/** Why a capability id was refused, in one sentence, for every caller's own
|
|
1013
|
-
* typed error (the lock parser raises invalid-lock, manifest validation raises
|
|
1014
|
-
* invalid-package-manifest — the code each consumer already branches on). */
|
|
1015
|
-
export const capabilityIdViolation = (id) =>
|
|
1016
|
-
`${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}/)`;
|
|
1017
|
-
|
|
1018
1049
|
const PORTABLE_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
1019
1050
|
const CAPABILITY_ENV_ID_RE = /^[a-z][a-z0-9]*\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
1020
1051
|
const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
|
|
@@ -1108,17 +1139,18 @@ export function stripInternalAnnotations(parsed) {
|
|
|
1108
1139
|
return out;
|
|
1109
1140
|
}
|
|
1110
1141
|
|
|
1111
|
-
function loadManifestAt(idir, origin) {
|
|
1142
|
+
function loadManifestAt(idir, origin, { strict = false } = {}) {
|
|
1112
1143
|
const mf = join(idir, "oats.json");
|
|
1113
1144
|
if (!existsSync(mf)) return undefined;
|
|
1114
1145
|
let raw;
|
|
1115
|
-
try { raw = JSON.parse(readFileSync(mf, "utf8")); }
|
|
1116
|
-
catch (e) { throw new Error(`invalid capability manifest JSON ${mf}: ${e.message}`); }
|
|
1146
|
+
try { raw = strict ? parseStrictJson(readPortableBytes(mf)) : JSON.parse(readFileSync(mf, "utf8")); }
|
|
1147
|
+
catch (e) { if (strict) throw e; throw new Error(`invalid capability manifest JSON ${mf}: ${e.message}`); }
|
|
1117
1148
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`capability manifest ${mf} must be a JSON object (got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw})`);
|
|
1118
1149
|
// BEFORE any validation or annotation: the artifact declares capability DATA,
|
|
1119
1150
|
// never kernel annotations about itself.
|
|
1120
1151
|
const m = stripInternalAnnotations(raw);
|
|
1121
1152
|
const id = validateCapabilityManifest(m, mf);
|
|
1153
|
+
validateCapabilityInputDeclarations(m);
|
|
1122
1154
|
if (!m.version || !m.description) throw new Error(`capability ${id} manifest needs version and description`);
|
|
1123
1155
|
const targetFields = ["global", "groups", "souls", "targets"].filter((key) => Object.prototype.hasOwnProperty.call(m, key));
|
|
1124
1156
|
if (targetFields.length) throw new Error(`capability ${id} manifest cannot declare config-owned targets: ${targetFields.join(", ")}`);
|
|
@@ -1126,6 +1158,7 @@ function loadManifestAt(idir, origin) {
|
|
|
1126
1158
|
if (m.command && !/^[a-z0-9][a-z0-9-]*$/.test(m.command)) throw new Error(`capability ${id} has invalid command namespace "${m.command}"`);
|
|
1127
1159
|
for (const [hook, value] of Object.entries(m.hooks || {})) {
|
|
1128
1160
|
if (!APPROVED_HOOKS.has(hook)) throw new Error(`capability ${id} declares unsupported hook "${hook}"`);
|
|
1161
|
+
if (value && typeof value === "object") objectAt(value, ["command", "required", "inputs"], ["command"]);
|
|
1129
1162
|
if (!hookDeclaration(value)) throw new Error(`capability ${id} hook "${hook}" must be a command string or { command, required }`);
|
|
1130
1163
|
if (value && typeof value === "object" && value.required !== undefined && typeof value.required !== "boolean") {
|
|
1131
1164
|
throw new Error(`capability ${id} hook "${hook}": "required" must be a boolean`);
|
|
@@ -1136,9 +1169,322 @@ function loadManifestAt(idir, origin) {
|
|
|
1136
1169
|
}
|
|
1137
1170
|
if (m.agents !== undefined && (!Array.isArray(m.agents) || m.agents.some((a) => typeof a !== "string"))) throw new Error(`capability ${id} "agents" must be an array of package-relative soul directories`);
|
|
1138
1171
|
validateManifestOperations(m, id);
|
|
1172
|
+
validateBindingInterface(m);
|
|
1139
1173
|
return { ...m, _dir: idir, _origin: origin };
|
|
1140
1174
|
}
|
|
1141
1175
|
|
|
1176
|
+
function assertCapabilitySettingValues(manifest, settings) {
|
|
1177
|
+
for (const [key, decl] of Object.entries(manifest.settings || {})) {
|
|
1178
|
+
if (decl && typeof decl === "object" && Array.isArray(decl.values) && Object.hasOwn(settings, key) && settings[key] !== undefined && !decl.values.some((v) => String(v) === String(settings[key]))) {
|
|
1179
|
+
throw new Error(`capability setting ${manifest.capability}.${key} is ${JSON.stringify(settings[key])}, not one of ${decl.values.map((v) => JSON.stringify(v)).join(", ")}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function loadRetainedManifest(root) {
|
|
1185
|
+
const manifest = loadManifestAt(root, "captured", { strict: true });
|
|
1186
|
+
if (!manifest) throw oatsError("resolution-incomplete", "retained capability manifest is absent");
|
|
1187
|
+
assertCapabilitySelfContained(root, manifest);
|
|
1188
|
+
const compatible = capabilityCompatibility(manifest);
|
|
1189
|
+
if (!compatible.compatible) throw oatsError("capability-incompatible", `captured capability requires OATS ${compatible.range}`);
|
|
1190
|
+
return manifest;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
export function approveAvailableCapability(deployment, artifactSet, capability, origin) {
|
|
1194
|
+
return approveAvailableArtifact(deployment, artifactSet, capability, origin, loadRetainedManifest);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Explicit new-work preparation, with one frozen repository transaction.
|
|
1198
|
+
* This first adapter captures command/curriculum profiles; provider-owned
|
|
1199
|
+
* binding or distinct helper policy gaps return needs-configuration, never
|
|
1200
|
+
* a fabricated complete record. No lifecycle side effects run here. */
|
|
1201
|
+
export function prepareCapturedComposition(input, { repositoryOptions = {} } = {}) {
|
|
1202
|
+
canonicalJson(input);
|
|
1203
|
+
objectAt(input, ["deployment", "source", "origin", "workspace", "member", "operator", "mode", "allowLocalPaths", "standaloneContextKey", "launch", "helperLaunches"], ["deployment", "source"]);
|
|
1204
|
+
if (input.launch !== undefined) validateCapturedLaunchRequest(input.launch, validateLaunchConfig);
|
|
1205
|
+
if (input.helperLaunches !== undefined) {
|
|
1206
|
+
objectAt(input.helperLaunches, null, []);
|
|
1207
|
+
for (const request of Object.values(input.helperLaunches)) validateCapturedLaunchRequest(request, validateLaunchConfig);
|
|
1208
|
+
}
|
|
1209
|
+
if (input.mode !== undefined && !WORK_MODES.includes(input.mode)) throw oatsError("invalid-declaration", "invalid preparation work mode");
|
|
1210
|
+
const deployment = portableScope(input.deployment);
|
|
1211
|
+
const previous = readLock3(deployment); // Old state refuses before scratch/fetch.
|
|
1212
|
+
const directory = mkdtempSync(join(portableStateDirectory(deployment, true), ".prepare-")), owned = lstatSync(directory);
|
|
1213
|
+
const origin = input.origin ?? { kind: "operator", document: { kind: "operator", id: "oats-prepare" }, pointer: "/source" };
|
|
1214
|
+
let repositories, primary, catalog;
|
|
1215
|
+
try {
|
|
1216
|
+
repositories = createRepositoryTransaction({ ...repositoryOptions, directory, accessContextKey: repositoryOptions.accessContextKey ?? "native" });
|
|
1217
|
+
return prepareComposition({ ...input, deployment, origin, directory }, { repositories, previous, kernel: {
|
|
1218
|
+
loadPackageManifestAt, capabilityCompatibility, assertPlatformInvariantLocks, materializeCapability,
|
|
1219
|
+
manifest: loadRetainedManifest,
|
|
1220
|
+
binding: runCapturedProviderBinding,
|
|
1221
|
+
catalog(id, selector) {
|
|
1222
|
+
catalog ??= officialPackageCatalog();
|
|
1223
|
+
if (!Object.hasOwn(catalog, id)) throw oatsError("invalid-source", "unknown package dependency catalog identity");
|
|
1224
|
+
return { ...catalog[id], ...(selector === undefined ? {} : { ref: selector }) };
|
|
1225
|
+
},
|
|
1226
|
+
complete: data => completePreparedResources(data, { root: PKG_ROOT, workModes: WORK_MODES,
|
|
1227
|
+
settings: assertCapabilitySettingValues, skills: skillEntriesIn, hooks: manifestHookDeclarations,
|
|
1228
|
+
validateLaunchConfig, runtimeRequirements: applicableRequirements }),
|
|
1229
|
+
} });
|
|
1230
|
+
} catch (error) { primary = error; throw error; }
|
|
1231
|
+
finally {
|
|
1232
|
+
try {
|
|
1233
|
+
repositories?.close();
|
|
1234
|
+
const current = lstatSync(directory);
|
|
1235
|
+
if (!current.isDirectory() || current.dev !== owned.dev || current.ino !== owned.ino) throw oatsError("source-incomplete", "preparation scratch ownership changed");
|
|
1236
|
+
removeOwnedStaging(directory);
|
|
1237
|
+
} catch (cleanup) {
|
|
1238
|
+
if (!primary) throw cleanup;
|
|
1239
|
+
const error = new AggregateError([primary, cleanup], "preparation and owned scratch cleanup failed", { cause: primary });
|
|
1240
|
+
error.code = primary.code; error.stagingPath = directory; throw error;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Load an exact record for one action, using the existing COMPLETE kernel
|
|
1246
|
+
* manifest codecs. No scoped config, mutable install or marketplace fallback.
|
|
1247
|
+
* Launch/lifecycle adoption and provider qualification are separate integration
|
|
1248
|
+
* steps; unsupported actions refuse rather than switching to legacy dispatch. */
|
|
1249
|
+
function capturedCapabilityCodecs() {
|
|
1250
|
+
return {
|
|
1251
|
+
manifest: loadRetainedManifest,
|
|
1252
|
+
settings: assertCapabilitySettingValues,
|
|
1253
|
+
host: ({ manifest }) => (manifest.requires || []).filter((requirement) => requirement.command && !which(requirement.command)),
|
|
1254
|
+
executable(manifest, relativePath) {
|
|
1255
|
+
const file = capabilityExecutablePath(manifest, relativePath);
|
|
1256
|
+
if (!file || !statSync(file).isFile()) throw oatsError("resource-not-found", "captured executable is not a regular file");
|
|
1257
|
+
return realpathSync(file);
|
|
1258
|
+
},
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** Exact prospective/captured binding phases; no partial record or ambient provider. */
|
|
1263
|
+
export function runCapturedProviderBinding(options) {
|
|
1264
|
+
return invokeProviderBinding(options, capturedCapabilityCodecs());
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function capturedDispatchCodecs(options, { admitting = false } = {}) {
|
|
1268
|
+
return {
|
|
1269
|
+
...capturedCapabilityCodecs(),
|
|
1270
|
+
launch(recipe) {
|
|
1271
|
+
assertLaunchRecipe(recipe, "captured resolution");
|
|
1272
|
+
validateLaunchConfig("captured", { runtime: recipe.runtime, executable: recipe.executable, args: recipe.args, env: recipe.env,
|
|
1273
|
+
...(recipe.model != null ? { model: recipe.model } : {}), ...(recipe.yolo !== undefined ? { yolo: recipe.yolo } : {}) }, "captured resolution");
|
|
1274
|
+
},
|
|
1275
|
+
operations: manifestOperations,
|
|
1276
|
+
hooks: manifestHookDeclarations,
|
|
1277
|
+
invocation(base, action, capability) {
|
|
1278
|
+
return buildCapturedInvocationContext({ loaded: { ...base, capability }, action,
|
|
1279
|
+
instance: options.invocationTarget ?? null, intent: options.intent ?? null,
|
|
1280
|
+
...(Object.hasOwn(options, "priorReceipt") ? { priorReceipt: options.priorReceipt } : {}) });
|
|
1281
|
+
},
|
|
1282
|
+
bindings(bindings, capability, record, invocation) {
|
|
1283
|
+
// Admission is static authorization to ATTEMPT, not readiness or effects.
|
|
1284
|
+
// The normal loader still checks readiness with the saved intent before execution.
|
|
1285
|
+
if (admitting) return;
|
|
1286
|
+
for (const [slot, binding] of bindings) {
|
|
1287
|
+
if (capability.manifest.layer !== slot) throw oatsError("invalid-resolution", "captured binding slot differs from the manifest owner");
|
|
1288
|
+
const result = runCapturedProviderBinding({ deployment: options.deployment, artifacts: record.artifacts,
|
|
1289
|
+
capability: capability.id, phase: "check", settings: capability.settings,
|
|
1290
|
+
input: { binding, context: record.context, action: options.action, ...(invocation ? { invocation } : {}) } });
|
|
1291
|
+
if (result.status !== "ready") throw oatsError(result.problems[0]?.code ?? "provider-not-qualified", "captured provider is not ready for this action");
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
export function loadCapturedDispatch(options) {
|
|
1298
|
+
return loadCapturedAction(options, capturedDispatchCodecs(options));
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** Admit against exact retained action/approval and current owned incarnation.
|
|
1302
|
+
* No provider phase runs here; readiness/execution consume the persisted intent. */
|
|
1303
|
+
export function admitCapturedAction({ deployment, resolution, home, action, input = {}, retryExecutionId, ...rest }) {
|
|
1304
|
+
canonicalJson(rest); objectAt(rest, ["priorReceipt"], []);
|
|
1305
|
+
const metadata = readCapturedInstanceMetadata(home);
|
|
1306
|
+
const options = { deployment, resolution, action,
|
|
1307
|
+
invocationTarget: { home, work: join(home, "work"), name: metadata.instance, agent: metadata.agent },
|
|
1308
|
+
...(Object.hasOwn(rest, "priorReceipt") ? { priorReceipt: rest.priorReceipt } : {}) };
|
|
1309
|
+
const loaded = loadCapturedAction(options, capturedDispatchCodecs(options, { admitting: true }));
|
|
1310
|
+
if (!loaded.capability) throw oatsError("unsupported-action", "inspection/composition does not admit mutation");
|
|
1311
|
+
return admitCapturedInstanceAction({ deployment, home, executionBinding: loaded.invocation.executionBinding,
|
|
1312
|
+
capability: loaded.capability.id, action, input, ...(retryExecutionId !== undefined ? { retryExecutionId } : {}) });
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Static callable API availability, not instance/provider/host readiness.
|
|
1316
|
+
* This descriptor never probes a backend or admits a native request. */
|
|
1317
|
+
export function capturedNativeSessionAvailability() {
|
|
1318
|
+
return { schemaVersion: 1, api: { contract: "oats.captured-session", version: 2, available: true, backends: ["tmux", "herdr"] }, readiness: { status: "not-checked" } };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** Resolve only a source record's explicit helper edge. This is static retained
|
|
1322
|
+
* authority inspection, never helper-name discovery, preparation or launch. */
|
|
1323
|
+
export function resolveCapturedHelper(input) {
|
|
1324
|
+
canonicalJson(input);
|
|
1325
|
+
objectAt(input, ["executionBinding", "helper", "name"], ["executionBinding", "helper"]);
|
|
1326
|
+
validateExecutionBinding(input.executionBinding);
|
|
1327
|
+
if (typeof input.helper !== "string" || !input.helper || input.helper.includes("\0")) throw oatsError("invalid-declaration", "helper must be an exact captured helper-map key");
|
|
1328
|
+
if (Object.hasOwn(input, "name") && (typeof input.name !== "string" || !input.name)) throw oatsError("invalid-declaration", "expected helper name must be non-empty text");
|
|
1329
|
+
const deployment = portableScope(input.executionBinding.deployment);
|
|
1330
|
+
const source = loadCapturedDispatch({ deployment, resolution: input.executionBinding.resolution, action: { kind: "inspect" } });
|
|
1331
|
+
if (!Object.hasOwn(source.record.helpers, input.helper)) throw oatsError("helper-not-selected", "source resolution does not select that helper key");
|
|
1332
|
+
const resolution = source.record.helpers[input.helper];
|
|
1333
|
+
const helper = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
|
|
1334
|
+
if (helper.record.subject.kind !== "helper") throw oatsError("invalid-resolution", "captured helper edge does not name a dedicated helper");
|
|
1335
|
+
if (input.name !== undefined && input.name !== helper.record.subject.name) throw oatsError("invalid-resolution", "captured helper name differs from requested name");
|
|
1336
|
+
const responsibleHuman = helper.record.messagingChoice.enabled ? helper.record.messagingChoice.privateKey.human : null;
|
|
1337
|
+
const sourceHuman = source.record.messagingChoice.enabled ? source.record.messagingChoice.privateKey.human : null;
|
|
1338
|
+
if (canonicalJson(source.record.context) !== canonicalJson(helper.record.context) || canonicalJson(sourceHuman) !== canonicalJson(responsibleHuman)) {
|
|
1339
|
+
throw oatsError("needs-configuration", "distinct helper context/human requires an explicit captured helper-request policy; no implicit inheritance was used");
|
|
1340
|
+
}
|
|
1341
|
+
if (!helper.record.dispatch.composition) throw oatsError("resolution-incomplete", "selected helper has no captured curriculum");
|
|
1342
|
+
return {
|
|
1343
|
+
schemaVersion: 1,
|
|
1344
|
+
sourceExecutionBinding: { schemaVersion: 1, deployment, resolution: source.resolution },
|
|
1345
|
+
executionBinding: { schemaVersion: 1, deployment, resolution },
|
|
1346
|
+
helper: { key: input.helper, name: helper.record.subject.name, subject: helper.record.subject },
|
|
1347
|
+
context: helper.record.context, responsibleHuman, workMode: helper.record.dispatch.composition.mode,
|
|
1348
|
+
launchSelection: helper.record.dispatch.launch === null ? null : {
|
|
1349
|
+
runtime: helper.record.dispatch.launch.runtime, model: helper.record.dispatch.launch.model,
|
|
1350
|
+
},
|
|
1351
|
+
launch: capturedNativeSessionAvailability(),
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** Bounded scaffold-only adoption of one exact captured composition. Placement
|
|
1356
|
+
* is explicit and launch/hooks remain pending; unsupported work modes refuse. */
|
|
1357
|
+
export function scaffoldCapturedInstance({ deployment, resolution, home, instance }) {
|
|
1358
|
+
const loaded = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
|
|
1359
|
+
const blocked = loaded.approvals.filter((entry) => !["approved", "not-required"].includes(entry.status));
|
|
1360
|
+
if (blocked.length) throw oatsError("approval-required", "captured scaffold needs approval for every selected executable artifact", blocked);
|
|
1361
|
+
validateCapturedDirectoryTarget(home, instance);
|
|
1362
|
+
if (loaded.record.dispatch.composition?.mode !== "directory") throw oatsError("needs-configuration", "captured scaffold currently requires retained directory work mode");
|
|
1363
|
+
if (existsSync(home)) throw oatsError("E_INSTANCE_EXISTS", `instance home already exists: ${home}`);
|
|
1364
|
+
// Native evidence is initialized only for this newly created incarnation.
|
|
1365
|
+
// Existing sidecars are retained obligations, never silent history backfill.
|
|
1366
|
+
const history = nativeHistoryPath(home), baseline = retirementBaselinePath(home);
|
|
1367
|
+
const parents = [dirname(history), dirname(dirname(baseline)), dirname(baseline)];
|
|
1368
|
+
const parentGuard = (create = false) => {
|
|
1369
|
+
for (const path of parents) {
|
|
1370
|
+
if (create && !existsSync(path)) mkdirSync(path, { mode: 0o700 });
|
|
1371
|
+
if (existsSync(path) && !lstatSync(path).isDirectory()) throw oatsError("integrity-drift", "native custody parent is not an owned directory");
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
parentGuard();
|
|
1375
|
+
if (existsSync(history) || existsSync(baseline)) throw oatsError("migration-required", "native custody already exists at this home address; preserve it and select a fresh home");
|
|
1376
|
+
const result = materializeCapturedDirectoryScaffold({ home, instance, loaded });
|
|
1377
|
+
try {
|
|
1378
|
+
const original = readCapturedInstanceAuthority(deployment, home).row;
|
|
1379
|
+
const assertRoots = () => { assertCapturedInstanceCustody(original); parentGuard(); };
|
|
1380
|
+
assertRoots(); parentGuard(true); initializeNativeHistory(home);
|
|
1381
|
+
writeRetirementBaseline(home, join(home, "work"), "directory", {}, [], { launched: false },
|
|
1382
|
+
{ exclusive: true, incarnationId: original.incarnationId, executionBinding: original.executionBinding });
|
|
1383
|
+
assertRoots();
|
|
1384
|
+
const metadata = readCapturedInstanceMetadata(home);
|
|
1385
|
+
const proof = { schemaVersion: 1, directories: { historyRoot: directoryIdentity(dirname(history)), history: directoryIdentity(history),
|
|
1386
|
+
retirementRoot: directoryIdentity(dirname(dirname(baseline))), baselines: directoryIdentity(dirname(baseline)) } };
|
|
1387
|
+
atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...metadata, captured: { ...metadata.captured, nativeScaffold: proof } }), { assertRoots });
|
|
1388
|
+
markCapturedNativeScaffold(deployment, home, proof);
|
|
1389
|
+
return result;
|
|
1390
|
+
} catch (error) { error.home = home; error.cleanupRequired = true; throw error; }
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/** Run retained spawn hooks for an exact scaffold. Required-hook failure retains
|
|
1394
|
+
* the home and its receipts for explicit compensation; launch stays pending. */
|
|
1395
|
+
export function activateCapturedScaffold(options) {
|
|
1396
|
+
let { deployment, resolution, home, extraEnv = {} } = options;
|
|
1397
|
+
home = resolve(home);
|
|
1398
|
+
const file = join(home, "instance.json");
|
|
1399
|
+
let metadata = readCapturedInstanceMetadata(home);
|
|
1400
|
+
const original = readCapturedInstanceAuthority(deployment, home).row;
|
|
1401
|
+
const retry = Object.hasOwn(options, "retryIntents"), retryIntents = retry ? options.retryIntents : {};
|
|
1402
|
+
canonicalJson(retryIntents); objectAt(retryIntents, null, []);
|
|
1403
|
+
const savedIntents = {};
|
|
1404
|
+
for (const intent of original.intents.filter(entry => entry.action.kind === "hook" && entry.action.name === "spawn")) {
|
|
1405
|
+
if (Object.hasOwn(savedIntents, intent.capability)) throw oatsError("invalid-resolution", "multiple spawn requests need explicit reconciliation, not inferred retry selection");
|
|
1406
|
+
savedIntents[intent.capability] = { schemaVersion: 1, executionId: intent.executionId, incarnationId: original.incarnationId, attempt: intent.attempt };
|
|
1407
|
+
}
|
|
1408
|
+
for (const [id, intent] of Object.entries(metadata.captured.hookIntents || {})) {
|
|
1409
|
+
if (savedIntents[id]?.executionId !== intent.executionId || intent.incarnationId !== original.incarnationId) throw oatsError("invalid-resolution", "saved spawn reference differs from indexed authority");
|
|
1410
|
+
}
|
|
1411
|
+
if (retry) {
|
|
1412
|
+
const expected = Object.fromEntries(Object.entries(savedIntents).map(([id, intent]) => [id, intent.executionId]));
|
|
1413
|
+
if (canonicalJson(expected) !== canonicalJson(retryIntents)) throw oatsError("invalid-resolution", "spawn retry must name every indexed hook intent exactly; no partial implicit new requests");
|
|
1414
|
+
} else if (Object.keys(savedIntents).length) throw oatsError("invalid-resolution", "existing spawn obligations require an explicit retry");
|
|
1415
|
+
const priorStatus = retry ? metadata.captured.lifecycle : "scaffolded-hooks-pending";
|
|
1416
|
+
if (retry && !["spawn-failed-cleanup-required", "spawned-cleanup-required"].includes(priorStatus)) throw oatsError("invalid-resolution", "captured scaffold is not awaiting activation retry");
|
|
1417
|
+
const inspected = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
|
|
1418
|
+
const composed = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
|
|
1419
|
+
const subject = inspected.record.subject, expectedAgent = subject.kind === "persistent" ? subject.soul.alias : subject.name;
|
|
1420
|
+
const expectedHuman = inspected.record.messagingChoice.enabled ? inspected.record.messagingChoice.privateKey.human : null;
|
|
1421
|
+
let sameDeployment = false;
|
|
1422
|
+
try { sameDeployment = portableScope(metadata.executionBinding?.deployment) === portableScope(deployment); } catch { /* refused below */ }
|
|
1423
|
+
if (metadata.home !== home || metadata.instance !== basename(home) || metadata.agent !== expectedAgent
|
|
1424
|
+
|| metadata.captured?.lifecycle !== priorStatus || metadata.executionBinding?.resolution?.id !== resolution.id
|
|
1425
|
+
|| !sameDeployment || canonicalJson(metadata.responsibleHuman) !== canonicalJson(expectedHuman)) {
|
|
1426
|
+
throw oatsError("invalid-resolution", "captured scaffold metadata differs from the requested resolution or home");
|
|
1427
|
+
}
|
|
1428
|
+
const assertRoots = () => {
|
|
1429
|
+
const row = assertCapturedInstanceCustody(original);
|
|
1430
|
+
if (row.status !== "spawn-hooks-running") throw oatsError("selection-changed", "activation no longer owns the indexed lifecycle state");
|
|
1431
|
+
return row;
|
|
1432
|
+
};
|
|
1433
|
+
const reportFailure = (error, observed = {}) => {
|
|
1434
|
+
const facts = { meta: observed.meta || {}, intents: observed.intents || {} };
|
|
1435
|
+
let report, reportingFailure;
|
|
1436
|
+
try { report = recordCapturedCustodyFailure(original, { ...facts, code: error.code || "E_CAPTURED_PUBLICATION", message: error.message }); }
|
|
1437
|
+
catch (failure) { reportingFailure = { code: failure.code || "E_CAPTURED_CUSTODY", message: String(failure.message).slice(0, 200) }; }
|
|
1438
|
+
// Independent ledger first; if that too is held, propagate the observed
|
|
1439
|
+
// receipt through the existing caller error channel, not the foreign home.
|
|
1440
|
+
error.home = home;
|
|
1441
|
+
error.capturedCustody = { incarnationId: original.incarnationId, executionBinding: original.executionBinding,
|
|
1442
|
+
...facts, ...(report ? { report } : {}), ...(reportingFailure ? { reportingFailure } : {}) };
|
|
1443
|
+
throw error;
|
|
1444
|
+
};
|
|
1445
|
+
const publish = (next, status, observed = {}) => {
|
|
1446
|
+
try {
|
|
1447
|
+
atomicWriteFileSync(file, canonicalJson(next), { assertRoots });
|
|
1448
|
+
assertRoots();
|
|
1449
|
+
setCapturedInstanceStatus(deployment, home, status, { expectedStatus: "spawn-hooks-running" });
|
|
1450
|
+
} catch (error) { reportFailure(error, observed); }
|
|
1451
|
+
};
|
|
1452
|
+
setCapturedInstanceStatus(deployment, home, "spawn-hooks-running", { expectedStatus: priorStatus });
|
|
1453
|
+
let hooks;
|
|
1454
|
+
try {
|
|
1455
|
+
hooks = runCapturedLifecycleHooks("spawn", { deployment, resolution, home, instance: metadata.instance, agentName: expectedAgent, retryIntents, assertRoots,
|
|
1456
|
+
extraEnv: { ...extraEnv, OATS_WORK: "directory", OATS_KIND: subject.kind } });
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
const failure = { capability: "oats.kernel", event: "spawn", message: String(error.message || error).slice(0, 200), required: true, unconfirmed: true };
|
|
1459
|
+
metadata = { ...metadata, capabilityMeta: { ...metadata.capabilityMeta, ...error.capturedHooks?.meta }, captured: { ...metadata.captured,
|
|
1460
|
+
lifecycle: "spawn-failed-cleanup-required", hookOrder: error.capturedHooks?.order || [], hookFailures: [failure],
|
|
1461
|
+
hookIntents: { ...savedIntents, ...error.capturedHooks?.intents } } };
|
|
1462
|
+
publish(metadata, "spawn-failed-cleanup-required", error.capturedHooks);
|
|
1463
|
+
error.home = home; throw error;
|
|
1464
|
+
}
|
|
1465
|
+
const required = hooks.failures.filter((failure) => failure.required);
|
|
1466
|
+
let unsettled;
|
|
1467
|
+
try {
|
|
1468
|
+
const row = readCapturedInstanceIndex(deployment).instances.find(row => row.incarnationId === original.incarnationId);
|
|
1469
|
+
if (!row) throw oatsError("selection-changed", "captured activation is no longer indexed");
|
|
1470
|
+
unsettled = row.intents.some(intent => intent.state !== "completed");
|
|
1471
|
+
} catch (error) { reportFailure(error, hooks); }
|
|
1472
|
+
// Optional functionality can fail without a required-hook error, but custody
|
|
1473
|
+
// remains explicitly nonterminal and the saved-map retry route stays usable.
|
|
1474
|
+
const status = required.length ? "spawn-failed-cleanup-required" : unsettled ? "spawned-cleanup-required" : "spawned-launch-pending";
|
|
1475
|
+
hooks.intents = { ...savedIntents, ...hooks.intents };
|
|
1476
|
+
metadata = { ...metadata, capabilityMeta: { ...metadata.capabilityMeta, ...hooks.meta }, captured: { ...metadata.captured,
|
|
1477
|
+
lifecycle: status, hookOrder: hooks.order, hookFailures: hooks.failures, hookIntents: hooks.intents } };
|
|
1478
|
+
publish(metadata, status, hooks);
|
|
1479
|
+
if (required.length) {
|
|
1480
|
+
const error = oatsError("E_REQUIRED_HOOK_FAILED", "captured spawn hook failed; home and receipts are retained for explicit cleanup", required);
|
|
1481
|
+
error.home = home; throw error;
|
|
1482
|
+
}
|
|
1483
|
+
return { home, instance: metadata.instance, agent: expectedAgent, incarnationId: metadata.incarnationId, executionBinding: metadata.executionBinding,
|
|
1484
|
+
responsibleHuman: expectedHuman, launched: false, hooks, launchPending: true, functionalReady: true,
|
|
1485
|
+
hooksPending: unsettled, cleanupRequired: unsettled };
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1142
1488
|
/** `operations`: what a capability offers a GUI or scheduler by name, each
|
|
1143
1489
|
* delegating to one of its own `commands`. kind "action" runs something;
|
|
1144
1490
|
* kind "view" answers { documents: [...] } for presentation (a knowledge
|
|
@@ -1251,40 +1597,6 @@ export function capabilityManifest(name, startDir) {
|
|
|
1251
1597
|
return capabilityManifests(startDir)[name];
|
|
1252
1598
|
}
|
|
1253
1599
|
|
|
1254
|
-
/** Recursively copy a tree the way `cpSync(..., { recursive: true })` would —
|
|
1255
|
-
* except catchably.
|
|
1256
|
-
*
|
|
1257
|
-
* Node 22's recursive `cpSync` performs its recursion in native code, and on
|
|
1258
|
-
* macOS an unreadable directory inside the tree surfaces as an uncaught libc++
|
|
1259
|
-
* `filesystem_error` that TERMINATES THE PROCESS. No JS `catch` or `finally`
|
|
1260
|
-
* runs, so a transaction using it can never clean up staging or roll back the
|
|
1261
|
-
* store, the lock and the ignore file. Every package-, capability- and
|
|
1262
|
-
* user-shaped tree in the engine therefore goes through this hand-walk instead,
|
|
1263
|
-
* where an EACCES is an ordinary throwable error.
|
|
1264
|
-
*
|
|
1265
|
-
* Semantics chosen to be safe rather than maximally faithful:
|
|
1266
|
-
* - deterministic traversal (sorted entries), so two copies of one tree hash
|
|
1267
|
-
* identically;
|
|
1268
|
-
* - symlinks are recreated VERBATIM — never followed, never rewritten — because
|
|
1269
|
-
* the bytes about to be hashed must be the bytes the author wrote;
|
|
1270
|
-
* - FIFOs, sockets and device nodes are rejected fail-closed: they are not
|
|
1271
|
-
* distributable content, and copying them has no defined meaning here;
|
|
1272
|
-
* - directory modes are applied AFTER their children, so a read-only source
|
|
1273
|
-
* directory cannot block writing its own contents. */
|
|
1274
|
-
export function copyTreeSafe(src, dest) {
|
|
1275
|
-
const st = lstatSync(src);
|
|
1276
|
-
if (st.isSymbolicLink()) { symlinkSync(readlinkSync(src), dest); return; }
|
|
1277
|
-
if (st.isFile()) { copyFileSync(src, dest); chmodSync(dest, st.mode & 0o7777); return; }
|
|
1278
|
-
if (!st.isDirectory()) {
|
|
1279
|
-
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`);
|
|
1280
|
-
}
|
|
1281
|
-
mkdirSync(dest, { recursive: true });
|
|
1282
|
-
for (const e of readdirSync(src, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1283
|
-
copyTreeSafe(join(src, e.name), join(dest, e.name));
|
|
1284
|
-
}
|
|
1285
|
-
chmodSync(dest, st.mode & 0o7777);
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
1600
|
/** Remove source-control metadata from the ROOT of a managed artifact.
|
|
1289
1601
|
*
|
|
1290
1602
|
* It must not be installed, and it must not become an integrity exclusion:
|
|
@@ -1588,14 +1900,6 @@ export function capabilityCompatibility(manifest, version = OATS_VERSION) {
|
|
|
1588
1900
|
}
|
|
1589
1901
|
|
|
1590
1902
|
// ---------- distribution packages (docs/design/package-engine-contract.md) ----------
|
|
1591
|
-
/** Kernel error with a stable machine-readable code (contract §4) and optional provenance. */
|
|
1592
|
-
export function oatsError(code, message, provenance) {
|
|
1593
|
-
const e = new Error(message);
|
|
1594
|
-
e.code = code;
|
|
1595
|
-
if (provenance) e.provenance = provenance;
|
|
1596
|
-
return e;
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
1903
|
/** Where one materialized capability artifact lives at a scope.
|
|
1600
1904
|
*
|
|
1601
1905
|
* This is the LAST line of defence, and it is a positive proof rather than a
|
|
@@ -1614,10 +1918,6 @@ export const installedCapabilityDir = (levelDir, capabilityId) => {
|
|
|
1614
1918
|
}
|
|
1615
1919
|
return dir;
|
|
1616
1920
|
};
|
|
1617
|
-
/** Generated provenance file inside every materialized artifact. It is INSIDE
|
|
1618
|
-
* the hashed tree, so tampering with it is integrity drift; the lock stays
|
|
1619
|
-
* authoritative. */
|
|
1620
|
-
export const CAPABILITY_INSTALLATION_FILE = ".oats-installation.json";
|
|
1621
1921
|
/** Prefix of a transaction staging directory. It lives inside the (gitignored)
|
|
1622
1922
|
* installed store so the commit phase is a same-filesystem rename, and it is
|
|
1623
1923
|
* dot-prefixed so discovery skips it. */
|
|
@@ -1628,37 +1928,6 @@ const STAGING_PREFIX = ".staging-";
|
|
|
1628
1928
|
* use site: every resolver reads the configured path and falls back here. */
|
|
1629
1929
|
export const DEFAULT_PACKAGE_PATH = "oats-package";
|
|
1630
1930
|
|
|
1631
|
-
/** Normalize a configured package path to its canonical form, or throw.
|
|
1632
|
-
*
|
|
1633
|
-
* Canonical form is a POSIX-relative path with no redundant or trailing
|
|
1634
|
-
* separators; every spelling of the repository root ("", ".", "./", "./.")
|
|
1635
|
-
* normalizes to the single canonical "." so a root selection round-trips
|
|
1636
|
-
* identically through spec → lock → JSON → doctor/list/update (contract §4).
|
|
1637
|
-
*
|
|
1638
|
-
* Fail-closed: absolute paths, Windows drive paths, host-ambient "~" spellings,
|
|
1639
|
-
* backslash separators (ambiguous — a backslash is a legal POSIX filename
|
|
1640
|
-
* character, so accepting it as a separator would make containment checks
|
|
1641
|
-
* disagree with the filesystem) and NUL are rejected as invalid-source; ".."
|
|
1642
|
-
* traversal is path-escape. Returns undefined ONLY for an absent value, so the
|
|
1643
|
-
* caller can apply the source-appropriate default. */
|
|
1644
|
-
export function normalizePackagePath(raw, { where = "package path", code = "invalid-source" } = {}) {
|
|
1645
|
-
// ABSENT means absent. A present `null` (JSON's way of spelling a malformed
|
|
1646
|
-
// value) is a violation, not a fall-through to the caller's default — a
|
|
1647
|
-
// catalog entry that says `"path": null` must fail, not silently install
|
|
1648
|
-
// DEFAULT_PACKAGE_PATH.
|
|
1649
|
-
if (raw === undefined) return undefined;
|
|
1650
|
-
if (typeof raw !== "string") throw oatsError(code, `${where} must be a string (got ${Array.isArray(raw) ? "array" : raw === null ? "null" : typeof raw})`);
|
|
1651
|
-
const s = raw.trim();
|
|
1652
|
-
if (s.includes("\0")) throw oatsError(code, `${where} contains a NUL byte`);
|
|
1653
|
-
if (s.startsWith("~")) throw oatsError(code, `${where} "${s}" is a host-ambient path — package paths are repository-relative`);
|
|
1654
|
-
if (s.includes("\\")) throw oatsError(code, `${where} "${s}" uses backslashes — package paths are POSIX-relative (use "/")`);
|
|
1655
|
-
if (/^[A-Za-z]:[/\\]/.test(s)) throw oatsError(code, `${where} "${s}" is an absolute drive path — package paths are repository-relative`);
|
|
1656
|
-
if (isAbsolute(s)) throw oatsError(code, `${where} "${s}" is absolute — package paths are repository-relative`);
|
|
1657
|
-
const segments = s.split("/").filter((seg) => seg !== "" && seg !== ".");
|
|
1658
|
-
if (segments.includes("..")) throw oatsError("path-escape", `${where} "${s}" escapes the source root with ".."`);
|
|
1659
|
-
return segments.length ? segments.join("/") : ".";
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
1931
|
/** Split an optional `#<package-path>` fragment off a source spec. A source may
|
|
1663
1932
|
* carry at most one fragment; the fragment is removed BEFORE `@ref` parsing so
|
|
1664
1933
|
* a path can never be mistaken for part of a ref. */
|
|
@@ -1768,51 +2037,6 @@ export function inspectGitSourceRoot(spec) {
|
|
|
1768
2037
|
} catch (e) { rmSync(tmp, { recursive: true, force: true }); throw e; }
|
|
1769
2038
|
}
|
|
1770
2039
|
|
|
1771
|
-
/** Parse a lock entry's `source` against the EXACT normalized grammar the
|
|
1772
|
-
* writer produces. Strict on purpose: `updatePackage` turns this back into a
|
|
1773
|
-
* source spec, so a payload that merely "starts with catalog:" but is not a
|
|
1774
|
-
* valid catalog id gets RECLASSIFIED downstream — `catalog:../evil` would be
|
|
1775
|
-
* re-parsed as a host-relative local path and acquired from the operator's
|
|
1776
|
-
* filesystem. A lock also never carries a `#<path>` fragment: the selected
|
|
1777
|
-
* root is the entry's own `path` field, and a fragment here would produce a
|
|
1778
|
-
* double-fragment spec on update. */
|
|
1779
|
-
function parseLockSource(src) {
|
|
1780
|
-
const s = String(src || "");
|
|
1781
|
-
const bad = (why) => oatsError("invalid-source", `unknown lock source "${src}" — ${why}`);
|
|
1782
|
-
if (s.includes("#")) throw bad(`lock sources carry no "#<path>" fragment; the selected package root is the entry's "path" field`);
|
|
1783
|
-
if (s.startsWith("path:")) {
|
|
1784
|
-
const p = s.slice(5);
|
|
1785
|
-
if (!p) throw bad("empty path source");
|
|
1786
|
-
if (!isAbsolute(p)) throw bad("path source must be an absolute directory (the writer always resolves it)");
|
|
1787
|
-
return { kind: "path", path: p, normalized: s };
|
|
1788
|
-
}
|
|
1789
|
-
if (s.startsWith("catalog:")) {
|
|
1790
|
-
const body = s.slice(8);
|
|
1791
|
-
// Split at the FIRST "@", mirroring the public parser's regex: the catalog
|
|
1792
|
-
// id grammar cannot contain "@", so everything after the first one is the
|
|
1793
|
-
// selector. Splitting at the LAST "@" misreads a legitimate ref spelling
|
|
1794
|
-
// such as `oats.okf@release@candidate` — which the writer does produce —
|
|
1795
|
-
// as the id `oats.okf@release`.
|
|
1796
|
-
const at = body.indexOf("@");
|
|
1797
|
-
const id = at > 0 ? body.slice(0, at) : body;
|
|
1798
|
-
const selector = at > 0 ? body.slice(at + 1) : undefined;
|
|
1799
|
-
if (!PACKAGE_ID_RE.test(id)) throw bad(`"${id}" is not a valid official catalog id`);
|
|
1800
|
-
if (at > 0 && !selector) throw bad("empty catalog selector");
|
|
1801
|
-
return { kind: "catalog", id, selector, normalized: s };
|
|
1802
|
-
}
|
|
1803
|
-
if (s.startsWith("git:")) {
|
|
1804
|
-
const body = s.slice(4);
|
|
1805
|
-
const at = body.lastIndexOf("@") > body.lastIndexOf("/") ? body.lastIndexOf("@") : -1;
|
|
1806
|
-
const url = at > 0 ? body.slice(0, at) : body;
|
|
1807
|
-
const ref = at > 0 ? body.slice(at + 1) : undefined;
|
|
1808
|
-
if (!url) throw bad("empty git url");
|
|
1809
|
-
if (at > 0 && !ref) throw bad("empty git ref");
|
|
1810
|
-
if (!/^(https?:\/\/|file:\/\/|git@|ssh:\/\/|git:\/\/)/.test(url)) throw bad(`"${url}" is not an http(s)/ssh/file/git URL`);
|
|
1811
|
-
return { kind: "git", url, ref, normalized: s };
|
|
1812
|
-
}
|
|
1813
|
-
throw oatsError("invalid-source", `unknown lock source "${src}"`);
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
2040
|
/** Official package catalog: identity + discovery ONLY — resolving through it
|
|
1817
2041
|
* never advances a lock and never grants executable trust (contract §1).
|
|
1818
2042
|
* Workstream 3 seeds the kernel-bundled catalog; OATS_PACKAGE_CATALOG points
|
|
@@ -1914,27 +2138,6 @@ export function packageIntegrity(dir) {
|
|
|
1914
2138
|
return `sha256-${hash.digest("hex")}`;
|
|
1915
2139
|
}
|
|
1916
2140
|
|
|
1917
|
-
/** Stable integrity of a MATERIALIZED capability artifact: every byte under
|
|
1918
|
-
* `.agents/capabilities/installed/<id>/`, with NO exclusions — capability source,
|
|
1919
|
-
* the materialized runtime closure (node_modules), and the generated
|
|
1920
|
-
* `.oats-installation.json` provenance file all count. This is the only digest
|
|
1921
|
-
* executable trust binds to, which is why a separate dependency digest does not
|
|
1922
|
-
* exist at capability level: the closure is inside the artifact, so tampering
|
|
1923
|
-
* with a dependency is ordinary artifact drift. */
|
|
1924
|
-
export function capabilityArtifactIntegrity(dir) {
|
|
1925
|
-
const hash = createHash("sha256");
|
|
1926
|
-
const walk = (d) => {
|
|
1927
|
-
for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1928
|
-
const p = join(d, e.name);
|
|
1929
|
-
if (e.isDirectory()) walk(p);
|
|
1930
|
-
else if (e.isFile()) { hash.update(relative(dir, p)); hash.update("\0file\0"); hash.update(readFileSync(p)); hash.update("\0"); }
|
|
1931
|
-
else if (e.isSymbolicLink()) { hash.update(relative(dir, p)); hash.update("\0symlink\0"); hash.update(readlinkSync(p)); hash.update("\0"); }
|
|
1932
|
-
}
|
|
1933
|
-
};
|
|
1934
|
-
walk(dir);
|
|
1935
|
-
return `sha256-${hash.digest("hex")}`;
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
2141
|
const PACKAGE_MANIFEST_KEYS = new Set(["package", "version", "description", "compatibility", "capabilities", "configTemplates", "configs", "dependencies"]);
|
|
1939
2142
|
/** Canonical home of a package's config templates. Manifest paths are
|
|
1940
2143
|
* repository-relative and always spelled with "/", on every platform. */
|
|
@@ -1959,12 +2162,12 @@ export function isCanonicalTemplatePath(p) {
|
|
|
1959
2162
|
* whichever spelling the manifest used
|
|
1960
2163
|
* _capabilities [{ id, rel, dir, manifest }]
|
|
1961
2164
|
*/
|
|
1962
|
-
export function loadPackageManifestAt(pdir) {
|
|
2165
|
+
export function loadPackageManifestAt(pdir, { strict = false } = {}) {
|
|
1963
2166
|
const mf = join(pdir, "oats-package.json");
|
|
1964
2167
|
if (!existsSync(mf)) throw oatsError("invalid-package-manifest", `${pdir} has no oats-package.json distribution manifest`);
|
|
1965
2168
|
let m;
|
|
1966
|
-
try { m = JSON.parse(readFileSync(mf, "utf8")); }
|
|
1967
|
-
catch (e) { throw oatsError("invalid-package-manifest", `invalid JSON in ${mf}: ${e.message}`); }
|
|
2169
|
+
try { m = strict ? parseStrictJson(readPortableBytes(mf)) : JSON.parse(readFileSync(mf, "utf8")); }
|
|
2170
|
+
catch (e) { if (strict) throw e; throw oatsError("invalid-package-manifest", `invalid JSON in ${mf}: ${e.message}`); }
|
|
1968
2171
|
// Hostile-input shapes: JSON null/scalar/array roots are valid JSON but not manifests.
|
|
1969
2172
|
if (!m || typeof m !== "object" || Array.isArray(m)) throw oatsError("invalid-package-manifest", `${mf} must be a JSON object (got ${m === null ? "null" : Array.isArray(m) ? "array" : typeof m})`);
|
|
1970
2173
|
if (typeof m.package !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(m.package)) throw oatsError("invalid-package-manifest", `${mf} needs a valid string "package" identity (lowercase [a-z0-9._-])`);
|
|
@@ -2015,7 +2218,7 @@ export function loadPackageManifestAt(pdir) {
|
|
|
2015
2218
|
for (const rel of m.capabilities) {
|
|
2016
2219
|
const dir = inside(rel, "capability");
|
|
2017
2220
|
if (!existsSync(join(dir, "oats.json"))) throw oatsError("invalid-package-manifest", `package ${m.package} capability path ${rel} has no oats.json (not a capability)`);
|
|
2018
|
-
const cm = loadManifestAt(dir, `package:${m.package}
|
|
2221
|
+
const cm = loadManifestAt(dir, `package:${m.package}`, { strict });
|
|
2019
2222
|
// A PACKAGE-exported id will be materialized as a directory name under
|
|
2020
2223
|
// installed/, so it must satisfy the materialized grammar — stricter than
|
|
2021
2224
|
// the legacy standalone-capability rule loadManifestAt applies, which still
|
|
@@ -2129,21 +2332,7 @@ export function assertCapabilitySelfContained(capDir, manifest) {
|
|
|
2129
2332
|
}
|
|
2130
2333
|
}
|
|
2131
2334
|
|
|
2132
|
-
const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
2133
2335
|
export const LOCKFILE_VERSION = 2;
|
|
2134
|
-
/** Package rows lock the TRANSPORT unit only: no capability list (the capability
|
|
2135
|
-
* rows' `package` back-reference is the single provider truth) and no trust
|
|
2136
|
-
* (trust binds to materialized capability artifacts). */
|
|
2137
|
-
const LOCK_PACKAGE_KEYS = new Set(["source", "path", "version", "commit", "integrity", "dependencies"]);
|
|
2138
|
-
/** Capability rows lock the MATERIALIZED entity. */
|
|
2139
|
-
const LOCK_CAPABILITY_KEYS = new Set(["version", "package", "path", "integrity", "trusted"]);
|
|
2140
|
-
/** Own-property PRESENCE of any of these on a package row is forbidden
|
|
2141
|
-
* transitional evidence (contract §4.1) — never truthiness and never array
|
|
2142
|
-
* length, so an empty `capabilities: []` or a dependency-free old row still
|
|
2143
|
-
* classifies. Package-row `path`/`dependencies` are NEVER tells: the current
|
|
2144
|
-
* shape retains both. */
|
|
2145
|
-
const TRANSITIONAL_ROW_FIELDS = ["capabilities", "trustedCapabilities", "depsIntegrity"];
|
|
2146
|
-
|
|
2147
2336
|
/** A null-prototype copy of a raw parsed JSON map. Raw objects return inherited
|
|
2148
2337
|
* `constructor`/`toString`/`valueOf` for `map[id]` even with no own entry, so
|
|
2149
2338
|
* every ID-keyed map in the engine goes through this (or `Object.hasOwn`) before
|
|
@@ -2154,93 +2343,15 @@ function nullProtoMap(raw) {
|
|
|
2154
2343
|
return out;
|
|
2155
2344
|
}
|
|
2156
2345
|
|
|
2157
|
-
/**
|
|
2158
|
-
*
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
* the state-free empty transitional document, §4.1). Returns
|
|
2164
|
-
* { version, packages, capabilities, legacyCapabilities } (null-prototype and
|
|
2165
|
-
* validated) or null when the file does not exist. */
|
|
2346
|
+
/** Public bytes seam for evidence-grade migration. Ordinary consumers use the
|
|
2347
|
+
* file wrapper below; both share the one acyclic legacy decoder. */
|
|
2348
|
+
export function parseLockBytesStrict(bytes, { file = "<oats-lock.json>", limits } = {}) {
|
|
2349
|
+
return decodeLegacyLockBytes(bytes, { file, limits, retiredCapabilityReason });
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2166
2352
|
export function parseLockFileStrict(file) {
|
|
2167
2353
|
if (!existsSync(file)) return null;
|
|
2168
|
-
|
|
2169
|
-
let parsed;
|
|
2170
|
-
try { parsed = JSON.parse(readFileSync(file, "utf8")); }
|
|
2171
|
-
catch (e) { throw bad(`malformed JSON — ${e.message}`); }
|
|
2172
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw bad(`lock root must be a JSON object (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed})`);
|
|
2173
|
-
const v = parsed.lockfileVersion;
|
|
2174
|
-
if (v !== undefined && typeof v !== "number") throw bad(`lockfileVersion must be a number (got ${JSON.stringify(v)})`);
|
|
2175
|
-
if (v !== undefined && v !== 1 && v !== 2) throw bad(`unsupported lockfileVersion ${v}`);
|
|
2176
|
-
const version = v ?? 1;
|
|
2177
|
-
if (parsed.capabilities !== undefined && (parsed.capabilities === null || typeof parsed.capabilities !== "object" || Array.isArray(parsed.capabilities))) {
|
|
2178
|
-
throw bad(`"capabilities" must be an object map (got ${parsed.capabilities === null ? "null" : Array.isArray(parsed.capabilities) ? "array" : typeof parsed.capabilities})`);
|
|
2179
|
-
}
|
|
2180
|
-
const out = { version, packages: Object.create(null), capabilities: Object.create(null), legacyCapabilities: Object.create(null) };
|
|
2181
|
-
if (version === 1) {
|
|
2182
|
-
if (parsed.packages !== undefined) throw bad(`lockfileVersion 1 must not carry a "packages" map`);
|
|
2183
|
-
// Validate the COMPLETE v1 map before ANY consumer sees it. Retirement
|
|
2184
|
-
// intentionally wins over shape validation (the user is told to delete that
|
|
2185
|
-
// entry), but every non-retired entry is strict — that keeps restore
|
|
2186
|
-
// preflight atomic and stops a malformed entry from granting the
|
|
2187
|
-
// marketplace/hoisted exemption during discovery (reviewer-12e2d86).
|
|
2188
|
-
for (const id of Object.keys(parsed.capabilities || {})) {
|
|
2189
|
-
const entry = parsed.capabilities[id];
|
|
2190
|
-
if (!retiredCapabilityReason(id)) {
|
|
2191
|
-
const violation = legacyCapabilityEntryViolation(entry);
|
|
2192
|
-
if (violation) throw bad(`legacy entry "${id}" is malformed (${violation})`, { package: id });
|
|
2193
|
-
}
|
|
2194
|
-
out.legacyCapabilities[id] = entry;
|
|
2195
|
-
}
|
|
2196
|
-
return out;
|
|
2197
|
-
}
|
|
2198
|
-
const rawPackages = parsed.packages;
|
|
2199
|
-
if (rawPackages === undefined || rawPackages === null || typeof rawPackages !== "object" || Array.isArray(rawPackages)) {
|
|
2200
|
-
throw bad(`lockfileVersion 2 requires a "packages" object map (got ${rawPackages === undefined ? "undefined" : rawPackages === null ? "null" : Array.isArray(rawPackages) ? "array" : typeof rawPackages})`);
|
|
2201
|
-
}
|
|
2202
|
-
const packageKeys = Object.keys(rawPackages);
|
|
2203
|
-
const hasCapabilityMap = Object.hasOwn(parsed, "capabilities");
|
|
2204
|
-
const stateFree = !packageKeys.length && (!hasCapabilityMap || !Object.keys(parsed.capabilities).length);
|
|
2205
|
-
// UNSUPPORTED TRANSITIONAL v2 (contract §4.1) — an exact OR predicate, decided
|
|
2206
|
-
// centrally before any discovery or mutation. It is never converted and never
|
|
2207
|
-
// partially interpreted; the operator recreates the scope.
|
|
2208
|
-
const unsupported = (why) => bad(`unsupported transitional package-root lockfileVersion 2 (${why}). This is the superseded package-store lock shape; it is not converted or interpreted. Delete this lock (and any .agents/packages directory) and recreate the scope's state with \`oats install\`.`);
|
|
2209
|
-
if (!stateFree) {
|
|
2210
|
-
if (!hasCapabilityMap) throw unsupported(`no top-level "capabilities" map`);
|
|
2211
|
-
for (const id of packageKeys) {
|
|
2212
|
-
const row = rawPackages[id];
|
|
2213
|
-
if (!row || typeof row !== "object" || Array.isArray(row)) continue; // shape error reported below
|
|
2214
|
-
const tells = TRANSITIONAL_ROW_FIELDS.filter((f) => Object.hasOwn(row, f));
|
|
2215
|
-
if (tells.length) throw unsupported(`package row "${id}" carries ${tells.join(", ")}`);
|
|
2216
|
-
}
|
|
2217
|
-
}
|
|
2218
|
-
for (const id of packageKeys) {
|
|
2219
|
-
if (!PACKAGE_ID_RE.test(id)) throw bad(`packages map has an invalid package key ${JSON.stringify(id)}`, { package: id });
|
|
2220
|
-
const e = rawPackages[id];
|
|
2221
|
-
if (!e || typeof e !== "object" || Array.isArray(e)) throw bad(`lock entry for "${id}" is not an object`, { package: id });
|
|
2222
|
-
const extra = Object.keys(e).filter((k) => !LOCK_PACKAGE_KEYS.has(k));
|
|
2223
|
-
if (extra.length) throw bad(`lock entry for "${id}" has unknown keys: ${extra.join(", ")}`, { package: id });
|
|
2224
|
-
out.packages[id] = e;
|
|
2225
|
-
}
|
|
2226
|
-
for (const id of Object.keys(out.packages)) validateLockEntry(id, out.packages[id], out.packages, { file });
|
|
2227
|
-
// A state-free empty transitional document carries nothing, so it reads as the
|
|
2228
|
-
// canonical empty lock rather than failing (contract §4.1).
|
|
2229
|
-
if (hasCapabilityMap) {
|
|
2230
|
-
for (const id of Object.keys(parsed.capabilities)) {
|
|
2231
|
-
// BEFORE any filesystem join anywhere downstream: a revised-v2 capability
|
|
2232
|
-
// key names a directory under installed/, so the grammar is enforced here,
|
|
2233
|
-
// in the one central reader, rather than at each consumer.
|
|
2234
|
-
if (!isMaterializedCapabilityId(id)) throw bad(`capabilities map has an invalid capability key: ${capabilityIdViolation(id)}`);
|
|
2235
|
-
const e = parsed.capabilities[id];
|
|
2236
|
-
if (!e || typeof e !== "object" || Array.isArray(e)) throw bad(`capability lock entry for "${id}" is not an object`, { package: id });
|
|
2237
|
-
const extra = Object.keys(e).filter((k) => !LOCK_CAPABILITY_KEYS.has(k));
|
|
2238
|
-
if (extra.length) throw bad(`capability lock entry for "${id}" has unknown keys: ${extra.join(", ")}`, { package: id });
|
|
2239
|
-
out.capabilities[id] = e;
|
|
2240
|
-
}
|
|
2241
|
-
}
|
|
2242
|
-
for (const id of Object.keys(out.capabilities)) validateCapabilityLockEntry(id, out.capabilities[id], out.packages, { file });
|
|
2243
|
-
return out;
|
|
2354
|
+
return parseLockBytesStrict(readFileSync(file), { file });
|
|
2244
2355
|
}
|
|
2245
2356
|
|
|
2246
2357
|
/** Every lock-owning scope visible from a directory, outermost → innermost.
|
|
@@ -2753,108 +2864,13 @@ function pruneCreatedAnchors(createdAnchors) {
|
|
|
2753
2864
|
}
|
|
2754
2865
|
}
|
|
2755
2866
|
|
|
2756
|
-
/**
|
|
2757
|
-
*
|
|
2758
|
-
*
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
* `schemaVersion` is a constant of the format, bumped only by an explicit
|
|
2764
|
-
* contract change (which is itself an integrity change, so it is visible). */
|
|
2765
|
-
function capabilityInstallationRecord(cap, pkg) {
|
|
2766
|
-
return {
|
|
2767
|
-
schemaVersion: 1,
|
|
2768
|
-
capability: cap.id,
|
|
2769
|
-
version: cap.manifest.version,
|
|
2770
|
-
package: pkg.package,
|
|
2771
|
-
packageVersion: pkg.version,
|
|
2772
|
-
source: pkg.source,
|
|
2773
|
-
commit: pkg.commit,
|
|
2774
|
-
packagePath: pkg.path,
|
|
2775
|
-
capabilityPath: cap.rel,
|
|
2776
|
-
};
|
|
2777
|
-
}
|
|
2778
|
-
function writeCapabilityInstallation(dest, cap, pkg) {
|
|
2779
|
-
writeFileSync(join(dest, CAPABILITY_INSTALLATION_FILE), JSON.stringify(capabilityInstallationRecord(cap, pkg), null, 2) + "\n", { mode: 0o644 });
|
|
2780
|
-
}
|
|
2781
|
-
/** Read an artifact's provenance and check it AGREES with the lock rows it was
|
|
2782
|
-
* projected from. Disagreement is invalid-lock: the artifact and the lock claim
|
|
2783
|
-
* different origins, and neither may silently win. (A modified file also fails
|
|
2784
|
-
* integrity, but this gives the precise diagnosis.) */
|
|
2785
|
-
export function verifyCapabilityInstallation(dir, capabilityId, capRow, pkgRow) {
|
|
2786
|
-
const file = join(dir, CAPABILITY_INSTALLATION_FILE);
|
|
2787
|
-
if (!existsSync(file)) throw oatsError("invalid-lock", `materialized capability ${capabilityId} has no ${CAPABILITY_INSTALLATION_FILE} provenance — reproject it with \`oats install\``, [{ package: capabilityId, file }]);
|
|
2788
|
-
let doc;
|
|
2789
|
-
try { doc = JSON.parse(readFileSync(file, "utf8")); }
|
|
2790
|
-
catch (e) { throw oatsError("invalid-lock", `materialized capability ${capabilityId} has malformed ${CAPABILITY_INSTALLATION_FILE}: ${e.message}`, [{ package: capabilityId, file }]); }
|
|
2791
|
-
const expected = {
|
|
2792
|
-
schemaVersion: 1, capability: capabilityId, version: capRow.version, package: capRow.package,
|
|
2793
|
-
packageVersion: pkgRow.version, source: pkgRow.source, commit: pkgRow.commit,
|
|
2794
|
-
packagePath: pkgRow.path, capabilityPath: capRow.path,
|
|
2795
|
-
};
|
|
2796
|
-
for (const [k, want] of Object.entries(expected)) {
|
|
2797
|
-
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 }]);
|
|
2798
|
-
}
|
|
2799
|
-
return doc;
|
|
2800
|
-
}
|
|
2801
|
-
|
|
2802
|
-
/** The executable surface a capability manifest declares. */
|
|
2803
|
-
function executableSurfaceOf(manifest) {
|
|
2804
|
-
return {
|
|
2805
|
-
commands: Object.keys(manifest?.commands || {}),
|
|
2806
|
-
hooks: Object.keys(manifest?.hooks || {}),
|
|
2807
|
-
environment: [...(manifest?.environment || [])],
|
|
2808
|
-
...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
|
|
2809
|
-
};
|
|
2810
|
-
}
|
|
2811
|
-
function hasExecutableSurface(manifest) {
|
|
2812
|
-
const s = executableSurfaceOf(manifest);
|
|
2813
|
-
return s.commands.length > 0 || s.hooks.length > 0 || s.environment.length > 0;
|
|
2814
|
-
}
|
|
2815
|
-
|
|
2816
|
-
/** MATERIALIZE one capability out of a staged package payload into a flat,
|
|
2817
|
-
* self-contained artifact (Decision: "Materialized capabilities are
|
|
2818
|
-
* self-contained"). Everything happens in staging; nothing durable is touched.
|
|
2819
|
-
*
|
|
2820
|
-
* Order matters and is load-bearing:
|
|
2821
|
-
* 1. materialize the capability's own runtime closure (npm ci, no scripts);
|
|
2822
|
-
* 2. verify self-containment of every DECLARED resource, then symlink
|
|
2823
|
-
* containment and the native-binary scan of the materialized closure —
|
|
2824
|
-
* all against the CAPABILITY root, before any digest;
|
|
2825
|
-
* 3. move the validated root into the artifacts area;
|
|
2826
|
-
* 4. write .oats-installation.json provenance INSIDE the artifact;
|
|
2827
|
-
* 5. hash the finished artifact.
|
|
2828
|
-
* Hashing last is what makes provenance and closure tamper-evident.
|
|
2829
|
-
*/
|
|
2830
|
-
function materializeCapability({ cap, pkg, artifactsDir }) {
|
|
2831
|
-
const rep = materializeCapabilityDeps(cap.dir);
|
|
2832
|
-
if (rep.error) throw oatsError("invalid-package-manifest", `runtime dependency materialization failed for capability "${cap.id}" of package "${pkg.package}": ${rep.error}`);
|
|
2833
|
-
assertCapabilitySelfContained(cap.dir, cap.manifest);
|
|
2834
|
-
assertMaterializedDepsContained(cap.dir);
|
|
2835
|
-
assertNoNativeBinaries(cap.dir);
|
|
2836
|
-
const dest = join(artifactsDir, cap.id);
|
|
2837
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
2838
|
-
if (cap.rel === ".") {
|
|
2839
|
-
// Legacy flat layout: the capability root IS the staged package root, which
|
|
2840
|
-
// other steps still need, so copy instead of moving it out. copyTreeSafe
|
|
2841
|
-
// recreates symlinks verbatim — rewriting a link target would change bytes
|
|
2842
|
-
// the integrity digest is about to cover — and stays catchable.
|
|
2843
|
-
copyTreeSafe(cap.dir, dest);
|
|
2844
|
-
} else renameSync(cap.dir, dest);
|
|
2845
|
-
writeCapabilityInstallation(dest, cap, pkg);
|
|
2846
|
-
return {
|
|
2847
|
-
capability: cap.id, version: cap.manifest.version, package: pkg.package, path: cap.rel,
|
|
2848
|
-
dir: dest, integrity: capabilityArtifactIntegrity(dest),
|
|
2849
|
-
// The DECLARED fundamental layer, normalized to null when the capability
|
|
2850
|
-
// declares none. A config template may bind a fundamental slot to one of the
|
|
2851
|
-
// root package's own capabilities, and until the artifact is materialized
|
|
2852
|
-
// there is nowhere else to read that from — so it belongs in the pre-commit
|
|
2853
|
-
// preview, not in a post-commit validation with an outer rollback.
|
|
2854
|
-
layer: cap.manifest.layer ?? null,
|
|
2855
|
-
executableSurface: executableSurfaceOf(cap.manifest), manifest: cap.manifest,
|
|
2856
|
-
};
|
|
2857
|
-
}
|
|
2867
|
+
/** One materializer for old acquisition/restore and the new preparation adapter:
|
|
2868
|
+
* dependencies -> complete containment/native checks -> projection -> stable v1
|
|
2869
|
+
* installation provenance -> digest. No source/selection/trust policy moves here. */
|
|
2870
|
+
const materializeCapability = createCapabilityMaterializer({
|
|
2871
|
+
materializeCapabilityDeps, assertCapabilitySelfContained,
|
|
2872
|
+
assertMaterializedDepsContained, assertNoNativeBinaries,
|
|
2873
|
+
});
|
|
2858
2874
|
|
|
2859
2875
|
/** Config-template descriptors AND payload bytes, read from a staged package
|
|
2860
2876
|
* before staging is discarded, so the config lane can offer or adopt a template
|
|
@@ -2938,10 +2954,14 @@ export function acquirePackage(levelDir, spec, opts = {}) {
|
|
|
2938
2954
|
const { dir: staging, createdAnchors, ignore } = beginStaging(levelDir);
|
|
2939
2955
|
const artifactsDir = join(staging, "artifacts");
|
|
2940
2956
|
mkdirSync(artifactsDir, { recursive: true });
|
|
2941
|
-
const resolved = new Map(); // identity → staged package record
|
|
2942
2957
|
let counter = 0;
|
|
2943
|
-
const
|
|
2958
|
+
const readPackage = (srcSpec, parent, chain) => {
|
|
2959
|
+
const baseDir = parent?.parsedSource.kind === "path" ? parent.parsedSource.path : undefined;
|
|
2944
2960
|
const p = parsePackageSource(srcSpec, { baseDir });
|
|
2961
|
+
if (parent) {
|
|
2962
|
+
if (p.kind === "git" && !p.ref) throw oatsError("invalid-source", `package dependency must be pinned to a tag/commit: "${srcSpec}" (declared by ${parent.package})`);
|
|
2963
|
+
if (p.kind === "path" && p.relative && !baseDir) throw oatsError("invalid-source", `package dependency "${srcSpec}" (declared by ${parent.package}) is a relative path, but ${parent.package} was not acquired from a local path — relative dependencies only work between co-located local packages`);
|
|
2964
|
+
}
|
|
2945
2965
|
const dest = join(staging, `pkg-${counter++}`);
|
|
2946
2966
|
let commit, packagePath;
|
|
2947
2967
|
if (!chain.length && opts.rootSnapshot) {
|
|
@@ -2962,7 +2982,6 @@ export function acquirePackage(levelDir, spec, opts = {}) {
|
|
|
2962
2982
|
} else ({ commit, path: packagePath } = fetchPackageSource(p, dest, opts.catalog));
|
|
2963
2983
|
const m = loadPackageManifestAt(dest);
|
|
2964
2984
|
const id = m.package;
|
|
2965
|
-
if (chain.includes(id)) throw oatsError("dependency-cycle", `package dependency cycle: ${[...chain, id].join(" → ")}`, [...chain, id]);
|
|
2966
2985
|
// Preserve the ORIGINAL catalog spec in lock metadata: bare and explicit
|
|
2967
2986
|
// selector forms must remain distinguishable for update. The resolved git
|
|
2968
2987
|
// commit is already pinned separately in `commit`.
|
|
@@ -2971,36 +2990,21 @@ export function acquirePackage(levelDir, spec, opts = {}) {
|
|
|
2971
2990
|
// contain several packages (contract §1.1), so two payload roots claiming one
|
|
2972
2991
|
// package identity are a collision, not the same package resolved twice.
|
|
2973
2992
|
const sourceKey = `${source}#${packagePath}`;
|
|
2974
|
-
|
|
2975
|
-
const prev = resolved.get(id);
|
|
2976
|
-
if (prev.sourceKey !== sourceKey) throw oatsError("duplicate-package-identity", `two sources claim package "${id}" at ${levelDir}: ${prev.sourceKey} and ${sourceKey}`, [prev.sourceKey, sourceKey]);
|
|
2977
|
-
rmSync(dest, { recursive: true, force: true });
|
|
2978
|
-
return id;
|
|
2979
|
-
}
|
|
2980
|
-
const compat = capabilityCompatibility(m);
|
|
2981
|
-
if (!compat.compatible) throw oatsError("incompatible-oats", `package ${id} requires OATS ${compat.range} (running ${OATS_VERSION})`);
|
|
2982
|
-
const deps = [];
|
|
2983
|
-
for (const d of m.dependencies || []) {
|
|
2984
|
-
// Relative local-path dependencies resolve against the DEPENDING PACKAGE'S
|
|
2985
|
-
// source root (contract intent: package-relative), never the process CWD.
|
|
2986
|
-
// For git/catalog parents there is no local base.
|
|
2987
|
-
const depBase = p.kind === "path" ? p.path : undefined;
|
|
2988
|
-
const dp = parsePackageSource(d, { baseDir: depBase });
|
|
2989
|
-
if (dp.kind === "git" && !dp.ref) throw oatsError("invalid-source", `package dependency must be pinned to a tag/commit: "${d}" (declared by ${id})`);
|
|
2990
|
-
// EVERY relative path dependency requires a local base — classified from
|
|
2991
|
-
// the parsed payload so "path:sub" / whitespace spellings cannot resolve
|
|
2992
|
-
// through the process CWD from a git/catalog manifest (reviewer-2a4adec).
|
|
2993
|
-
if (dp.kind === "path" && dp.relative && !depBase) throw oatsError("invalid-source", `package dependency "${d}" (declared by ${id}) is a relative path, but ${id} was not acquired from a local path — relative dependencies only work between co-located local packages`);
|
|
2994
|
-
deps.push(resolveClosure(d, [...chain, id], depBase));
|
|
2995
|
-
}
|
|
2996
|
-
resolved.set(id, {
|
|
2993
|
+
return {
|
|
2997
2994
|
package: id, dir: dest, manifest: m, source, path: packagePath, sourceKey, commit,
|
|
2998
|
-
version: m.version,
|
|
2999
|
-
}
|
|
3000
|
-
return id;
|
|
2995
|
+
version: m.version, capabilities: m._capabilities, parsedSource: p, dependencyRequests: m.dependencies || [],
|
|
2996
|
+
};
|
|
3001
2997
|
};
|
|
3002
2998
|
try {
|
|
3003
|
-
const
|
|
2999
|
+
const { roots: [rootId], packages: resolved } = resolvePackageClosure({
|
|
3000
|
+
requests: [spec], readPackage, context: levelDir,
|
|
3001
|
+
validatePackage(record) {
|
|
3002
|
+
const compat = capabilityCompatibility(record.manifest);
|
|
3003
|
+
if (!compat.compatible) throw oatsError("incompatible-oats", `package ${record.package} requires OATS ${compat.range} (running ${OATS_VERSION})`);
|
|
3004
|
+
},
|
|
3005
|
+
finalizePackage: (record, deps) => ({ ...record, integrity: packageIntegrity(record.dir), deps }),
|
|
3006
|
+
discardPackage: (record) => rmSync(record.dir, { recursive: true, force: true }),
|
|
3007
|
+
});
|
|
3004
3008
|
if (opts.expectPackage && rootId !== opts.expectPackage) {
|
|
3005
3009
|
throw oatsError("duplicate-package-identity", `source ${spec} no longer provides root package "${opts.expectPackage}" (root resolved to "${rootId}")`);
|
|
3006
3010
|
}
|
|
@@ -3187,97 +3191,6 @@ export function acquirePackage(levelDir, spec, opts = {}) {
|
|
|
3187
3191
|
}
|
|
3188
3192
|
}
|
|
3189
3193
|
|
|
3190
|
-
/** Semantic lock-entry validation for a PACKAGE row (runtime API addendum §4):
|
|
3191
|
-
* source/commit pairing, canonical path, dependency references (incl. self and
|
|
3192
|
-
* cycle over the locked graph), digest shapes, uniqueness. Run BEFORE restore,
|
|
3193
|
-
* trust/approval, update/remove/migration planning, the locked-template reader,
|
|
3194
|
-
* and doctor/list consumption. Fails closed with code "invalid-lock" carrying
|
|
3195
|
-
* file/package provenance; never normalizes or auto-repairs on read. */
|
|
3196
|
-
export function validateLockEntry(packageId, entry, allPackages = {}, opts = {}) {
|
|
3197
|
-
const where = opts.file ? ` (${opts.file})` : "";
|
|
3198
|
-
const bad = (msg) => oatsError("invalid-lock", `lock entry for package "${packageId}"${where} is invalid: ${msg}`, [{ package: packageId, file: opts.file, violation: msg }]);
|
|
3199
|
-
if (!entry || typeof entry !== "object") throw bad("not an object");
|
|
3200
|
-
for (const k of ["source", "path", "version", "commit", "integrity"]) if (!entry[k] || typeof entry[k] !== "string") throw bad(`missing ${k}`);
|
|
3201
|
-
// The selected package root is a STRICT separate field (contract §1.1) stored
|
|
3202
|
-
// in canonical form only — a lock is never normalized or repaired on read, so
|
|
3203
|
-
// a non-canonical spelling ("./sub", "sub/", "") is invalid, not silently
|
|
3204
|
-
// accepted. That is what makes the root representation round-trip.
|
|
3205
|
-
{
|
|
3206
|
-
let canonical;
|
|
3207
|
-
try { canonical = normalizePackagePath(entry.path, { where: "path", code: "invalid-lock" }); }
|
|
3208
|
-
catch (e) { throw bad(`invalid path ${JSON.stringify(entry.path)} — ${e.message}`); }
|
|
3209
|
-
if (canonical !== entry.path) throw bad(`path ${JSON.stringify(entry.path)} is not in canonical form (expected ${JSON.stringify(canonical)})`);
|
|
3210
|
-
}
|
|
3211
|
-
if (!/^sha256-[0-9a-f]{64}$/.test(entry.integrity)) throw bad(`malformed integrity "${entry.integrity}"`);
|
|
3212
|
-
// Present-but-wrong-typed optional fields are invalid — default ONLY when absent.
|
|
3213
|
-
// `dependencies` is ALWAYS recorded (empty array when none), so a reader never
|
|
3214
|
-
// has to distinguish absent from empty.
|
|
3215
|
-
if (!Array.isArray(entry.dependencies)) throw bad("dependencies must be an array (empty when the package has none)");
|
|
3216
|
-
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)}`);
|
|
3217
|
-
if (new Set(entry.dependencies).size !== entry.dependencies.length) throw bad("dependencies contains duplicates");
|
|
3218
|
-
// Package rows lock transport only. A capability list, a trust list or a
|
|
3219
|
-
// dependency-closure digest here is the unsupported transitional shape — the
|
|
3220
|
-
// central parser rejects those documents outright (contract §4.1); this is
|
|
3221
|
-
// the entry-level backstop for a row reaching validation another way.
|
|
3222
|
-
for (const gone of TRANSITIONAL_ROW_FIELDS) {
|
|
3223
|
-
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)`);
|
|
3224
|
-
}
|
|
3225
|
-
let src;
|
|
3226
|
-
try { src = parseLockSource(entry.source); } catch { throw bad(`unrecognized source "${entry.source}"`); }
|
|
3227
|
-
if (src.kind === "path" && !src.path) throw bad("empty path source");
|
|
3228
|
-
if (src.kind === "git" && !src.url) throw bad("empty git source");
|
|
3229
|
-
if (src.kind === "catalog" && !src.id) throw bad("empty catalog source");
|
|
3230
|
-
if (src.kind === "path") {
|
|
3231
|
-
if (entry.commit !== "local") throw bad(`path source requires commit "local", got "${entry.commit}"`);
|
|
3232
|
-
// Local acquisition is exact-directory: the source string already names the
|
|
3233
|
-
// package root, so the only valid contained path is the root itself.
|
|
3234
|
-
if (entry.path !== ".") throw bad(`path source requires path "." (local sources are exact directories), got ${JSON.stringify(entry.path)}`);
|
|
3235
|
-
}
|
|
3236
|
-
else if (!/^[0-9a-f]{40}$/.test(entry.commit)) throw bad(`${src.kind} source requires an exact 40-hex commit, got "${entry.commit}"`);
|
|
3237
|
-
for (const d of entry.dependencies || []) {
|
|
3238
|
-
if (d === packageId) throw bad(`self-dependency "${d}"`);
|
|
3239
|
-
// Object.hasOwn: a dependency literally named "constructor"/"__proto__"
|
|
3240
|
-
// must not pass via Object.prototype.
|
|
3241
|
-
if (!Object.hasOwn(allPackages, d)) throw bad(`dependency "${d}" is not locked in the same packages map`);
|
|
3242
|
-
}
|
|
3243
|
-
// Cycle over the locked dependency graph reachable from this entry.
|
|
3244
|
-
const visiting = new Set();
|
|
3245
|
-
const visited = new Set();
|
|
3246
|
-
const walk = (id, chain) => {
|
|
3247
|
-
if (visited.has(id)) return;
|
|
3248
|
-
if (visiting.has(id)) throw bad(`dependency cycle in the locked graph: ${[...chain, id].join(" → ")}`);
|
|
3249
|
-
visiting.add(id);
|
|
3250
|
-
const deps = Object.hasOwn(allPackages, id) && Array.isArray(allPackages[id]?.dependencies) ? allPackages[id].dependencies : [];
|
|
3251
|
-
for (const d of deps) if (Object.hasOwn(allPackages, d) || d === packageId) walk(d, [...chain, id]);
|
|
3252
|
-
visiting.delete(id); visited.add(id);
|
|
3253
|
-
};
|
|
3254
|
-
walk(packageId, []);
|
|
3255
|
-
return true;
|
|
3256
|
-
}
|
|
3257
|
-
|
|
3258
|
-
/** Semantic validation of one CAPABILITY row against the whole document
|
|
3259
|
-
* (contract §4). The `package` back-reference must name a locked package: it is
|
|
3260
|
-
* the single provider truth, so a dangling reference would leave a materialized
|
|
3261
|
-
* artifact with no provenance to restore or verify it from. */
|
|
3262
|
-
export function validateCapabilityLockEntry(capabilityId, entry, allPackages = {}, opts = {}) {
|
|
3263
|
-
const where = opts.file ? ` (${opts.file})` : "";
|
|
3264
|
-
const bad = (msg) => oatsError("invalid-lock", `capability lock entry for "${capabilityId}"${where} is invalid: ${msg}`, [{ package: capabilityId, file: opts.file, violation: msg }]);
|
|
3265
|
-
if (typeof capabilityId !== "string" || !capabilityId) throw bad("empty capability id");
|
|
3266
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw bad("not an object");
|
|
3267
|
-
for (const k of ["version", "package", "path", "integrity"]) if (!entry[k] || typeof entry[k] !== "string") throw bad(`missing ${k}`);
|
|
3268
|
-
if (!PACKAGE_ID_RE.test(entry.package)) throw bad(`provider package ${JSON.stringify(entry.package)} is not a valid package identity`);
|
|
3269
|
-
if (!Object.hasOwn(allPackages, entry.package)) throw bad(`provider package "${entry.package}" is not locked in the same packages map`);
|
|
3270
|
-
{
|
|
3271
|
-
let canonical;
|
|
3272
|
-
try { canonical = normalizePackagePath(entry.path, { where: "path", code: "invalid-lock" }); }
|
|
3273
|
-
catch (e) { throw bad(`invalid path ${JSON.stringify(entry.path)} — ${e.message}`); }
|
|
3274
|
-
if (canonical !== entry.path) throw bad(`path ${JSON.stringify(entry.path)} is not in canonical form (expected ${JSON.stringify(canonical)})`);
|
|
3275
|
-
}
|
|
3276
|
-
if (!/^sha256-[0-9a-f]{64}$/.test(entry.integrity)) throw bad(`malformed integrity "${entry.integrity}"`);
|
|
3277
|
-
if (typeof entry.trusted !== "boolean") throw bad(`"trusted" must be a boolean (got ${JSON.stringify(entry.trusted)})`);
|
|
3278
|
-
return true;
|
|
3279
|
-
}
|
|
3280
|
-
|
|
3281
3194
|
/** Fetch ONE locked package's exact provenance into staging and return the
|
|
3282
3195
|
* verified payload root. Used by restore, update planning, migration and the
|
|
3283
3196
|
* locked-template reader — every path that needs the exact bytes a package row
|
|
@@ -3661,33 +3574,27 @@ export function readLockedConfigTemplates(startDir, packageId, opts = {}) {
|
|
|
3661
3574
|
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
3662
3575
|
}
|
|
3663
3576
|
|
|
3664
|
-
/** Validate one LEGACY (lockfileVersion 1) capability lock entry against the v1
|
|
3665
|
-
* schema shape; returns null when valid or a violation string.
|
|
3666
|
-
*
|
|
3667
|
-
* Engine-internal: the strict reader uses it so a malformed v1 entry is refused
|
|
3668
|
-
* before any consumer sees the map. It is deliberately NOT a "residue" check —
|
|
3669
|
-
* migration produces no residue, and the superseded transitional v2 shape is
|
|
3670
|
-
* rejected wholesale rather than partially parsed. */
|
|
3671
|
-
export function legacyCapabilityEntryViolation(entry) {
|
|
3672
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return "not an object";
|
|
3673
|
-
for (const k of ["source", "version", "integrity"]) if (typeof entry[k] !== "string" || !entry[k]) return `missing/invalid ${k}`;
|
|
3674
|
-
if (!/^sha256-[0-9a-f]{64}$/.test(entry.integrity)) return `malformed integrity "${entry.integrity}"`;
|
|
3675
|
-
if (entry.commit !== undefined && typeof entry.commit !== "string") return "invalid commit";
|
|
3676
|
-
if (entry.trustedExecutables !== undefined && typeof entry.trustedExecutables !== "boolean") return "invalid trustedExecutables";
|
|
3677
|
-
return null;
|
|
3678
|
-
}
|
|
3679
|
-
|
|
3680
3577
|
/** Atomic file replacement: write to a same-directory temp file, then rename
|
|
3681
3578
|
* over the destination — an interrupted write leaves the original bytes intact
|
|
3682
3579
|
* (reviewer-21849d4). */
|
|
3683
|
-
function atomicWriteFileSync(file, content) {
|
|
3580
|
+
function atomicWriteFileSync(file, content, { assertRoots } = {}) {
|
|
3684
3581
|
const tmp = join(dirname(file), `.${basename(file)}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
3582
|
+
let written = false;
|
|
3685
3583
|
try {
|
|
3686
|
-
|
|
3584
|
+
assertRoots?.();
|
|
3585
|
+
writeFileSync(tmp, content, assertRoots ? { flag: "wx", mode: 0o600 } : undefined); written = true;
|
|
3586
|
+
assertRoots?.();
|
|
3687
3587
|
renameSync(tmp, file);
|
|
3688
|
-
} catch (
|
|
3689
|
-
|
|
3690
|
-
|
|
3588
|
+
} catch (error) {
|
|
3589
|
+
// Never follow a replacement parent to clean up a same-named temp file.
|
|
3590
|
+
// Unguarded legacy callers retain their existing behavior.
|
|
3591
|
+
if (assertRoots && !written) throw error;
|
|
3592
|
+
try { assertRoots?.(); rmSync(tmp, { force: true }); }
|
|
3593
|
+
catch (cleanup) {
|
|
3594
|
+
const failure = new AggregateError([error, cleanup], "metadata publication and owned cleanup failed", { cause: error });
|
|
3595
|
+
failure.code = error.code || cleanup.code; throw failure;
|
|
3596
|
+
}
|
|
3597
|
+
throw error;
|
|
3691
3598
|
}
|
|
3692
3599
|
}
|
|
3693
3600
|
|
|
@@ -4170,15 +4077,8 @@ export function composeInstanceAgentsMd(soulDir, contextDir, soulName, workMode,
|
|
|
4170
4077
|
if (cap.inject && existsSync(cap.inject)) wanted.push([`capability:${cap.id}`, cap.inject]);
|
|
4171
4078
|
}
|
|
4172
4079
|
for (const inj of resolved.injects) wanted.push([`config:${inj.source}`, inj.file]);
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
for (const [source, file] of wanted) {
|
|
4176
|
-
const content = readFileSync(file, "utf8").trim();
|
|
4177
|
-
const block = `<!-- oats:${source} src=${file} -->\n${content}\n<!-- /oats:${source} -->`;
|
|
4178
|
-
text += `\n${block}\n`;
|
|
4179
|
-
blocks.push({ source, file, content });
|
|
4180
|
-
}
|
|
4181
|
-
return { text, blocks, resolved };
|
|
4080
|
+
const blocks = wanted.map(([source, file]) => ({ source, file, content: readFileSync(file, "utf8").trim() }));
|
|
4081
|
+
return { text: renderInstructionText(readFileSync(agentsMd, "utf8"), blocks), blocks, resolved };
|
|
4182
4082
|
}
|
|
4183
4083
|
|
|
4184
4084
|
/** The skill entries a tree contributes — THE discovery rule, shared by preflight
|
|
@@ -4726,6 +4626,187 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
|
|
|
4726
4626
|
return results;
|
|
4727
4627
|
}
|
|
4728
4628
|
|
|
4629
|
+
/** Derive one selected supplemental input from the exact hook owner's binding
|
|
4630
|
+
* and already-validated generic target. Never infer dependency from a slot/name. */
|
|
4631
|
+
function capturedHookSourceReceipt(loaded) {
|
|
4632
|
+
const { record, capability, invocation } = loaded, target = invocation?.instance;
|
|
4633
|
+
const declaration = capability && hookDeclaration(capability.manifest.hooks?.[invocation?.action.name]);
|
|
4634
|
+
if (!target || invocation.action.kind !== "hook" || invocation.capability !== capability.id || declaration?.inputs?.sourceReceipt?.version !== 1) throw oatsError("invalid-resolution", "source receipt requires an opted-in exact owned hook target");
|
|
4635
|
+
const binding = record.bindings[capability.manifest.layer];
|
|
4636
|
+
if (!binding || binding.capability !== capability.id) throw oatsError("needs-configuration", "source receipt requires the opting capability's actual selected binding");
|
|
4637
|
+
const roleFile = loaded.resources.get(record.dispatch.composition?.body);
|
|
4638
|
+
if (!roleFile) throw oatsError("invalid-resolution", "selected source input lacks its canonical body resource");
|
|
4639
|
+
return validateCapturedSourceReceipt({ schemaVersion: 1, kind: record.subject.kind,
|
|
4640
|
+
home: target.home, work: target.work, context: invocation.executionBinding.deployment,
|
|
4641
|
+
agent: target.agent, instance: target.name, sourceIdentity: record.subject.kind === "persistent" ? record.subject.soul.identity : null,
|
|
4642
|
+
role: decodeUtf8(readPortableBytes(roleFile)), executionBinding: invocation.executionBinding,
|
|
4643
|
+
responsibleHuman: invocation.responsibleHuman, binding });
|
|
4644
|
+
}
|
|
4645
|
+
|
|
4646
|
+
/** Captured lifecycle execution uses the same hook result contract, but every
|
|
4647
|
+
* executable/settings/binding input comes from one verified resolution. It
|
|
4648
|
+
* preflights all applicable hooks before the first lifecycle side effect. */
|
|
4649
|
+
export function runCapturedLifecycleHooks(event, { deployment, resolution, home, instance, agentName, priorMeta = {}, extraEnv = {}, sourceReceipt, assertRoots, retryIntents = {} }) {
|
|
4650
|
+
if (!APPROVED_HOOKS.has(event)) throw oatsError("unsupported-action", `unsupported captured lifecycle event ${JSON.stringify(event)}`);
|
|
4651
|
+
for (const [name, value] of [["home", home], ["deployment", deployment]]) if (typeof value !== "string" || !isAbsolute(value)) throw oatsError("invalid-declaration", `${name} must be absolute`);
|
|
4652
|
+
if (typeof instance !== "string" || !instance || typeof agentName !== "string" || !agentName) throw oatsError("invalid-declaration", "captured lifecycle needs instance and agent names");
|
|
4653
|
+
if (sourceReceipt !== undefined) validateCapturedSourceReceipt(sourceReceipt);
|
|
4654
|
+
const inspected = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
|
|
4655
|
+
if (sourceReceipt) {
|
|
4656
|
+
const { record } = inspected, persistent = record.subject.kind === "persistent";
|
|
4657
|
+
const expectedAgent = persistent ? record.subject.soul.alias : record.subject.name;
|
|
4658
|
+
const expectedIdentity = persistent ? record.subject.soul.identity : null;
|
|
4659
|
+
const expectedHuman = record.messagingChoice.enabled ? record.messagingChoice.privateKey.human : null;
|
|
4660
|
+
const expectedBinding = Object.values(record.bindings).find((binding) => binding.capability === sourceReceipt.binding.capability);
|
|
4661
|
+
const bodyKey = record.dispatch.composition?.body, bodyFile = bodyKey ? inspected.resources.get(bodyKey) : undefined;
|
|
4662
|
+
let role; try { role = bodyFile ? readFileSync(bodyFile, "utf8") : undefined; } catch { role = undefined; }
|
|
4663
|
+
let sameDeployment = false;
|
|
4664
|
+
try { sameDeployment = portableScope(sourceReceipt.executionBinding.deployment) === portableScope(deployment); } catch { /* invalid scope refuses below */ }
|
|
4665
|
+
if (sourceReceipt.kind !== (persistent ? "persistent" : "helper") || sourceReceipt.home !== resolve(home)
|
|
4666
|
+
|| sourceReceipt.work !== join(resolve(home), "work") || sourceReceipt.agent !== expectedAgent || agentName !== expectedAgent || sourceReceipt.instance !== instance
|
|
4667
|
+
|| sourceReceipt.executionBinding.resolution.id !== resolution.id || !sameDeployment
|
|
4668
|
+
|| canonicalJson(sourceReceipt.sourceIdentity) !== canonicalJson(expectedIdentity)
|
|
4669
|
+
|| canonicalJson(sourceReceipt.responsibleHuman) !== canonicalJson(expectedHuman)
|
|
4670
|
+
|| !expectedBinding || canonicalJson(sourceReceipt.binding) !== canonicalJson(expectedBinding)
|
|
4671
|
+
|| role === undefined || sourceReceipt.role !== role) {
|
|
4672
|
+
throw oatsError("invalid-resolution", "captured source receipt differs from the verified resolution or lifecycle target");
|
|
4673
|
+
}
|
|
4674
|
+
}
|
|
4675
|
+
const ids = Object.keys(inspected.record.dispatch.providerManifests);
|
|
4676
|
+
if (event === "retire") ids.reverse();
|
|
4677
|
+
const entries = ids.flatMap((id) => {
|
|
4678
|
+
const capability = inspected.capabilities.get(id), declaration = hookDeclaration(capability?.manifest?.hooks?.[event]);
|
|
4679
|
+
return declaration ? [{ id, capability, declaration }] : [];
|
|
4680
|
+
});
|
|
4681
|
+
if (sourceReceipt && !entries.some(({ id, declaration }) => id === sourceReceipt.binding.capability && declaration.inputs?.sourceReceipt?.version === 1)) throw oatsError("invalid-resolution", "explicit source receipt requires a matching opted-in hook owner");
|
|
4682
|
+
objectAt(retryIntents, entries.map(({ id }) => id), []);
|
|
4683
|
+
const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [], intents: {} };
|
|
4684
|
+
const checkRoots = () => {
|
|
4685
|
+
try { assertRoots?.(); }
|
|
4686
|
+
catch (error) { error.capturedHooks = { meta: results.meta, intents: results.intents, failures: results.failures, order: results.order }; throw error; }
|
|
4687
|
+
};
|
|
4688
|
+
const envOwners = new Map(), envDeclarations = new Map(entries.map(({ id, capability }) => [id, {
|
|
4689
|
+
names: new Set(capability.manifest.environment || []), namespaces: [...(capability.manifest.environmentNamespaces || [])],
|
|
4690
|
+
}]));
|
|
4691
|
+
const ready = [];
|
|
4692
|
+
for (const entry of entries) {
|
|
4693
|
+
const options = { deployment, resolution, action: { kind: "hook", capability: entry.id, name: event },
|
|
4694
|
+
invocationTarget: { home, work: join(home, "work"), name: instance, agent: agentName },
|
|
4695
|
+
...(Object.hasOwn(priorMeta, entry.id) ? { priorReceipt: priorMeta[entry.id] } : {}) };
|
|
4696
|
+
let staticLoaded;
|
|
4697
|
+
try {
|
|
4698
|
+
// Check every static source/target/approval/executable before any effects.
|
|
4699
|
+
// Readiness is action-specific and receives its admitted intent below.
|
|
4700
|
+
staticLoaded = loadCapturedAction(options, capturedDispatchCodecs(options, { admitting: true }));
|
|
4701
|
+
} catch (error) {
|
|
4702
|
+
const detail = String(error.message || error).slice(0, 200);
|
|
4703
|
+
results.warnings.push(`${entry.id} ${event} hook unavailable: ${detail}`);
|
|
4704
|
+
results.failures.push({ capability: entry.id, event, message: detail, required: entry.declaration.required });
|
|
4705
|
+
if (event === "spawn" && entry.declaration.required) return results;
|
|
4706
|
+
continue;
|
|
4707
|
+
}
|
|
4708
|
+
// Selected-input authority failures are not optional hook functionality.
|
|
4709
|
+
// Derive ALL requested owner inputs before any provider check/effect.
|
|
4710
|
+
const selectedReceipt = entry.declaration.inputs?.sourceReceipt ? capturedHookSourceReceipt(staticLoaded) : null;
|
|
4711
|
+
if (sourceReceipt?.binding.capability === entry.id && canonicalJson(sourceReceipt) !== canonicalJson(selectedReceipt)) throw oatsError("invalid-resolution", "explicit receipt differs from the derived opted-in input");
|
|
4712
|
+
ready.push({ ...entry, options, selectedReceipt, instanceFacts: staticLoaded.invocation.instance });
|
|
4713
|
+
}
|
|
4714
|
+
for (const { id, capability, declaration, options, selectedReceipt, instanceFacts } of ready) {
|
|
4715
|
+
checkRoots(); results.order.push(id);
|
|
4716
|
+
const env = { ...process.env };
|
|
4717
|
+
for (const key of Object.keys(env)) if (key.startsWith("OATS_") || key.startsWith("PI_AGENT_") || key === "PI_AGENTS_ROOT") delete env[key];
|
|
4718
|
+
Object.assign(env, extraEnv, {
|
|
4719
|
+
OATS_EVENT: event, OATS_INSTANCE: instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, OATS_AGENT: agentName,
|
|
4720
|
+
OATS_CAPABILITY: id, OATS_CAPABILITY_ROOT: capability.manifest._dir, OATS_LAYER: capability.manifest.layer || "",
|
|
4721
|
+
OATS_CONTEXT: deployment, OATS_WORKSPACE: deployment, OATS_LEVEL: deployment,
|
|
4722
|
+
OATS_DEPLOYMENT: deployment, OATS_RESOLUTION: resolution.id,
|
|
4723
|
+
OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")), OATS_SETTINGS: JSON.stringify(capability.settings),
|
|
4724
|
+
OATS_META: JSON.stringify(priorMeta[id] || {}),
|
|
4725
|
+
});
|
|
4726
|
+
// Caller extras cannot nominate an input file, including when no owner
|
|
4727
|
+
// selected that input. Only private wrappers below supply these variables.
|
|
4728
|
+
for (const key of ["OATS_SOURCE_RECEIPT_FILE", "OATS_INVOCATION_CONTEXT_FILE", "OATS_BINDING_FILE"]) delete env[key];
|
|
4729
|
+
let admission, started = false;
|
|
4730
|
+
try {
|
|
4731
|
+
admission = admitCapturedAction({ deployment, resolution, home, action: options.action,
|
|
4732
|
+
...(Object.hasOwn(priorMeta, id) ? { priorReceipt: priorMeta[id] } : {}),
|
|
4733
|
+
...(Object.hasOwn(retryIntents, id) ? { retryExecutionId: retryIntents[id] } : {}) });
|
|
4734
|
+
results.intents[id] = admission.intent;
|
|
4735
|
+
if (admission.replayed) {
|
|
4736
|
+
// Never re-execute completed hooks to reconstruct runtime contributions.
|
|
4737
|
+
if (!admission.replayable) throw oatsError("needs-configuration", "completed hook has non-replayable runtime contributions");
|
|
4738
|
+
if (admission.receipt !== null) results.meta[id] = admission.receipt;
|
|
4739
|
+
continue;
|
|
4740
|
+
}
|
|
4741
|
+
const admittedOptions = { ...options, intent: admission.intent, priorReceipt: admission.receipt };
|
|
4742
|
+
const admitted = loadCapturedAction(admittedOptions, capturedDispatchCodecs(admittedOptions, { admitting: true }));
|
|
4743
|
+
if (canonicalJson(admitted.invocation.instance) !== canonicalJson(instanceFacts)) throw oatsError("selection-changed", "hook incarnation changed after input preflight");
|
|
4744
|
+
if (selectedReceipt && canonicalJson(capturedHookSourceReceipt(admitted)) !== canonicalJson(selectedReceipt)) throw oatsError("selection-changed", "selected source input changed after admission");
|
|
4745
|
+
const loaded = loadCapturedDispatch(admittedOptions);
|
|
4746
|
+
env.OATS_META = JSON.stringify(admission.receipt ?? {});
|
|
4747
|
+
const invoke = (snapshotEnv = {}) => withCapturedInvocationContextFile(loaded.invocation, contextEnv => withCapturedBindingFile(loaded, bindingEnv => {
|
|
4748
|
+
checkRoots();
|
|
4749
|
+
beginCapturedIntent({ deployment, home, intent: admission.intent, action: options.action }); started = true;
|
|
4750
|
+
return execFileSync(process.execPath, [loaded.executable.file, ...loaded.executable.args], { cwd: home, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000, killSignal: "SIGKILL", env: { ...env, ...snapshotEnv, ...contextEnv, ...bindingEnv } });
|
|
4751
|
+
}));
|
|
4752
|
+
const stdout = selectedReceipt ? withCapturedSourceReceiptFile(home, selectedReceipt, invoke) : invoke();
|
|
4753
|
+
const lastLine = String(stdout).trim().split("\n").filter(Boolean).pop() || "{}";
|
|
4754
|
+
let output = {}; try { output = JSON.parse(lastLine); } catch { /* non-JSON hook output is allowed */ }
|
|
4755
|
+
if (output.meta) results.meta[id] = output.meta;
|
|
4756
|
+
if (output.brief) results.briefs.push(`- ${output.brief}`);
|
|
4757
|
+
if (output.warning) results.warnings.push(output.warning);
|
|
4758
|
+
if (output.launch && typeof output.launch === "object") for (const [runtime, values] of Object.entries(output.launch)) results.launch[runtime] = `${results.launch[runtime] ? `${results.launch[runtime]} ` : ""}${values}`;
|
|
4759
|
+
if (event === "launch" || (output.launch && typeof output.launch === "object" && Object.keys(output.launch).length) || (output.env && typeof output.env === "object" && Object.keys(output.env).length)) {
|
|
4760
|
+
results.contributions.push({ capability: id, layer: capability.manifest.layer || null, level: deployment, settings: { ...capability.settings },
|
|
4761
|
+
trust: { trusted: true, integrity: inspected.record.artifacts.capabilities[id].artifact.integrity.value },
|
|
4762
|
+
launch: output.launch && typeof output.launch === "object" ? { ...output.launch } : {}, env: output.env && typeof output.env === "object" ? Object.keys(output.env).sort() : [] });
|
|
4763
|
+
}
|
|
4764
|
+
if (output.env !== undefined) {
|
|
4765
|
+
if (event !== "spawn" && event !== "launch") throw new HookEnvironmentContractError(`${id} hook env is supported only for spawn and launch, not ${event}`);
|
|
4766
|
+
Object.assign(results.env, validateHookEnvironment(id, output.env, envOwners, envDeclarations));
|
|
4767
|
+
}
|
|
4768
|
+
settleCapturedIntent({ deployment, home, intent: admission.intent, action: options.action, state: "completed",
|
|
4769
|
+
receipt: results.meta[id] ?? admission.receipt,
|
|
4770
|
+
replayable: !Object.keys(output.env || {}).length && !Object.keys(output.launch || {}).length });
|
|
4771
|
+
} catch (error) {
|
|
4772
|
+
// A nested private-snapshot cleanup failure must not erase a hook's
|
|
4773
|
+
// already-observed provider receipt. Unwrap bounded owned error causes;
|
|
4774
|
+
// never reinterpret that receipt as a successful/fully cleaned lifecycle.
|
|
4775
|
+
let observed, reported;
|
|
4776
|
+
const seen = new Set(); let cause = error;
|
|
4777
|
+
while (cause && typeof cause === "object" && !seen.has(cause) && seen.size < 16) {
|
|
4778
|
+
seen.add(cause);
|
|
4779
|
+
if (cause.invocationCompleted && typeof cause.invocationResult === "string") { observed = cause.invocationResult; break; }
|
|
4780
|
+
if (typeof cause.stdout === "string" || Buffer.isBuffer(cause.stdout)) { observed = cause.stdout; break; }
|
|
4781
|
+
cause = cause.cause;
|
|
4782
|
+
}
|
|
4783
|
+
try {
|
|
4784
|
+
const output = JSON.parse(String(observed ?? "").trim().split("\n").filter(Boolean).pop() || "{}");
|
|
4785
|
+
if (output?.meta) results.meta[id] = output.meta;
|
|
4786
|
+
if (typeof output?.warning === "string" && output.warning.trim()) reported = output.warning.trim();
|
|
4787
|
+
} catch { /* non-JSON hook failure output is allowed */ }
|
|
4788
|
+
let custodyError;
|
|
4789
|
+
if (admission && !admission.replayed) {
|
|
4790
|
+
try { settleCapturedIntent({ deployment, home, intent: admission.intent, action: options.action,
|
|
4791
|
+
state: started ? "unconfirmed" : "blocked", receipt: results.meta[id] ?? admission.receipt }); }
|
|
4792
|
+
catch (failure) { custodyError = failure; } // Possibly published: retain, never erase/re-admit.
|
|
4793
|
+
}
|
|
4794
|
+
const environment = error instanceof HookEnvironmentContractError;
|
|
4795
|
+
const cleanup = error instanceof AggregateError;
|
|
4796
|
+
const required = environment || cleanup || !!custodyError || declaration.required;
|
|
4797
|
+
const detail = reported || String(error.message || error).slice(0, 200);
|
|
4798
|
+
results.warnings.push(`${id} ${event} hook ${environment ? "environment contract " : ""}failed: ${detail}`);
|
|
4799
|
+
results.failures.push({ capability: id, event, message: detail, required,
|
|
4800
|
+
...(started || custodyError ? { unconfirmed: true } : {}),
|
|
4801
|
+
...(custodyError ? { custody: { code: custodyError.code || "E_INTENT_CUSTODY", message: String(custodyError.message).slice(0, 200) } } : {}),
|
|
4802
|
+
...(environment ? { contract: "environment" } : {}),
|
|
4803
|
+
...(cleanup ? { contract: "snapshot-cleanup", unconfirmed: true, cleanup: { code: error.code || "E_HOOK_CLEANUP", message: String(error.message || error).slice(0, 200) } } : {}) });
|
|
4804
|
+
if (event === "spawn" && required) return results;
|
|
4805
|
+
} finally { checkRoots(); }
|
|
4806
|
+
}
|
|
4807
|
+
return results;
|
|
4808
|
+
}
|
|
4809
|
+
|
|
4729
4810
|
// ---------- agents ----------
|
|
4730
4811
|
/** All local-agent base dirs readable for a root: the scope sibling (canonical)
|
|
4731
4812
|
* plus legacy nested locations. */
|
|
@@ -5452,7 +5533,9 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
|
|
|
5452
5533
|
const hookArgs = recipe.hooks?.launch?.[runtime] || "";
|
|
5453
5534
|
const tail = `${cfgArgs ? ` ${cfgArgs}` : ""}${hookArgs ? ` ${hookArgs}` : ""}`;
|
|
5454
5535
|
let cmdline;
|
|
5455
|
-
if (
|
|
5536
|
+
if (isPiSdkHost(recipe)) {
|
|
5537
|
+
cmdline = `${shq(executable)} ${piHostArgv(recipe, { home, sessionDir: capturedPiSessionDirectory(home) }).map(shq).join(" ")}`;
|
|
5538
|
+
} else if (runtime === "claude") {
|
|
5456
5539
|
cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
|
|
5457
5540
|
} else if (runtime === "codex") {
|
|
5458
5541
|
const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
|
|
@@ -5472,15 +5555,27 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
|
|
|
5472
5555
|
/** Execution, not preview: mark pending before dispatch, then resolve native
|
|
5473
5556
|
* storage inside the backend shell under the actual command environment.
|
|
5474
5557
|
* The original executable/argv is exec'd unchanged after recording succeeds. */
|
|
5475
|
-
function nativeRecordCommand(command, home, runtime) {
|
|
5558
|
+
function nativeRecordCommand(command, home, runtime, onPrepared, prepare = prepareNativeStart) {
|
|
5476
5559
|
const { tokens, binary } = parseLaunchCommand(command);
|
|
5477
5560
|
const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
|
|
5478
|
-
const id =
|
|
5561
|
+
const id = prepare(home, runtime);
|
|
5562
|
+
onPrepared?.(id);
|
|
5479
5563
|
const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
|
|
5480
5564
|
const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(runtime)} ${shq(JSON.stringify(args))} && exec ${tokens.slice(binary).map(t => t.text).join(" ")}`;
|
|
5481
5565
|
return `${tokens.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
|
|
5482
5566
|
}
|
|
5483
5567
|
|
|
5568
|
+
/** Common tmux/Herdr completion wrapper for the exact captured Pi SDK profile.
|
|
5569
|
+
* Save the actual native chain's shell status BEFORE the observer. Reuse the
|
|
5570
|
+
* original command environment (including its ORIGINAL attempt); no new intent
|
|
5571
|
+
* or process identity is constructed. The observer never replaces this status. */
|
|
5572
|
+
export function renderCapturedPiCompletion(command, executionCommand, { home, nativeRecordId }) {
|
|
5573
|
+
const { tokens, binary } = parseLaunchCommand(command);
|
|
5574
|
+
const prefix = tokens.slice(0, binary).map(token => token.text).join(" ");
|
|
5575
|
+
const args = ["--oats-pi-record-exit", "1", "--home", home, "--native-record", nativeRecordId].map(shq).join(" ");
|
|
5576
|
+
return `${executionCommand}; oats_start_status=$?; ${prefix} ${shq(process.execPath)} ${shq(PI_SDK_HOST)} ${args} --exit-status "$oats_start_status"`;
|
|
5577
|
+
}
|
|
5578
|
+
|
|
5484
5579
|
/** A recorded recipe this kernel understands, or a refusal before anything
|
|
5485
5580
|
* is observed or stopped. */
|
|
5486
5581
|
export function assertLaunchRecipe(recipe, what) {
|
|
@@ -6744,6 +6839,7 @@ function sessionDirectoryGuard(home) {
|
|
|
6744
6839
|
let meta;
|
|
6745
6840
|
try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { return; } // ordinary receipt validation reports this
|
|
6746
6841
|
if ((meta.work === "directory") !== (baseline.directoryWork === true)) throw oatsError("E_WORK_INSPECTION_FAILED", "directory work mode disagrees with independent session authority");
|
|
6842
|
+
if (baseline.executionBinding && (meta.incarnationId !== baseline.incarnationId || canonicalJson(meta.executionBinding ?? null) !== canonicalJson(baseline.executionBinding))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "captured native baseline differs from current incarnation binding");
|
|
6747
6843
|
};
|
|
6748
6844
|
check();
|
|
6749
6845
|
return check;
|
|
@@ -6953,13 +7049,14 @@ function retirementDisposableRoots(work, workMode, capabilities) {
|
|
|
6953
7049
|
return roots;
|
|
6954
7050
|
}
|
|
6955
7051
|
|
|
6956
|
-
function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime) {
|
|
7052
|
+
function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
|
|
6957
7053
|
if (mode === "directory") assertDirectoryRoots(home);
|
|
6958
7054
|
const isWorktree = mode === "worktree";
|
|
6959
7055
|
const status = isWorktree && existsSync(work) ? worktreeStatus(work) : "";
|
|
6960
7056
|
const disposableReceipts = isWorktree ? retirementDisposableRoots(work, workMode, capabilities) : [];
|
|
6961
7057
|
const baseline = {
|
|
6962
7058
|
version: RETIRE_BASELINE_VERSION,
|
|
7059
|
+
...(incarnationId ? { incarnationId, executionBinding } : {}),
|
|
6963
7060
|
...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
|
|
6964
7061
|
home: realPathOrNearest(home),
|
|
6965
7062
|
homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
|
|
@@ -6973,7 +7070,7 @@ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runti
|
|
|
6973
7070
|
};
|
|
6974
7071
|
const path = retirementBaselinePath(home);
|
|
6975
7072
|
mkdirSync(dirname(path), { recursive: true });
|
|
6976
|
-
writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600 });
|
|
7073
|
+
writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600, ...(exclusive ? { flag: "wx" } : {}) });
|
|
6977
7074
|
}
|
|
6978
7075
|
|
|
6979
7076
|
function nestedGitRoots(root) {
|
|
@@ -7431,12 +7528,251 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
|
|
|
7431
7528
|
* per-home lock and pending receipt across stop and launch, every preflight
|
|
7432
7529
|
* before the stop, a bounded SIGTERM with no escalation, factual receipts.
|
|
7433
7530
|
* Not a retirement: home, work, identity and notes stay. */
|
|
7531
|
+
/** Exact captured plan, existing native session transaction. This is dispatch
|
|
7532
|
+
* custody, not provider enrollment or proof that a model finished its task. */
|
|
7533
|
+
export function startCapturedInstanceSession(home, options = {}) {
|
|
7534
|
+
objectAt(options, ["deployment", "resolution", "backend", "task", "retryExecutionId", "restart", "env", "io", "stopGraceMs"], []);
|
|
7535
|
+
if (options.stopGraceMs !== undefined && (!Number.isSafeInteger(options.stopGraceMs) || options.stopGraceMs < 1 || options.stopGraceMs > 300000)) throw oatsError("E_BAD_ARGS", "captured stop grace must be 1-300000 milliseconds");
|
|
7536
|
+
let metadata = readCapturedInstanceMetadata(home);
|
|
7537
|
+
home = metadata.home;
|
|
7538
|
+
const binding = metadata.executionBinding, deployment = portableScope(options.deployment ?? binding.deployment), resolution = options.resolution ?? binding.resolution;
|
|
7539
|
+
if (canonicalJson({ schemaVersion: 1, deployment, resolution }) !== canonicalJson(binding)) throw oatsError("invalid-resolution", "captured start selector differs from the owned home");
|
|
7540
|
+
const original = readCapturedInstanceAuthority(deployment, home).row;
|
|
7541
|
+
if (!original.nativeScaffold || canonicalJson(metadata.captured.nativeScaffold ?? null) !== canonicalJson(original.nativeScaffold)) throw oatsError("migration-required", "captured home lacks fresh native scaffold evidence; no history backfill was performed");
|
|
7542
|
+
const latestSession = original.intents.findLast(intent => intent.action.kind === "session");
|
|
7543
|
+
const heldLifecycle = (message, indexedStatus = original.status) => {
|
|
7544
|
+
const error = oatsError("selection-changed", message);
|
|
7545
|
+
error.home = home;
|
|
7546
|
+
error.capturedCustody = { incarnationId: original.incarnationId, executionBinding: binding,
|
|
7547
|
+
indexedStatus, metadataStatus: metadata.captured.lifecycle, held: true,
|
|
7548
|
+
...(latestSession ? { intent: { schemaVersion: 1, executionId: latestSession.executionId, incarnationId: original.incarnationId, attempt: latestSession.attempt }, receipt: latestSession.receipt } : {}) };
|
|
7549
|
+
throw error;
|
|
7550
|
+
};
|
|
7551
|
+
const sourceStatus = metadata.captured.lifecycle;
|
|
7552
|
+
if (original.status !== sourceStatus || !["spawned-launch-pending", "start-dispatched", "start-failed-cleanup-required"].includes(sourceStatus)) heldLifecycle("captured native start requires matching permitted metadata/index lifecycle");
|
|
7553
|
+
if (sourceStatus === "start-failed-cleanup-required") {
|
|
7554
|
+
if (!latestSession || options.retryExecutionId !== latestSession.executionId) heldLifecycle("native cleanup requires its exact saved retry, not a new request");
|
|
7555
|
+
if (latestSession.state === "completed") heldLifecycle("completed native dispatch still has publication debt; explicit reconciliation is required");
|
|
7556
|
+
}
|
|
7557
|
+
let ownedState = sourceStatus;
|
|
7558
|
+
const assertRoots = () => {
|
|
7559
|
+
const row = assertCapturedInstanceCustody(original);
|
|
7560
|
+
if (row.status !== ownedState) heldLifecycle("native start no longer owns lifecycle state", row.status);
|
|
7561
|
+
const history = nativeHistoryPath(home), baseline = retirementBaselinePath(home);
|
|
7562
|
+
for (const [key, path] of Object.entries({ historyRoot: dirname(history), history, retirementRoot: dirname(dirname(baseline)), baselines: dirname(baseline) })) {
|
|
7563
|
+
if (!lstatSync(path).isDirectory() || canonicalJson(directoryIdentity(path)) !== canonicalJson(original.nativeScaffold.directories[key])) throw oatsError("integrity-drift", "native custody directory was replaced");
|
|
7564
|
+
}
|
|
7565
|
+
// The strict Pi record owner validates its private manifest via the pinned
|
|
7566
|
+
// inspect/prepare/started APIs. Its approved expected-ID claim legitimately
|
|
7567
|
+
// advances v1 -> v2 INSIDE prepare's assertAuthority callbacks. This callback
|
|
7568
|
+
// guards kernel custody, not a duplicate/reentrant record codec. Freezing
|
|
7569
|
+
// record-owned bytes to v1 here makes every valid Pi claim fail mid-write.
|
|
7570
|
+
// Literal legacy manifest custody stays unchanged for other runtimes.
|
|
7571
|
+
if (!piHost) {
|
|
7572
|
+
const manifest = parseStrictJson(readPortableBytes(join(history, "history.json")));
|
|
7573
|
+
if (canonicalJson(manifest) !== canonicalJson({ version: 1, home, completeHistory: true })) throw oatsError("integrity-drift", "native history scaffold authority changed");
|
|
7574
|
+
}
|
|
7575
|
+
const current = readCapturedInstanceMetadata(home);
|
|
7576
|
+
assertCapturedSessionPlacement(current, backend, knownTargets);
|
|
7577
|
+
const saved = parseStrictJson(readPortableBytes(baseline)), authority = runtimeAuthorityOf(saved);
|
|
7578
|
+
if (saved.version !== RETIRE_BASELINE_VERSION || saved.home !== home || saved.incarnationId !== original.incarnationId
|
|
7579
|
+
|| canonicalJson(saved.executionBinding) !== canonicalJson(binding) || !authority) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native baseline differs from captured incarnation");
|
|
7580
|
+
if (authority.launched) {
|
|
7581
|
+
const target = authority.sessionTarget ?? { backend: "tmux", ...authority.tmux };
|
|
7582
|
+
validateCapturedSessionTarget(target, backend, current.instance);
|
|
7583
|
+
if (backend.backend === "herdr" && !knownTargets.some(known => canonicalJson(known) === canonicalJson(target))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native baseline target lacks indexed custody");
|
|
7584
|
+
} else if (current.launched || sessionHistory.some(intent => intent.state === "completed" && intent.receipt?.target)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native launch history contradicts an unlaunched baseline");
|
|
7585
|
+
if (current.launched !== authority.launched && !existsSync(join(home, ".oats-start-pending.json"))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native launch-state mismatch lacks pending recovery custody");
|
|
7586
|
+
return row;
|
|
7587
|
+
};
|
|
7588
|
+
const loaded = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
|
|
7589
|
+
const record = loaded.record, expectedAgent = record.subject.kind === "persistent" ? record.subject.soul.alias : record.subject.name;
|
|
7590
|
+
const human = record.messagingChoice.enabled ? record.messagingChoice.privateKey.human : null;
|
|
7591
|
+
if (metadata.agent !== expectedAgent || metadata.kind !== record.subject.kind || canonicalJson(metadata.responsibleHuman) !== canonicalJson(human)) throw oatsError("invalid-resolution", "native target differs from captured subject/human");
|
|
7592
|
+
if (record.dispatch.composition?.mode !== "directory") throw oatsError("needs-configuration", "captured native start requires supported owned directory work");
|
|
7593
|
+
if (loaded.approvals.some(item => !["approved", "not-required"].includes(item.status))) throw oatsError("approval-required", "captured native start needs every exact executable approval");
|
|
7594
|
+
if (!record.dispatch.launch) throw oatsError("needs-configuration", "captured record has no explicit launch inputs");
|
|
7595
|
+
if (record.dispatch.runtimePackages.length || applicableRequirements(record.dispatch.launch.runtime, [...loaded.capabilities.values()]).length) throw oatsError("needs-configuration", "required managed runtime roots/loader are not yet qualified; no current package discovery was used");
|
|
7596
|
+
if ([...loaded.capabilities.values()].some(cap => cap.manifest.hooks?.launch) || original.intents.some(intent => intent.action.kind === "hook" && intent.action.name === "spawn" && !intent.replayable)) throw oatsError("needs-configuration", "captured native hook contribution refresh is not qualified");
|
|
7597
|
+
const recipe = structuredClone(record.dispatch.launch);
|
|
7598
|
+
if (recipe.executableResource) {
|
|
7599
|
+
const resource = loaded.resources.get(recipe.executableResource);
|
|
7600
|
+
if (!resource) throw oatsError("resolution-incomplete", "captured native entrypoint resource is missing");
|
|
7601
|
+
const manifest = loaded.manifests.get(recipe.entrypoint.capability), specification = manifest?.commands?.[recipe.entrypoint.command];
|
|
7602
|
+
if (typeof specification !== "string") throw oatsError("invalid-resolution", "retained native entrypoint is not declared");
|
|
7603
|
+
const [script, ...declaredArgs] = specification.trim().split(/\s+/), declaredFile = capabilityExecutablePath(manifest, script);
|
|
7604
|
+
if (!declaredFile || realpathSync(declaredFile) !== resource) throw oatsError("invalid-resolution", "native entrypoint resource differs from retained command");
|
|
7605
|
+
if (declaredArgs.length) throw oatsError("needs-configuration", "native entrypoint arguments require a qualified captured adapter");
|
|
7606
|
+
recipe.executable = resource;
|
|
7607
|
+
} else if (recipe.executableResolvedFrom !== "explicit-host" || !isAbsolute(recipe.executable)) throw oatsError("needs-configuration", "native executable is neither a retained entrypoint nor an explicit host tool");
|
|
7608
|
+
if (!recipe.executableResource && existsSync(recipe.executable)) {
|
|
7609
|
+
const actual = realpathSync(recipe.executable), managed = join(deployment, ".agents");
|
|
7610
|
+
if (actual === managed || actual.startsWith(`${managed}${sep}`)) throw oatsError("needs-configuration", "an OATS-managed executable needs its retained resource reference, not a host-tool escape");
|
|
7611
|
+
}
|
|
7612
|
+
const broken = checkLaunchExecutable(recipe.executable);
|
|
7613
|
+
if (broken) throw oatsError("E_LAUNCH_EXECUTABLE", broken);
|
|
7614
|
+
const piHost = isPiSdkHost(recipe);
|
|
7615
|
+
if (recipe.runtime === "pi" && !piHost) throw oatsError("needs-configuration", "captured Pi requires the explicitly selected strict SDK host; a Pi CLI recipe was not reinterpreted");
|
|
7616
|
+
if (recipe.args.length && !piHost) throw oatsError("needs-configuration", "native extra arguments require a qualified captured adapter; no flags were inferred or ignored");
|
|
7617
|
+
const piProfile = piHost ? validatePiHostRecipe(recipe) : null; // refuse retained contributions before normalization
|
|
7618
|
+
recipe.hooks = { launch: {}, env: {}, contributions: [] };
|
|
7619
|
+
const piSessionDir = piHost ? capturedPiSessionDirectory(home) : null;
|
|
7620
|
+
if (piHost) {
|
|
7621
|
+
resolvePiSdkEntry(piProfile); // public export metadata only; no SDK/auth/model import
|
|
7622
|
+
requireCapturedPiRecordSupport(); // refuse before admission/backend when guards are absent
|
|
7623
|
+
inspectCapturedPiRoot(home, { incarnationId: original.incarnationId, sessionDir: piSessionDir });
|
|
7624
|
+
}
|
|
7625
|
+
const baseEnv = { ...(options.env || process.env) };
|
|
7626
|
+
for (const key of Object.keys(baseEnv)) if (/^(OATS_|OAS_|PI_AGENT_)/.test(key) || key === "PI_AGENTS_ROOT") delete baseEnv[key];
|
|
7627
|
+
const missing = missingLaunchEnvRefs(recipe.env, baseEnv);
|
|
7628
|
+
if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", "captured native environment references are unavailable");
|
|
7629
|
+
const backend = Object.hasOwn(options, "backend") ? options.backend : metadata.captured.nativeBackend;
|
|
7630
|
+
if (backend === undefined) throw oatsError("needs-configuration", "first captured native start needs an explicit backend endpoint");
|
|
7631
|
+
validateCapturedSessionBackend(backend);
|
|
7632
|
+
if (checkLaunchExecutable(backend.binary)) throw oatsError("E_LAUNCH_EXECUTABLE", "selected native backend executable is unavailable");
|
|
7633
|
+
if (Object.hasOwn(metadata.captured, "nativeBackend") && canonicalJson(metadata.captured.nativeBackend) !== canonicalJson(backend)) throw oatsError("invalid-resolution", "native backend relocation requires separate qualified custody");
|
|
7634
|
+
const sessionHistory = original.intents.filter(intent => intent.action.kind === "session");
|
|
7635
|
+
const targetReceipts = sourceStatus === "start-failed-cleanup-required" ? sessionHistory.slice(-2) : sessionHistory.slice(-1);
|
|
7636
|
+
const knownTargets = backend.backend === "herdr" ? targetReceipts.flatMap(intent => intent.receipt?.target?.backend === "herdr"
|
|
7637
|
+
? [validateCapturedSessionTarget(intent.receipt.target, backend, metadata.instance)] : []) : [];
|
|
7638
|
+
const nativeTarget = backend.backend === "tmux" ? { backend: "tmux", session: backend.session, window: metadata.instance, socket: backend.socket }
|
|
7639
|
+
: latestSession?.receipt?.target?.backend === "herdr" ? validateCapturedSessionTarget(latestSession.receipt.target, backend, metadata.instance) : undefined;
|
|
7640
|
+
assertCapturedSessionPlacement(metadata, backend, knownTargets);
|
|
7641
|
+
if (options.restart !== undefined && typeof options.restart !== "boolean") throw oatsError("invalid-declaration", "restart must be boolean");
|
|
7642
|
+
const taskFile = join(home, "TASK.md");
|
|
7643
|
+
if (options.task !== undefined && typeof options.task !== "string") throw oatsError("invalid-declaration", "native task must be explicit text");
|
|
7644
|
+
const task = options.task === undefined ? decodeUtf8(readPortableBytes(taskFile)) : options.task;
|
|
7645
|
+
canonicalJson(task, { maxBytes: 512 * 1024 });
|
|
7646
|
+
if (task.includes("\0")) throw oatsError("invalid-declaration", "native task contains NUL");
|
|
7647
|
+
if (piHost && !task.trim()) throw oatsError("E_PI_HOST_TASK", "captured Pi print requires a nonempty task");
|
|
7648
|
+
const taskBytes = Buffer.from(task);
|
|
7649
|
+
const assertCurriculum = () => {
|
|
7650
|
+
assertRoots();
|
|
7651
|
+
if (decodeUtf8(readPortableBytes(join(home, "AGENTS.md"))) !== loaded.composition.text
|
|
7652
|
+
|| !lstatSync(join(home, "CLAUDE.md")).isSymbolicLink() || readlinkSync(join(home, "CLAUDE.md")) !== "AGENTS.md"
|
|
7653
|
+
|| !lstatSync(join(home, "soul")).isSymbolicLink() || realpathSync(join(home, "soul")) !== dirname(loaded.resources.get(record.dispatch.composition.body))) throw oatsError("integrity-drift", "native curriculum differs from retained composition");
|
|
7654
|
+
const actual = readdirSync(join(home, ".agents/skills")).sort(), expected = loaded.composition.skills.map(skill => skill.name).sort();
|
|
7655
|
+
if (canonicalJson(actual) !== canonicalJson(expected)) throw oatsError("integrity-drift", "native skill inventory differs from retained composition");
|
|
7656
|
+
for (const skill of loaded.composition.skills) if (canonicalJson(treeIntegrity(join(home, ".agents/skills", skill.name))) !== canonicalJson(treeIntegrity(skill.path))) throw oatsError("integrity-drift", "native skill bytes differ from retained composition");
|
|
7657
|
+
};
|
|
7658
|
+
assertCurriculum();
|
|
7659
|
+
const action = { kind: "session", name: options.restart ? "restart" : "start" };
|
|
7660
|
+
const input = { backend, taskIntegrity: { format: "oats.bytes.v1", value: `sha256-${createHash("sha256").update(taskBytes).digest("hex")}` } };
|
|
7661
|
+
const pendingPath = join(home, ".oats-start-pending.json");
|
|
7662
|
+
const makeCommand = (intent) => `OATS_DEPLOYMENT=${shq(deployment)} OATS_RESOLUTION=${shq(resolution.id)} OATS_AGENT=${shq(metadata.agent)} OATS_CLI_BIN=${shq(realpathSync(join(PKG_ROOT, "bin/oats.mjs")))} OATS_INCARNATION_ID=${shq(original.incarnationId)} OATS_EXECUTION_ID=${shq(intent.executionId)} OATS_EXECUTION_ATTEMPT=${shq(String(intent.attempt))} ${renderLaunchRecipe(recipe, { home, instance: metadata.instance })}`;
|
|
7663
|
+
if (existsSync(pendingPath)) {
|
|
7664
|
+
const pending = parseStrictJson(readPortableBytes(pendingPath)), ref = pending.capturedIntent;
|
|
7665
|
+
validateIntentRef(ref);
|
|
7666
|
+
if (typeof pending.nativeRecordId !== "string" || !/^[a-f0-9-]{36}$/.test(pending.nativeRecordId)) throw oatsError("invalid-resolution", "pending native history reference is invalid");
|
|
7667
|
+
const evidence = parseStrictJson(readPortableBytes(join(nativeHistoryPath(home), `${pending.nativeRecordId}.json`)));
|
|
7668
|
+
if (evidence.version !== (piHost ? 2 : 1) || evidence.id !== pending.nativeRecordId || evidence.home !== home || evidence.runtime !== recipe.runtime || !(piHost ? ["pending", "root-established", "started"] : ["pending", "started"]).includes(evidence.state)) throw oatsError("invalid-resolution", "pending native history differs from captured start");
|
|
7669
|
+
if (piHost && (evidence.incarnationId !== original.incarnationId || canonicalJson(evidence.intent) !== canonicalJson(ref))) throw oatsError("invalid-resolution", "Pi native witness differs from the original admitted intent");
|
|
7670
|
+
const prior = original.intents.find(intent => intent.executionId === ref?.executionId);
|
|
7671
|
+
if (!prior || prior.action.kind !== "session" || latestSession.executionId !== pending.id || ref.incarnationId !== original.incarnationId || pending.id !== ref.executionId || ref.attempt > prior.attempt
|
|
7672
|
+
|| canonicalJson(pending.launch) !== canonicalJson(recipe) || pending.command !== makeCommand(ref)) throw oatsError("invalid-resolution", "pending native receipt is not this captured launch");
|
|
7673
|
+
if (pending.phase === "allocating") {
|
|
7674
|
+
if (backend.backend !== "herdr" || canonicalJson(pending.endpoint) !== canonicalJson(backend)) throw oatsError("invalid-resolution", "pending allocation endpoint differs from admitted backend");
|
|
7675
|
+
const error = oatsError("E_SESSION_UNKNOWN", "native allocation outcome is unknown; retain the same intent and reconcile, never allocate again");
|
|
7676
|
+
error.home = home; error.nativeCustody = { intent: ref, pendingPath, observed: prior.receipt, unconfirmed: true }; throw error;
|
|
7677
|
+
}
|
|
7678
|
+
validateCapturedSessionTarget(pending.target, backend, metadata.instance);
|
|
7679
|
+
if (backend.backend === "herdr" && (!prior.receipt?.target || canonicalJson(prior.receipt.target) !== canonicalJson(pending.target))) throw oatsError("invalid-resolution", "pending Herdr target lacks matching indexed observation");
|
|
7680
|
+
}
|
|
7681
|
+
// Static target/curriculum/backend/task validation precedes provider code.
|
|
7682
|
+
// This read-only inspection is not a provider-native enrollment grant.
|
|
7683
|
+
for (const [slot, providerBinding] of Object.entries(record.bindings)) {
|
|
7684
|
+
const capability = loaded.capabilities.get(providerBinding.capability), inspection = { kind: "inspect" };
|
|
7685
|
+
const invocation = buildCapturedInvocationContext({ loaded: { ...loaded, capability }, action: inspection,
|
|
7686
|
+
instance: { home, work: join(home, "work"), name: metadata.instance, agent: metadata.agent } });
|
|
7687
|
+
const checked = runCapturedProviderBinding({ deployment, artifacts: record.artifacts, capability: capability.id, phase: "check", settings: capability.settings,
|
|
7688
|
+
input: { binding: providerBinding, context: record.context, action: inspection, invocation } });
|
|
7689
|
+
if (checked.status !== "ready") throw oatsError(checked.problems[0]?.code || "provider-not-qualified", `captured ${slot} provider is unavailable`);
|
|
7690
|
+
}
|
|
7691
|
+
assertCurriculum();
|
|
7692
|
+
const admission = admitCapturedInstanceAction({ deployment, home, executionBinding: binding, capability: null, action, input, expectedStatus: sourceStatus,
|
|
7693
|
+
...(options.retryExecutionId !== undefined ? { retryExecutionId: options.retryExecutionId } : {}) });
|
|
7694
|
+
if (admission.replayed) return { ...admission.receipt, intent: admission.intent, replayed: true };
|
|
7695
|
+
let observed = null, pendingObservation = null;
|
|
7696
|
+
try {
|
|
7697
|
+
ownedState = "start-running";
|
|
7698
|
+
setCapturedInstanceStatus(deployment, home, ownedState, { expectedStatus: sourceStatus });
|
|
7699
|
+
beginCapturedIntent({ deployment, home, intent: admission.intent, action });
|
|
7700
|
+
atomicWriteFileSync(taskFile, task, { assertRoots });
|
|
7701
|
+
metadata = { ...metadata, backend: backend.backend,
|
|
7702
|
+
...(backend.backend === "tmux" ? { tmux: { session: nativeTarget.session, window: nativeTarget.window, socket: nativeTarget.socket } } : {}),
|
|
7703
|
+
captured: { ...metadata.captured, nativeBackend: backend } };
|
|
7704
|
+
atomicWriteFileSync(join(home, "instance.json"), canonicalJson(metadata), { assertRoots });
|
|
7705
|
+
const plan = { recipe, command: makeCommand(admission.intent), runtime: recipe.runtime, model: recipe.model, yolo: recipe.yolo };
|
|
7706
|
+
const exec = (binary, args, executionOptions) => {
|
|
7707
|
+
assertCurriculum();
|
|
7708
|
+
if (!readPortableBytes(taskFile).equals(taskBytes)) throw oatsError("integrity-drift", "native task changed after admission");
|
|
7709
|
+
if (existsSync(pendingPath)) {
|
|
7710
|
+
const pending = parseStrictJson(readPortableBytes(pendingPath));
|
|
7711
|
+
if (pending.id === admission.intent.executionId) pendingObservation = { id: pending.id, startedAt: pending.startedAt,
|
|
7712
|
+
...(pending.target ? { target: pending.target } : {}), ...(pending.endpoint ? { endpoint: pending.endpoint } : {}), ...(pending.phase ? { phase: pending.phase } : {}),
|
|
7713
|
+
...(pending.nativeRecordId ? { nativeRecordId: pending.nativeRecordId } : {}), unconfirmed: true };
|
|
7714
|
+
}
|
|
7715
|
+
const env = { ...baseEnv };
|
|
7716
|
+
if (backend.backend === "herdr") {
|
|
7717
|
+
delete env.HERDR_SESSION; delete env.HERDR_SOCKET_PATH;
|
|
7718
|
+
if (binary === backend.binary) {
|
|
7719
|
+
if (executionOptions.env?.HERDR_SOCKET_PATH !== backend.socket) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "Herdr transport socket differs from admitted endpoint");
|
|
7720
|
+
env.HERDR_SOCKET_PATH = backend.socket;
|
|
7721
|
+
}
|
|
7722
|
+
}
|
|
7723
|
+
return (options.io?.exec || execFileSync)(backend.backend === "tmux" && binary === "tmux" ? backend.binary : binary, args, { ...executionOptions, env });
|
|
7724
|
+
};
|
|
7725
|
+
const observeTarget = (target, facts) => {
|
|
7726
|
+
validateCapturedSessionTarget(target, backend, metadata.instance);
|
|
7727
|
+
pendingObservation = { ...facts, target, unconfirmed: true };
|
|
7728
|
+
knownTargets.push(target); assertRoots();
|
|
7729
|
+
observeCapturedNativeTarget(original, admission.intent, pendingObservation);
|
|
7730
|
+
};
|
|
7731
|
+
observed = startInstanceSession(home, { restart: !!options.restart, ...(options.stopGraceMs === undefined ? {} : { stopGraceMs: options.stopGraceMs }), env: baseEnv,
|
|
7732
|
+
io: { ...options.io, exec, strictHerdrTarget: backend.backend === "herdr" },
|
|
7733
|
+
[CAPTURED_NATIVE_START]: { plan, intent: admission.intent, endpoint: backend, target: nativeTarget, observeTarget, assertRoots,
|
|
7734
|
+
...(piHost ? { prepareNativeRecord: () => {
|
|
7735
|
+
assertCurriculum();
|
|
7736
|
+
return prepareCapturedPiStart(home, { incarnationId: original.incarnationId, intent: admission.intent, sessionDir: piSessionDir, assertAuthority: assertRoots });
|
|
7737
|
+
} } : {}) } });
|
|
7738
|
+
assertRoots();
|
|
7739
|
+
settleCapturedIntent({ deployment, home, intent: admission.intent, action, state: "completed", receipt: observed, replayable: true });
|
|
7740
|
+
const next = readCapturedInstanceMetadata(home);
|
|
7741
|
+
atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...next, captured: { ...next.captured, lifecycle: "start-dispatched", nativeIntent: admission.intent } }), { assertRoots });
|
|
7742
|
+
setCapturedInstanceStatus(deployment, home, "start-dispatched", { expectedStatus: ownedState });
|
|
7743
|
+
ownedState = "start-dispatched";
|
|
7744
|
+
return { ...observed, intent: admission.intent, incarnationId: original.incarnationId, dispatchAccepted: true };
|
|
7745
|
+
} catch (error) {
|
|
7746
|
+
let reportingFailure;
|
|
7747
|
+
try { failCapturedNativeIntent(original, admission.intent, observed ?? pendingObservation ?? admission.receipt); ownedState = "start-failed-cleanup-required"; }
|
|
7748
|
+
catch (failure) { reportingFailure = failure.code || "E_NATIVE_CUSTODY"; }
|
|
7749
|
+
try {
|
|
7750
|
+
assertRoots(); const current = readCapturedInstanceMetadata(home);
|
|
7751
|
+
atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...current, captured: { ...current.captured, lifecycle: "start-failed-cleanup-required", nativeIntent: admission.intent } }), { assertRoots });
|
|
7752
|
+
} catch { /* never write/clean a replacement home */ }
|
|
7753
|
+
error.home = home; error.nativeCustody = { intent: admission.intent, pendingPath, observed, pendingObservation, unconfirmed: true, ...(reportingFailure ? { reportingFailure } : {}) };
|
|
7754
|
+
throw error;
|
|
7755
|
+
}
|
|
7756
|
+
}
|
|
7757
|
+
|
|
7434
7758
|
export function restartInstanceSession(home, o = {}) { return startInstanceSession(home, { ...o, restart: true }); }
|
|
7435
7759
|
export function startInstanceSession(home, o = {}) {
|
|
7436
7760
|
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
|
|
7437
|
-
// Check the sibling receipt BEFORE resolving a possibly substituted home.
|
|
7438
7761
|
if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
|
|
7439
|
-
const
|
|
7762
|
+
const directoryGuard = sessionDirectoryGuard(home);
|
|
7763
|
+
if (!o[CAPTURED_NATIVE_START]) {
|
|
7764
|
+
let metadata; try { metadata = parseStrictJson(readPortableBytes(join(home, "instance.json"))); } catch { /* legacy receipt validation reports unreadable metadata */ }
|
|
7765
|
+
if (metadata?.executionBinding) {
|
|
7766
|
+
const portableOptions = { ...o };
|
|
7767
|
+
for (const key of ["model", "runtime", "launchConfig", "yolo"]) {
|
|
7768
|
+
if (portableOptions[key] !== undefined) throw oatsError("E_BAD_ARGS", "captured start cannot replace its recorded runtime/model/configuration");
|
|
7769
|
+
delete portableOptions[key]; // Legacy callers pass omitted options as undefined.
|
|
7770
|
+
}
|
|
7771
|
+
return startCapturedInstanceSession(home, portableOptions);
|
|
7772
|
+
}
|
|
7773
|
+
}
|
|
7774
|
+
const capturedStart = o[CAPTURED_NATIVE_START];
|
|
7775
|
+
const checkRoots = () => { directoryGuard(); capturedStart?.assertRoots(); };
|
|
7440
7776
|
const realHome = realPathOrNearest(home);
|
|
7441
7777
|
let originalHomeIdentity;
|
|
7442
7778
|
try { originalHomeIdentity = directoryIdentity(realHome); } catch { /* missing home reported below */ }
|
|
@@ -7460,7 +7796,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7460
7796
|
// The independent receipt first (retire and session consult it), then the
|
|
7461
7797
|
// mutable metadata; both tmp+rename. A failure between them is what the
|
|
7462
7798
|
// pending receipt exists for.
|
|
7463
|
-
const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop }, clearPending = true) => {
|
|
7799
|
+
const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId }, clearPending = true) => {
|
|
7464
7800
|
checkRoots();
|
|
7465
7801
|
const baselinePath = retirementBaselinePath(realHome);
|
|
7466
7802
|
let baseline;
|
|
@@ -7478,9 +7814,10 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7478
7814
|
...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}) };
|
|
7479
7815
|
if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
|
|
7480
7816
|
else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7817
|
+
if (capturedStart) atomicWriteFileSync(metaPath, canonicalJson(next), { assertRoots: checkRoots });
|
|
7818
|
+
else writeJsonAtomic(metaPath, next);
|
|
7819
|
+
if (clearPending) { checkRoots(); rmSync(pendingPath, { force: true }); }
|
|
7820
|
+
return { instance: meta.instance, agent: meta.agent, home: realHome, runtime: next.runtime, backend, model: model ?? null, launchConfig: next.launch?.launchConfig ?? null, yolo: next.yolo ?? null, target, startedAt, restartCount: next.restartCount, reused, ...(nativeRecordId ? { nativeRecordId } : {}), ...(stop ? { stop } : {}) };
|
|
7484
7821
|
};
|
|
7485
7822
|
try { mkdirSync(lock); }
|
|
7486
7823
|
catch (e) {
|
|
@@ -7526,11 +7863,15 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7526
7863
|
const exited = existsSync(exitedPath) && readFileSync(exitedPath, "utf8").trim() === pending.id;
|
|
7527
7864
|
if (!exited) throw oatsError("E_SESSION_START_BUSY", `${basename(realHome)} is still starting; refresh its status before retrying`);
|
|
7528
7865
|
}
|
|
7866
|
+
if (capturedStart && pending.id === capturedStart.intent.executionId && (!st.present || st.state === "shell")) throw oatsError("E_SESSION_UNKNOWN", "captured retry cannot turn a missing/exited pending target into a duplicate launch; retain and reconcile its evidence");
|
|
7529
7867
|
// Reconcile even an exited target: the independent baseline may
|
|
7530
7868
|
// already name it while metadata still names the old allocation.
|
|
7531
7869
|
const meta = readMeta();
|
|
7532
7870
|
const done = record(meta, { ...pending, backend: pbackend, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
|
|
7533
7871
|
if (st.present && st.state !== "shell") {
|
|
7872
|
+
// Same logical captured request already dispatched: adopting it is the
|
|
7873
|
+
// retry outcome, even for restart. Only a DISTINCT restart may continue.
|
|
7874
|
+
if (capturedStart && pending.id === capturedStart.intent.executionId) return done;
|
|
7534
7875
|
if (o.restart) { rmSync(pendingPath, { force: true }); }
|
|
7535
7876
|
else {
|
|
7536
7877
|
// The recovered target runs what the receipt says; a choice made
|
|
@@ -7545,9 +7886,10 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7545
7886
|
// 2. The ordinary gate and observation, all under the lock.
|
|
7546
7887
|
const receipt = instanceSessionTarget(realHome);
|
|
7547
7888
|
const meta = readMeta();
|
|
7548
|
-
const runtime = meta.runtime;
|
|
7889
|
+
const runtime = capturedStart?.plan.runtime || meta.runtime;
|
|
7549
7890
|
if (!["pi", "claude", "codex"].includes(runtime)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `instance ${meta.instance || realHome} records runtime ${JSON.stringify(runtime)}, which this kernel cannot relaunch`);
|
|
7550
|
-
const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
|
|
7891
|
+
const backend = capturedStart ? capturedStart.endpoint.backend : (meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux");
|
|
7892
|
+
if (capturedStart && receipt.target && (!capturedStart.target || canonicalJson(receipt.target) !== canonicalJson(capturedStart.target))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "independent endpoint differs from admitted native target");
|
|
7551
7893
|
let command = meta.command;
|
|
7552
7894
|
let model = meta.model || undefined;
|
|
7553
7895
|
// What this start launches: the frozen command (optionally with another
|
|
@@ -7556,8 +7898,9 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7556
7898
|
// preflight happens here, before anything is observed or stopped.
|
|
7557
7899
|
const selected = o.launchConfig !== undefined || o.runtime !== undefined || o.yolo !== undefined;
|
|
7558
7900
|
const hasRecipe = meta.launch && typeof meta.launch === "object";
|
|
7559
|
-
let launchPlan = null;
|
|
7560
|
-
if (
|
|
7901
|
+
let launchPlan = capturedStart?.plan || null;
|
|
7902
|
+
if (capturedStart) { command = launchPlan.command; model = launchPlan.model; }
|
|
7903
|
+
else if (selected || hasRecipe) {
|
|
7561
7904
|
// Every start of a home with a recipe (ordinary, model-only, or under a
|
|
7562
7905
|
// selection) goes through the one planner: recipe shape, the recorded
|
|
7563
7906
|
// or selected executable, references, capability contributions under
|
|
@@ -7583,7 +7926,8 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7583
7926
|
const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
|
|
7584
7927
|
const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
|
|
7585
7928
|
checkRoots(); // launch hooks/preparation have run; no backend has been observed
|
|
7586
|
-
const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo
|
|
7929
|
+
const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo,
|
|
7930
|
+
...(capturedStart ? { capturedIntent: capturedStart.intent } : {}) } : {};
|
|
7587
7931
|
let target = receipt.target;
|
|
7588
7932
|
let state = { present: false, state: "not-launched" };
|
|
7589
7933
|
let serverGone = false;
|
|
@@ -7607,28 +7951,40 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7607
7951
|
if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
|
|
7608
7952
|
}
|
|
7609
7953
|
const startedAt = new Date().toISOString();
|
|
7610
|
-
const id = randomUUID();
|
|
7954
|
+
const id = capturedStart?.intent.executionId || randomUUID();
|
|
7611
7955
|
checkRoots();
|
|
7612
|
-
const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime
|
|
7613
|
-
|
|
7956
|
+
const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime,
|
|
7957
|
+
capturedStart ? id => { planExtra.nativeRecordId = id; } : undefined, capturedStart?.prepareNativeRecord);
|
|
7958
|
+
const completedCommand = capturedStart && isPiSdkHost(launchPlan?.recipe)
|
|
7959
|
+
? renderCapturedPiCompletion(command, executionCommand, { home: realHome, nativeRecordId: planExtra.nativeRecordId })
|
|
7960
|
+
: `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
|
|
7614
7961
|
let reused = "new";
|
|
7615
7962
|
if (backend === "herdr") {
|
|
7616
|
-
if (!target) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
|
|
7963
|
+
if (!target && !capturedStart) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
|
|
7964
|
+
const base = capturedStart?.endpoint ?? { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
|
|
7617
7965
|
if (state.present) { reused = "pane"; }
|
|
7618
7966
|
else {
|
|
7619
|
-
const base = { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
|
|
7620
7967
|
try { herdrSnapshot(base, o.io); }
|
|
7621
|
-
catch (e) {
|
|
7622
|
-
|
|
7968
|
+
catch (e) {
|
|
7969
|
+
if (capturedStart) throw oatsError("E_SESSION_UNKNOWN", "selected Herdr endpoint is unavailable or incompatible; no fallback was started");
|
|
7970
|
+
throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`);
|
|
7971
|
+
}
|
|
7972
|
+
if (capturedStart) {
|
|
7973
|
+
checkRoots();
|
|
7974
|
+
writeJsonAtomic(pendingPath, { id, phase: "allocating", endpoint: base, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
7975
|
+
}
|
|
7976
|
+
try { target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io); }
|
|
7977
|
+
catch (e) { if (capturedStart) throw launchFailure("Herdr allocation", e); throw e; }
|
|
7623
7978
|
}
|
|
7979
|
+
if (capturedStart) capturedStart.observeTarget(target, { id, startedAt, nativeRecordId: planExtra.nativeRecordId });
|
|
7624
7980
|
checkRoots();
|
|
7625
7981
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
7626
7982
|
try { launchHerdr(target, `${paneEnvExports}cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
|
|
7627
7983
|
catch (e) { throw launchFailure("Herdr", e); }
|
|
7628
7984
|
} else {
|
|
7629
|
-
const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
|
|
7630
|
-
const window = target?.window || meta.tmux?.window || meta.instance;
|
|
7631
|
-
let socket = target?.socket || meta.tmux?.socket;
|
|
7985
|
+
const session = capturedStart?.target.session ?? (target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION);
|
|
7986
|
+
const window = capturedStart?.target.window ?? (target?.window || meta.tmux?.window || meta.instance);
|
|
7987
|
+
let socket = capturedStart?.target.socket ?? (target?.socket || meta.tmux?.socket);
|
|
7632
7988
|
const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
|
|
7633
7989
|
// A fallback shell (no harness descendant) or a retained dead pane is
|
|
7634
7990
|
// the agent's own pane: the command runs there, no other window touched.
|