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
16 changes: 14 additions & 2 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ manage the integrations on their own:

```bash
hey setup claude # install the skill and the hey@37signals plugin for Claude Code
hey setup codex # install the shared skill for Codex
hey setup codex # install the shared skill and sandbox rules for Codex
hey skill install # install the skill only (~/.agents/skills/hey, linked for detected agents)
hey setup agents # non-interactive: skill + a single detected agent (the installer uses this)
hey setup agents --remove # remove HEY's managed skills and Claude Code plugin
hey setup agents --remove # remove HEY's managed skills, Codex rules and Claude Code plugin
hey doctor # check skill and plugin health per detected agent
```

Expand All @@ -33,6 +33,18 @@ skill twice. Setup and version refresh remove that legacy path only when its
ownership marker proves an older hey-cli created it; user-authored files are
left untouched.

Codex runs every shell command in a sandbox that blocks the system keyring and the
network, so a `hey` command run there can neither read your credentials nor reach HEY.
`hey setup codex` therefore also writes `~/.codex/rules/hey.rules` (under `$CODEX_HOME`
when that is set): prefix rules that let Codex run hey's read-only commands, such as
`hey search`, `hey box`, `hey thread read` and the list, show and view subcommands,
outside the sandbox without asking. Every other hey command, including anything that
sends, changes or saves, still goes through Codex's approval prompt each time. Codex
reads rules only when a session starts, so restart Codex after setup. `hey doctor`
reports the rules as missing or out of date after an upgrade that changes them; rerun
`hey setup codex` to refresh them. `hey setup agents --remove` deletes the file. Setup
never overwrites a `hey.rules` it did not write: move your own aside first.

