Skip to content
Open
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
10 changes: 5 additions & 5 deletions internal/aiagents/adapter/codex/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
//
// - ~/.codex/hooks.json — hook definitions (JSON)
// - ~/.codex/config.toml — global config; install also sets
// `[features].codex_hooks = true` here so Codex actually invokes
// `[features].hooks = true` here so Codex actually invokes
// hooks at runtime
//
// Uninstall removes DMG-owned hook entries from hooks.json but does
// NOT revert the codex_hooks feature flag — the user may have wired
// NOT revert the hooks feature flag — the user may have wired
// up other tools' hooks that depend on it.
//
// Restore + Status are intentionally absent (see adapter.Adapter for
Expand Down Expand Up @@ -91,7 +91,7 @@ func (a *Adapter) Detect(ctx context.Context, exec executor.Executor) (adapter.D
}

// Install adds DMG-owned hooks to hooks.json and ensures the
// `[features].codex_hooks=true` flag in config.toml.
// `[features].hooks=true` flag in config.toml.
//
// Multi-file safety: every output buffer (hooks.json + config.toml)
// is loaded, validated, and encoded BEFORE the first write happens —
Expand Down Expand Up @@ -150,13 +150,13 @@ func (a *Adapter) Install(ctx context.Context) (adapter.InstallResult, error) {
}
res.CreatedDirs = appendUnique(res.CreatedDirs, cfgWR.CreatedDirs...)
}
res.Notes = append(res.Notes, "enabled [features].codex_hooks=true in "+a.configPath)
res.Notes = append(res.Notes, "enabled [features].hooks=true in "+a.configPath)
}
return res, nil
}

