diff --git a/environment/broker_capability.go b/environment/broker_capability.go new file mode 100644 index 0000000..8ac16da --- /dev/null +++ b/environment/broker_capability.go @@ -0,0 +1,73 @@ +package environment + +import ( + "context" + "encoding/json" + "log/slog" + "os/exec" + "path/filepath" + "sync" + "time" +) + +var ( + fdutyBrokerOnce sync.Once + fdutyBrokerCapable bool +) + +// BrokerEgressEnabled reports whether this runner should advertise egress-broker +// support to safari. Two conditions must both hold: +// +// 1. this build can run the broker (BrokerSupported — Linux only), and +// 2. the bundled fduty can act as a broker-mode client. +// +// Condition 2 is load-bearing: once safari sees broker=1 it delivers the +// per-person app_key out-of-band over the inherited control fd and NEVER in the +// bash env. An older bundled fduty that doesn't understand FLASHDUTY_CRED_FD +// would then have no usable key and every authenticated call would fail. The +// runner does not refresh fduty on self-update, so a fleet runner can carry a +// stale fduty across a runner upgrade. Gating the advertisement here makes the +// rollout self-healing: a stale fduty keeps the legacy env-key path, and once it +// is updated the next reconnect advertises broker mode. It also works in +// zero-egress BYOC, where the runner cannot fetch a newer fduty. +// +// The fduty probe runs at most once and is cached; its cost is a single +// `fduty version --json`. +func BrokerEgressEnabled() bool { + if !BrokerSupported { + return false + } + fdutyBrokerOnce.Do(func() { + fdutyBrokerCapable = probeFdutyBrokerEgress() + }) + return fdutyBrokerCapable +} + +// probeFdutyBrokerEgress runs the bundled `fduty version --json` and reports +// whether it advertises broker_egress=true. Any failure — fduty missing, an +// older fduty that ignores --json and prints a plain line, or an exec error — is +// treated as "not capable" so the caller falls back to the legacy env-key path +// instead of breaking auth. +func probeFdutyBrokerEgress() bool { + dir := BundledToolsDir() + if dir == "" { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + //nolint:gosec // G204: the path is the runner's own bundled-tools dir, not user input. + out, err := exec.CommandContext(ctx, filepath.Join(dir, "fduty"), "version", "--json").Output() + if err != nil { + slog.Debug("fduty broker-egress probe failed; treating bundled fduty as legacy (env-key path)", "error", err) + return false + } + var v struct { + BrokerEgress bool `json:"broker_egress"` + } + if err := json.Unmarshal(out, &v); err != nil { + slog.Debug("fduty broker-egress probe: non-JSON output; treating bundled fduty as legacy (env-key path)") + return false + } + return v.BrokerEgress +} diff --git a/environment/broker_capability_test.go b/environment/broker_capability_test.go new file mode 100644 index 0000000..6ca0206 --- /dev/null +++ b/environment/broker_capability_test.go @@ -0,0 +1,53 @@ +//go:build unix + +package environment + +import ( + "os" + "path/filepath" + "testing" +) + +// writeFakeFduty drops a stub `fduty` into dir whose `version --json` prints body. +func writeFakeFduty(t *testing.T, dir, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "fduty"), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatalf("write fake fduty: %v", err) + } +} + +// TestProbeFdutyBrokerEgress covers the capability probe the runner uses to +// decide whether to advertise broker mode. A capable fduty emits +// broker_egress=true; everything else (missing field, false, legacy plain text, +// nonzero exit) must read as "not capable" so the runner stays on the env-key path. +func TestProbeFdutyBrokerEgress(t *testing.T) { + cases := []struct { + name string + body string // what the fake `fduty version --json` prints + want bool + }{ + {"broker-capable json", `echo '{"broker_egress":true,"version":"x"}'`, true}, + {"json missing field", `echo '{"version":"x"}'`, false}, + {"broker_egress false", `echo '{"broker_egress":false}'`, false}, + {"legacy plain text", `echo 'flashduty version 1.2.3 (abc) built today'`, false}, + {"nonzero exit", `exit 1`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFakeFduty(t, dir, tc.body) + t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", dir) + if got := probeFdutyBrokerEgress(); got != tc.want { + t.Fatalf("probeFdutyBrokerEgress() = %v, want %v", got, tc.want) + } + }) + } +} + +// TestProbeFdutyBrokerEgress_Missing: no fduty in the bundled-tools dir → not capable. +func TestProbeFdutyBrokerEgress_Missing(t *testing.T) { + t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", t.TempDir()) + if probeFdutyBrokerEgress() { + t.Fatal("probe must be false when fduty is absent") + } +} diff --git a/ws/client.go b/ws/client.go index e8ca974..4a21df2 100644 --- a/ws/client.go +++ b/ws/client.go @@ -113,8 +113,10 @@ func (c *Client) Connect(ctx context.Context) error { q := u.Query() q.Set("token", c.token) // Advertise egress-broker capability so Safari sends the per-person app_key - // out-of-band (never in the bash env). Only Linux builds carry the broker. - if environment.BrokerSupported { + // out-of-band (never in the bash env). Requires both a Linux build AND a + // bundled fduty that can act as a broker-mode client (probed) — otherwise + // Safari would deliver the key out-of-band to an fduty that can't read it. + if environment.BrokerEgressEnabled() { q.Set("broker", "1") } u.RawQuery = q.Encode()