Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
639b9ae
Serve a connector worker its dispatch over MCP: basecamp_connect
jorgemanrubia Sep 17, 2026
3cee0bf
One live task per event; the three actions agree on what is dispatchable
jorgemanrubia Sep 17, 2026
8883096
Make a task only of instructions a worker can pull
jorgemanrubia Sep 17, 2026
2e6e9e7
Open an existing ledger without any path that can create it
jorgemanrubia Sep 17, 2026
4f5f6d7
Supersede returns unexposed work; finished work is never handed out anew
jorgemanrubia Sep 17, 2026
b1a06c4
One task per conversation, one mention parser, one state-root check
jorgemanrubia Sep 17, 2026
a29c397
A removed mention leaves a space, so nothing joins across it
jorgemanrubia Sep 17, 2026
5d8864c
Answer from the row, refuse with fields, name the domain
jorgemanrubia Sep 17, 2026
ff6fc5d
Take links as a list of strings from any caller
jorgemanrubia Sep 17, 2026
3f6c7bf
Build the instruction with the ledger free
jorgemanrubia Sep 17, 2026
67aac1d
Write the dispatch lifecycle down, enforce it in one place, try every…
jorgemanrubia Sep 17, 2026
d91e361
Withdraw a failed spawn's exposure; bound the token read; refuse an e…
jorgemanrubia Sep 17, 2026
32fe533
Withdraw only what the ledger can see no worker had
jorgemanrubia Sep 17, 2026
b146b57
A record is dispatched exactly while a live task carries it, in the d…
jorgemanrubia Sep 17, 2026
95fe772
Make the lifecycle table say what the code does; use the checked dire…
jorgemanrubia Sep 17, 2026
9dbed2a
One privacy check per ledger file; the task's own columns are the dat…
jorgemanrubia Sep 17, 2026
7e21802
Know the ledger file by what it is, close it once, refuse before writing
jorgemanrubia Sep 17, 2026
c11e554
Claim the ledger file by identity, release it after the database closes
jorgemanrubia Sep 17, 2026
fca7015
A pull is what hands work to a worker; a live task keeps its conversa…
jorgemanrubia Sep 17, 2026
9a1042f
Read the token descriptor the way the flag parser will, or not at all
jorgemanrubia Sep 17, 2026
6796808
Tie retirement and pulls to the task, and read only this command's de…
jorgemanrubia Sep 17, 2026
a089855
Let cobra say which invocation this is, and the database tie the rest…
jorgemanrubia Sep 17, 2026
efc7c4a
Hold a later open to the owner the check passed, and read for one inv…
jorgemanrubia Sep 17, 2026
670c82e
Read the arguments without touching the command that runs them
jorgemanrubia Sep 17, 2026
a74d10b
Keep the token descriptor from children instead of racing to read it
jorgemanrubia Sep 17, 2026
0e493b0
Vet the whole path on a later open, without opening the ledger
jorgemanrubia Sep 17, 2026
4496992
Say why the descriptor number converts safely
jorgemanrubia Sep 17, 2026
1bac99f
Seal inherited descriptors at startup instead of scanning argv
jorgemanrubia Sep 17, 2026
a21cd40
Cover the second open's file-mode check, and a mention tag that close…
jorgemanrubia Sep 18, 2026
b7cff61
Write the conversation a task holds on the task's own rows
jorgemanrubia Sep 18, 2026
07ee317
Refuse to start when an inherited descriptor cannot be sealed
jorgemanrubia Sep 18, 2026
01806f0
Read a task token only where the descriptor it arrives on is sealed
jorgemanrubia Sep 18, 2026
c7a71af
Draw the line above standard I/O once
jorgemanrubia Sep 18, 2026
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
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -11042,6 +11042,8 @@ FLAG basecamp logout --verbose type=count
FLAG basecamp mcp --account type=string
FLAG basecamp mcp --agent type=bool
FLAG basecamp mcp --cache-dir type=string
FLAG basecamp mcp --connect-state type=string
FLAG basecamp mcp --connect-token-fd type=string
FLAG basecamp mcp --count type=bool
FLAG basecamp mcp --domains type=stringSlice
FLAG basecamp mcp --help type=bool
Expand Down
104 changes: 104 additions & 0 deletions internal/cli/inherited_fds_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//go:build linux

package cli

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

"golang.org/x/sys/unix"

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

