Skip to content

Commit 8b663e2

Browse files
committed
CoreUpgrade: re-pin the Interceptor extension after a deploy that clears its skill
The payload ships skills/Interceptor but not its built Extension/ -- a local pin produced by the skill's own Tools/Pin.sh. A wholesale clear of that skill removes the extension with no payload replacement, and the loss is quiet: Chrome keeps an already-loaded unpacked extension running from memory after its directory is gone, so browser verification keeps working until the next Chrome restart. Adds planRepin (pure decision) + runRepin (isolated execFile), announced in the dry-run plan and run after the rollback-guarded block so a failed re-pin can never roll back a good upgrade. It acts only when that skill was actually cleared, Pin.sh survived the deploy, and the operator's local build exists; otherwise it names the missing precondition and the manual command. Documents the one deliberate exception this creates to the payload-distrust stance in the header: the step executes a payload-shipped script. 5 new tests (32 total, all green).
1 parent b79d4ff commit 8b663e2

2 files changed

Lines changed: 112 additions & 1 deletion

File tree

LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
55
import {
66
isPreserved, computeClearList, planUpgrade, copyMissing, preflight, payloadHas,
77
parseArgs, safeRemove, scopedBackup, rollback, snapshotDeployTargets, extractCustomizations,
8-
planClaudeSplit, applyClaudeSplit, undoClaudeSplit,
8+
planClaudeSplit, applyClaudeSplit, undoClaudeSplit, planRepin,
99
} from "./LifeosUpgrade";
1010

1111
// TR6: every tmp root is tracked and removed after the suite — no orphaned temp trees.
@@ -372,3 +372,55 @@ test("split + rollback compose: deploy failure restores CLAUDE.md, removes GLOBA
372372
expect(existsSync(join(root, "LIFEOS/TOOLS/new.ts"))).toBe(false); // deploy remnant gone
373373
rmSync(bak, { recursive: true, force: true });
374374
});
375+
376+
// ── planRepin: the Interceptor extension is a payload-shipped skill's UNSHIPPED artifact ──
377+
// Regression guard: a wholesale clear of skills/Interceptor removes its pinned
378+
// Extension/ build, which the payload does not ship and cannot restore.
379+
380+
function repinTree(opts: { pin?: boolean; src?: boolean }) {
381+
const root = tmp("lu-rp-"), srcRoot = tmp("lu-rps-");
382+
if (opts.pin) {
383+
mkdirSync(join(root, "skills/Interceptor/Tools"), { recursive: true });
384+
writeFileSync(join(root, "skills/Interceptor/Tools/Pin.sh"), "#!/usr/bin/env bash\ntrue\n");
385+
}
386+
if (opts.src) mkdirSync(join(srcRoot, "extension/dist"), { recursive: true });
387+
return { root, srcRoot };
388+
}
389+
390+
test("planRepin: acts only when Interceptor was cleared AND both Pin.sh and the build exist", () => {
391+
const { root, srcRoot } = repinTree({ pin: true, src: true });
392+
const p = planRepin(root, ["LIFEOS/TOOLS", "skills/Interceptor"], srcRoot);
393+
expect(p.act).toBe(true);
394+
expect(p.script).toBe(join(root, "skills/Interceptor/Tools/Pin.sh"));
395+
expect(p.src).toBe(join(srcRoot, "extension/dist"));
396+
});
397+
398+
test("planRepin: no-ops when Interceptor is not in the clear list (nothing was deleted)", () => {
399+
const { root, srcRoot } = repinTree({ pin: true, src: true });
400+
const p = planRepin(root, ["LIFEOS/TOOLS"], srcRoot);
401+
expect(p.act).toBe(false);
402+
expect(p.reason).toContain("not being replaced");
403+
});
404+
405+
test("planRepin: declines with actionable reason when the local build dir is absent", () => {
406+
const { root, srcRoot } = repinTree({ pin: true, src: false });
407+
const p = planRepin(root, ["skills/Interceptor"], srcRoot);
408+
expect(p.act).toBe(false);
409+
expect(p.reason).toContain("INTERCEPTOR_SRC");
410+
});
411+
412+
test("planRepin: declines when the payload stopped shipping Pin.sh", () => {
413+
const { root, srcRoot } = repinTree({ pin: false, src: true });
414+
const p = planRepin(root, ["skills/Interceptor"], srcRoot);
415+
expect(p.act).toBe(false);
416+
expect(p.reason).toContain("Pin.sh absent");
417+
});
418+
419+
test("planRepin: is pure — it never creates or mutates anything on disk", () => {
420+
const { root, srcRoot } = repinTree({ pin: true, src: true });
421+
const before = readdirSync(root).sort();
422+
planRepin(root, ["skills/Interceptor"], srcRoot);
423+
planRepin(root, [], srcRoot);
424+
expect(readdirSync(root).sort()).toEqual(before);
425+
expect(existsSync(join(root, "skills/Interceptor/Extension"))).toBe(false);
426+
});

LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
* delete path), then reports the ACTUAL rollback outcome (partial failures named).
2121
* - all deletes go through safeRemove (isPreserved + path-escape, case-insensitive).
2222
*
23+
* ONE DELIBERATE EXCEPTION to the payload-distrust stance above: the post-deploy
24+
* re-pin step EXECUTES a payload-shipped script (skills/Interceptor/Tools/Pin.sh,
25+
* which after deploy is the payload's copy). This is not a new trust boundary in
26+
* practice — an upgraded install is about to run the payload's hooks, tools, and
27+
* skills anyway — but it IS script execution, so it is opt-out by construction:
28+
* it runs only when that skill was actually replaced AND the operator's local
29+
* interceptor build exists, is announced in the dry-run plan before --apply, and
30+
* sits outside the rollback-guarded block so it can never trigger a rollback.
31+
*
2332
* Known limitations (documented, low-risk): safeRemove's path-escape guard is
2433
* lexical (does not resolve a symlinked config-root ancestor); "backup precedes
2534
* delete" is enforced by main()'s call order, not an invariant inside safeRemove.
@@ -34,6 +43,7 @@
3443
import { existsSync, readdirSync, lstatSync, rmSync, mkdirSync, copyFileSync, cpSync, readFileSync, writeFileSync } from "node:fs";
3544
import { join, sep, resolve, dirname } from "node:path";
3645
import { homedir } from "node:os";
46+
import { execFileSync } from "node:child_process";
3747

3848
const ensureParentDir = (p: string) => mkdirSync(dirname(p), { recursive: true });
3949
const errCode = (e: unknown): string | undefined => (e as { code?: string })?.code;
@@ -151,6 +161,42 @@ export function planUpgrade(configRoot: string, payloadRoot: string): Plan {
151161
return { configRoot, payloadRoot, clear, deployRoots, warnings };
152162
}
153163

164+
// ── Post-deploy: re-pin the Interceptor browser extension ────────────────────
165+
// The payload ships skills/Interceptor but NOT its built Extension/ — a ~15MB
166+
// local pin of ~/Projects/interceptor/extension/dist produced by the skill's own
167+
// Tools/Pin.sh. Clearing that skill wholesale therefore deletes the extension
168+
// with no payload replacement, and the loss is quiet: Chrome keeps an already-
169+
// loaded unpacked extension running from memory after its directory disappears,
170+
// so browser verification keeps working until the next Chrome restart and then
171+
// fails as an unrelated-looking runner error. Pin.sh re-derives the pin from the
172+
// operator's local build, so recovery is deterministic when that build exists.
173+
//
174+
// Decision is pure (planRepin) and the side effect is isolated (runRepin) so the
175+
// interesting half is testable without shelling out.
176+
export type RepinPlan = { act: boolean; reason: string; script: string; src: string };
177+
178+
export function planRepin(configRoot: string, clear: string[], interceptorSrc?: string): RepinPlan {
179+
const script = join(configRoot, "skills", "Interceptor", "Tools", "Pin.sh");
180+
const src = join(interceptorSrc || join(homedir(), "Projects", "interceptor"), "extension", "dist");
181+
const p = (act: boolean, reason: string) => ({ act, reason, script, src });
182+
if (!clear.includes("skills/Interceptor")) return p(false, "skills/Interceptor not being replaced — extension untouched");
183+
if (!existsSync(script)) return p(false, `Pin.sh absent (${script}) — payload no longer ships it; re-pin by hand`);
184+
if (!existsSync(src)) return p(false, `interceptor build dir absent (${src}) — set INTERCEPTOR_SRC or rebuild, then run Pin.sh by hand`);
185+
return p(true, "extension was cleared with the skill — re-pinning from the local build");
186+
}
187+
188+
/** Runs Pin.sh via execFile (argument array, never a shell string). Never throws: a
189+
* failed re-pin must not fail an otherwise-successful upgrade — it is reported instead. */
190+
export function runRepin(plan: RepinPlan): { ok: boolean; output: string } {
191+
try {
192+
const out = execFileSync("/bin/bash", [plan.script], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
193+
return { ok: true, output: String(out).trim() };
194+
} catch (e) {
195+
const err = e as { stderr?: unknown; message?: string };
196+
return { ok: false, output: String(err?.stderr || err?.message || e).trim() };
197+
}
198+
}
199+
154200
// ── Preflight ────────────────────────────────────────────────────────────────
155201
export function preflight(configRoot: string, payloadRoot: string, backupDir?: string): string[] {
156202
const errs: string[] = [];
@@ -473,6 +519,9 @@ function main() {
473519
console.log(`\nPRESERVED (never touched): USER, MEMORY, ARBOL, .env*, CLAUDE.md, settings*.json, private skills/_*, harness dirs, and any entry the payload does NOT ship.`);
474520
console.log(`\nWARNINGS:`); plan.warnings.forEach((w) => console.log(" ⚠ " + w));
475521

522+
const repin = planRepin(configRoot, plan.clear, process.env.INTERCEPTOR_SRC);
523+
console.log(`\nPOST-DEPLOY re-pin (Interceptor extension): ${repin.act ? "WILL RUN" : "skip"}${repin.reason}`);
524+
476525
let splitPlan: SplitPlan | undefined;
477526
if (splitClaudeMd) {
478527
splitPlan = planClaudeSplit(configRoot, payloadRoot);
@@ -521,6 +570,16 @@ function main() {
521570
process.exit(1);
522571
}
523572

573+
// Post-deploy re-pin. Deliberately AFTER the rollback-guarded block: a re-pin
574+
// failure is not an upgrade failure, so it must not reach that catch.
575+
if (repin.act) {
576+
console.log(`[post] re-pinning Interceptor extension from ${repin.src}…`);
577+
const r = runRepin(repin);
578+
console.log(r.ok ? ` ${r.output}` : ` ⚠ re-pin FAILED (upgrade itself is fine): ${r.output}\n → run ${repin.script} by hand, then Load Unpacked in chrome://extensions/.`);
579+
} else if (plan.clear.includes("skills/Interceptor")) {
580+
console.log(`[post] ⚠ Interceptor extension NOT re-pinned — ${repin.reason}`);
581+
}
582+
524583
console.log(`\nDONE. Backup: ${backupDir}`);
525584
if (splitClaudeMd && splitPlan?.willSplit)
526585
console.log(`⚠ NEXT (REQUIRED): the new CLAUDE.md ships with ALL @-imports COMMENTED — your identity/TELOS/GLOBAL context is DORMANT until you run the payload's ActivateImports.ts. Run it FIRST, then reconcile settings + run InstallHooks.ts.`);

0 commit comments

Comments
 (0)