instar 1.3.102 → 1.3.104

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.
Files changed (44) hide show
  1. package/dist/commands/gate.d.ts.map +1 -1
  2. package/dist/commands/gate.js +8 -1
  3. package/dist/commands/gate.js.map +1 -1
  4. package/dist/commands/nuke.d.ts.map +1 -1
  5. package/dist/commands/nuke.js +19 -10
  6. package/dist/commands/nuke.js.map +1 -1
  7. package/dist/commands/server.d.ts.map +1 -1
  8. package/dist/commands/server.js +81 -0
  9. package/dist/commands/server.js.map +1 -1
  10. package/dist/core/PostUpdateMigrator.d.ts +33 -0
  11. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  12. package/dist/core/PostUpdateMigrator.js +155 -20
  13. package/dist/core/PostUpdateMigrator.js.map +1 -1
  14. package/dist/core/TopicPlacementPinStore.d.ts +37 -0
  15. package/dist/core/TopicPlacementPinStore.d.ts.map +1 -0
  16. package/dist/core/TopicPlacementPinStore.js +95 -0
  17. package/dist/core/TopicPlacementPinStore.js.map +1 -0
  18. package/dist/server/middleware.d.ts.map +1 -1
  19. package/dist/server/middleware.js +13 -0
  20. package/dist/server/middleware.js.map +1 -1
  21. package/dist/threadline/listener-daemon.d.ts.map +1 -1
  22. package/dist/threadline/listener-daemon.js +18 -8
  23. package/dist/threadline/listener-daemon.js.map +1 -1
  24. package/dist/threadline/mcp-stdio-entry.js +8 -3
  25. package/dist/threadline/mcp-stdio-entry.js.map +1 -1
  26. package/package.json +1 -1
  27. package/scripts/threadline-bridge-backfill.mjs +7 -1
  28. package/src/data/builtin-manifest.json +29 -29
  29. package/src/templates/hooks/compaction-recovery.sh +24 -9
  30. package/src/templates/hooks/session-start.sh +9 -2
  31. package/src/templates/hooks/slack-channel-context.sh +8 -1
  32. package/src/templates/hooks/telegram-topic-context.sh +14 -2
  33. package/src/templates/scripts/imessage-reply.sh +7 -3
  34. package/src/templates/scripts/instar-watchdog.sh +14 -5
  35. package/src/templates/scripts/secret-drop-retrieve.mjs +12 -4
  36. package/src/templates/scripts/serendipity-capture.sh +15 -9
  37. package/src/templates/scripts/slack-reply.sh +8 -1
  38. package/src/templates/scripts/telegram-reply.sh +11 -5
  39. package/src/templates/scripts/whatsapp-reply.sh +7 -4
  40. package/upgrades/1.3.103.md +77 -0
  41. package/upgrades/1.3.104.md +58 -0
  42. package/upgrades/NEXT.eli16.md +71 -0
  43. package/upgrades/side-effects/mesh-rpc-auth-and-transfer-activation.md +72 -0
  44. package/upgrades/side-effects/secret-externalization-hook-resolver-audit.md +48 -0
