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
20 changes: 20 additions & 0 deletions internal/cli/inherited_fds_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//go:build darwin

package cli

// sealInheritedDescriptors keeps every descriptor this process inherited out
// of the processes it starts, by marking each one close-on-exec. It is the
// Linux one's (inherited_fds_linux.go) for macOS, where there is no
// close_range: the process's own list of descriptors is /dev/fd, and each is
// marked as the Linux fallback marks /proc/self/fd's.
//
// /dev/fd is a filesystem macOS always mounts. Where it cannot be read this
// fails closed: no other source says which descriptors are open, and the
// descriptor limit is no bound, since a parent can lower it after opening one
// above it (Copilot on #812).
func sealInheritedDescriptors() error {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return sealListedDescriptors(devFD)
}

// devFD is where macOS lists the descriptors a process holds.
const devFD = "/dev/fd"
76 changes: 76 additions & 0 deletions internal/cli/inherited_fds_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//go:build darwin

package cli

import (
"os"
"os/exec"
"strconv"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)

// The descriptor number is high on purpose: a child picks the lowest free one
// for its own files.
const inheritedFDDarwin = 200

// inheritDarwin puts a readable pipe at fd, the way an exec'd child receives
// one: open, with no close-on-exec flag of its own.
func inheritDarwin(t *testing.T, fd int) {
t.Helper()
reader, writer, err := os.Pipe()
require.NoError(t, err)
_, err = writer.WriteString("a-task-token\n")
require.NoError(t, err)
require.NoError(t, writer.Close())
t.Cleanup(func() { _ = reader.Close() })

require.NoError(t, unix.Dup2(int(reader.Fd()), fd))
t.Cleanup(func() { _ = unix.Close(fd) })
flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
require.NoError(t, err)
require.Zero(t, flags&unix.FD_CLOEXEC, "an inherited descriptor arrives without it")
}

func cloexec(t *testing.T, fd int) bool {
t.Helper()
flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
require.NoError(t, err)
return flags&unix.FD_CLOEXEC != 0
}

// On macOS an inherited descriptor does not reach the processes this one
// starts, and stays readable here.
func TestSealedDescriptorsDoNotReachChildrenOnMacOS(t *testing.T) {
inheritDarwin(t, inheritedFDDarwin)

require.NoError(t, sealInheritedDescriptors())

assert.True(t, cloexec(t, inheritedFDDarwin))
fd := strconv.Itoa(inheritedFDDarwin)
out, err := exec.CommandContext(t.Context(), "/bin/sh", "-c",
"if (: <&"+fd+") 2>/dev/null; then echo inherited; else echo sealed; fi").Output() //nolint:gosec // G204: the descriptor number is the test's own constant
require.NoError(t, err)
assert.Equal(t, "sealed\n", string(out), "the child inherited the descriptor")

buffer := make([]byte, len("a-task-token\n"))
read, err := unix.Read(inheritedFDDarwin, buffer)
require.NoError(t, err)
assert.Equal(t, "a-task-token\n", string(buffer[:read]), "marked, not closed or consumed")
}

// /dev/fd is how macOS lists a process's descriptors, and the walk seals what
// it names.
func TestTheDevFDWalkSealsWhatItNames(t *testing.T) {
inheritDarwin(t, inheritedFDDarwin)
require.NoError(t, sealListedDescriptors(devFD))
assert.True(t, cloexec(t, inheritedFDDarwin))
}

// A listing that cannot be read is an error, never a quiet success.
func TestTheDevFDWalkRefusesAListingItCannotRead(t *testing.T) {
require.Error(t, sealListedDescriptors(t.TempDir()+"/missing"))
}
59 changes: 0 additions & 59 deletions internal/cli/inherited_fds_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@
package cli

