|
20 | 20 | * delete path), then reports the ACTUAL rollback outcome (partial failures named). |
21 | 21 | * - all deletes go through safeRemove (isPreserved + path-escape, case-insensitive). |
22 | 22 | * |
| 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 | + * |
23 | 32 | * Known limitations (documented, low-risk): safeRemove's path-escape guard is |
24 | 33 | * lexical (does not resolve a symlinked config-root ancestor); "backup precedes |
25 | 34 | * delete" is enforced by main()'s call order, not an invariant inside safeRemove. |
|
34 | 43 | import { existsSync, readdirSync, lstatSync, rmSync, mkdirSync, copyFileSync, cpSync, readFileSync, writeFileSync } from "node:fs"; |
35 | 44 | import { join, sep, resolve, dirname } from "node:path"; |
36 | 45 | import { homedir } from "node:os"; |
| 46 | +import { execFileSync } from "node:child_process"; |
37 | 47 |
|
38 | 48 | const ensureParentDir = (p: string) => mkdirSync(dirname(p), { recursive: true }); |
39 | 49 | const errCode = (e: unknown): string | undefined => (e as { code?: string })?.code; |
@@ -151,6 +161,42 @@ export function planUpgrade(configRoot: string, payloadRoot: string): Plan { |
151 | 161 | return { configRoot, payloadRoot, clear, deployRoots, warnings }; |
152 | 162 | } |
153 | 163 |
|
| 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 | + |
154 | 200 | // ── Preflight ──────────────────────────────────────────────────────────────── |
155 | 201 | export function preflight(configRoot: string, payloadRoot: string, backupDir?: string): string[] { |
156 | 202 | const errs: string[] = []; |
@@ -473,6 +519,9 @@ function main() { |
473 | 519 | 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.`); |
474 | 520 | console.log(`\nWARNINGS:`); plan.warnings.forEach((w) => console.log(" ⚠ " + w)); |
475 | 521 |
|
| 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 | + |
476 | 525 | let splitPlan: SplitPlan | undefined; |
477 | 526 | if (splitClaudeMd) { |
478 | 527 | splitPlan = planClaudeSplit(configRoot, payloadRoot); |
@@ -521,6 +570,16 @@ function main() { |
521 | 570 | process.exit(1); |
522 | 571 | } |
523 | 572 |
|
| 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 | + |
524 | 583 | console.log(`\nDONE. Backup: ${backupDir}`); |
525 | 584 | if (splitClaudeMd && splitPlan?.willSplit) |
526 | 585 | 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