feat(TASK-039): wire hooks into bundle installs and reconcile builtin hook dirs

Bundles: RemoteLayout.hooks + scan/is_empty/plan routing into hooks_dir,
a functional --filter hooks arm replacing the inert interim arm,
TopCategory::Hooks provenance rows, and per-file 'bundle defines hook
script' summary lines (visibility only, no approval prompt).

Reconciliation: new builtin_manifest module — a hidden per-directory
.builtin-manifest records the files the builtin installer actually owns
(written this run, previously owned, or pending failed deletions);
deletion is strictly manifest-minus-embed so user-created files in the
same directory are never touched. Wired into install_builtin_agents for
agents/<name>/hooks/ and a greenfield minimal Role::install_builtin_role_hooks
for roles/hooks/.

Hardening: bundles are barred from shipping any path component that
aliases .builtin-manifest (Unicode case fold + trailing dot/space,
directories included); manifest entries reuse the bundle store's
is_safe_component rules; malformed manifests fail safe toward keeping
files; role-side reconcile failures degrade to a warning like the
agent side.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 13:44:35 -06:00
1 parent e62502c862
commit 42380d4b82
7 files changed
+893 -8

No files matched your search

+47
View File
@@ -9290,6 +9290,53 @@ mod tests {
assert!(agents_dir.join("architect").join("config.yaml").exists());
}
#[test]
#[serial]
fn install_builtin_agents_reconciles_hooks_subdir_via_manifest() {
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
let agents_dir = paths::agents_data_dir();
// Simulate an upgrade from an older install whose coder shipped a
// hook script that the current embed no longer contains: the manifest
// records it, so reconciliation removes it — while a user-created
// script in the same directory (absent from the manifest) survives.
let hooks_dir = agents_dir.join("coder").join("hooks");
create_dir_all(&hooks_dir).unwrap();
write(hooks_dir.join(BUILTIN_MANIFEST_FILE), "old-hook.sh\n").unwrap();
write(hooks_dir.join("old-hook.sh"), "stale").unwrap();
write(hooks_dir.join("user-hook.sh"), "user-owned").unwrap();
// A custom agent outside the bundle is never reconciled, even with a
// manifest present.
let custom_hooks = agents_dir.join("myagent").join("hooks");
create_dir_all(&custom_hooks).unwrap();
write(custom_hooks.join(BUILTIN_MANIFEST_FILE), "custom.sh\n").unwrap();
write(custom_hooks.join("custom.sh"), "keep").unwrap();
Agent::install_builtin_agents(false).unwrap();
assert!(
!hooks_dir.join("old-hook.sh").exists(),
"manifest-listed hook absent from the embed must be removed"
);
assert!(
hooks_dir.join("user-hook.sh").exists(),
"user-created hook files must survive reconciliation"
);
assert!(
!hooks_dir.join(BUILTIN_MANIFEST_FILE).exists(),
"an empty shipped set removes the manifest itself"
);
assert!(
custom_hooks.join("custom.sh").exists(),
"custom agents outside the bundle must survive reconciliation"
);
assert!(custom_hooks.join(BUILTIN_MANIFEST_FILE).exists());
}
#[test]
#[serial]
fn install_builtin_skills_force_overwrites_only_with_force() {