import (
"errors"
"fmt"
"math"
"os"

"golang.org/x/sys/unix"

Expand Down Expand Up @@ -46,59 +43,3 @@ func sealInheritedDescriptors() error {
// parameter of sealListedDescriptors only so a test can watch the walk refuse
// a listing it cannot trust.
const procSelfFD = "/proc/self/fd"

func sealListedDescriptors(listingDir string) error {
dir, err := os.Open(listingDir)
if err != nil {
return fmt.Errorf("could not open %s to seal the descriptors this process inherited: %w", listingDir, err)
}
defer func() { _ = dir.Close() }()
names, err := dir.Readdirnames(-1)
if err != nil {
return fmt.Errorf("could not read %s to seal the descriptors this process inherited: %w", listingDir, err)
}
listing, err := sysfd.Of(dir.Fd())
if err != nil {
return fmt.Errorf("could not seal the descriptors this process inherited: %w", err)
}
for _, name := range names {
fd, err := sysfd.Parse(name)
if err != nil {
return fmt.Errorf("could not seal the descriptors this process inherited: %s holds %q, which is not a descriptor", listingDir, name)
}
if fd < sysfd.FirstNonStandard || fd == listing {
continue
}
if err := sealDescriptor(fd); err != nil {
return err
}
}
return nil
}

// sealDescriptor marks one descriptor close-on-exec.
//
// A descriptor that is no longer open is the one failure that is not a
// failure: the listing is a snapshot, and a number that has been closed since
// it was taken is already out of reach of every child. Anything else means
// the descriptor is open and still inheritable, which is the thing this
// refuses to let happen quietly.
func sealDescriptor(fd sysfd.Descriptor) error {
flags, err := unix.FcntlInt(fd.Uintptr(), unix.F_GETFD, 0)
if err != nil {
if errors.Is(err, unix.EBADF) {
return nil
}
return fmt.Errorf("could not read the flags of inherited descriptor %s: %w", fd, err)
}
if flags&unix.FD_CLOEXEC != 0 {
return nil
}
if _, err := unix.FcntlInt(fd.Uintptr(), unix.F_SETFD, flags|unix.FD_CLOEXEC); err != nil {
if errors.Is(err, unix.EBADF) {
return nil
}
return fmt.Errorf("could not keep inherited descriptor %s from the processes this one starts: %w", fd, err)
}
return nil
}
6 changes: 3 additions & 3 deletions internal/cli/inherited_fds_other.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
//go:build !linux
//go:build !linux && !darwin

package cli

// sealInheritedDescriptors has nothing to seal where the handover it protects
// cannot happen: a task token arrives on an inherited descriptor only on
// Linux, which is what internal/commands/mcp_token_linux.go accepts and
// internal/commands/mcp_token_other.go refuses. Elsewhere this keeps the
// Linux and macOS, which is what internal/commands/mcp_token_sealed.go accepts
// and internal/commands/mcp_token_other.go refuses. Elsewhere this keeps the
// program building and starting the same way.
func sealInheritedDescriptors() error { return nil }
72 changes: 72 additions & 0 deletions internal/cli/inherited_fds_seal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//go:build linux || darwin

package cli

import (
"errors"
"fmt"
"os"

"golang.org/x/sys/unix"

"github.com/basecamp/basecamp-cli/internal/sysfd"
)

// sealListedDescriptors marks close-on-exec every descriptor listingDir names:
// /proc/self/fd on Linux, /dev/fd on macOS, each the process's own list of
// what it holds.
func sealListedDescriptors(listingDir string) error {
dir, err := os.Open(listingDir)
if err != nil {
return fmt.Errorf("could not open %s to seal the descriptors this process inherited: %w", listingDir, err)
}
defer func() { _ = dir.Close() }()
names, err := dir.Readdirnames(-1)
if err != nil {
return fmt.Errorf("could not read %s to seal the descriptors this process inherited: %w", listingDir, err)
}
listing, err := sysfd.Of(dir.Fd())
if err != nil {
return fmt.Errorf("could not seal the descriptors this process inherited: %w", err)
}
for _, name := range names {
fd, err := sysfd.Parse(name)
if err != nil {
return fmt.Errorf("could not seal the descriptors this process inherited: %s holds %q, which is not a descriptor", listingDir, name)
}
if fd < sysfd.FirstNonStandard || fd == listing {
continue
}
if err := sealDescriptor(fd); err != nil {
return err
}
}
return nil
}

// sealDescriptor marks one descriptor close-on-exec.
//
// A descriptor that is no longer open is the one failure that is not a
// failure: the listing is a snapshot, and a number that has been closed since
// it was taken is already out of reach of every child. Anything else means
// the descriptor is open and still inheritable, which is the thing this
// refuses to let happen quietly.
func sealDescriptor(fd sysfd.Descriptor) error {
flags, err := unix.FcntlInt(fd.Uintptr(), unix.F_GETFD, 0)
if err != nil {
if errors.Is(err, unix.EBADF) {
return nil
}
return fmt.Errorf("could not read the flags of inherited descriptor %s: %w", fd, err)
}
if flags&unix.FD_CLOEXEC != 0 {
return nil
}
if _, err := unix.FcntlInt(fd.Uintptr(), unix.F_SETFD, flags|unix.FD_CLOEXEC); err != nil {
if errors.Is(err, unix.EBADF) {
return nil
}
return fmt.Errorf("could not keep inherited descriptor %s from the processes this one starts: %w", fd, err)
}
return nil
}
2 changes: 1 addition & 1 deletion internal/cli/token_handover_platforms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
// asks about the code rather than about a build tag it repeats.
const (
sealingDir = "."
sealingFile = "inherited_fds_linux.go"
sealingFile = "inherited_fds_seal.go"
handoverDir = "../commands"
refusalFile = "mcp_token_other.go"
)
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ go to stderr. SIGINT and SIGTERM cancel live workers with stop reason
shutdown, settle them, and exit 130 and 143. --shadow admits and logs in an
isolated state directory and dispatches nothing. --hold sets a durable hold:
intake and admission run, nothing dispatches or posts, and earlier records
wait for review, until basecamp connect release. Linux only.
wait for review, until basecamp connect release. Linux and macOS only.

basecamp connect status what it heard, holds and ran
basecamp connect doctor what it needs to run
Expand Down
10 changes: 4 additions & 6 deletions internal/commands/connect_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,14 +365,12 @@ func preflightReason(err error) string {
// connectUnsupportedOSCheck is the Platform check on a GOOS the connector
// does not run on. It gives the constraint that actually applies
// (connectSupportedOS, and #736): the task token reaches a worker's MCP
// server over an inherited descriptor, and Linux alone seals the descriptors
// a process passes on. Reading a process's start time is the half macOS has,
// so naming that here told a Mac reader the connector could run there and
// then refused it anyway.
// server over an inherited descriptor, and only Linux and macOS seal the
// descriptors a process passes on.
func connectUnsupportedOSCheck(goos string) setup.Check {
return setup.Check{Name: "Platform", Status: setup.StatusFail,
Message: fmt.Sprintf("The connector does not run on %s: %s", goos, connectLinuxOnlyReason),
Hint: "Run the connector on Linux; the rest of the CLI runs here."}
Message: fmt.Sprintf("The connector does not run on %s: %s", goos, connectSupportedOSReason),
Comment thread
robzolkos marked this conversation as resolved.
Comment thread
robzolkos marked this conversation as resolved.
Hint: "Run the connector on Linux or macOS; the rest of the CLI runs here."}
}

// driverChecks refuses what the run command refuses: doctor never calls a
Expand Down
14 changes: 6 additions & 8 deletions internal/commands/connect_operator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,18 +267,16 @@ func TestConnectDoctorRefusesOnlyWhatTheRunCommandRefuses(t *testing.T) {
// The check and the run command's refusal say the one reason, so a person
// cannot be told two different things about the same platform.
func TestConnectDoctorPlatformCheckNamesTheConstraintThatApplies(t *testing.T) {
c := connectUnsupportedOSCheck("darwin")
c := connectUnsupportedOSCheck("windows")
assert.Equal(t, "Platform", c.Name)
assert.Equal(t, setup.StatusFail, c.Status)
assert.Contains(t, c.Message, "darwin", "it names the platform it refuses")
assert.Contains(t, c.Message, connectLinuxOnlyReason, "it gives the reason the run command gives")
assert.NotContains(t, c.Message, "macOS",
"a refusal must not name a capability the platform it refuses actually has")
assert.Contains(t, c.Message, "windows", "it names the platform it refuses")
assert.Contains(t, c.Message, connectSupportedOSReason, "it gives the reason the run command gives")

err := connectUnsupportedOSError("darwin")
err := connectUnsupportedOSError("windows")
require.Error(t, err)
assert.Contains(t, err.Error(), connectLinuxOnlyReason, "doctor and the run command give the one reason")
assert.NotContains(t, err.Error(), "macOS")
assert.Contains(t, err.Error(), connectSupportedOSReason, "doctor and the run command give the one reason")
assert.Contains(t, err.Error(), "Linux and macOS only, not windows")
}

// The acp driver runs a pinned adapter out of the connector's own npm
Expand Down
32 changes: 15 additions & 17 deletions internal/commands/connect_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,28 +614,26 @@ func connectConsumerNamespace(agentID int64, shadow bool) string {
return name
}

// connectSupportedOS is where the connector runs: Linux, and for now only
// Linux.
// connectSupportedOS is where the connector runs: Linux and macOS.
//
// Two things have to hold, and macOS has only one of them. The driver must
// be able to read process start times, so a recorded worker group is never
// signaled after its pid was reused — macOS can. And the task token has to
// reach the worker's MCP server, which it does on an inherited descriptor:
// `connect worker-mcp` execs `basecamp mcp --connect-token-fd`, and that
// hand-over is accepted only where the descriptors this process inherited
// are sealed against everything it starts, which is Linux alone
// (mcp_token_linux.go, and #736, which gated it deliberately). On macOS
// every non-shadow dispatch would start a worker whose Basecamp tools fail
// at the handshake, so the connector says so here rather than at the far
// end of each task.
// Two things have to hold. The driver must be able to read process start
// times, so a recorded worker group is never signaled after its pid was
// reused. And the task token has to reach the worker's MCP server, which it
// does on an inherited descriptor: `connect worker-mcp` execs `basecamp mcp
// --connect-token-fd`, and that hand-over is accepted only where the
// descriptors this process inherited are sealed against everything it
// starts (mcp_token_sealed.go, internal/cli's inherited_fds_seal.go). Linux
// and macOS do both; anywhere else every non-shadow dispatch would start a
// worker whose Basecamp tools fail at the handshake, so the connector says so
// here rather than at the far end of each task.
func connectSupportedOS(goos string) bool {
return goos == "linux"
return goos == "linux" || goos == "darwin"
Comment thread
robzolkos marked this conversation as resolved.
Comment thread
robzolkos marked this conversation as resolved.
Comment thread
robzolkos marked this conversation as resolved.
}

// connectLinuxOnlyReason is why, in one place: the run command's refusal and
// connectSupportedOSReason is why, in one place: the run command's refusal and
// doctor's Platform check say the same thing, so a person who meets one and
// then the other is not told two different stories about their machine.
const connectLinuxOnlyReason = "the task token reaches a worker's MCP server over an inherited descriptor, and Linux is the only platform that seals the descriptors a process inherits"
const connectSupportedOSReason = "the task token reaches a worker's MCP server over an inherited descriptor, and the CLI keeps inherited descriptors from the programs it starts only on Linux and macOS"

// connectUnsupportedOSError is the refusal on a platform the connector does
// not run on, given by the run command and by `service install`, which would
Expand All @@ -645,7 +643,7 @@ const connectLinuxOnlyReason = "the task token reaches a worker's MCP server ove
// refusal is prose, not a command to run, and a hint that begins like a
// command is read as one — by a person, and by TestHintCommandsResolve.
func connectUnsupportedOSError(goos string) error {
return output.ErrUsage(fmt.Sprintf("The connector runs on Linux only, not %s: %s", goos, connectLinuxOnlyReason))
return output.ErrUsage(fmt.Sprintf("The connector runs on Linux and macOS only, not %s: %s", goos, connectSupportedOSReason))
}

// connectServed is connect.json's served projects as they are now, not as
Expand Down
7 changes: 4 additions & 3 deletions internal/commands/connect_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ func TestConnectStateLivesUnderXDGStateHome(t *testing.T) {
// Copilot on #738: macOS passed this check and then failed every non-shadow
// dispatch at the worker's MCP handshake, because the token hand-over onto
// an inherited descriptor is accepted only where those descriptors are
// sealed — Linux (#736).
func TestConnectRunsOnLinuxOnly(t *testing.T) {
// sealed — Linux (#736), and macOS, which seals them through /dev/fd.
func TestConnectRunsOnLinuxAndMacOS(t *testing.T) {
assert.True(t, connectSupportedOS("linux"))
for _, goos := range []string{"darwin", "freebsd", "openbsd", "windows"} {
assert.True(t, connectSupportedOS("darwin"))
for _, goos := range []string{"freebsd", "openbsd", "windows"} {
assert.False(t, connectSupportedOS(goos), goos)
}
}
Expand Down
Loading
Loading