// sealInheritedDescriptors keeps every descriptor this process inherited out
// of the processes it starts, by marking each one close-on-exec.
//
// It decides nothing. No flag, no argument and no environment variable says
// which descriptor is which, so there is nothing here that can disagree with
// the command cobra goes on to run: a connector-started worker is handed its
// task token on an inherited pipe, and by the time the command reads it, the
// descriptor has been out of reach of any child since the first line of the
// program — including the children the root command's persistent hooks may
// start while loading configuration or checking for an update.
//
// Marking a descriptor close-on-exec neither closes it nor disturbs what is
// buffered in it, so nothing is consumed for an invocation that never serves.
// Standard input, output and error are left alone: a child is meant to share
// those.
//
// It fails closed. This is the credential handover boundary, and a process
// that cannot say every inherited descriptor is sealed cannot say the token
// descriptor is; the error it returns stops startup rather than letting the
// program run on with a promise it did not keep.
func sealInheritedDescriptors() error {
if err := unix.CloseRange(uint(sysfd.FirstNonStandard), math.MaxUint32, unix.CLOSE_RANGE_CLOEXEC); err == nil {
return nil
}
// Kernels before 5.11 do not know CLOSE_RANGE_CLOEXEC. Ask the process
// which descriptors it actually has and mark those.
return sealListedDescriptors(procSelfFD)
}

// procSelfFD is where Linux lists the descriptors a process holds. It is a
// 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
}
178 changes: 178 additions & 0 deletions internal/cli/inherited_fds_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//go:build linux

package cli

import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"

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

// standardDescriptors are stdin, stdout and stderr by number, written out
// here so these tests do not agree with the code they check about which
// descriptors those are.
var standardDescriptors = []int{0, 1, 2}

// The descriptor number is high on purpose: a child picks the lowest free one
// for its own files, so a low number could be in a listing for a reason that
// has nothing to do with inheritance.
const inheritedFD = 200

// An inherited descriptor does not reach the processes this one starts.
func TestSealedDescriptorsDoNotReachChildren(t *testing.T) {
shell, err := exec.LookPath("sh")
if err != nil {
t.Skip("no shell to start a child with")
}
inherit(t, inheritedFD)

require.NoError(t, sealInheritedDescriptors())

out, err := exec.CommandContext(t.Context(), shell, "-c", "ls /proc/self/fd").Output() //nolint:gosec // G204: a listing of the child's own descriptors
require.NoError(t, err)
assert.NotContains(t, strings.Fields(string(out)), "200", "the child inherited the descriptor")
assert.True(t, fdIsOpen(inheritedFD), "and this process still has it")
}

// It is marked, not closed, and nothing buffered in it is consumed: the
// command that the descriptor is for still reads what was written to it.
func TestSealingLeavesTheDescriptorReadable(t *testing.T) {
inherit(t, inheritedFD)

require.NoError(t, sealInheritedDescriptors())

flags, err := unix.FcntlInt(uintptr(inheritedFD), unix.F_GETFD, 0)
require.NoError(t, err)
assert.NotZero(t, flags&unix.FD_CLOEXEC)
buffer := make([]byte, len("a-task-token\n"))
read, err := unix.Read(inheritedFD, buffer)
require.NoError(t, err)
assert.Equal(t, "a-task-token\n", string(buffer[:read]))
}

// Kernels before 5.11 have no CLOSE_RANGE_CLOEXEC, and the walk that stands in
// for it there is the same promise.
func TestSealingWithoutCloseRange(t *testing.T) {
inherit(t, inheritedFD)

require.NoError(t, sealListedDescriptors(procSelfFD))

flags, err := unix.FcntlInt(uintptr(inheritedFD), unix.F_GETFD, 0)
require.NoError(t, err)
assert.NotZero(t, flags&unix.FD_CLOEXEC)
}

// Standard input, output and error are a child's to share.
func TestSealingLeavesTheStandardDescriptors(t *testing.T) {
shareStandardDescriptors(t)
before := standardFlags(t)

require.NoError(t, sealInheritedDescriptors())

assert.Equal(t, before, standardFlags(t))
}

// inherit 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 inherit(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.Dup3(int(reader.Fd()), fd, 0))
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 fdIsOpen(fd int) bool {
_, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
return err == nil
}

// shareStandardDescriptors puts stdin, stdout and stderr in the state a child
// inherits them in, so the test is about what sealing leaves alone rather than
// about how the test binary happened to be started.
func shareStandardDescriptors(t *testing.T) {
t.Helper()
for _, fd := range standardDescriptors {
flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
require.NoError(t, err)
_, err = unix.FcntlInt(uintptr(fd), unix.F_SETFD, flags&^unix.FD_CLOEXEC)
require.NoError(t, err)
t.Cleanup(func() { _, _ = unix.FcntlInt(uintptr(fd), unix.F_SETFD, flags) })
}
}

func standardFlags(t *testing.T) []int {
t.Helper()
flags := make([]int, 0, len(standardDescriptors))
for _, fd := range standardDescriptors {
got, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
require.NoError(t, err)
flags = append(flags, got)
}
return flags
}

