@awebai/oats 0.23.2 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -391
- package/bin/oats-pi-sdk-host.mjs +17 -0
- package/bin/oats.mjs +470 -51
- package/capabilities/oats-okf/bin/oats-okf-binding.mjs +14 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +79 -11
- package/capabilities/oats-okf/lib/binding-wire.mjs +268 -0
- package/capabilities/oats-okf/lib/captured-worker.mjs +109 -0
- package/capabilities/oats-okf/lib/config.mjs +2 -1
- package/capabilities/oats-okf/lib/inspection.mjs +16 -1
- package/capabilities/oats-okf/lib/invocation-context.mjs +111 -0
- package/capabilities/oats-okf/lib/invocation-shape.mjs +135 -0
- package/capabilities/oats-okf/lib/io.mjs +1 -1
- package/capabilities/oats-okf/lib/portable-binding.mjs +199 -0
- package/capabilities/oats-okf/lib/source-contract.mjs +46 -0
- package/capabilities/oats-okf/lib/sources.mjs +123 -3
- package/capabilities/oats-okf/lib/stores.mjs +104 -25
- package/capabilities/oats-okf/lib/worker.mjs +69 -10
- package/capabilities/oats-okf/oats.json +35 -7
- package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +87 -0
- package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +113 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +23 -5
- package/capabilities/oats-okf/skills/okf/SKILL.md +42 -1
- package/docs/artifact-approvals.schema.json +7 -0
- package/docs/capabilities.md +4 -0
- package/docs/capability-manifest.schema.json +37 -66
- package/docs/captured-invocation-context.schema.json +7 -0
- package/docs/captured-resolution.schema.json +7 -0
- package/docs/design/2026-09-14-artifact-retention-contract.md +190 -0
- package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +708 -0
- package/docs/design/2026-09-14-portable-souls-contract-amendments.md +85 -0
- package/docs/design/2026-09-14-portable-souls-explainer.md +750 -0
- package/docs/design/2026-09-15-captured-dispatch.md +127 -0
- package/docs/design/2026-09-15-captured-resolution-records.md +143 -0
- package/docs/design/2026-09-15-package-preparation.md +100 -0
- package/docs/design/2026-09-15-portable-data-contract.md +121 -0
- package/docs/design/2026-09-15-portable-declarations.md +189 -0
- package/docs/design/2026-09-15-portable-souls-handoff.md +150 -0
- package/docs/design/2026-09-15-portable-souls-implementation.md +417 -0
- package/docs/design/2026-09-15-selection-lock-and-approval.md +122 -0
- package/docs/design/2026-09-15-source-observation.md +119 -0
- package/docs/design/2026-09-16-captured-admission.md +77 -0
- package/docs/design/2026-09-16-captured-helper-dispatch.md +105 -0
- package/docs/design/2026-09-16-captured-launch-inputs.md +42 -0
- package/docs/design/2026-09-16-command-profile-preparation.md +86 -0
- package/docs/design/2026-09-16-fresh-install-first-rollout.md +47 -0
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +277 -0
- package/docs/design/2026-09-16-knowledge-capability-contract.md +61 -0
- package/docs/design/2026-09-16-messaging-capability-contract.md +59 -0
- package/docs/design/2026-09-16-portable-migration-evidence.md +156 -0
- package/docs/design/2026-09-16-portable-onboarding.md +177 -0
- package/docs/design/2026-09-16-prepare-request-transport.md +26 -0
- package/docs/design/2026-09-16-provider-binding-codecs.md +98 -0
- package/docs/design/2026-09-16-provider-binding-wire.md +247 -0
- package/docs/design/2026-09-17-capability-helper-input-contract.md +95 -0
- package/docs/design/2026-09-17-captured-backend-parity.md +53 -0
- package/docs/design/2026-09-17-captured-native-start.md +58 -0
- package/docs/design/2026-09-17-portable-boundary-hookup.md +19 -0
- package/docs/design/2026-09-17-portable-boundary-resources.md +52 -0
- package/docs/design/2026-09-17-public-captured-start.md +108 -0
- package/docs/design/2026-09-17-public-prepare-request.md +90 -0
- package/docs/design/2026-09-18-captured-pi-host.md +205 -0
- package/docs/design/2026-09-18-first-cut-release-checklist.md +131 -0
- package/docs/design/2026-09-18-herdr-protocol-compatibility.md +60 -0
- package/docs/design/2026-09-20-redesign-program-board.md +70 -0
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +287 -0
- package/docs/design/2026-09-20-workspace-onboarding-public.md +124 -0
- package/docs/design/README.md +42 -0
- package/docs/desktop-cli-api.md +5 -2
- package/docs/execution-capsule.schema.json +108 -0
- package/docs/execution-targets.md +20 -7
- package/docs/first-team.md +1 -1
- package/docs/knowledge-theory.md +353 -111
- package/docs/knowledge.md +10 -1
- package/docs/layers.md +89 -354
- package/docs/oats-lock-v3.schema.json +7 -0
- package/docs/oats-member.schema.json +38 -0
- package/docs/oats-workspace.schema.json +68 -0
- package/docs/official-marketplace.md +79 -0
- package/docs/packages.md +4 -0
- package/docs/portable.schema.json +2512 -0
- package/docs/provider-check-input.schema.json +7 -0
- package/docs/release-notes/v0.24.0.md +104 -0
- package/docs/release-notes/v0.24.1.md +17 -0
- package/docs/schedules.md +126 -14
- package/docs/soul.schema.json +82 -0
- package/docs/souls-and-instances.md +20 -7
- package/docs/workspace-adoption.md +285 -0
- package/docs/workspaces.md +154 -0
- package/injects/oats-portable.md +16 -0
- package/injects/portable-instance-boundary.md +39 -0
- package/injects/portable-work-directory.md +29 -0
- package/lib/artifact-approvals.mjs +120 -0
- package/lib/artifact-tree.mjs +141 -0
- package/lib/capability-artifacts.mjs +179 -0
- package/lib/capability-execution.mjs +15 -0
- package/lib/capability-inputs.mjs +39 -0
- package/lib/capability-provenance.mjs +231 -0
- package/lib/captured-action-shape.mjs +21 -0
- package/lib/captured-admission-shape.mjs +20 -0
- package/lib/captured-binding-file.mjs +36 -0
- package/lib/captured-dispatch.mjs +66 -0
- package/lib/captured-instance-index.mjs +277 -0
- package/lib/captured-invocation-context.mjs +130 -0
- package/lib/captured-launch-request.mjs +46 -0
- package/lib/captured-operation-process.mjs +15 -0
- package/lib/captured-pi-custody.mjs +29 -0
- package/lib/captured-pi-host.mjs +167 -0
- package/lib/captured-pi-outcome.mjs +172 -0
- package/lib/captured-resolutions.mjs +275 -0
- package/lib/captured-scaffold.mjs +87 -0
- package/lib/captured-selector.mjs +28 -0
- package/lib/captured-session-backend.mjs +52 -0
- package/lib/captured-source-receipt-file.mjs +72 -0
- package/lib/config-data.mjs +104 -0
- package/lib/core.mjs +961 -566
- package/lib/errors.mjs +7 -0
- package/lib/helper-injection-policy.mjs +98 -0
- package/lib/herdr.mjs +18 -7
- package/lib/instruction-composition.mjs +31 -0
- package/lib/legacy-lock-codec.mjs +106 -0
- package/lib/manifest-settings.mjs +84 -0
- package/lib/package-closure.mjs +48 -0
- package/lib/package-materialization.mjs +83 -0
- package/lib/pi-sdk-host.mjs +229 -0
- package/lib/portable-artifacts.mjs +115 -0
- package/lib/portable-choices.mjs +82 -0
- package/lib/portable-composition.mjs +136 -0
- package/lib/portable-digest.mjs +105 -0
- package/lib/portable-files.mjs +26 -0
- package/lib/portable-identity.mjs +40 -0
- package/lib/portable-lock.mjs +117 -0
- package/lib/portable-migration-artifacts.mjs +135 -0
- package/lib/portable-migration-evidence.mjs +305 -0
- package/lib/portable-migration-store.mjs +199 -0
- package/lib/portable-migration.mjs +104 -0
- package/lib/portable-onboarding-acceptance.mjs +66 -0
- package/lib/portable-onboarding-request.mjs +49 -0
- package/lib/portable-onboarding.mjs +249 -0
- package/lib/portable-package-preparation.mjs +188 -0
- package/lib/portable-policy.mjs +44 -0
- package/lib/portable-shape.mjs +35 -0
- package/lib/portable-soul.mjs +38 -0
- package/lib/portable-state.mjs +80 -0
- package/lib/portable-values.mjs +181 -0
- package/lib/prepare-composition.mjs +151 -0
- package/lib/prepared-bindings.mjs +78 -0
- package/lib/prepared-resources.mjs +127 -0
- package/lib/provider-binding-broker.mjs +59 -0
- package/lib/provider-binding-wire.mjs +110 -0
- package/lib/provider-binding.mjs +22 -0
- package/lib/repository-observation.mjs +226 -0
- package/lib/resolution-shape.mjs +393 -0
- package/lib/schedule-capsule.mjs +206 -0
- package/lib/schedule.mjs +259 -38
- package/lib/servers.mjs +15 -0
- package/lib/soul-constraints.mjs +40 -0
- package/lib/source-projection.mjs +84 -0
- package/lib/source-spec.mjs +189 -0
- package/lib/workspace-definition.mjs +126 -0
- package/lib/workspace-discovery.mjs +146 -0
- package/package-catalog.json +2 -1
- package/package.json +3 -2
- package/packages/record/lib/capture-cc.mjs +14 -6
- package/packages/record/lib/formats.mjs +14 -3
- package/packages/record/lib/native-history.mjs +277 -7
- package/packages/record/lib/session-snapshot.mjs +25 -5
- package/packages/record/lib/sessions-for-home.mjs +30 -13
- package/skills/oats/SKILL.md +12 -7
- package/skills/oats-config/SKILL.md +11 -9
- package/skills/oats-packages/SKILL.md +12 -8
- package/skills/oats-portable/SKILL.md +115 -0
- package/skills/oats-portable-artifacts/SKILL.md +63 -0
package/lib/core.mjs
CHANGED
|
@@ -35,11 +35,66 @@ 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 { inspectPortableOnboarding as inspectOnboarding, describePortableOnboarding } from "./portable-onboarding.mjs";
|
|
65
|
+
import { readLock3 } from "./portable-lock.mjs";
|
|
66
|
+
import { portableScope, portableStateDirectory } from "./portable-state.mjs";
|
|
67
|
+
import { objectAt } from "./portable-shape.mjs";
|
|
68
|
+
import { validateBindingInterface } from "./provider-binding.mjs";
|
|
69
|
+
import { invokeProviderBinding } from "./provider-binding-broker.mjs";
|
|
70
|
+
import { withCapturedBindingFile } from "./captured-binding-file.mjs";
|
|
71
|
+
import { validateCapturedSourceReceipt, withCapturedSourceReceiptFile } from "./captured-source-receipt-file.mjs";
|
|
72
|
+
export { withCapturedBindingFile, validateCapturedSourceReceipt, withCapturedSourceReceiptFile };
|
|
73
|
+
import { executableSurfaceOf, hasExecutableSurface } from "./capability-execution.mjs";
|
|
74
|
+
import { createCapabilityMaterializer } from "./package-materialization.mjs";
|
|
75
|
+
import { resolvePackageClosure } from "./package-closure.mjs";
|
|
76
|
+
import { canonicalJson, parseStrictJson, decodeUtf8 } from "./portable-values.mjs";
|
|
77
|
+
import { treeIntegrity } from "./portable-digest.mjs";
|
|
78
|
+
import { readPortableBytes } from "./portable-files.mjs";
|
|
79
|
+
import { capabilityArtifactIntegrity, copyTreeSafe, removeOwnedStaging } from "./artifact-tree.mjs";
|
|
80
|
+
import {
|
|
81
|
+
CAPABILITIES_DIRNAME, INSTALLED_SUBDIR,
|
|
82
|
+
isMaterializedCapabilityId, capabilityIdViolation, CAPABILITY_INSTALLATION_FILE,
|
|
83
|
+
normalizePackagePath, parseLockSource, PACKAGE_ID_RE,
|
|
84
|
+
validateLockEntry, validateCapabilityLockEntry, verifyCapabilityInstallation,
|
|
85
|
+
} from "./capability-provenance.mjs";
|
|
86
|
+
import { decodeLegacyLockBytes, legacyCapabilityEntryViolation } from "./legacy-lock-codec.mjs";
|
|
87
|
+
// Keep the existing public core surface; internal data helpers are not re-exported.
|
|
88
|
+
export { oatsError } from "./errors.mjs";
|
|
89
|
+
export { legacyCapabilityEntryViolation } from "./legacy-lock-codec.mjs";
|
|
90
|
+
export { capabilityArtifactIntegrity, copyTreeSafe } from "./artifact-tree.mjs";
|
|
91
|
+
export {
|
|
92
|
+
CAPABILITIES_DIRNAME, INSTALLED_SUBDIR, CAPABILITY_ID_RE,
|
|
93
|
+
isMaterializedCapabilityId, capabilityIdViolation, CAPABILITY_INSTALLATION_FILE,
|
|
94
|
+
normalizePackagePath, validateLockEntry, validateCapabilityLockEntry,
|
|
95
|
+
verifyCapabilityInstallation,
|
|
96
|
+
} from "./capability-provenance.mjs";
|
|
97
|
+
|
|
43
98
|
export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
|
|
44
99
|
/** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
|
|
45
100
|
* must satisfy, so the retry cannot skip Git cleanup on an unrecognised value. */
|
|
@@ -711,16 +766,20 @@ function bindingObject(value) {
|
|
|
711
766
|
function hookDeclaration(value) {
|
|
712
767
|
if (typeof value === "string") return { command: value, required: false };
|
|
713
768
|
if (value && typeof value === "object" && typeof value.command === "string") {
|
|
714
|
-
return { command: value.command, required: value.required === true };
|
|
769
|
+
return { command: value.command, required: value.required === true, ...(Object.hasOwn(value, "inputs") ? { inputs: value.inputs } : {}) };
|
|
715
770
|
}
|
|
716
771
|
return undefined;
|
|
717
772
|
}
|
|
773
|
+
function manifestHookDeclarations(manifest) {
|
|
774
|
+
return Object.fromEntries(Object.entries(manifest?.hooks || {}).flatMap(([event, value]) => {
|
|
775
|
+
const declaration = hookDeclaration(value);
|
|
776
|
+
return APPROVED_HOOKS.has(event) && declaration ? [[event, declaration.command]] : [];
|
|
777
|
+
}));
|
|
778
|
+
}
|
|
718
779
|
function manifestHookCommands(manifest) {
|
|
719
780
|
const out = {};
|
|
720
|
-
for (const [ev,
|
|
721
|
-
const
|
|
722
|
-
if (!APPROVED_HOOKS.has(ev) || !decl) continue;
|
|
723
|
-
const [script, ...args] = decl.command.split(/\s+/);
|
|
781
|
+
for (const [ev, command] of Object.entries(manifestHookDeclarations(manifest))) {
|
|
782
|
+
const [script, ...args] = command.split(/\s+/);
|
|
724
783
|
const abs = manifestPath(manifest, script);
|
|
725
784
|
if (abs) out[ev] = ["node", shq(abs), ...args].join(" ");
|
|
726
785
|
}
|
|
@@ -849,11 +908,7 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
849
908
|
if (![...enabledValues][0]) continue;
|
|
850
909
|
// A declared value set is enforced for an ACTIVE capability: a misspelling
|
|
851
910
|
// 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
|
-
}
|
|
911
|
+
assertCapabilitySettingValues(manifest, settings);
|
|
857
912
|
const compatibility = capabilityCompatibility(manifest);
|
|
858
913
|
if (!compatibility.compatible) throw new Error(`capability "${id}" requires OATS ${compatibility.range}; running ${compatibility.version}`);
|
|
859
914
|
const trust = capabilityTrust(manifest, contextDir);
|
|
@@ -988,33 +1043,10 @@ const OATS_HOME_DIR = process.env.OATS_HOME_DIR || join(homedir(), ".oats");
|
|
|
988
1043
|
/** Legacy pre-v0.8 laptop acquisition root — kept only so doctor can warn about it. */
|
|
989
1044
|
export const LEGACY_HOME_CAPABILITIES_DIR = join(OATS_HOME_DIR, "capabilities");
|
|
990
1045
|
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
1046
|
export const OWNED_SUBDIR = "owned";
|
|
995
1047
|
export const installedCapabilitiesDir = (level) => join(level, CAPABILITIES_DIRNAME, INSTALLED_SUBDIR);
|
|
996
1048
|
export const ownedCapabilitiesDir = (level) => join(level, CAPABILITIES_DIRNAME, OWNED_SUBDIR);
|
|
997
1049
|
|
|
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
1050
|
const PORTABLE_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
1019
1051
|
const CAPABILITY_ENV_ID_RE = /^[a-z][a-z0-9]*\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
1020
1052
|
const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
|
|
@@ -1108,17 +1140,18 @@ export function stripInternalAnnotations(parsed) {
|
|
|
1108
1140
|
return out;
|
|
1109
1141
|
}
|
|
1110
1142
|
|
|
1111
|
-
function loadManifestAt(idir, origin) {
|
|
1143
|
+
function loadManifestAt(idir, origin, { strict = false } = {}) {
|
|
1112
1144
|
const mf = join(idir, "oats.json");
|
|
1113
1145
|
if (!existsSync(mf)) return undefined;
|
|
1114
1146
|
let raw;
|
|
1115
|
-
try { raw = JSON.parse(readFileSync(mf, "utf8")); }
|
|
1116
|
-
catch (e) { throw new Error(`invalid capability manifest JSON ${mf}: ${e.message}`); }
|
|
1147
|
+
try { raw = strict ? parseStrictJson(readPortableBytes(mf)) : JSON.parse(readFileSync(mf, "utf8")); }
|
|
1148
|
+
catch (e) { if (strict) throw e; throw new Error(`invalid capability manifest JSON ${mf}: ${e.message}`); }
|
|
1117
1149
|
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
1150
|
// BEFORE any validation or annotation: the artifact declares capability DATA,
|
|
1119
1151
|
// never kernel annotations about itself.
|
|
1120
1152
|
const m = stripInternalAnnotations(raw);
|
|
1121
1153
|
const id = validateCapabilityManifest(m, mf);
|
|
1154
|
+
validateCapabilityInputDeclarations(m);
|
|
1122
1155
|
if (!m.version || !m.description) throw new Error(`capability ${id} manifest needs version and description`);
|
|
1123
1156
|
const targetFields = ["global", "groups", "souls", "targets"].filter((key) => Object.prototype.hasOwnProperty.call(m, key));
|
|
1124
1157
|
if (targetFields.length) throw new Error(`capability ${id} manifest cannot declare config-owned targets: ${targetFields.join(", ")}`);
|
|
@@ -1126,6 +1159,7 @@ function loadManifestAt(idir, origin) {
|
|
|
1126
1159
|
if (m.command && !/^[a-z0-9][a-z0-9-]*$/.test(m.command)) throw new Error(`capability ${id} has invalid command namespace "${m.command}"`);
|
|
1127
1160
|
for (const [hook, value] of Object.entries(m.hooks || {})) {
|
|
1128
1161
|
if (!APPROVED_HOOKS.has(hook)) throw new Error(`capability ${id} declares unsupported hook "${hook}"`);
|
|
1162
|
+
if (value && typeof value === "object") objectAt(value, ["command", "required", "inputs"], ["command"]);
|
|
1129
1163
|
if (!hookDeclaration(value)) throw new Error(`capability ${id} hook "${hook}" must be a command string or { command, required }`);
|
|
1130
1164
|
if (value && typeof value === "object" && value.required !== undefined && typeof value.required !== "boolean") {
|
|
1131
1165
|
throw new Error(`capability ${id} hook "${hook}": "required" must be a boolean`);
|
|
@@ -1136,9 +1170,347 @@ function loadManifestAt(idir, origin) {
|
|
|
1136
1170
|
}
|
|
1137
1171
|
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
1172
|
validateManifestOperations(m, id);
|
|
1173
|
+
validateBindingInterface(m);
|
|
1139
1174
|
return { ...m, _dir: idir, _origin: origin };
|
|
1140
1175
|
}
|
|
1141
1176
|
|
|
1177
|
+
function assertCapabilitySettingValues(manifest, settings) {
|
|
1178
|
+
for (const [key, decl] of Object.entries(manifest.settings || {})) {
|
|
1179
|
+
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]))) {
|
|
1180
|
+
throw new Error(`capability setting ${manifest.capability}.${key} is ${JSON.stringify(settings[key])}, not one of ${decl.values.map((v) => JSON.stringify(v)).join(", ")}`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function loadRetainedManifest(root) {
|
|
1186
|
+
const manifest = loadManifestAt(root, "captured", { strict: true });
|
|
1187
|
+
if (!manifest) throw oatsError("resolution-incomplete", "retained capability manifest is absent");
|
|
1188
|
+
assertCapabilitySelfContained(root, manifest);
|
|
1189
|
+
const compatible = capabilityCompatibility(manifest);
|
|
1190
|
+
if (!compatible.compatible) throw oatsError("capability-incompatible", `captured capability requires OATS ${compatible.range}`);
|
|
1191
|
+
return manifest;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
export function approveAvailableCapability(deployment, artifactSet, capability, origin) {
|
|
1195
|
+
return approveAvailableArtifact(deployment, artifactSet, capability, origin, loadRetainedManifest);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** Public read-only source/workspace inspection through the existing facade.
|
|
1199
|
+
* Owns only transient repository scratch, never deployment state, provisioning,
|
|
1200
|
+
* provider code, approvals or a serialized mutation witness. */
|
|
1201
|
+
export function inspectPortableOnboarding(input, { repositoryOptions = {} } = {}) {
|
|
1202
|
+
canonicalJson(input);
|
|
1203
|
+
const directory = mkdtempSync(join(realpathSync(tmpdir()), "oats-source-inspection-")), owned = lstatSync(directory);
|
|
1204
|
+
let repositories, primary;
|
|
1205
|
+
try {
|
|
1206
|
+
repositories = createRepositoryTransaction({ ...repositoryOptions, directory, accessContextKey: repositoryOptions.accessContextKey ?? "native" });
|
|
1207
|
+
return describePortableOnboarding(inspectOnboarding(input, { repositories }));
|
|
1208
|
+
} catch (error) { primary = error; throw error; }
|
|
1209
|
+
finally {
|
|
1210
|
+
try {
|
|
1211
|
+
repositories?.close();
|
|
1212
|
+
const current = lstatSync(directory);
|
|
1213
|
+
if (!current.isDirectory() || current.dev !== owned.dev || current.ino !== owned.ino) throw oatsError("source-incomplete", "inspection scratch ownership changed");
|
|
1214
|
+
removeOwnedStaging(directory);
|
|
1215
|
+
} catch (cleanup) {
|
|
1216
|
+
if (!primary) throw cleanup;
|
|
1217
|
+
const error = new AggregateError([primary, cleanup], "inspection and owned scratch cleanup failed", { cause: primary });
|
|
1218
|
+
error.code = primary.code; error.stagingPath = directory; throw error;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** Explicit new-work preparation, with one frozen repository transaction.
|
|
1224
|
+
* This first adapter captures command/curriculum profiles; provider-owned
|
|
1225
|
+
* binding or distinct helper policy gaps return needs-configuration, never
|
|
1226
|
+
* a fabricated complete record. No lifecycle side effects run here. */
|
|
1227
|
+
export function prepareCapturedComposition(input, { repositoryOptions = {} } = {}) {
|
|
1228
|
+
canonicalJson(input);
|
|
1229
|
+
objectAt(input, ["deployment", "source", "origin", "workspace", "member", "operator", "mode", "allowLocalPaths", "standaloneContextKey", "launch", "helperLaunches"], ["deployment", "source"]);
|
|
1230
|
+
if (input.launch !== undefined) validateCapturedLaunchRequest(input.launch, validateLaunchConfig);
|
|
1231
|
+
if (input.helperLaunches !== undefined) {
|
|
1232
|
+
objectAt(input.helperLaunches, null, []);
|
|
1233
|
+
for (const request of Object.values(input.helperLaunches)) validateCapturedLaunchRequest(request, validateLaunchConfig);
|
|
1234
|
+
}
|
|
1235
|
+
if (input.mode !== undefined && !WORK_MODES.includes(input.mode)) throw oatsError("invalid-declaration", "invalid preparation work mode");
|
|
1236
|
+
const deployment = portableScope(input.deployment);
|
|
1237
|
+
const previous = readLock3(deployment); // Old state refuses before scratch/fetch.
|
|
1238
|
+
const directory = mkdtempSync(join(portableStateDirectory(deployment, true), ".prepare-")), owned = lstatSync(directory);
|
|
1239
|
+
const origin = input.origin ?? { kind: "operator", document: { kind: "operator", id: "oats-prepare" }, pointer: "/source" };
|
|
1240
|
+
let repositories, primary, catalog;
|
|
1241
|
+
try {
|
|
1242
|
+
repositories = createRepositoryTransaction({ ...repositoryOptions, directory, accessContextKey: repositoryOptions.accessContextKey ?? "native" });
|
|
1243
|
+
return prepareComposition({ ...input, deployment, origin, directory }, { repositories, previous, kernel: {
|
|
1244
|
+
loadPackageManifestAt, capabilityCompatibility, assertPlatformInvariantLocks, materializeCapability,
|
|
1245
|
+
manifest: loadRetainedManifest,
|
|
1246
|
+
binding: runCapturedProviderBinding,
|
|
1247
|
+
catalog(id, selector) {
|
|
1248
|
+
catalog ??= officialPackageCatalog();
|
|
1249
|
+
if (!Object.hasOwn(catalog, id)) throw oatsError("invalid-source", "unknown package dependency catalog identity");
|
|
1250
|
+
return { ...catalog[id], ...(selector === undefined ? {} : { ref: selector }) };
|
|
1251
|
+
},
|
|
1252
|
+
complete: data => completePreparedResources(data, { root: PKG_ROOT, workModes: WORK_MODES,
|
|
1253
|
+
settings: assertCapabilitySettingValues, skills: skillEntriesIn, hooks: manifestHookDeclarations,
|
|
1254
|
+
validateLaunchConfig, runtimeRequirements: applicableRequirements }),
|
|
1255
|
+
} });
|
|
1256
|
+
} catch (error) { primary = error; throw error; }
|
|
1257
|
+
finally {
|
|
1258
|
+
try {
|
|
1259
|
+
repositories?.close();
|
|
1260
|
+
const current = lstatSync(directory);
|
|
1261
|
+
if (!current.isDirectory() || current.dev !== owned.dev || current.ino !== owned.ino) throw oatsError("source-incomplete", "preparation scratch ownership changed");
|
|
1262
|
+
removeOwnedStaging(directory);
|
|
1263
|
+
} catch (cleanup) {
|
|
1264
|
+
if (!primary) throw cleanup;
|
|
1265
|
+
const error = new AggregateError([primary, cleanup], "preparation and owned scratch cleanup failed", { cause: primary });
|
|
1266
|
+
error.code = primary.code; error.stagingPath = directory; throw error;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** Load an exact record for one action, using the existing COMPLETE kernel
|
|
1272
|
+
* manifest codecs. No scoped config, mutable install or marketplace fallback.
|
|
1273
|
+
* Launch/lifecycle adoption and provider qualification are separate integration
|
|
1274
|
+
* steps; unsupported actions refuse rather than switching to legacy dispatch. */
|
|
1275
|
+
function capturedCapabilityCodecs() {
|
|
1276
|
+
return {
|
|
1277
|
+
manifest: loadRetainedManifest,
|
|
1278
|
+
settings: assertCapabilitySettingValues,
|
|
1279
|
+
host: ({ manifest }) => (manifest.requires || []).filter((requirement) => requirement.command && !which(requirement.command)),
|
|
1280
|
+
executable(manifest, relativePath) {
|
|
1281
|
+
const file = capabilityExecutablePath(manifest, relativePath);
|
|
1282
|
+
if (!file || !statSync(file).isFile()) throw oatsError("resource-not-found", "captured executable is not a regular file");
|
|
1283
|
+
return realpathSync(file);
|
|
1284
|
+
},
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** Exact prospective/captured binding phases; no partial record or ambient provider. */
|
|
1289
|
+
export function runCapturedProviderBinding(options) {
|
|
1290
|
+
return invokeProviderBinding(options, capturedCapabilityCodecs());
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function capturedDispatchCodecs(options, { admitting = false } = {}) {
|
|
1294
|
+
return {
|
|
1295
|
+
...capturedCapabilityCodecs(),
|
|
1296
|
+
launch(recipe) {
|
|
1297
|
+
assertLaunchRecipe(recipe, "captured resolution");
|
|
1298
|
+
validateLaunchConfig("captured", { runtime: recipe.runtime, executable: recipe.executable, args: recipe.args, env: recipe.env,
|
|
1299
|
+
...(recipe.model != null ? { model: recipe.model } : {}), ...(recipe.yolo !== undefined ? { yolo: recipe.yolo } : {}) }, "captured resolution");
|
|
1300
|
+
},
|
|
1301
|
+
operations: manifestOperations,
|
|
1302
|
+
hooks: manifestHookDeclarations,
|
|
1303
|
+
invocation(base, action, capability) {
|
|
1304
|
+
return buildCapturedInvocationContext({ loaded: { ...base, capability }, action,
|
|
1305
|
+
instance: options.invocationTarget ?? null, intent: options.intent ?? null,
|
|
1306
|
+
...(Object.hasOwn(options, "priorReceipt") ? { priorReceipt: options.priorReceipt } : {}) });
|
|
1307
|
+
},
|
|
1308
|
+
bindings(bindings, capability, record, invocation) {
|
|
1309
|
+
// Admission is static authorization to ATTEMPT, not readiness or effects.
|
|
1310
|
+
// The normal loader still checks readiness with the saved intent before execution.
|
|
1311
|
+
if (admitting) return;
|
|
1312
|
+
for (const [slot, binding] of bindings) {
|
|
1313
|
+
if (capability.manifest.layer !== slot) throw oatsError("invalid-resolution", "captured binding slot differs from the manifest owner");
|
|
1314
|
+
const result = runCapturedProviderBinding({ deployment: options.deployment, artifacts: record.artifacts,
|
|
1315
|
+
capability: capability.id, phase: "check", settings: capability.settings,
|
|
1316
|
+
input: { binding, context: record.context, action: options.action, ...(invocation ? { invocation } : {}) } });
|
|
1317
|
+
if (result.status !== "ready") throw oatsError(result.problems[0]?.code ?? "provider-not-qualified", "captured provider is not ready for this action");
|
|
1318
|
+
}
|
|
1319
|
+
},
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
export function loadCapturedDispatch(options) {
|
|
1324
|
+
return loadCapturedAction(options, capturedDispatchCodecs(options));
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Admit against exact retained action/approval and current owned incarnation.
|
|
1328
|
+
* No provider phase runs here; readiness/execution consume the persisted intent. */
|
|
1329
|
+
export function admitCapturedAction({ deployment, resolution, home, action, input = {}, retryExecutionId, ...rest }) {
|
|
1330
|
+
canonicalJson(rest); objectAt(rest, ["priorReceipt"], []);
|
|
1331
|
+
const metadata = readCapturedInstanceMetadata(home);
|
|
1332
|
+
const options = { deployment, resolution, action,
|
|
1333
|
+
invocationTarget: { home, work: join(home, "work"), name: metadata.instance, agent: metadata.agent },
|
|
1334
|
+
...(Object.hasOwn(rest, "priorReceipt") ? { priorReceipt: rest.priorReceipt } : {}) };
|
|
1335
|
+
const loaded = loadCapturedAction(options, capturedDispatchCodecs(options, { admitting: true }));
|
|
1336
|
+
if (!loaded.capability) throw oatsError("unsupported-action", "inspection/composition does not admit mutation");
|
|
1337
|
+
return admitCapturedInstanceAction({ deployment, home, executionBinding: loaded.invocation.executionBinding,
|
|
1338
|
+
capability: loaded.capability.id, action, input, ...(retryExecutionId !== undefined ? { retryExecutionId } : {}) });
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** Static callable API availability, not instance/provider/host readiness.
|
|
1342
|
+
* This descriptor never probes a backend or admits a native request. */
|
|
1343
|
+
export function capturedNativeSessionAvailability() {
|
|
1344
|
+
return { schemaVersion: 1, api: { contract: "oats.captured-session", version: 2, available: true, backends: ["tmux", "herdr"] }, readiness: { status: "not-checked" } };
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** Resolve only a source record's explicit helper edge. This is static retained
|
|
1348
|
+
* authority inspection, never helper-name discovery, preparation or launch. */
|
|
1349
|
+
export function resolveCapturedHelper(input) {
|
|
1350
|
+
canonicalJson(input);
|
|
1351
|
+
objectAt(input, ["executionBinding", "helper", "name"], ["executionBinding", "helper"]);
|
|
1352
|
+
validateExecutionBinding(input.executionBinding);
|
|
1353
|
+
if (typeof input.helper !== "string" || !input.helper || input.helper.includes("\0")) throw oatsError("invalid-declaration", "helper must be an exact captured helper-map key");
|
|
1354
|
+
if (Object.hasOwn(input, "name") && (typeof input.name !== "string" || !input.name)) throw oatsError("invalid-declaration", "expected helper name must be non-empty text");
|
|
1355
|
+
const deployment = portableScope(input.executionBinding.deployment);
|
|
1356
|
+
const source = loadCapturedDispatch({ deployment, resolution: input.executionBinding.resolution, action: { kind: "inspect" } });
|
|
1357
|
+
if (!Object.hasOwn(source.record.helpers, input.helper)) throw oatsError("helper-not-selected", "source resolution does not select that helper key");
|
|
1358
|
+
const resolution = source.record.helpers[input.helper];
|
|
1359
|
+
const helper = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
|
|
1360
|
+
if (helper.record.subject.kind !== "helper") throw oatsError("invalid-resolution", "captured helper edge does not name a dedicated helper");
|
|
1361
|
+
if (input.name !== undefined && input.name !== helper.record.subject.name) throw oatsError("invalid-resolution", "captured helper name differs from requested name");
|
|
1362
|
+
const responsibleHuman = helper.record.messagingChoice.enabled ? helper.record.messagingChoice.privateKey.human : null;
|
|
1363
|
+
const sourceHuman = source.record.messagingChoice.enabled ? source.record.messagingChoice.privateKey.human : null;
|
|
1364
|
+
if (canonicalJson(source.record.context) !== canonicalJson(helper.record.context) || canonicalJson(sourceHuman) !== canonicalJson(responsibleHuman)) {
|
|
1365
|
+
throw oatsError("needs-configuration", "distinct helper context/human requires an explicit captured helper-request policy; no implicit inheritance was used");
|
|
1366
|
+
}
|
|
1367
|
+
if (!helper.record.dispatch.composition) throw oatsError("resolution-incomplete", "selected helper has no captured curriculum");
|
|
1368
|
+
return {
|
|
1369
|
+
schemaVersion: 1,
|
|
1370
|
+
sourceExecutionBinding: { schemaVersion: 1, deployment, resolution: source.resolution },
|
|
1371
|
+
executionBinding: { schemaVersion: 1, deployment, resolution },
|
|
1372
|
+
helper: { key: input.helper, name: helper.record.subject.name, subject: helper.record.subject },
|
|
1373
|
+
context: helper.record.context, responsibleHuman, workMode: helper.record.dispatch.composition.mode,
|
|
1374
|
+
launchSelection: helper.record.dispatch.launch === null ? null : {
|
|
1375
|
+
runtime: helper.record.dispatch.launch.runtime, model: helper.record.dispatch.launch.model,
|
|
1376
|
+
},
|
|
1377
|
+
launch: capturedNativeSessionAvailability(),
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/** Bounded scaffold-only adoption of one exact captured composition. Placement
|
|
1382
|
+
* is explicit and launch/hooks remain pending; unsupported work modes refuse. */
|
|
1383
|
+
export function scaffoldCapturedInstance({ deployment, resolution, home, instance }) {
|
|
1384
|
+
const loaded = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
|
|
1385
|
+
const blocked = loaded.approvals.filter((entry) => !["approved", "not-required"].includes(entry.status));
|
|
1386
|
+
if (blocked.length) throw oatsError("approval-required", "captured scaffold needs approval for every selected executable artifact", blocked);
|
|
1387
|
+
validateCapturedDirectoryTarget(home, instance);
|
|
1388
|
+
if (loaded.record.dispatch.composition?.mode !== "directory") throw oatsError("needs-configuration", "captured scaffold currently requires retained directory work mode");
|
|
1389
|
+
if (existsSync(home)) throw oatsError("E_INSTANCE_EXISTS", `instance home already exists: ${home}`);
|
|
1390
|
+
// Native evidence is initialized only for this newly created incarnation.
|
|
1391
|
+
// Existing sidecars are retained obligations, never silent history backfill.
|
|
1392
|
+
const history = nativeHistoryPath(home), baseline = retirementBaselinePath(home);
|
|
1393
|
+
const parents = [dirname(history), dirname(dirname(baseline)), dirname(baseline)];
|
|
1394
|
+
const parentGuard = (create = false) => {
|
|
1395
|
+
for (const path of parents) {
|
|
1396
|
+
if (create && !existsSync(path)) mkdirSync(path, { mode: 0o700 });
|
|
1397
|
+
if (existsSync(path) && !lstatSync(path).isDirectory()) throw oatsError("integrity-drift", "native custody parent is not an owned directory");
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
parentGuard();
|
|
1401
|
+
if (existsSync(history) || existsSync(baseline)) throw oatsError("migration-required", "native custody already exists at this home address; preserve it and select a fresh home");
|
|
1402
|
+
const result = materializeCapturedDirectoryScaffold({ home, instance, loaded });
|
|
1403
|
+
try {
|
|
1404
|
+
const original = readCapturedInstanceAuthority(deployment, home).row;
|
|
1405
|
+
const assertRoots = () => { assertCapturedInstanceCustody(original); parentGuard(); };
|
|
1406
|
+
assertRoots(); parentGuard(true); initializeNativeHistory(home);
|
|
1407
|
+
writeRetirementBaseline(home, join(home, "work"), "directory", {}, [], { launched: false },
|
|
1408
|
+
{ exclusive: true, incarnationId: original.incarnationId, executionBinding: original.executionBinding });
|
|
1409
|
+
assertRoots();
|
|
1410
|
+
const metadata = readCapturedInstanceMetadata(home);
|
|
1411
|
+
const proof = { schemaVersion: 1, directories: { historyRoot: directoryIdentity(dirname(history)), history: directoryIdentity(history),
|
|
1412
|
+
retirementRoot: directoryIdentity(dirname(dirname(baseline))), baselines: directoryIdentity(dirname(baseline)) } };
|
|
1413
|
+
atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...metadata, captured: { ...metadata.captured, nativeScaffold: proof } }), { assertRoots });
|
|
1414
|
+
markCapturedNativeScaffold(deployment, home, proof);
|
|
1415
|
+
return result;
|
|
1416
|
+
} catch (error) { error.home = home; error.cleanupRequired = true; throw error; }
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** Run retained spawn hooks for an exact scaffold. Required-hook failure retains
|
|
1420
|
+
* the home and its receipts for explicit compensation; launch stays pending. */
|
|
1421
|
+
export function activateCapturedScaffold(options) {
|
|
1422
|
+
let { deployment, resolution, home, extraEnv = {} } = options;
|
|
1423
|
+
home = resolve(home);
|
|
1424
|
+
const file = join(home, "instance.json");
|
|
1425
|
+
let metadata = readCapturedInstanceMetadata(home);
|
|
1426
|
+
const original = readCapturedInstanceAuthority(deployment, home).row;
|
|
1427
|
+
const retry = Object.hasOwn(options, "retryIntents"), retryIntents = retry ? options.retryIntents : {};
|
|
1428
|
+
canonicalJson(retryIntents); objectAt(retryIntents, null, []);
|
|
1429
|
+
const savedIntents = {};
|
|
1430
|
+
for (const intent of original.intents.filter(entry => entry.action.kind === "hook" && entry.action.name === "spawn")) {
|
|
1431
|
+
if (Object.hasOwn(savedIntents, intent.capability)) throw oatsError("invalid-resolution", "multiple spawn requests need explicit reconciliation, not inferred retry selection");
|
|
1432
|
+
savedIntents[intent.capability] = { schemaVersion: 1, executionId: intent.executionId, incarnationId: original.incarnationId, attempt: intent.attempt };
|
|
1433
|
+
}
|
|
1434
|
+
for (const [id, intent] of Object.entries(metadata.captured.hookIntents || {})) {
|
|
1435
|
+
if (savedIntents[id]?.executionId !== intent.executionId || intent.incarnationId !== original.incarnationId) throw oatsError("invalid-resolution", "saved spawn reference differs from indexed authority");
|
|
1436
|
+
}
|
|
1437
|
+
if (retry) {
|
|
1438
|
+
const expected = Object.fromEntries(Object.entries(savedIntents).map(([id, intent]) => [id, intent.executionId]));
|
|
1439
|
+
if (canonicalJson(expected) !== canonicalJson(retryIntents)) throw oatsError("invalid-resolution", "spawn retry must name every indexed hook intent exactly; no partial implicit new requests");
|
|
1440
|
+
} else if (Object.keys(savedIntents).length) throw oatsError("invalid-resolution", "existing spawn obligations require an explicit retry");
|
|
1441
|
+
const priorStatus = retry ? metadata.captured.lifecycle : "scaffolded-hooks-pending";
|
|
1442
|
+
if (retry && !["spawn-failed-cleanup-required", "spawned-cleanup-required"].includes(priorStatus)) throw oatsError("invalid-resolution", "captured scaffold is not awaiting activation retry");
|
|
1443
|
+
const inspected = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
|
|
1444
|
+
const composed = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
|
|
1445
|
+
const subject = inspected.record.subject, expectedAgent = subject.kind === "persistent" ? subject.soul.alias : subject.name;
|
|
1446
|
+
const expectedHuman = inspected.record.messagingChoice.enabled ? inspected.record.messagingChoice.privateKey.human : null;
|
|
1447
|
+
let sameDeployment = false;
|
|
1448
|
+
try { sameDeployment = portableScope(metadata.executionBinding?.deployment) === portableScope(deployment); } catch { /* refused below */ }
|
|
1449
|
+
if (metadata.home !== home || metadata.instance !== basename(home) || metadata.agent !== expectedAgent
|
|
1450
|
+
|| metadata.captured?.lifecycle !== priorStatus || metadata.executionBinding?.resolution?.id !== resolution.id
|
|
1451
|
+
|| !sameDeployment || canonicalJson(metadata.responsibleHuman) !== canonicalJson(expectedHuman)) {
|
|
1452
|
+
throw oatsError("invalid-resolution", "captured scaffold metadata differs from the requested resolution or home");
|
|
1453
|
+
}
|
|
1454
|
+
const assertRoots = () => {
|
|
1455
|
+
const row = assertCapturedInstanceCustody(original);
|
|
1456
|
+
if (row.status !== "spawn-hooks-running") throw oatsError("selection-changed", "activation no longer owns the indexed lifecycle state");
|
|
1457
|
+
return row;
|
|
1458
|
+
};
|
|
1459
|
+
const reportFailure = (error, observed = {}) => {
|
|
1460
|
+
const facts = { meta: observed.meta || {}, intents: observed.intents || {} };
|
|
1461
|
+
let report, reportingFailure;
|
|
1462
|
+
try { report = recordCapturedCustodyFailure(original, { ...facts, code: error.code || "E_CAPTURED_PUBLICATION", message: error.message }); }
|
|
1463
|
+
catch (failure) { reportingFailure = { code: failure.code || "E_CAPTURED_CUSTODY", message: String(failure.message).slice(0, 200) }; }
|
|
1464
|
+
// Independent ledger first; if that too is held, propagate the observed
|
|
1465
|
+
// receipt through the existing caller error channel, not the foreign home.
|
|
1466
|
+
error.home = home;
|
|
1467
|
+
error.capturedCustody = { incarnationId: original.incarnationId, executionBinding: original.executionBinding,
|
|
1468
|
+
...facts, ...(report ? { report } : {}), ...(reportingFailure ? { reportingFailure } : {}) };
|
|
1469
|
+
throw error;
|
|
1470
|
+
};
|
|
1471
|
+
const publish = (next, status, observed = {}) => {
|
|
1472
|
+
try {
|
|
1473
|
+
atomicWriteFileSync(file, canonicalJson(next), { assertRoots });
|
|
1474
|
+
assertRoots();
|
|
1475
|
+
setCapturedInstanceStatus(deployment, home, status, { expectedStatus: "spawn-hooks-running" });
|
|
1476
|
+
} catch (error) { reportFailure(error, observed); }
|
|
1477
|
+
};
|
|
1478
|
+
setCapturedInstanceStatus(deployment, home, "spawn-hooks-running", { expectedStatus: priorStatus });
|
|
1479
|
+
let hooks;
|
|
1480
|
+
try {
|
|
1481
|
+
hooks = runCapturedLifecycleHooks("spawn", { deployment, resolution, home, instance: metadata.instance, agentName: expectedAgent, retryIntents, assertRoots,
|
|
1482
|
+
extraEnv: { ...extraEnv, OATS_WORK: "directory", OATS_KIND: subject.kind } });
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
const failure = { capability: "oats.kernel", event: "spawn", message: String(error.message || error).slice(0, 200), required: true, unconfirmed: true };
|
|
1485
|
+
metadata = { ...metadata, capabilityMeta: { ...metadata.capabilityMeta, ...error.capturedHooks?.meta }, captured: { ...metadata.captured,
|
|
1486
|
+
lifecycle: "spawn-failed-cleanup-required", hookOrder: error.capturedHooks?.order || [], hookFailures: [failure],
|
|
1487
|
+
hookIntents: { ...savedIntents, ...error.capturedHooks?.intents } } };
|
|
1488
|
+
publish(metadata, "spawn-failed-cleanup-required", error.capturedHooks);
|
|
1489
|
+
error.home = home; throw error;
|
|
1490
|
+
}
|
|
1491
|
+
const required = hooks.failures.filter((failure) => failure.required);
|
|
1492
|
+
let unsettled;
|
|
1493
|
+
try {
|
|
1494
|
+
const row = readCapturedInstanceIndex(deployment).instances.find(row => row.incarnationId === original.incarnationId);
|
|
1495
|
+
if (!row) throw oatsError("selection-changed", "captured activation is no longer indexed");
|
|
1496
|
+
unsettled = row.intents.some(intent => intent.state !== "completed");
|
|
1497
|
+
} catch (error) { reportFailure(error, hooks); }
|
|
1498
|
+
// Optional functionality can fail without a required-hook error, but custody
|
|
1499
|
+
// remains explicitly nonterminal and the saved-map retry route stays usable.
|
|
1500
|
+
const status = required.length ? "spawn-failed-cleanup-required" : unsettled ? "spawned-cleanup-required" : "spawned-launch-pending";
|
|
1501
|
+
hooks.intents = { ...savedIntents, ...hooks.intents };
|
|
1502
|
+
metadata = { ...metadata, capabilityMeta: { ...metadata.capabilityMeta, ...hooks.meta }, captured: { ...metadata.captured,
|
|
1503
|
+
lifecycle: status, hookOrder: hooks.order, hookFailures: hooks.failures, hookIntents: hooks.intents } };
|
|
1504
|
+
publish(metadata, status, hooks);
|
|
1505
|
+
if (required.length) {
|
|
1506
|
+
const error = oatsError("E_REQUIRED_HOOK_FAILED", "captured spawn hook failed; home and receipts are retained for explicit cleanup", required);
|
|
1507
|
+
error.home = home; throw error;
|
|
1508
|
+
}
|
|
1509
|
+
return { home, instance: metadata.instance, agent: expectedAgent, incarnationId: metadata.incarnationId, executionBinding: metadata.executionBinding,
|
|
1510
|
+
responsibleHuman: expectedHuman, launched: false, hooks, launchPending: true, functionalReady: true,
|
|
1511
|
+
hooksPending: unsettled, cleanupRequired: unsettled };
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1142
1514
|
/** `operations`: what a capability offers a GUI or scheduler by name, each
|
|
1143
1515
|
* delegating to one of its own `commands`. kind "action" runs something;
|
|
1144
1516
|
* kind "view" answers { documents: [...] } for presentation (a knowledge
|
|
@@ -1251,40 +1623,6 @@ export function capabilityManifest(name, startDir) {
|
|
|
1251
1623
|
return capabilityManifests(startDir)[name];
|
|
1252
1624
|
}
|
|
1253
1625
|
|
|
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
1626
|
/** Remove source-control metadata from the ROOT of a managed artifact.
|
|
1289
1627
|
*
|
|
1290
1628
|
* It must not be installed, and it must not become an integrity exclusion:
|
|
@@ -1588,14 +1926,6 @@ export function capabilityCompatibility(manifest, version = OATS_VERSION) {
|
|
|
1588
1926
|
}
|
|
1589
1927
|
|
|
1590
1928
|
// ---------- 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
1929
|
/** Where one materialized capability artifact lives at a scope.
|
|
1600
1930
|
*
|
|
1601
1931
|
* This is the LAST line of defence, and it is a positive proof rather than a
|
|
@@ -1614,10 +1944,6 @@ export const installedCapabilityDir = (levelDir, capabilityId) => {
|
|
|
1614
1944
|
}
|
|
1615
1945
|
return dir;
|
|
1616
1946
|
};
|
|
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
1947
|
/** Prefix of a transaction staging directory. It lives inside the (gitignored)
|
|
1622
1948
|
* installed store so the commit phase is a same-filesystem rename, and it is
|
|
1623
1949
|
* dot-prefixed so discovery skips it. */
|
|
@@ -1628,37 +1954,6 @@ const STAGING_PREFIX = ".staging-";
|
|
|
1628
1954
|
* use site: every resolver reads the configured path and falls back here. */
|
|
1629
1955
|
export const DEFAULT_PACKAGE_PATH = "oats-package";
|
|
1630
1956
|
|
|
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
1957
|
/** Split an optional `#<package-path>` fragment off a source spec. A source may
|
|
1663
1958
|
* carry at most one fragment; the fragment is removed BEFORE `@ref` parsing so
|
|
1664
1959
|
* a path can never be mistaken for part of a ref. */
|
|
@@ -1768,51 +2063,6 @@ export function inspectGitSourceRoot(spec) {
|
|
|
1768
2063
|
} catch (e) { rmSync(tmp, { recursive: true, force: true }); throw e; }
|
|
1769
2064
|
}
|
|
1770
2065
|
|
|
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
2066
|
/** Official package catalog: identity + discovery ONLY — resolving through it
|
|
1817
2067
|
* never advances a lock and never grants executable trust (contract §1).
|
|
1818
2068
|
* Workstream 3 seeds the kernel-bundled catalog; OATS_PACKAGE_CATALOG points
|
|
@@ -1914,27 +2164,6 @@ export function packageIntegrity(dir) {
|
|
|
1914
2164
|
return `sha256-${hash.digest("hex")}`;
|
|
1915
2165
|
}
|
|
1916
2166
|
|
|
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
2167
|
const PACKAGE_MANIFEST_KEYS = new Set(["package", "version", "description", "compatibility", "capabilities", "configTemplates", "configs", "dependencies"]);
|
|
1939
2168
|
/** Canonical home of a package's config templates. Manifest paths are
|
|
1940
2169
|
* repository-relative and always spelled with "/", on every platform. */
|
|
@@ -1959,12 +2188,12 @@ export function isCanonicalTemplatePath(p) {
|
|
|
1959
2188
|
* whichever spelling the manifest used
|
|
1960
2189
|
* _capabilities [{ id, rel, dir, manifest }]
|
|
1961
2190
|
*/
|
|
1962
|
-
export function loadPackageManifestAt(pdir) {
|
|
2191
|
+
export function loadPackageManifestAt(pdir, { strict = false } = {}) {
|
|
1963
2192
|
const mf = join(pdir, "oats-package.json");
|
|
1964
2193
|
if (!existsSync(mf)) throw oatsError("invalid-package-manifest", `${pdir} has no oats-package.json distribution manifest`);
|
|
1965
2194
|
let m;
|
|
1966
|
-
try { m = JSON.parse(readFileSync(mf, "utf8")); }
|
|
1967
|
-
catch (e) { throw oatsError("invalid-package-manifest", `invalid JSON in ${mf}: ${e.message}`); }
|
|
2195
|
+
try { m = strict ? parseStrictJson(readPortableBytes(mf)) : JSON.parse(readFileSync(mf, "utf8")); }
|
|
2196
|
+
catch (e) { if (strict) throw e; throw oatsError("invalid-package-manifest", `invalid JSON in ${mf}: ${e.message}`); }
|
|
1968
2197
|
// Hostile-input shapes: JSON null/scalar/array roots are valid JSON but not manifests.
|
|
1969
2198
|
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
2199
|
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 +2244,7 @@ export function loadPackageManifestAt(pdir) {
|
|
|
2015
2244
|
for (const rel of m.capabilities) {
|
|
2016
2245
|
const dir = inside(rel, "capability");
|
|
2017
2246
|
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}
|
|
2247
|
+
const cm = loadManifestAt(dir, `package:${m.package}`, { strict });
|
|
2019
2248
|
// A PACKAGE-exported id will be materialized as a directory name under
|
|
2020
2249
|
// installed/, so it must satisfy the materialized grammar — stricter than
|
|
2021
2250
|
// the legacy standalone-capability rule loadManifestAt applies, which still
|
|
@@ -2129,21 +2358,7 @@ export function assertCapabilitySelfContained(capDir, manifest) {
|
|
|
2129
2358
|
}
|
|
2130
2359
|
}
|
|
2131
2360
|
|
|
2132
|
-
const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
2133
2361
|
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
2362
|
/** A null-prototype copy of a raw parsed JSON map. Raw objects return inherited
|
|
2148
2363
|
* `constructor`/`toString`/`valueOf` for `map[id]` even with no own entry, so
|
|
2149
2364
|
* every ID-keyed map in the engine goes through this (or `Object.hasOwn`) before
|
|
@@ -2154,93 +2369,15 @@ function nullProtoMap(raw) {
|
|
|
2154
2369
|
return out;
|
|
2155
2370
|
}
|
|
2156
2371
|
|
|
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. */
|
|
2372
|
+
/** Public bytes seam for evidence-grade migration. Ordinary consumers use the
|
|
2373
|
+
* file wrapper below; both share the one acyclic legacy decoder. */
|
|
2374
|
+
export function parseLockBytesStrict(bytes, { file = "<oats-lock.json>", limits } = {}) {
|
|
2375
|
+
return decodeLegacyLockBytes(bytes, { file, limits, retiredCapabilityReason });
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2166
2378
|
export function parseLockFileStrict(file) {
|
|
2167
2379
|
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;
|
|
2380
|
+
return parseLockBytesStrict(readFileSync(file), { file });
|
|
2244
2381
|
}
|
|
2245
2382
|
|
|
2246
2383
|
/** Every lock-owning scope visible from a directory, outermost → innermost.
|
|
@@ -2753,108 +2890,13 @@ function pruneCreatedAnchors(createdAnchors) {
|
|
|
2753
2890
|
}
|
|
2754
2891
|
}
|
|
2755
2892
|
|
|
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
|
-
}
|
|
2893
|
+
/** One materializer for old acquisition/restore and the new preparation adapter:
|
|
2894
|
+
* dependencies -> complete containment/native checks -> projection -> stable v1
|
|
2895
|
+
* installation provenance -> digest. No source/selection/trust policy moves here. */
|
|
2896
|
+
const materializeCapability = createCapabilityMaterializer({
|
|
2897
|
+
materializeCapabilityDeps, assertCapabilitySelfContained,
|
|
2898
|
+
assertMaterializedDepsContained, assertNoNativeBinaries,
|
|
2899
|
+
});
|
|
2858
2900
|
|
|
2859
2901
|
/** Config-template descriptors AND payload bytes, read from a staged package
|
|
2860
2902
|
* before staging is discarded, so the config lane can offer or adopt a template
|
|
@@ -2938,10 +2980,14 @@ export function acquirePackage(levelDir, spec, opts = {}) {
|
|
|
2938
2980
|
const { dir: staging, createdAnchors, ignore } = beginStaging(levelDir);
|
|
2939
2981
|
const artifactsDir = join(staging, "artifacts");
|
|
2940
2982
|
mkdirSync(artifactsDir, { recursive: true });
|
|
2941
|
-
const resolved = new Map(); // identity → staged package record
|
|
2942
2983
|
let counter = 0;
|
|
2943
|
-
const
|
|
2984
|
+
const readPackage = (srcSpec, parent, chain) => {
|
|
2985
|
+
const baseDir = parent?.parsedSource.kind === "path" ? parent.parsedSource.path : undefined;
|
|
2944
2986
|
const p = parsePackageSource(srcSpec, { baseDir });
|
|
2987
|
+
if (parent) {
|
|
2988
|
+
if (p.kind === "git" && !p.ref) throw oatsError("invalid-source", `package dependency must be pinned to a tag/commit: "${srcSpec}" (declared by ${parent.package})`);
|
|
2989
|
+
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`);
|
|
2990
|
+
}
|
|
2945
2991
|
const dest = join(staging, `pkg-${counter++}`);
|
|
2946
2992
|
let commit, packagePath;
|
|
2947
2993
|
if (!chain.length && opts.rootSnapshot) {
|
|
@@ -2962,7 +3008,6 @@ export function acquirePackage(levelDir, spec, opts = {}) {
|
|
|
2962
3008
|
} else ({ commit, path: packagePath } = fetchPackageSource(p, dest, opts.catalog));
|
|
2963
3009
|
const m = loadPackageManifestAt(dest);
|
|
2964
3010
|
const id = m.package;
|
|
2965
|
-
if (chain.includes(id)) throw oatsError("dependency-cycle", `package dependency cycle: ${[...chain, id].join(" → ")}`, [...chain, id]);
|
|
2966
3011
|
// Preserve the ORIGINAL catalog spec in lock metadata: bare and explicit
|
|
2967
3012
|
// selector forms must remain distinguishable for update. The resolved git
|
|
2968
3013
|
// commit is already pinned separately in `commit`.
|
|
@@ -2971,36 +3016,21 @@ export function acquirePackage(levelDir, spec, opts = {}) {
|
|
|
2971
3016
|
// contain several packages (contract §1.1), so two payload roots claiming one
|
|
2972
3017
|
// package identity are a collision, not the same package resolved twice.
|
|
2973
3018
|
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, {
|
|
3019
|
+
return {
|
|
2997
3020
|
package: id, dir: dest, manifest: m, source, path: packagePath, sourceKey, commit,
|
|
2998
|
-
version: m.version,
|
|
2999
|
-
}
|
|
3000
|
-
return id;
|
|
3021
|
+
version: m.version, capabilities: m._capabilities, parsedSource: p, dependencyRequests: m.dependencies || [],
|
|
3022
|
+
};
|
|
3001
3023
|
};
|
|
3002
3024
|
try {
|
|
3003
|
-
const
|
|
3025
|
+
const { roots: [rootId], packages: resolved } = resolvePackageClosure({
|
|
3026
|
+
requests: [spec], readPackage, context: levelDir,
|
|
3027
|
+
validatePackage(record) {
|
|
3028
|
+
const compat = capabilityCompatibility(record.manifest);
|
|
3029
|
+
if (!compat.compatible) throw oatsError("incompatible-oats", `package ${record.package} requires OATS ${compat.range} (running ${OATS_VERSION})`);
|
|
3030
|
+
},
|
|
3031
|
+
finalizePackage: (record, deps) => ({ ...record, integrity: packageIntegrity(record.dir), deps }),
|
|
3032
|
+
discardPackage: (record) => rmSync(record.dir, { recursive: true, force: true }),
|
|
3033
|
+
});
|
|
3004
3034
|
if (opts.expectPackage && rootId !== opts.expectPackage) {
|
|
3005
3035
|
throw oatsError("duplicate-package-identity", `source ${spec} no longer provides root package "${opts.expectPackage}" (root resolved to "${rootId}")`);
|
|
3006
3036
|
}
|
|
@@ -3187,97 +3217,6 @@ export function acquirePackage(levelDir, spec, opts = {}) {
|
|
|
3187
3217
|
}
|
|
3188
3218
|
}
|
|
3189
3219
|
|
|
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
3220
|
/** Fetch ONE locked package's exact provenance into staging and return the
|
|
3282
3221
|
* verified payload root. Used by restore, update planning, migration and the
|
|
3283
3222
|
* locked-template reader — every path that needs the exact bytes a package row
|
|
@@ -3661,33 +3600,27 @@ export function readLockedConfigTemplates(startDir, packageId, opts = {}) {
|
|
|
3661
3600
|
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
3662
3601
|
}
|
|
3663
3602
|
|
|
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
3603
|
/** Atomic file replacement: write to a same-directory temp file, then rename
|
|
3681
3604
|
* over the destination — an interrupted write leaves the original bytes intact
|
|
3682
3605
|
* (reviewer-21849d4). */
|
|
3683
|
-
function atomicWriteFileSync(file, content) {
|
|
3606
|
+
function atomicWriteFileSync(file, content, { assertRoots } = {}) {
|
|
3684
3607
|
const tmp = join(dirname(file), `.${basename(file)}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
3608
|
+
let written = false;
|
|
3685
3609
|
try {
|
|
3686
|
-
|
|
3610
|
+
assertRoots?.();
|
|
3611
|
+
writeFileSync(tmp, content, assertRoots ? { flag: "wx", mode: 0o600 } : undefined); written = true;
|
|
3612
|
+
assertRoots?.();
|
|
3687
3613
|
renameSync(tmp, file);
|
|
3688
|
-
} catch (
|
|
3689
|
-
|
|
3690
|
-
|
|
3614
|
+
} catch (error) {
|
|
3615
|
+
// Never follow a replacement parent to clean up a same-named temp file.
|
|
3616
|
+
// Unguarded legacy callers retain their existing behavior.
|
|
3617
|
+
if (assertRoots && !written) throw error;
|
|
3618
|
+
try { assertRoots?.(); rmSync(tmp, { force: true }); }
|
|
3619
|
+
catch (cleanup) {
|
|
3620
|
+
const failure = new AggregateError([error, cleanup], "metadata publication and owned cleanup failed", { cause: error });
|
|
3621
|
+
failure.code = error.code || cleanup.code; throw failure;
|
|
3622
|
+
}
|
|
3623
|
+
throw error;
|
|
3691
3624
|
}
|
|
3692
3625
|
}
|
|
3693
3626
|
|
|
@@ -4170,15 +4103,8 @@ export function composeInstanceAgentsMd(soulDir, contextDir, soulName, workMode,
|
|
|
4170
4103
|
if (cap.inject && existsSync(cap.inject)) wanted.push([`capability:${cap.id}`, cap.inject]);
|
|
4171
4104
|
}
|
|
4172
4105
|
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 };
|
|
4106
|
+
const blocks = wanted.map(([source, file]) => ({ source, file, content: readFileSync(file, "utf8").trim() }));
|
|
4107
|
+
return { text: renderInstructionText(readFileSync(agentsMd, "utf8"), blocks), blocks, resolved };
|
|
4182
4108
|
}
|
|
4183
4109
|
|
|
4184
4110
|
/** The skill entries a tree contributes — THE discovery rule, shared by preflight
|
|
@@ -4726,6 +4652,187 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
|
|
|
4726
4652
|
return results;
|
|
4727
4653
|
}
|
|
4728
4654
|
|
|
4655
|
+
/** Derive one selected supplemental input from the exact hook owner's binding
|
|
4656
|
+
* and already-validated generic target. Never infer dependency from a slot/name. */
|
|
4657
|
+
function capturedHookSourceReceipt(loaded) {
|
|
4658
|
+
const { record, capability, invocation } = loaded, target = invocation?.instance;
|
|
4659
|
+
const declaration = capability && hookDeclaration(capability.manifest.hooks?.[invocation?.action.name]);
|
|
4660
|
+
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");
|
|
4661
|
+
const binding = record.bindings[capability.manifest.layer];
|
|
4662
|
+
if (!binding || binding.capability !== capability.id) throw oatsError("needs-configuration", "source receipt requires the opting capability's actual selected binding");
|
|
4663
|
+
const roleFile = loaded.resources.get(record.dispatch.composition?.body);
|
|
4664
|
+
if (!roleFile) throw oatsError("invalid-resolution", "selected source input lacks its canonical body resource");
|
|
4665
|
+
return validateCapturedSourceReceipt({ schemaVersion: 1, kind: record.subject.kind,
|
|
4666
|
+
home: target.home, work: target.work, context: invocation.executionBinding.deployment,
|
|
4667
|
+
agent: target.agent, instance: target.name, sourceIdentity: record.subject.kind === "persistent" ? record.subject.soul.identity : null,
|
|
4668
|
+
role: decodeUtf8(readPortableBytes(roleFile)), executionBinding: invocation.executionBinding,
|
|
4669
|
+
responsibleHuman: invocation.responsibleHuman, binding });
|
|
4670
|
+
}
|
|
4671
|
+
|
|
4672
|
+
/** Captured lifecycle execution uses the same hook result contract, but every
|
|
4673
|
+
* executable/settings/binding input comes from one verified resolution. It
|
|
4674
|
+
* preflights all applicable hooks before the first lifecycle side effect. */
|
|
4675
|
+
export function runCapturedLifecycleHooks(event, { deployment, resolution, home, instance, agentName, priorMeta = {}, extraEnv = {}, sourceReceipt, assertRoots, retryIntents = {} }) {
|
|
4676
|
+
if (!APPROVED_HOOKS.has(event)) throw oatsError("unsupported-action", `unsupported captured lifecycle event ${JSON.stringify(event)}`);
|
|
4677
|
+
for (const [name, value] of [["home", home], ["deployment", deployment]]) if (typeof value !== "string" || !isAbsolute(value)) throw oatsError("invalid-declaration", `${name} must be absolute`);
|
|
4678
|
+
if (typeof instance !== "string" || !instance || typeof agentName !== "string" || !agentName) throw oatsError("invalid-declaration", "captured lifecycle needs instance and agent names");
|
|
4679
|
+
if (sourceReceipt !== undefined) validateCapturedSourceReceipt(sourceReceipt);
|
|
4680
|
+
const inspected = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
|
|
4681
|
+
if (sourceReceipt) {
|
|
4682
|
+
const { record } = inspected, persistent = record.subject.kind === "persistent";
|
|
4683
|
+
const expectedAgent = persistent ? record.subject.soul.alias : record.subject.name;
|
|
4684
|
+
const expectedIdentity = persistent ? record.subject.soul.identity : null;
|
|
4685
|
+
const expectedHuman = record.messagingChoice.enabled ? record.messagingChoice.privateKey.human : null;
|
|
4686
|
+
const expectedBinding = Object.values(record.bindings).find((binding) => binding.capability === sourceReceipt.binding.capability);
|
|
4687
|
+
const bodyKey = record.dispatch.composition?.body, bodyFile = bodyKey ? inspected.resources.get(bodyKey) : undefined;
|
|
4688
|
+
let role; try { role = bodyFile ? readFileSync(bodyFile, "utf8") : undefined; } catch { role = undefined; }
|
|
4689
|
+
let sameDeployment = false;
|
|
4690
|
+
try { sameDeployment = portableScope(sourceReceipt.executionBinding.deployment) === portableScope(deployment); } catch { /* invalid scope refuses below */ }
|
|
4691
|
+
if (sourceReceipt.kind !== (persistent ? "persistent" : "helper") || sourceReceipt.home !== resolve(home)
|
|
4692
|
+
|| sourceReceipt.work !== join(resolve(home), "work") || sourceReceipt.agent !== expectedAgent || agentName !== expectedAgent || sourceReceipt.instance !== instance
|
|
4693
|
+
|| sourceReceipt.executionBinding.resolution.id !== resolution.id || !sameDeployment
|
|
4694
|
+
|| canonicalJson(sourceReceipt.sourceIdentity) !== canonicalJson(expectedIdentity)
|
|
4695
|
+
|| canonicalJson(sourceReceipt.responsibleHuman) !== canonicalJson(expectedHuman)
|
|
4696
|
+
|| !expectedBinding || canonicalJson(sourceReceipt.binding) !== canonicalJson(expectedBinding)
|
|
4697
|
+
|| role === undefined || sourceReceipt.role !== role) {
|
|
4698
|
+
throw oatsError("invalid-resolution", "captured source receipt differs from the verified resolution or lifecycle target");
|
|
4699
|
+
}
|
|
4700
|
+
}
|
|
4701
|
+
const ids = Object.keys(inspected.record.dispatch.providerManifests);
|
|
4702
|
+
if (event === "retire") ids.reverse();
|
|
4703
|
+
const entries = ids.flatMap((id) => {
|
|
4704
|
+
const capability = inspected.capabilities.get(id), declaration = hookDeclaration(capability?.manifest?.hooks?.[event]);
|
|
4705
|
+
return declaration ? [{ id, capability, declaration }] : [];
|
|
4706
|
+
});
|
|
4707
|
+
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");
|
|
4708
|
+
objectAt(retryIntents, entries.map(({ id }) => id), []);
|
|
4709
|
+
const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [], intents: {} };
|
|
4710
|
+
const checkRoots = () => {
|
|
4711
|
+
try { assertRoots?.(); }
|
|
4712
|
+
catch (error) { error.capturedHooks = { meta: results.meta, intents: results.intents, failures: results.failures, order: results.order }; throw error; }
|
|
4713
|
+
};
|
|
4714
|
+
const envOwners = new Map(), envDeclarations = new Map(entries.map(({ id, capability }) => [id, {
|
|
4715
|
+
names: new Set(capability.manifest.environment || []), namespaces: [...(capability.manifest.environmentNamespaces || [])],
|
|
4716
|
+
}]));
|
|
4717
|
+
const ready = [];
|
|
4718
|
+
for (const entry of entries) {
|
|
4719
|
+
const options = { deployment, resolution, action: { kind: "hook", capability: entry.id, name: event },
|
|
4720
|
+
invocationTarget: { home, work: join(home, "work"), name: instance, agent: agentName },
|
|
4721
|
+
...(Object.hasOwn(priorMeta, entry.id) ? { priorReceipt: priorMeta[entry.id] } : {}) };
|
|
4722
|
+
let staticLoaded;
|
|
4723
|
+
try {
|
|
4724
|
+
// Check every static source/target/approval/executable before any effects.
|
|
4725
|
+
// Readiness is action-specific and receives its admitted intent below.
|
|
4726
|
+
staticLoaded = loadCapturedAction(options, capturedDispatchCodecs(options, { admitting: true }));
|
|
4727
|
+
} catch (error) {
|
|
4728
|
+
const detail = String(error.message || error).slice(0, 200);
|
|
4729
|
+
results.warnings.push(`${entry.id} ${event} hook unavailable: ${detail}`);
|
|
4730
|
+
results.failures.push({ capability: entry.id, event, message: detail, required: entry.declaration.required });
|
|
4731
|
+
if (event === "spawn" && entry.declaration.required) return results;
|
|
4732
|
+
continue;
|
|
4733
|
+
}
|
|
4734
|
+
// Selected-input authority failures are not optional hook functionality.
|
|
4735
|
+
// Derive ALL requested owner inputs before any provider check/effect.
|
|
4736
|
+
const selectedReceipt = entry.declaration.inputs?.sourceReceipt ? capturedHookSourceReceipt(staticLoaded) : null;
|
|
4737
|
+
if (sourceReceipt?.binding.capability === entry.id && canonicalJson(sourceReceipt) !== canonicalJson(selectedReceipt)) throw oatsError("invalid-resolution", "explicit receipt differs from the derived opted-in input");
|
|
4738
|
+
ready.push({ ...entry, options, selectedReceipt, instanceFacts: staticLoaded.invocation.instance });
|
|
4739
|
+
}
|
|
4740
|
+
for (const { id, capability, declaration, options, selectedReceipt, instanceFacts } of ready) {
|
|
4741
|
+
checkRoots(); results.order.push(id);
|
|
4742
|
+
const env = { ...process.env };
|
|
4743
|
+
for (const key of Object.keys(env)) if (key.startsWith("OATS_") || key.startsWith("PI_AGENT_") || key === "PI_AGENTS_ROOT") delete env[key];
|
|
4744
|
+
Object.assign(env, extraEnv, {
|
|
4745
|
+
OATS_EVENT: event, OATS_INSTANCE: instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, OATS_AGENT: agentName,
|
|
4746
|
+
OATS_CAPABILITY: id, OATS_CAPABILITY_ROOT: capability.manifest._dir, OATS_LAYER: capability.manifest.layer || "",
|
|
4747
|
+
OATS_CONTEXT: deployment, OATS_WORKSPACE: deployment, OATS_LEVEL: deployment,
|
|
4748
|
+
OATS_DEPLOYMENT: deployment, OATS_RESOLUTION: resolution.id,
|
|
4749
|
+
OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")), OATS_SETTINGS: JSON.stringify(capability.settings),
|
|
4750
|
+
OATS_META: JSON.stringify(priorMeta[id] || {}),
|
|
4751
|
+
});
|
|
4752
|
+
// Caller extras cannot nominate an input file, including when no owner
|
|
4753
|
+
// selected that input. Only private wrappers below supply these variables.
|
|
4754
|
+
for (const key of ["OATS_SOURCE_RECEIPT_FILE", "OATS_INVOCATION_CONTEXT_FILE", "OATS_BINDING_FILE"]) delete env[key];
|
|
4755
|
+
let admission, started = false;
|
|
4756
|
+
try {
|
|
4757
|
+
admission = admitCapturedAction({ deployment, resolution, home, action: options.action,
|
|
4758
|
+
...(Object.hasOwn(priorMeta, id) ? { priorReceipt: priorMeta[id] } : {}),
|
|
4759
|
+
...(Object.hasOwn(retryIntents, id) ? { retryExecutionId: retryIntents[id] } : {}) });
|
|
4760
|
+
results.intents[id] = admission.intent;
|
|
4761
|
+
if (admission.replayed) {
|
|
4762
|
+
// Never re-execute completed hooks to reconstruct runtime contributions.
|
|
4763
|
+
if (!admission.replayable) throw oatsError("needs-configuration", "completed hook has non-replayable runtime contributions");
|
|
4764
|
+
if (admission.receipt !== null) results.meta[id] = admission.receipt;
|
|
4765
|
+
continue;
|
|
4766
|
+
}
|
|
4767
|
+
const admittedOptions = { ...options, intent: admission.intent, priorReceipt: admission.receipt };
|
|
4768
|
+
const admitted = loadCapturedAction(admittedOptions, capturedDispatchCodecs(admittedOptions, { admitting: true }));
|
|
4769
|
+
if (canonicalJson(admitted.invocation.instance) !== canonicalJson(instanceFacts)) throw oatsError("selection-changed", "hook incarnation changed after input preflight");
|
|
4770
|
+
if (selectedReceipt && canonicalJson(capturedHookSourceReceipt(admitted)) !== canonicalJson(selectedReceipt)) throw oatsError("selection-changed", "selected source input changed after admission");
|
|
4771
|
+
const loaded = loadCapturedDispatch(admittedOptions);
|
|
4772
|
+
env.OATS_META = JSON.stringify(admission.receipt ?? {});
|
|
4773
|
+
const invoke = (snapshotEnv = {}) => withCapturedInvocationContextFile(loaded.invocation, contextEnv => withCapturedBindingFile(loaded, bindingEnv => {
|
|
4774
|
+
checkRoots();
|
|
4775
|
+
beginCapturedIntent({ deployment, home, intent: admission.intent, action: options.action }); started = true;
|
|
4776
|
+
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 } });
|
|
4777
|
+
}));
|
|
4778
|
+
const stdout = selectedReceipt ? withCapturedSourceReceiptFile(home, selectedReceipt, invoke) : invoke();
|
|
4779
|
+
const lastLine = String(stdout).trim().split("\n").filter(Boolean).pop() || "{}";
|
|
4780
|
+
let output = {}; try { output = JSON.parse(lastLine); } catch { /* non-JSON hook output is allowed */ }
|
|
4781
|
+
if (output.meta) results.meta[id] = output.meta;
|
|
4782
|
+
if (output.brief) results.briefs.push(`- ${output.brief}`);
|
|
4783
|
+
if (output.warning) results.warnings.push(output.warning);
|
|
4784
|
+
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}`;
|
|
4785
|
+
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)) {
|
|
4786
|
+
results.contributions.push({ capability: id, layer: capability.manifest.layer || null, level: deployment, settings: { ...capability.settings },
|
|
4787
|
+
trust: { trusted: true, integrity: inspected.record.artifacts.capabilities[id].artifact.integrity.value },
|
|
4788
|
+
launch: output.launch && typeof output.launch === "object" ? { ...output.launch } : {}, env: output.env && typeof output.env === "object" ? Object.keys(output.env).sort() : [] });
|
|
4789
|
+
}
|
|
4790
|
+
if (output.env !== undefined) {
|
|
4791
|
+
if (event !== "spawn" && event !== "launch") throw new HookEnvironmentContractError(`${id} hook env is supported only for spawn and launch, not ${event}`);
|
|
4792
|
+
Object.assign(results.env, validateHookEnvironment(id, output.env, envOwners, envDeclarations));
|
|
4793
|
+
}
|
|
4794
|
+
settleCapturedIntent({ deployment, home, intent: admission.intent, action: options.action, state: "completed",
|
|
4795
|
+
receipt: results.meta[id] ?? admission.receipt,
|
|
4796
|
+
replayable: !Object.keys(output.env || {}).length && !Object.keys(output.launch || {}).length });
|
|
4797
|
+
} catch (error) {
|
|
4798
|
+
// A nested private-snapshot cleanup failure must not erase a hook's
|
|
4799
|
+
// already-observed provider receipt. Unwrap bounded owned error causes;
|
|
4800
|
+
// never reinterpret that receipt as a successful/fully cleaned lifecycle.
|
|
4801
|
+
let observed, reported;
|
|
4802
|
+
const seen = new Set(); let cause = error;
|
|
4803
|
+
while (cause && typeof cause === "object" && !seen.has(cause) && seen.size < 16) {
|
|
4804
|
+
seen.add(cause);
|
|
4805
|
+
if (cause.invocationCompleted && typeof cause.invocationResult === "string") { observed = cause.invocationResult; break; }
|
|
4806
|
+
if (typeof cause.stdout === "string" || Buffer.isBuffer(cause.stdout)) { observed = cause.stdout; break; }
|
|
4807
|
+
cause = cause.cause;
|
|
4808
|
+
}
|
|
4809
|
+
try {
|
|
4810
|
+
const output = JSON.parse(String(observed ?? "").trim().split("\n").filter(Boolean).pop() || "{}");
|
|
4811
|
+
if (output?.meta) results.meta[id] = output.meta;
|
|
4812
|
+
if (typeof output?.warning === "string" && output.warning.trim()) reported = output.warning.trim();
|
|
4813
|
+
} catch { /* non-JSON hook failure output is allowed */ }
|
|
4814
|
+
let custodyError;
|
|
4815
|
+
if (admission && !admission.replayed) {
|
|
4816
|
+
try { settleCapturedIntent({ deployment, home, intent: admission.intent, action: options.action,
|
|
4817
|
+
state: started ? "unconfirmed" : "blocked", receipt: results.meta[id] ?? admission.receipt }); }
|
|
4818
|
+
catch (failure) { custodyError = failure; } // Possibly published: retain, never erase/re-admit.
|
|
4819
|
+
}
|
|
4820
|
+
const environment = error instanceof HookEnvironmentContractError;
|
|
4821
|
+
const cleanup = error instanceof AggregateError;
|
|
4822
|
+
const required = environment || cleanup || !!custodyError || declaration.required;
|
|
4823
|
+
const detail = reported || String(error.message || error).slice(0, 200);
|
|
4824
|
+
results.warnings.push(`${id} ${event} hook ${environment ? "environment contract " : ""}failed: ${detail}`);
|
|
4825
|
+
results.failures.push({ capability: id, event, message: detail, required,
|
|
4826
|
+
...(started || custodyError ? { unconfirmed: true } : {}),
|
|
4827
|
+
...(custodyError ? { custody: { code: custodyError.code || "E_INTENT_CUSTODY", message: String(custodyError.message).slice(0, 200) } } : {}),
|
|
4828
|
+
...(environment ? { contract: "environment" } : {}),
|
|
4829
|
+
...(cleanup ? { contract: "snapshot-cleanup", unconfirmed: true, cleanup: { code: error.code || "E_HOOK_CLEANUP", message: String(error.message || error).slice(0, 200) } } : {}) });
|
|
4830
|
+
if (event === "spawn" && required) return results;
|
|
4831
|
+
} finally { checkRoots(); }
|
|
4832
|
+
}
|
|
4833
|
+
return results;
|
|
4834
|
+
}
|
|
4835
|
+
|
|
4729
4836
|
// ---------- agents ----------
|
|
4730
4837
|
/** All local-agent base dirs readable for a root: the scope sibling (canonical)
|
|
4731
4838
|
* plus legacy nested locations. */
|
|
@@ -5452,7 +5559,9 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
|
|
|
5452
5559
|
const hookArgs = recipe.hooks?.launch?.[runtime] || "";
|
|
5453
5560
|
const tail = `${cfgArgs ? ` ${cfgArgs}` : ""}${hookArgs ? ` ${hookArgs}` : ""}`;
|
|
5454
5561
|
let cmdline;
|
|
5455
|
-
if (
|
|
5562
|
+
if (isPiSdkHost(recipe)) {
|
|
5563
|
+
cmdline = `${shq(executable)} ${piHostArgv(recipe, { home, sessionDir: capturedPiSessionDirectory(home) }).map(shq).join(" ")}`;
|
|
5564
|
+
} else if (runtime === "claude") {
|
|
5456
5565
|
cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
|
|
5457
5566
|
} else if (runtime === "codex") {
|
|
5458
5567
|
const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
|
|
@@ -5472,15 +5581,27 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
|
|
|
5472
5581
|
/** Execution, not preview: mark pending before dispatch, then resolve native
|
|
5473
5582
|
* storage inside the backend shell under the actual command environment.
|
|
5474
5583
|
* The original executable/argv is exec'd unchanged after recording succeeds. */
|
|
5475
|
-
function nativeRecordCommand(command, home, runtime) {
|
|
5584
|
+
function nativeRecordCommand(command, home, runtime, onPrepared, prepare = prepareNativeStart) {
|
|
5476
5585
|
const { tokens, binary } = parseLaunchCommand(command);
|
|
5477
5586
|
const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
|
|
5478
|
-
const id =
|
|
5587
|
+
const id = prepare(home, runtime);
|
|
5588
|
+
onPrepared?.(id);
|
|
5479
5589
|
const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
|
|
5480
5590
|
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
5591
|
return `${tokens.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
|
|
5482
5592
|
}
|
|
5483
5593
|
|
|
5594
|
+
/** Common tmux/Herdr completion wrapper for the exact captured Pi SDK profile.
|
|
5595
|
+
* Save the actual native chain's shell status BEFORE the observer. Reuse the
|
|
5596
|
+
* original command environment (including its ORIGINAL attempt); no new intent
|
|
5597
|
+
* or process identity is constructed. The observer never replaces this status. */
|
|
5598
|
+
export function renderCapturedPiCompletion(command, executionCommand, { home, nativeRecordId }) {
|
|
5599
|
+
const { tokens, binary } = parseLaunchCommand(command);
|
|
5600
|
+
const prefix = tokens.slice(0, binary).map(token => token.text).join(" ");
|
|
5601
|
+
const args = ["--oats-pi-record-exit", "1", "--home", home, "--native-record", nativeRecordId].map(shq).join(" ");
|
|
5602
|
+
return `${executionCommand}; oats_start_status=$?; ${prefix} ${shq(process.execPath)} ${shq(PI_SDK_HOST)} ${args} --exit-status "$oats_start_status"`;
|
|
5603
|
+
}
|
|
5604
|
+
|
|
5484
5605
|
/** A recorded recipe this kernel understands, or a refusal before anything
|
|
5485
5606
|
* is observed or stopped. */
|
|
5486
5607
|
export function assertLaunchRecipe(recipe, what) {
|
|
@@ -6744,6 +6865,7 @@ function sessionDirectoryGuard(home) {
|
|
|
6744
6865
|
let meta;
|
|
6745
6866
|
try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { return; } // ordinary receipt validation reports this
|
|
6746
6867
|
if ((meta.work === "directory") !== (baseline.directoryWork === true)) throw oatsError("E_WORK_INSPECTION_FAILED", "directory work mode disagrees with independent session authority");
|
|
6868
|
+
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
6869
|
};
|
|
6748
6870
|
check();
|
|
6749
6871
|
return check;
|
|
@@ -6953,13 +7075,14 @@ function retirementDisposableRoots(work, workMode, capabilities) {
|
|
|
6953
7075
|
return roots;
|
|
6954
7076
|
}
|
|
6955
7077
|
|
|
6956
|
-
function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime) {
|
|
7078
|
+
function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
|
|
6957
7079
|
if (mode === "directory") assertDirectoryRoots(home);
|
|
6958
7080
|
const isWorktree = mode === "worktree";
|
|
6959
7081
|
const status = isWorktree && existsSync(work) ? worktreeStatus(work) : "";
|
|
6960
7082
|
const disposableReceipts = isWorktree ? retirementDisposableRoots(work, workMode, capabilities) : [];
|
|
6961
7083
|
const baseline = {
|
|
6962
7084
|
version: RETIRE_BASELINE_VERSION,
|
|
7085
|
+
...(incarnationId ? { incarnationId, executionBinding } : {}),
|
|
6963
7086
|
...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
|
|
6964
7087
|
home: realPathOrNearest(home),
|
|
6965
7088
|
homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
|
|
@@ -6973,7 +7096,7 @@ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runti
|
|
|
6973
7096
|
};
|
|
6974
7097
|
const path = retirementBaselinePath(home);
|
|
6975
7098
|
mkdirSync(dirname(path), { recursive: true });
|
|
6976
|
-
writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600 });
|
|
7099
|
+
writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600, ...(exclusive ? { flag: "wx" } : {}) });
|
|
6977
7100
|
}
|
|
6978
7101
|
|
|
6979
7102
|
function nestedGitRoots(root) {
|
|
@@ -7024,9 +7147,11 @@ function runtimeAuthorityOf(baseline) {
|
|
|
7024
7147
|
return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
|
|
7025
7148
|
}
|
|
7026
7149
|
|
|
7027
|
-
/** Session control uses the
|
|
7150
|
+
/** Session control uses the independent endpoint receipt and, for captured
|
|
7151
|
+
* homes, its original incarnation's existing home/work custody. */
|
|
7028
7152
|
function instanceSessionTarget(home) {
|
|
7029
7153
|
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
|
|
7154
|
+
const requestedHome = home;
|
|
7030
7155
|
home = realPathOrNearest(home);
|
|
7031
7156
|
let baseline, meta;
|
|
7032
7157
|
try {
|
|
@@ -7035,18 +7160,29 @@ function instanceSessionTarget(home) {
|
|
|
7035
7160
|
} catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
|
|
7036
7161
|
const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
|
|
7037
7162
|
if (!authority) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or invalid for ${home}`);
|
|
7163
|
+
let io;
|
|
7164
|
+
if ([baseline, meta].some(value => ["executionBinding", "incarnationId", "captured"].some(key => Object.hasOwn(value, key)))) {
|
|
7165
|
+
// The independent receipt remains the anchor even if mutable home markers
|
|
7166
|
+
// are removed. Missing/invalid captured evidence never falls back to legacy.
|
|
7167
|
+
validateExecutionBinding(baseline.executionBinding);
|
|
7168
|
+
const { row } = readCapturedInstanceAuthority(baseline.executionBinding.deployment, requestedHome);
|
|
7169
|
+
if (row.incarnationId !== baseline.incarnationId || canonicalJson(row.executionBinding) !== canonicalJson(baseline.executionBinding)) {
|
|
7170
|
+
throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "captured incarnation disagrees with independent session receipt");
|
|
7171
|
+
}
|
|
7172
|
+
io = { exec: (...args) => { assertCapturedInstanceCustody(row); return execFileSync(...args); } };
|
|
7173
|
+
}
|
|
7038
7174
|
const endpointAgrees = authority.sessionTarget
|
|
7039
7175
|
? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === authority.sessionTarget[key])
|
|
7040
7176
|
: !meta.sessionTarget && meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
|
|
7041
7177
|
if (meta.launched !== authority.launched || (authority.launched && !endpointAgrees)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
|
|
7042
|
-
return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined };
|
|
7178
|
+
return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined, io };
|
|
7043
7179
|
}
|
|
7044
7180
|
|
|
7045
7181
|
export function inspectInstanceSession(home) {
|
|
7046
7182
|
if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped" };
|
|
7047
7183
|
const s = instanceSessionTarget(home);
|
|
7048
7184
|
if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched" };
|
|
7049
|
-
try { return { home: s.home, ...inspectSessionTarget(s.target) }; }
|
|
7185
|
+
try { return { home: s.home, ...inspectSessionTarget(s.target, s.io) }; }
|
|
7050
7186
|
catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
|
|
7051
7187
|
}
|
|
7052
7188
|
|
|
@@ -7060,7 +7196,7 @@ export function inputInstanceSession(home, text) {
|
|
|
7060
7196
|
if (typeof text !== "string" || !text.trim() || text.includes("\0") || Buffer.byteLength(text) > 256 * 1024) throw oatsError("E_BAD_ARGS", "session input must be nonempty text without NUL, at most 256 KiB");
|
|
7061
7197
|
const s = instanceSessionTarget(home);
|
|
7062
7198
|
if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
|
|
7063
|
-
try { return { home: s.home, ...inputSessionTarget(s.target, text) }; }
|
|
7199
|
+
try { return { home: s.home, ...inputSessionTarget(s.target, text, s.io) }; }
|
|
7064
7200
|
catch (e) { throw oatsError("E_SESSION_INPUT_FAILED", `cannot submit session input: ${e.message}`); }
|
|
7065
7201
|
}
|
|
7066
7202
|
|
|
@@ -7431,12 +7567,251 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
|
|
|
7431
7567
|
* per-home lock and pending receipt across stop and launch, every preflight
|
|
7432
7568
|
* before the stop, a bounded SIGTERM with no escalation, factual receipts.
|
|
7433
7569
|
* Not a retirement: home, work, identity and notes stay. */
|
|
7570
|
+
/** Exact captured plan, existing native session transaction. This is dispatch
|
|
7571
|
+
* custody, not provider enrollment or proof that a model finished its task. */
|
|
7572
|
+
export function startCapturedInstanceSession(home, options = {}) {
|
|
7573
|
+
objectAt(options, ["deployment", "resolution", "backend", "task", "retryExecutionId", "restart", "env", "io", "stopGraceMs"], []);
|
|
7574
|
+
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");
|
|
7575
|
+
let metadata = readCapturedInstanceMetadata(home);
|
|
7576
|
+
home = metadata.home;
|
|
7577
|
+
const binding = metadata.executionBinding, deployment = portableScope(options.deployment ?? binding.deployment), resolution = options.resolution ?? binding.resolution;
|
|
7578
|
+
if (canonicalJson({ schemaVersion: 1, deployment, resolution }) !== canonicalJson(binding)) throw oatsError("invalid-resolution", "captured start selector differs from the owned home");
|
|
7579
|
+
const original = readCapturedInstanceAuthority(deployment, home).row;
|
|
7580
|
+
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");
|
|
7581
|
+
const latestSession = original.intents.findLast(intent => intent.action.kind === "session");
|
|
7582
|
+
const heldLifecycle = (message, indexedStatus = original.status) => {
|
|
7583
|
+
const error = oatsError("selection-changed", message);
|
|
7584
|
+
error.home = home;
|
|
7585
|
+
error.capturedCustody = { incarnationId: original.incarnationId, executionBinding: binding,
|
|
7586
|
+
indexedStatus, metadataStatus: metadata.captured.lifecycle, held: true,
|
|
7587
|
+
...(latestSession ? { intent: { schemaVersion: 1, executionId: latestSession.executionId, incarnationId: original.incarnationId, attempt: latestSession.attempt }, receipt: latestSession.receipt } : {}) };
|
|
7588
|
+
throw error;
|
|
7589
|
+
};
|
|
7590
|
+
const sourceStatus = metadata.captured.lifecycle;
|
|
7591
|
+
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");
|
|
7592
|
+
if (sourceStatus === "start-failed-cleanup-required") {
|
|
7593
|
+
if (!latestSession || options.retryExecutionId !== latestSession.executionId) heldLifecycle("native cleanup requires its exact saved retry, not a new request");
|
|
7594
|
+
if (latestSession.state === "completed") heldLifecycle("completed native dispatch still has publication debt; explicit reconciliation is required");
|
|
7595
|
+
}
|
|
7596
|
+
let ownedState = sourceStatus;
|
|
7597
|
+
const assertRoots = () => {
|
|
7598
|
+
const row = assertCapturedInstanceCustody(original);
|
|
7599
|
+
if (row.status !== ownedState) heldLifecycle("native start no longer owns lifecycle state", row.status);
|
|
7600
|
+
const history = nativeHistoryPath(home), baseline = retirementBaselinePath(home);
|
|
7601
|
+
for (const [key, path] of Object.entries({ historyRoot: dirname(history), history, retirementRoot: dirname(dirname(baseline)), baselines: dirname(baseline) })) {
|
|
7602
|
+
if (!lstatSync(path).isDirectory() || canonicalJson(directoryIdentity(path)) !== canonicalJson(original.nativeScaffold.directories[key])) throw oatsError("integrity-drift", "native custody directory was replaced");
|
|
7603
|
+
}
|
|
7604
|
+
// The strict Pi record owner validates its private manifest via the pinned
|
|
7605
|
+
// inspect/prepare/started APIs. Its approved expected-ID claim legitimately
|
|
7606
|
+
// advances v1 -> v2 INSIDE prepare's assertAuthority callbacks. This callback
|
|
7607
|
+
// guards kernel custody, not a duplicate/reentrant record codec. Freezing
|
|
7608
|
+
// record-owned bytes to v1 here makes every valid Pi claim fail mid-write.
|
|
7609
|
+
// Literal legacy manifest custody stays unchanged for other runtimes.
|
|
7610
|
+
if (!piHost) {
|
|
7611
|
+
const manifest = parseStrictJson(readPortableBytes(join(history, "history.json")));
|
|
7612
|
+
if (canonicalJson(manifest) !== canonicalJson({ version: 1, home, completeHistory: true })) throw oatsError("integrity-drift", "native history scaffold authority changed");
|
|
7613
|
+
}
|
|
7614
|
+
const current = readCapturedInstanceMetadata(home);
|
|
7615
|
+
assertCapturedSessionPlacement(current, backend, knownTargets);
|
|
7616
|
+
const saved = parseStrictJson(readPortableBytes(baseline)), authority = runtimeAuthorityOf(saved);
|
|
7617
|
+
if (saved.version !== RETIRE_BASELINE_VERSION || saved.home !== home || saved.incarnationId !== original.incarnationId
|
|
7618
|
+
|| canonicalJson(saved.executionBinding) !== canonicalJson(binding) || !authority) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native baseline differs from captured incarnation");
|
|
7619
|
+
if (authority.launched) {
|
|
7620
|
+
const target = authority.sessionTarget ?? { backend: "tmux", ...authority.tmux };
|
|
7621
|
+
validateCapturedSessionTarget(target, backend, current.instance);
|
|
7622
|
+
if (backend.backend === "herdr" && !knownTargets.some(known => canonicalJson(known) === canonicalJson(target))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native baseline target lacks indexed custody");
|
|
7623
|
+
} 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");
|
|
7624
|
+
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");
|
|
7625
|
+
return row;
|
|
7626
|
+
};
|
|
7627
|
+
const loaded = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
|
|
7628
|
+
const record = loaded.record, expectedAgent = record.subject.kind === "persistent" ? record.subject.soul.alias : record.subject.name;
|
|
7629
|
+
const human = record.messagingChoice.enabled ? record.messagingChoice.privateKey.human : null;
|
|
7630
|
+
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");
|
|
7631
|
+
if (record.dispatch.composition?.mode !== "directory") throw oatsError("needs-configuration", "captured native start requires supported owned directory work");
|
|
7632
|
+
if (loaded.approvals.some(item => !["approved", "not-required"].includes(item.status))) throw oatsError("approval-required", "captured native start needs every exact executable approval");
|
|
7633
|
+
if (!record.dispatch.launch) throw oatsError("needs-configuration", "captured record has no explicit launch inputs");
|
|
7634
|
+
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");
|
|
7635
|
+
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");
|
|
7636
|
+
const recipe = structuredClone(record.dispatch.launch);
|
|
7637
|
+
if (recipe.executableResource) {
|
|
7638
|
+
const resource = loaded.resources.get(recipe.executableResource);
|
|
7639
|
+
if (!resource) throw oatsError("resolution-incomplete", "captured native entrypoint resource is missing");
|
|
7640
|
+
const manifest = loaded.manifests.get(recipe.entrypoint.capability), specification = manifest?.commands?.[recipe.entrypoint.command];
|
|
7641
|
+
if (typeof specification !== "string") throw oatsError("invalid-resolution", "retained native entrypoint is not declared");
|
|
7642
|
+
const [script, ...declaredArgs] = specification.trim().split(/\s+/), declaredFile = capabilityExecutablePath(manifest, script);
|
|
7643
|
+
if (!declaredFile || realpathSync(declaredFile) !== resource) throw oatsError("invalid-resolution", "native entrypoint resource differs from retained command");
|
|
7644
|
+
if (declaredArgs.length) throw oatsError("needs-configuration", "native entrypoint arguments require a qualified captured adapter");
|
|
7645
|
+
recipe.executable = resource;
|
|
7646
|
+
} 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");
|
|
7647
|
+
if (!recipe.executableResource && existsSync(recipe.executable)) {
|
|
7648
|
+
const actual = realpathSync(recipe.executable), managed = join(deployment, ".agents");
|
|
7649
|
+
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");
|
|
7650
|
+
}
|
|
7651
|
+
const broken = checkLaunchExecutable(recipe.executable);
|
|
7652
|
+
if (broken) throw oatsError("E_LAUNCH_EXECUTABLE", broken);
|
|
7653
|
+
const piHost = isPiSdkHost(recipe);
|
|
7654
|
+
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");
|
|
7655
|
+
if (recipe.args.length && !piHost) throw oatsError("needs-configuration", "native extra arguments require a qualified captured adapter; no flags were inferred or ignored");
|
|
7656
|
+
const piProfile = piHost ? validatePiHostRecipe(recipe) : null; // refuse retained contributions before normalization
|
|
7657
|
+
recipe.hooks = { launch: {}, env: {}, contributions: [] };
|
|
7658
|
+
const piSessionDir = piHost ? capturedPiSessionDirectory(home) : null;
|
|
7659
|
+
if (piHost) {
|
|
7660
|
+
resolvePiSdkEntry(piProfile); // public export metadata only; no SDK/auth/model import
|
|
7661
|
+
requireCapturedPiRecordSupport(); // refuse before admission/backend when guards are absent
|
|
7662
|
+
inspectCapturedPiRoot(home, { incarnationId: original.incarnationId, sessionDir: piSessionDir });
|
|
7663
|
+
}
|
|
7664
|
+
const baseEnv = { ...(options.env || process.env) };
|
|
7665
|
+
for (const key of Object.keys(baseEnv)) if (/^(OATS_|OAS_|PI_AGENT_)/.test(key) || key === "PI_AGENTS_ROOT") delete baseEnv[key];
|
|
7666
|
+
const missing = missingLaunchEnvRefs(recipe.env, baseEnv);
|
|
7667
|
+
if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", "captured native environment references are unavailable");
|
|
7668
|
+
const backend = Object.hasOwn(options, "backend") ? options.backend : metadata.captured.nativeBackend;
|
|
7669
|
+
if (backend === undefined) throw oatsError("needs-configuration", "first captured native start needs an explicit backend endpoint");
|
|
7670
|
+
validateCapturedSessionBackend(backend);
|
|
7671
|
+
if (checkLaunchExecutable(backend.binary)) throw oatsError("E_LAUNCH_EXECUTABLE", "selected native backend executable is unavailable");
|
|
7672
|
+
if (Object.hasOwn(metadata.captured, "nativeBackend") && canonicalJson(metadata.captured.nativeBackend) !== canonicalJson(backend)) throw oatsError("invalid-resolution", "native backend relocation requires separate qualified custody");
|
|
7673
|
+
const sessionHistory = original.intents.filter(intent => intent.action.kind === "session");
|
|
7674
|
+
const targetReceipts = sourceStatus === "start-failed-cleanup-required" ? sessionHistory.slice(-2) : sessionHistory.slice(-1);
|
|
7675
|
+
const knownTargets = backend.backend === "herdr" ? targetReceipts.flatMap(intent => intent.receipt?.target?.backend === "herdr"
|
|
7676
|
+
? [validateCapturedSessionTarget(intent.receipt.target, backend, metadata.instance)] : []) : [];
|
|
7677
|
+
const nativeTarget = backend.backend === "tmux" ? { backend: "tmux", session: backend.session, window: metadata.instance, socket: backend.socket }
|
|
7678
|
+
: latestSession?.receipt?.target?.backend === "herdr" ? validateCapturedSessionTarget(latestSession.receipt.target, backend, metadata.instance) : undefined;
|
|
7679
|
+
assertCapturedSessionPlacement(metadata, backend, knownTargets);
|
|
7680
|
+
if (options.restart !== undefined && typeof options.restart !== "boolean") throw oatsError("invalid-declaration", "restart must be boolean");
|
|
7681
|
+
const taskFile = join(home, "TASK.md");
|
|
7682
|
+
if (options.task !== undefined && typeof options.task !== "string") throw oatsError("invalid-declaration", "native task must be explicit text");
|
|
7683
|
+
const task = options.task === undefined ? decodeUtf8(readPortableBytes(taskFile)) : options.task;
|
|
7684
|
+
canonicalJson(task, { maxBytes: 512 * 1024 });
|
|
7685
|
+
if (task.includes("\0")) throw oatsError("invalid-declaration", "native task contains NUL");
|
|
7686
|
+
if (piHost && !task.trim()) throw oatsError("E_PI_HOST_TASK", "captured Pi print requires a nonempty task");
|
|
7687
|
+
const taskBytes = Buffer.from(task);
|
|
7688
|
+
const assertCurriculum = () => {
|
|
7689
|
+
assertRoots();
|
|
7690
|
+
if (decodeUtf8(readPortableBytes(join(home, "AGENTS.md"))) !== loaded.composition.text
|
|
7691
|
+
|| !lstatSync(join(home, "CLAUDE.md")).isSymbolicLink() || readlinkSync(join(home, "CLAUDE.md")) !== "AGENTS.md"
|
|
7692
|
+
|| !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");
|
|
7693
|
+
const actual = readdirSync(join(home, ".agents/skills")).sort(), expected = loaded.composition.skills.map(skill => skill.name).sort();
|
|
7694
|
+
if (canonicalJson(actual) !== canonicalJson(expected)) throw oatsError("integrity-drift", "native skill inventory differs from retained composition");
|
|
7695
|
+
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");
|
|
7696
|
+
};
|
|
7697
|
+
assertCurriculum();
|
|
7698
|
+
const action = { kind: "session", name: options.restart ? "restart" : "start" };
|
|
7699
|
+
const input = { backend, taskIntegrity: { format: "oats.bytes.v1", value: `sha256-${createHash("sha256").update(taskBytes).digest("hex")}` } };
|
|
7700
|
+
const pendingPath = join(home, ".oats-start-pending.json");
|
|
7701
|
+
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 })}`;
|
|
7702
|
+
if (existsSync(pendingPath)) {
|
|
7703
|
+
const pending = parseStrictJson(readPortableBytes(pendingPath)), ref = pending.capturedIntent;
|
|
7704
|
+
validateIntentRef(ref);
|
|
7705
|
+
if (typeof pending.nativeRecordId !== "string" || !/^[a-f0-9-]{36}$/.test(pending.nativeRecordId)) throw oatsError("invalid-resolution", "pending native history reference is invalid");
|
|
7706
|
+
const evidence = parseStrictJson(readPortableBytes(join(nativeHistoryPath(home), `${pending.nativeRecordId}.json`)));
|
|
7707
|
+
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");
|
|
7708
|
+
if (piHost && (evidence.incarnationId !== original.incarnationId || canonicalJson(evidence.intent) !== canonicalJson(ref))) throw oatsError("invalid-resolution", "Pi native witness differs from the original admitted intent");
|
|
7709
|
+
const prior = original.intents.find(intent => intent.executionId === ref?.executionId);
|
|
7710
|
+
if (!prior || prior.action.kind !== "session" || latestSession.executionId !== pending.id || ref.incarnationId !== original.incarnationId || pending.id !== ref.executionId || ref.attempt > prior.attempt
|
|
7711
|
+
|| canonicalJson(pending.launch) !== canonicalJson(recipe) || pending.command !== makeCommand(ref)) throw oatsError("invalid-resolution", "pending native receipt is not this captured launch");
|
|
7712
|
+
if (pending.phase === "allocating") {
|
|
7713
|
+
if (backend.backend !== "herdr" || canonicalJson(pending.endpoint) !== canonicalJson(backend)) throw oatsError("invalid-resolution", "pending allocation endpoint differs from admitted backend");
|
|
7714
|
+
const error = oatsError("E_SESSION_UNKNOWN", "native allocation outcome is unknown; retain the same intent and reconcile, never allocate again");
|
|
7715
|
+
error.home = home; error.nativeCustody = { intent: ref, pendingPath, observed: prior.receipt, unconfirmed: true }; throw error;
|
|
7716
|
+
}
|
|
7717
|
+
validateCapturedSessionTarget(pending.target, backend, metadata.instance);
|
|
7718
|
+
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");
|
|
7719
|
+
}
|
|
7720
|
+
// Static target/curriculum/backend/task validation precedes provider code.
|
|
7721
|
+
// This read-only inspection is not a provider-native enrollment grant.
|
|
7722
|
+
for (const [slot, providerBinding] of Object.entries(record.bindings)) {
|
|
7723
|
+
const capability = loaded.capabilities.get(providerBinding.capability), inspection = { kind: "inspect" };
|
|
7724
|
+
const invocation = buildCapturedInvocationContext({ loaded: { ...loaded, capability }, action: inspection,
|
|
7725
|
+
instance: { home, work: join(home, "work"), name: metadata.instance, agent: metadata.agent } });
|
|
7726
|
+
const checked = runCapturedProviderBinding({ deployment, artifacts: record.artifacts, capability: capability.id, phase: "check", settings: capability.settings,
|
|
7727
|
+
input: { binding: providerBinding, context: record.context, action: inspection, invocation } });
|
|
7728
|
+
if (checked.status !== "ready") throw oatsError(checked.problems[0]?.code || "provider-not-qualified", `captured ${slot} provider is unavailable`);
|
|
7729
|
+
}
|
|
7730
|
+
assertCurriculum();
|
|
7731
|
+
const admission = admitCapturedInstanceAction({ deployment, home, executionBinding: binding, capability: null, action, input, expectedStatus: sourceStatus,
|
|
7732
|
+
...(options.retryExecutionId !== undefined ? { retryExecutionId: options.retryExecutionId } : {}) });
|
|
7733
|
+
if (admission.replayed) return { ...admission.receipt, intent: admission.intent, replayed: true };
|
|
7734
|
+
let observed = null, pendingObservation = null;
|
|
7735
|
+
try {
|
|
7736
|
+
ownedState = "start-running";
|
|
7737
|
+
setCapturedInstanceStatus(deployment, home, ownedState, { expectedStatus: sourceStatus });
|
|
7738
|
+
beginCapturedIntent({ deployment, home, intent: admission.intent, action });
|
|
7739
|
+
atomicWriteFileSync(taskFile, task, { assertRoots });
|
|
7740
|
+
metadata = { ...metadata, backend: backend.backend,
|
|
7741
|
+
...(backend.backend === "tmux" ? { tmux: { session: nativeTarget.session, window: nativeTarget.window, socket: nativeTarget.socket } } : {}),
|
|
7742
|
+
captured: { ...metadata.captured, nativeBackend: backend } };
|
|
7743
|
+
atomicWriteFileSync(join(home, "instance.json"), canonicalJson(metadata), { assertRoots });
|
|
7744
|
+
const plan = { recipe, command: makeCommand(admission.intent), runtime: recipe.runtime, model: recipe.model, yolo: recipe.yolo };
|
|
7745
|
+
const exec = (binary, args, executionOptions) => {
|
|
7746
|
+
assertCurriculum();
|
|
7747
|
+
if (!readPortableBytes(taskFile).equals(taskBytes)) throw oatsError("integrity-drift", "native task changed after admission");
|
|
7748
|
+
if (existsSync(pendingPath)) {
|
|
7749
|
+
const pending = parseStrictJson(readPortableBytes(pendingPath));
|
|
7750
|
+
if (pending.id === admission.intent.executionId) pendingObservation = { id: pending.id, startedAt: pending.startedAt,
|
|
7751
|
+
...(pending.target ? { target: pending.target } : {}), ...(pending.endpoint ? { endpoint: pending.endpoint } : {}), ...(pending.phase ? { phase: pending.phase } : {}),
|
|
7752
|
+
...(pending.nativeRecordId ? { nativeRecordId: pending.nativeRecordId } : {}), unconfirmed: true };
|
|
7753
|
+
}
|
|
7754
|
+
const env = { ...baseEnv };
|
|
7755
|
+
if (backend.backend === "herdr") {
|
|
7756
|
+
delete env.HERDR_SESSION; delete env.HERDR_SOCKET_PATH;
|
|
7757
|
+
if (binary === backend.binary) {
|
|
7758
|
+
if (executionOptions.env?.HERDR_SOCKET_PATH !== backend.socket) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "Herdr transport socket differs from admitted endpoint");
|
|
7759
|
+
env.HERDR_SOCKET_PATH = backend.socket;
|
|
7760
|
+
}
|
|
7761
|
+
}
|
|
7762
|
+
return (options.io?.exec || execFileSync)(backend.backend === "tmux" && binary === "tmux" ? backend.binary : binary, args, { ...executionOptions, env });
|
|
7763
|
+
};
|
|
7764
|
+
const observeTarget = (target, facts) => {
|
|
7765
|
+
validateCapturedSessionTarget(target, backend, metadata.instance);
|
|
7766
|
+
pendingObservation = { ...facts, target, unconfirmed: true };
|
|
7767
|
+
knownTargets.push(target); assertRoots();
|
|
7768
|
+
observeCapturedNativeTarget(original, admission.intent, pendingObservation);
|
|
7769
|
+
};
|
|
7770
|
+
observed = startInstanceSession(home, { restart: !!options.restart, ...(options.stopGraceMs === undefined ? {} : { stopGraceMs: options.stopGraceMs }), env: baseEnv,
|
|
7771
|
+
io: { ...options.io, exec, strictHerdrTarget: backend.backend === "herdr" },
|
|
7772
|
+
[CAPTURED_NATIVE_START]: { plan, intent: admission.intent, endpoint: backend, target: nativeTarget, observeTarget, assertRoots,
|
|
7773
|
+
...(piHost ? { prepareNativeRecord: () => {
|
|
7774
|
+
assertCurriculum();
|
|
7775
|
+
return prepareCapturedPiStart(home, { incarnationId: original.incarnationId, intent: admission.intent, sessionDir: piSessionDir, assertAuthority: assertRoots });
|
|
7776
|
+
} } : {}) } });
|
|
7777
|
+
assertRoots();
|
|
7778
|
+
settleCapturedIntent({ deployment, home, intent: admission.intent, action, state: "completed", receipt: observed, replayable: true });
|
|
7779
|
+
const next = readCapturedInstanceMetadata(home);
|
|
7780
|
+
atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...next, captured: { ...next.captured, lifecycle: "start-dispatched", nativeIntent: admission.intent } }), { assertRoots });
|
|
7781
|
+
setCapturedInstanceStatus(deployment, home, "start-dispatched", { expectedStatus: ownedState });
|
|
7782
|
+
ownedState = "start-dispatched";
|
|
7783
|
+
return { ...observed, intent: admission.intent, incarnationId: original.incarnationId, dispatchAccepted: true };
|
|
7784
|
+
} catch (error) {
|
|
7785
|
+
let reportingFailure;
|
|
7786
|
+
try { failCapturedNativeIntent(original, admission.intent, observed ?? pendingObservation ?? admission.receipt); ownedState = "start-failed-cleanup-required"; }
|
|
7787
|
+
catch (failure) { reportingFailure = failure.code || "E_NATIVE_CUSTODY"; }
|
|
7788
|
+
try {
|
|
7789
|
+
assertRoots(); const current = readCapturedInstanceMetadata(home);
|
|
7790
|
+
atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...current, captured: { ...current.captured, lifecycle: "start-failed-cleanup-required", nativeIntent: admission.intent } }), { assertRoots });
|
|
7791
|
+
} catch { /* never write/clean a replacement home */ }
|
|
7792
|
+
error.home = home; error.nativeCustody = { intent: admission.intent, pendingPath, observed, pendingObservation, unconfirmed: true, ...(reportingFailure ? { reportingFailure } : {}) };
|
|
7793
|
+
throw error;
|
|
7794
|
+
}
|
|
7795
|
+
}
|
|
7796
|
+
|
|
7434
7797
|
export function restartInstanceSession(home, o = {}) { return startInstanceSession(home, { ...o, restart: true }); }
|
|
7435
7798
|
export function startInstanceSession(home, o = {}) {
|
|
7436
7799
|
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
7800
|
if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
|
|
7439
|
-
const
|
|
7801
|
+
const directoryGuard = sessionDirectoryGuard(home);
|
|
7802
|
+
if (!o[CAPTURED_NATIVE_START]) {
|
|
7803
|
+
let metadata; try { metadata = parseStrictJson(readPortableBytes(join(home, "instance.json"))); } catch { /* legacy receipt validation reports unreadable metadata */ }
|
|
7804
|
+
if (metadata?.executionBinding) {
|
|
7805
|
+
const portableOptions = { ...o };
|
|
7806
|
+
for (const key of ["model", "runtime", "launchConfig", "yolo"]) {
|
|
7807
|
+
if (portableOptions[key] !== undefined) throw oatsError("E_BAD_ARGS", "captured start cannot replace its recorded runtime/model/configuration");
|
|
7808
|
+
delete portableOptions[key]; // Legacy callers pass omitted options as undefined.
|
|
7809
|
+
}
|
|
7810
|
+
return startCapturedInstanceSession(home, portableOptions);
|
|
7811
|
+
}
|
|
7812
|
+
}
|
|
7813
|
+
const capturedStart = o[CAPTURED_NATIVE_START];
|
|
7814
|
+
const checkRoots = () => { directoryGuard(); capturedStart?.assertRoots(); };
|
|
7440
7815
|
const realHome = realPathOrNearest(home);
|
|
7441
7816
|
let originalHomeIdentity;
|
|
7442
7817
|
try { originalHomeIdentity = directoryIdentity(realHome); } catch { /* missing home reported below */ }
|
|
@@ -7460,7 +7835,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7460
7835
|
// The independent receipt first (retire and session consult it), then the
|
|
7461
7836
|
// mutable metadata; both tmp+rename. A failure between them is what the
|
|
7462
7837
|
// pending receipt exists for.
|
|
7463
|
-
const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop }, clearPending = true) => {
|
|
7838
|
+
const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId }, clearPending = true) => {
|
|
7464
7839
|
checkRoots();
|
|
7465
7840
|
const baselinePath = retirementBaselinePath(realHome);
|
|
7466
7841
|
let baseline;
|
|
@@ -7478,9 +7853,10 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7478
7853
|
...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}) };
|
|
7479
7854
|
if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
|
|
7480
7855
|
else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7856
|
+
if (capturedStart) atomicWriteFileSync(metaPath, canonicalJson(next), { assertRoots: checkRoots });
|
|
7857
|
+
else writeJsonAtomic(metaPath, next);
|
|
7858
|
+
if (clearPending) { checkRoots(); rmSync(pendingPath, { force: true }); }
|
|
7859
|
+
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
7860
|
};
|
|
7485
7861
|
try { mkdirSync(lock); }
|
|
7486
7862
|
catch (e) {
|
|
@@ -7526,11 +7902,15 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7526
7902
|
const exited = existsSync(exitedPath) && readFileSync(exitedPath, "utf8").trim() === pending.id;
|
|
7527
7903
|
if (!exited) throw oatsError("E_SESSION_START_BUSY", `${basename(realHome)} is still starting; refresh its status before retrying`);
|
|
7528
7904
|
}
|
|
7905
|
+
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
7906
|
// Reconcile even an exited target: the independent baseline may
|
|
7530
7907
|
// already name it while metadata still names the old allocation.
|
|
7531
7908
|
const meta = readMeta();
|
|
7532
7909
|
const done = record(meta, { ...pending, backend: pbackend, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
|
|
7533
7910
|
if (st.present && st.state !== "shell") {
|
|
7911
|
+
// Same logical captured request already dispatched: adopting it is the
|
|
7912
|
+
// retry outcome, even for restart. Only a DISTINCT restart may continue.
|
|
7913
|
+
if (capturedStart && pending.id === capturedStart.intent.executionId) return done;
|
|
7534
7914
|
if (o.restart) { rmSync(pendingPath, { force: true }); }
|
|
7535
7915
|
else {
|
|
7536
7916
|
// The recovered target runs what the receipt says; a choice made
|
|
@@ -7545,9 +7925,10 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7545
7925
|
// 2. The ordinary gate and observation, all under the lock.
|
|
7546
7926
|
const receipt = instanceSessionTarget(realHome);
|
|
7547
7927
|
const meta = readMeta();
|
|
7548
|
-
const runtime = meta.runtime;
|
|
7928
|
+
const runtime = capturedStart?.plan.runtime || meta.runtime;
|
|
7549
7929
|
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";
|
|
7930
|
+
const backend = capturedStart ? capturedStart.endpoint.backend : (meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux");
|
|
7931
|
+
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
7932
|
let command = meta.command;
|
|
7552
7933
|
let model = meta.model || undefined;
|
|
7553
7934
|
// What this start launches: the frozen command (optionally with another
|
|
@@ -7556,8 +7937,9 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7556
7937
|
// preflight happens here, before anything is observed or stopped.
|
|
7557
7938
|
const selected = o.launchConfig !== undefined || o.runtime !== undefined || o.yolo !== undefined;
|
|
7558
7939
|
const hasRecipe = meta.launch && typeof meta.launch === "object";
|
|
7559
|
-
let launchPlan = null;
|
|
7560
|
-
if (
|
|
7940
|
+
let launchPlan = capturedStart?.plan || null;
|
|
7941
|
+
if (capturedStart) { command = launchPlan.command; model = launchPlan.model; }
|
|
7942
|
+
else if (selected || hasRecipe) {
|
|
7561
7943
|
// Every start of a home with a recipe (ordinary, model-only, or under a
|
|
7562
7944
|
// selection) goes through the one planner: recipe shape, the recorded
|
|
7563
7945
|
// or selected executable, references, capability contributions under
|
|
@@ -7583,7 +7965,8 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7583
7965
|
const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
|
|
7584
7966
|
const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
|
|
7585
7967
|
checkRoots(); // launch hooks/preparation have run; no backend has been observed
|
|
7586
|
-
const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo
|
|
7968
|
+
const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo,
|
|
7969
|
+
...(capturedStart ? { capturedIntent: capturedStart.intent } : {}) } : {};
|
|
7587
7970
|
let target = receipt.target;
|
|
7588
7971
|
let state = { present: false, state: "not-launched" };
|
|
7589
7972
|
let serverGone = false;
|
|
@@ -7607,28 +7990,40 @@ export function startInstanceSession(home, o = {}) {
|
|
|
7607
7990
|
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
7991
|
}
|
|
7609
7992
|
const startedAt = new Date().toISOString();
|
|
7610
|
-
const id = randomUUID();
|
|
7993
|
+
const id = capturedStart?.intent.executionId || randomUUID();
|
|
7611
7994
|
checkRoots();
|
|
7612
|
-
const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime
|
|
7613
|
-
|
|
7995
|
+
const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime,
|
|
7996
|
+
capturedStart ? id => { planExtra.nativeRecordId = id; } : undefined, capturedStart?.prepareNativeRecord);
|
|
7997
|
+
const completedCommand = capturedStart && isPiSdkHost(launchPlan?.recipe)
|
|
7998
|
+
? renderCapturedPiCompletion(command, executionCommand, { home: realHome, nativeRecordId: planExtra.nativeRecordId })
|
|
7999
|
+
: `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
|
|
7614
8000
|
let reused = "new";
|
|
7615
8001
|
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`);
|
|
8002
|
+
if (!target && !capturedStart) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
|
|
8003
|
+
const base = capturedStart?.endpoint ?? { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
|
|
7617
8004
|
if (state.present) { reused = "pane"; }
|
|
7618
8005
|
else {
|
|
7619
|
-
const base = { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
|
|
7620
8006
|
try { herdrSnapshot(base, o.io); }
|
|
7621
|
-
catch (e) {
|
|
7622
|
-
|
|
8007
|
+
catch (e) {
|
|
8008
|
+
if (capturedStart) throw oatsError("E_SESSION_UNKNOWN", "selected Herdr endpoint is unavailable or incompatible; no fallback was started");
|
|
8009
|
+
throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`);
|
|
8010
|
+
}
|
|
8011
|
+
if (capturedStart) {
|
|
8012
|
+
checkRoots();
|
|
8013
|
+
writeJsonAtomic(pendingPath, { id, phase: "allocating", endpoint: base, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
8014
|
+
}
|
|
8015
|
+
try { target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io); }
|
|
8016
|
+
catch (e) { if (capturedStart) throw launchFailure("Herdr allocation", e); throw e; }
|
|
7623
8017
|
}
|
|
8018
|
+
if (capturedStart) capturedStart.observeTarget(target, { id, startedAt, nativeRecordId: planExtra.nativeRecordId });
|
|
7624
8019
|
checkRoots();
|
|
7625
8020
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
7626
8021
|
try { launchHerdr(target, `${paneEnvExports}cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
|
|
7627
8022
|
catch (e) { throw launchFailure("Herdr", e); }
|
|
7628
8023
|
} 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;
|
|
8024
|
+
const session = capturedStart?.target.session ?? (target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION);
|
|
8025
|
+
const window = capturedStart?.target.window ?? (target?.window || meta.tmux?.window || meta.instance);
|
|
8026
|
+
let socket = capturedStart?.target.socket ?? (target?.socket || meta.tmux?.socket);
|
|
7632
8027
|
const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
|
|
7633
8028
|
// A fallback shell (no harness descendant) or a retained dead pane is
|
|
7634
8029
|
// the agent's own pane: the command runs there, no other window touched.
|