Skip to content

Commit 5379afa

Browse files
Karlclaude
andcommitted
Refactor: move agent settings logic to Definition.ApplySettings; eliminate agent name checks in sandbox layer
- Add ApplySettings func, ShortLivedOAuthWarning bool, and SeedsAllAgents bool fields to agent.Definition - Move injectIdleHook and its shell command constants from sandbox/create.go into agent/agent.go — agent-specific logic belongs in the agent package - Populate ApplySettings for Claude (skipDangerousModePermissionPrompt, sandbox disabled, preferredNotifChannel, idle hooks) and Gemini (folderTrust disabled) - Set ShortLivedOAuthWarning=true on Claude; SeedsAllAgents=true on shell - Replace switch agentDef.Name blocks in ensureContainerSettings and ensureShellContainerSettings with agentDef.ApplySettings(settings) calls - Replace if agentDef.Name == "shell" routing with if agentDef.SeedsAllAgents - Replace if agentDef.Name == "claude" in create_seed.go with if agentDef.ShortLivedOAuthWarning - Add tests for ApplySettings, ShortLivedOAuthWarning, and SeedsAllAgents on all relevant agents Adding a new agent with custom settings now requires no changes outside agent/agent.go. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 473e9ff commit 5379afa

File tree

4 files changed

+200
-118
lines changed

4 files changed

+200
-118
lines changed

agent/agent.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ type Definition struct {
7676
NetworkAllowlist []string // domains allowed when network-isolated
7777
ContextFile string // filename in StateDir for sandbox context reference (e.g., "CLAUDE.md")
7878
AgentFilesExclude []string // glob patterns to skip when copying agent_files (string form)
79+
80+
// ApplySettings patches the agent's settings map before it is written to disk.
81+
// Called with the parsed settings map; mutates it in place.
82+
// Nil means no patches are needed.
83+
ApplySettings func(settings map[string]any)
84+
85+
// ShortLivedOAuthWarning, if true, warns users when an OAuth credential file
86+
// is copied into the sandbox (used by Claude Code which uses short-lived tokens).
87+
ShortLivedOAuthWarning bool
88+
89+
// SeedsAllAgents, if true, means this agent seeds home configs for all real
90+
// agents rather than just itself (used by the shell agent).
91+
SeedsAllAgents bool
7992
}
8093

