Skip to content
Merged
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
12 changes: 10 additions & 2 deletions internal/cli/host_launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,16 @@ func TestMain(m *testing.M) {
default:
// Most historical front-door tests assert the non-targeting argv contract.
// Keep that baseline independent of the developer's terminal; dedicated
// wrapper fixtures set targeting metadata explicitly.
for _, key := range []string{"ZELLIJ", "ZELLIJ_PANE_ID", "ZELLIJ_SESSION_NAME"} {
// wrapper fixtures set targeting metadata explicitly. All nine names the
// terminal-host allowance probes, so the suite passes identically under
// tmux, Zellij, Herdr, CMUX, or a bare terminal (TERM_PROGRAM included).
for _, key := range []string{
"ZELLIJ_SESSION_NAME", "ZELLIJ_PANE_ID",
"TMUX", "TMUX_PANE",
"HERDR_ENV", "HERDR_PANE_ID",
"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID",
"TERM_PROGRAM",
} {
if err := os.Unsetenv(key); err != nil {
fmt.Fprintln(os.Stderr, "test setup: unset", key+":", err)
os.Exit(1)
Expand Down
81 changes: 52 additions & 29 deletions internal/cli/safehouse_env_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ func envPassSafehouse(t *testing.T, dir string) string {
body := `#!/bin/sh
# Capture the inherited value before scrubbing (the host env safehouse sees).
saved_bin="${SPACEDOCK_BIN-}"
saved_zellij="${ZELLIJ-}"
saved_zellij_pane_id="${ZELLIJ_PANE_ID-}"
saved_zellij_session_name="${ZELLIJ_SESSION_NAME-}"
saved_tmux="${TMUX-}"
saved_tmux_pane="${TMUX_PANE-}"
saved_extra_target="${EXTRA_TARGET-}"
pass="${SAFEHOUSE_ENV_PASS-}"
unset SPACEDOCK_BIN ZELLIJ ZELLIJ_PANE_ID ZELLIJ_SESSION_NAME EXTRA_TARGET
unset SPACEDOCK_BIN ZELLIJ_PANE_ID ZELLIJ_SESSION_NAME TMUX TMUX_PANE EXTRA_TARGET
append_pass() {
if [ -n "$pass" ]; then pass="$pass,$1"; else pass="$1"; fi
}
Expand All @@ -43,9 +44,10 @@ done
if [ "$1" = "--" ]; then shift; fi
# Honor each named pass-through: forward values from the saved host env.
case ",$pass," in *,SPACEDOCK_BIN,*) export SPACEDOCK_BIN="$saved_bin" ;; esac
case ",$pass," in *,ZELLIJ,*) export ZELLIJ="$saved_zellij" ;; esac
case ",$pass," in *,ZELLIJ_PANE_ID,*) export ZELLIJ_PANE_ID="$saved_zellij_pane_id" ;; esac
case ",$pass," in *,ZELLIJ_SESSION_NAME,*) export ZELLIJ_SESSION_NAME="$saved_zellij_session_name" ;; esac
case ",$pass," in *,TMUX,*) export TMUX="$saved_tmux" ;; esac
case ",$pass," in *,TMUX_PANE,*) export TMUX_PANE="$saved_tmux_pane" ;; esac
case ",$pass," in *,EXTRA_TARGET,*) export EXTRA_TARGET="$saved_extra_target" ;; esac
exec "$@"
`
Expand Down Expand Up @@ -134,42 +136,56 @@ func TestSafehouseEnvPassForwardsSpacedockBin(t *testing.T) {
})
}

func TestSafehouseEnvPassForwardsZellijTargetingMetadata(t *testing.T) {
// TestSafehouseEnvPassForwardsTerminalTargetingMetadata proves the presence
// filter through a scrubbing wrapper for two host families: a tmux-pair
// parent's child sees the pair's values and no Zellij names, and the
// Zellij-pair case still forwards — each with the other family's names never
// presented as empty (they're never named in --env-pass to begin with).
func TestSafehouseEnvPassForwardsTerminalTargetingMetadata(t *testing.T) {
dir := t.TempDir()
safehousePath := envPassSafehouse(t, dir)
probe := zellijTargetingProbe(t, dir)
probe := terminalTargetingProbe(t, dir)

parent := []string{
"PATH=/usr/bin:/bin",
"ZELLIJ=0",
"ZELLIJ_PANE_ID=51",
"ZELLIJ_SESSION_NAME=excellent-pheasant",
}

t.Run("wrapper-owned targeting allowlist forwards exact inherited values", func(t *testing.T) {
setZellijTargetingEnv(t)
t.Run("tmux pair forwards exact inherited values; Zellij names stay unset", func(t *testing.T) {
setTmuxTargetingEnv(t)
parent := []string{
"PATH=/usr/bin:/bin",
"TMUX=/tmp/tmux-501/default,12345,0",
"TMUX_PANE=%3",
}
argv := safehouse.Wrap([]string{probe}, []string{"--env-pass", spacedockBinEnv})
out := runWrapped(t, safehousePath, argv[1:], parent)
want := "ZELLIJ=0\nZELLIJ_PANE_ID=51\nZELLIJ_SESSION_NAME=excellent-pheasant"
want := "TMUX=/tmp/tmux-501/default,12345,0\nTMUX_PANE=%3\nZELLIJ_PANE_ID=<unset>\nZELLIJ_SESSION_NAME=<unset>"
if out != want {
t.Fatalf("Zellij metadata = %q, want %q", out, want)
t.Fatalf("terminal metadata = %q, want %q", out, want)
}
})

t.Run("without Zellij names the scrubbed child cannot see targeting metadata", func(t *testing.T) {
clearZellijTargetingEnv(t)
t.Run("Zellij pair still forwards; tmux names stay unset", func(t *testing.T) {
setZellijTargetingEnv(t)
parent := []string{
"PATH=/usr/bin:/bin",
"ZELLIJ_PANE_ID=51",
"ZELLIJ_SESSION_NAME=excellent-pheasant",
}
argv := safehouse.Wrap([]string{probe}, []string{"--env-pass", spacedockBinEnv})
out := runWrapped(t, safehousePath, argv[1:], parent)
want := "ZELLIJ=<unset>\nZELLIJ_PANE_ID=<unset>\nZELLIJ_SESSION_NAME=<unset>"
want := "TMUX=<unset>\nTMUX_PANE=<unset>\nZELLIJ_PANE_ID=51\nZELLIJ_SESSION_NAME=excellent-pheasant"
if out != want {
t.Fatalf("Zellij metadata = %q, want %q", out, want)
t.Fatalf("terminal metadata = %q, want %q", out, want)
}
})

t.Run("native global allowlist composes with the built-in trio", func(t *testing.T) {
t.Run("native global allowlist composes with the built-in allowance", func(t *testing.T) {
setZellijTargetingEnv(t)
probe := extraTargetProbe(t, dir)
env := append(append([]string{}, parent...), "SAFEHOUSE_ENV_PASS=EXTRA_TARGET", "EXTRA_TARGET=operator-choice")
env := []string{
"PATH=/usr/bin:/bin",
"ZELLIJ_PANE_ID=51",
"ZELLIJ_SESSION_NAME=excellent-pheasant",
"SAFEHOUSE_ENV_PASS=EXTRA_TARGET",
"EXTRA_TARGET=operator-choice",
}
argv := safehouse.Wrap([]string{probe}, []string{"--env-pass", spacedockBinEnv})
out := runWrapped(t, safehousePath, argv[1:], env)
if out != "EXTRA_TARGET=operator-choice" {
Expand All @@ -180,15 +196,21 @@ func TestSafehouseEnvPassForwardsZellijTargetingMetadata(t *testing.T) {

func setZellijTargetingEnv(t *testing.T) {
t.Helper()
clearZellijTargetingEnv(t)
t.Setenv("ZELLIJ", "0")
clearTerminalTargetingEnv(t)
t.Setenv("ZELLIJ_PANE_ID", "51")
t.Setenv("ZELLIJ_SESSION_NAME", "excellent-pheasant")
}

func clearZellijTargetingEnv(t *testing.T) {
func setTmuxTargetingEnv(t *testing.T) {
t.Helper()
clearTerminalTargetingEnv(t)
t.Setenv("TMUX", "/tmp/tmux-501/default,12345,0")
t.Setenv("TMUX_PANE", "%3")
}

func clearTerminalTargetingEnv(t *testing.T) {
t.Helper()
for _, key := range []string{"ZELLIJ", "ZELLIJ_PANE_ID", "ZELLIJ_SESSION_NAME"} {
for _, key := range []string{"ZELLIJ_PANE_ID", "ZELLIJ_SESSION_NAME", "TMUX", "TMUX_PANE"} {
value, present := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
Expand All @@ -203,11 +225,12 @@ func clearZellijTargetingEnv(t *testing.T) {
}
}

func zellijTargetingProbe(t *testing.T, dir string) string {
func terminalTargetingProbe(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "zellij-probe")
path := filepath.Join(dir, "terminal-probe")
body := `#!/bin/sh
printf 'ZELLIJ=%s\n' "${ZELLIJ-<unset>}"
printf 'TMUX=%s\n' "${TMUX-<unset>}"
printf 'TMUX_PANE=%s\n' "${TMUX_PANE-<unset>}"
printf 'ZELLIJ_PANE_ID=%s\n' "${ZELLIJ_PANE_ID-<unset>}"
printf 'ZELLIJ_SESSION_NAME=%s\n' "${ZELLIJ_SESSION_NAME-<unset>}"
`
Expand Down
30 changes: 28 additions & 2 deletions internal/safehouse/safehouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,41 @@ func TranslateFlags(deprefixed []string) (extra []string, err error) {
return extra, nil
}

// terminalHostEnvVars are the nine signals subspace's r skill probes across
// its six terminal hosts, in the probe's own resolution order.
// Source of truth: spacedock-subspace plugins/subspace/skills/r/SKILL.md,
// "Select one terminal" — duplicated by decision (see safehouse-terminal-env-passthrough.md).
var terminalHostEnvVars = []string{
"ZELLIJ_SESSION_NAME", "ZELLIJ_PANE_ID",
"TMUX", "TMUX_PANE",
"HERDR_ENV", "HERDR_PANE_ID",
"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID",
"TERM_PROGRAM",
}

// terminalTargetingEnvArgs returns Safehouse's built-in terminal/session
// metadata allowance. Safehouse itself composes repeated --env-pass flags and
// SAFEHOUSE_ENV_PASS, so this wrapper adds its default without parsing caller
// arguments or owning an operator configuration surface.
func terminalTargetingEnvArgs() []string {
if _, present := os.LookupEnv("ZELLIJ"); !present {
return terminalEnvPassArgs(os.LookupEnv)
}

// terminalEnvPassArgs is the presence-filter composer: it names a variable in
// the returned --env-pass allowance only when lookup reports it set in the
// parent, in terminalHostEnvVars' probe order. An empty parent (nothing set)
// yields no allowance at all, never an empty flag.
func terminalEnvPassArgs(lookup func(string) (string, bool)) []string {
var present []string
for _, name := range terminalHostEnvVars {
if _, ok := lookup(name); ok {
present = append(present, name)
}
}
if len(present) == 0 {
return nil
}
return []string{"--env-pass=ZELLIJ,ZELLIJ_PANE_ID,ZELLIJ_SESSION_NAME"}
return []string{"--env-pass=" + strings.Join(present, ",")}
}

// Wrap returns the inner argv wrapped as
Expand Down
88 changes: 83 additions & 5 deletions internal/safehouse/safehouse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,22 +97,100 @@ func TestWrapWithExtra(t *testing.T) {
}
}

// TestWrapAddsTerminalTargetingEnvArgument reshapes the allowance test to the
// tmux pair: bare ZELLIJ is gone from the gate, and the built-in allowance now
// covers every host the consumer probes, not just Zellij.
func TestWrapAddsTerminalTargetingEnvArgument(t *testing.T) {
clearTerminalTargetingEnv(t)
t.Setenv("ZELLIJ", "0")
t.Setenv("ZELLIJ_PANE_ID", "51")
t.Setenv("ZELLIJ_SESSION_NAME", "excellent-pheasant")
t.Setenv("TMUX", "/tmp/tmux-501/default,12345,0")
t.Setenv("TMUX_PANE", "%3")

got := Wrap([]string{"claude"}, []string{"--env-pass", "SPACEDOCK_BIN"})
want := []string{"safehouse", "--trust-workdir-config", "--env-pass=ZELLIJ,ZELLIJ_PANE_ID,ZELLIJ_SESSION_NAME", "--env-pass", "SPACEDOCK_BIN", "--", "claude"}
want := []string{"safehouse", "--trust-workdir-config", "--env-pass=TMUX,TMUX_PANE", "--env-pass", "SPACEDOCK_BIN", "--", "claude"}
if !equalArgv(got, want) {
t.Fatalf("Wrap = %v, want %v", got, want)
}
}

// TestTerminalEnvPassArgsComposer is the AC-2 oracle: the argv composer names
// a variable only when it is present in the (stubbed) parent, in probe order,
// and an empty parent yields no allowance at all — never a fixed list, never
// gated on a different variable than the one being passed.
func TestTerminalEnvPassArgsComposer(t *testing.T) {
cases := []struct {
name string
lookup func(string) (string, bool)
want []string
}{
{
name: "empty parent yields no allowance",
lookup: mapLookup(nil),
want: nil,
},
{
name: "tmux pair only",
lookup: mapLookup(map[string]string{
"TMUX": "/tmp/tmux-501/default,12345,0",
"TMUX_PANE": "%3",
}),
want: []string{"--env-pass=TMUX,TMUX_PANE"},
},
{
name: "all nine set, emitted in probe order",
lookup: mapLookup(map[string]string{
"ZELLIJ_SESSION_NAME": "excellent-pheasant",
"ZELLIJ_PANE_ID": "51",
"TMUX": "/tmp/tmux-501/default,12345,0",
"TMUX_PANE": "%3",
"HERDR_ENV": "1",
"HERDR_PANE_ID": "7",
"CMUX_WORKSPACE_ID": "ws-1",
"CMUX_SURFACE_ID": "sf-1",
"TERM_PROGRAM": "ghostty",
}),
want: []string{"--env-pass=ZELLIJ_SESSION_NAME,ZELLIJ_PANE_ID,TMUX,TMUX_PANE,HERDR_ENV,HERDR_PANE_ID,CMUX_WORKSPACE_ID,CMUX_SURFACE_ID,TERM_PROGRAM"},
},
{
name: "set-but-empty name is still named",
lookup: mapLookup(map[string]string{
"TMUX": "",
}),
want: []string{"--env-pass=TMUX"},
},
{
name: "a name outside the list is never named",
lookup: mapLookup(map[string]string{
"NOT_A_TERMINAL_SIGNAL": "1",
}),
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := terminalEnvPassArgs(tc.lookup)
if !equalArgv(got, tc.want) {
t.Fatalf("terminalEnvPassArgs() = %v, want %v", got, tc.want)
}
})
}
}

// mapLookup adapts a plain map to the (string) (string, bool) lookup shape
// terminalEnvPassArgs takes, so the composer's table test is deterministic
// under any developer terminal — it never touches real process env.
func mapLookup(m map[string]string) func(string) (string, bool) {
return func(name string) (string, bool) {
v, ok := m[name]
return v, ok
}
}

// clearTerminalTargetingEnv unsets all nine signals terminalHostEnvVars
// probes, so tests that exercise Wrap's built-in allowance start from a clean
// baseline regardless of the developer's own terminal host.
func clearTerminalTargetingEnv(t *testing.T) {
t.Helper()
for _, key := range []string{"ZELLIJ", "ZELLIJ_PANE_ID", "ZELLIJ_SESSION_NAME"} {
for _, key := range terminalHostEnvVars {
value, present := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
Expand Down