// The walk that stands in for CLOSE_RANGE_CLOEXEC says so when it cannot read
// the listing it works from. Returning quietly would leave the caller
// believing every inherited descriptor was sealed when none of them were.
func TestSealingRefusesWhenItCannotListTheDescriptors(t *testing.T) {
err := sealListedDescriptors(filepath.Join(t.TempDir(), "no-such-listing"))

require.Error(t, err)
assert.Contains(t, err.Error(), "seal the descriptors this process inherited")
}

// A listing this process cannot make sense of is not a listing it may skip:
// an entry that is not a descriptor number means the walk cannot say which
// descriptors it covered.
func TestSealingRefusesAListingItCannotRead(t *testing.T) {
listing := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(listing, "not-a-descriptor"), nil, 0o600))

err := sealListedDescriptors(listing)

require.Error(t, err)
assert.Contains(t, err.Error(), `"not-a-descriptor"`)
}

// A descriptor named by the listing but no longer open is the one failure
// that is not one: it is already out of reach of every child. The listing is
// a snapshot, so this happens whenever anything closes a descriptor while the
// walk runs.
func TestSealingAcceptsADescriptorThatHasSinceBeenClosed(t *testing.T) {
listing := t.TempDir()
closedFD := inheritedFD + 1
require.False(t, fdIsOpen(closedFD), "the test needs a descriptor number nothing holds")
require.NoError(t, os.WriteFile(filepath.Join(listing, strconv.Itoa(closedFD)), nil, 0o600))

assert.NoError(t, sealListedDescriptors(listing))
}

// The walk seals what the listing names, so a listing standing in for
// /proc/self/fd reaches the same descriptor the real one would.
func TestSealingSealsWhatTheListingNames(t *testing.T) {
inherit(t, inheritedFD)
listing := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(listing, strconv.Itoa(inheritedFD)), nil, 0o600))

require.NoError(t, sealListedDescriptors(listing))

flags, err := unix.FcntlInt(uintptr(inheritedFD), unix.F_GETFD, 0)
require.NoError(t, err)
assert.NotZero(t, flags&unix.FD_CLOEXEC)
}
10 changes: 10 additions & 0 deletions internal/cli/inherited_fds_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//go:build !linux

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
// program building and starting the same way.
func sealInheritedDescriptors() error { return nil }
23 changes: 23 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,19 @@ func postRunNoticesEnabled(app *appctx.App) bool {

// Execute runs the root command.
func Execute() {
// Before anything else: a descriptor this process inherited belongs to
// this process, not to the children it starts. The root command's
// persistent hooks load configuration, tighten directories and may check
// for an update before any command's own RunE runs, and a connector-
// started worker arrives holding its task token on an inherited pipe.
//
// A process that cannot seal them cannot make that promise, so it does
// not start: continuing would run every hook with the token descriptor
// still there for a child to inherit, and say nothing about it.
if err := sealInheritedDescriptors(); err != nil {
os.Exit(reportStartupFailure(os.Stderr, err))
}

cmd := NewRootCmd()

// Add subcommands
Expand Down Expand Up @@ -541,6 +554,16 @@ func reportWireError(w io.Writer, err error) int {
return output.ExitCodeFor(apiErr.Code)
}

// reportStartupFailure says on stderr why the program will not run at all.
// It comes before cobra has parsed anything, so there is no command to ask
// for an output format and no envelope to fill; stderr also keeps it clear of
// a command whose stdout speaks a wire protocol.
func reportStartupFailure(w io.Writer, err error) int {
apiErr := output.AsError(err)
fmt.Fprintln(w, "Error: "+richtext.SanitizeSingleLine(apiErr.Message))
return output.ExitCodeFor(apiErr.Code)
}

// jqUsable reports whether a filter parses and compiles. Only these failures
// are knowable before any output is produced, which is what makes clearing the
// filter safe: a filter that fails at runtime may already have written.
Expand Down
15 changes: 15 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"bytes"
"errors"
"os"
"runtime"
"testing"
Expand Down Expand Up @@ -409,6 +410,20 @@ func TestReportWireError(t *testing.T) {
assert.Equal(t, "Error: badgateway injected\n", buf.String())
}

// TestReportStartupFailure pins what a refused startup looks like: the
// program has not parsed a command yet, so there is no output format to
// honor and no envelope to fill — one sanitized line on stderr and a
// non-zero exit. sealInheritedDescriptors is the only caller: a process that
// cannot keep an inherited credential from its children does not run.
func TestReportStartupFailure(t *testing.T) {
var buf bytes.Buffer

code := reportStartupFailure(&buf, errors.New("could not open /proc/self/fd to seal the descriptors this process inherited"))

assert.Equal(t, "Error: could not open /proc/self/fd to seal the descriptors this process inherited\n", buf.String())
assert.NotEqual(t, output.ExitOK, code)
}

// TestResolveProfileMayCreatePassesUnknownName: a command that registers the
// named profile itself (auth login --with-token) receives the name; every
// other command is still refused an unknown --profile / BASECAMP_PROFILE.
Expand Down
Loading
Loading