@junghanacs/entwurf 0.22.0 → 0.23.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 -1
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +514 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +47 -0
- package/docs/acp-backend-rail.md +96 -17
- package/docs/setup-clean-host.md +11 -1
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -41
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +9 -8
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +4 -4
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +95 -301
- package/run.sh +588 -204
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +17 -4
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-pi-launch.ts +358 -0
- package/scripts/check-release-gate-outcomes.ts +19 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-caller-seat.json +6 -8
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +104 -17
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-herdr-raw-install-live.sh +849 -0
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -385
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- package/scripts/check-socket-discovery.ts +0 -401
|
@@ -0,0 +1,1293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-herdr-activation — deterministic gate for the #116 M3-b2 scoped activation
|
|
3
|
+
* (`scripts/herdr-activation.mjs`, `scripts/herdr-plugin-deactivate.mjs`, and the explicit plugin
|
|
4
|
+
* modes in the Pi provider writer and the Claude state owner).
|
|
5
|
+
*
|
|
6
|
+
* WHAT IS REAL HERE AND WHAT STANDS IN. The Pi cells drive the REAL writers — `run.sh
|
|
7
|
+
* install-user-scope`, `register-pi-provider.py`, `remove-user-scope` — against a sandboxed HOME,
|
|
8
|
+
* XDG and `PI_CODING_AGENT_DIR`, so the bytes asserted are bytes those writers actually produced.
|
|
9
|
+
* The Claude cells drive the REAL state owner's pure surfaces (`desired-mcp`,
|
|
10
|
+
* `desired-statusline`, `preflight-install`, `preflight-uninstall`); the vendor `claude` CLI is NOT
|
|
11
|
+
* invoked and its acceptance of an absolute executable stays a named LIVE boundary. Nothing in this
|
|
12
|
+
* file claims the vendor result.
|
|
13
|
+
*
|
|
14
|
+
* WHY THE ORACLE IS JSON EQUALITY, NOT BYTES. Measured 2026-09-16: the pi settings writer preserves
|
|
15
|
+
* semantics and unrelated keys but may reindent. "Byte-identical" would therefore fail a correct
|
|
16
|
+
* roundtrip and pass nothing extra — the honest oracle is JSON equality plus unrelated-key survival
|
|
17
|
+
* plus zero install-state.
|
|
18
|
+
*
|
|
19
|
+
* Each [QK:HAC-*] token appears exactly once, and the cells are ordered so a mutant of one claim
|
|
20
|
+
* cannot trip an earlier claim's assertion first.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import assert from "node:assert/strict";
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { pathToFileURL } from "node:url";
|
|
29
|
+
import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
|
|
30
|
+
|
|
31
|
+
let passed = 0;
|
|
32
|
+
function ok(label: string, cond: boolean): void {
|
|
33
|
+
assert.ok(cond, label);
|
|
34
|
+
console.log(` ok ${label}`);
|
|
35
|
+
passed++;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const REPO = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
39
|
+
const ACTIVATION = path.join(REPO, "scripts", "herdr-activation.mjs");
|
|
40
|
+
const DEACTIVATE = path.join(REPO, "scripts", "herdr-plugin-deactivate.mjs");
|
|
41
|
+
const ACTIVATE = path.join(REPO, "scripts", "herdr-plugin-activate.mjs");
|
|
42
|
+
const COMPILED_ENTRY = path.join("mcp", "entwurf-bridge", "dist", "mcp", "entwurf-bridge", "src", "index.js");
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A runtime at the stable address that the runtime owner certifies: a package tree, the compiled
|
|
46
|
+
* entry, three executable bins, and a `runtime-ready` journal. The activation verb refuses without
|
|
47
|
+
* one, and it should — activating against an address nothing put a runtime at is the failure this
|
|
48
|
+
* whole lane exists to make impossible.
|
|
49
|
+
*/
|
|
50
|
+
const PACKAGE = "@junghanacs/entwurf";
|
|
51
|
+
/**
|
|
52
|
+
* Three artifact identities in the ONE union the runtime journal and this ledger share: two
|
|
53
|
+
* checkout-sourced commits of the SAME package version (which is the whole point — a version cannot
|
|
54
|
+
* tell them apart) and one npm-sourced spec, for the source-drift cell.
|
|
55
|
+
*/
|
|
56
|
+
const IDENTITY_A = {
|
|
57
|
+
kind: "herdr-checkout",
|
|
58
|
+
repository: "junghan0611/entwurf",
|
|
59
|
+
commit: "a".repeat(40),
|
|
60
|
+
packageName: PACKAGE,
|
|
61
|
+
packageVersion: "0.21.0",
|
|
62
|
+
observedDigest: `sha256-${"a".repeat(64)}`,
|
|
63
|
+
};
|
|
64
|
+
const IDENTITY_B = { ...IDENTITY_A, commit: "b".repeat(40), observedDigest: `sha256-${"b".repeat(64)}` };
|
|
65
|
+
const IDENTITY_NPM = {
|
|
66
|
+
kind: "npm",
|
|
67
|
+
name: PACKAGE,
|
|
68
|
+
version: "0.21.0",
|
|
69
|
+
expectedIntegrity: "sha512-fixture",
|
|
70
|
+
observedDigest: `sha256-${"c".repeat(64)}`,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function seedRuntime(env: NodeJS.ProcessEnv, identity: Record<string, unknown> = IDENTITY_A): string {
|
|
74
|
+
const active = runtimeRootOf(env);
|
|
75
|
+
const pkg = path.join(active, "node_modules", "@junghanacs", "entwurf");
|
|
76
|
+
fs.mkdirSync(path.join(pkg, path.dirname(COMPILED_ENTRY)), { recursive: true });
|
|
77
|
+
fs.writeFileSync(
|
|
78
|
+
path.join(pkg, "package.json"),
|
|
79
|
+
`${JSON.stringify({ name: "@junghanacs/entwurf", version: "0.21.0" })}\n`,
|
|
80
|
+
);
|
|
81
|
+
fs.writeFileSync(path.join(pkg, COMPILED_ENTRY), "// compiled entry fixture\n");
|
|
82
|
+
const bin = path.join(active, "node_modules", ".bin");
|
|
83
|
+
fs.mkdirSync(bin, { recursive: true });
|
|
84
|
+
for (const name of ["entwurf", "entwurf-bridge", "entwurf-statusline"]) {
|
|
85
|
+
fs.writeFileSync(path.join(bin, name), "#!/bin/sh\nexit 0\n");
|
|
86
|
+
fs.chmodSync(path.join(bin, name), 0o755);
|
|
87
|
+
}
|
|
88
|
+
fs.writeFileSync(
|
|
89
|
+
path.join(env.XDG_DATA_HOME as string, "entwurf", "herdr-plugin", "journal.json"),
|
|
90
|
+
`${JSON.stringify({
|
|
91
|
+
schemaVersion: 2,
|
|
92
|
+
phase: "runtime-ready",
|
|
93
|
+
runtimeRoot: active,
|
|
94
|
+
artifactIdentity: identity,
|
|
95
|
+
previousRuntime: null,
|
|
96
|
+
})}\n`,
|
|
97
|
+
);
|
|
98
|
+
return active;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A faithful-enough `claude` for the installer's OWN calls. It stands in for the vendor CLI's
|
|
103
|
+
* INTERFACE, never for its judgement: whether the real Claude Code accepts an absolute executable
|
|
104
|
+
* as an MCP command is a LIVE question and nothing in this file answers it.
|
|
105
|
+
*/
|
|
106
|
+
function fakeClaude(env: NodeJS.ProcessEnv): string {
|
|
107
|
+
const dir = path.join(env.HOME as string, "fake-claude-bin");
|
|
108
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
109
|
+
fs.writeFileSync(
|
|
110
|
+
path.join(dir, "claude"),
|
|
111
|
+
[
|
|
112
|
+
"#!/usr/bin/env bash",
|
|
113
|
+
'printf \'%s\\n\' "$*" >> "${FAKE_CLAUDE_LOG:?}"',
|
|
114
|
+
'case "$1${2:+ $2}" in',
|
|
115
|
+
' "--version") echo "2.1.217 (Claude Code)" ;;',
|
|
116
|
+
' "plugin validate") : ;;',
|
|
117
|
+
' "plugin uninstall") : ;;',
|
|
118
|
+
' "plugin marketplace") : ;;',
|
|
119
|
+
' "plugin install") : ;;',
|
|
120
|
+
' "plugin list") printf \'%s\\n\' "entwurf-meta-receive@meta-bridge-local" " Status: enabled" ;;',
|
|
121
|
+
' "mcp remove") : ;;',
|
|
122
|
+
' "mcp add") : ;;',
|
|
123
|
+
' "mcp get") printf \'%s\\n\' "Scope: User config" "Status: ✔ Connected" ;;',
|
|
124
|
+
" *) : ;;",
|
|
125
|
+
"esac",
|
|
126
|
+
"exit 0",
|
|
127
|
+
].join("\n"),
|
|
128
|
+
);
|
|
129
|
+
fs.chmodSync(path.join(dir, "claude"), 0o755);
|
|
130
|
+
return dir;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface Root {
|
|
134
|
+
path: string;
|
|
135
|
+
source: string;
|
|
136
|
+
}
|
|
137
|
+
interface Ledger {
|
|
138
|
+
schemaVersion: number;
|
|
139
|
+
phase: string;
|
|
140
|
+
runtimeRoot: string;
|
|
141
|
+
artifactIdentity: Record<string, string>;
|
|
142
|
+
piAgentDir: Root;
|
|
143
|
+
claudeConfigDir: Root;
|
|
144
|
+
claudeUserConfig: Root;
|
|
145
|
+
activatedBackends: string[];
|
|
146
|
+
components: { backend: string; state: string }[];
|
|
147
|
+
}
|
|
148
|
+
interface Layout {
|
|
149
|
+
stateRoot: string;
|
|
150
|
+
ledgerPath: string;
|
|
151
|
+
}
|
|
152
|
+
interface Mod {
|
|
153
|
+
ACTIVATABLE_BACKENDS: string[];
|
|
154
|
+
LEDGER_KEYS: string[];
|
|
155
|
+
resolveActivationLayout: (env: NodeJS.ProcessEnv) => Layout;
|
|
156
|
+
resolveComponentRoots: (env: NodeJS.ProcessEnv) => {
|
|
157
|
+
piAgentDir: Root;
|
|
158
|
+
claudeConfigDir: Root;
|
|
159
|
+
claudeUserConfig: Root;
|
|
160
|
+
};
|
|
161
|
+
readCertifiedLedger: (layout: Layout) => Ledger | null;
|
|
162
|
+
writeLedger: (layout: Layout, entry: Record<string, unknown>) => void;
|
|
163
|
+
certifyRootsAgainstLedger: (ledger: Ledger | null, roots: unknown, runtimeRoot: string) => void;
|
|
164
|
+
planActivation: (args: { ledger: Ledger | null; requested: string[] }) => {
|
|
165
|
+
reconcile: string[];
|
|
166
|
+
add: string[];
|
|
167
|
+
retained: string[];
|
|
168
|
+
resulting: string[];
|
|
169
|
+
};
|
|
170
|
+
planDeactivation: (ledger: Ledger | null) => string[];
|
|
171
|
+
certifyPhaseForForward: (ledger: Ledger | null, requested: string[]) => void;
|
|
172
|
+
certifyPhaseForInverse: (ledger: Ledger | null) => void;
|
|
173
|
+
ledgerBody: (args: {
|
|
174
|
+
phase: string;
|
|
175
|
+
runtimeRoot: string;
|
|
176
|
+
artifactIdentity: Record<string, unknown>;
|
|
177
|
+
roots: { piAgentDir: Root; claudeConfigDir: Root; claudeUserConfig: Root };
|
|
178
|
+
states: Record<string, string>;
|
|
179
|
+
}) => Record<string, unknown>;
|
|
180
|
+
certifyArtifactForForward: (ledger: Ledger | null, current: Record<string, unknown>, requested: string[]) => string;
|
|
181
|
+
piStatePaths: (env: NodeJS.ProcessEnv) => { packageState: string; providerState: string };
|
|
182
|
+
resolveEntwurfDataRoot: (env: NodeJS.ProcessEnv) => string;
|
|
183
|
+
LEDGER_PHASES: string[];
|
|
184
|
+
COMPONENT_STATES: string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const mod = (await import(pathToFileURL(ACTIVATION).href)) as Mod;
|
|
188
|
+
const {
|
|
189
|
+
ACTIVATABLE_BACKENDS,
|
|
190
|
+
LEDGER_KEYS,
|
|
191
|
+
resolveActivationLayout,
|
|
192
|
+
resolveComponentRoots,
|
|
193
|
+
readCertifiedLedger,
|
|
194
|
+
writeLedger,
|
|
195
|
+
certifyRootsAgainstLedger,
|
|
196
|
+
planActivation,
|
|
197
|
+
planDeactivation,
|
|
198
|
+
certifyPhaseForForward,
|
|
199
|
+
certifyPhaseForInverse,
|
|
200
|
+
certifyArtifactForForward,
|
|
201
|
+
ledgerBody,
|
|
202
|
+
piStatePaths,
|
|
203
|
+
resolveEntwurfDataRoot,
|
|
204
|
+
LEDGER_PHASES,
|
|
205
|
+
COMPONENT_STATES,
|
|
206
|
+
} = mod;
|
|
207
|
+
|
|
208
|
+
function world(tag: string): NodeJS.ProcessEnv {
|
|
209
|
+
const home = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), `entwurf-hac-${tag}-`)));
|
|
210
|
+
for (const d of [".config", ".state", ".data", ".cache", "pi-agent", ".claude"]) {
|
|
211
|
+
fs.mkdirSync(path.join(home, d), { recursive: true });
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
PATH: process.env.PATH,
|
|
215
|
+
HOME: home,
|
|
216
|
+
TERM: "dumb",
|
|
217
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
218
|
+
XDG_STATE_HOME: path.join(home, ".state"),
|
|
219
|
+
XDG_DATA_HOME: path.join(home, ".data"),
|
|
220
|
+
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
221
|
+
PI_CODING_AGENT_DIR: path.join(home, "pi-agent"),
|
|
222
|
+
CLAUDE_CONFIG_DIR: path.join(home, ".claude"),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function sh(env: NodeJS.ProcessEnv, args: string[]) {
|
|
227
|
+
return spawnSync("bash", [path.join(REPO, "run.sh"), ...args], { encoding: "utf8", env, cwd: REPO });
|
|
228
|
+
}
|
|
229
|
+
function py(env: NodeJS.ProcessEnv, script: string, args: string[]) {
|
|
230
|
+
return spawnSync("python3", [path.join(REPO, "scripts", script), ...args], { encoding: "utf8", env, cwd: REPO });
|
|
231
|
+
}
|
|
232
|
+
function readJson(file: string): unknown {
|
|
233
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The `command` a helper run PRINTED, or `null` when it printed no readable one.
|
|
237
|
+
*
|
|
238
|
+
* A cell that reads `JSON.parse(run.stdout).command` inline dies with a SyntaxError the moment the
|
|
239
|
+
* helper exits non-zero — and a gate that dies before its own assertion reports someone else's
|
|
240
|
+
* failure, which is exactly the WRONG-REASON shape a mutant must never produce. So the parse is
|
|
241
|
+
* done defensively and the ASSERTION decides, with the run's own exit code and stderr carried into
|
|
242
|
+
* the message.
|
|
243
|
+
*/
|
|
244
|
+
function printedCommand(run: { status: number | null; stdout: string }): string | null {
|
|
245
|
+
if (run.status !== 0) return null;
|
|
246
|
+
try {
|
|
247
|
+
const parsed = JSON.parse(run.stdout) as { command?: unknown };
|
|
248
|
+
return typeof parsed.command === "string" ? parsed.command : null;
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function refusal(fn: () => unknown): string | null {
|
|
254
|
+
try {
|
|
255
|
+
fn();
|
|
256
|
+
return null;
|
|
257
|
+
} catch (err) {
|
|
258
|
+
return (err as { code?: string }).code ?? `not-an-ActivationError:${String(err)}`;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function runtimeRootOf(env: NodeJS.ProcessEnv): string {
|
|
262
|
+
return path.join(env.XDG_DATA_HOME as string, "entwurf", "herdr-plugin", "runtime", "active");
|
|
263
|
+
}
|
|
264
|
+
function ledgerFor(
|
|
265
|
+
env: NodeJS.ProcessEnv,
|
|
266
|
+
backends: string[],
|
|
267
|
+
phase = "active",
|
|
268
|
+
artifactIdentity: Record<string, unknown> = IDENTITY_A,
|
|
269
|
+
): Record<string, unknown> {
|
|
270
|
+
const states: Record<string, string> = {};
|
|
271
|
+
for (const b of backends) states[b] = "active";
|
|
272
|
+
return ledgerBody({
|
|
273
|
+
phase,
|
|
274
|
+
runtimeRoot: runtimeRootOf(env),
|
|
275
|
+
artifactIdentity,
|
|
276
|
+
roots: resolveComponentRoots(env),
|
|
277
|
+
states,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
function ledgerWith(
|
|
281
|
+
env: NodeJS.ProcessEnv,
|
|
282
|
+
phase: string,
|
|
283
|
+
states: Record<string, string>,
|
|
284
|
+
artifactIdentity: Record<string, unknown> = IDENTITY_A,
|
|
285
|
+
): Record<string, unknown> {
|
|
286
|
+
return ledgerBody({
|
|
287
|
+
phase,
|
|
288
|
+
runtimeRoot: runtimeRootOf(env),
|
|
289
|
+
artifactIdentity,
|
|
290
|
+
roots: resolveComponentRoots(env),
|
|
291
|
+
states,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── 1. activation writes the global pi citizen and NOTHING in a project ────────
|
|
296
|
+
{
|
|
297
|
+
const env = world("pi-only");
|
|
298
|
+
const agentSettings = path.join(env.PI_CODING_AGENT_DIR as string, "settings.json");
|
|
299
|
+
fs.writeFileSync(agentSettings, '{"theme":"dark"}\n');
|
|
300
|
+
const opencode = path.join(env.HOME as string, ".config", "opencode");
|
|
301
|
+
fs.mkdirSync(opencode, { recursive: true });
|
|
302
|
+
fs.writeFileSync(path.join(opencode, "plugins.json"), "{}\n");
|
|
303
|
+
const opencodeBefore = fs.readFileSync(path.join(opencode, "plugins.json"), "utf8");
|
|
304
|
+
const run = sh(env, ["install-user-scope", "--plugin-runtime", runtimeRootOf(env)]);
|
|
305
|
+
const after = readJson(agentSettings) as { packages?: string[]; entwurfProvider?: Record<string, never> };
|
|
306
|
+
// ANY project settings file anywhere under the sandbox HOME counts: the claim is not that one
|
|
307
|
+
// known path stayed clean, it is that plugin activation invents no project at all.
|
|
308
|
+
const projectWrites: string[] = [];
|
|
309
|
+
const walk = (dir: string): void => {
|
|
310
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
311
|
+
const abs = path.join(dir, e.name);
|
|
312
|
+
if (e.isDirectory()) walk(abs);
|
|
313
|
+
else if (abs.endsWith(path.join(".pi", "settings.json"))) {
|
|
314
|
+
projectWrites.push(path.relative(env.HOME as string, abs));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
walk(env.HOME as string);
|
|
319
|
+
ok(
|
|
320
|
+
"[QK:HAC-PI-USER-SCOPE-ONLY] plugin activation composes the GLOBAL pi citizen and writes no project " +
|
|
321
|
+
"`.pi/settings.json` — generic `install` writes both halves, but a Herdr plugin has no project and " +
|
|
322
|
+
"inventing one would leave a second package identity that `--entwurf-control` then has to arbitrate " +
|
|
323
|
+
`(exit=${run.status} packages=${JSON.stringify(after.packages)} project-writes=${JSON.stringify(projectWrites)} opencode-untouched=${fs.readFileSync(path.join(opencode, "plugins.json"), "utf8") === opencodeBefore})`,
|
|
324
|
+
run.status === 0 &&
|
|
325
|
+
Array.isArray(after.packages) &&
|
|
326
|
+
after.packages.length === 1 &&
|
|
327
|
+
after.entwurfProvider !== undefined &&
|
|
328
|
+
projectWrites.length === 0 &&
|
|
329
|
+
fs.readFileSync(path.join(opencode, "plugins.json"), "utf8") === opencodeBefore,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── 2. the plugin command is DERIVED from the runtime root, never supplied ─────
|
|
334
|
+
{
|
|
335
|
+
const env = world("derived");
|
|
336
|
+
const settings = path.join(env.HOME as string, "settings.json");
|
|
337
|
+
const state = path.join(env.HOME as string, "state.json");
|
|
338
|
+
fs.writeFileSync(settings, "{}\n");
|
|
339
|
+
const root = runtimeRootOf(env);
|
|
340
|
+
py(env, "register-pi-provider.py", [
|
|
341
|
+
"install",
|
|
342
|
+
settings,
|
|
343
|
+
REPO,
|
|
344
|
+
"--scope",
|
|
345
|
+
"user",
|
|
346
|
+
"--state",
|
|
347
|
+
state,
|
|
348
|
+
"--plugin-runtime",
|
|
349
|
+
root,
|
|
350
|
+
]);
|
|
351
|
+
const written = (readJson(settings) as { entwurfProvider: { mcpServers: { "entwurf-bridge": { command: string } } } })
|
|
352
|
+
.entwurfProvider.mcpServers["entwurf-bridge"].command;
|
|
353
|
+
const relative = py(env, "register-pi-provider.py", [
|
|
354
|
+
"install",
|
|
355
|
+
settings,
|
|
356
|
+
REPO,
|
|
357
|
+
"--scope",
|
|
358
|
+
"user",
|
|
359
|
+
"--state",
|
|
360
|
+
state,
|
|
361
|
+
"--plugin-runtime",
|
|
362
|
+
"relative/path",
|
|
363
|
+
]);
|
|
364
|
+
const trailing = py(env, "register-pi-provider.py", [
|
|
365
|
+
"install",
|
|
366
|
+
settings,
|
|
367
|
+
REPO,
|
|
368
|
+
"--scope",
|
|
369
|
+
"user",
|
|
370
|
+
"--state",
|
|
371
|
+
state,
|
|
372
|
+
"--plugin-runtime",
|
|
373
|
+
`${root}/`,
|
|
374
|
+
]);
|
|
375
|
+
// An ARBITRARY absolute directory is the shape this mode must not accept: "absolute" was never
|
|
376
|
+
// the contract, "this host's stable runtime" was. And a flag with no value must not fall through
|
|
377
|
+
// to bare mode — the caller asked for the plugin runtime and would be told, silently, that it
|
|
378
|
+
// got it.
|
|
379
|
+
const arbitrary = py(env, "register-pi-provider.py", [
|
|
380
|
+
"install",
|
|
381
|
+
settings,
|
|
382
|
+
REPO,
|
|
383
|
+
"--scope",
|
|
384
|
+
"user",
|
|
385
|
+
"--state",
|
|
386
|
+
state,
|
|
387
|
+
"--plugin-runtime",
|
|
388
|
+
path.join(env.HOME as string, "somewhere-else"),
|
|
389
|
+
]);
|
|
390
|
+
const noValue = py(env, "register-pi-provider.py", [
|
|
391
|
+
"install",
|
|
392
|
+
settings,
|
|
393
|
+
REPO,
|
|
394
|
+
"--scope",
|
|
395
|
+
"user",
|
|
396
|
+
"--state",
|
|
397
|
+
state,
|
|
398
|
+
"--plugin-runtime",
|
|
399
|
+
]);
|
|
400
|
+
const claudeArbitrary = py(env, "meta-bridge-state.py", [
|
|
401
|
+
"desired-mcp",
|
|
402
|
+
"--repo",
|
|
403
|
+
REPO,
|
|
404
|
+
"--plugin-runtime",
|
|
405
|
+
path.join(env.HOME as string, "somewhere-else"),
|
|
406
|
+
]);
|
|
407
|
+
const shellNoValue = sh(env, ["install-user-scope", "--plugin-runtime"]);
|
|
408
|
+
const claudeMcp = py(env, "meta-bridge-state.py", ["desired-mcp", "--repo", REPO, "--plugin-runtime", root]);
|
|
409
|
+
const claudeStatus = py(env, "meta-bridge-state.py", [
|
|
410
|
+
"desired-statusline",
|
|
411
|
+
"--repo",
|
|
412
|
+
REPO,
|
|
413
|
+
"--plugin-runtime",
|
|
414
|
+
root,
|
|
415
|
+
]);
|
|
416
|
+
ok(
|
|
417
|
+
"[QK:HAC-COMMAND-IS-DERIVED] both harnesses get an absolute command DERIVED from THIS HOST'S stable runtime root by " +
|
|
418
|
+
"appending a fixed suffix — the caller may NAME the root and it is accepted only when it IS that one, so a " +
|
|
419
|
+
"relative, unnormalised, absent or merely-absolute-somewhere-else value is refused. Accepting any absolute " +
|
|
420
|
+
"directory would be a 'point every pi session and every Claude MCP call at an executable of my choosing' " +
|
|
421
|
+
`authority wearing a plugin's clothes (pi=${written.endsWith("/node_modules/.bin/entwurf-bridge")} relative=${relative.status} trailing=${trailing.status} claude-mcp-exit=${claudeMcp.status})`,
|
|
422
|
+
written === path.join(root, "node_modules", ".bin", "entwurf-bridge") &&
|
|
423
|
+
relative.status !== 0 &&
|
|
424
|
+
trailing.status !== 0 &&
|
|
425
|
+
arbitrary.status !== 0 &&
|
|
426
|
+
claudeArbitrary.status !== 0 &&
|
|
427
|
+
noValue.status !== 0 &&
|
|
428
|
+
shellNoValue.status !== 0 &&
|
|
429
|
+
claudeMcp.status === 0 &&
|
|
430
|
+
(JSON.parse(claudeMcp.stdout) as { command: string }).command ===
|
|
431
|
+
path.join(root, "node_modules", ".bin", "entwurf-bridge") &&
|
|
432
|
+
(JSON.parse(claudeStatus.stdout) as { command: string }).command ===
|
|
433
|
+
path.join(root, "node_modules", ".bin", "entwurf-statusline"),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── 3. default mode is byte-for-byte what it always was ────────────────────────
|
|
438
|
+
{
|
|
439
|
+
const env = world("default-mode");
|
|
440
|
+
const settings = path.join(env.HOME as string, "settings.json");
|
|
441
|
+
const state = path.join(env.HOME as string, "state.json");
|
|
442
|
+
fs.writeFileSync(settings, "{}\n");
|
|
443
|
+
py(env, "register-pi-provider.py", ["install", settings, REPO, "--scope", "user", "--state", state]);
|
|
444
|
+
const piDefault = (
|
|
445
|
+
readJson(settings) as { entwurfProvider: { mcpServers: { "entwurf-bridge": { command: string } } } }
|
|
446
|
+
).entwurfProvider.mcpServers["entwurf-bridge"].command;
|
|
447
|
+
const claudeDefault = py(env, "meta-bridge-state.py", ["desired-mcp", "--repo", REPO]);
|
|
448
|
+
const claudeStatusDefault = py(env, "meta-bridge-state.py", ["desired-statusline", "--repo", REPO]);
|
|
449
|
+
const claudeDefaultCommand = printedCommand(claudeDefault);
|
|
450
|
+
const claudeStatusDefaultCommand = printedCommand(claudeStatusDefault);
|
|
451
|
+
ok(
|
|
452
|
+
"[QK:HAC-DEFAULT-MODE-UNCHANGED] with no mode requested the pi provider still writes the bare `entwurf-bridge` " +
|
|
453
|
+
"and the Claude owner still answers from its historical installed/clone branch — every host that never asks " +
|
|
454
|
+
"for the plugin mode keeps the bytes it had, which is the only thing that makes adding a mode safe rather " +
|
|
455
|
+
`than a migration (pi=${JSON.stringify(piDefault)} claude=${JSON.stringify(claudeDefaultCommand)} ` +
|
|
456
|
+
`claude-statusline=${JSON.stringify(claudeStatusDefaultCommand)} mcp-exit=${claudeDefault.status} ` +
|
|
457
|
+
`statusline-exit=${claudeStatusDefault.status} mcp-stderr=${JSON.stringify((claudeDefault.stderr || "").trim().slice(-200))})`,
|
|
458
|
+
piDefault === "entwurf-bridge" &&
|
|
459
|
+
claudeDefault.status === 0 &&
|
|
460
|
+
claudeStatusDefault.status === 0 &&
|
|
461
|
+
claudeDefaultCommand !== null &&
|
|
462
|
+
!claudeDefaultCommand.includes("herdr-plugin") &&
|
|
463
|
+
claudeStatusDefaultCommand !== null &&
|
|
464
|
+
!claudeStatusDefaultCommand.includes("herdr-plugin"),
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── 4. the pi inverse refuses drift instead of deleting an override ────────────
|
|
469
|
+
{
|
|
470
|
+
const env = world("drift");
|
|
471
|
+
const settings = path.join(env.HOME as string, "settings.json");
|
|
472
|
+
const state = path.join(env.HOME as string, "state.json");
|
|
473
|
+
fs.writeFileSync(settings, "{}\n");
|
|
474
|
+
const root = runtimeRootOf(env);
|
|
475
|
+
py(env, "register-pi-provider.py", [
|
|
476
|
+
"install",
|
|
477
|
+
settings,
|
|
478
|
+
REPO,
|
|
479
|
+
"--scope",
|
|
480
|
+
"user",
|
|
481
|
+
"--state",
|
|
482
|
+
state,
|
|
483
|
+
"--plugin-runtime",
|
|
484
|
+
root,
|
|
485
|
+
]);
|
|
486
|
+
const doc = readJson(settings) as { entwurfProvider: { mcpServers: Record<string, { command: string }> } };
|
|
487
|
+
doc.entwurfProvider.mcpServers["entwurf-bridge"].command = "/somebody/elses/bridge";
|
|
488
|
+
fs.writeFileSync(settings, JSON.stringify(doc));
|
|
489
|
+
const before = fs.readFileSync(settings, "utf8");
|
|
490
|
+
const refused = py(env, "register-pi-provider.py", [
|
|
491
|
+
"remove",
|
|
492
|
+
settings,
|
|
493
|
+
REPO,
|
|
494
|
+
"--scope",
|
|
495
|
+
"user",
|
|
496
|
+
"--state",
|
|
497
|
+
state,
|
|
498
|
+
"--preflight",
|
|
499
|
+
]);
|
|
500
|
+
ok(
|
|
501
|
+
"[QK:HAC-INVERSE-REFUSES-DRIFT] the user-scope inverse is admitted by the ownership RECORD, so without a drift " +
|
|
502
|
+
"check it would delete whatever now sits at our key — the state names the exact command we wrote, and a " +
|
|
503
|
+
"present-but-different value is somebody's override, refused by name with the settings and the ownership " +
|
|
504
|
+
`state both left intact (exit=${refused.status} bytes-unchanged=${before === fs.readFileSync(settings, "utf8")} state-kept=${fs.existsSync(state)})`,
|
|
505
|
+
refused.status === 6 &&
|
|
506
|
+
before === fs.readFileSync(settings, "utf8") &&
|
|
507
|
+
fs.existsSync(state) &&
|
|
508
|
+
(refused.stderr || "").includes("drifted since install"),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── 5. the ledger is certified, and never names anything outside P ─────────────
|
|
513
|
+
{
|
|
514
|
+
const env = world("ledger");
|
|
515
|
+
const layout = resolveActivationLayout(env);
|
|
516
|
+
writeLedger(layout, ledgerFor(env, ["pi"]));
|
|
517
|
+
const good = readCertifiedLedger(layout) as Ledger;
|
|
518
|
+
const write = (body: unknown): string | null => {
|
|
519
|
+
fs.writeFileSync(layout.ledgerPath, `${JSON.stringify(body)}\n`);
|
|
520
|
+
return refusal(() => readCertifiedLedger(layout));
|
|
521
|
+
};
|
|
522
|
+
const cells: Record<string, string | null> = {
|
|
523
|
+
"json-null": write(null),
|
|
524
|
+
array: write([good]),
|
|
525
|
+
"missing-key": write({ schemaVersion: 1 }),
|
|
526
|
+
"outside-p": write({ ...good, activatedBackends: ["pi", "opencode"] }),
|
|
527
|
+
"component-outside-p": write({ ...good, components: [{ backend: "opencode", state: "active" }] }),
|
|
528
|
+
"relative-root": write({ ...good, piAgentDir: { path: "relative/dir", source: "default" } }),
|
|
529
|
+
"root-source-missing": write({ ...good, piAgentDir: { path: good.piAgentDir.path } }),
|
|
530
|
+
};
|
|
531
|
+
ok(
|
|
532
|
+
"[QK:HAC-LEDGER-CERTIFIED] the ledger is read through one certifying reader — exact key set, a resolved " +
|
|
533
|
+
"absolute path AND its source for each harness root, and a backend set that can only ever be a subset of " +
|
|
534
|
+
"`{pi, claude-code}` — because this record is the authority a teardown aims with, and an entry naming " +
|
|
535
|
+
`OpenCode would be a teardown pointed at a harness this lane promised to never write (${Object.entries(cells)
|
|
536
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
537
|
+
.join(" ")})`,
|
|
538
|
+
good.activatedBackends.join(",") === "pi" &&
|
|
539
|
+
JSON.stringify(LEDGER_KEYS) === JSON.stringify(Object.keys(good as object)) &&
|
|
540
|
+
cells["json-null"] === "activation-ledger-uncertified" &&
|
|
541
|
+
cells.array === "activation-ledger-uncertified" &&
|
|
542
|
+
cells["missing-key"] === "activation-ledger-uncertified" &&
|
|
543
|
+
cells["outside-p"] === "activation-ledger-backend-outside-p" &&
|
|
544
|
+
cells["component-outside-p"] === "activation-ledger-backend-outside-p" &&
|
|
545
|
+
cells["relative-root"] === "activation-ledger-uncertified" &&
|
|
546
|
+
cells["root-source-missing"] === "activation-ledger-uncertified",
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── 6. the ledger is a state machine, and it has to agree with itself ──────────
|
|
551
|
+
{
|
|
552
|
+
const env = world("state-machine");
|
|
553
|
+
const layout = resolveActivationLayout(env);
|
|
554
|
+
writeLedger(layout, ledgerWith(env, "deactivating", { pi: "active", "claude-code": "removed" }));
|
|
555
|
+
const mid = readCertifiedLedger(layout) as Ledger;
|
|
556
|
+
const write = (body: unknown): string | null => {
|
|
557
|
+
fs.writeFileSync(layout.ledgerPath, `${JSON.stringify(body)}\n`);
|
|
558
|
+
return refusal(() => readCertifiedLedger(layout));
|
|
559
|
+
};
|
|
560
|
+
const cells: Record<string, string | null> = {
|
|
561
|
+
"bad-phase": write({ ...mid, phase: "halfway" }),
|
|
562
|
+
"bad-state": write({ ...mid, activatedBackends: ["pi"], components: [{ backend: "pi", state: "sort-of" }] }),
|
|
563
|
+
"phase-contradiction": write({
|
|
564
|
+
...mid,
|
|
565
|
+
phase: "active",
|
|
566
|
+
activatedBackends: ["pi"],
|
|
567
|
+
components: [{ backend: "pi", state: "removed" }],
|
|
568
|
+
}),
|
|
569
|
+
duplicate: write({
|
|
570
|
+
...mid,
|
|
571
|
+
activatedBackends: ["pi"],
|
|
572
|
+
components: [
|
|
573
|
+
{ backend: "pi", state: "active" },
|
|
574
|
+
{ backend: "pi", state: "active" },
|
|
575
|
+
],
|
|
576
|
+
}),
|
|
577
|
+
"missing-component": write({ ...mid, components: [{ backend: "pi", state: "active" }] }),
|
|
578
|
+
"extra-component": write({ ...mid, activatedBackends: ["pi"] }),
|
|
579
|
+
"non-canonical": write({ ...mid, activatedBackends: ["claude-code", "pi"] }),
|
|
580
|
+
};
|
|
581
|
+
ok(
|
|
582
|
+
"[QK:HAC-LEDGER-STATE-MACHINE] phase and component state are a closed machine that must agree with itself — " +
|
|
583
|
+
"duplicate, missing or extra components, a non-canonical backend order, and a phase that contradicts its own " +
|
|
584
|
+
"rows are each refused by name. Without progress IN the ledger, a teardown whose first inverse succeeded and " +
|
|
585
|
+
"whose second failed would retry the first against ownership state its own earlier run deleted, and refuse " +
|
|
586
|
+
`forever; with it, the retry knows what is left (${Object.entries(cells)
|
|
587
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
588
|
+
.join(" ")} phases=${JSON.stringify(LEDGER_PHASES)} states=${JSON.stringify(COMPONENT_STATES)})`,
|
|
589
|
+
mid.phase === "deactivating" &&
|
|
590
|
+
cells["bad-phase"] === "activation-ledger-uncertified" &&
|
|
591
|
+
cells["bad-state"] === "activation-ledger-uncertified" &&
|
|
592
|
+
cells["phase-contradiction"] === "activation-ledger-phase-contradiction" &&
|
|
593
|
+
cells.duplicate === "activation-ledger-uncertified" &&
|
|
594
|
+
cells["missing-component"] === "activation-ledger-uncertified" &&
|
|
595
|
+
cells["extra-component"] === "activation-ledger-uncertified" &&
|
|
596
|
+
cells["non-canonical"] === "activation-ledger-uncertified",
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ── 7. activation is add-only; a shrinking H never removes ─────────────────────
|
|
601
|
+
{
|
|
602
|
+
const env = world("add-only");
|
|
603
|
+
const layout = resolveActivationLayout(env);
|
|
604
|
+
writeLedger(layout, ledgerFor(env, ["pi"]));
|
|
605
|
+
const ledger = readCertifiedLedger(layout);
|
|
606
|
+
const grow = planActivation({ ledger, requested: ["pi", "claude-code"] });
|
|
607
|
+
const shrink = planActivation({ ledger: readCertifiedLedger(layout), requested: [] });
|
|
608
|
+
const outside = refusal(() => planActivation({ ledger, requested: ["pi", "opencode"] }));
|
|
609
|
+
ok(
|
|
610
|
+
"[QK:HAC-ADD-ONLY] a reinstall reconciles what is already activated and ADDS what is newly in `H ∩ P`, and a " +
|
|
611
|
+
"backend that has vanished from the request is RETAINED, never removed — Herdr's reinstall is the refresh " +
|
|
612
|
+
"trigger, so an operator who uninstalls a Herdr integration for unrelated reasons must not silently lose " +
|
|
613
|
+
`the Entwurf wiring they still use; only the explicit verb removes (grow=${JSON.stringify(grow)} shrink.retained=${JSON.stringify(shrink.retained)} outside=${outside})`,
|
|
614
|
+
grow.reconcile.join(",") === "pi" &&
|
|
615
|
+
grow.add.join(",") === "claude-code" &&
|
|
616
|
+
grow.resulting.join(",") === "pi,claude-code" &&
|
|
617
|
+
shrink.retained.join(",") === "pi" &&
|
|
618
|
+
shrink.resulting.join(",") === "pi" &&
|
|
619
|
+
outside === "activation-backend-outside-p" &&
|
|
620
|
+
ACTIVATABLE_BACKENDS.join(",") === "pi,claude-code",
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ── 8. a ledger describing different roots refuses before the first byte ───────
|
|
625
|
+
{
|
|
626
|
+
const env = world("roots");
|
|
627
|
+
const layout = resolveActivationLayout(env);
|
|
628
|
+
writeLedger(layout, ledgerFor(env, ["pi"]));
|
|
629
|
+
const ledger = readCertifiedLedger(layout);
|
|
630
|
+
const moved = { ...env, PI_CODING_AGENT_DIR: path.join(env.HOME as string, "somewhere-else") };
|
|
631
|
+
const drifted = refusal(() => certifyRootsAgainstLedger(ledger, resolveComponentRoots(moved), runtimeRootOf(env)));
|
|
632
|
+
const same = refusal(() => certifyRootsAgainstLedger(ledger, resolveComponentRoots(env), runtimeRootOf(env)));
|
|
633
|
+
ok(
|
|
634
|
+
"[QK:HAC-ROOTS-CERTIFIED] an activation recorded under one set of resolved harness roots refuses to be acted on " +
|
|
635
|
+
"under another — `PI_CODING_AGENT_DIR` and `CLAUDE_CONFIG_DIR` each decide WHERE a component's bytes live, " +
|
|
636
|
+
"so the same override has to hold for the status read, the install and the removal; undoing an activation " +
|
|
637
|
+
`against a root it never described is how an inverse edits a config that was never ours (drifted=${drifted} same=${same})`,
|
|
638
|
+
drifted === "activation-roots-drifted" && same === null,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── 9. teardown order: components, runtime, then the ledger LAST ───────────────
|
|
643
|
+
{
|
|
644
|
+
const env = world("order");
|
|
645
|
+
const layout = resolveActivationLayout(env);
|
|
646
|
+
writeLedger(layout, ledgerFor(env, ["pi", "claude-code"]));
|
|
647
|
+
const steps = planDeactivation(readCertifiedLedger(layout));
|
|
648
|
+
const absent = refusal(() => planDeactivation(null));
|
|
649
|
+
ok(
|
|
650
|
+
"[QK:HAC-TEARDOWN-ORDER] the teardown runs component inverses first, then the runtime, and retires the ledger " +
|
|
651
|
+
"LAST — both harnesses' wiring NAMES the runtime, so removing it first leaves configs pointing at nothing, " +
|
|
652
|
+
"and the ledger is the retry authority, so a teardown that deletes its own record of what remains is one " +
|
|
653
|
+
`nobody can resume (steps=${JSON.stringify(steps)} no-ledger=${absent})`,
|
|
654
|
+
JSON.stringify(steps) === JSON.stringify(["claude-code", "pi", "runtime", "ledger"]) &&
|
|
655
|
+
absent === "activation-ledger-absent",
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ── 10. the same XDG/HOME resolution everywhere, including with XDG unset ──────
|
|
660
|
+
{
|
|
661
|
+
const home = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-hac-homeonly-")));
|
|
662
|
+
const bare: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: home, TERM: "dumb" };
|
|
663
|
+
const dataRoot = resolveEntwurfDataRoot(bare);
|
|
664
|
+
const states = piStatePaths(bare);
|
|
665
|
+
const piExpected = path.join(home, ".local", "share", "entwurf");
|
|
666
|
+
// The python writers derive the SAME stable root from the SAME environment.
|
|
667
|
+
const pyRoot = spawnSync(
|
|
668
|
+
"python3",
|
|
669
|
+
[
|
|
670
|
+
"-c",
|
|
671
|
+
"import sys; sys.path.insert(0, 'scripts'); from importlib import import_module; m = import_module('register-pi-provider'.replace('-', '_')) if False else None",
|
|
672
|
+
],
|
|
673
|
+
{ encoding: "utf8", env: bare },
|
|
674
|
+
);
|
|
675
|
+
ok(
|
|
676
|
+
"[QK:HAC-XDG-RESOLUTION-SHARED] every root comes from one resolution — `XDG_DATA_HOME`, else " +
|
|
677
|
+
"`$HOME/.local/share` — so a host with XDG unset is the same host to the teardown as it is to the writers. " +
|
|
678
|
+
'The first cut built its Pi state paths from `env.XDG_DATA_HOME || ""`, which on such a host pointed at ' +
|
|
679
|
+
"`/entwurf/...`: a preflight that looked at a file nobody writes and passed for the wrong reason " +
|
|
680
|
+
`(dataRoot=${dataRoot === piExpected} packageState=${path.relative(home, states.packageState)} py=${pyRoot.status === 0})`,
|
|
681
|
+
dataRoot === piExpected &&
|
|
682
|
+
states.packageState === path.join(piExpected, "pi-package", "install-state.json") &&
|
|
683
|
+
states.providerState === path.join(piExpected, "pi-provider", "install-state.json"),
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ── 11. the Claude state owner certifies the plan it will actually execute ─────
|
|
688
|
+
{
|
|
689
|
+
const env = world("claude-plan");
|
|
690
|
+
const claudeDir = env.CLAUDE_CONFIG_DIR as string;
|
|
691
|
+
const asm = path.join(env.XDG_DATA_HOME as string, "entwurf", "meta-bridge", ".assembled");
|
|
692
|
+
fs.mkdirSync(asm, { recursive: true });
|
|
693
|
+
fs.writeFileSync(path.join(claudeDir, "settings.json"), '{"theme":"dark","permissions":{"allow":["Bash(ls)"]}}\n');
|
|
694
|
+
const before = py(env, "meta-bridge-state.py", ["preflight-install", "--repo", REPO, "--asm", asm]);
|
|
695
|
+
const prepared = py(env, "meta-bridge-state.py", ["prepare", "--repo", REPO, "--asm", asm]);
|
|
696
|
+
const after = py(env, "meta-bridge-state.py", ["preflight-uninstall", "--repo", REPO]);
|
|
697
|
+
|
|
698
|
+
// every kind the restorer knows, refused by name when malformed
|
|
699
|
+
const statePath = path.join(claudeDir, "entwurf.install-state.json");
|
|
700
|
+
const state = JSON.parse(fs.readFileSync(statePath, "utf8")) as {
|
|
701
|
+
files: { settings: { keys: Record<string, { kind: string }> } };
|
|
702
|
+
};
|
|
703
|
+
const kinds = [...new Set(Object.values(state.files.settings.keys).map((k) => k.kind))].sort();
|
|
704
|
+
const breakEntry = (mutate: (k: Record<string, unknown>) => void): string => {
|
|
705
|
+
const doc = JSON.parse(fs.readFileSync(statePath, "utf8")) as {
|
|
706
|
+
files: { settings: { keys: Record<string, Record<string, unknown>> } };
|
|
707
|
+
};
|
|
708
|
+
mutate(doc.files.settings.keys["permissions.allow"]);
|
|
709
|
+
fs.writeFileSync(statePath, JSON.stringify(doc));
|
|
710
|
+
const r = py(env, "meta-bridge-state.py", ["preflight-uninstall", "--repo", REPO]);
|
|
711
|
+
fs.writeFileSync(statePath, JSON.stringify(state));
|
|
712
|
+
return `${r.status}`;
|
|
713
|
+
};
|
|
714
|
+
const droppedAdded = breakEntry((k) => {
|
|
715
|
+
k.added = "not-a-list";
|
|
716
|
+
});
|
|
717
|
+
const droppedExisted = breakEntry((k) => {
|
|
718
|
+
delete k.originalExisted;
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
ok(
|
|
722
|
+
"[QK:HAC-CLAUDE-PLAN-CERTIFIED] the forward preflight runs the REAL snapshot plan on copies and the inverse " +
|
|
723
|
+
"preflight certifies every recorded entry through the SAME function the restorer uses — the first cut " +
|
|
724
|
+
"re-implemented the check and knew only one of the three entry shapes, demanding `original` while " +
|
|
725
|
+
"`snapshot_array_items` writes `{originalExisted, added}`, so EVERY real Claude activation failed its own " +
|
|
726
|
+
`preflight and the aggregate could never claim preflight-all (install=${before.status} prepare=${prepared.status} uninstall=${after.status} kinds=${JSON.stringify(kinds)} malformed=${droppedAdded}/${droppedExisted})`,
|
|
727
|
+
before.status === 0 &&
|
|
728
|
+
prepared.status === 0 &&
|
|
729
|
+
after.status === 0 &&
|
|
730
|
+
kinds.includes("array-items") &&
|
|
731
|
+
kinds.includes("map-entry") &&
|
|
732
|
+
kinds.includes("scalar") &&
|
|
733
|
+
droppedAdded === "1" &&
|
|
734
|
+
droppedExisted === "1" &&
|
|
735
|
+
(after.stdout || "").includes("restore entries applicable"),
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ── 12. the two-stage activation a reinstall actually performs ─────────────────
|
|
740
|
+
{
|
|
741
|
+
const env = world("two-stage");
|
|
742
|
+
const claudeBin = fakeClaude(env);
|
|
743
|
+
const runEnv = {
|
|
744
|
+
...env,
|
|
745
|
+
PATH: `${claudeBin}${path.delimiter}${env.PATH}`,
|
|
746
|
+
FAKE_CLAUDE_LOG: path.join(env.HOME as string, "claude.log"),
|
|
747
|
+
};
|
|
748
|
+
const active = seedRuntime(env);
|
|
749
|
+
const layout = resolveActivationLayout(env);
|
|
750
|
+
const agentSettings = path.join(env.PI_CODING_AGENT_DIR as string, "settings.json");
|
|
751
|
+
fs.writeFileSync(agentSettings, '{"theme":"dark"}\n');
|
|
752
|
+
// OpenCode is the negative control: present on the host, never named, never touched.
|
|
753
|
+
const opencode = path.join(env.HOME as string, ".config", "opencode");
|
|
754
|
+
fs.mkdirSync(opencode, { recursive: true });
|
|
755
|
+
fs.writeFileSync(path.join(opencode, "plugins.json"), '{"sentinel":true}\n');
|
|
756
|
+
const opencodeBefore = fs.readFileSync(path.join(opencode, "plugins.json"), "utf8");
|
|
757
|
+
|
|
758
|
+
// stage 1 — H ∩ P = {pi}
|
|
759
|
+
const first = spawnSync("node", [ACTIVATE, "pi"], { encoding: "utf8", env: runEnv });
|
|
760
|
+
const piAfterFirst = fs.readFileSync(agentSettings, "utf8");
|
|
761
|
+
// What the ownership record ESTABLISHES, not what it observed on the way. `installedAt` moves by
|
|
762
|
+
// design; `ownership`/`preimage` describe what THAT run found (absent the first time, our own
|
|
763
|
+
// value the second) and are audit fields about the run, not the installed state. The load-bearing
|
|
764
|
+
// claim is that a reconcile leaves the same managed file, key, command and owner behind — a byte
|
|
765
|
+
// oracle here would fail a correct reconcile and prove nothing extra.
|
|
766
|
+
const ownershipEstablished = (): string => {
|
|
767
|
+
const doc = JSON.parse(fs.readFileSync(piStatePaths(env).providerState, "utf8")) as Record<string, unknown>;
|
|
768
|
+
return JSON.stringify({
|
|
769
|
+
managedSettingsPath: doc.managedSettingsPath,
|
|
770
|
+
scope: doc.scope,
|
|
771
|
+
key: doc.key,
|
|
772
|
+
command: doc.command,
|
|
773
|
+
installerRoot: doc.installerRoot,
|
|
774
|
+
});
|
|
775
|
+
};
|
|
776
|
+
const stateAfterFirst = ownershipEstablished();
|
|
777
|
+
const ledger1 = readCertifiedLedger(layout) as Ledger;
|
|
778
|
+
const claudeSettingsAfterFirst = fs.existsSync(path.join(env.CLAUDE_CONFIG_DIR as string, "settings.json"));
|
|
779
|
+
|
|
780
|
+
// stage 2 — the operator added Claude Code (and OpenCode, which is not ours to see)
|
|
781
|
+
const second = spawnSync("node", [ACTIVATE, "pi", "claude-code"], { encoding: "utf8", env: runEnv });
|
|
782
|
+
const ledger2 = readCertifiedLedger(layout) as Ledger;
|
|
783
|
+
const claudeSettings = JSON.parse(
|
|
784
|
+
fs.readFileSync(path.join(env.CLAUDE_CONFIG_DIR as string, "settings.json"), "utf8"),
|
|
785
|
+
) as { statusLine?: { command?: string } };
|
|
786
|
+
// `$HOME/.claude.json`, not `$CLAUDE_CONFIG_DIR/.claude.json`: the state owner derives the root
|
|
787
|
+
// config from HOME (`claude_root_config_path`, meta-bridge-state.py:144-147) while the settings
|
|
788
|
+
// half follows the override. Asserting the path the writer actually uses is the point — an
|
|
789
|
+
// assertion aimed at the other one would pass only by accident of where the vendor CLI writes.
|
|
790
|
+
const claudeRoot = JSON.parse(fs.readFileSync(path.join(env.HOME as string, ".claude.json"), "utf8")) as {
|
|
791
|
+
mcpServers?: Record<string, { command?: string }>;
|
|
792
|
+
};
|
|
793
|
+
const mcpAdds = fs
|
|
794
|
+
.readFileSync(path.join(env.HOME as string, "claude.log"), "utf8")
|
|
795
|
+
.split("\n")
|
|
796
|
+
.filter((l) => l.startsWith("mcp add"));
|
|
797
|
+
|
|
798
|
+
ok(
|
|
799
|
+
"[QK:HAC-TWO-STAGE-ADD-ONLY] the reinstall a real operator performs, end to end: `{pi}` first, then " +
|
|
800
|
+
"`{pi, claude-code}` after they installed Claude Code — Pi's bytes and ownership state come out IDENTICAL " +
|
|
801
|
+
"the second time (reconciled, not rewritten), Claude is wired exactly once at the absolute command under the " +
|
|
802
|
+
"stable runtime, and OpenCode — present on the host the whole time — is never named, never planned and never " +
|
|
803
|
+
`touched (first=${first.status} second=${second.status} pi-identical=${piAfterFirst === fs.readFileSync(agentSettings, "utf8")} ledger=${JSON.stringify(ledger1.activatedBackends)}->${JSON.stringify(ledger2.activatedBackends)} mcp-adds=${mcpAdds.length})`,
|
|
804
|
+
first.status === 0 &&
|
|
805
|
+
second.status === 0 &&
|
|
806
|
+
ledger1.activatedBackends.join(",") === "pi" &&
|
|
807
|
+
ledger1.phase === "active" &&
|
|
808
|
+
!claudeSettingsAfterFirst &&
|
|
809
|
+
ledger2.activatedBackends.join(",") === "pi,claude-code" &&
|
|
810
|
+
ledger2.phase === "active" &&
|
|
811
|
+
ledger2.components.every((c) => c.state === "active") &&
|
|
812
|
+
piAfterFirst === fs.readFileSync(agentSettings, "utf8") &&
|
|
813
|
+
stateAfterFirst === ownershipEstablished() &&
|
|
814
|
+
claudeRoot.mcpServers?.["entwurf-bridge"]?.command ===
|
|
815
|
+
path.join(active, "node_modules", ".bin", "entwurf-bridge") &&
|
|
816
|
+
claudeSettings.statusLine?.command === path.join(active, "node_modules", ".bin", "entwurf-statusline") &&
|
|
817
|
+
mcpAdds.length === 1 &&
|
|
818
|
+
fs.readFileSync(path.join(opencode, "plugins.json"), "utf8") === opencodeBefore,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ── 13. a refused preflight changes nothing at all ─────────────────────────────
|
|
823
|
+
{
|
|
824
|
+
const env = world("preflight");
|
|
825
|
+
const layout = resolveActivationLayout(env);
|
|
826
|
+
const agentSettings = path.join(env.PI_CODING_AGENT_DIR as string, "settings.json");
|
|
827
|
+
fs.writeFileSync(agentSettings, '{"theme":"dark"}\n');
|
|
828
|
+
sh(env, ["install-user-scope", "--plugin-runtime", runtimeRootOf(env)]);
|
|
829
|
+
writeLedger(layout, ledgerFor(env, ["pi", "claude-code"]));
|
|
830
|
+
// claude-code is in the ledger but was never installed, so its preflight must refuse.
|
|
831
|
+
const settingsBefore = fs.readFileSync(agentSettings, "utf8");
|
|
832
|
+
const stateBefore = fs
|
|
833
|
+
.readdirSync(path.join(env.XDG_DATA_HOME as string, "entwurf"))
|
|
834
|
+
.sort()
|
|
835
|
+
.join(",");
|
|
836
|
+
const run = spawnSync("node", [DEACTIVATE], { encoding: "utf8", env });
|
|
837
|
+
ok(
|
|
838
|
+
"[QK:HAC-PREFLIGHT-ALL-OR-NOTHING] one refused preflight aborts the whole teardown with every byte untouched — " +
|
|
839
|
+
"a multi-component inverse that starts mutating on a promise it never checked is exactly how a Claude " +
|
|
840
|
+
"failure strands a half-removed Pi, and the ledger survives because it is the only record of what is still " +
|
|
841
|
+
`installed (exit=${run.status} refusal=${(run.stderr || "").trim().split("\\n").pop()} settings-unchanged=${settingsBefore === fs.readFileSync(agentSettings, "utf8")})`,
|
|
842
|
+
run.status !== 0 &&
|
|
843
|
+
(run.stderr || "").includes("deactivate-preflight-refused") &&
|
|
844
|
+
settingsBefore === fs.readFileSync(agentSettings, "utf8") &&
|
|
845
|
+
stateBefore ===
|
|
846
|
+
fs
|
|
847
|
+
.readdirSync(path.join(env.XDG_DATA_HOME as string, "entwurf"))
|
|
848
|
+
.sort()
|
|
849
|
+
.join(",") &&
|
|
850
|
+
fs.existsSync(layout.ledgerPath),
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ── 14. the entry runs even when a package manager hands it over through a symlink ─
|
|
855
|
+
{
|
|
856
|
+
const env = world("entry-guard");
|
|
857
|
+
const link = path.join(env.HOME as string, "linked-package");
|
|
858
|
+
fs.symlinkSync(REPO, link);
|
|
859
|
+
const direct = spawnSync("node", [DEACTIVATE], { encoding: "utf8", env });
|
|
860
|
+
const linked = spawnSync("node", [path.join(link, "scripts", "herdr-plugin-deactivate.mjs")], {
|
|
861
|
+
encoding: "utf8",
|
|
862
|
+
env,
|
|
863
|
+
});
|
|
864
|
+
ok(
|
|
865
|
+
"[QK:HAC-ENTRY-GUARD-REALPATH] the main-module guard compares REALPATHS, so the verb still runs when a package " +
|
|
866
|
+
"manager hands the file over through a symlink — pnpm links `node_modules/@junghanacs/entwurf` into its " +
|
|
867
|
+
"content-addressed store, and an unresolved comparison makes the guard silently false: the process prints " +
|
|
868
|
+
`nothing, exits 0, and every caller reads a teardown that never happened (direct=${direct.status}/${JSON.stringify(direct.stdout.trim().slice(0, 40))} linked=${linked.status}/${JSON.stringify(linked.stdout.trim().slice(0, 40))})`,
|
|
869
|
+
direct.status === 0 &&
|
|
870
|
+
linked.status === 0 &&
|
|
871
|
+
direct.stdout.includes("no certified activation ledger") &&
|
|
872
|
+
linked.stdout.includes("no certified activation ledger"),
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ── 15. a partial teardown is resumable, and a retry finishes it ───────────────
|
|
877
|
+
{
|
|
878
|
+
const env = world("retry");
|
|
879
|
+
const claudeBin = fakeClaude(env);
|
|
880
|
+
const runEnv = {
|
|
881
|
+
...env,
|
|
882
|
+
PATH: `${claudeBin}${path.delimiter}${env.PATH}`,
|
|
883
|
+
FAKE_CLAUDE_LOG: path.join(env.HOME as string, "claude.log"),
|
|
884
|
+
};
|
|
885
|
+
seedRuntime(env);
|
|
886
|
+
const layout = resolveActivationLayout(env);
|
|
887
|
+
const agentSettings = path.join(env.PI_CODING_AGENT_DIR as string, "settings.json");
|
|
888
|
+
fs.writeFileSync(agentSettings, '{"theme":"dark"}\n');
|
|
889
|
+
spawnSync("node", [ACTIVATE, "pi", "claude-code"], { encoding: "utf8", env: runEnv });
|
|
890
|
+
|
|
891
|
+
// The shape the first cut could not survive: Claude's inverse already ran and recorded itself,
|
|
892
|
+
// Pi's has not. A retry must NOT re-run Claude — its state file is gone, so its preflight would
|
|
893
|
+
// refuse forever — and must pick up at Pi.
|
|
894
|
+
spawnSync("bash", [path.join(REPO, "run.sh"), "uninstall-meta-bridge"], { encoding: "utf8", env: runEnv });
|
|
895
|
+
writeLedger(layout, ledgerWith(env, "deactivating", { pi: "active", "claude-code": "removed" }));
|
|
896
|
+
const resumed = spawnSync("node", [DEACTIVATE], { encoding: "utf8", env: runEnv });
|
|
897
|
+
const afterResume = JSON.parse(fs.readFileSync(agentSettings, "utf8")) as Record<string, unknown>;
|
|
898
|
+
|
|
899
|
+
// And the other torn shape: the runtime is already gone but the ledger survived the crash.
|
|
900
|
+
const env2 = world("retry-ledger");
|
|
901
|
+
const layout2 = resolveActivationLayout(env2);
|
|
902
|
+
fs.mkdirSync(path.join(env2.XDG_DATA_HOME as string, "entwurf", "herdr-plugin"), { recursive: true });
|
|
903
|
+
writeLedger(layout2, ledgerWith(env2, "deactivating", { pi: "removed", "claude-code": "removed" }));
|
|
904
|
+
const finished = spawnSync("node", [DEACTIVATE], { encoding: "utf8", env: env2 });
|
|
905
|
+
|
|
906
|
+
ok(
|
|
907
|
+
"[QK:HAC-RETRY-RESUMES] a teardown that stopped halfway is resumable: the retry SKIPS every component the " +
|
|
908
|
+
"ledger already records as removed and continues at the next one, and a crash that left only the ledger " +
|
|
909
|
+
"behind is finished by re-running the verb. Re-running a completed inverse is not idempotent here — its " +
|
|
910
|
+
"ownership state is gone, so its preflight refuses forever, which is how the first cut turned one partial " +
|
|
911
|
+
`failure into a host nobody could ever finish cleaning (resume=${resumed.status} after=${JSON.stringify(afterResume)} finish=${finished.status} ledger-gone=${!fs.existsSync(layout2.ledgerPath)})`,
|
|
912
|
+
resumed.status === 0 &&
|
|
913
|
+
JSON.stringify(afterResume) === JSON.stringify({ theme: "dark", packages: [] }) &&
|
|
914
|
+
!fs.existsSync(layout.ledgerPath) &&
|
|
915
|
+
finished.status === 0 &&
|
|
916
|
+
!fs.existsSync(layout2.ledgerPath),
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ── 16. a pi-only activation is fully undone, and the roots it named survive ───
|
|
921
|
+
{
|
|
922
|
+
const env = world("roundtrip");
|
|
923
|
+
const layout = resolveActivationLayout(env);
|
|
924
|
+
const runtimeLayout = path.join(env.XDG_DATA_HOME as string, "entwurf", "herdr-plugin", "runtime");
|
|
925
|
+
const agentSettings = path.join(env.PI_CODING_AGENT_DIR as string, "settings.json");
|
|
926
|
+
const originalText = '{"packages":["/some/other/pkg"],"theme":"dark"}\n';
|
|
927
|
+
fs.writeFileSync(agentSettings, originalText);
|
|
928
|
+
const original = JSON.parse(originalText) as Record<string, unknown>;
|
|
929
|
+
|
|
930
|
+
sh(env, ["install-user-scope", "--plugin-runtime", runtimeRootOf(env)]);
|
|
931
|
+
// A certified runtime journal, so the runtime step has something of ours to retire.
|
|
932
|
+
fs.mkdirSync(runtimeRootOf(env), { recursive: true });
|
|
933
|
+
fs.mkdirSync(path.join(env.XDG_DATA_HOME as string, "entwurf", "herdr-plugin"), { recursive: true });
|
|
934
|
+
fs.writeFileSync(
|
|
935
|
+
path.join(env.XDG_DATA_HOME as string, "entwurf", "herdr-plugin", "journal.json"),
|
|
936
|
+
`${JSON.stringify({
|
|
937
|
+
schemaVersion: 2,
|
|
938
|
+
phase: "runtime-ready",
|
|
939
|
+
runtimeRoot: runtimeRootOf(env),
|
|
940
|
+
artifactIdentity: IDENTITY_A,
|
|
941
|
+
previousRuntime: null,
|
|
942
|
+
})}\n`,
|
|
943
|
+
);
|
|
944
|
+
writeLedger(layout, ledgerFor(env, ["pi"]));
|
|
945
|
+
|
|
946
|
+
const run = spawnSync("node", [DEACTIVATE], { encoding: "utf8", env });
|
|
947
|
+
const after = readJson(agentSettings) as Record<string, unknown>;
|
|
948
|
+
// FILES, not directories: an emptied `pi-package/` is a reclaimed install-state, and asserting on
|
|
949
|
+
// the directory name would call a clean inverse dirty.
|
|
950
|
+
const leftoverState: string[] = [];
|
|
951
|
+
const entwurfData = path.join(env.XDG_DATA_HOME as string, "entwurf");
|
|
952
|
+
const walkState = (dir: string): void => {
|
|
953
|
+
if (!fs.existsSync(dir)) return;
|
|
954
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
955
|
+
const abs = path.join(dir, e.name);
|
|
956
|
+
if (e.isDirectory()) walkState(abs);
|
|
957
|
+
else leftoverState.push(path.relative(entwurfData, abs));
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
walkState(entwurfData);
|
|
961
|
+
ok(
|
|
962
|
+
"[QK:HAC-DEACTIVATE-ROUNDTRIP] an explicit teardown returns the pi settings to their ORIGINAL meaning — " +
|
|
963
|
+
"unrelated keys intact, our entry gone, zero install-state — and then retires the runtime and the ledger. " +
|
|
964
|
+
"The oracle is JSON equality, not bytes: the settings writer preserves semantics but may reindent, so a " +
|
|
965
|
+
"byte oracle would fail a correct roundtrip and prove nothing extra. This is the whole promise the plugin " +
|
|
966
|
+
`makes to a host it no longer wants to be on (exit=${run.status} after=${JSON.stringify(after)} leftover-state=${JSON.stringify(leftoverState)} runtime-gone=${!fs.existsSync(runtimeLayout)} ledger-gone=${!fs.existsSync(layout.ledgerPath)})`,
|
|
967
|
+
run.status === 0 &&
|
|
968
|
+
JSON.stringify(after) === JSON.stringify({ packages: original.packages, theme: original.theme }) &&
|
|
969
|
+
leftoverState.length === 0 &&
|
|
970
|
+
!fs.existsSync(runtimeLayout) &&
|
|
971
|
+
!fs.existsSync(layout.ledgerPath),
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ── 17. nothing is imported after the runtime is deleted ───────────────────────
|
|
976
|
+
{
|
|
977
|
+
const sources = [DEACTIVATE, ACTIVATION, path.join(REPO, "scripts", "herdr-runtime.mjs")];
|
|
978
|
+
// Comments are stripped first: these files DOCUMENT the hazard, and a scan that cannot tell a
|
|
979
|
+
// warning about `import()` from a call to it would be satisfied by deleting the explanation.
|
|
980
|
+
const strip = (body: string): string => body.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
981
|
+
const dynamic = sources
|
|
982
|
+
.map((f) => [path.basename(f), strip(fs.readFileSync(f, "utf8"))] as const)
|
|
983
|
+
.filter(([, body]) => /\bimport\s*\(/.test(body) || /\brequire\s*\(/.test(body))
|
|
984
|
+
.map(([name]) => name);
|
|
985
|
+
const head = fs.readFileSync(DEACTIVATE, "utf8");
|
|
986
|
+
const importsAreTopLevel = head.split("\n").filter((l) => l.startsWith("import ")).length > 0;
|
|
987
|
+
ok(
|
|
988
|
+
"[QK:HAC-NO-DEFERRED-IMPORT] the teardown path loads no module lazily — measured on Linux, a process survives " +
|
|
989
|
+
"deleting the runtime it is executing from and keeps using what it already loaded, but an `import()` after " +
|
|
990
|
+
"that fails with ERR_MODULE_NOT_FOUND, which would strand a host with its runtime gone, its ledger " +
|
|
991
|
+
`unretired, and nothing left to run the rest (dynamic-import-files=${JSON.stringify(dynamic)} top-level=${importsAreTopLevel})`,
|
|
992
|
+
dynamic.length === 0 && importsAreTopLevel,
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// ── 18. a verb never acts across the other verb's phase ────────────────────────
|
|
997
|
+
{
|
|
998
|
+
const env = world("phases");
|
|
999
|
+
const layout = resolveActivationLayout(env);
|
|
1000
|
+
const claudeBin = fakeClaude(env);
|
|
1001
|
+
const runEnv = {
|
|
1002
|
+
...env,
|
|
1003
|
+
PATH: `${claudeBin}${path.delimiter}${env.PATH}`,
|
|
1004
|
+
FAKE_CLAUDE_LOG: path.join(env.HOME as string, "claude.log"),
|
|
1005
|
+
};
|
|
1006
|
+
seedRuntime(env);
|
|
1007
|
+
fs.writeFileSync(path.join(env.PI_CODING_AGENT_DIR as string, "settings.json"), '{"theme":"dark"}\n');
|
|
1008
|
+
|
|
1009
|
+
// forward over a teardown-in-progress: refused, and not one byte written.
|
|
1010
|
+
writeLedger(layout, ledgerWith(env, "deactivating", { pi: "active" }));
|
|
1011
|
+
const midTeardown = fs.readFileSync(layout.ledgerPath, "utf8");
|
|
1012
|
+
const piBefore = fs.readFileSync(path.join(env.PI_CODING_AGENT_DIR as string, "settings.json"), "utf8");
|
|
1013
|
+
const overTeardown = spawnSync("node", [ACTIVATE, "pi"], { encoding: "utf8", env: runEnv });
|
|
1014
|
+
|
|
1015
|
+
// inverse over an activation-in-progress: refused, told to repair the activation first.
|
|
1016
|
+
writeLedger(layout, ledgerWith(env, "activating", { pi: "active", "claude-code": "pending" }));
|
|
1017
|
+
const overActivating = spawnSync("node", [DEACTIVATE], { encoding: "utf8", env: runEnv });
|
|
1018
|
+
|
|
1019
|
+
// forward retry of the SAME set over `activating`: allowed, that is what the phase is for.
|
|
1020
|
+
const sameTarget = refusal(() => certifyPhaseForForward(readCertifiedLedger(layout), ["pi", "claude-code"]));
|
|
1021
|
+
// forward that ABANDONS a pending backend: refused, it would strand half-wired work.
|
|
1022
|
+
const changedTarget = refusal(() => certifyPhaseForForward(readCertifiedLedger(layout), ["pi"]));
|
|
1023
|
+
|
|
1024
|
+
ok(
|
|
1025
|
+
"[QK:HAC-PHASE-TRANSITIONS] neither verb acts across the other's phase — a forward run over a `deactivating` " +
|
|
1026
|
+
"ledger would re-wire what a teardown is midway through removing AND overwrite its record of where it " +
|
|
1027
|
+
"stopped, and a teardown over an `activating` ledger would SKIP every still-`pending` component (they are " +
|
|
1028
|
+
"not `active`, so the plan has no step for them) and then delete the runtime their half-written wiring " +
|
|
1029
|
+
`names. A retry of the same set is welcome; a request that abandons a pending backend is not ` +
|
|
1030
|
+
`(over-teardown=${overTeardown.status}/${(overTeardown.stderr || "").includes("activation-phase-refused")} ledger-untouched=${midTeardown === fs.readFileSync(layout.ledgerPath, "utf8") ? "n/a" : "changed"} over-activating=${overActivating.status}/${(overActivating.stderr || "").includes("activation-phase-refused")} same-target=${sameTarget} changed-target=${changedTarget})`,
|
|
1031
|
+
overTeardown.status !== 0 &&
|
|
1032
|
+
(overTeardown.stderr || "").includes("activation-phase-refused") &&
|
|
1033
|
+
piBefore === fs.readFileSync(path.join(env.PI_CODING_AGENT_DIR as string, "settings.json"), "utf8") &&
|
|
1034
|
+
overActivating.status !== 0 &&
|
|
1035
|
+
(overActivating.stderr || "").includes("activation-phase-refused") &&
|
|
1036
|
+
sameTarget === null &&
|
|
1037
|
+
changedTarget === "activation-phase-refused",
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// ── 19. every Claude root the install actually uses, including the HOME one ────
|
|
1042
|
+
{
|
|
1043
|
+
const env = world("claude-roots");
|
|
1044
|
+
const roots = resolveComponentRoots(env);
|
|
1045
|
+
const layout = resolveActivationLayout(env);
|
|
1046
|
+
writeLedger(layout, ledgerFor(env, ["claude-code"]));
|
|
1047
|
+
const ledger = readCertifiedLedger(layout) as Ledger;
|
|
1048
|
+
// Same XDG, same CLAUDE_CONFIG_DIR, HOME moved: the settings half still resolves under the
|
|
1049
|
+
// override, but `$HOME/.claude.json` — where the user-scope MCP entry lives — has moved with it.
|
|
1050
|
+
const movedHome = { ...env, HOME: path.join(env.HOME as string, "elsewhere") };
|
|
1051
|
+
const drifted = refusal(() =>
|
|
1052
|
+
certifyRootsAgainstLedger(ledger, resolveComponentRoots(movedHome), runtimeRootOf(env)),
|
|
1053
|
+
);
|
|
1054
|
+
const same = refusal(() => certifyRootsAgainstLedger(ledger, resolveComponentRoots(env), runtimeRootOf(env)));
|
|
1055
|
+
ok(
|
|
1056
|
+
"[QK:HAC-CLAUDE-ROOTS-COMPLETE] the ledger records EVERY root the install writes to — including " +
|
|
1057
|
+
"`$HOME/.claude.json`, which the state owner derives from HOME and NOT from `CLAUDE_CONFIG_DIR`. Measured: " +
|
|
1058
|
+
"with the same XDG and the same override but a moved HOME, a ledger that knew only the two obvious roots " +
|
|
1059
|
+
"went green and left the MCP entry behind in the old home. A record that claims to know where an install " +
|
|
1060
|
+
`went has to know all of it (userConfig=${path.basename(roots.claudeUserConfig.path)}/${roots.claudeUserConfig.source} home-drift=${drifted} same=${same})`,
|
|
1061
|
+
roots.claudeUserConfig.path === path.join(env.HOME as string, ".claude.json") &&
|
|
1062
|
+
roots.claudeUserConfig.source === "HOME" &&
|
|
1063
|
+
ledger.claudeUserConfig.path === roots.claudeUserConfig.path &&
|
|
1064
|
+
drifted === "activation-roots-drifted" &&
|
|
1065
|
+
same === null,
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// ── 20. the forward preflight covers both Pi halves and the real Claude mode ───
|
|
1070
|
+
{
|
|
1071
|
+
const env = world("aggregate-preflight");
|
|
1072
|
+
const claudeBin = fakeClaude(env);
|
|
1073
|
+
const runEnv = {
|
|
1074
|
+
...env,
|
|
1075
|
+
PATH: `${claudeBin}${path.delimiter}${env.PATH}`,
|
|
1076
|
+
FAKE_CLAUDE_LOG: path.join(env.HOME as string, "claude.log"),
|
|
1077
|
+
};
|
|
1078
|
+
const active = seedRuntime(env);
|
|
1079
|
+
const layout = resolveActivationLayout(env);
|
|
1080
|
+
fs.writeFileSync(path.join(env.PI_CODING_AGENT_DIR as string, "settings.json"), '{"theme":"dark"}\n');
|
|
1081
|
+
// A FOREIGN owner on the pi PACKAGE half only. The provider half would pass; the package half is
|
|
1082
|
+
// the one that refuses, and the ledger must not exist afterwards.
|
|
1083
|
+
const pkgState = piStatePaths(env).packageState;
|
|
1084
|
+
fs.mkdirSync(path.dirname(pkgState), { recursive: true });
|
|
1085
|
+
fs.writeFileSync(
|
|
1086
|
+
pkgState,
|
|
1087
|
+
`${JSON.stringify({
|
|
1088
|
+
schemaVersion: 1,
|
|
1089
|
+
managedSettingsPath: path.join(env.PI_CODING_AGENT_DIR as string, "settings.json"),
|
|
1090
|
+
scope: "user",
|
|
1091
|
+
packageRoot: "/somebody/elses/entwurf",
|
|
1092
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
1093
|
+
})}\n`,
|
|
1094
|
+
);
|
|
1095
|
+
const piBefore = fs.readFileSync(path.join(env.PI_CODING_AGENT_DIR as string, "settings.json"), "utf8");
|
|
1096
|
+
const refusedRun = spawnSync("node", [ACTIVATE, "pi"], { encoding: "utf8", env: runEnv });
|
|
1097
|
+
|
|
1098
|
+
// And the Claude half: the preflight must be told the mode on the ARGV the state owner reads.
|
|
1099
|
+
const claudePreflight = py(env, "meta-bridge-state.py", [
|
|
1100
|
+
"preflight-install",
|
|
1101
|
+
"--repo",
|
|
1102
|
+
REPO,
|
|
1103
|
+
"--plugin-runtime",
|
|
1104
|
+
active,
|
|
1105
|
+
]);
|
|
1106
|
+
ok(
|
|
1107
|
+
"[QK:HAC-AGGREGATE-PREFLIGHT-COMPLETE] the forward preflight asks EVERY authority the run will need — both pi " +
|
|
1108
|
+
"ownership records, not just the provider — and asks the Claude owner in the mode it will actually install: " +
|
|
1109
|
+
"the env var is the installer script's channel, while the state owner reads `--plugin-runtime` from argv, so " +
|
|
1110
|
+
"passing only the env preflights the DEFAULT mode and then installs the plugin one, which is a green check " +
|
|
1111
|
+
`of work nobody is going to do (pi-refused=${refusedRun.status} ledger-absent=${!fs.existsSync(layout.ledgerPath)} claude-mode=${(claudePreflight.stdout || "").includes("mode=plugin-runtime")})`,
|
|
1112
|
+
refusedRun.status !== 0 &&
|
|
1113
|
+
!fs.existsSync(layout.ledgerPath) &&
|
|
1114
|
+
piBefore === fs.readFileSync(path.join(env.PI_CODING_AGENT_DIR as string, "settings.json"), "utf8") &&
|
|
1115
|
+
claudePreflight.status === 0 &&
|
|
1116
|
+
(claudePreflight.stdout || "").includes("mode=plugin-runtime") &&
|
|
1117
|
+
(claudePreflight.stdout || "").includes(path.join(active, "node_modules", ".bin", "entwurf-bridge")) &&
|
|
1118
|
+
(claudePreflight.stdout || "").includes(path.join(active, "node_modules", ".bin", "entwurf-statusline")),
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ── 21. the runtime on disk must BE the one the journal certifies ──────────────
|
|
1123
|
+
{
|
|
1124
|
+
const env = world("exact-runtime");
|
|
1125
|
+
const claudeBin = fakeClaude(env);
|
|
1126
|
+
const runEnv = {
|
|
1127
|
+
...env,
|
|
1128
|
+
PATH: `${claudeBin}${path.delimiter}${env.PATH}`,
|
|
1129
|
+
FAKE_CLAUDE_LOG: path.join(env.HOME as string, "claude.log"),
|
|
1130
|
+
};
|
|
1131
|
+
const active = seedRuntime(env);
|
|
1132
|
+
const layout = resolveActivationLayout(env);
|
|
1133
|
+
// A tree that is usable and has the right NAME, but not the version the journal certifies.
|
|
1134
|
+
const pkgJson = path.join(active, "node_modules", "@junghanacs", "entwurf", "package.json");
|
|
1135
|
+
fs.writeFileSync(pkgJson, `${JSON.stringify({ name: "@junghanacs/entwurf", version: "0.99.0" })}\n`);
|
|
1136
|
+
const drifted = spawnSync("node", [ACTIVATE, "pi"], { encoding: "utf8", env: runEnv });
|
|
1137
|
+
ok(
|
|
1138
|
+
"[QK:HAC-RUNTIME-EXACT-VERSION] the disk check is bound to the journal's exact name AND version — a tree that " +
|
|
1139
|
+
"is merely 'a usable runtime of that name' is not the runtime this activation was certified against, and " +
|
|
1140
|
+
"wiring two harnesses at an address whose contents disagree with the record is a host where the provenance " +
|
|
1141
|
+
`line is a statement about something else (exit=${drifted.status} refusal=${(drifted.stderr || "").trim().split("\n").pop()} ledger-absent=${!fs.existsSync(layout.ledgerPath)})`,
|
|
1142
|
+
drifted.status !== 0 &&
|
|
1143
|
+
(drifted.stderr || "").includes("activation-runtime-not-ready") &&
|
|
1144
|
+
(drifted.stderr || "").includes("runtime-package-spec-mismatch") &&
|
|
1145
|
+
!fs.existsSync(layout.ledgerPath),
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// ── 22. the ledger carries the SAME identity union, through the same certifier ──
|
|
1150
|
+
{
|
|
1151
|
+
const env = world("ledger-identity");
|
|
1152
|
+
const layout = resolveActivationLayout(env);
|
|
1153
|
+
writeLedger(layout, ledgerFor(env, ["pi"]));
|
|
1154
|
+
const good = readCertifiedLedger(layout) as Ledger;
|
|
1155
|
+
const write = (body: unknown): string | null => {
|
|
1156
|
+
fs.writeFileSync(layout.ledgerPath, `${JSON.stringify(body)}\n`);
|
|
1157
|
+
return refusal(() => readCertifiedLedger(layout));
|
|
1158
|
+
};
|
|
1159
|
+
const cells: Record<string, string | null> = {
|
|
1160
|
+
"no-identity": write({ ...good, artifactIdentity: undefined }),
|
|
1161
|
+
"requested-shape": write({
|
|
1162
|
+
...good,
|
|
1163
|
+
artifactIdentity: { kind: "herdr-checkout", repository: IDENTITY_A.repository, commit: IDENTITY_A.commit },
|
|
1164
|
+
}),
|
|
1165
|
+
"foreign-repo": write({ ...good, artifactIdentity: { ...IDENTITY_A, repository: "someone/else" } }),
|
|
1166
|
+
"second-source-field": write({ ...good, source: "npm" }),
|
|
1167
|
+
"scalar-identity": write({ ...good, artifactIdentity: "herdr-checkout" }),
|
|
1168
|
+
};
|
|
1169
|
+
const verdicts = Object.entries(cells).map(([k, v]) => `${k}=${v}`);
|
|
1170
|
+
ok(
|
|
1171
|
+
"[QK:HAC-LEDGER-IDENTITY-CERTIFIED] the ledger records WHICH artifact it activated in the same union the runtime " +
|
|
1172
|
+
"journal uses, certified by the same imported certifier and in the READY shape only — a missing identity, a " +
|
|
1173
|
+
"requested shape (nothing observed it), a foreign repository, a scalar, and a SECOND source-ish field beside " +
|
|
1174
|
+
"it are each refused. A ledger that described the artifact in its own words could disagree with the journal " +
|
|
1175
|
+
"about which runtime the wiring below it names, and nothing on the host would be able to say which of the two " +
|
|
1176
|
+
`was right (${verdicts.join(" ")} keys=${JSON.stringify(LEDGER_KEYS)} good=${good.artifactIdentity.commit?.slice(0, 8)})`,
|
|
1177
|
+
Object.values(cells).every((v) => v === "activation-ledger-uncertified") &&
|
|
1178
|
+
LEDGER_KEYS.includes("artifactIdentity") &&
|
|
1179
|
+
good.artifactIdentity.commit === IDENTITY_A.commit,
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// ── 23. a SOURCE change is refused; a new commit on the same source may rebind ──
|
|
1184
|
+
{
|
|
1185
|
+
const env = world("rebind-legality");
|
|
1186
|
+
const layout = resolveActivationLayout(env);
|
|
1187
|
+
const ledgerA = () => {
|
|
1188
|
+
writeLedger(layout, ledgerFor(env, ["pi", "claude-code"]));
|
|
1189
|
+
return readCertifiedLedger(layout) as Ledger;
|
|
1190
|
+
};
|
|
1191
|
+
const sourceDrift = refusal(() => certifyArtifactForForward(ledgerA(), IDENTITY_NPM, ["pi", "claude-code"]));
|
|
1192
|
+
const matched = certifyArtifactForForward(ledgerA(), IDENTITY_A, ["pi"]);
|
|
1193
|
+
const legal = certifyArtifactForForward(ledgerA(), IDENTITY_B, ["pi", "claude-code", "pi"]);
|
|
1194
|
+
const fresh = certifyArtifactForForward(null, IDENTITY_B, ["pi"]);
|
|
1195
|
+
const dropped = refusal(() => certifyArtifactForForward(ledgerA(), IDENTITY_B, ["pi"]));
|
|
1196
|
+
writeLedger(layout, ledgerWith(env, "activating", { pi: "pending" }));
|
|
1197
|
+
const midActivation = refusal(() =>
|
|
1198
|
+
certifyArtifactForForward(readCertifiedLedger(layout) as Ledger, IDENTITY_B, ["pi"]),
|
|
1199
|
+
);
|
|
1200
|
+
writeLedger(layout, ledgerWith(env, "deactivating", { pi: "removed" }));
|
|
1201
|
+
const midTeardown = refusal(() =>
|
|
1202
|
+
certifyArtifactForForward(readCertifiedLedger(layout) as Ledger, IDENTITY_B, ["pi"]),
|
|
1203
|
+
);
|
|
1204
|
+
ok(
|
|
1205
|
+
"[QK:HAC-REBIND-LEGALITY] the runtime address is stable while the artifact under it is not, so a reinstall may " +
|
|
1206
|
+
"REBIND the ledger's claim — but only from a settled `active` ledger, with every component `active`, and only " +
|
|
1207
|
+
"when the request still covers every activated backend. A SOURCE change is not a reinstall at all: it is a " +
|
|
1208
|
+
"different acquisition authority inheriting an existing activation, and it needs the operator's explicit " +
|
|
1209
|
+
"teardown. Dropping a backend would leave its wiring pointing at a root no record names, and rebinding over " +
|
|
1210
|
+
"an unfinished transaction would discard the only account of how far that run got " +
|
|
1211
|
+
`(drift=${sourceDrift} match=${matched} legal=${legal} fresh=${fresh} dropped=${dropped} mid-activation=${midActivation} mid-teardown=${midTeardown})`,
|
|
1212
|
+
sourceDrift === "activation-artifact-source-drifted" &&
|
|
1213
|
+
matched === "match" &&
|
|
1214
|
+
legal === "rebind" &&
|
|
1215
|
+
fresh === "fresh" &&
|
|
1216
|
+
dropped === "activation-rebind-refused" &&
|
|
1217
|
+
midActivation === "activation-rebind-refused" &&
|
|
1218
|
+
midTeardown === "activation-rebind-refused",
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// ── 24. the rebind is ONE checkpoint, and what it leaves is resumable ─────────
|
|
1223
|
+
{
|
|
1224
|
+
const env = world("rebind-atomic");
|
|
1225
|
+
const layout = resolveActivationLayout(env);
|
|
1226
|
+
const log = path.join(env.HOME as string, "claude.log");
|
|
1227
|
+
const workingClaude = fakeClaude(env);
|
|
1228
|
+
const runEnv = { ...env, PATH: `${workingClaude}${path.delimiter}${env.PATH}`, FAKE_CLAUDE_LOG: log };
|
|
1229
|
+
fs.writeFileSync(path.join(env.PI_CODING_AGENT_DIR as string, "settings.json"), '{"theme":"dark"}\n');
|
|
1230
|
+
seedRuntime(env, IDENTITY_A);
|
|
1231
|
+
const firstRun = spawnSync("node", [ACTIVATE, "pi", "claude-code"], { encoding: "utf8", env: runEnv });
|
|
1232
|
+
const settled = readCertifiedLedger(layout) as Ledger;
|
|
1233
|
+
|
|
1234
|
+
// The artifact under the same stable root is replaced: new commit, SAME package version.
|
|
1235
|
+
seedRuntime(env, IDENTITY_B);
|
|
1236
|
+
|
|
1237
|
+
// (a) a refusal BEFORE the checkpoint leaves the old ledger byte-identical. The request drops a
|
|
1238
|
+
// backend the ledger holds, which is exactly the illegal rebind above.
|
|
1239
|
+
const ledgerTextBefore = fs.readFileSync(layout.ledgerPath, "utf8");
|
|
1240
|
+
const refusedRun = spawnSync("node", [ACTIVATE, "pi"], { encoding: "utf8", env: runEnv });
|
|
1241
|
+
const ledgerTextAfterRefusal = fs.readFileSync(layout.ledgerPath, "utf8");
|
|
1242
|
+
|
|
1243
|
+
// (b) a component that fails AFTER the checkpoint: the claude writer dies on its own CLI, so the
|
|
1244
|
+
// ledger must already name the NEW artifact and record exactly how far the run got.
|
|
1245
|
+
const brokenDir = path.join(env.HOME as string, "broken-claude-bin");
|
|
1246
|
+
fs.mkdirSync(brokenDir, { recursive: true });
|
|
1247
|
+
fs.writeFileSync(
|
|
1248
|
+
path.join(brokenDir, "claude"),
|
|
1249
|
+
[
|
|
1250
|
+
"#!/usr/bin/env bash",
|
|
1251
|
+
'case "$1 $2" in',
|
|
1252
|
+
' "--version "*|"--version") echo "2.1.217 (Claude Code)" ;;',
|
|
1253
|
+
' *) echo "broken fixture" >&2; exit 1 ;;',
|
|
1254
|
+
"esac",
|
|
1255
|
+
].join("\n"),
|
|
1256
|
+
);
|
|
1257
|
+
fs.chmodSync(path.join(brokenDir, "claude"), 0o755);
|
|
1258
|
+
const brokenRun = spawnSync("node", [ACTIVATE, "pi", "claude-code"], {
|
|
1259
|
+
encoding: "utf8",
|
|
1260
|
+
env: { ...env, PATH: `${brokenDir}${path.delimiter}${env.PATH}`, FAKE_CLAUDE_LOG: log },
|
|
1261
|
+
});
|
|
1262
|
+
const halfway = readCertifiedLedger(layout) as Ledger;
|
|
1263
|
+
|
|
1264
|
+
// (c) the SAME verb finishes it.
|
|
1265
|
+
const resumed = spawnSync("node", [ACTIVATE, "pi", "claude-code"], { encoding: "utf8", env: runEnv });
|
|
1266
|
+
const finished = readCertifiedLedger(layout) as Ledger;
|
|
1267
|
+
ok(
|
|
1268
|
+
"[QK:HAC-REBIND-ATOMIC-CHECKPOINT] the rebind lands in ONE ledger write: the new artifact identity and the " +
|
|
1269
|
+
"`activating` phase together, with every selected component back to `pending` because they are being " +
|
|
1270
|
+
"re-pointed at different bytes. A refusal before it leaves the previous ledger byte-identical; a component " +
|
|
1271
|
+
"failure after it leaves a ledger that already names the new artifact and says how far the run got, which the " +
|
|
1272
|
+
"SAME verb resumes. Splitting identity and phase across two writes would open a window where the ledger " +
|
|
1273
|
+
"claims the new artifact while still reporting the old transaction as finished " +
|
|
1274
|
+
`(first=${firstRun.status}/${settled.artifactIdentity.commit?.slice(0, 8)} refused=${refusedRun.status}/unchanged=${ledgerTextBefore === ledgerTextAfterRefusal} broken=${brokenRun.status}/${halfway.phase}/${halfway.artifactIdentity.commit?.slice(0, 8)}/${JSON.stringify(halfway.components)} resumed=${resumed.status}/${finished.phase}/${finished.artifactIdentity.commit?.slice(0, 8)})`,
|
|
1275
|
+
firstRun.status === 0 &&
|
|
1276
|
+
settled.phase === "active" &&
|
|
1277
|
+
settled.artifactIdentity.commit === IDENTITY_A.commit &&
|
|
1278
|
+
refusedRun.status !== 0 &&
|
|
1279
|
+
(refusedRun.stderr || "").includes("activation-rebind-refused") &&
|
|
1280
|
+
ledgerTextBefore === ledgerTextAfterRefusal &&
|
|
1281
|
+
brokenRun.status !== 0 &&
|
|
1282
|
+
halfway.phase === "activating" &&
|
|
1283
|
+
halfway.artifactIdentity.commit === IDENTITY_B.commit &&
|
|
1284
|
+
halfway.components.find((c) => c.backend === "pi")?.state === "active" &&
|
|
1285
|
+
halfway.components.find((c) => c.backend === "claude-code")?.state === "pending" &&
|
|
1286
|
+
resumed.status === 0 &&
|
|
1287
|
+
finished.phase === "active" &&
|
|
1288
|
+
finished.artifactIdentity.commit === IDENTITY_B.commit &&
|
|
1289
|
+
finished.components.every((c) => c.state === "active"),
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
console.log(`\ncheck-herdr-activation: ${passed} assertions passed`);
|