Skip to content

Commit ba48ff6

Browse files
committed
codex install: auto-enable codex_hooks in config.toml
Removes the manual prerequisite. applyCodex now ensures `codex_hooks = true` is present at the top level of `~/.codex/config.toml` before writing hooks.json: - file missing → create with the flag - flag missing → append the line (with backup of original) - flag = false → flip to true (with backup of original) - flag = true → no-op Backups follow the same .agentlock-backup-<ts> naming as hooks.json, and the same checkSafeCodexTarget guard protects writes into a real ~/.codex (still gated by AGENTLOCK_ALLOW_APPLY_REAL_HOME=1). The codex_hooks_disabled error path is gone on both daemon and CLI. Tests cover all three write cases plus the no-op.
1 parent c44cd4d commit ba48ff6

6 files changed

Lines changed: 183 additions & 30 deletions

File tree

cli/src/commands/install.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -423,11 +423,6 @@ export async function runInstall(argv: string[] = []): Promise<void> {
423423
"use --config-dir ./dev/.claude (or ./dev/.codex) for dev runs, or set\n" +
424424
"AGENTLOCK_ALLOW_APPLY_REAL_HOME=1 on the daemon for real installs.\n",
425425
);
426-
} else if (msg.includes("codex_hooks_disabled")) {
427-
process.stderr.write(
428-
"\ncodex install refused: codex_hooks must be enabled first.\n" +
429-
"add `codex_hooks = true` to your ~/.codex/config.toml and retry.\n",
430-
);
431426
} else {
432427
process.stderr.write(`\napply failed: ${msg}\n`);
433428
}

control-plane/internal/api/install.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -380,10 +380,6 @@ func installApplyHandler(d Deps) http.HandlerFunc {
380380
writeError(w, http.StatusForbidden, "unsafe_target", err.Error())
381381
return
382382
}
383-
if errors.Is(err, errCodexFlagDisabled) {
384-
writeError(w, http.StatusFailedDependency, "codex_hooks_disabled", err.Error())
385-
return
386-
}
387383
log.Printf("install.apply: applyCodex: %v", err)
388384
writeError(w, http.StatusInternalServerError, "apply_error", "codex install failed")
389385
return

control-plane/internal/api/install_codex.go

Lines changed: 127 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
// daemon. See docs/reference/hook-daemon-path.md.
55
//
66
// Codex's lifecycle hooks are gated by `codex_hooks = true` in
7-
// ~/.codex/config.toml. Apply refuses if the flag is unset; we never
8-
// write into the user's TOML.
7+
// ~/.codex/config.toml. Apply auto-enables the flag (creating the file
8+
// if needed, backing it up if it existed) so users don't have to hand-
9+
// edit their TOML before installing. The same checkSafeCodexTarget
10+
// guard that protects hooks.json also protects config.toml — writes
11+
// into a real ~/.codex still require AGENTLOCK_ALLOW_APPLY_REAL_HOME=1.
912

1013
package api
1114