// Uninstall removes DMG-owned hook entries from hooks.json. The
// `[features].codex_hooks` flag in config.toml is intentionally NOT
// `[features].hooks` flag in config.toml is intentionally NOT
// reverted — the user may have other tools' hooks that depend on it
// being enabled.
//
Expand Down
31 changes: 20 additions & 11 deletions internal/aiagents/adapter/codex/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,8 +477,8 @@ func TestInstallCreatesHooksAndFeatureFlag(t *testing.T) {
if !ok {
t.Fatalf("features table missing: %v", cfgMap)
}
if features["codex_hooks"] != true {
t.Errorf("codex_hooks not true: %v", features)
if features["hooks"] != true {
t.Errorf("hooks not true: %v", features)
}

// InstallResult tracks both files written under root chown.
Expand Down Expand Up @@ -539,8 +539,8 @@ other_flag = true
if features["other_flag"] != true {
t.Errorf("unrelated features key lost: %v", features)
}
if features["codex_hooks"] != true {
t.Errorf("codex_hooks not enabled: %v", features)
if features["hooks"] != true {
t.Errorf("hooks not enabled: %v", features)
}
}

Expand Down Expand Up @@ -732,16 +732,25 @@ enabled = true
`sandbox = "workspace-write"`,
"[telemetry]",
"enabled = true",
"codex_hooks = true",
} {
if !strings.Contains(s, want) {
t.Errorf("expected %q in output; got: %s", want, s)
}
}
// Order: telemetry must still come AFTER features (which now contains codex_hooks).
// New `hooks = true` key must be present under [features], asserted structurally.
cfgMap := readTOML(t, cfg)
features, ok := cfgMap["features"].(map[string]any)
if !ok {
t.Fatalf("features table missing: %v", cfgMap)
}
if features["hooks"] != true {
t.Errorf("hooks not true under [features]: %v", features)
}
// Order: telemetry must still come AFTER features (which now contains hooks).
// Anchor to start-of-line so the substring can't match a `codex_hooks` legacy line.
featIdx := strings.Index(s, "[features]")
telIdx := strings.Index(s, "[telemetry]")
chIdx := strings.Index(s, "codex_hooks")
chIdx := strings.Index(s, "\nhooks = true")
if !(featIdx < chIdx && chIdx < telIdx) {
t.Errorf("table order disturbed: %s", s)
}
Expand Down Expand Up @@ -777,7 +786,7 @@ func TestInstallSecondInstallIsByteStableNoOp(t *testing.T) {

func TestInstallNoOpDoesNotRewriteConfigTOML(t *testing.T) {
a, _, _, cfg := withCodexFiles(t, "", `[features]
codex_hooks = true
hooks = true
sandbox = "workspace-write"
`)
original, _ := os.ReadFile(cfg)
Expand Down Expand Up @@ -928,7 +937,7 @@ func TestUninstallPreservesHooksJSONUserKeyOrder(t *testing.T) {
}

func TestUninstallLeavesFeatureFlagEnabled(t *testing.T) {
// Uninstall must NOT revert `[features].codex_hooks = true`. Other
// Uninstall must NOT revert `[features].hooks = true`. Other
// tools may have wired up their own hooks that depend on it being on.
a, _, _, cfg := newCodexHome(t)
if _, err := a.Install(context.Background()); err != nil {
Expand All @@ -942,7 +951,7 @@ func TestUninstallLeavesFeatureFlagEnabled(t *testing.T) {
if !ok {
t.Fatalf("features table missing after uninstall: %v", cfgMap)
}
if features["codex_hooks"] != true {
t.Errorf("codex_hooks was reverted on uninstall: %v", features)
if features["hooks"] != true {
t.Errorf("hooks was reverted on uninstall: %v", features)
}
}
7 changes: 4 additions & 3 deletions internal/aiagents/cli/uninstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"os"
"path/filepath"
"regexp"
"strings"
"testing"

Expand Down Expand Up @@ -263,7 +264,7 @@ func TestRunUninstall_ExplicitAgentSkipsDetection(t *testing.T) {

// TestRunUninstall_CodexLeavesFeatureFlag pins the invariant that
// uninstall removes hook entries from hooks.json but does NOT revert
// [features].codex_hooks=true in config.toml. Other tools' hooks may
// [features].hooks=true in config.toml. Other tools' hooks may
// depend on that flag staying enabled.
func TestRunUninstall_CodexLeavesFeatureFlag(t *testing.T) {
home, m := runInstallForTest(t, "codex")
Expand All @@ -276,8 +277,8 @@ func TestRunUninstall_CodexLeavesFeatureFlag(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(beforeCfg), "codex_hooks") {
t.Fatalf("seed broken: install didn't set codex_hooks flag: %s", string(beforeCfg))
if !regexp.MustCompile(`(?m)^hooks[ \t]*=[ \t]*true`).Match(beforeCfg) {
t.Fatalf("seed broken: install didn't set hooks flag: %s", string(beforeCfg))
}

var stdout, stderr bytes.Buffer
Expand Down
2 changes: 1 addition & 1 deletion internal/aiagents/configedit/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// rewrite key order, drop comments (TOML), and renormalize whitespace.
// configedit performs path-targeted edits backed by tidwall/gjson and
// tidwall/sjson for JSON, plus a narrow regex+mask patcher for the
// codex_hooks TOML feature flag.
// Codex hooks TOML feature flag.
//
// Scope is intentionally narrow: only what the claudecode and codex
// adapters need.
Expand Down
26 changes: 15 additions & 11 deletions internal/aiagents/configedit/toml.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,26 @@ import (
toml "github.com/pelletier/go-toml/v2"
)

// EnsureCodexHooksFlag returns the input bytes with `[features].codex_hooks
// EnsureCodexHooksFlag returns the input bytes with `[features].hooks
// = true` ensured. All bytes outside the touched line/section are
// preserved exactly. The boolean is true when the input changed.
//
// Behavior:
// - If `codex_hooks = true` already exists under [features], no change.
// - If `codex_hooks = false` exists under [features], only the value
// - If `hooks = true` already exists under [features], no change.
// - If `hooks = false` exists under [features], only the value
// token is rewritten to `true`.
// - If [features] exists without the key, `codex_hooks = true` is
// - If [features] exists without the key, `hooks = true` is
// inserted on its own line immediately after the table header.
// - If [features] does not exist, a new `[features]` table is appended
// at the end of the file with `codex_hooks = true`.
// at the end of the file with `hooks = true`.
//
// A pre-existing deprecated `codex_hooks` line is left untouched; the
// user can clean it up. Codex CLI accepts both keys today but warns on
// the old one — see https://developers.openai.com/codex/config-basic#feature-flags.
//
// Multi-line strings (`"""..."""`, `”'...”'`) and comments are masked
// before pattern matching so that user content cannot trick the
// scanner into treating the literal text `[features]` or `codex_hooks =
// scanner into treating the literal text `[features]` or `hooks =
// true` inside a string as a real table header or key.
//
// The patched output is validated by go-toml/v2 before return; if the
Expand All @@ -47,7 +51,7 @@ func EnsureCodexHooksFlag(data []byte) ([]byte, bool, error) {
if len(data) > 0 {
b.WriteByte('\n')
}
b.WriteString("[features]\ncodex_hooks = true\n")
b.WriteString("[features]\nhooks = true\n")
out, changed = b.Bytes(), true
} else if loc := codexHooksLineRE.FindSubmatchIndex(masked[start:end]); loc != nil {
valStart := start + loc[4]
Expand All @@ -61,10 +65,10 @@ func EnsureCodexHooksFlag(data []byte) ([]byte, bool, error) {
b.Write(data[valEnd:])
out, changed = b.Bytes(), true
} else {
// Insert codex_hooks = true immediately after the [features] header line.
// Insert hooks = true immediately after the [features] header line.
var b bytes.Buffer
b.Write(data[:headerEnd])
b.WriteString("codex_hooks = true\n")
b.WriteString("hooks = true\n")
b.Write(data[headerEnd:])
out, changed = b.Bytes(), true
}
Expand All @@ -79,7 +83,7 @@ func EnsureCodexHooksFlag(data []byte) ([]byte, bool, error) {
}

// CodexHooksEnabled reports whether the bytes contain
// `[features].codex_hooks = true`. Multi-line strings and comments are
// `[features].hooks = true`. Multi-line strings and comments are
// masked so a literal containing the same text in a docstring is not
// misread as the real flag.
func CodexHooksEnabled(data []byte) bool {
Expand All @@ -98,7 +102,7 @@ func CodexHooksEnabled(data []byte) bool {
var (
featuresHeaderRE = regexp.MustCompile(`(?m)^[ \t]*\[[ \t]*features[ \t]*\][ \t]*(#.*)?$`)
anyHeaderRE = regexp.MustCompile(`(?m)^[ \t]*\[\[?[^\]\n]+\]\]?[ \t]*(#.*)?$`)
codexHooksLineRE = regexp.MustCompile(`(?m)^([ \t]*codex_hooks[ \t]*=[ \t]*)(true|false)([ \t]*(?:#.*)?)$`)
codexHooksLineRE = regexp.MustCompile(`(?m)^([ \t]*hooks[ \t]*=[ \t]*)(true|false)([ \t]*(?:#.*)?)$`)
)

// findFeaturesSection scans masked TOML bytes and returns:
Expand Down
43 changes: 22 additions & 21 deletions internal/aiagents/configedit/toml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ func TestEnsureCodexHooksFlagAppendsWhenAbsent(t *testing.T) {
if !strings.Contains(s, "[features]") {
t.Errorf("missing [features]: %s", s)
}
if !strings.Contains(s, "codex_hooks = true") {
t.Errorf("missing codex_hooks: %s", s)
if !strings.Contains(s, "\nhooks = true") {
t.Errorf("missing hooks: %s", s)
}
// Original line preserved.
if !strings.HasPrefix(s, `model = "gpt-5"`) {
Expand All @@ -48,8 +48,8 @@ other_flag = true
t.Errorf("expected changed=true")
}
s := string(out)
if !strings.Contains(s, "codex_hooks = true") {
t.Errorf("missing codex_hooks: %s", s)
if !strings.Contains(s, "\nhooks = true") {
t.Errorf("missing hooks: %s", s)
}
// Original keys still present and order preserved.
if !strings.Contains(s, "other_flag = true") {
Expand All @@ -62,7 +62,7 @@ other_flag = true

func TestEnsureCodexHooksFlagFlipsFalseToTrue(t *testing.T) {
in := []byte(`[features]
codex_hooks = false
hooks = false
`)
out, changed, err := EnsureCodexHooksFlag(in)
if err != nil {
Expand All @@ -71,17 +71,17 @@ codex_hooks = false
if !changed {
t.Errorf("expected changed=true")
}
if !strings.Contains(string(out), "codex_hooks = true") {
if !strings.Contains(string(out), "\nhooks = true") {
t.Errorf("flag not flipped: %s", out)
}
if strings.Contains(string(out), "codex_hooks = false") {
if strings.Contains(string(out), "\nhooks = false") {
t.Errorf("old false value still present: %s", out)
}
}

func TestEnsureCodexHooksFlagNoOpWhenTrue(t *testing.T) {
in := []byte(`[features]
codex_hooks = true
hooks = true
other = false
`)
out, changed, err := EnsureCodexHooksFlag(in)
Expand Down Expand Up @@ -122,26 +122,27 @@ enabled = true
`sandbox = "workspace-write"`,
"[telemetry]",
"enabled = true",
"codex_hooks = true",
"\nhooks = true",
} {
if !strings.Contains(s, want) {
t.Errorf("expected output to contain %q; got %s", want, s)
}
}
// codex_hooks must land in [features], not [telemetry].
// hooks must land in [features], not [telemetry].
// Anchor to line-start so a future `codex_hooks` line can't satisfy the index.
featStart := strings.Index(s, "[features]")
telStart := strings.Index(s, "[telemetry]")
codexAt := strings.Index(s, "codex_hooks")
codexAt := strings.Index(s, "\nhooks = true")
if !(featStart < codexAt && codexAt < telStart) {
t.Errorf("codex_hooks landed outside [features]: %s", s)
t.Errorf("hooks landed outside [features]: %s", s)
}
}

func TestEnsureCodexHooksFlagIgnoresLiteralsInsideMultilineStrings(t *testing.T) {
// The literal text `[features]` and `codex_hooks = true` appear
// The literal text `[features]` and `hooks = true` appear
// inside a triple-quoted string. The patcher must NOT treat them as
// real TOML structure, and must still append a real [features] table.
in := []byte("docstring = \"\"\"\n[features]\ncodex_hooks = true\n\"\"\"\n")
in := []byte("docstring = \"\"\"\n[features]\nhooks = true\n\"\"\"\n")
out, changed, err := EnsureCodexHooksFlag(in)
if err != nil {
t.Fatal(err)
Expand All @@ -152,10 +153,10 @@ func TestEnsureCodexHooksFlagIgnoresLiteralsInsideMultilineStrings(t *testing.T)
// Output must still contain the docstring intact and a NEW real
// [features] table at the end.
s := string(out)
if !strings.Contains(s, "docstring = \"\"\"\n[features]\ncodex_hooks = true\n\"\"\"") {
if !strings.Contains(s, "docstring = \"\"\"\n[features]\nhooks = true\n\"\"\"") {
t.Errorf("docstring corrupted: %s", s)
}
if !strings.HasSuffix(s, "[features]\ncodex_hooks = true\n") {
if !strings.HasSuffix(s, "[features]\nhooks = true\n") {
t.Errorf("real [features] table not appended: %s", s)
}
// Validates as TOML.
Expand All @@ -168,7 +169,7 @@ func TestEnsureCodexHooksFlagIgnoresLiteralsInsideMultilineStrings(t *testing.T)
func TestCodexHooksEnabledIgnoresLiteralsInsideStrings(t *testing.T) {
// The flag appears inside a literal multiline string, NOT as a real
// key. CodexHooksEnabled must report false.
in := []byte("docstring = \"\"\"\n[features]\ncodex_hooks = true\n\"\"\"\n")
in := []byte("docstring = \"\"\"\n[features]\nhooks = true\n\"\"\"\n")
if CodexHooksEnabled(in) {
t.Errorf("multiline string content must not be detected as enabled flag")
}
Expand All @@ -187,7 +188,7 @@ func TestEnsureCodexHooksFlagRejectsPatchProducingInvalidTOML(t *testing.T) {
}

func TestCodexHooksEnabledIgnoresCommentedFlag(t *testing.T) {
in := []byte("# [features]\n# codex_hooks = true\n")
in := []byte("# [features]\n# hooks = true\n")
if CodexHooksEnabled(in) {
t.Fatal("commented flag must not count as enabled")
}
Expand All @@ -201,9 +202,9 @@ func TestCodexHooksEnabled(t *testing.T) {
}{
{"absent", `model = "gpt-5"`, false},
{"missing key", "[features]\nother = true\n", false},
{"false", "[features]\ncodex_hooks = false\n", false},
{"true", "[features]\ncodex_hooks = true\n", true},
{"true with comment", "[features]\ncodex_hooks = true # on\n", true},
{"false", "[features]\nhooks = false\n", false},
{"true", "[features]\nhooks = true\n", true},
{"true with comment", "[features]\nhooks = true # on\n", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
Loading