8194
var agents = map[string]*Definition{
@@ -141,6 +154,19 @@ var agents = map[string]*Definition{
141154
NetworkAllowlist: []string{"api.anthropic.com", "claude.ai", "platform.claude.com", "statsig.anthropic.com", "sentry.io"},
142155
ContextFile: "CLAUDE.md",
143156
AgentFilesExclude: []string{"projects/", "statsig/", "todos/", ".credentials.json", "*.log"},
157+
ApplySettings: func(s map[string]any) {
158+
s["skipDangerousModePermissionPrompt"] = true
159+
// Disable Claude Code's built-in sandbox-exec to prevent nesting failures.
160+
// sandbox-exec cannot be nested — an inner sandbox-exec inherits the outer
161+
// profile's restrictions and typically fails.
162+
s["sandbox"] = map[string]any{"enabled": false}
163+
// Ensure Claude Code emits BEL for tmux tab highlighting.
164+
s["preferredNotifChannel"] = "terminal_bell"
165+
// Inject hooks for status tracking. Claude Code's own hook system is
166+
// far more reliable than polling tmux capture-pane for a ready pattern.
167+
injectIdleHook(s)
168+
},
169+
ShortLivedOAuthWarning: true,
144170
},
145171
"gemini": {
146172
Name: "gemini",
@@ -172,6 +198,16 @@ var agents = map[string]*Definition{
172198
NetworkAllowlist: []string{"generativelanguage.googleapis.com", "cloudcode-pa.googleapis.com", "oauth2.googleapis.com"},
173199
ContextFile: "GEMINI.md",
174200
AgentFilesExclude: []string{"logs/", "oauth_creds.json", "google_accounts.json"},
201+
ApplySettings: func(s map[string]any) {
202+
// Preserve existing security settings (e.g. auth.selectedType) while
203+
// disabling folder trust — the container is already sandboxed.
204+
security, _ := s["security"].(map[string]any)
205+
if security == nil {
206+
security = map[string]any{}
207+
}
208+
security["folderTrust"] = map[string]any{"enabled": false}
209+
s["security"] = security
210+
},
175211
},
176212
"opencode": {
177213
Name: "opencode",
@@ -353,9 +389,56 @@ func buildShellAgent() *Definition {
353389
ModelFlag: "",
354390
ModelAliases: nil,
355391
NetworkAllowlist: networkAllowlist,
392+
SeedsAllAgents: true,
356393
}
357394
}
358395

359396
func init() {
360397
agents["shell"] = buildShellAgent()
361398
}
399+
400+
// statusIdleCommand writes idle status to agent-status.json and appends a
401+
// structured JSONL entry to logs/agent-hooks.jsonl when Claude finishes a
402+
// response (Notification hook). Uses $YOLOAI_DIR for portability across
403+
// backends (Docker=/yoloai, seatbelt=sandbox dir).
404+
const statusIdleCommand = `printf '{"ts":"%s","level":"info","event":"hook.idle","msg":"agent hook: idle","status":"idle"}\n' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" >> "${YOLOAI_DIR:-/yoloai}/logs/agent-hooks.jsonl" && printf '{"status":"idle","exit_code":null,"timestamp":%d}\n' "$(date +%s)" > "${YOLOAI_DIR:-/yoloai}/agent-status.json"`
405+
406+
// statusActiveCommand writes active status to agent-status.json and appends a
407+
// structured JSONL entry to logs/agent-hooks.jsonl when Claude starts working
408+
// (PreToolUse hook). This ensures the title updates from "> name" back to
409+
// "name" when the user submits a new prompt.
410+
const statusActiveCommand = `printf '{"ts":"%s","level":"info","event":"hook.active","msg":"agent hook: active","status":"active"}\n' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" >> "${YOLOAI_DIR:-/yoloai}/logs/agent-hooks.jsonl" && printf '{"status":"active","exit_code":null,"timestamp":%d}\n' "$(date +%s)" > "${YOLOAI_DIR:-/yoloai}/agent-status.json"`
411+
412+
// injectIdleHook merges hooks into Claude Code's settings map for status tracking.
413+
// Notification → idle (turn complete), PreToolUse → running (work started).
414+
// Preserves any existing hooks the user may have configured.
415+
func injectIdleHook(settings map[string]any) {
416+
hooks, _ := settings["hooks"].(map[string]any)
417+
if hooks == nil {
418+
hooks = map[string]any{}
419+
}
420+
421+
// Notification hook: mark idle when Claude finishes a response.
422+
idleHook := map[string]any{
423+
"type": "command",
424+
"command": statusIdleCommand,
425+
}
426+
idleGroup := map[string]any{
427+
"hooks": []any{idleHook},
428+
}
429+
existingNotif, _ := hooks["Notification"].([]any)
430+
hooks["Notification"] = append(existingNotif, idleGroup)
431+
432+
// PreToolUse hook: mark active when Claude starts using tools.
433+
activeHook := map[string]any{
434+
"type": "command",
435+
"command": statusActiveCommand,
436+
}
437+
activeGroup := map[string]any{
438+
"hooks": []any{activeHook},
439+
}
440+
existingPre, _ := hooks["PreToolUse"].([]any)
441+
hooks["PreToolUse"] = append(existingPre, activeGroup)
442+
443+
settings["hooks"] = hooks
444+
}

agent/agent_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,99 @@ func TestGetAgent_Unknown(t *testing.T) {
293293
assert.Nil(t, GetAgent("unknown"))
294294
assert.Nil(t, GetAgent(""))
295295
}
296+
297+
func TestApplySettings_Claude(t *testing.T) {
298+
def := GetAgent("claude")
299+
require.NotNil(t, def)
300+
require.NotNil(t, def.ApplySettings, "claude should have ApplySettings set")
301+
302+
settings := map[string]any{}
303+
def.ApplySettings(settings)
304+
305+
assert.Equal(t, true, settings["skipDangerousModePermissionPrompt"])
306+
assert.Equal(t, map[string]any{"enabled": false}, settings["sandbox"])
307+
assert.Equal(t, "terminal_bell", settings["preferredNotifChannel"])
308+
309+
// Verify idle hooks were injected
310+
hooks, ok := settings["hooks"].(map[string]any)
311+
require.True(t, ok, "hooks should be a map")
312+
assert.NotNil(t, hooks["Notification"], "Notification hook should be set")
313+
assert.NotNil(t, hooks["PreToolUse"], "PreToolUse hook should be set")
314+
}
315+
316+
func TestApplySettings_ClaudePreservesExistingHooks(t *testing.T) {
317+
def := GetAgent("claude")
318+
require.NotNil(t, def)
319+
320+
existingHook := map[string]any{"type": "command", "command": "echo existing"}
321+
existingGroup := map[string]any{"hooks": []any{existingHook}}
322+
settings := map[string]any{
323+
"hooks": map[string]any{
324+
"Notification": []any{existingGroup},
325+
},
326+
}
327+
def.ApplySettings(settings)
328+
329+
hooks := settings["hooks"].(map[string]any)
330+
notifHooks := hooks["Notification"].([]any)
331+
assert.Len(t, notifHooks, 2, "should preserve existing hook and append idle hook")
332+
}
333+
334+
func TestApplySettings_Gemini(t *testing.T) {
335+
def := GetAgent("gemini")
336+
require.NotNil(t, def)
337+
require.NotNil(t, def.ApplySettings, "gemini should have ApplySettings set")
338+
339+
settings := map[string]any{}
340+
def.ApplySettings(settings)
341+
342+
security, ok := settings["security"].(map[string]any)
343+
require.True(t, ok, "security should be a map")
344+
folderTrust, ok := security["folderTrust"].(map[string]any)
345+
require.True(t, ok, "folderTrust should be a map")
346+
assert.Equal(t, false, folderTrust["enabled"])
347+
}
348+
349+
func TestApplySettings_GeminiPreservesExistingSecurityFields(t *testing.T) {
350+
def := GetAgent("gemini")
351+
require.NotNil(t, def)
352+
353+
settings := map[string]any{
354+
"security": map[string]any{"auth": map[string]any{"selectedType": "oauth"}},
355+
}
356+
def.ApplySettings(settings)
357+
358+
security := settings["security"].(map[string]any)
359+
// Existing field should be preserved
360+
assert.Equal(t, map[string]any{"selectedType": "oauth"}, security["auth"])
361+
// folderTrust should be added
362+
assert.Equal(t, map[string]any{"enabled": false}, security["folderTrust"])
363+
}
364+
365+
func TestApplySettings_OtherAgentsNil(t *testing.T) {
366+
for _, name := range []string{"aider", "codex", "opencode", "test", "idle"} {
367+
def := GetAgent(name)
368+
require.NotNil(t, def, "agent %q should exist", name)
369+
assert.Nil(t, def.ApplySettings, "agent %q should have nil ApplySettings", name)
370+
}
371+
}
372+
373+
func TestShortLivedOAuthWarning(t *testing.T) {
374+
assert.True(t, GetAgent("claude").ShortLivedOAuthWarning, "claude should have ShortLivedOAuthWarning=true")
375+
376+
for _, name := range []string{"aider", "gemini", "codex", "opencode", "test", "idle", "shell"} {
377+
def := GetAgent(name)
378+
require.NotNil(t, def, "agent %q should exist", name)
379+
assert.False(t, def.ShortLivedOAuthWarning, "agent %q should have ShortLivedOAuthWarning=false", name)
380+
}
381+
}
382+
383+
func TestSeedsAllAgents(t *testing.T) {
384+
assert.True(t, GetAgent("shell").SeedsAllAgents, "shell should have SeedsAllAgents=true")
385+
386+
for _, name := range []string{"aider", "claude", "gemini", "codex", "opencode", "test", "idle"} {
387+
def := GetAgent(name)
388+
require.NotNil(t, def, "agent %q should exist", name)
389+
assert.False(t, def.SeedsAllAgents, "agent %q should have SeedsAllAgents=false", name)
390+
}
391+
}

sandbox/create.go

Lines changed: 19 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,16 +1354,15 @@ func copySeedFiles(agentDef *agent.Definition, sandboxDir string, hasAPIKey bool
13541354
}
13551355

13561356
// ensureContainerSettings merges required container settings into agent-state/settings.json.
1357-
// Agent-specific adjustments:
1358-
// - Claude Code: skip --dangerously-skip-permissions prompt, disable nested sandbox-exec.
1359-
// - Gemini CLI: disable folder-trust prompt (the container IS the sandbox).
1360-
// - Shell: apply each real agent's settings into home-seed subdirectories.
1357+
// Agent-specific adjustments are driven by each agent's ApplySettings field.
1358+
// Shell agents (SeedsAllAgents=true) apply each real agent's settings into
1359+
// home-seed subdirectories instead.
13611360
func ensureContainerSettings(agentDef *agent.Definition, sandboxDir, isolation string) error {
1362-
if agentDef.Name == "shell" {
1361+
if agentDef.SeedsAllAgents {
13631362
return ensureShellContainerSettings(sandboxDir, isolation)
13641363
}
13651364

1366-
if agentDef.StateDir == "" {
1365+
if agentDef.StateDir == "" || agentDef.ApplySettings == nil {
13671366
return nil
13681367
}
13691368

@@ -1376,133 +1375,37 @@ func ensureContainerSettings(agentDef *agent.Definition, sandboxDir, isolation s
13761375
}
13771376
settingsPath := filepath.Join(agentStateDir, "settings.json")
13781377

1379-
switch agentDef.Name {
1380-
case "claude":
1381-
settings, err := readJSONMap(settingsPath)
1382-
if err != nil {
1383-
return err
1384-
}
1385-
settings["skipDangerousModePermissionPrompt"] = true
1386-
// Disable Claude Code's built-in sandbox-exec to prevent nesting failures.
1387-
// sandbox-exec cannot be nested — an inner sandbox-exec inherits the outer
1388-
// profile's restrictions and typically fails.
1389-
settings["sandbox"] = map[string]interface{}{"enabled": false}
1390-
// Ensure Claude Code emits BEL for tmux tab highlighting
1391-
settings["preferredNotifChannel"] = "terminal_bell"
1392-
// Inject hooks for status tracking. Claude Code's own hook system is
1393-
// far more reliable than polling tmux capture-pane for a ready pattern.
1394-
injectIdleHook(settings)
1395-
return writeJSONMap(settingsPath, settings)
1396-
1397-
case "gemini":
1398-
settings, err := readJSONMap(settingsPath)
1399-
if err != nil {
1400-
return err
1401-
}
1402-
// Preserve existing security settings (e.g. auth.selectedType) while
1403-
// disabling folder trust — the container is already sandboxed.
1404-
security, _ := settings["security"].(map[string]interface{})
1405-
if security == nil {
1406-
security = map[string]interface{}{}
1407-
}
1408-
security["folderTrust"] = map[string]interface{}{"enabled": false}
1409-
settings["security"] = security
1410-
return writeJSONMap(settingsPath, settings)
1411-
1412-
default:
1413-
return nil
1378+
settings, err := readJSONMap(settingsPath)
1379+
if err != nil {
1380+
return err
14141381
}
1382+
agentDef.ApplySettings(settings)
1383+
return writeJSONMap(settingsPath, settings)
14151384
}
14161385

14171386
// ensureShellContainerSettings applies each real agent's container settings
14181387
// to its home-seed subdirectory (e.g., home-seed/.claude/settings.json).
1419-
func ensureShellContainerSettings(sandboxDir, isolation string) error {
1388+
func ensureShellContainerSettings(sandboxDir string, _ string) error {
14201389
for _, name := range agent.RealAgents() {
14211390
def := agent.GetAgent(name)
1422-
if def.StateDir == "" {
1391+
if def.StateDir == "" || def.ApplySettings == nil {
14231392
continue
14241393
}
14251394
dirBase := filepath.Base(def.StateDir)
14261395
settingsPath := filepath.Join(sandboxDir, "home-seed", dirBase, "settings.json")
14271396

1428-
switch name {
1429-
case "claude":
1430-
settings, err := readJSONMap(settingsPath)
1431-
if err != nil {
1432-
return err
1433-
}
1434-
settings["skipDangerousModePermissionPrompt"] = true
1435-
settings["sandbox"] = map[string]interface{}{"enabled": false}
1436-
injectIdleHook(settings)
1437-
if err := writeJSONMap(settingsPath, settings); err != nil {
1438-
return err
1439-
}
1440-
1441-
case "gemini":
1442-
settings, err := readJSONMap(settingsPath)
1443-
if err != nil {
1444-
return err
1445-
}
1446-
security, _ := settings["security"].(map[string]interface{})
1447-
if security == nil {
1448-
security = map[string]interface{}{}
1449-
}
1450-
security["folderTrust"] = map[string]interface{}{"enabled": false}
1451-
settings["security"] = security
1452-
if err := writeJSONMap(settingsPath, settings); err != nil {
1453-
return err
1454-
}
1397+
settings, err := readJSONMap(settingsPath)
1398+
if err != nil {
1399+
return err
1400+
}
1401+
def.ApplySettings(settings)
1402+
if err := writeJSONMap(settingsPath, settings); err != nil {
1403+
return err
14551404
}
14561405
}
14571406
return nil
14581407
}
14591408

1460-
// statusIdleCommand writes idle status to agent-status.json and appends a
1461-
// structured JSONL entry to logs/agent-hooks.jsonl when Claude finishes a
1462-
// response (Notification hook). Uses $YOLOAI_DIR for portability across
1463-
// backends (Docker=/yoloai, seatbelt=sandbox dir).
1464-
const statusIdleCommand = `printf '{"ts":"%s","level":"info","event":"hook.idle","msg":"agent hook: idle","status":"idle"}\n' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" >> "${YOLOAI_DIR:-/yoloai}/logs/agent-hooks.jsonl" && printf '{"status":"idle","exit_code":null,"timestamp":%d}\n' "$(date +%s)" > "${YOLOAI_DIR:-/yoloai}/agent-status.json"`
1465-
1466-
// statusActiveCommand writes active status to agent-status.json and appends a
1467-
// structured JSONL entry to logs/agent-hooks.jsonl when Claude starts working
1468-
// (PreToolUse hook). This ensures the title updates from "> name" back to
1469-
// "name" when the user submits a new prompt.
1470-
const statusActiveCommand = `printf '{"ts":"%s","level":"info","event":"hook.active","msg":"agent hook: active","status":"active"}\n' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" >> "${YOLOAI_DIR:-/yoloai}/logs/agent-hooks.jsonl" && printf '{"status":"active","exit_code":null,"timestamp":%d}\n' "$(date +%s)" > "${YOLOAI_DIR:-/yoloai}/agent-status.json"`
1471-
1472-
// injectIdleHook merges hooks into Claude Code's settings map for status tracking.
1473-
// Notification → idle (turn complete), PreToolUse → running (work started).
1474-
// Preserves any existing hooks the user may have configured.
1475-
func injectIdleHook(settings map[string]any) {
1476-
hooks, _ := settings["hooks"].(map[string]any)
1477-
if hooks == nil {
1478-
hooks = map[string]any{}
1479-
}
1480-
1481-
// Notification hook: mark idle when Claude finishes a response.
1482-
idleHook := map[string]any{
1483-
"type": "command",
1484-
"command": statusIdleCommand,
1485-
}
1486-
idleGroup := map[string]any{
1487-
"hooks": []any{idleHook},
1488-
}
1489-
existingNotif, _ := hooks["Notification"].([]any)
1490-
hooks["Notification"] = append(existingNotif, idleGroup)
1491-
1492-
// PreToolUse hook: mark active when Claude starts using tools.
1493-
activeHook := map[string]any{
1494-
"type": "command",
1495-
"command": statusActiveCommand,
1496-
}
1497-
activeGroup := map[string]any{
1498-
"hooks": []any{activeHook},
1499-
}
1500-
existingPre, _ := hooks["PreToolUse"].([]any)
1501-
hooks["PreToolUse"] = append(existingPre, activeGroup)
1502-
1503-
settings["hooks"] = hooks
1504-
}
1505-
15061409
// ensureHomeSeedConfig patches home-seed/.claude.json to set installMethod to
15071410
// "npm-global". The host file typically has "native" since the user's local
15081411
// Claude Code uses the native installer, but inside the container we install

sandbox/create_seed.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ func (m *Manager) seedSandbox(agentDef *agent.Definition, sandboxDir, isolation
1818
return false, fmt.Errorf("copy seed files: %w", err)
1919
}
2020

21-
// Warn when Claude is using short-lived OAuth credentials instead of a long-lived token.
22-
if agentDef.Name == "claude" && copiedAuth {
21+
// Warn when an agent is using short-lived OAuth credentials instead of a long-lived token.
22+
if agentDef.ShortLivedOAuthWarning && copiedAuth {
2323
fmt.Fprintln(m.output, "Warning: using OAuth credentials from ~/.claude/.credentials.json") //nolint:errcheck // best-effort warning
2424
fmt.Fprintln(m.output, " These tokens expire after ~30 minutes and may fail in long-running sessions.") //nolint:errcheck // best-effort warning
2525
fmt.Fprintln(m.output, " For reliable auth, run 'claude setup-token' and export CLAUDE_CODE_OAUTH_TOKEN instead.") //nolint:errcheck // best-effort warning

0 commit comments

Comments
 (0)