@@ -25,8 +28,7 @@ import (
2528
const codexMCPGapWarning = "Codex CLI: PreToolUse only fires for shell tool calls today. MCP tool calls are NOT gated by OpenAgentLock until upstream expands hook coverage."
2629

2730
var (
28-
errCodexFlagDisabled = errors.New("codex_hooks = true is missing from config.toml; enable it before installing")
29-
codexFlagLineRegex = regexp.MustCompile(`(?m)^\s*codex_hooks\s*=\s*(true|false)\b`)
31+
codexFlagLineRegex = regexp.MustCompile(`(?m)^\s*codex_hooks\s*=\s*(true|false)\b`)
3032
)
3133

3234
// codexHooksPath returns the absolute path to the hooks.json file we'd
@@ -160,12 +162,13 @@ func applyCodex(daemonURL, configDirOverride, agentlockBinary string, overrides
160162
if err != nil {
161163
return installManifestE{}, fileOp{}, nil, err
162164
}
163-
enabled, err := codexFlagEnabled(tomlPath)
165+
tomlNote, err := ensureCodexFlagEnabled(tomlPath)
164166
if err != nil {
165167
return installManifestE{}, fileOp{}, nil, err
166168
}
167-
if !enabled {
168-
return installManifestE{}, fileOp{}, nil, fmt.Errorf("%w (path: %s)", errCodexFlagDisabled, tomlPath)
169+
warnings := []string{codexMCPGapWarning}
170+
if tomlNote != "" {
171+
warnings = append(warnings, tomlNote)
169172
}
170173

171174
hooksPath, err := codexHooksPath(configDirOverride, overrides)
@@ -216,7 +219,123 @@ func applyCodex(daemonURL, configDirOverride, agentlockBinary string, overrides
216219
Path: abs,
217220
Reason: fmt.Sprintf("wired Codex CLI hooks → %s (via shim)", daemonURL),
218221
BackupPath: backupPath,
219-
}, []string{codexMCPGapWarning}, nil
222+
}, warnings, nil
223+
}
224+
225+
// ensureCodexFlagEnabled idempotently makes sure
226+
// `codex_hooks = true` is a top-level key in the user's config.toml.
227+
// Cases:
228+
// 1. file missing → create with `codex_hooks = true\n`
229+
// 2. flag missing → append `codex_hooks = true\n`
230+
// 3. flag = false → rewrite to true (with backup)
231+
// 4. flag = true → no-op
232+
//
233+
// Returns a human-readable note when a write happened (empty string =
234+
// no change), so the caller can surface it as an install warning. The
235+
// real-home guard mirrors hooks.json: writes into a real ~/.codex
236+
// require AGENTLOCK_ALLOW_APPLY_REAL_HOME=1.
237+
func ensureCodexFlagEnabled(tomlPath string) (string, error) {
238+
abs, err := filepath.Abs(tomlPath)
239+
if err != nil {
240+
return "", fmt.Errorf("resolve %s: %w", tomlPath, err)
241+
}
242+
if err := checkSafeCodexTarget(abs); err != nil {
243+
return "", err
244+
}
245+
246+
existing, readErr := os.ReadFile(abs)
247+
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
248+
return "", fmt.Errorf("read %s: %w", abs, readErr)
249+
}
250+
251+
// Case 1: file missing → create with the flag set.
252+
if errors.Is(readErr, os.ErrNotExist) {
253+
if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil {
254+
return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(abs), err)
255+
}
256+
if err := policy.AtomicWriteFile(abs, []byte("codex_hooks = true\n"), 0o644); err != nil {
257+
return "", fmt.Errorf("write %s: %w", abs, err)
258+
}
259+
return fmt.Sprintf("created %s with codex_hooks = true", abs), nil
260+
}
261+
262+
// Look only at top-level (pre-first-section) lines, mirroring
263+
// codexFlagEnabled.
264+
state, idx := topLevelCodexFlag(existing)
265+
switch state {
266+
case "true":
267+
return "", nil // case 4: already set
268+
case "false":
269+
// case 3: rewrite the matching line.
270+
updated := append([]byte(nil), existing[:idx.start]...)
271+
updated = append(updated, []byte("codex_hooks = true")...)
272+
updated = append(updated, existing[idx.end:]...)
273+
backup := fmt.Sprintf("%s.agentlock-backup-%d", abs, time.Now().UnixNano())
274+
if err := policy.AtomicWriteFile(backup, existing, 0o600); err != nil {
275+
return "", fmt.Errorf("write backup: %w", err)
276+
}
277+
if err := policy.AtomicWriteFile(abs, updated, 0o644); err != nil {
278+
return "", fmt.Errorf("write %s: %w", abs, err)
279+
}
280+
return fmt.Sprintf("flipped codex_hooks false→true in %s (backup: %s)", abs, backup), nil
281+
default:
282+
// case 2: append. Make sure we don't glue onto a partial line.
283+
var buf []byte
284+
buf = append(buf, existing...)
285+
if len(buf) > 0 && buf[len(buf)-1] != '\n' {
286+
buf = append(buf, '\n')
287+
}
288+
buf = append(buf, []byte("codex_hooks = true\n")...)
289+
backup := fmt.Sprintf("%s.agentlock-backup-%d", abs, time.Now().UnixNano())
290+
if err := policy.AtomicWriteFile(backup, existing, 0o600); err != nil {
291+
return "", fmt.Errorf("write backup: %w", err)
292+
}
293+
if err := policy.AtomicWriteFile(abs, buf, 0o644); err != nil {
294+
return "", fmt.Errorf("write %s: %w", abs, err)
295+
}
296+
return fmt.Sprintf("added codex_hooks = true to %s (backup: %s)", abs, backup), nil
297+
}
298+
}
299+
300+
type codexFlagSpan struct{ start, end int }
301+
302+
// topLevelCodexFlag scans bytes for the first top-level (pre-section)
303+
// `codex_hooks = (true|false)` line. Returns "" if none found.
304+
func topLevelCodexFlag(b []byte) (string, codexFlagSpan) {
305+
cursor := 0
306+
for cursor < len(b) {
307+
nl := indexByteFrom(b, '\n', cursor)
308+
end := nl
309+
if end < 0 {
310+
end = len(b)
311+
}
312+
line := b[cursor:end]
313+
trimmed := strings.TrimSpace(string(line))
314+
if strings.HasPrefix(trimmed, "[") {
315+
return "", codexFlagSpan{}
316+
}
317+
if trimmed != "" && !strings.HasPrefix(trimmed, "#") {
318+
if m := codexFlagLineRegex.FindSubmatchIndex(line); m != nil {
319+
return string(line[m[2]:m[3]]), codexFlagSpan{cursor + m[0], cursor + m[1]}
320+
}
321+
}
322+
if nl < 0 {
323+
break
324+
}
325+
cursor = nl + 1
326+
}
327+
return "", codexFlagSpan{}
328+
}
329+
330+
func indexByteFrom(b []byte, c byte, from int) int {
331+
if from >= len(b) {
332+
return -1
333+
}
334+
idx := strings.IndexByte(string(b[from:]), c)
335+
if idx < 0 {
336+
return -1
337+
}
338+
return from + idx
220339
}
221340