@@ -0,0 +1,77 @@
1
+ ---
2
+ review-convergence: complete
3
+ approved: true
4
+ approved-by: justin (verbal, topic 2169: "yes! then we need to do a full, robust audit to fix this whole class of issues since it keeps coming up")
5
+ ---
6
+
7
+ # Upgrade Guide — vNEXT
8
+
9
+ <!-- bump: patch -->
10
+
11
+ ## What Changed
12
+
13
+ **Fleet-wide fix for the silent-403 class.** Every shipped hook, script, Node
14
+ helper, and migrator-emitted template that resolves the agent's `authToken`
15
+ now survives the secret-externalization refactor. Two related bugs landed
16
+ together because both produced the same user-visible symptom (a hook that
17
+ stops emitting any output):
18
+
19
+ - **Auth-token resolution: env-first with a string-type guard.**
20
+ `INSTAR_AUTH_TOKEN` (set by `SessionManager` per spawned session and by
21
+ `JobScheduler` per scheduled job) is checked first. The disk fallback now
22
+ guards `cfg.authToken` against non-string values, so the literal
23
+ `{ "secret": true }` placeholder produced by `SecretMigrator` after
24
+ multi-machine pairing can never leak through as a Bearer token. (Previously,
25
+ scripts sent `[object Object]` or `{'secret': True}` as the Authorization
26
+ header, the server 403'd, and the script's downstream Python parser
27
+ silently exited on the error JSON — no output at all.)
28
+
29
+ - **Port-parse tolerates whitespace.** The `grep -o '"port":[0-9]*'` pattern
30
+ used in every hook required no whitespace between the colon and the
31
+ number, but our prettified `config.json` writes `"port": 4042` (with
32
+ space). The pattern silently produced empty `$PORT`, the hook exited
33
+ early, and not a single HTTP request reached the server. Replaced with
34
+ `grep -oE '"port"[[:space:]]*:[[:space:]]*[0-9]+'` + digit-only extraction.
35
+
36
+ The structural cure includes a unit-tier lint that fails any future re-
37
+ introduction of the broken pattern (positive + negative-verified) and a new
38
+ migration pass (`migrateSecretExternalizationSurvivability`) that upgrades
39
+ deployed auxiliary scripts (`imessage-reply.sh`, `serendipity-capture.sh`,
40
+ `slack-channel-context.sh`) without touching custom forks. The canonical
41
+ always-overwrite hooks (`session-start.sh`, `compaction-recovery.sh`,
42
+ `telegram-topic-context.sh`) heal on next auto-update by the existing
43
+ `migrateHooks` path.
44
+
45
+ ## What to Tell Your User
46
+
47
+ If your agent recently went silent after compaction — emitting only the
48
+ wall-clock-time block and then nothing else, despite a healthy server — this
49
+ is the fix. Topic-history injection is back. No configuration needed.
50
+
51
+ After auto-update, send yourself a Telegram message in any forum topic.
52
+ The agent's response should reference what you actually said — not greet
53
+ you like it's the first message. If it doesn't, something else is wrong;
54
+ please flag.
55
+
56
+ ## Summary of New Capabilities
57
+
58
+ | Capability | How to Use |
59
+ |-----------|-----------|
60
+ | Auth-token resolution survives secret-externalization | Automatic. Every shipped script + hook now reads `INSTAR_AUTH_TOKEN` env first, then falls back to `config.json` with a string-type guard. |
61
+ | Port-parse tolerates JSON whitespace | Automatic. Hooks no longer exit-early when `config.json` is prettified. |
62
+ | Lint blocks future regressions | `tests/unit/secret-externalization-hook-resolver-lint.test.ts` greps every shell + Node read for the broken pattern and fails on any reintroduction. |
63
+ | Auxiliary-script migration for deployed agents | Existing agents with the old `imessage-reply.sh` / `serendipity-capture.sh` / `slack-channel-context.sh` get the fix on next auto-update via `migrateSecretExternalizationSurvivability`. Custom forks (no shipped marker) are untouched. |
64
+
65
+ ## Evidence
66
+
67
+ - 13 new tests across 3 tiers (unit + integration-grade + e2e). All green on a
68
+ full `vitest` run.
69
+ - 35 PostUpdateMigrator-* unit test files (232 tests) verified clean (no
70
+ regression from migrator extensions).
71
+ - Manual smoke test against a real externalized agent: the fixed
72
+ `getTelegramTopicContextHook()` content correctly fetches topic history
73
+ with INSTAR_AUTH_TOKEN env alone (config.json holds the placeholder), and
74
+ correctly degrades to the unauth branch without leaking the placeholder
75
+ when env is missing.
76
+ - Side-effects review:
77
+ `upgrades/side-effects/secret-externalization-hook-resolver-audit.md`.
@@ -0,0 +1,58 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- bump: patch -->
4
+
5
+ ## What Changed
6
+
7
+ **Two fixes that make running one agent across multiple machines actually work
8
+ end-to-end.**
9
+
10
+ First, a real bug: the private channel machines use to send each other commands
11
+ was being blocked by the normal login check before it could even verify the
12
+ message's own signature. Because each machine has its own login token, one
13
+ machine could never present a token the other would accept — so every
14
+ cross-machine action (checking if a peer is alive, handing a conversation to
15
+ another machine, moving one) was silently refused. That channel is already
16
+ protected by a strong per-machine cryptographic signature, so it now correctly
17
+ skips the token check and relies on the signature. This is the missing piece
18
+ that made a second machine show as offline even when both machines could reach
19
+ each other perfectly.
20
+
21
+ Second, the headline "move this to the mini" feature is now wired all the way
22
+ through. Saying "move this to <machine nickname>" (or "run this on <nickname>")
23
+ in a conversation now pins that conversation to the named machine and hands it
24
+ over, so it continues there. Before, the phrase was understood but nothing acted
25
+ on it.
26
+
27
+ ## What to Tell Your User
28
+
29
+ If you run on one machine, nothing changes. If you run across two machines: a
30
+ second machine will now correctly show as online and become eligible to receive
31
+ conversations, and you can say "move this to the mini" (using whatever nickname
32
+ shows on your Machines list) to hand the current conversation to that machine —
33
+ it picks up there. Both machines need to update for this to work.
34
+
35
+ ## Summary of New Capabilities
36
+
37
+ - The machine-to-machine command channel is exempt from the API token check and
38
+ authed solely by its per-machine cryptographic signature — so cross-machine
39
+ presence, delivery, and transfer work over the network, not just in tests.
40
+ - New `TopicPlacementPinStore`: durably pins a conversation to a chosen machine.
41
+ - "move this to <nickname>" / "run this on <nickname>" is wired on inbound:
42
+ recognize the command, pin the conversation, release local ownership, and let
43
+ it re-place onto the named machine via the existing place-and-resume path.
44
+
45
+ ## Evidence
46
+
47
+ - `tests/unit/mesh-rpc-auth-exemption.test.ts` — the machine command route
48
+ reaches its handler with no token (and ignores a wrong one — the signature is
49
+ the auth); a normal protected route still requires the token.
50
+ - `tests/unit/topic-placement-pin-store.test.ts` — pin set/get/clear, durable
51
+ across restarts, tolerant of a corrupt file.
52
+ - `tests/unit/transfer-activation-wiring.test.ts` — the recognizer/planner are
53
+ wired on inbound before routing, a transfer sets the pin + releases ownership,
54
+ the pin is passed into placement, and the whole path is dark-gated.
55
+ - Found on real hardware (laptop + Mac mini): the mini stayed offline because its
56
+ `/mesh/rpc` returned 401 before the signed envelope was checked; with the
57
+ exemption the cross-machine calls authenticate off the envelope as designed.
58
+ - Side-effects: `upgrades/side-effects/mesh-rpc-auth-and-transfer-activation.md`.
@@ -0,0 +1,71 @@
1
+ # vNEXT — plain English overview
2
+
3
+ ## What this change is
4
+
5
+ Your agent has a bunch of helper scripts that need to talk to its own local
6
+ server. They authenticate using an "auth token" — basically a password the
7
+ server expects in every request.
8
+
9
+ Recently we added a feature that **moves that password out of the plain
10
+ config file into an encrypted store**, so the password no longer sits in
11
+ plain text on disk. That's a good security improvement.
12
+
13
+ But when we did that, we forgot to update *every* place the helpers read
14
+ the password from. So a bunch of helpers kept reading the plain config
15
+ file, found the placeholder marker (a tiny note that says "the real
16
+ password lives in the encrypted store now"), and used **that marker** as
17
+ the password. The server rejected every request. The helpers didn't
18
+ notice the rejection — they just quietly emitted nothing.
19
+
20
+ The user-visible result: your agent comes back from a memory compaction
21
+ and has no idea what conversation you were in. So it greets you like a
22
+ stranger.
23
+
24
+ ## What already exists
25
+
26
+ - The `INSTAR_AUTH_TOKEN` environment variable. Every time the agent
27
+ spawns a new session, it sets this env var with the real password.
28
+ - The encrypted secret store at `.instar/secrets/config.secrets.enc`,
29
+ managed by `SecretMigrator` / `SecretStore`.
30
+ - A migration system (`PostUpdateMigrator`) that rewrites helper scripts
31
+ on every auto-update. Some helpers were already on this track; others
32
+ weren't.
33
+
34
+ ## What's new
35
+
36
+ - **Every helper now reads `INSTAR_AUTH_TOKEN` env first.** The env var
37
+ is always present in a session, so it's the canonical path.
38
+ - **The disk-fallback now has a "is this actually a password?" check.**
39
+ If the config file says `{ "secret": true }` (the placeholder), the
40
+ guard rejects it and falls through to no-auth instead of sending the
41
+ placeholder as a password.
42
+ - **A second, unrelated bug** also got fixed in the same change: the
43
+ helpers parsed the server port from the config file with a regex that
44
+ refused to allow whitespace, so `"port": 4042` (the format we
45
+ actually ship) was unparseable. The helpers exited silently with no
46
+ port, hitting nothing. Replaced with a whitespace-tolerant pattern.
47
+ - **A new automated check** scans every helper for this broken pattern
48
+ and refuses to commit any future change that reintroduces it.
49
+ - **An update routine** that fixes the helpers on existing deployed
50
+ agents (without overwriting any custom scripts the user wrote
51
+ themselves).
52
+
53
+ ## What you need to decide
54
+
55
+ Nothing. This is a bug fix, no configuration involved. Auto-update will
56
+ heal every deployed agent on next version bump.
57
+
58
+ ## How to verify it worked after deploy
59
+
60
+ After your agent updates past the version that includes this fix, send
61
+ yourself a Telegram message in any forum topic. The agent's response
62
+ should reference what you actually said — not greet you like it's the
63
+ first message. If it doesn't, something else is wrong; please flag.
64
+
65
+ ## Why this matters more than it might look
66
+
67
+ This is a "silent failure" class — the helpers were broken but emitted
68
+ no error, so the bug only surfaced through the symptom (incoherent
69
+ agent replies after compaction). That makes the failure invisible until
70
+ a user notices the symptom. The structural lint added in this change
71
+ makes the class itself impossible to reintroduce silently.
@@ -0,0 +1,72 @@
1
+ # Side-effects review — /mesh/rpc bearer-auth exemption + transfer-by-nickname activation
2
+
3
+ ## What was happening (real-hardware, 2026-05-29)
4
+
5
+ After the HTTP presence transport (B, v1.3.102) deployed to the laptop, the Mac
6
+ mini STILL showed offline in `GET /pool`. Root cause, found by probing the mini's
7
+ `/mesh/rpc` over its tunnel: **401 "Missing or invalid Authorization header"**.
8
+
9
+ `/mesh/rpc` — the §L0 machine-to-machine command transport — was sitting behind
10
+ the general API bearer-token middleware, but `MeshRpcClient.send` sends only the
11
+ signed envelope (no `Authorization` header), and it *can't* send a useful one:
12
+ each machine install holds its own `authToken`, so the sender has no token the
13
+ receiver would accept. So EVERY cross-machine call — the new presence pull AND
14
+ `deliverMessage` / `place` / `claim` / `transfer` — was rejected 401 before the
15
+ dispatcher's envelope check ever ran. The pool passed all in-process dispatcher
16
+ tests yet was completely dead over a real tunnel.
17
+
18
+ Separately, the headline "move this to <nickname>" trigger was unbuilt: the
19
+ recognizer + planner existed as pure tested units but had no caller, nothing
20
+ persisted the pin, and `SessionRouter.route()` was called with no `topicMetadata`
21
+ — so placement always fell back to least-loaded and a relocation command did
22
+ nothing.
23
+
24
+ ## The fix
25
+
26
+ 1. **`src/server/middleware.ts`** — exempt `/mesh/rpc` from the bearer gate, next
27
+ to the other signature/HMAC-authed m2m endpoints (`/a2a/inbox`, relay). The
28
+ route is authed SOLELY by its Ed25519 signed, recipient-bound envelope
29
+ (verify → RBAC → nonce, in the dispatcher). This single change is what makes
30
+ the cross-machine pool function over the wire at all.
31
+
32
+ 2. **`src/core/TopicPlacementPinStore.ts`** (new) — durable `{topicId → {preferredMachine,
33
+ pinned, updatedAt}}`. `asTopicMetadata()` shapes the pin for
34
+ `PlacementExecutor.decide`; `lastUpdatedAtMs()` feeds the rate-limit guard.
35
+
36
+ 3. **`src/commands/server.ts`** — wire the §L4 transfer-by-nickname activation
37
+ (gated on stage ≠ 'dark', BEFORE the route() interception): recognize the
38
+ command → plan → on transfer/noop set the pin AND release local ownership so
39
+ the topic's next message re-places onto the pinned machine via the
40
+ already-wired placeAndClaim → spawnOnMachine → owner-side `onAccepted` resume
41
+ path; confirm-required/reject reply to the user; the command message is
42
+ consumed. And pass `topicMetadata: pinStore.asTopicMetadata(sessionKey)` into
43
+ `route()` so placement honors the pin.
44
+
45
+ ## Blast radius
46
+
47
+ - **The auth exemption is the only change to a live, always-on code path.** It
48
+ REMOVES a gate from `/mesh/rpc`; the dispatcher's envelope verification (which
49
+ already ran on every accepted call) becomes the sole auth — strictly stronger
50
+ than a shared bearer for cross-machine (per-peer Ed25519 keys, recipient
51
+ binding, nonce replay protection). A non-mesh / single-machine agent never
52
+ receives a `/mesh/rpc` POST, so the exemption is inert for it.
53
+ - **The activation is dark-gated.** The recognizer/pin/route-metadata wiring only
54
+ runs when `_sessionPoolStage() !== 'dark'` (default dark), so it ships inert and
55
+ is exercised only in the controlled rollout/proof. The pin store is a new file
56
+ under `stateDir/session-pool/`; nothing reads it unless the activation runs.
57
+ - **No new config / route / schema / hook / skill → no migration.** Server +
58
+ middleware + one new core module; existing agents pick it up on the next
59
+ release. Both machines need it (the receiver's `/mesh/rpc` must accept the
60
+ envelope; the router needs the recognizer + pin).
61
+
62
+ ## Tests
63
+
64
+ - `tests/unit/mesh-rpc-auth-exemption.test.ts` — `/mesh/rpc` reaches its handler
65
+ with NO Authorization header (and even with a wrong bearer — the envelope is
66
+ the auth); a normal protected path still 401s without a token.
67
+ - `tests/unit/topic-placement-pin-store.test.ts` — set/get/asTopicMetadata/
68
+ lastUpdatedAtMs/clear, durable across instances, corrupt-file tolerance.
69
+ - `tests/unit/transfer-activation-wiring.test.ts` — server boot constructs the
70
+ pin store + recognizer/planner imports, recognizes on inbound before route(),
71
+ applies a transfer (sets pin + releases ownership), returns when handled, passes
72
+ the pin as `topicMetadata`, and is dark-gated.
@@ -0,0 +1,48 @@
1
+ # Side-effects review — secret-externalization hook resolver audit
2
+
3
+ ## What changed
4
+
5
+ Two related fleet-wide classes are fixed in one PR.
6
+
7
+ 1. **Auth-token reads survive secret-externalization.** Every shipped shell hook, shell script, Node helper, and migrator-emitted template string that resolves the instar server's `authToken` now:
8
+ - Prefers `INSTAR_AUTH_TOKEN` env (set by `SessionManager` per spawned session and by `JobScheduler` per scheduled job — always available inside the Claude Code session).
9
+ - Falls back to reading `config.json` with a string-type guard. When `SecretMigrator` has externalized the token, on-disk `authToken` is the literal placeholder `{ "secret": true }`; the guard rejects it and yields empty so the placeholder cannot leak as a Bearer.
10
+ 2. **Port-parse regex tolerates whitespace.** `grep -o '"port":[0-9]*'` is replaced by `grep -oE '"port"[[:space:]]*:[[:space:]]*[0-9]+'` + digit-only extraction. The previous form silently produced empty PORT whenever `config.json` had `"port": 4042` (with space) — and our prettified configs all do — making every history-injection hook exit early on a healthy install. Both bugs together produced the 2026-05-29 silent-history failure.
11
+
12
+ ## Why
13
+
14
+ The 2026-05-29 incident: Justin's `telegram-topic-context.sh` stopped injecting topic history after the agent compacted. The hook silently 403'd because `authToken` had been moved to the encrypted secret store and the hook was still reading the literal `{ secret: true }` placeholder out of `config.json` as a Bearer token. The agent came back with no topic context, sent an incoherent reply, and Justin had to manually trace the root cause back. This is the SECOND time a script in this class has bitten a deployed agent (the 2026-05-28 `telegram-reply.sh` 403 was the first), so the fix is structural: env-first everywhere + string-type guard + lint that catches the broken pattern + migrator that upgrades stale-on-disk copies.
15
+
16
+ ## Risk surface
17
+
18
+ - **Hook scripts (always-overwritten):** `session-start.sh`, `compaction-recovery.sh`, `telegram-topic-context.sh`. Existing agents get the fix on first auto-update. The canonical content lives inside `PostUpdateMigrator.getHookContent()`; the parallel `src/templates/hooks/*.sh` files are kept in sync (the manifest tracks their SHA256). Risk: low — these are already overwritten on every migration tick.
19
+ - **Reply scripts (SHA-based migration):** `telegram-reply.sh` upgrades via `migrateReplyScriptToPortConfig` (pre-PR SHA `371d7e8f…` is already in the prior-shipped allowlist; agents get the fix on first auto-update). `slack-reply.sh` and `whatsapp-reply.sh` upgrade via `migrateReplyScriptTo408`, extended to also fire when `INSTAR_AUTH_TOKEN` is missing — previously the function skipped any script with HTTP 408 handling, which would have stranded a 408-aware-but-auth-broken copy forever. Risk: low — the marker-string check still protects custom forks.
20
+ - **Auxiliary scripts (new migration):** `imessage-reply.sh`, `serendipity-capture.sh`, `slack-channel-context.sh` get a new `migrateSecretExternalizationSurvivability` pass that upgrades shipped copies and skips custom forks (shipped-marker check). Risk: low — guarded by both the marker check and the negative `INSTAR_AUTH_TOKEN` check.
21
+ - **CLI/in-process callers:** `gate.ts` and `nuke.ts` did raw `JSON.parse(fs.readFileSync('config.json'))` then `cfg.authToken` directly. Patched to env-first + string-type-guard. The other CLI commands go through `loadConfig()` (which calls `mergeConfigWithSecrets`) and are already safe. Risk: low — the change is the same env-first/string-guard pattern.
22
+ - **Daemons:** `listener-daemon.ts` (HMAC-derives a signing key from `authToken`), `mcp-stdio-entry.ts` (uses `authToken` as the inbound Bearer). Both now env-first + string-type-guard. Risk: low — the env-first path is preferred when the daemon is launched by the spawn pipeline (which exports the var); the disk fallback still works for plaintext-config installs.
23
+ - **CI / lint:** new unit test `secret-externalization-hook-resolver-lint.test.ts` greps every shell + Node read of `authToken`. The lint MUST go green on the fixed tree (verified) and MUST fail on any re-introduction of the broken pattern (verified via destructive negative test). Risk: low — the lint is allowlisted for in-process `loadConfig` callers; future authors won't trip on the safe path.
24
+
25
+ ## Bug surfaces eliminated
26
+
27
+ - Topic-history injection silently 403s after secret externalization. (Direct cause of the 2026-05-29 incident.)
28
+ - `telegram-reply.sh` 403s when invoked from a session whose env is missing — partially mitigated in the 2026-05-28 fix but the audit confirmed the disk-fallback path was still broken; this PR closes both halves.
29
+ - `session-start.sh` could not fetch working-memory or shared-state after externalization (same root cause as the topic-history bug).
30
+ - `compaction-recovery.sh` lost its post-compaction memory query after externalization.
31
+ - A standby-machine daemon whose key derivation went through `authToken` would silently derive a key from `{ secret: true }` and lose peer connectivity. (Speculative — no incident observed, but the path was broken.)
32
+
33
+ ## Migration footprint
34
+
35
+ - `migrateHooks()` already always-overwrites the three canonical hooks → existing agents heal automatically on next auto-update.
36
+ - `migrateScripts()` already SHA-migrates `telegram-reply.sh` and now (with the extended INSTAR_AUTH_TOKEN check) auto-migrates `slack-reply.sh` + `whatsapp-reply.sh` when the previously-shipped marker is present.
37
+ - `migrateSecretExternalizationSurvivability()` (NEW) auto-migrates `imessage-reply.sh`, `serendipity-capture.sh`, `slack-channel-context.sh`. Idempotent on the post-fix shape.
38
+
39
+ ## Testing
40
+
41
+ - Unit (lint): `tests/unit/secret-externalization-hook-resolver-lint.test.ts` — 3 tests. Greps `src/templates/`, `src/`, `scripts/` for the broken pattern; verified negative (broken pattern caught) and positive (clean pattern passes).
42
+ - Unit (migrator): `tests/unit/secret-externalization-survivability-migrator.test.ts` — 5 tests. Seeds broken state, runs `migrate()`, asserts upgrade. Custom-fork test verifies the marker check protects user-modified scripts.
43
+ - Integration-grade (in `tests/unit/`): `tests/unit/secret-externalization-hook-injection.test.ts` — 2 tests. Spins an in-process stub server (bound to all interfaces; ASYNC `spawn` is required because `execFileSync` blocks Node's event loop and freezes the stub). Seeds `config.json` with the placeholder, runs the canonical migrator-emitted hook, asserts injection works with env-first auth and never leaks the placeholder as a Bearer.
44
+ - E2E: `tests/e2e/secret-externalization-hook-lifecycle.test.ts` — 3 tests. Simulates pre-fix agent → `migrate()` → asserts every shipped script carries the env-first canary; verifies idempotency on a second pass; verifies the canonical migrator-emitted hooks always carry the canary.
45
+
46
+ ## Test count
47
+
48
+ 13 new tests across 3 tiers (per Testing Integrity Standard). 29 existing related tests (`PostUpdateMigrator-time-injection.test.ts`, `compaction-telegram-context.test.ts`) remain green.