From b5cc222274a8ce615fad4726e460f3601915a89c Mon Sep 17 00:00:00 2001 From: CL Kao Date: Tue, 18 Aug 2026 08:28:37 -0700 Subject: [PATCH] Replace the Zellij-sentinel env-pass gate with a nine-signal presence filter terminalTargetingEnvArgs gated on bare ZELLIJ and passed a fixed three-name list regardless of what was actually set, so tmux/Herdr/CMUX/ Ghostty/Apple Terminal parents lost all terminal-host identity crossing the safehouse boundary, and unset Zellij names could reach the child. Replace it with terminalEnvPassArgs, a pure presence filter over subspace's own nine-signal probe set (injectable lookup for deterministic unit tests), emitted in probe order; a name appears only when LookupEnv reports it set in the parent. Reshapes the Wrap allowance test and cli env-pass smoke fixtures to the tmux pair (bare ZELLIJ is gone from the vocabulary), and widens the terminal-independent test hygiene (clear-helpers, TestMain baseline) to all nine names so the suite passes identically under tmux, Zellij, or a bare terminal. --- internal/cli/host_launch_test.go | 12 +++- internal/cli/safehouse_env_smoke_test.go | 81 ++++++++++++++-------- internal/safehouse/safehouse.go | 30 +++++++- internal/safehouse/safehouse_test.go | 88 ++++++++++++++++++++++-- 4 files changed, 173 insertions(+), 38 deletions(-) diff --git a/internal/cli/host_launch_test.go b/internal/cli/host_launch_test.go index efe76fc38..c3561e40d 100644 --- a/internal/cli/host_launch_test.go +++ b/internal/cli/host_launch_test.go @@ -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) diff --git a/internal/cli/safehouse_env_smoke_test.go b/internal/cli/safehouse_env_smoke_test.go index 8db8c463e..1502bf938 100644 --- a/internal/cli/safehouse_env_smoke_test.go +++ b/internal/cli/safehouse_env_smoke_test.go @@ -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 } @@ -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 "$@" ` @@ -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=\nZELLIJ_SESSION_NAME=" 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=\nZELLIJ_PANE_ID=\nZELLIJ_SESSION_NAME=" + want := "TMUX=\nTMUX_PANE=\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" { @@ -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) @@ -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-}" +printf 'TMUX=%s\n' "${TMUX-}" +printf 'TMUX_PANE=%s\n' "${TMUX_PANE-}" printf 'ZELLIJ_PANE_ID=%s\n' "${ZELLIJ_PANE_ID-}" printf 'ZELLIJ_SESSION_NAME=%s\n' "${ZELLIJ_SESSION_NAME-}" ` diff --git a/internal/safehouse/safehouse.go b/internal/safehouse/safehouse.go index b336c6471..1d98c70e7 100644 --- a/internal/safehouse/safehouse.go +++ b/internal/safehouse/safehouse.go @@ -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 diff --git a/internal/safehouse/safehouse_test.go b/internal/safehouse/safehouse_test.go index 2792655a4..2f9bb3551 100644 --- a/internal/safehouse/safehouse_test.go +++ b/internal/safehouse/safehouse_test.go @@ -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)