222341
// checkSafeCodexTarget refuses to let apply write into the developer's

control-plane/internal/api/install_codex_test.go

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,33 +91,76 @@ func TestInstallPlan_CodexProducesWriteOpAndWarning(t *testing.T) {
9191
}
9292
}
9393

94-
func TestInstallApply_CodexRefusedWhenFlagDisabled(t *testing.T) {
94+
func TestInstallApply_CodexAutoEnablesFlagWhenMissing(t *testing.T) {
9595
t.Setenv("AGENTLOCK_ALLOW_APPLY", "1")
9696
fx := newGateFixture(t, enforcePolicyYAML)
9797
dir := t.TempDir()
98-
// config.toml exists but the flag isn't set to true.
98+
// config.toml exists but the flag isn't set: auto-append.
9999
writeCodexConfigToml(t, dir, "# nothing relevant\n")
100100

101101
res := postCodexApply(t, fx, dir, "/usr/local/bin/agentlock")
102102
defer res.Body.Close()
103-
if res.StatusCode != http.StatusFailedDependency {
104-
t.Fatalf("status = %d, want 424", res.StatusCode)
103+
if res.StatusCode != http.StatusOK {
104+
buf := new(bytes.Buffer)
105+
_, _ = buf.ReadFrom(res.Body)
106+
t.Fatalf("status = %d body=%s", res.StatusCode, buf.String())
105107
}
106-
var body map[string]string
107-
_ = json.NewDecoder(res.Body).Decode(&body)
108-
if body["error"] != "codex_hooks_disabled" {
109-
t.Fatalf("error = %v", body["error"])
108+
got, err := os.ReadFile(filepath.Join(dir, "config.toml"))
109+
if err != nil {
110+
t.Fatalf("read config.toml: %v", err)
111+
}
112+
if !strings.Contains(string(got), "codex_hooks = true") {
113+
t.Fatalf("expected codex_hooks = true appended, got:\n%s", got)
110114
}
111115
}
112116

113-
func TestInstallApply_CodexRefusedWhenConfigMissing(t *testing.T) {
117+
func TestInstallApply_CodexFlipsFalseToTrue(t *testing.T) {
118+
t.Setenv("AGENTLOCK_ALLOW_APPLY", "1")
119+
fx := newGateFixture(t, enforcePolicyYAML)
120+
dir := t.TempDir()
121+
writeCodexConfigToml(t, dir, "codex_hooks = false\n")
122+
123+
res := postCodexApply(t, fx, dir, "/usr/local/bin/agentlock")
124+
defer res.Body.Close()
125+
if res.StatusCode != http.StatusOK {
126+
buf := new(bytes.Buffer)
127+
_, _ = buf.ReadFrom(res.Body)
128+
t.Fatalf("status = %d body=%s", res.StatusCode, buf.String())
129+
}
130+
got, err := os.ReadFile(filepath.Join(dir, "config.toml"))
131+
if err != nil {
132+
t.Fatalf("read config.toml: %v", err)
133+
}
134+
if !strings.Contains(string(got), "codex_hooks = true") {
135+
t.Fatalf("expected flipped to true, got:\n%s", got)
136+
}
137+
if strings.Contains(string(got), "codex_hooks = false") {
138+
t.Fatalf("expected old false line replaced, got:\n%s", got)
139+
}
140+
// Backup of the original false-state should exist alongside.
141+
matches, _ := filepath.Glob(filepath.Join(dir, "config.toml.agentlock-backup-*"))
142+
if len(matches) != 1 {
143+
t.Fatalf("expected 1 backup, got %v", matches)
144+
}
145+
}
146+
147+
func TestInstallApply_CodexCreatesConfigWhenMissing(t *testing.T) {
114148
t.Setenv("AGENTLOCK_ALLOW_APPLY", "1")
115149
fx := newGateFixture(t, enforcePolicyYAML)
116150
dir := t.TempDir() // no config.toml at all
117151
res := postCodexApply(t, fx, dir, "/usr/local/bin/agentlock")
118152
defer res.Body.Close()
119-
if res.StatusCode != http.StatusFailedDependency {
120-
t.Fatalf("status = %d, want 424", res.StatusCode)
153+
if res.StatusCode != http.StatusOK {
154+
buf := new(bytes.Buffer)
155+
_, _ = buf.ReadFrom(res.Body)
156+
t.Fatalf("status = %d body=%s", res.StatusCode, buf.String())
157+
}
158+
got, err := os.ReadFile(filepath.Join(dir, "config.toml"))
159+
if err != nil {
160+
t.Fatalf("expected config.toml created: %v", err)
161+
}
162+
if string(got) != "codex_hooks = true\n" {
163+
t.Fatalf("expected fresh codex_hooks=true file, got:\n%s", got)
121164
}
122165
}
123166

docs/guide/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ Then pick a signer tier and run `install`. Two recommended paths:
130130
agentlock install --tier software
131131
```
132132

133-
Pick the harnesses to harden, review the diff, confirm. The installer writes harness-specific configuration (e.g. `~/.claude/settings.json` hook entries, `~/.codex/config.toml` `codex_hooks`) and registers a clean rollback path you can invoke later with `agentlock uninstall`.
133+
Pick the harnesses to harden, review the diff, confirm. The installer writes harness-specific configuration (e.g. `~/.claude/settings.json` hook entries, `~/.codex/hooks.json`, plus `codex_hooks = true` in `~/.codex/config.toml` — auto-set on first install, with a backup of the original) and registers a clean rollback path you can invoke later with `agentlock uninstall`.
134134

135135
Open the dashboard at <http://127.0.0.1:7879/> to watch live activity.
136136

docs/reference/hooks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ event = "pre_tool"
2929
command = ["agentlock", "hook", "codex", "pre-tool"]
3030
```
3131

32-
The installer refuses to wire Codex without `codex_hooks = true` in your config; the flag is opt-in upstream.
32+
`agentlock install` auto-enables the flag for you: it creates `~/.codex/config.toml` if missing, flips `codex_hooks = false` to `true`, or appends the line to an existing TOML — backing the original up first. The flag stays user-removable; we never enable it without an install run.
3333

3434
Codex command hooks are bash-only today; MCP coverage at the hook layer is a tracked upstream gap, not something we can paper over.
3535

0 commit comments

Comments
 (0)