@awebai/oats 0.23.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/bin/oats-pi-sdk-host.mjs +17 -0
- package/bin/oats.mjs +442 -51
- package/capabilities/oats-okf/bin/oats-okf-binding.mjs +14 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +79 -11
- package/capabilities/oats-okf/lib/binding-wire.mjs +268 -0
- package/capabilities/oats-okf/lib/captured-worker.mjs +101 -0
- package/capabilities/oats-okf/lib/config.mjs +2 -1
- package/capabilities/oats-okf/lib/inspection.mjs +16 -1
- package/capabilities/oats-okf/lib/invocation-context.mjs +111 -0
- package/capabilities/oats-okf/lib/invocation-shape.mjs +135 -0
- package/capabilities/oats-okf/lib/io.mjs +1 -1
- package/capabilities/oats-okf/lib/portable-binding.mjs +199 -0
- package/capabilities/oats-okf/lib/source-contract.mjs +46 -0
- package/capabilities/oats-okf/lib/sources.mjs +123 -3
- package/capabilities/oats-okf/lib/stores.mjs +104 -25
- package/capabilities/oats-okf/lib/worker.mjs +69 -10
- package/capabilities/oats-okf/oats.json +35 -7
- package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +87 -0
- package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +113 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +23 -5
- package/capabilities/oats-okf/skills/okf/SKILL.md +42 -1
- package/docs/artifact-approvals.schema.json +7 -0
- package/docs/capability-manifest.schema.json +37 -66
- package/docs/captured-invocation-context.schema.json +7 -0
- package/docs/captured-resolution.schema.json +7 -0
- package/docs/design/2026-09-14-artifact-retention-contract.md +190 -0
- package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +708 -0
- package/docs/design/2026-09-14-portable-souls-contract-amendments.md +85 -0
- package/docs/design/2026-09-14-portable-souls-explainer.md +750 -0
- package/docs/design/2026-09-15-captured-dispatch.md +127 -0
- package/docs/design/2026-09-15-captured-resolution-records.md +143 -0
- package/docs/design/2026-09-15-package-preparation.md +100 -0
- package/docs/design/2026-09-15-portable-data-contract.md +121 -0
- package/docs/design/2026-09-15-portable-declarations.md +189 -0
- package/docs/design/2026-09-15-portable-souls-handoff.md +150 -0
- package/docs/design/2026-09-15-portable-souls-implementation.md +417 -0
- package/docs/design/2026-09-15-selection-lock-and-approval.md +122 -0
- package/docs/design/2026-09-15-source-observation.md +119 -0
- package/docs/design/2026-09-16-captured-admission.md +77 -0
- package/docs/design/2026-09-16-captured-helper-dispatch.md +105 -0
- package/docs/design/2026-09-16-captured-launch-inputs.md +42 -0
- package/docs/design/2026-09-16-command-profile-preparation.md +86 -0
- package/docs/design/2026-09-16-fresh-install-first-rollout.md +47 -0
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +277 -0
- package/docs/design/2026-09-16-knowledge-capability-contract.md +61 -0
- package/docs/design/2026-09-16-messaging-capability-contract.md +59 -0
- package/docs/design/2026-09-16-portable-migration-evidence.md +156 -0
- package/docs/design/2026-09-16-portable-onboarding.md +177 -0
- package/docs/design/2026-09-16-prepare-request-transport.md +26 -0
- package/docs/design/2026-09-16-provider-binding-codecs.md +98 -0
- package/docs/design/2026-09-16-provider-binding-wire.md +247 -0
- package/docs/design/2026-09-17-capability-helper-input-contract.md +95 -0
- package/docs/design/2026-09-17-captured-backend-parity.md +53 -0
- package/docs/design/2026-09-17-captured-native-start.md +58 -0
- package/docs/design/2026-09-17-portable-boundary-hookup.md +19 -0
- package/docs/design/2026-09-17-portable-boundary-resources.md +52 -0
- package/docs/design/2026-09-17-public-captured-start.md +108 -0
- package/docs/design/2026-09-17-public-prepare-request.md +90 -0
- package/docs/design/2026-09-18-captured-pi-host.md +205 -0
- package/docs/design/2026-09-18-first-cut-release-checklist.md +131 -0
- package/docs/design/2026-09-18-herdr-protocol-compatibility.md +60 -0
- package/docs/desktop-cli-api.md +5 -2
- package/docs/execution-capsule.schema.json +108 -0
- package/docs/execution-targets.md +20 -7
- package/docs/oats-lock-v3.schema.json +7 -0
- package/docs/oats-member.schema.json +38 -0
- package/docs/oats-workspace.schema.json +68 -0
- package/docs/portable.schema.json +2512 -0
- package/docs/provider-check-input.schema.json +7 -0
- package/docs/release-notes/v0.24.0.md +104 -0
- package/docs/schedules.md +126 -14
- package/docs/soul.schema.json +82 -0
- package/injects/oats-portable.md +17 -0
- package/injects/portable-instance-boundary.md +39 -0
- package/injects/portable-work-directory.md +29 -0
- package/lib/artifact-approvals.mjs +120 -0
- package/lib/artifact-tree.mjs +141 -0
- package/lib/capability-artifacts.mjs +179 -0
- package/lib/capability-execution.mjs +15 -0
- package/lib/capability-inputs.mjs +39 -0
- package/lib/capability-provenance.mjs +231 -0
- package/lib/captured-action-shape.mjs +21 -0
- package/lib/captured-admission-shape.mjs +20 -0
- package/lib/captured-binding-file.mjs +36 -0
- package/lib/captured-dispatch.mjs +66 -0
- package/lib/captured-instance-index.mjs +277 -0
- package/lib/captured-invocation-context.mjs +130 -0
- package/lib/captured-launch-request.mjs +46 -0
- package/lib/captured-operation-process.mjs +15 -0
- package/lib/captured-pi-custody.mjs +29 -0
- package/lib/captured-pi-host.mjs +167 -0
- package/lib/captured-pi-outcome.mjs +172 -0
- package/lib/captured-resolutions.mjs +275 -0
- package/lib/captured-scaffold.mjs +87 -0
- package/lib/captured-selector.mjs +28 -0
- package/lib/captured-session-backend.mjs +52 -0
- package/lib/captured-source-receipt-file.mjs +72 -0
- package/lib/config-data.mjs +104 -0
- package/lib/core.mjs +918 -562
- package/lib/errors.mjs +7 -0
- package/lib/helper-injection-policy.mjs +98 -0
- package/lib/herdr.mjs +18 -7
- package/lib/instruction-composition.mjs +31 -0
- package/lib/legacy-lock-codec.mjs +106 -0
- package/lib/manifest-settings.mjs +84 -0
- package/lib/package-closure.mjs +48 -0
- package/lib/package-materialization.mjs +83 -0
- package/lib/pi-sdk-host.mjs +229 -0
- package/lib/portable-artifacts.mjs +115 -0
- package/lib/portable-choices.mjs +82 -0
- package/lib/portable-composition.mjs +136 -0
- package/lib/portable-digest.mjs +105 -0
- package/lib/portable-files.mjs +26 -0
- package/lib/portable-identity.mjs +40 -0
- package/lib/portable-lock.mjs +117 -0
- package/lib/portable-migration-artifacts.mjs +135 -0
- package/lib/portable-migration-evidence.mjs +305 -0
- package/lib/portable-migration-store.mjs +199 -0
- package/lib/portable-migration.mjs +104 -0
- package/lib/portable-onboarding-acceptance.mjs +66 -0
- package/lib/portable-onboarding-request.mjs +49 -0
- package/lib/portable-onboarding.mjs +230 -0
- package/lib/portable-package-preparation.mjs +188 -0
- package/lib/portable-policy.mjs +44 -0
- package/lib/portable-shape.mjs +35 -0
- package/lib/portable-soul.mjs +38 -0
- package/lib/portable-state.mjs +80 -0
- package/lib/portable-values.mjs +181 -0
- package/lib/prepare-composition.mjs +151 -0
- package/lib/prepared-bindings.mjs +78 -0
- package/lib/prepared-resources.mjs +127 -0
- package/lib/provider-binding-broker.mjs +59 -0
- package/lib/provider-binding-wire.mjs +110 -0
- package/lib/provider-binding.mjs +22 -0
- package/lib/repository-observation.mjs +226 -0
- package/lib/resolution-shape.mjs +393 -0
- package/lib/schedule-capsule.mjs +206 -0
- package/lib/schedule.mjs +259 -38
- package/lib/servers.mjs +15 -0
- package/lib/soul-constraints.mjs +40 -0
- package/lib/source-projection.mjs +84 -0
- package/lib/source-spec.mjs +189 -0
- package/lib/workspace-definition.mjs +126 -0
- package/lib/workspace-discovery.mjs +146 -0
- package/package-catalog.json +1 -1
- package/package.json +3 -2
- package/packages/record/lib/capture-cc.mjs +14 -6
- package/packages/record/lib/formats.mjs +14 -3
- package/packages/record/lib/native-history.mjs +277 -7
- package/packages/record/lib/session-snapshot.mjs +25 -5
- package/packages/record/lib/sessions-for-home.mjs +30 -13
- package/skills/oats/SKILL.md +12 -7
- package/skills/oats-config/SKILL.md +12 -9
- package/skills/oats-packages/SKILL.md +12 -8
- package/skills/oats-portable/SKILL.md +116 -0
- package/skills/oats-portable-artifacts/SKILL.md +63 -0
- package/skills/oats-portable-setup/SKILL.md +69 -0
|
@@ -20,6 +20,7 @@ import { lstatSync, readdirSync, statSync } from "node:fs";
|
|
|
20
20
|
import { basename, dirname, join } from "node:path";
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { nativeDirectory } from "./session-roots.mjs";
|
|
23
|
+
import { guardCapturedPath } from "./native-history.mjs";
|
|
23
24
|
|
|
24
25
|
// Iterate JSONL lines of a buffer without materializing the whole file as
|
|
25
26
|
// one string — real transcripts reach hundreds of MB (a 789 MB Codex
|
|
@@ -54,9 +55,16 @@ function* parsedLines(bytes) {
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function listJsonlFiles(root, maxDepth, { strict = false } = {}) {
|
|
58
|
+
function listJsonlFiles(root, maxDepth, { strict = false } = {}, allowCaptured = false) {
|
|
59
|
+
const capturedPi = typeof root === "object" && root !== null ? root.capturedPi : undefined;
|
|
60
|
+
if (typeof root !== "string") {
|
|
61
|
+
if (!allowCaptured || !capturedPi || Object.keys(root).some(k => !["path", "capturedPi"].includes(k))) throw new Error("unsupported protected session inventory");
|
|
62
|
+
root = root.path;
|
|
63
|
+
}
|
|
64
|
+
guardCapturedPath(root, capturedPi);
|
|
58
65
|
const out = [];
|
|
59
66
|
const walk = (dir, depth) => {
|
|
67
|
+
guardCapturedPath(dir, capturedPi);
|
|
60
68
|
let names;
|
|
61
69
|
try {
|
|
62
70
|
names = readdirSync(dir, { withFileTypes: true });
|
|
@@ -68,14 +76,16 @@ function listJsonlFiles(root, maxDepth, { strict = false } = {}) {
|
|
|
68
76
|
}
|
|
69
77
|
for (const entry of names.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
70
78
|
const path = join(dir, entry.name);
|
|
79
|
+
guardCapturedPath(path, capturedPi); // protected links refuse before traversal or native reads
|
|
71
80
|
if (entry.name.endsWith(".jsonl") && !entry.isDirectory()) {
|
|
72
|
-
out.push(path); // privacy rules precede opening/statting source links
|
|
81
|
+
out.push(capturedPi ? { path, capturedPi } : path); // privacy rules precede opening/statting source links
|
|
73
82
|
} else if (entry.isDirectory() || (entry.isSymbolicLink() && statSync(path).isDirectory())) {
|
|
74
83
|
if (depth < maxDepth) walk(path, depth + 1);
|
|
75
84
|
}
|
|
76
85
|
}
|
|
77
86
|
};
|
|
78
87
|
walk(root, 0);
|
|
88
|
+
guardCapturedPath(root, capturedPi);
|
|
79
89
|
return out;
|
|
80
90
|
}
|
|
81
91
|
|
|
@@ -131,6 +141,7 @@ function ccRoots(home = homedir(), env = process.env, options = {}) {
|
|
|
131
141
|
function listCcFiles(root, { strict = false } = {}) {
|
|
132
142
|
const out = [];
|
|
133
143
|
const entries = (dir, optional = false) => {
|
|
144
|
+
guardCapturedPath(dir);
|
|
134
145
|
try { return readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
|
|
135
146
|
catch (err) { if (optional && err.code === "ENOENT") return []; throw err; }
|
|
136
147
|
};
|
|
@@ -362,7 +373,7 @@ export const SESSION_FORMATS = {
|
|
|
362
373
|
pi: {
|
|
363
374
|
source: "pi",
|
|
364
375
|
defaultRoots: piRoots,
|
|
365
|
-
listFiles: (roots, options) => roots.flatMap((r) => listJsonlFiles(r, 1, options)),
|
|
376
|
+
listFiles: (roots, options) => roots.flatMap((r) => listJsonlFiles(r, 1, options, true)),
|
|
366
377
|
sessionId: piSessionId,
|
|
367
378
|
extractText: extractPiText,
|
|
368
379
|
},
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Independent native record custody. Recipes are relaunch templates, not proof
|
|
2
2
|
// of where a past process wrote. Only the execution-side recorder resolves env.
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { isUtf8 } from "node:buffer";
|
|
6
8
|
import { nativeLaunchLocations } from "./session-roots.mjs";
|
|
7
9
|
|
|
8
10
|
function canonical(home) {
|
|
@@ -34,7 +36,8 @@ function atomic(path, value) {
|
|
|
34
36
|
renameSync(tmp, path);
|
|
35
37
|
}
|
|
36
38
|
function manifest(home) {
|
|
37
|
-
const value =
|
|
39
|
+
const value = privateJson(join(nativeHistoryPath(home), "history.json"));
|
|
40
|
+
if (value.version === 2) return validateManifest(value, sourceHome(home));
|
|
38
41
|
if (value.version !== 1 || value.home !== sourceHome(home) || typeof value.completeHistory !== "boolean") throw new Error("invalid native record history authority");
|
|
39
42
|
return value;
|
|
40
43
|
}
|
|
@@ -42,12 +45,15 @@ function manifest(home) {
|
|
|
42
45
|
// start can record new locations but cannot invent authority for earlier starts.
|
|
43
46
|
export function initializeNativeHistory(home, { completeHistory = true } = {}) {
|
|
44
47
|
const dir = nativeHistoryPath(home);
|
|
48
|
+
// An existing custody directory with a lost manifest is not a new home.
|
|
49
|
+
// Do not repair it to an empty inventory (including through legacy callers).
|
|
50
|
+
if (lstatSync(dir, { throwIfNoEntry: false }) || lstatSync(rootFor(home), { throwIfNoEntry: false })) { manifest(home); return; }
|
|
45
51
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
46
52
|
try { writeFileSync(join(dir, "history.json"), JSON.stringify({ version: 1, home: sourceHome(home), completeHistory }) + "\n", { mode: 0o600, flag: "wx" }); }
|
|
47
53
|
catch (e) { if (e.code !== "EEXIST") throw e; manifest(home); } // retain earlier launches if a name is reused
|
|
48
54
|
}
|
|
49
55
|
export function prepareNativeStart(home, runtime) {
|
|
50
|
-
try { manifest(home); }
|
|
56
|
+
try { if (manifest(home).version !== 1) custodyError("protected history requires captured native preparation"); }
|
|
51
57
|
catch (e) {
|
|
52
58
|
if (e.code !== "ENOENT") throw e;
|
|
53
59
|
initializeNativeHistory(home, { completeHistory: false });
|
|
@@ -60,23 +66,36 @@ export function prepareNativeStart(home, runtime) {
|
|
|
60
66
|
// harness, after the backend shell's startup. No environment map or argv is
|
|
61
67
|
// serialized. Failure leaves pending evidence and prevents the native exec.
|
|
62
68
|
export function recordNativeStart(home, id, runtime, args, env = process.env) {
|
|
63
|
-
manifest(home);
|
|
69
|
+
const header = manifest(home);
|
|
64
70
|
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("invalid native record start id");
|
|
65
71
|
const path = join(nativeHistoryPath(home), `${id}.json`);
|
|
66
|
-
const pending = JSON.parse(readFileSync(path, "utf8"));
|
|
72
|
+
const pending = header.version === 2 ? privateJson(path) : JSON.parse(readFileSync(path, "utf8"));
|
|
73
|
+
if (header.version === 2) {
|
|
74
|
+
if (pending.version !== 2) custodyError("protected history cannot use a v1 start receipt");
|
|
75
|
+
return recordCapturedPiStart(home, id, runtime, args, env);
|
|
76
|
+
}
|
|
77
|
+
if (pending.version === 2) custodyError("captured receipt lacks its expected-ID manifest");
|
|
67
78
|
if (pending.version !== 1 || pending.id !== id || pending.home !== sourceHome(home) || pending.runtime !== runtime || pending.state !== "pending") throw new Error("invalid native record start receipt");
|
|
68
79
|
const locations = nativeLaunchLocations(runtime, { cwd: home, env, args }).map(canonical);
|
|
69
80
|
atomic(path, { version: 1, id, home: sourceHome(home), runtime, state: "started", startedAt: new Date().toISOString(), locations });
|
|
70
81
|
}
|
|
71
|
-
export function historicalSessionRoots(home) {
|
|
82
|
+
export function historicalSessionRoots(home, { withProof = false } = {}) {
|
|
72
83
|
const authority = manifest(home);
|
|
73
84
|
if (!authority.completeHistory) throw new Error("native record roots for earlier launches are unknown; legacy history cannot certify complete capture");
|
|
74
85
|
const roots = { cc: [], pi: [], codex: [] };
|
|
86
|
+
if (authority.version === 2) {
|
|
87
|
+
if (!withProof) custodyError("protected native roots require proof-bearing inventory");
|
|
88
|
+
const capturedPi = { home: authority.home, nativeRecordId: authority.capturedPi.nativeRecordIds[0] };
|
|
89
|
+
const row = proofReceipt(capturedPi); // validates the ENTIRE expected inventory, not surviving rows alone
|
|
90
|
+
roots.pi.push({ path: row.proposedRoot, capturedPi });
|
|
91
|
+
return roots;
|
|
92
|
+
}
|
|
75
93
|
for (const name of readdirSync(nativeHistoryPath(home)).sort()) {
|
|
76
94
|
if (name === "history.json") continue;
|
|
77
95
|
if (!/^[a-f0-9-]{36}\.json$/.test(name)) throw new Error("unrecognized or unfinished native record receipt");
|
|
78
96
|
const row = JSON.parse(readFileSync(join(nativeHistoryPath(home), name), "utf8"));
|
|
79
97
|
const source = { claude: "cc", pi: "pi", codex: "codex" }[row.runtime];
|
|
98
|
+
if (row.version === 2) custodyError("captured receipt lacks its expected-ID manifest");
|
|
80
99
|
if (row.version !== 1 || row.id !== basename(name, ".json") || row.home !== authority.home || !source || row.state !== "started" || !Array.isArray(row.locations) || row.locations.length === 0) throw new Error("native record launch is pending or its location receipt is invalid");
|
|
81
100
|
for (const path of row.locations) {
|
|
82
101
|
if (typeof path !== "string" || !path.startsWith("/") || path.includes("\0") || resolve(path) !== path) throw new Error("invalid historical native record location");
|
|
@@ -85,3 +104,254 @@ export function historicalSessionRoots(home) {
|
|
|
85
104
|
}
|
|
86
105
|
return roots;
|
|
87
106
|
}
|
|
107
|
+
|
|
108
|
+
// This advertises the COMPLETE protected discovery/read/append chain, not only
|
|
109
|
+
// this writer. The five libraries ship together. Existing v1 receipts are never
|
|
110
|
+
// upgraded; only genuinely unused, complete scaffold history may move forward.
|
|
111
|
+
export const CAPTURED_PI_RECORD_VERSION = 2;
|
|
112
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
113
|
+
function custodyError(message) { throw Object.assign(new Error(message), { code: "E_CAPTURED_PI_CUSTODY" }); }
|
|
114
|
+
function closed(value, required, optional = []) {
|
|
115
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || required.some(k => !Object.hasOwn(value, k)) || Object.keys(value).some(k => !required.includes(k) && !optional.includes(k))) custodyError("invalid captured Pi custody shape");
|
|
116
|
+
}
|
|
117
|
+
function absolutePath(path) { return typeof path === "string" && isAbsolute(path) && resolve(path) === path && !/[\0\r\n]/.test(path); }
|
|
118
|
+
function validIntent(intent, incarnationId) {
|
|
119
|
+
closed(intent, ["schemaVersion", "executionId", "incarnationId", "attempt"]);
|
|
120
|
+
if (intent.schemaVersion !== 1 || !UUID.test(incarnationId) || intent.incarnationId !== incarnationId || typeof intent.executionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(intent.executionId) || !Number.isSafeInteger(intent.attempt) || intent.attempt < 1) custodyError("invalid original captured Pi intent");
|
|
121
|
+
}
|
|
122
|
+
function rootFor(home) { const history = nativeHistoryPath(home); return join(dirname(history), basename(history) + ".pi"); }
|
|
123
|
+
function physical(path, directory = false) {
|
|
124
|
+
if (!absolutePath(path) || realpathSync(path) !== path) custodyError("captured Pi custody path is not physical");
|
|
125
|
+
const stat = lstatSync(path);
|
|
126
|
+
if (stat.isSymbolicLink() || (directory && !stat.isDirectory())) custodyError("captured Pi custody path was substituted");
|
|
127
|
+
return stat;
|
|
128
|
+
}
|
|
129
|
+
function directoryIdentity(path) {
|
|
130
|
+
const stat = physical(path, true);
|
|
131
|
+
if (!Number.isSafeInteger(stat.dev) || !Number.isSafeInteger(stat.ino)) custodyError("unrepresentable captured directory identity");
|
|
132
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
133
|
+
}
|
|
134
|
+
function privateJson(path) {
|
|
135
|
+
physical(dirname(path), true);
|
|
136
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
137
|
+
try {
|
|
138
|
+
const before = fstatSync(fd);
|
|
139
|
+
if (!before.isFile() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600 || (process.getuid && before.uid !== process.getuid()) || before.size > 256 * 1024) custodyError("invalid private captured receipt");
|
|
140
|
+
const assertNamedDescriptor = () => {
|
|
141
|
+
// Ancestor-link ABA during open may return a different regular/private FD.
|
|
142
|
+
// Check the actual physical named authority BEFORE reading that descriptor.
|
|
143
|
+
const named = physical(path), opened = fstatSync(fd);
|
|
144
|
+
for (const stat of [opened, named]) if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino || stat.nlink !== 1 || stat.size !== before.size || stat.mtimeMs !== before.mtimeMs || stat.ctimeMs !== before.ctimeMs) custodyError("captured receipt descriptor differs from named authority");
|
|
145
|
+
physical(dirname(path), true);
|
|
146
|
+
};
|
|
147
|
+
assertNamedDescriptor();
|
|
148
|
+
const bytes = Buffer.alloc(before.size + 1); let size = 0;
|
|
149
|
+
while (size < bytes.length) {
|
|
150
|
+
assertNamedDescriptor();
|
|
151
|
+
const n = readSync(fd, bytes, size, bytes.length - size, null);
|
|
152
|
+
if (n === 0) break;
|
|
153
|
+
size += n;
|
|
154
|
+
}
|
|
155
|
+
assertNamedDescriptor();
|
|
156
|
+
if (size !== before.size) custodyError("captured receipt size changed");
|
|
157
|
+
const data = bytes.subarray(0, size);
|
|
158
|
+
if (!isUtf8(data)) custodyError("captured receipt is not UTF-8");
|
|
159
|
+
const text = data.toString("utf8"), value = JSON.parse(text);
|
|
160
|
+
// New private v2 receipts use our compact serializer. Reject duplicate keys
|
|
161
|
+
// and alternate encodings rather than letting parsers disagree on authority.
|
|
162
|
+
if (value?.version === 2 && text.trim() !== JSON.stringify(value)) custodyError("captured receipt is not canonical writer output");
|
|
163
|
+
return value;
|
|
164
|
+
} finally { closeSync(fd); }
|
|
165
|
+
}
|
|
166
|
+
function validateManifest(header, home) {
|
|
167
|
+
closed(header, ["version", "home", "completeHistory", ...(header?.version === 2 ? ["capturedPi"] : [])]);
|
|
168
|
+
if (![1, 2].includes(header.version) || header.home !== home || header.completeHistory !== true) custodyError("captured history is missing or incomplete");
|
|
169
|
+
if (header.version === 2) {
|
|
170
|
+
closed(header.capturedPi, ["incarnationId", "nativeRecordIds"]);
|
|
171
|
+
const { incarnationId, nativeRecordIds: ids } = header.capturedPi;
|
|
172
|
+
if (!UUID.test(incarnationId) || !Array.isArray(ids) || ids.length === 0 || ids.length > 4096 || ids.some(id => typeof id !== "string" || !UUID.test(id)) || new Set(ids).size !== ids.length) custodyError("invalid captured expected-ID inventory");
|
|
173
|
+
}
|
|
174
|
+
return header;
|
|
175
|
+
}
|
|
176
|
+
function protectedManifest(home) {
|
|
177
|
+
return validateManifest(privateJson(join(nativeHistoryPath(home), "history.json")), home);
|
|
178
|
+
}
|
|
179
|
+
function inventory(home) {
|
|
180
|
+
if (!absolutePath(home) || sourceHome(home) !== home) custodyError("captured home identity is not canonical");
|
|
181
|
+
const history = nativeHistoryPath(home); physical(history, true);
|
|
182
|
+
const header = protectedManifest(home);
|
|
183
|
+
const names = readdirSync(history).sort();
|
|
184
|
+
if (names.length > 16384) custodyError("captured receipt inventory exceeds bound");
|
|
185
|
+
const rows = new Map();
|
|
186
|
+
for (const name of names) {
|
|
187
|
+
if (name === "history.json") continue;
|
|
188
|
+
const id = basename(name, ".json");
|
|
189
|
+
if (name !== id + ".json" || !UUID.test(id)) custodyError("unfinished or unknown captured native receipt");
|
|
190
|
+
const row = privateJson(join(history, name));
|
|
191
|
+
if (row.version === 2) {
|
|
192
|
+
validateReceipt(row, home, id);
|
|
193
|
+
if (header.version !== 2 || !header.capturedPi.nativeRecordIds.includes(id) || row.incarnationId !== header.capturedPi.incarnationId || (row.rootWitness && row.rootWitness.establishedBy.nativeRecordId !== header.capturedPi.nativeRecordIds[0])) custodyError("unlisted or foreign captured native receipt");
|
|
194
|
+
} else if (header.version === 2 || row.version !== 1 || row.id !== id || row.home !== home || !["claude", "pi", "codex"].includes(row.runtime) || row.state !== "started" || !Array.isArray(row.locations) || !row.locations.length || row.locations.some(p => !absolutePath(p))) custodyError("unknown or pending historical native receipt");
|
|
195
|
+
rows.set(id, row);
|
|
196
|
+
}
|
|
197
|
+
if (header.version === 2 && (rows.size !== header.capturedPi.nativeRecordIds.length || header.capturedPi.nativeRecordIds.some(id => !rows.has(id)))) custodyError("expected native receipt is missing; history is not fresh or complete");
|
|
198
|
+
if (!isDeepStrictEqual(privateJson(join(history, "history.json")), header) || !isDeepStrictEqual(readdirSync(history).sort(), names)) custodyError("native custody inventory changed during validation");
|
|
199
|
+
return { header, rows };
|
|
200
|
+
}
|
|
201
|
+
function validateReceipt(row, home, id) {
|
|
202
|
+
closed(row, ["version", "id", "home", "runtime", "incarnationId", "intent", "state", "proposedRoot", "rootWitness"], ["startedAt", "locations"]);
|
|
203
|
+
if (row.version !== 2 || row.id !== id || !UUID.test(id) || row.home !== home || row.runtime !== "pi" || row.proposedRoot !== rootFor(home) || !["pending", "root-established", "started"].includes(row.state)) custodyError("invalid captured native receipt");
|
|
204
|
+
validIntent(row.intent, row.incarnationId);
|
|
205
|
+
if (row.state === "started") {
|
|
206
|
+
if (typeof row.startedAt !== "string" || !Number.isFinite(Date.parse(row.startedAt)) || new Date(row.startedAt).toISOString() !== row.startedAt || !isDeepStrictEqual(row.locations, [row.proposedRoot])) custodyError("invalid captured native start fields");
|
|
207
|
+
} else if (Object.hasOwn(row, "startedAt") || Object.hasOwn(row, "locations")) custodyError("unstarted captured receipt has execution fields");
|
|
208
|
+
if (row.rootWitness === null) { if (row.state !== "pending") custodyError("captured root is unproven"); return; }
|
|
209
|
+
const w = row.rootWitness;
|
|
210
|
+
closed(w, ["schemaVersion", "profile", "path", "identity", "establishedBy"]);
|
|
211
|
+
closed(w.identity, ["dev", "ino"]); closed(w.establishedBy, ["nativeRecordId", "intent"]);
|
|
212
|
+
if (w.schemaVersion !== 1 || w.profile !== "pi-zero-print-1" || w.path !== row.proposedRoot || !UUID.test(w.establishedBy.nativeRecordId) || !Number.isSafeInteger(w.identity.dev) || w.identity.dev < 0 || !Number.isSafeInteger(w.identity.ino) || w.identity.ino < 0) custodyError("invalid original Pi root witness");
|
|
213
|
+
validIntent(w.establishedBy.intent, row.incarnationId);
|
|
214
|
+
}
|
|
215
|
+
function validateWitness(row, rows) {
|
|
216
|
+
const witness = row.rootWitness, establishing = witness && rows.get(witness.establishedBy.nativeRecordId);
|
|
217
|
+
if (!establishing || establishing.version !== 2 || !["root-established", "started"].includes(establishing.state) || establishing.home !== row.home || establishing.incarnationId !== row.incarnationId || establishing.rootWitness?.establishedBy.nativeRecordId !== establishing.id || !isDeepStrictEqual(establishing.intent, witness.establishedBy.intent) || !isDeepStrictEqual(establishing.rootWitness, witness)) custodyError("original establishing receipt is missing or contradictory");
|
|
218
|
+
if (!isDeepStrictEqual(directoryIdentity(row.proposedRoot), witness.identity)) custodyError("original Pi session directory was replaced");
|
|
219
|
+
}
|
|
220
|
+
function selection(home, authority, withIntent = false, creating = false) {
|
|
221
|
+
closed(authority, ["incarnationId", "sessionDir", ...(withIntent ? ["intent"] : []), ...(creating ? ["assertAuthority"] : [])]);
|
|
222
|
+
if (!UUID.test(authority.incarnationId) || !absolutePath(home) || home !== sourceHome(home) || authority.sessionDir !== rootFor(home)) custodyError("captured root selection differs from original custody");
|
|
223
|
+
if (withIntent) validIntent(authority.intent, authority.incarnationId);
|
|
224
|
+
if (creating && typeof authority.assertAuthority !== "function") custodyError("kernel authority callback required");
|
|
225
|
+
}
|
|
226
|
+
function inspectedRoot(home, authority) {
|
|
227
|
+
const { header, rows } = inventory(home), captured = [...rows.values()].filter(r => r.version === 2);
|
|
228
|
+
if (header.version === 2 && header.capturedPi.incarnationId !== authority.incarnationId) custodyError("captured inventory belongs to another incarnation");
|
|
229
|
+
for (const row of captured) {
|
|
230
|
+
if (row.incarnationId !== authority.incarnationId || row.state !== "started") custodyError("foreign or uncertain captured root claim");
|
|
231
|
+
validateWitness(row, rows);
|
|
232
|
+
}
|
|
233
|
+
if (!captured.length) {
|
|
234
|
+
if (header.version !== 1 || rows.size || lstatSync(authority.sessionDir, { throwIfNoEntry: false })) custodyError("existing or previously claimed native storage is not fresh");
|
|
235
|
+
return { header, rows, witness: null };
|
|
236
|
+
}
|
|
237
|
+
const witness = captured[0].rootWitness;
|
|
238
|
+
if (captured.some(r => !isDeepStrictEqual(r.rootWitness, witness))) custodyError("conflicting original Pi root witnesses");
|
|
239
|
+
return { header, rows, witness };
|
|
240
|
+
}
|
|
241
|
+
export function inspectCapturedPiRoot(home, authority) {
|
|
242
|
+
selection(home, authority); inspectedRoot(home, authority);
|
|
243
|
+
}
|
|
244
|
+
function syncDirectory(path) { const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { fsyncSync(fd); } finally { closeSync(fd); } }
|
|
245
|
+
function publishCustody(home, name, value, previous, check) {
|
|
246
|
+
const history = nativeHistoryPath(home), path = join(history, name), tmp = path + "." + randomUUID() + ".tmp";
|
|
247
|
+
const bytes = JSON.stringify(value) + "\n";
|
|
248
|
+
if (Buffer.byteLength(bytes) > 256 * 1024) custodyError("captured custody publication exceeds bound");
|
|
249
|
+
const unchanged = () => { check(); if (previous === null ? lstatSync(path, { throwIfNoEntry: false }) : !isDeepStrictEqual(privateJson(path), previous)) custodyError("native custody publication lost original authority"); };
|
|
250
|
+
unchanged();
|
|
251
|
+
const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
252
|
+
try { writeFileSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); }
|
|
253
|
+
unchanged(); renameSync(tmp, path); syncDirectory(history); check();
|
|
254
|
+
if (!isDeepStrictEqual(privateJson(path), value)) custodyError("native custody publication is uncertain");
|
|
255
|
+
}
|
|
256
|
+
function publishCaptured(home, row, previous, check) {
|
|
257
|
+
validateReceipt(row, home, row.id);
|
|
258
|
+
publishCustody(home, row.id + ".json", row, previous, check);
|
|
259
|
+
}
|
|
260
|
+
export function prepareCapturedPiStart(home, authority) {
|
|
261
|
+
selection(home, authority, true, true); authority.assertAuthority();
|
|
262
|
+
const { header, rows, witness } = inspectedRoot(home, authority);
|
|
263
|
+
if ([...rows.values()].some(r => r.version === 2 && r.intent.executionId === authority.intent.executionId)) custodyError("existing native admission must not prepare another start");
|
|
264
|
+
const history = nativeHistoryPath(home), directories = [dirname(history), history].map(path => [path, directoryIdentity(path)]);
|
|
265
|
+
const check = () => {
|
|
266
|
+
authority.assertAuthority();
|
|
267
|
+
for (const [path, identity] of directories) if (!isDeepStrictEqual(directoryIdentity(path), identity)) custodyError("original native custody parent changed");
|
|
268
|
+
for (const [id, row] of rows) if (!isDeepStrictEqual(privateJson(join(history, id + ".json")), row)) custodyError("original native receipt changed during preparation");
|
|
269
|
+
if (witness && !isDeepStrictEqual(directoryIdentity(authority.sessionDir), witness.identity)) custodyError("original root changed during preparation");
|
|
270
|
+
};
|
|
271
|
+
const id = randomUUID(), pending = { version: 2, id, home, runtime: "pi", incarnationId: authority.incarnationId, intent: structuredClone(authority.intent), state: "pending", proposedRoot: authority.sessionDir, rootWitness: structuredClone(witness) };
|
|
272
|
+
const claimed = validateManifest({ version: 2, home, completeHistory: header.completeHistory, capturedPi: { incarnationId: authority.incarnationId, nativeRecordIds: [...(header.capturedPi?.nativeRecordIds ?? []), id] } }, home);
|
|
273
|
+
// The expected ID is durable BEFORE its pending receipt or root can exist.
|
|
274
|
+
// Losing either receipt or S can no longer masquerade as a fresh scaffold.
|
|
275
|
+
publishCustody(home, "history.json", claimed, header, check);
|
|
276
|
+
const claimCheck = () => { check(); if (!isDeepStrictEqual(protectedManifest(home), claimed)) custodyError("original expected-ID claim changed"); };
|
|
277
|
+
publishCaptured(home, pending, null, claimCheck); claimCheck();
|
|
278
|
+
let established = witness;
|
|
279
|
+
if (witness) validateWitness(pending, rows);
|
|
280
|
+
else {
|
|
281
|
+
mkdirSync(authority.sessionDir, { mode: 0o700 }); // exclusive: EEXIST is uncertainty, never adoption
|
|
282
|
+
const identity = directoryIdentity(authority.sessionDir); syncDirectory(dirname(authority.sessionDir)); claimCheck();
|
|
283
|
+
established = { schemaVersion: 1, profile: "pi-zero-print-1", path: authority.sessionDir, identity, establishedBy: { nativeRecordId: id, intent: structuredClone(authority.intent) } };
|
|
284
|
+
}
|
|
285
|
+
const guard = () => { claimCheck(); if (!isDeepStrictEqual(directoryIdentity(authority.sessionDir), established.identity)) custodyError("captured root changed during establishment"); };
|
|
286
|
+
publishCaptured(home, { ...pending, state: "root-established", rootWitness: established }, pending, guard);
|
|
287
|
+
inventory(home); // no unlisted/unfinished writes may be ignored before return
|
|
288
|
+
return id;
|
|
289
|
+
}
|
|
290
|
+
function recordCapturedPiStart(home, id, runtime, args, env) {
|
|
291
|
+
const { header, rows } = inventory(home), row = rows.get(id);
|
|
292
|
+
if (!row || row.version !== 2 || row.state !== "root-established" || runtime !== "pi") custodyError("captured start must consume an established v2 receipt");
|
|
293
|
+
for (const prior of rows.values()) if (prior.version === 2 && prior.id !== id) {
|
|
294
|
+
if (prior.state !== "started" || prior.incarnationId !== row.incarnationId || !isDeepStrictEqual(prior.rootWitness, row.rootWitness)) custodyError("other native start is foreign or uncertain");
|
|
295
|
+
validateWitness(prior, rows);
|
|
296
|
+
}
|
|
297
|
+
if (env.OATS_INSTANCE_HOME !== home || env.OATS_INCARNATION_ID !== row.incarnationId || env.OATS_EXECUTION_ID !== row.intent.executionId || env.OATS_EXECUTION_ATTEMPT !== String(row.intent.attempt)) custodyError("native execution differs from original admitted context");
|
|
298
|
+
const selected = Array.isArray(args) ? args.filter(a => typeof a === "string" && (a === "--session-dir" || a.startsWith("--session-dir="))) : [];
|
|
299
|
+
if (selected.length !== 1 || !isDeepStrictEqual(nativeLaunchLocations(runtime, { cwd: home, args, env }), [row.proposedRoot])) custodyError("effective native session directory differs from original root");
|
|
300
|
+
const guard = () => {
|
|
301
|
+
const { rows: current } = inventory(home);
|
|
302
|
+
if (!isDeepStrictEqual(current.get(id), row)) custodyError("prepared native receipt changed");
|
|
303
|
+
validateWitness(row, current);
|
|
304
|
+
};
|
|
305
|
+
// Publication itself changes this row, so the effect guard checks original
|
|
306
|
+
// root/establishing association; previous bytes are separately pinned above.
|
|
307
|
+
guard();
|
|
308
|
+
const establish = rows.get(row.rootWitness.establishedBy.nativeRecordId);
|
|
309
|
+
const check = () => {
|
|
310
|
+
if (!isDeepStrictEqual(protectedManifest(home), header)) custodyError("expected-ID claim changed before execution");
|
|
311
|
+
const current = privateJson(join(nativeHistoryPath(home), establish.id + ".json"));
|
|
312
|
+
if (establish.id !== id && !isDeepStrictEqual(current, establish)) custodyError("establishing receipt changed");
|
|
313
|
+
if (!isDeepStrictEqual(directoryIdentity(row.proposedRoot), row.rootWitness.identity)) custodyError("native root changed before execution");
|
|
314
|
+
};
|
|
315
|
+
publishCaptured(home, { ...row, state: "started", startedAt: new Date().toISOString(), locations: [row.proposedRoot] }, row, check);
|
|
316
|
+
}
|
|
317
|
+
function proofReceipt(proof) {
|
|
318
|
+
closed(proof, ["home", "nativeRecordId"]);
|
|
319
|
+
if (!UUID.test(proof.nativeRecordId)) custodyError("invalid native root proof reference");
|
|
320
|
+
const { rows } = inventory(proof.home), row = rows.get(proof.nativeRecordId);
|
|
321
|
+
if (!row || row.version !== 2 || row.state !== "started") custodyError("missing started native root proof");
|
|
322
|
+
for (const r of rows.values()) if (r.version === 2) {
|
|
323
|
+
if (r.state !== "started" || r.incarnationId !== row.incarnationId || !isDeepStrictEqual(r.rootWitness, row.rootWitness)) custodyError("incomplete or contradictory captured history");
|
|
324
|
+
validateWitness(r, rows);
|
|
325
|
+
}
|
|
326
|
+
return row;
|
|
327
|
+
}
|
|
328
|
+
export function assertCapturedPiStart(home, nativeRecordId, authority) {
|
|
329
|
+
selection(home, authority, true);
|
|
330
|
+
const row = proofReceipt({ home, nativeRecordId });
|
|
331
|
+
if (row.incarnationId !== authority.incarnationId || !isDeepStrictEqual(row.intent, authority.intent) || row.proposedRoot !== authority.sessionDir) custodyError("started receipt differs from original admitted root");
|
|
332
|
+
}
|
|
333
|
+
function protectedRoot(path) {
|
|
334
|
+
for (let dir = resolve(path); ; dir = dirname(dir)) {
|
|
335
|
+
if (basename(dirname(dir)) === ".oats-native-record" && /^[a-f0-9]{64}\.pi$/.test(basename(dir))) return dir;
|
|
336
|
+
if (dirname(dir) === dir) return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Shared discovery/snapshot/capture perimeter. A copied proof is only a REF:
|
|
340
|
+
// reload the external authoritative receipts; never accept supplied dev/ino.
|
|
341
|
+
export function assertCapturedPiProof(proof, path) {
|
|
342
|
+
const row = proofReceipt(proof), rel = relative(row.proposedRoot, path);
|
|
343
|
+
if (!absolutePath(path) || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) custodyError("native source escaped witnessed root");
|
|
344
|
+
for (let current = path; ; current = dirname(current)) {
|
|
345
|
+
const stat = physical(current, current !== path);
|
|
346
|
+
if (current === row.proposedRoot) {
|
|
347
|
+
if (!stat.isDirectory() || !isDeepStrictEqual({ dev: stat.dev, ino: stat.ino }, row.rootWitness.identity)) custodyError("native root changed during contained-path validation");
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
export function guardCapturedPath(path, proof) {
|
|
353
|
+
if (proof !== undefined) return assertCapturedPiProof(proof, path);
|
|
354
|
+
let physicalPath = path;
|
|
355
|
+
try { physicalPath = realpathSync(path); } catch (e) { if (e.code !== "ENOENT") throw e; }
|
|
356
|
+
if (protectedRoot(path) || protectedRoot(physicalPath)) custodyError("protected native path requires its original proof");
|
|
357
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// are not attribution: a renamed/replaced source must never donate bytes to
|
|
3
3
|
// the previous source's stream. Reads and validation use the SAME descriptor.
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import { fstatSync, readSync, statSync } from "node:fs";
|
|
5
|
+
import { fstatSync, lstatSync, readSync, statSync } from "node:fs";
|
|
6
|
+
import { guardCapturedPath } from "./native-history.mjs";
|
|
6
7
|
|
|
7
8
|
export function identity(stat) {
|
|
8
9
|
if (!stat.isFile()) throw new Error("session source is not a regular file");
|
|
@@ -15,24 +16,41 @@ export function sameVersion(a, b) {
|
|
|
15
16
|
}
|
|
16
17
|
export function digest(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// O_NOFOLLOW protects the leaf only. An ancestor may have redirected open()
|
|
20
|
+
// and been restored before it returns: bind that FD to the physically contained
|
|
21
|
+
// named file under the original root proof BEFORE reading even one byte.
|
|
22
|
+
export function assertProtectedDescriptor(fd, path, capturedPi) {
|
|
23
|
+
guardCapturedPath(path, capturedPi);
|
|
24
|
+
if (capturedPi === undefined) return;
|
|
25
|
+
const opened = fstatSync(fd), named = lstatSync(path);
|
|
26
|
+
if (!opened.isFile() || !named.isFile() || !sameFile(opened, named)) throw new Error(`protected session descriptor differs from its contained source: ${path}`);
|
|
27
|
+
guardCapturedPath(path, capturedPi);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readRange(fd, start, size, path, capturedPi) {
|
|
31
|
+
assertProtectedDescriptor(fd, path, capturedPi);
|
|
19
32
|
const buf = Buffer.alloc(size - start);
|
|
20
33
|
let done = 0;
|
|
21
34
|
while (done < buf.length) {
|
|
35
|
+
if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
|
|
22
36
|
const n = readSync(fd, buf, done, buf.length - done, start + done);
|
|
23
37
|
if (n === 0) throw new Error(`short read of session source: ${path}`);
|
|
24
38
|
done += n;
|
|
25
39
|
}
|
|
40
|
+
if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
|
|
26
41
|
return buf;
|
|
27
42
|
}
|
|
28
43
|
|
|
29
|
-
export function hashPrefix(fd, size, path) {
|
|
44
|
+
export function hashPrefix(fd, size, path, capturedPi) {
|
|
45
|
+
assertProtectedDescriptor(fd, path, capturedPi);
|
|
30
46
|
const hash = createHash("sha256"), buf = Buffer.alloc(64 * 1024);
|
|
31
47
|
for (let pos = 0; pos < size;) {
|
|
48
|
+
if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
|
|
32
49
|
const n = readSync(fd, buf, 0, Math.min(buf.length, size - pos), pos);
|
|
33
50
|
if (n === 0) throw new Error(`short read of session source: ${path}`);
|
|
34
51
|
hash.update(buf.subarray(0, n)); pos += n;
|
|
35
52
|
}
|
|
53
|
+
if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
|
|
36
54
|
return hash.digest("hex");
|
|
37
55
|
}
|
|
38
56
|
|
|
@@ -46,16 +64,18 @@ export function assertIdentity(stat, expected, path) {
|
|
|
46
64
|
// A changed same-size file is not append growth. Verification happens before
|
|
47
65
|
// journal writes; afterwards the buffer to append is independent of the path.
|
|
48
66
|
export function verifySnapshot(fd, path, snapshot) {
|
|
67
|
+
assertProtectedDescriptor(fd, path, snapshot.capturedPi);
|
|
49
68
|
const after = fstatSync(fd), named = statSync(path);
|
|
50
69
|
assertIdentity(after, snapshot, path);
|
|
51
70
|
assertIdentity(named, snapshot, path);
|
|
52
71
|
if (!sameVersion(after, named)) throw new Error(`session source changed during capture: ${path}`);
|
|
53
|
-
if (sameVersion(after, snapshot)) return;
|
|
54
|
-
if (after.size <= snapshot.size || hashPrefix(fd, snapshot.size, path) !== snapshot.hash) {
|
|
72
|
+
if (sameVersion(after, snapshot)) { guardCapturedPath(path, snapshot.capturedPi); return; }
|
|
73
|
+
if (after.size <= snapshot.size || hashPrefix(fd, snapshot.size, path, snapshot.capturedPi) !== snapshot.hash) {
|
|
55
74
|
throw new Error(`session source changed during capture: ${path}`);
|
|
56
75
|
}
|
|
57
76
|
// A second change while validating is uncertain, not absence of evidence.
|
|
58
77
|
if (!sameVersion(after, fstatSync(fd)) || !sameVersion(after, statSync(path))) {
|
|
59
78
|
throw new Error(`session source changed during verification: ${path}`);
|
|
60
79
|
}
|
|
80
|
+
guardCapturedPath(path, snapshot.capturedPi);
|
|
61
81
|
}
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
// instance's own, and nothing outside it — not the parent workspace, not a
|
|
13
13
|
// sibling home — is ever swept in.
|
|
14
14
|
|
|
15
|
-
import { closeSync, fstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
15
|
+
import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
16
16
|
import { basename, resolve, sep } from "node:path";
|
|
17
17
|
import { isUtf8 } from "node:buffer";
|
|
18
18
|
|
|
19
19
|
import { SESSION_FORMATS } from "./formats.mjs";
|
|
20
|
-
import { hashPrefix, identity, sameVersion, verifySnapshot } from "./session-snapshot.mjs";
|
|
20
|
+
import { assertProtectedDescriptor, hashPrefix, identity, sameVersion, verifySnapshot } from "./session-snapshot.mjs";
|
|
21
21
|
import { sourceSessionEnvironment } from "./session-roots.mjs";
|
|
22
|
-
import { historicalSessionRoots } from "./native-history.mjs";
|
|
22
|
+
import { guardCapturedPath, historicalSessionRoots } from "./native-history.mjs";
|
|
23
23
|
|
|
24
24
|
// A session's first lines can be large (Claude Code queue operations and
|
|
25
25
|
// file-history snapshots run to 100 KB and more) and the first cwd-bearing
|
|
@@ -30,11 +30,12 @@ import { historicalSessionRoots } from "./native-history.mjs";
|
|
|
30
30
|
const CHUNK_BYTES = 64 * 1024;
|
|
31
31
|
export const CWD_SCAN_BOUND_BYTES = 8 * 1024 * 1024;
|
|
32
32
|
|
|
33
|
-
function* wholeLines(fd, bound) {
|
|
33
|
+
function* wholeLines(fd, bound, path, capturedPi) {
|
|
34
34
|
const buf = Buffer.alloc(CHUNK_BYTES);
|
|
35
35
|
let pieces = [], size = 0; // keep raw bytes across UTF-8/chunk boundaries
|
|
36
36
|
let offset = 0;
|
|
37
37
|
while (offset < bound) {
|
|
38
|
+
if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
|
|
38
39
|
const n = readSync(fd, buf, 0, Math.min(CHUNK_BYTES, bound - offset), offset);
|
|
39
40
|
if (n === 0) break;
|
|
40
41
|
offset += n;
|
|
@@ -73,17 +74,20 @@ function cwdOfLine(source, line) {
|
|
|
73
74
|
|
|
74
75
|
/** Descriptor-derived attribution and (for accepted cwd) content witness.
|
|
75
76
|
* No cwd within `bound` means unknown format, torn input or no attribution. */
|
|
76
|
-
export function sessionAttribution(source, path, { bound = CWD_SCAN_BOUND_BYTES, acceptCwd = () => true } = {}) {
|
|
77
|
-
|
|
77
|
+
export function sessionAttribution(source, path, { bound = CWD_SCAN_BOUND_BYTES, acceptCwd = () => true, capturedPi } = {}) {
|
|
78
|
+
if (capturedPi && source !== "pi") throw new Error("protected Pi proof cannot qualify another format");
|
|
79
|
+
guardCapturedPath(path, capturedPi);
|
|
80
|
+
const fd = openSync(path, capturedPi ? constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK : "r");
|
|
78
81
|
try {
|
|
79
|
-
|
|
82
|
+
if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
|
|
83
|
+
const snapshot = { ...identity(fstatSync(fd)), ...(capturedPi ? { capturedPi } : {}) };
|
|
80
84
|
let cwd;
|
|
81
|
-
for (const line of wholeLines(fd, Math.min(bound, snapshot.size))) {
|
|
85
|
+
for (const line of wholeLines(fd, Math.min(bound, snapshot.size), path, capturedPi)) {
|
|
82
86
|
cwd = cwdOfLine(source, line);
|
|
83
87
|
if (cwd) break;
|
|
84
88
|
}
|
|
85
89
|
// Never hash/read the body of an unrelated or unattributable session.
|
|
86
|
-
if (cwd && acceptCwd(cwd)) snapshot.hash = hashPrefix(fd, snapshot.size, path);
|
|
90
|
+
if (cwd && acceptCwd(cwd)) snapshot.hash = hashPrefix(fd, snapshot.size, path, capturedPi);
|
|
87
91
|
// Never combine attribution read from an old prefix with a new witness.
|
|
88
92
|
// Discovery requires a stable read; append growth between discovery and
|
|
89
93
|
// capture is supported by the witness's prefix hash.
|
|
@@ -133,17 +137,19 @@ export function sessionsForHome(home, { roots, onUnattributed, bound, ignore, on
|
|
|
133
137
|
// excluded, not filled from an unrelated observer. The opt-in fallback is
|
|
134
138
|
// for standalone/legacy inventories, never proof of historical completeness.
|
|
135
139
|
let context;
|
|
136
|
-
if (!roots && fallback !== "current-env") roots = historicalSessionRoots(home);
|
|
140
|
+
if (!roots && fallback !== "current-env") roots = historicalSessionRoots(home, { withProof: true });
|
|
137
141
|
if (!roots) context = sourceSessionEnvironment(home, env);
|
|
138
142
|
for (const fmt of Object.values(SESSION_FORMATS)) {
|
|
139
143
|
const rs = roots ? (roots[fmt.source] ?? []) : fmt.defaultRoots(context.home, context.env, { cwd: home });
|
|
140
|
-
for (const
|
|
144
|
+
for (const entry of fmt.listFiles(rs, { strict: true })) {
|
|
145
|
+
const path = typeof entry === "string" ? entry : entry.path;
|
|
146
|
+
const capturedPi = typeof entry === "string" ? undefined : entry.capturedPi;
|
|
141
147
|
const sessionId = fmt.sessionId(path);
|
|
142
148
|
if (ignore?.ignores(path, [basename(path), sessionId, ...(fmt.ignoreKeys?.(path) ?? [])])) {
|
|
143
149
|
onIgnored?.(fmt.source, path);
|
|
144
150
|
continue;
|
|
145
151
|
}
|
|
146
|
-
const { cwd, snapshot } = sessionAttribution(fmt.source, path, { bound, acceptCwd: cwd => within(canonical(cwd), target) });
|
|
152
|
+
const { cwd, snapshot } = sessionAttribution(fmt.source, path, { bound, capturedPi, acceptCwd: cwd => within(canonical(cwd), target) });
|
|
147
153
|
if (!cwd) { if (onUnattributed) onUnattributed(fmt.source, path); continue; }
|
|
148
154
|
if (!within(canonical(cwd), target)) continue;
|
|
149
155
|
out.push({
|
|
@@ -155,8 +161,19 @@ export function sessionsForHome(home, { roots, onUnattributed, bound, ignore, on
|
|
|
155
161
|
bytes: snapshot.size,
|
|
156
162
|
mtime: new Date(snapshot.mtimeMs).toISOString(),
|
|
157
163
|
snapshot,
|
|
164
|
+
...(capturedPi ? { capturedPi } : {}),
|
|
158
165
|
});
|
|
159
166
|
}
|
|
160
167
|
}
|
|
161
|
-
|
|
168
|
+
out.sort((a, b) => a.mtime.localeCompare(b.mtime) || a.path.localeCompare(b.path));
|
|
169
|
+
const protectedRoots = (roots?.pi ?? []).filter(r => typeof r === "object" && r?.capturedPi);
|
|
170
|
+
if (protectedRoots.length) {
|
|
171
|
+
// Existing CLI iterates this same inventory again after its capture lock,
|
|
172
|
+
// including when it is empty. Retain root proof without synthetic sessions
|
|
173
|
+
// or a bin/parser change, and revalidate before certifying its boundaries.
|
|
174
|
+
const check = () => { for (const r of protectedRoots) guardCapturedPath(r.path, r.capturedPi); };
|
|
175
|
+
check();
|
|
176
|
+
Object.defineProperty(out, Symbol.iterator, { value: function* () { check(); yield* Array.prototype[Symbol.iterator].call(this); check(); } });
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
162
179
|
}
|
package/skills/oats/SKILL.md
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: oats
|
|
3
3
|
description: >-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
oats-
|
|
9
|
-
"retire this instance", "oats doctor", "oats status", "how does OATS work".
|
|
4
|
+
Use only for operating a legacy uncaptured OATS deployment through its current
|
|
5
|
+
config chain: legacy status, spawn, retire, doctor, capability commands, and
|
|
6
|
+
instance layout. Triggers: "legacy OATS", "uncaptured instance", or an
|
|
7
|
+
instance without executionBinding. For a captured portable composition, load
|
|
8
|
+
oats-portable instead; never use this skill to fill missing captured inputs.
|
|
10
9
|
---
|
|
11
10
|
|
|
12
|
-
# Operating
|
|
11
|
+
# Operating legacy uncaptured OATS
|
|
12
|
+
|
|
13
|
+
> **Legacy-only procedure.** This skill documents the current config-chain
|
|
14
|
+
> engine for instances without a captured execution binding. A portable instance
|
|
15
|
+
> must use **oats-portable** and explicit deployment/resolution authority. Never
|
|
16
|
+
> apply the config cascade, agent types, or team-scoped lookup below to complete
|
|
17
|
+
> or override a captured record.
|
|
13
18
|
|
|
14
19
|
A **soul** is a durable specialized agent. An **instance** is one disposable,
|
|
15
20
|
resumable incarnation. A **capability package** distributes reusable skills,
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: oats-config
|
|
3
3
|
description: >-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"oats init", "configure OATS", "oats-config.yaml", "adopt a config template".
|
|
10
|
-
Package acquisition/update/remove, locks, restore, and trust mechanics
|
|
11
|
-
belong to the oats-packages skill.
|
|
4
|
+
Use only for configuring a legacy uncaptured OATS deployment with the current
|
|
5
|
+
oats-config.yaml cascade, including legacy activation, agent types, targeting,
|
|
6
|
+
overrides, and templates. Triggers: "legacy oats-config", "uncaptured config",
|
|
7
|
+
"oats use", or "agent type". For portable source/workspace preparation, load
|
|
8
|
+
oats-portable-setup instead; this legacy policy never fills a captured record.
|
|
12
9
|
---
|
|
13
10
|
|
|
14
|
-
# Configuring OATS
|
|
11
|
+
# Configuring legacy uncaptured OATS
|
|
12
|
+
|
|
13
|
+
> **Legacy-only procedure.** The cascading scopes, agent-type targeting and
|
|
14
|
+
> closest-team rules below are compatibility behavior for uncaptured instances.
|
|
15
|
+
> They are not portable composition authorities. Use **oats-portable-setup** for
|
|
16
|
+
> the source/workspace two-authority model and never consult this cascade as a
|
|
17
|
+
> fallback during captured preparation or dispatch.
|
|
15
18
|
|
|
16
19
|
Config lives in `oats-config.yaml` at laptop (`~`), workspace, and repository
|
|
17
20
|
levels; resolution walks from a soul's repository outward, closest scope wins.
|