@junghanacs/entwurf 0.33.0 → 0.34.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/AGENTS.md +4 -4
- package/CHANGELOG.md +162 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +9 -6
- package/VERIFY.md +20 -7
- package/docs/setup-clean-host.md +43 -4
- package/package.json +2 -2
- package/pi/pi-durable/bootstrap.mjs +111 -11
- package/pi/pi-durable/carrier/runtime.js +11 -14
- package/pi/pi-durable/carrier/tui.js +76 -11
- package/pi/pi-durable/overlay/runtime-contacts.patch +199 -7
- package/pi/pi-durable/overlay/upstream-pin.json +3 -3
- package/run.sh +42 -10
- package/scripts/check-gate-qualification.ts +62 -5
- package/scripts/check-herdr-activation.ts +130 -10
- package/scripts/check-herdr-plugin-build.ts +235 -6
- package/scripts/check-release-gate-outcomes.ts +1 -1
- package/scripts/emit-pi-durable-carrier.ts +61 -21
- package/scripts/herdr-activation.mjs +16 -0
- package/scripts/herdr-plugin-activate.mjs +112 -76
- package/scripts/herdr-plugin-deactivate.mjs +41 -2
- package/scripts/mutants/gate-qualification.json +24 -0
- package/scripts/mutants/herdr-activation.json +35 -0
- package/scripts/mutants/herdr-plugin-build.json +66 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +3 -3
- package/scripts/mutants/pi-durable.json +178 -11
|
@@ -187,12 +187,18 @@ function tree(root: string): string[] {
|
|
|
187
187
|
* installed package's activation verb is — absent, incapable (exits 0 on an empty request), or a
|
|
188
188
|
* faithful recorder that refuses an empty request by name and logs the argv it was given.
|
|
189
189
|
*/
|
|
190
|
-
function fixtureAcquire(opts: {
|
|
190
|
+
function fixtureAcquire(opts: {
|
|
191
|
+
activate: "absent" | "incapable" | "recorder";
|
|
192
|
+
log?: string;
|
|
193
|
+
fail?: boolean;
|
|
194
|
+
name?: string;
|
|
195
|
+
}) {
|
|
196
|
+
const name = opts.name ?? PACKAGE;
|
|
191
197
|
return ({ identity, prefix }: AcquireArgs) => {
|
|
192
198
|
const version = identity.version ?? FIXTURE_VERSION;
|
|
193
|
-
const root = path.join(prefix, "node_modules",
|
|
199
|
+
const root = path.join(prefix, "node_modules", name);
|
|
194
200
|
fs.mkdirSync(path.join(root, path.dirname(COMPILED_ENTRY)), { recursive: true });
|
|
195
|
-
fs.writeFileSync(path.join(root, "package.json"), `${JSON.stringify({ name
|
|
201
|
+
fs.writeFileSync(path.join(root, "package.json"), `${JSON.stringify({ name, version })}\n`);
|
|
196
202
|
fs.writeFileSync(path.join(root, COMPILED_ENTRY), "// compiled entry fixture\n");
|
|
197
203
|
const bin = path.join(prefix, "node_modules", ".bin");
|
|
198
204
|
fs.mkdirSync(bin, { recursive: true });
|
|
@@ -222,7 +228,7 @@ function fixtureAcquire(opts: { activate: "absent" | "incapable" | "recorder"; l
|
|
|
222
228
|
observedDigest: `sha256-${createHash("sha256")
|
|
223
229
|
.update(identity.commit ?? "x")
|
|
224
230
|
.digest("hex")}`,
|
|
225
|
-
packageName:
|
|
231
|
+
packageName: name,
|
|
226
232
|
packageVersion: version,
|
|
227
233
|
};
|
|
228
234
|
};
|
|
@@ -238,7 +244,7 @@ function drive(
|
|
|
238
244
|
env: NodeJS.ProcessEnv,
|
|
239
245
|
text: string | { error: string } | { status: number; stderr: string },
|
|
240
246
|
deps: Record<string, unknown> = {},
|
|
241
|
-
): { code: number | null; refusal: string | null; out: string; progress: string[] } {
|
|
247
|
+
): { code: number | null; refusal: string | null; detail: string; out: string; progress: string[] } {
|
|
242
248
|
let out = "";
|
|
243
249
|
// The operator's terminal is a SEAM here, never the real `/dev/tty`: a gate that narrated into
|
|
244
250
|
// whatever terminal happened to be running it would be writing outside its own sandbox.
|
|
@@ -269,11 +275,12 @@ function drive(
|
|
|
269
275
|
readRuntimeLock: () => FIXTURE_NPM_LOCK,
|
|
270
276
|
...deps,
|
|
271
277
|
});
|
|
272
|
-
return { code, refusal: null, out, progress: progressLines };
|
|
278
|
+
return { code, refusal: null, detail: "", out, progress: progressLines };
|
|
273
279
|
} catch (err) {
|
|
274
280
|
return {
|
|
275
281
|
code: null,
|
|
276
282
|
refusal: (err as { code?: string }).code ?? `not-a-HerdrBuildError:${String(err)}`,
|
|
283
|
+
detail: (err as { detail?: string }).detail ?? String(err),
|
|
277
284
|
out,
|
|
278
285
|
progress: progressLines,
|
|
279
286
|
};
|
|
@@ -727,6 +734,228 @@ function drive(
|
|
|
727
734
|
);
|
|
728
735
|
}
|
|
729
736
|
|
|
737
|
+
// ── 6c. the selected components' owners are asked before the first byte (#135) ─
|
|
738
|
+
/** Run one of the shipped Pi ownership writers against a sandbox, exactly as run.sh does. */
|
|
739
|
+
function piOwner(env: NodeJS.ProcessEnv, script: string, args: string[]): SpawnResult {
|
|
740
|
+
return spawnSync("python3", [path.join(REPO, "scripts", script), ...args], {
|
|
741
|
+
encoding: "utf8",
|
|
742
|
+
env,
|
|
743
|
+
}) as unknown as SpawnResult;
|
|
744
|
+
}
|
|
745
|
+
function piSettingsOf(env: NodeJS.ProcessEnv): string {
|
|
746
|
+
return path.join(env.HOME as string, ".pi", "agent", "settings.json");
|
|
747
|
+
}
|
|
748
|
+
function piStateOf(env: NodeJS.ProcessEnv, half: "package" | "provider"): string {
|
|
749
|
+
return path.join(env.XDG_DATA_HOME as string, "entwurf", `pi-${half}`, "install-state.json");
|
|
750
|
+
}
|
|
751
|
+
/** A LIVE foreign owner: a real directory standing for a developer checkout, registered for real. */
|
|
752
|
+
function foreignPiOwner(env: NodeJS.ProcessEnv, halves: ("package" | "provider")[]): string {
|
|
753
|
+
const checkout = path.join(env.HOME as string, "dev", "entwurf");
|
|
754
|
+
fs.mkdirSync(checkout, { recursive: true });
|
|
755
|
+
fs.mkdirSync(path.dirname(piSettingsOf(env)), { recursive: true });
|
|
756
|
+
fs.writeFileSync(piSettingsOf(env), '{"theme":"dark"}\n');
|
|
757
|
+
if (halves.includes("package")) {
|
|
758
|
+
const r = piOwner(env, "register-pi-package.py", [
|
|
759
|
+
piSettingsOf(env),
|
|
760
|
+
checkout,
|
|
761
|
+
"--scope",
|
|
762
|
+
"user",
|
|
763
|
+
"--state",
|
|
764
|
+
piStateOf(env, "package"),
|
|
765
|
+
]);
|
|
766
|
+
assert.equal(r.status, 0, `fixture: seeding the foreign package owner failed: ${r.stderr}`);
|
|
767
|
+
}
|
|
768
|
+
if (halves.includes("provider")) {
|
|
769
|
+
const r = piOwner(env, "register-pi-provider.py", [
|
|
770
|
+
"install",
|
|
771
|
+
piSettingsOf(env),
|
|
772
|
+
checkout,
|
|
773
|
+
"--scope",
|
|
774
|
+
"user",
|
|
775
|
+
"--state",
|
|
776
|
+
piStateOf(env, "provider"),
|
|
777
|
+
]);
|
|
778
|
+
assert.equal(r.status, 0, `fixture: seeding the foreign provider owner failed: ${r.stderr}`);
|
|
779
|
+
}
|
|
780
|
+
return checkout;
|
|
781
|
+
}
|
|
782
|
+
/** An acquisition that must never be reached: it counts, and it throws a sentinel instead of fetching. */
|
|
783
|
+
function countingSentinel(): { acquire: () => never; calls: () => number } {
|
|
784
|
+
let calls = 0;
|
|
785
|
+
return {
|
|
786
|
+
acquire: () => {
|
|
787
|
+
calls++;
|
|
788
|
+
throw new Error("HPB_OWNERSHIP_SENTINEL_NO_NETWORK");
|
|
789
|
+
},
|
|
790
|
+
calls: () => calls,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
{
|
|
794
|
+
const env = world("owner-package");
|
|
795
|
+
foreignPiOwner(env, ["package"]);
|
|
796
|
+
const before = treeDigest(env.HOME as string);
|
|
797
|
+
const sentinel = countingSentinel();
|
|
798
|
+
const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
|
|
799
|
+
acquire: sentinel.acquire,
|
|
800
|
+
resolveCommit: () => COMMIT_A,
|
|
801
|
+
});
|
|
802
|
+
const after = treeDigest(env.HOME as string);
|
|
803
|
+
ok(
|
|
804
|
+
"[QK:HPB-OWNERSHIP-BEFORE-ACQUISITION] a Pi user-scope registration owned by another LIVE root refuses the " +
|
|
805
|
+
"plugin install BEFORE anything is fetched or written: the SAME shipped package-owner preflight the installed " +
|
|
806
|
+
"activation runs is asked against the prospective plugin root, so the refusal names the component and the " +
|
|
807
|
+
"acquisition is never called — no installing journal, runtime, cache or ledger appears and every byte under " +
|
|
808
|
+
"the sandbox HOME is unchanged. The first cut asked only after bootstrap, so a known refusal still fetched and " +
|
|
809
|
+
`installed a runtime first (refusal=${r.refusal} acquired=${sentinel.calls()} home-unchanged=${before === after} ` +
|
|
810
|
+
`journal=${fs.existsSync(journalPathOf(env))} detail=${JSON.stringify(r.detail.slice(0, 160))})`,
|
|
811
|
+
r.refusal === "herdr-build-component-ownership-refused" &&
|
|
812
|
+
r.detail.includes("pi-package") &&
|
|
813
|
+
sentinel.calls() === 0 &&
|
|
814
|
+
before === after &&
|
|
815
|
+
!fs.existsSync(journalPathOf(env)),
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
{
|
|
819
|
+
// The provider key carries its OWN ownership record; a host whose package entry is free but whose
|
|
820
|
+
// provider key another root owns must refuse just as early.
|
|
821
|
+
const env = world("owner-provider");
|
|
822
|
+
foreignPiOwner(env, ["provider"]);
|
|
823
|
+
const before = treeDigest(env.HOME as string);
|
|
824
|
+
const sentinel = countingSentinel();
|
|
825
|
+
const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
|
|
826
|
+
acquire: sentinel.acquire,
|
|
827
|
+
resolveCommit: () => COMMIT_A,
|
|
828
|
+
});
|
|
829
|
+
ok(
|
|
830
|
+
"[QK:HPB-OWNERSHIP-PROVIDER-HALF] the provider half is asked on its own: a free package entry with a provider " +
|
|
831
|
+
"key another LIVE root owns still refuses before acquisition, names `pi-provider`, and leaves the sandbox " +
|
|
832
|
+
`HOME byte-identical (refusal=${r.refusal} acquired=${sentinel.calls()} home-unchanged=${before === treeDigest(env.HOME as string)} detail=${JSON.stringify(r.detail.slice(0, 120))})`,
|
|
833
|
+
r.refusal === "herdr-build-component-ownership-refused" &&
|
|
834
|
+
r.detail.includes("pi-provider") &&
|
|
835
|
+
!r.detail.includes("pi-package:") &&
|
|
836
|
+
sentinel.calls() === 0 &&
|
|
837
|
+
before === treeDigest(env.HOME as string),
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
{
|
|
841
|
+
// Claude's preflight is its PLAN check (documents and managed-key shapes), run against the
|
|
842
|
+
// prospective root in plugin mode. It is not a claim about which root owns Claude.
|
|
843
|
+
const env = world("owner-claude-plan");
|
|
844
|
+
fs.mkdirSync(path.join(env.HOME as string, ".claude"), { recursive: true });
|
|
845
|
+
fs.writeFileSync(path.join(env.HOME as string, ".claude", "settings.json"), "[]\n");
|
|
846
|
+
const before = treeDigest(env.HOME as string);
|
|
847
|
+
const sentinel = countingSentinel();
|
|
848
|
+
const r = drive(env, listing({ claude: "current (v2) (/home/u/.claude)" }), {
|
|
849
|
+
acquire: sentinel.acquire,
|
|
850
|
+
resolveCommit: () => COMMIT_A,
|
|
851
|
+
});
|
|
852
|
+
ok(
|
|
853
|
+
"[QK:HPB-OWNERSHIP-CLAUDE-PLAN] a selected Claude whose install plan its own state owner refuses (a settings " +
|
|
854
|
+
"file that is not a JSON object) refuses before acquisition, names `claude-code`, and writes nothing " +
|
|
855
|
+
`(refusal=${r.refusal} acquired=${sentinel.calls()} home-unchanged=${before === treeDigest(env.HOME as string)} detail=${JSON.stringify(r.detail.slice(0, 120))})`,
|
|
856
|
+
r.refusal === "herdr-build-component-ownership-refused" &&
|
|
857
|
+
r.detail.includes("claude-code") &&
|
|
858
|
+
sentinel.calls() === 0 &&
|
|
859
|
+
before === treeDigest(env.HOME as string),
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
{
|
|
863
|
+
// Only the SELECTED components are asked: a backend Herdr has not integrated cannot veto another's install.
|
|
864
|
+
const piOnly = world("owner-unselected-claude");
|
|
865
|
+
fs.mkdirSync(path.join(piOnly.HOME as string, ".claude"), { recursive: true });
|
|
866
|
+
fs.writeFileSync(path.join(piOnly.HOME as string, ".claude", "settings.json"), "[]\n");
|
|
867
|
+
const piLog = path.join(piOnly.HOME as string, "argv.log");
|
|
868
|
+
const piRun = drive(piOnly, listing({ pi: "current (v8) (/home/u/.pi)" }), {
|
|
869
|
+
acquire: fixtureAcquire({ activate: "recorder", log: piLog }),
|
|
870
|
+
resolveCommit: () => COMMIT_A,
|
|
871
|
+
});
|
|
872
|
+
const claudeOnly = world("owner-unselected-pi");
|
|
873
|
+
foreignPiOwner(claudeOnly, ["package", "provider"]);
|
|
874
|
+
const claudeLog = path.join(claudeOnly.HOME as string, "argv.log");
|
|
875
|
+
const claudeRun = drive(claudeOnly, listing({ claude: "current (v2) (/home/u/.claude)" }), {
|
|
876
|
+
acquire: fixtureAcquire({ activate: "recorder", log: claudeLog }),
|
|
877
|
+
resolveCommit: () => COMMIT_A,
|
|
878
|
+
});
|
|
879
|
+
const logged = (f: string) => (fs.existsSync(f) ? fs.readFileSync(f, "utf8").trim() : "");
|
|
880
|
+
ok(
|
|
881
|
+
"[QK:HPB-OWNERSHIP-SELECTED-ONLY] only the backends in `A` are asked: Pi alone installs over a Claude settings " +
|
|
882
|
+
"file its owner would refuse, and Claude alone installs over a Pi registration another root owns — an " +
|
|
883
|
+
"unselected backend's owner never vetoes the selected one " +
|
|
884
|
+
`(pi-only=${piRun.code}/${piRun.refusal}/${JSON.stringify(logged(piLog))} claude-only=${claudeRun.code}/${claudeRun.refusal}/${JSON.stringify(logged(claudeLog))})`,
|
|
885
|
+
piRun.code === 0 && logged(piLog) === "pi" && claudeRun.code === 0 && logged(claudeLog) === "claude-code",
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
{
|
|
889
|
+
// A SAME-owner reinstall under a symlinked XDG_DATA_HOME. The installed verb sees its own root in
|
|
890
|
+
// PHYSICAL form (Node realpaths the ESM entry) and the provider owner records that root without
|
|
891
|
+
// resolving it, so the early ask must use the physical form too, while the stable command stays the
|
|
892
|
+
// lexical runtime address.
|
|
893
|
+
const home = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-hpb-owner-symlinked-")));
|
|
894
|
+
const realData = path.join(home, "real-data");
|
|
895
|
+
fs.mkdirSync(realData, { recursive: true });
|
|
896
|
+
fs.symlinkSync(realData, path.join(home, "data"));
|
|
897
|
+
const env: NodeJS.ProcessEnv = {
|
|
898
|
+
HOME: home,
|
|
899
|
+
XDG_DATA_HOME: path.join(home, "data"),
|
|
900
|
+
XDG_CACHE_HOME: path.join(home, "cache"),
|
|
901
|
+
XDG_STATE_HOME: path.join(home, "state"),
|
|
902
|
+
PATH: process.env.PATH,
|
|
903
|
+
};
|
|
904
|
+
const physicalRoot = path.join(realData, "entwurf", "herdr-plugin", "runtime", "active", "node_modules", PACKAGE);
|
|
905
|
+
fs.mkdirSync(path.dirname(piSettingsOf(env)), { recursive: true });
|
|
906
|
+
fs.writeFileSync(piSettingsOf(env), '{"theme":"dark"}\n');
|
|
907
|
+
const pkg = piOwner(env, "register-pi-package.py", [
|
|
908
|
+
piSettingsOf(env),
|
|
909
|
+
physicalRoot,
|
|
910
|
+
"--scope",
|
|
911
|
+
"user",
|
|
912
|
+
"--state",
|
|
913
|
+
piStateOf(env, "package"),
|
|
914
|
+
]);
|
|
915
|
+
const prov = piOwner(env, "register-pi-provider.py", [
|
|
916
|
+
"install",
|
|
917
|
+
piSettingsOf(env),
|
|
918
|
+
physicalRoot,
|
|
919
|
+
"--scope",
|
|
920
|
+
"user",
|
|
921
|
+
"--state",
|
|
922
|
+
piStateOf(env, "provider"),
|
|
923
|
+
"--plugin-runtime",
|
|
924
|
+
activeDirOf(env),
|
|
925
|
+
]);
|
|
926
|
+
assert.equal(pkg.status, 0, `fixture: same-owner package seed failed: ${pkg.stderr}`);
|
|
927
|
+
assert.equal(prov.status, 0, `fixture: same-owner provider seed failed: ${prov.stderr}`);
|
|
928
|
+
const log = path.join(home, "argv.log");
|
|
929
|
+
const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
|
|
930
|
+
acquire: fixtureAcquire({ activate: "recorder", log }),
|
|
931
|
+
resolveCommit: () => COMMIT_A,
|
|
932
|
+
});
|
|
933
|
+
ok(
|
|
934
|
+
"[QK:HPB-OWNERSHIP-PHYSICAL-ROOT] a same-owner reinstall under a symlinked XDG_DATA_HOME is admitted, not " +
|
|
935
|
+
"mistaken for a foreign root: the early owner preflight asks with the PHYSICAL prospective package root " +
|
|
936
|
+
"(the form the installed verb records as its own) and passes the LEXICAL stable runtime as the plugin-mode " +
|
|
937
|
+
"runtime flag (the only form the provider owner accepts), so the build reaches activation " +
|
|
938
|
+
`(code=${r.code} refusal=${r.refusal} detail=${JSON.stringify(r.detail.slice(0, 160))})`,
|
|
939
|
+
r.code === 0 && fs.existsSync(log) && fs.readFileSync(log, "utf8").trim() === "pi",
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
{
|
|
943
|
+
// The owners were asked about one root; the installed verb must run from that same root.
|
|
944
|
+
const env = world("owner-root-tie");
|
|
945
|
+
const log = path.join(env.HOME as string, "argv.log");
|
|
946
|
+
const r = drive(env, listing({ pi: "current (v8) (/home/u/.pi)" }), {
|
|
947
|
+
acquire: fixtureAcquire({ activate: "recorder", log, name: "@elsewhere/entwurf" }),
|
|
948
|
+
readRuntimeLock: () => FIXTURE_CHECKOUT_LOCK,
|
|
949
|
+
resolveCommit: () => COMMIT_A,
|
|
950
|
+
});
|
|
951
|
+
ok(
|
|
952
|
+
"[QK:HPB-PLANNED-ROOT-TIE] the root the component owners were asked about is the root the installed verb runs " +
|
|
953
|
+
"from: an artifact that lands under another package name is refused by name before activation, so the early " +
|
|
954
|
+
`answer can never have been about a different root (refusal=${r.refusal} activated=${fs.existsSync(log)} detail=${JSON.stringify(r.detail.slice(0, 160))})`,
|
|
955
|
+
r.refusal === "herdr-build-artifact-drifted" && !fs.existsSync(log),
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
730
959
|
// ── 7. the runner claims nothing about Herdr's own commit ──────────────────────
|
|
731
960
|
{
|
|
732
961
|
const env = world("post-build-gap");
|
|
@@ -1098,7 +1098,7 @@ console.log(
|
|
|
1098
1098
|
"a run.sh wrapper declining its own prerequisite (including the measured LIVE=1 no-cortex-connection cell); and every " +
|
|
1099
1099
|
"LIVE smoke is either wired into release_gate or excluded by a sentence the docs still carry; and the moved " +
|
|
1100
1100
|
"check-gate-qualification stays reachable on its owners (absent from the default chain, exactly once in CI, " +
|
|
1101
|
-
"
|
|
1101
|
+
"absent from release_gate: the one body is the prepared SHA's CI step) and the CI step qualifies the FULL floor, which runs before it, " +
|
|
1102
1102
|
"while the exact-SHA release oracle requires that BODY step to have concluded success at the release SHA, " +
|
|
1103
1103
|
"and the CI filter that decides when that body runs covers every mutant subject and still runs it for all " +
|
|
1104
1104
|
"five historical reds; and " +
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* Inputs, and nothing else: the pinned commit's OBJECTS (each declared file and the license, read as
|
|
11
11
|
* `<commit>:<path>` — never a worktree, so the repository's checkout state cannot enter), the packaged
|
|
12
12
|
* overlay patch, and this Node's `stripTypeScriptTypes` in strip mode (upstream runs these exact
|
|
13
|
-
* sources under Node's strip-only TypeScript). The patch
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* sources under Node's strip-only TypeScript). The one patch carries exactly one section per file the
|
|
14
|
+
* pin declares `patched`, and no other section (#134). Each section is applied by git's own engine to a
|
|
15
|
+
* private scratch copy of its file; the base blob and the patched result must carry the blob ids that
|
|
16
|
+
* section's `index` line names. Each stripped file is then relocated by pi/pi-durable/carrier-relocation.mjs, and the targets
|
|
16
17
|
* the six files import must equal the pin's declared `distTargets` exactly.
|
|
17
18
|
*
|
|
18
19
|
* `--write` emits pi/pi-durable/carrier/{<files>,LICENSE} and fills the pin's generated fields
|
|
@@ -118,22 +119,60 @@ function readPin(): CarrierPin {
|
|
|
118
119
|
`the emitter is ${EMITTER_TOOL} in ${EMITTER_MODE} mode, not ${JSON.stringify(pin.carrier.emitter)}`,
|
|
119
120
|
);
|
|
120
121
|
}
|
|
121
|
-
if (pin.carrier.files.
|
|
122
|
-
throw refuse("carrier-pin-malformed", "
|
|
122
|
+
if (!pin.carrier.files.some((f) => f.patched)) {
|
|
123
|
+
throw refuse("carrier-pin-malformed", "at least one carrier file carries the overlay patch");
|
|
123
124
|
}
|
|
124
125
|
return pin;
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
128
|
+
/** One file's section of the overlay patch: its own bytes and the two blob ids its `index` line names. */
|
|
129
|
+
export interface PatchSection {
|
|
130
|
+
bytes: Buffer;
|
|
131
|
+
base: string;
|
|
132
|
+
result: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The overlay patch split into its per-file sections, keyed by the existing file each one changes, and
|
|
137
|
+
* matched against the source paths the pin declares `patched`: exactly one section per declared file and
|
|
138
|
+
* no section for any other file. Anything else is the patch's shape, refused by name.
|
|
139
|
+
*/
|
|
140
|
+
export function patchSections(
|
|
141
|
+
text: string,
|
|
142
|
+
patched: readonly string[],
|
|
143
|
+
name = "the overlay patch",
|
|
144
|
+
): Map<string, PatchSection> {
|
|
145
|
+
const starts = [...text.matchAll(/^diff --git /gm)].map((m) => m.index);
|
|
146
|
+
if (starts.length === 0 || starts[0] !== 0) {
|
|
147
|
+
throw refuse("carrier-patch-shape", `${name} must start with its first file section`);
|
|
148
|
+
}
|
|
149
|
+
const sections = new Map<string, PatchSection>();
|
|
150
|
+
for (const [at, start] of starts.entries()) {
|
|
151
|
+
const chunk = text.slice(start, starts[at + 1] ?? text.length);
|
|
152
|
+
const files = [...chunk.matchAll(/^diff --git a\/(\S+) b\/(\S+)$/gm)];
|
|
153
|
+
const index = [...chunk.matchAll(/^index ([0-9a-f]+)\.\.([0-9a-f]+) 100644$/gm)];
|
|
154
|
+
if (files.length !== 1 || files[0][1] !== files[0][2] || index.length !== 1) {
|
|
155
|
+
throw refuse("carrier-patch-shape", `every section of ${name} changes exactly one existing file`);
|
|
156
|
+
}
|
|
157
|
+
const file = files[0][1];
|
|
158
|
+
if (sections.has(file)) throw refuse("carrier-patch-shape", `${name} changes ${file} in two sections`);
|
|
159
|
+
sections.set(file, { bytes: Buffer.from(chunk, "utf8"), base: index[0][1], result: index[0][2] });
|
|
135
160
|
}
|
|
136
|
-
|
|
161
|
+
const undeclared = [...sections.keys()].filter((file) => !patched.includes(file));
|
|
162
|
+
const missing = patched.filter((file) => !sections.has(file));
|
|
163
|
+
if (undeclared.length > 0 || missing.length > 0) {
|
|
164
|
+
throw refuse(
|
|
165
|
+
"carrier-patch-shape",
|
|
166
|
+
`${name} must change exactly the files the pin declares patched (undeclared: ${undeclared.join(", ") || "none"}; missing: ${missing.join(", ") || "none"})`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return sections;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readPatch(pin: CarrierPin): Map<string, PatchSection> {
|
|
173
|
+
const text = fs.readFileSync(path.join(DURABLE, "overlay", pin.patches[0]), "utf8");
|
|
174
|
+
const patched = pin.carrier.files.filter((f) => f.patched).map((f) => f.source);
|
|
175
|
+
return patchSections(text, patched, pin.patches[0]);
|
|
137
176
|
}
|
|
138
177
|
|
|
139
178
|
/** `base` with the patch applied by git, in a private scratch tree outside any repository. */
|
|
@@ -172,7 +211,7 @@ function emit(source: string, pin: CarrierPin): Emission {
|
|
|
172
211
|
if (git(source, ["cat-file", "-t", pin.commit]).toString().trim() !== "commit") {
|
|
173
212
|
throw refuse("carrier-source-unreadable", `${pin.commit} is not a commit in ${source}`);
|
|
174
213
|
}
|
|
175
|
-
const
|
|
214
|
+
const sections = readPatch(pin);
|
|
176
215
|
const names = pin.carrier.files.map((f) => f.name);
|
|
177
216
|
const targets = pin.carrier.distTargets;
|
|
178
217
|
if (new Set(targets).size !== targets.length) throw refuse("carrier-pin-malformed", "distTargets repeats a path");
|
|
@@ -183,18 +222,19 @@ function emit(source: string, pin: CarrierPin): Emission {
|
|
|
183
222
|
const original = readSourceObject(source, `${pin.commit}:${declared.source}`);
|
|
184
223
|
const blob = blobId(original);
|
|
185
224
|
let text = original;
|
|
186
|
-
|
|
187
|
-
|
|
225
|
+
const section = declared.patched ? sections.get(declared.source) : undefined;
|
|
226
|
+
if (section !== undefined) {
|
|
227
|
+
if (!blob.startsWith(section.base)) {
|
|
188
228
|
throw refuse(
|
|
189
229
|
"carrier-patch-base",
|
|
190
|
-
`the patch changes ${
|
|
230
|
+
`the patch changes ${declared.source} from ${section.base}; the pinned object is ${blob}`,
|
|
191
231
|
);
|
|
192
232
|
}
|
|
193
|
-
text = applyPatch(declared.source, original,
|
|
194
|
-
if (!blobId(text).startsWith(
|
|
233
|
+
text = applyPatch(declared.source, original, section.bytes);
|
|
234
|
+
if (!blobId(text).startsWith(section.result)) {
|
|
195
235
|
throw refuse(
|
|
196
236
|
"carrier-patch-result",
|
|
197
|
-
`patched ${declared.source} is ${blobId(text)}, the patch names ${
|
|
237
|
+
`patched ${declared.source} is ${blobId(text)}, the patch names ${section.result}`,
|
|
198
238
|
);
|
|
199
239
|
}
|
|
200
240
|
}
|
|
@@ -155,6 +155,22 @@ export function writeLedger(layout, entry) {
|
|
|
155
155
|
* host it was never describing.
|
|
156
156
|
*/
|
|
157
157
|
export function readCertifiedLedger(layout) {
|
|
158
|
+
// lstat FIRST (#135). `readFileSync` follows links, so a dangling symlink at the ledger address
|
|
159
|
+
// read as ENOENT — "no ledger" — and every verb then acted as if nothing had ever been activated.
|
|
160
|
+
// Only a regular file, or nothing at all, may stand there.
|
|
161
|
+
let kind;
|
|
162
|
+
try {
|
|
163
|
+
kind = fs.lstatSync(layout.ledgerPath, { throwIfNoEntry: false });
|
|
164
|
+
} catch (err) {
|
|
165
|
+
throw new ActivationError("activation-ledger-uncertified", `${layout.ledgerPath}: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
if (kind === undefined) return null;
|
|
168
|
+
if (!kind.isFile()) {
|
|
169
|
+
throw new ActivationError(
|
|
170
|
+
"activation-ledger-uncertified",
|
|
171
|
+
`${layout.ledgerPath} is ${kind.isSymbolicLink() ? "a symlink" : kind.isDirectory() ? "a directory" : "not a regular file"}; only a regular file or nothing may stand at the ledger address`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
158
174
|
let raw;
|
|
159
175
|
try {
|
|
160
176
|
raw = fs.readFileSync(layout.ledgerPath, "utf8");
|
|
@@ -33,11 +33,11 @@ import {
|
|
|
33
33
|
certifyRootsAgainstLedger,
|
|
34
34
|
componentStates,
|
|
35
35
|
ledgerBody,
|
|
36
|
+
piStatePaths,
|
|
36
37
|
planActivation,
|
|
37
38
|
readCertifiedLedger,
|
|
38
39
|
resolveActivationLayout,
|
|
39
40
|
resolveComponentRoots,
|
|
40
|
-
resolveEntwurfDataRoot,
|
|
41
41
|
writeLedger,
|
|
42
42
|
} from "./herdr-activation.mjs";
|
|
43
43
|
import {
|
|
@@ -49,8 +49,109 @@ import {
|
|
|
49
49
|
|
|
50
50
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
51
51
|
|
|
52
|
-
function run(command, args, env) {
|
|
53
|
-
return
|
|
52
|
+
function run(command, args, env, spawn = spawnSync) {
|
|
53
|
+
return spawn(command, args, { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ask every selected component's OWN ownership authority, read-only, whether a write for
|
|
58
|
+
* `packageRoot` would be admitted. Returns the refused `[name, result]` pairs; writes nothing.
|
|
59
|
+
*
|
|
60
|
+
* TWO CALL POSITIONS, ONE LIST (#135). The installed verb below asks it about itself, after the
|
|
61
|
+
* runtime exists. The Herdr build asks it BEFORE any byte of runtime work, with the checkout's copy
|
|
62
|
+
* of the same owners and the PROSPECTIVE installed root — so an owner the late check would refuse
|
|
63
|
+
* is refused while nothing has been fetched. `scriptRoot` is whose owner scripts run; `packageRoot`
|
|
64
|
+
* is the root they judge. The prospective root must be in the form this verb will later see as its
|
|
65
|
+
* own PACKAGE_ROOT (physical: Node realpaths the ESM entry), because the provider owner records the
|
|
66
|
+
* root without resolving it; `runtimeRoot` stays the lexical stable address the commands are
|
|
67
|
+
* derived from.
|
|
68
|
+
*
|
|
69
|
+
* Claude's preflight is its document/plan-shape check. It does not judge which root owns Claude.
|
|
70
|
+
*/
|
|
71
|
+
export function preflightSelectedComponents(
|
|
72
|
+
env,
|
|
73
|
+
{ scriptRoot, packageRoot, runtimeRoot, selected, spawn = spawnSync },
|
|
74
|
+
) {
|
|
75
|
+
const preflights = [];
|
|
76
|
+
if (selected.includes("claude-code")) {
|
|
77
|
+
preflights.push([
|
|
78
|
+
"claude-code",
|
|
79
|
+
// The mode rides the ARGV, because that is what the state owner reads. The env var is the
|
|
80
|
+
// installer script's channel; passing only that preflights the DEFAULT mode and then
|
|
81
|
+
// installs the plugin one — a green check of work nobody is going to do.
|
|
82
|
+
run(
|
|
83
|
+
"python3",
|
|
84
|
+
[
|
|
85
|
+
path.join(scriptRoot, "scripts", "meta-bridge-state.py"),
|
|
86
|
+
"preflight-install",
|
|
87
|
+
"--repo",
|
|
88
|
+
packageRoot,
|
|
89
|
+
"--plugin-runtime",
|
|
90
|
+
runtimeRoot,
|
|
91
|
+
],
|
|
92
|
+
{ ...env, ENTWURF_PLUGIN_RUNTIME: runtimeRoot },
|
|
93
|
+
spawn,
|
|
94
|
+
),
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
if (selected.includes("pi")) {
|
|
98
|
+
const agentSettings = path.join(resolveComponentRoots(env).piAgentDir.path, "settings.json");
|
|
99
|
+
// ONE resolution for both owner records, the one their writers use (`run.sh` `${XDG_DATA_HOME:-…}`).
|
|
100
|
+
const piState = piStatePaths(env);
|
|
101
|
+
// BOTH halves. The user-scope citizen is a package registration and a provider key with
|
|
102
|
+
// SEPARATE ownership records, and a foreign or corrupt package state refuses independently —
|
|
103
|
+
// preflighting only the provider would let the first ledger write land on a host the package
|
|
104
|
+
// writer was always going to refuse.
|
|
105
|
+
preflights.push([
|
|
106
|
+
"pi-package",
|
|
107
|
+
run(
|
|
108
|
+
"python3",
|
|
109
|
+
[
|
|
110
|
+
path.join(scriptRoot, "scripts", "register-pi-package.py"),
|
|
111
|
+
agentSettings,
|
|
112
|
+
packageRoot,
|
|
113
|
+
"--scope",
|
|
114
|
+
"user",
|
|
115
|
+
"--state",
|
|
116
|
+
piState.packageState,
|
|
117
|
+
"--preflight",
|
|
118
|
+
],
|
|
119
|
+
env,
|
|
120
|
+
spawn,
|
|
121
|
+
),
|
|
122
|
+
]);
|
|
123
|
+
preflights.push([
|
|
124
|
+
"pi-provider",
|
|
125
|
+
run(
|
|
126
|
+
"python3",
|
|
127
|
+
[
|
|
128
|
+
path.join(scriptRoot, "scripts", "register-pi-provider.py"),
|
|
129
|
+
"install",
|
|
130
|
+
agentSettings,
|
|
131
|
+
packageRoot,
|
|
132
|
+
"--scope",
|
|
133
|
+
"user",
|
|
134
|
+
"--state",
|
|
135
|
+
piState.providerState,
|
|
136
|
+
"--plugin-runtime",
|
|
137
|
+
runtimeRoot,
|
|
138
|
+
"--preflight",
|
|
139
|
+
],
|
|
140
|
+
env,
|
|
141
|
+
spawn,
|
|
142
|
+
),
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
return preflights.filter(([, r]) => r.status !== 0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The owner's own last line for each refused component, for the operator to act on. */
|
|
149
|
+
export function describeRefusedComponents(refused) {
|
|
150
|
+
return refused
|
|
151
|
+
.map(
|
|
152
|
+
([name, r]) => `${name}: ${(r.stderr || r.stdout || (r.error ? r.error.message : "")).trim().split("\n").pop()}`,
|
|
153
|
+
)
|
|
154
|
+
.join("; ");
|
|
54
155
|
}
|
|
55
156
|
|
|
56
157
|
/** The only input: a subset of P, parsed from argv and refused if it is anything else. */
|
|
@@ -113,79 +214,14 @@ export function activate(env, requested) {
|
|
|
113
214
|
const plan = planActivation({ ledger, requested });
|
|
114
215
|
const selected = [...plan.reconcile, ...plan.add];
|
|
115
216
|
|
|
116
|
-
// 3. preflight EVERY selected component before the first byte
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
run(
|
|
125
|
-
"python3",
|
|
126
|
-
[
|
|
127
|
-
path.join(PACKAGE_ROOT, "scripts", "meta-bridge-state.py"),
|
|
128
|
-
"preflight-install",
|
|
129
|
-
"--repo",
|
|
130
|
-
PACKAGE_ROOT,
|
|
131
|
-
"--plugin-runtime",
|
|
132
|
-
runtime.activeDir,
|
|
133
|
-
],
|
|
134
|
-
{ ...env, ENTWURF_PLUGIN_RUNTIME: runtime.activeDir },
|
|
135
|
-
),
|
|
136
|
-
]);
|
|
137
|
-
}
|
|
138
|
-
if (selected.includes("pi")) {
|
|
139
|
-
const agentSettings = path.join(roots.piAgentDir.path, "settings.json");
|
|
140
|
-
const dataRoot = resolveEntwurfDataRoot(env);
|
|
141
|
-
// BOTH halves. The user-scope citizen is a package registration and a provider key with
|
|
142
|
-
// SEPARATE ownership records, and a foreign or corrupt package state refuses independently —
|
|
143
|
-
// preflighting only the provider would let the first ledger write land on a host the package
|
|
144
|
-
// writer was always going to refuse.
|
|
145
|
-
preflights.push([
|
|
146
|
-
"pi-package",
|
|
147
|
-
run(
|
|
148
|
-
"python3",
|
|
149
|
-
[
|
|
150
|
-
path.join(PACKAGE_ROOT, "scripts", "register-pi-package.py"),
|
|
151
|
-
agentSettings,
|
|
152
|
-
PACKAGE_ROOT,
|
|
153
|
-
"--scope",
|
|
154
|
-
"user",
|
|
155
|
-
"--state",
|
|
156
|
-
path.join(dataRoot, "pi-package", "install-state.json"),
|
|
157
|
-
"--preflight",
|
|
158
|
-
],
|
|
159
|
-
env,
|
|
160
|
-
),
|
|
161
|
-
]);
|
|
162
|
-
preflights.push([
|
|
163
|
-
"pi-provider",
|
|
164
|
-
run(
|
|
165
|
-
"python3",
|
|
166
|
-
[
|
|
167
|
-
path.join(PACKAGE_ROOT, "scripts", "register-pi-provider.py"),
|
|
168
|
-
"install",
|
|
169
|
-
agentSettings,
|
|
170
|
-
PACKAGE_ROOT,
|
|
171
|
-
"--scope",
|
|
172
|
-
"user",
|
|
173
|
-
"--state",
|
|
174
|
-
path.join(
|
|
175
|
-
env.XDG_DATA_HOME ?? path.join(env.HOME, ".local", "share"),
|
|
176
|
-
"entwurf",
|
|
177
|
-
"pi-provider",
|
|
178
|
-
"install-state.json",
|
|
179
|
-
),
|
|
180
|
-
"--plugin-runtime",
|
|
181
|
-
runtime.activeDir,
|
|
182
|
-
"--preflight",
|
|
183
|
-
],
|
|
184
|
-
env,
|
|
185
|
-
),
|
|
186
|
-
]);
|
|
187
|
-
}
|
|
188
|
-
const refused = preflights.filter(([, r]) => r.status !== 0);
|
|
217
|
+
// 3. preflight EVERY selected component before the first byte — the same list the Herdr build
|
|
218
|
+
// already asked about the prospective root; this is the installed recheck.
|
|
219
|
+
const refused = preflightSelectedComponents(env, {
|
|
220
|
+
scriptRoot: PACKAGE_ROOT,
|
|
221
|
+
packageRoot: PACKAGE_ROOT,
|
|
222
|
+
runtimeRoot: runtime.activeDir,
|
|
223
|
+
selected,
|
|
224
|
+
});
|
|
189
225
|
if (refused.length > 0) {
|
|
190
226
|
for (const [name, r] of refused) {
|
|
191
227
|
process.stderr.write(` ${name}: ${(r.stderr || r.stdout || "").trim().split("\n").pop()}\n`);
|