diff --git a/internal/cli/broker_dial_other.go b/internal/cli/broker_dial_other.go index d516138..0b7cb51 100644 --- a/internal/cli/broker_dial_other.go +++ b/internal/cli/broker_dial_other.go @@ -10,3 +10,9 @@ import ( func newBrokerHTTPClient(int) *http.Client { return nil } var errBrokerUnsupported = errors.New("flashduty: broker mode is not supported on this platform") + +// brokerEgressCapable reports whether this build can act as a broker-mode client. +// False on non-unix builds, which lack the FLASHDUTY_CRED_FD dial path. The +// runner reads this (via `fduty version --json`) to decide whether to advertise +// broker mode to safari. See broker_dial_unix.go for the rationale. +const brokerEgressCapable = false diff --git a/internal/cli/broker_dial_unix.go b/internal/cli/broker_dial_unix.go index 7070ed7..4e631d2 100644 --- a/internal/cli/broker_dial_unix.go +++ b/internal/cli/broker_dial_unix.go @@ -19,6 +19,13 @@ import ( // never returns nil), but defaultNewClient references it on every platform. var errBrokerUnsupported = errors.New("flashduty: broker mode is not supported on this platform") +// brokerEgressCapable reports whether this build can act as a broker-mode client +// (read FLASHDUTY_CRED_FD and dial over the inherited control fd). The runner +// probes it via `fduty version --json` and only advertises broker mode to safari +// when true — otherwise safari would deliver the per-person key out-of-band to an +// fduty that can't read it. True on unix, where this file is built. +const brokerEgressCapable = true + // brokerDialer owns the inherited control fd and serializes per-dial handshakes. // Each Dial sends a 1-byte request datagram on the control channel and receives // one dedicated SOCK_STREAM fd back via SCM_RIGHTS. diff --git a/internal/cli/version.go b/internal/cli/version.go index 28bc276..e7c72c0 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -24,7 +24,28 @@ func newVersionCmd() *cobra.Command { Use: "version", Short: "Print version information", Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("flashduty version %s (%s) built %s\n", versionStr, commitStr, dateStr) + out := cmd.OutOrStdout() + // A structured (--json / --output-format) request emits a + // machine-readable object that includes broker_egress, the capability + // the runner probes before advertising broker mode to safari. An older + // fduty ignores these flags and prints the plain line below, so the + // absence of the broker_egress field reads as "not capable". The plain + // human output is unchanged. + if currentOutputFormat().Structured() { + b, err := marshalStructured(map[string]any{ + "version": versionStr, + "commit": commitStr, + "date": dateStr, + "broker_egress": brokerEgressCapable, + }) + if err != nil { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), err) + return + } + _, _ = fmt.Fprintln(out, string(b)) + return + } + _, _ = fmt.Fprintf(out, "flashduty version %s (%s) built %s\n", versionStr, commitStr, dateStr) }, } } diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go new file mode 100644 index 0000000..7163b6b --- /dev/null +++ b/internal/cli/version_test.go @@ -0,0 +1,48 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// TestVersionPlainOutput keeps the human-readable line stable (the runner's +// install scripts and humans rely on it). +func TestVersionPlainOutput(t *testing.T) { + cmd := newVersionCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.Run(cmd, nil) + if got := buf.String(); !strings.HasPrefix(got, "flashduty version ") { + t.Fatalf("plain version output changed: %q", got) + } +} + +// TestVersionJSONAdvertisesBrokerEgress locks the capability contract the runner +// depends on: `fduty version --json` must emit a broker_egress field whose value +// matches this build's compile-time capability (true on unix, false elsewhere). +// If the field went missing, broker-capable runners would stop advertising +// broker mode and silently fall back to the legacy env-key path. +func TestVersionJSONAdvertisesBrokerEgress(t *testing.T) { + origJSON := flagJSON + flagJSON = true + defer func() { flagJSON = origJSON }() + + cmd := newVersionCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.Run(cmd, nil) + + var got map[string]any + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("version --json must be valid JSON, got %q: %v", buf.String(), err) + } + v, ok := got["broker_egress"] + if !ok { + t.Fatalf("version --json must include the broker_egress field, got: %q", buf.String()) + } + if v != brokerEgressCapable { + t.Fatalf("broker_egress = %v, want %v (compile-time capability)", v, brokerEgressCapable) + } +}