hey only ever writes skill directories it owns: each one it creates carries a
`.managed-by-hey-cli` marker, and install, replacement and automatic refresh all refuse a
`hey` skill directory (or symlink) without it — a hand-authored skill at one of those paths
Expand Down
5 changes: 4 additions & 1 deletion docs/architecture/auth-and-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ version change. Ownership is explicit: every skill write goes through `claimSkil
marks a directory it creates (`.managed-by-hey-cli`) and refuses a populated one without the
marker; removal (`removeExistingSkillLink`) touches only our canonical symlink or a marked
copy; refresh writes only marked, non-symlinked files. Do not add a write path that bypasses
the gate.
the gate. Codex's `$CODEX_HOME/rules/hey.rules`, which lets the read-only commands in
`harness.CodexReadCommands` run outside Codex's sandbox, is the other hey-cli-managed write: its
first line is the marker, and setup, removal and `hey doctor` all go through
`harness.InspectCodexRules`, which treats an unmarked file or a symlink as the user's.

New top-level commands and subcommands need the `.surface` snapshot updated
(`go test ./internal/cmd -run TestSurfaceSnapshot -update-surface`), and anything listed in
Expand Down
67 changes: 64 additions & 3 deletions internal/cmd/setup_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -73,6 +74,7 @@ var agentSetupHandlers = map[string]agentSetupHandler{
"codex": {
Labels: []string{
"Install the shared HEY skill for Codex",
"Let Codex run read-only hey commands outside its sandbox",
},
Run: runCodexSetup,
RunNonInteractive: runCodexSetupNonInteractive,
Expand Down Expand Up @@ -238,7 +240,11 @@ func runSetupAgent(cmd *cobra.Command, agent harness.AgentInfo, handler agentSet
if len(manualCommands) > 0 {
hint = strings.Join(manualCommands, "; ")
}
return &apierr.Error{Code: "setup_incomplete", Message: summary, Hint: hint}
setupErr := &apierr.Error{Code: "setup_incomplete", Message: summary, Hint: hint}
if len(setupErrors) > 0 {
setupErr.Meta = map[string]any{"errors": setupErrors}
}
return setupErr
}

breadcrumbs := []output.Breadcrumb{
Expand Down Expand Up @@ -393,7 +399,8 @@ func agentCommandFailure(out []byte, err error) string {

// --- Codex ---

// runCodexSetup connects Codex to the shared agent skill.
// runCodexSetup connects Codex to the shared agent skill and installs the
// rules that let its reads leave the sandbox.
func runCodexSetup(cmd *cobra.Command) error {
w := cmd.OutOrStdout()
path, err := installCodexSkill()
Expand All @@ -403,11 +410,22 @@ func runCodexSetup(cmd *cobra.Command) error {
return nil //nolint:nilerr // warn and continue; the post-setup snapshot reports the failure
}
fmt.Fprintln(w, statusLine(true, "Codex skill installed ("+path+")"))

rulesPath, err := installCodexRules()
if err != nil {
fmt.Fprintln(w, warning.format("Codex rules install failed: "+err.Error()))
fmt.Fprintln(w, "Then verify with: hey doctor")
return nil //nolint:nilerr // warn and continue; the post-setup snapshot reports the failure
}
fmt.Fprintln(w, statusLine(true, "Codex rules installed ("+rulesPath+"); restart Codex to load them"))
return nil
}

func runCodexSetupNonInteractive(*cobra.Command) error {
_, err := installCodexSkill()
if _, err := installCodexSkill(); err != nil {
return err
}
_, err := installCodexRules()
return err
}

Expand All @@ -434,6 +452,49 @@ func installCodexSkill() (string, error) {
return path, nil
}

// installCodexRules writes hey's rules file, which lets Codex run the read-only
// commands outside its sandbox. Like claimSkillDir, it replaces only a file
// hey-cli wrote. It runs after installCodexSkill, so it never fabricates the
// Codex home on a machine without Codex.
func installCodexRules() (string, error) {
path := harness.CodexRulesPath()
if path == "" {
return "", fmt.Errorf("cannot determine Codex home directory")
}
state, err := harness.InspectCodexRules(path)
if err != nil {
return "", fmt.Errorf("inspecting %s: %w", path, err)
}
if state == harness.CodexRulesForeign {
return "", fmt.Errorf("%s exists but was not written by hey-cli; move it aside to let hey install its Codex rules there", path)
}
if err := writeFileAtomic(path, harness.CodexRules()); err != nil {
return "", fmt.Errorf("writing %s: %w", path, err)
}
return path, nil
}

// writeFileAtomic replaces path through a rename, so Codex never loads a
// half-written rules file. The temporary name does not end in .rules, which
// keeps Codex from loading it either.
func writeFileAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil { // #nosec G301 -- Codex's own rules directory
return err
}
tmp, err := os.CreateTemp(dir, ".hey-rules-*.tmp")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // a no-op once the rename has moved it
_, writeErr := tmp.Write(data)
chmodErr := tmp.Chmod(0o644) // #nosec G302 -- rules are configuration, not secrets
if err := errors.Join(writeErr, chmodErr, tmp.Close()); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}

// --- Shared helpers ---

// statusLine renders a ✓/✗ checklist line.
Expand Down
26 changes: 26 additions & 0 deletions internal/cmd/setup_agents_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ func runRemoveAgentSetup(cmd *cobra.Command) error {
}
}

if didRemove, removeErr := removeCodexRules(); removeErr != nil {
failures = append(failures, "Codex rules: "+removeErr.Error())
} else if didRemove {
removed = append(removed, "Codex rules")
}

baseline := filepath.Join(home, ".agents", "skills", "hey")
if didRemove, removeErr := removeOwnedSkillFiles(baseline); removeErr != nil {
failures = append(failures, "agent skill: "+removeErr.Error())
Expand Down Expand Up @@ -123,6 +129,26 @@ func removeOwnedSkillFiles(dir string) (bool, error) {
return true, nil
}

// removeCodexRules deletes hey's Codex rules file, and only a regular file
// that carries hey-cli's marker: a user's hey.rules or symlink stays put.
func removeCodexRules() (bool, error) {
path := harness.CodexRulesPath()
if path == "" {
return false, nil
}
state, err := harness.InspectCodexRules(path)
if err != nil {
return false, fmt.Errorf("inspecting %s: %w", path, err)
}
if state != harness.CodexRulesCurrent && state != harness.CodexRulesStale {
return false, nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return false, fmt.Errorf("removing %s: %w", path, err)
}
return true, nil
}

func exists(path string) bool {
_, err := os.Lstat(path)
return err == nil
Expand Down
Loading
Loading