Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions CLI/CLI+Hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1975,10 +1975,13 @@ extension ProgramaCLI {
}
if rawHooks == nil, !install { return root }
var hooks = rawHooks as? [String: Any] ?? [:]
var updatedEvents = Set<String>()
for event in Array(hooks.keys) {
guard let rawGroups = hooks[event] as? [Any] else {
throw CodexHooksError(message: "hooks.json event \(event) must be an array")
}
// Trust keys are positional: rewrite Programa's first handler in place; a sole-handler group drops stale matchers.
let spec = install ? Self.codexHookSpecs.first { $0.event == event } : nil
var rewritten: [[String: Any]] = []
for (groupIndex, rawGroup) in rawGroups.enumerated() {
guard var group = rawGroup as? [String: Any] else {
Expand All @@ -1996,31 +1999,28 @@ extension ProgramaCLI {
throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex).command must be a string")
}
if !codexIsOwnedHandler(handler, event: event) { handlers.append(handler) }
else if let spec, updatedEvents.insert(event).inserted { handlers.append(Self.codexCanonicalHandler(spec)) }
}
if !handlers.isEmpty {
if rawHandlers.count == 1, codexIsOwnedHandler(handlers[0], event: event) { group = [:] }
group["hooks"] = handlers
rewritten.append(group)
}
}
if rewritten.isEmpty { hooks.removeValue(forKey: event) }
else { hooks[event] = rewritten }
hooks[event] = rewritten.isEmpty ? nil : rewritten
}
if install {
for spec in Self.codexHookSpecs {
var groups = hooks[spec.event] as? [[String: Any]] ?? []
groups.append(["hooks": [[
"type": "command",
"command": Self.codexHookCommand(spec.commandEvent),
"timeout": spec.timeout,
] as [String: Any]]])
hooks[spec.event] = groups
}
for spec in Self.codexHookSpecs where install && !updatedEvents.contains(spec.event) {
hooks[spec.event] = (hooks[spec.event] as? [[String: Any]] ?? []) + [["hooks": [Self.codexCanonicalHandler(spec)]]]
}
root["hooks"] = hooks
try codexValidateForeignHandlerPositions(before: source, after: root)
return root
}

private static func codexCanonicalHandler(_ spec: CodexHookSpec) -> [String: Any] {
["type": "command", "command": codexHookCommand(spec.commandEvent), "timeout": spec.timeout]
}

private func codexValidateForeignHandlerPositions(
before: [String: Any],
after: [String: Any]
Expand Down
39 changes: 37 additions & 2 deletions tests/test_cli_codex_hook_trust.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,17 +370,18 @@ def test_invalid_config_fails_before_hooks_are_mutated(self) -> None:
)

def test_install_refuses_to_reassign_a_foreign_positional_trust_key(self) -> None:
"""Removing a legacy group must not shift foreign trust onto Programa's replacement."""
"""Dropping a duplicate Programa group must not shift foreign trust positions."""
hooks = {
"hooks": {
"Stop": [
{"hooks": [owned_handler("stop")]},
{"hooks": [owned_handler("stop")]},
{"hooks": [foreign_handler("position-sensitive-stop")]},
]
}
}
original_hooks = self.write_hooks(hooks)
foreign_key = f"{self.codex_home.resolve() / 'hooks.json'}:stop:1:0"
foreign_key = f"{self.codex_home.resolve() / 'hooks.json'}:stop:2:0"
original_config = (
f'[hooks.state.{json.dumps(foreign_key)}]\n'
'trusted_hash = "sha256:foreign-position"\n'
Expand All @@ -397,6 +398,40 @@ def test_install_refuses_to_reassign_a_foreign_positional_trust_key(self) -> Non
self.assertEqual((self.codex_home / "hooks.json").read_bytes(), original_hooks)
self.assertEqual((self.codex_home / "config.toml").read_bytes(), original_config)

def test_reinstall_updates_programa_in_place_when_foreign_groups_follow_it(self) -> None:
"""A later tool's groups must keep their positions across a Programa reinstall."""
foreign_command = (
'bun --no-env-file "/x/darkroom/source/src/scripts/codex-hook.ts" '
'"src/hooks/session-end.ts"'
)
hooks: dict[str, Any] = {"hooks": {}}
for event, command_event in (
("SessionEnd", "session-end"),
("UserPromptSubmit", "prompt-submit"),
):
hooks["hooks"][event] = [
{"hooks": [owned_handler(command_event)]},
{"hooks": [{"type": "command", "command": foreign_command, "timeout": 5}]},
]
self.write_hooks(hooks)

install = self.run_cli("install-hooks")
self.assert_succeeded(install)

installed = self.read_hooks()["hooks"]
for event in ("SessionEnd", "UserPromptSubmit"):
groups = installed[event]
self.assertEqual(len(groups), 2, f"{event} must not gain or lose groups")
self.assertTrue(
is_programa_handler(event, groups[0]["hooks"][0]["command"]),
f"{event} Programa handler must stay at 0:0",
)
self.assertEqual(
groups[1]["hooks"],
[{"type": "command", "command": foreign_command, "timeout": 5}],
f"{event} foreign handler must stay at 1:0",
)

def test_uninstall_refuses_to_delete_a_user_comment_with_stale_trust(self) -> None:
"""A positional trust table cannot own comments that follow its assignments."""
hooks = {"hooks": {"SessionStart": [{"hooks": [owned_handler("session-start")]}]}}
Expand Down
Loading