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
20 changes: 20 additions & 0 deletions daemon/remote/cmd/cmuxd-remote/agent_launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down Expand Up @@ -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)
}
Comment on lines +468 to +478

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

ast-grep outline daemon/remote/cmd/cmuxd-remote \
  --items all --type function \
  --match 'configureClaudeTeamsShellWrapper|runClaudeTeamsRelay|tmuxClaudeTeamsRespawnEnvironment'

rg -n -C 5 \
  'CMUX_CLAUDE_TEAMS_SANDBOXED|CLAUDE_CODE_SANDBOXED|configureClaudeTeamsShellWrapper' \
  daemon/remote/cmd/cmuxd-remote -g '*.go'

Repository: manaflow-ai/cmux

Length of output: 22929


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- agent_launch.go: runClaudeTeamsRelay and cleanup ---'
sed -n '360,520p' daemon/remote/cmd/cmuxd-remote/agent_launch.go

printf '%s\n' '--- agent_launch.go: environment configuration and call order ---'
rg -n -C 12 \
  'configureAgentEnvironment|runClaudeTeamsRelay|extraEnv|syscall.Exec|CMUX_CLAUDE_TEAMS_SANDBOXED|CLAUDE_CODE_SANDBOXED' \
  daemon/remote/cmd/cmuxd-remote/agent_launch.go daemon/remote/cmd/cmuxd-remote/*.go

printf '%s\n' '--- agent_launch_shell.go: complete wrapper implementation ---'
cat -n daemon/remote/cmd/cmuxd-remote/agent_launch_shell.go

printf '%s\n' '--- respawn transport implementation ---'
sed -n '1735,1815p' daemon/remote/cmd/cmuxd-remote/tmux_compat.go

Repository: manaflow-ai/cmux

Length of output: 50372


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- configureClaudeTeamsShellWrapper ---'
sed -n '1,145p' daemon/remote/cmd/cmuxd-remote/agent_launch_shell.go

printf '%s\n' '--- production references to sandbox opt-in ---'
rg -n -C 8 \
  'CMUX_CLAUDE_TEAMS_SANDBOXED|CLAUDE_CODE_SANDBOXED|sandbox' \
  daemon/remote/cmd/cmuxd-remote \
  -g '*.go' \
  -g '!**/*_test.go'

printf '%s\n' '--- repository-wide non-test references ---'
rg -n -C 5 \
  'CMUX_CLAUDE_TEAMS_SANDBOXED|CLAUDE_CODE_SANDBOXED' \
  -g '!**/*_test.go' \
  -g '!**/vendor/**'

Repository: manaflow-ai/cmux

Length of output: 9149


Preserve the sandbox opt-in for teammate respawns.

configureClaudeTeamsShellWrapper does not restore CMUX_CLAUDE_TEAMS_SANDBOXED. After configureAgentEnvironment clears it, tmuxClaudeTeamsRespawnEnvironment cannot emit CLAUDE_CODE_SANDBOXED=1. Pass the opt-in through agentConfig.extraEnv or another authoritative post-cleanup source.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daemon/remote/cmd/cmuxd-remote/agent_launch.go` around lines 468 - 478, The
cleanup in configureAgentEnvironment must not discard the teammate sandbox
opt-in needed by tmuxClaudeTeamsRespawnEnvironment. Preserve
CMUX_CLAUDE_TEAMS_SANDBOXED through an authoritative post-cleanup source such as
agentConfig.extraEnv, so configureClaudeTeamsShellWrapper can restore it and
respawns emit CLAUDE_CODE_SANDBOXED=1.


// Publish only the socket-validated inherited routing identity. Invalid or
// incomplete ambient fragments must not leak into the agent process.
if cfg.launchContext != nil {
Expand Down
3 changes: 3 additions & 0 deletions daemon/remote/cmd/cmuxd-remote/agent_launch_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines +134 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Assert that inherited sandbox state is removed.

The test only preserves these variables. It does not set non-empty values or verify that configureAgentEnvironment removes them. Seed CLAUDE_CODE_SANDBOXED, CMUX_CLAUDE_TEAMS_SANDBOXED, and claudeTeamsRespawnEnvironmentKey, then assert that all are absent after configuration.

Proposed test extension
 t.Setenv("PATH", "/usr/bin:/bin")
+ t.Setenv("CLAUDE_CODE_SANDBOXED", "1")
+ t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "1")
+ t.Setenv(claudeTeamsRespawnEnvironmentKey, "transport")
 
 configureAgentEnvironment(agentConfig{
@@
 })
 
+ for _, key := range []string{
+   "CLAUDE_CODE_SANDBOXED",
+   "CMUX_CLAUDE_TEAMS_SANDBOXED",
+   claudeTeamsRespawnEnvironmentKey,
+ } {
+   if value, present := os.LookupEnv(key); present {
+     t.Errorf("%s leaked inherited value %q", key, value)
+   }
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"CLAUDE_CODE_SANDBOXED",
"CMUX_CLAUDE_TEAMS_SANDBOXED",
claudeTeamsRespawnEnvironmentKey,
t.Setenv("PATH", "/usr/bin:/bin")
t.Setenv("CLAUDE_CODE_SANDBOXED", "1")
t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "1")
t.Setenv(claudeTeamsRespawnEnvironmentKey, "transport")
configureAgentEnvironment(agentConfig{
// existing configuration
})
for _, key := range []string{
"CLAUDE_CODE_SANDBOXED",
"CMUX_CLAUDE_TEAMS_SANDBOXED",
claudeTeamsRespawnEnvironmentKey,
} {
if value, present := os.LookupEnv(key); present {
t.Errorf("%s leaked inherited value %q", key, value)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daemon/remote/cmd/cmuxd-remote/agent_launch_context_test.go` around lines 134
- 136, Update the test around configureAgentEnvironment to seed non-empty values
for CLAUDE_CODE_SANDBOXED, CMUX_CLAUDE_TEAMS_SANDBOXED, and
claudeTeamsRespawnEnvironmentKey before configuration, then assert each variable
is absent afterward while preserving the existing environment setup.

} {
t.Setenv(key, os.Getenv(key))
}
Expand Down
60 changes: 60 additions & 0 deletions daemon/remote/cmd/cmuxd-remote/claude_teams_respawn_env.go
Original file line number Diff line number Diff line change
@@ -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
}
165 changes: 165 additions & 0 deletions daemon/remote/cmd/cmuxd-remote/claude_teams_respawn_env_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
38 changes: 30 additions & 8 deletions daemon/remote/cmd/cmuxd-remote/tmux_compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions daemon/remote/cmd/cmuxd-remote/tmux_corpus_behavior_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}
Expand Down