diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index daf95b934a6..d13cd122e18 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -68,6 +68,14 @@ func runClaudeTeamsRelay(socketPath string, args []string, refreshAddr func() st configureClaudeNodeOptions(restoreModulePath) } + // Record the fully configured launch environment last, so teammate respawns + // replay the PATH this process actually execs claude with. + if encoded := encodeClaudeTeamsRespawnEnvironment(os.Environ()); encoded != "" { + os.Setenv(claudeTeamsRespawnEnvironmentKey, encoded) + } else { + os.Unsetenv(claudeTeamsRespawnEnvironmentKey) + } + launchArgs := claudeTeamsLaunchArgs(args) argv := append([]string{claudePath}, launchArgs...) @@ -457,6 +465,18 @@ func configureAgentEnvironment(cfg agentConfig) { os.Setenv("COLORTERM", "truecolor") } + // Drop launch state inherited from an enclosing claude-teams process tree: its + // PATH must not replace this agent's own in a respawn, and its trust-prompt + // bypass must not waive this agent's own prompt. cfg.extraEnv is applied after + // this, and runClaudeTeamsRelay records its own transport once this returns. + for _, key := range []string{ + "CLAUDE_CODE_SANDBOXED", + "CMUX_CLAUDE_TEAMS_SANDBOXED", + claudeTeamsRespawnEnvironmentKey, + } { + os.Unsetenv(key) + } + // Publish only the socket-validated inherited routing identity. Invalid or // incomplete ambient fragments must not leak into the agent process. if cfg.launchContext != nil { diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch_context_test.go b/daemon/remote/cmd/cmuxd-remote/agent_launch_context_test.go index 7c339e0cce3..f52658f1be6 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch_context_test.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch_context_test.go @@ -131,6 +131,9 @@ func TestConfigureAgentEnvironmentClearsRejectedRoutingIdentity(t *testing.T) { "COLORTERM", "CMUX_AGENT_LAUNCH_TEST_BIN", "CMUX_AGENT_LAUNCH_TEST_TERM", + "CLAUDE_CODE_SANDBOXED", + "CMUX_CLAUDE_TEAMS_SANDBOXED", + claudeTeamsRespawnEnvironmentKey, } { t.Setenv(key, os.Getenv(key)) } diff --git a/daemon/remote/cmd/cmuxd-remote/claude_teams_respawn_env.go b/daemon/remote/cmd/cmuxd-remote/claude_teams_respawn_env.go new file mode 100644 index 00000000000..73ae7e27fdc --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/claude_teams_respawn_env.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "strings" +) + +// claudeTeamsRespawnEnvironmentKey carries the encoded launcher environment from +// the claude-teams relay to the `__tmux-compat` process that respawns a teammate +// pane. Same key and wire format as the Swift +// ClaudeTeamsRespawnEnvironmentTransport, so either end can produce the value. +const claudeTeamsRespawnEnvironmentKey = "CMUX_CLAUDE_TEAMS_RESPAWN_ENV_B64" + +// claudeTeamsRespawnEnvironmentAllowlist is deliberately narrower than the Swift +// AgentLaunchEnvironmentPolicy: PATH is the only value a remote teammate needs +// replayed, and a second hand-copied allowlist would drift from the Swift one. +var claudeTeamsRespawnEnvironmentAllowlist = []string{"PATH"} + +// encodeClaudeTeamsRespawnEnvironment encodes the replay-safe subset of a +// launcher environment as base64 JSON, or "" when there is nothing to replay. +func encodeClaudeTeamsRespawnEnvironment(environ []string) string { + environment, _ := envMapWithOrder(environ) + selected := selectClaudeTeamsRespawnEnvironment(environment) + if len(selected) == 0 { + return "" + } + // json.Marshal sorts map keys, matching the Swift encoder's .sortedKeys. + encoded, err := json.Marshal(selected) + if err != nil { + return "" + } + return base64.StdEncoding.EncodeToString(encoded) +} + +// decodeClaudeTeamsRespawnEnvironment decodes a transport value and reapplies the +// allowlist, so a forged value cannot promote arbitrary variables into a teammate +// pane. Invalid data yields no values rather than a partial environment. +func decodeClaudeTeamsRespawnEnvironment(encoded string) map[string]string { + data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return nil + } + var transported map[string]string + if err := json.Unmarshal(data, &transported); err != nil { + return nil + } + return selectClaudeTeamsRespawnEnvironment(transported) +} + +func selectClaudeTeamsRespawnEnvironment(environment map[string]string) map[string]string { + selected := make(map[string]string, len(claudeTeamsRespawnEnvironmentAllowlist)) + for _, key := range claudeTeamsRespawnEnvironmentAllowlist { + // An empty value would emit `export PATH=''` and leave the pane worse off. + if value := environment[key]; value != "" { + selected[key] = value + } + } + return selected +} diff --git a/daemon/remote/cmd/cmuxd-remote/claude_teams_respawn_env_test.go b/daemon/remote/cmd/cmuxd-remote/claude_teams_respawn_env_test.go new file mode 100644 index 00000000000..99c2cb3e23c --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/claude_teams_respawn_env_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "encoding/base64" + "os" + "testing" +) + +func TestEncodeClaudeTeamsRespawnEnvironmentRoundTripsPath(t *testing.T) { + encoded := encodeClaudeTeamsRespawnEnvironment([]string{ + "PATH=/opt/homebrew/bin:/usr/bin:/bin", + "HOME=/home/user", + }) + if encoded == "" { + t.Fatal("encode returned empty for an environment containing PATH") + } + + decoded := decodeClaudeTeamsRespawnEnvironment(encoded) + if got := decoded["PATH"]; got != "/opt/homebrew/bin:/usr/bin:/bin" { + t.Errorf("PATH = %q, want the launcher PATH", got) + } + if len(decoded) != 1 { + t.Errorf("decoded = %v, want PATH only", decoded) + } +} + +func TestEncodeClaudeTeamsRespawnEnvironmentDropsNonAllowlistedKeys(t *testing.T) { + encoded := encodeClaudeTeamsRespawnEnvironment([]string{ + "PATH=/usr/bin", + "ANTHROPIC_API_KEY=secret", + "CMUX_SURFACE_ID=surface-1", + "malformed-entry-without-separator", + }) + + decoded := decodeClaudeTeamsRespawnEnvironment(encoded) + for _, key := range []string{"ANTHROPIC_API_KEY", "CMUX_SURFACE_ID"} { + if _, ok := decoded[key]; ok { + t.Errorf("%s crossed the respawn boundary", key) + } + } + if decoded["PATH"] != "/usr/bin" { + t.Errorf("PATH = %q, want /usr/bin", decoded["PATH"]) + } +} + +func TestEncodeClaudeTeamsRespawnEnvironmentWithoutPathEncodesNothing(t *testing.T) { + for _, environ := range [][]string{ + {"HOME=/home/user"}, + {"PATH="}, + nil, + } { + if got := encodeClaudeTeamsRespawnEnvironment(environ); got != "" { + t.Errorf("encode(%v) = %q, want empty", environ, got) + } + } +} + +// A forged or truncated transport value must yield nothing, never a partial or +// attacker-chosen environment. +func TestDecodeClaudeTeamsRespawnEnvironmentFailsClosed(t *testing.T) { + tests := []struct { + name string + encoded string + }{ + {"empty", ""}, + {"not base64", "!!!not-base64!!!"}, + {"base64 of non-JSON", base64.StdEncoding.EncodeToString([]byte("PATH=/usr/bin"))}, + {"base64 of a JSON array", base64.StdEncoding.EncodeToString([]byte(`["PATH"]`))}, + {"base64 of a JSON string", base64.StdEncoding.EncodeToString([]byte(`"PATH"`))}, + {"non-string JSON values", base64.StdEncoding.EncodeToString([]byte(`{"PATH":42}`))}, + {"allowlisted key absent", base64.StdEncoding.EncodeToString([]byte(`{"LD_PRELOAD":"/tmp/evil.so"}`))}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if decoded := decodeClaudeTeamsRespawnEnvironment(tc.encoded); len(decoded) != 0 { + t.Errorf("decoded = %v, want no values", decoded) + } + }) + } +} + +func TestTmuxClaudeTeamsRespawnEnvironmentReplaysTransportedPath(t *testing.T) { + encoded := encodeClaudeTeamsRespawnEnvironment([]string{"PATH=/opt/homebrew/bin:/usr/bin"}) + t.Setenv(claudeTeamsRespawnEnvironmentKey, encoded) + t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "") + + pairs := tmuxClaudeTeamsRespawnEnvironment() + want := []tmuxEnvPair{{key: "PATH", value: "/opt/homebrew/bin:/usr/bin"}} + if len(pairs) != len(want) || pairs[0] != want[0] { + t.Errorf("pairs = %v, want %v", pairs, want) + } +} + +func TestTmuxClaudeTeamsRespawnEnvironmentOrdersKeysDeterministically(t *testing.T) { + t.Setenv(claudeTeamsRespawnEnvironmentKey, encodeClaudeTeamsRespawnEnvironment([]string{"PATH=/usr/bin"})) + t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "1") + + pairs := tmuxClaudeTeamsRespawnEnvironment() + want := []tmuxEnvPair{ + {key: "CLAUDE_CODE_SANDBOXED", value: "1"}, + {key: "PATH", value: "/usr/bin"}, + } + if len(pairs) != len(want) { + t.Fatalf("pairs = %v, want %v", pairs, want) + } + for i := range want { + if pairs[i] != want[i] { + t.Errorf("pairs[%d] = %v, want %v", i, pairs[i], want[i]) + } + } +} + +func TestTmuxClaudeTeamsRespawnEnvironmentWithoutOptInOrTransportIsEmpty(t *testing.T) { + t.Setenv(claudeTeamsRespawnEnvironmentKey, "garbage") + t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "") + + if pairs := tmuxClaudeTeamsRespawnEnvironment(); pairs != nil { + t.Errorf("pairs = %v, want nil", pairs) + } +} + +// `cmux omc`/`omo`/`omx` launched from inside a claude-teams process tree must +// not replay the lead's PATH into its own pane respawns. +func TestConfigureAgentEnvironmentClearsInheritedRespawnTransport(t *testing.T) { + for _, key := range []string{ + "PATH", + "TMUX", + "TMUX_PANE", + "TERM", + "CMUX_SOCKET_PATH", + "CMUX_SOCKET", + "TERM_PROGRAM", + "COLORTERM", + "CMUX_WORKSPACE_ID", + "CMUX_SURFACE_ID", + "CMUX_PANEL_ID", + "CMUX_TAB_ID", + "CMUX_PANE_ID", + "CMUX_RESPAWN_TRANSPORT_TEST_BIN", + "CMUX_RESPAWN_TRANSPORT_TEST_TERM", + } { + t.Setenv(key, os.Getenv(key)) + } + t.Setenv("PATH", "/omc/own/bin:/usr/bin") + t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "") + t.Setenv(claudeTeamsRespawnEnvironmentKey, + encodeClaudeTeamsRespawnEnvironment([]string{"PATH=/claude-teams/lead/bin"})) + + configureAgentEnvironment(agentConfig{ + shimDir: t.TempDir(), + socketPath: "/tmp/cmux-respawn-transport-test.sock", + launchContext: nil, + tmuxPathPrefix: "cmux-omc", + cmuxBinEnvVar: "CMUX_RESPAWN_TRANSPORT_TEST_BIN", + termEnvVar: "CMUX_RESPAWN_TRANSPORT_TEST_TERM", + extraEnv: map[string]string{}, + }) + + if value, present := os.LookupEnv(claudeTeamsRespawnEnvironmentKey); present { + t.Errorf("%s survived as %q", claudeTeamsRespawnEnvironmentKey, value) + } + if pairs := tmuxClaudeTeamsRespawnEnvironment(); pairs != nil { + t.Errorf("pairs = %v, want nil", pairs) + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat.go index 84f5f3fa72a..a74fede2b1c 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "time" ) @@ -1754,17 +1755,38 @@ func tmuxRespawnStartCommand(command string, prependEnv []tmuxEnvPair) string { } // tmuxClaudeTeamsRespawnEnvironment re-supplies the environment a -// claude-teams teammate pane must start with. CLAUDE_CODE_SANDBOXED -// short-circuits Claude Code's interactive trust prompt, which a teammate -// pane can never answer. It is only set when the claude-teams launcher -// recorded the user's explicit opt-in (CMUX_CLAUDE_TEAMS_SANDBOXED=1), -// propagated to this process by the tmux shim. Mirrors the Swift -// tmuxClaudeTeamsRespawnEnvironment; see that for the full rationale. +// claude-teams teammate pane must start with. The pane is spawned by the +// relay rather than by the launcher, so it does not inherit the launcher's +// PATH; runClaudeTeamsRelay records that PATH in the respawn transport and +// this replays it, keeping teammate executable discovery identical to the +// lead's. CLAUDE_CODE_SANDBOXED short-circuits Claude Code's interactive +// trust prompt, which a teammate pane can never answer. It is only set when +// the claude-teams launcher recorded the user's explicit opt-in +// (CMUX_CLAUDE_TEAMS_SANDBOXED=1), propagated to this process by the tmux +// shim. Narrower than the Swift tmuxClaudeTeamsRespawnEnvironment, which also +// replays AgentLaunchEnvironmentPolicy.safeEnvironmentKeys; this replays PATH +// only. func tmuxClaudeTeamsRespawnEnvironment() []tmuxEnvPair { - if strings.TrimSpace(os.Getenv("CMUX_CLAUDE_TEAMS_SANDBOXED")) != "1" { + environment := decodeClaudeTeamsRespawnEnvironment(os.Getenv(claudeTeamsRespawnEnvironmentKey)) + if strings.TrimSpace(os.Getenv("CMUX_CLAUDE_TEAMS_SANDBOXED")) == "1" { + if environment == nil { + environment = map[string]string{} + } + environment["CLAUDE_CODE_SANDBOXED"] = "1" + } + if len(environment) == 0 { return nil } - return []tmuxEnvPair{{key: "CLAUDE_CODE_SANDBOXED", value: "1"}} + keys := make([]string, 0, len(environment)) + for key := range environment { + keys = append(keys, key) + } + sort.Strings(keys) + pairs := make([]tmuxEnvPair, 0, len(keys)) + for _, key := range keys { + pairs = append(pairs, tmuxEnvPair{key: key, value: environment[key]}) + } + return pairs } func tmuxSendKeys(rc *rpcContext, args []string) error { diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 15de4a51dfa..8d843afe01a 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -417,6 +417,8 @@ func TestConfigureAgentEnvironment(t *testing.T) { "TERM", "CMUX_SOCKET_PATH", "TERM_PROGRAM", "CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", "CMUX_PANEL_ID", "CMUX_TAB_ID", "CMUX_PANE_ID", "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "COLORTERM", + "CLAUDE_CODE_SANDBOXED", "CMUX_CLAUDE_TEAMS_SANDBOXED", + claudeTeamsRespawnEnvironmentKey, } saved := make(map[string]string) for _, k := range envKeys { diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_corpus_behavior_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_corpus_behavior_test.go index 7bb9d05eda4..5d39b346a81 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_corpus_behavior_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_corpus_behavior_test.go @@ -333,6 +333,9 @@ func TestTmuxCorpusNewSessionAndNewWindowCommandsDispatchShellText(t *testing.T) // respawn-pane" and Claude Code fell back to headless for the session. func TestTmuxCorpusRespawnPaneDispatchesSurfaceRespawn(t *testing.T) { t.Setenv("HOME", t.TempDir()) + // The respawn dispatch reads both from the ambient environment; pin them for every subtest. + t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "") + t.Setenv(claudeTeamsRespawnEnvironmentKey, "") const paneTarget = "%33333333-3333-4333-8333-333333333333" const wantSurface = "44444444-4444-4444-8444-444444444444" @@ -507,6 +510,53 @@ func TestTmuxCorpusRespawnPaneDispatchesSurfaceRespawn(t *testing.T) { } }) + t.Run("claude-teams respawn transport prepends the launcher PATH", func(t *testing.T) { + t.Setenv(claudeTeamsRespawnEnvironmentKey, encodeClaudeTeamsRespawnEnvironment( + []string{"PATH=/opt/homebrew/bin:/usr/bin:/bin"}, + )) + recorder := startTmuxCorpusRPCRecorder(t) + rc := &rpcContext{socketPath: recorder.socketPath} + + err := dispatchTmuxCommand(rc, "respawn-pane", []string{ + "-k", "-t", paneTarget, "claude --agent-id teammate-1", + }) + if err != nil { + t.Fatalf("respawn-pane: %v", err) + } + requests := recorder.requestsFor("surface.respawn") + if len(requests) != 1 { + t.Fatalf("surface.respawn requests = %d, want 1", len(requests)) + } + params := requests[0].Params + want := `/bin/sh -c 'export PATH='"'"'/opt/homebrew/bin:/usr/bin:/bin'"'"'; claude --agent-id teammate-1'` + if got := params["command"]; got != want { + t.Errorf("command = %q, want %q", got, want) + } + if got := params["tmux_start_command"]; got != "claude --agent-id teammate-1" { + t.Errorf("tmux_start_command = %q (must stay raw for persistence)", got) + } + }) + + t.Run("claude-teams respawn ignores an unreadable transport value", func(t *testing.T) { + t.Setenv(claudeTeamsRespawnEnvironmentKey, "!!!not-base64!!!") + recorder := startTmuxCorpusRPCRecorder(t) + rc := &rpcContext{socketPath: recorder.socketPath} + + err := dispatchTmuxCommand(rc, "respawn-pane", []string{ + "-k", "-t", paneTarget, "claude --agent-id teammate-1", + }) + if err != nil { + t.Fatalf("respawn-pane: %v", err) + } + requests := recorder.requestsFor("surface.respawn") + if len(requests) != 1 { + t.Fatalf("surface.respawn requests = %d, want 1", len(requests)) + } + if got := requests[0].Params["command"]; got != `/bin/sh -c 'claude --agent-id teammate-1'` { + t.Errorf("command = %q, want no exports", got) + } + }) + t.Run("missing -k is rejected", func(t *testing.T) { recorder := startTmuxCorpusRPCRecorder(t) rc := &rpcContext{socketPath: recorder.socketPath}