fix(template): register the session-end transcript archive hook in the devcontainer - #832
Merged
Merged
Conversation
…e devcontainer Closes #829. The hook has shipped in the devcontainer image and has never run. Claude Code executes only what a settings `hooks` block names, and nothing named it — so it was installed, tested, documented, and inert. ## The path is the staged copy, not /etc Registering `/etc/claude-code/hooks/session-end-archive.sh` would break the one-line image rollback that docs/architecture/devcontainer-image.md guarantees: revert the `FROM` pin to an image whose installer predates the hook and the setting names a file that image never creates — silently, for a hook that is deliberately async and quiet. The staged path has no such coupling. `COPY .devcontainer/config/` lives in the CONSUMER's Dockerfile, so the hook is there whatever image is pinned: the settings entry and the script it names ship as one artifact and roll back together. Mandatory hooks keep `/etc/…` — required_files guarantees them in every image, so no coupling exists to break. This reverses the path choice argued in #816. The reasons given there do not survive: "consistency" compared an optional hook against mandatory ones that cannot have this problem, and "protect-files.sh guards /etc" is not a real boundary — vscode has passwordless sudo and enable-claude-bypass.sh already writes managed settings with it. The documentation now says the staged path is chosen for rollback safety and explicitly NOT as a security boundary, because the earlier claim that it was agent-write-protected was false. ## Regression coverage Nothing could catch this class of bug: dogfood parity only compares the twins to each other, and #816's assertion checks the /etc copy this change does not use. The image test now reads the INSTALLED managed settings and requires every hook command they name to resolve to an executable, plus asserts SessionEnd is registered at the staged path specifically — a non-null check would still pass on an /etc path, because this candidate image does install that copy. That assertion also has to run. It lives in the image test, which fires on image inputs; a settings-only PR — exactly how this regresses — would have missed it. Added claude-settings.json to the PR-side paths filter beside ghostty.terminfo, which is there for the identical reason, leaving the push-side list untouched so repository config still never triggers a publish. Negative-controlled, each against the shipping code: deleting SessionEnd fails the presence check; a bad path fails the executable loop; reverting to /etc fails the staged-path assertion. The shipped guide said hook scripts live at /etc/claude-code/hooks/, which would send someone troubleshooting to a real file that is not the one running. It now distinguishes mandatory from optional hooks and says so. Verified: task verify, task ci, task test:devcontainer:image all green; 120 dogfood twins identical; actionlint and yamllint clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #829.
What
Registers the
session-end-archive.shhook so Claude Code actually runs it indevcontainers. This is the change that makes container transcript archiving
live — the hook has shipped in the image and never executed, because Claude
Code runs only what a settings
hooksblock names, and nothing named it.Why the staged path, not
/etc/claude-code/Registering
/etc/claude-code/hooks/session-end-archive.shwould break theone-line image rollback that
docs/architecture/devcontainer-image.mdguarantees. Revert the
FROMpin to an image whose installer predates the hookand the setting names a file that image never creates — silently, for a hook
that is deliberately
asyncand quiet.The staged path has no such coupling:
COPY .devcontainer/config/lives in theconsumer's Dockerfile, so the hook is present whatever image is pinned. The
settings entry and the script it names ship as one artifact and roll back
together. Mandatory hooks keep
/etc/…—required_filesguarantees them inevery image, so there is no coupling to break.
This reverses the path choice argued in #816, and that reversal is the
point rather than a detail. Neither original reason survives:
hooks, guaranteed in
/etcbyrequired_files. They cannot have thisproblem. An optional hook is genuinely a different case.
protect-files.shguards/etc/claude-code/" — not a real boundary.vscodehas passwordlesssudo, andenable-claude-bypass.shalreadywrites the managed settings there with it.
The documentation now states the staged path is chosen for rollback safety
and explicitly not as a security boundary — the earlier claim that root
ownership made it agent-write-protected was simply false.
Regression coverage
Nothing could catch this bug class. Dogfood parity only compares the twins to
each other, and #816's assertion checks the
/etccopy this change does notuse — so the hook could be unregistered, or pointed anywhere, with every gate
green.
The image test now reads the installed managed settings and requires every
hook command they name to resolve to an executable, and asserts
SessionEndisregistered at the staged path specifically. A non-null check is not enough: an
/etcpath would still pass, because this candidate image does install thatcopy.
That assertion also has to run. It lives in the image test, which fires on
image inputs — so a settings-only PR, which is exactly how this regresses, would
have missed it.
claude-settings.jsonis now in the PR-side paths filter besideghostty.terminfo, which is there for the identical reason. The push-side listis untouched, so repository config still never triggers a publish on
main.Negative-controlled, each against the shipping code:
SessionEnddeleted/etc/…Shipped docs
docs/guides/devcontainers.mdsaid hook scripts live at/etc/claude-code/hooks/, which would send someone troubleshooting to a realfile that is not the one running. It now distinguishes mandatory from
optional hooks and says so plainly. Both twins updated in lockstep.
Rigor
standard (default) → challenge ≤3, review ≤3, shepherd 4.
Six findings (one P1, five P2), all fixed in place, none deferred — every
one concerned this change's own correctness rather than the vendored script.
Verification
task verify,task ci, andtask test:devcontainer:imagegreen; 120 dogfoodtwins identical;
actionlintandyamllintclean.Not in this PR
evanharmon1/harmon-devkit#439,#440,#441— defects in the vendoredarchive script itself (corrupt archives never repaired, unbounded growth on the
volume, PID-reuse lock window). They become live once this merges and the hook
starts running. None blocks registration: archiving imperfectly beats a 30-day
deletion.
🤖 Generated with Claude Code