Skip to content
Merged
2 changes: 1 addition & 1 deletion pkgs/mcpx/docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,6 @@ that will enforce it. `TestEveryEnforcedBoundaryNamesItsRefusal` holds both.
| --- | --- | --- | --- |
| `transport.allowedOrigins` | a web page on an origin that is not loopback, the daemon's own address or listed in transport.allowedOrigins cannot use /v1 or /mcp | **enforced** | `TestAWebPageCannotRunCodeThroughTheDaemon`, `TestV1AndMCPAgreeOnOrigins` |
| `script.permissions` | a script runs with no more than the permission profile named; a runtime with no permission model refuses a narrowed profile | **enforced** | `TestScriptPermissionsIsReachableFromEverySurface`, `TestAStrictScriptCannotReadFilesOnARuntimeWithoutPermissions`, `TestARestrictedProfileIsRefusedByARuntimeThatCannotEnforceIt` |
| `elicit.confirmDestructive` | with it on, a call to a tool that may be destructive (annotated destructiveHint, or unannotated) does not run unless somebody confirms it | **enforced** | `TestADestructiveCallIsRefusedWhenNobodyConfirms`, `TestConfirmDestructiveHoldsOnAColdDaemon` |
| `elicit.confirmDestructive` | with it on, a call to a tool that may be destructive (annotated destructiveHint, or unannotated) does not run unless somebody confirms it; a remote server's annotation never clears the call (#2191) | **enforced** | `TestADestructiveCallIsRefusedWhenNobodyConfirms`, `TestConfirmDestructiveHoldsOnAColdDaemon` |
| `autonomy.max` | mcpx does no more on its own than the daemon's autonomy.max allows: a prompt, a recipe run by name or a configured hook asked for above it is lowered to it and the answer says so, from /v1, the CLI, MCP and the plugin alike. It does not govern code a caller sends to /v1/exec itself | **enforced** | `TestV1CannotRaiseAutonomyAboveTheCeiling`, `TestTheCeilingCannotBeRaisedAtRuntime`, `TestTheCLICannotRaiseAutonomyAboveTheCeiling`, `TestMCPCannotRaiseAutonomyAboveTheCeiling`, `TestThePluginCannotRaiseAutonomyAboveTheCeiling`, `TestHooksFromConfigDoNotRunAboveTheCeiling`, `TestRepairCannotRaiseAutonomyAboveTheCeiling`, `TestACallerCannotRaiseAClampedSetting` |
| `privileged operations (Admin)` | operations marked privileged change the daemon rather than read it; nothing checks who calls them, and every tool description and the OpenAPI document say so | **advisory** | #253 |
29 changes: 27 additions & 2 deletions pkgs/mcpx/internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"github.com/dezren39/mcpx/internal/defaults"
"github.com/dezren39/mcpx/internal/progspec"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -122,10 +123,31 @@ func (a *App) CmdDoctor(ctx context.Context, args []string) error {
// A server whose command is not installed is the single most common
// cause of "mcpx does not work", and it is invisible until a call.
var missing []string
var nixSpecs int
for _, s := range servers {
if s.Command == "" {
continue
}
if progspec.IsNix(s.Command) {
// Resolved by nix at launch (progspec, #1921); a PATH
// lookup would report it missing even on a machine
// where it runs fine, and doctor does not build. A
// malformed spec is still a config error here: it is
// rejected at launch, so report it like one.
if _, err := progspec.Parse(s.Command); err != nil {
missing = append(missing, s.Name+": "+err.Error())
continue
}
// A valid nix spec is only runnable where nix itself
// is: launch resolves through the nix binary, so its
// absence is a missing prerequisite, not an ok.
if _, err := exec.LookPath("nix"); err != nil {
missing = append(missing, fmt.Sprintf("%s: %s requires the nix binary, which is not on PATH", s.Name, s.Command))
continue
}
nixSpecs++
continue
Comment thread
dezren39 marked this conversation as resolved.
Resolved
Comment thread
dezren39 marked this conversation as resolved.
Resolved
}
if _, err := exec.LookPath(s.Command); err != nil {
missing = append(missing, s.Name+" needs "+s.Command)
}
Expand All @@ -134,8 +156,11 @@ func (a *App) CmdDoctor(ctx context.Context, args []string) error {
add(check{"server commands", "fail", strings.Join(missing, "; "),
"install them, or remove those servers from the configuration"})
} else if len(servers) > 0 {
add(check{"server commands", "ok",
fmt.Sprintf("all %d runnable", len(servers)), ""})
detail := fmt.Sprintf("all %d runnable", len(servers))
if nixSpecs > 0 {
detail += fmt.Sprintf("; %d via nix: program specs, resolved to store paths at launch", nixSpecs)
}
add(check{"server commands", "ok", detail, ""})
}

// Auth posture, from the same redacted summary `mcpx config`
Expand Down
25 changes: 24 additions & 1 deletion pkgs/mcpx/internal/mcpclient/stdio.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"time"

"github.com/dezren39/mcpx/internal/defaults"
"github.com/dezren39/mcpx/internal/progspec"
)

// StdioTransport runs an MCP server as a child process and speaks
Expand Down Expand Up @@ -72,6 +73,11 @@ type StdioOptions struct {
// StdinGrace and TermGrace are Close's waits after closing stdin and
// after SIGTERM. Zero means defaults.StdioStdinGrace / StdioTermGrace.
StdinGrace, TermGrace time.Duration
// Context, when set, bounds a nix: program-spec resolution at spawn:
// the caller's startup budget cancels the build instead of the build
// outliving the budget on its own timeout and launching a child whose
// handshake context has already expired. Nil means context.Background.
Context context.Context
}

// maxLine bounds a single JSON-RPC frame. MCP servers such as
Expand All @@ -83,7 +89,24 @@ func NewStdio(opts StdioOptions) (*StdioTransport, error) {
if opts.Command == "" {
return nil, errors.New("stdio transport: empty command")
}
cmd := exec.Command(opts.Command, opts.Args...)
// A nix: program spec resolves once, here at spawn, to the store
// path that actually runs (progspec, #1921): the child is the built
// program, never a nix process. A plain command passes through
// untouched, exactly as before. A resolution failure names the spec
// and refuses the launch rather than falling back to PATH.
command := opts.Command
if progspec.IsNix(command) {
ctx := opts.Context
if ctx == nil {
ctx = context.Background()
}
resolved, err := progspec.ResolvePathInDir(ctx, command, opts.Cwd)
if err != nil {
return nil, fmt.Errorf("stdio transport: %w", err)
}
command = resolved
}
cmd := exec.Command(command, opts.Args...)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
cmd.Dir = opts.Cwd

env := []string{}
Expand Down
179 changes: 179 additions & 0 deletions pkgs/mcpx/internal/mcpclient/stdio_progspec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package mcpclient_test

import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/dezren39/mcpx/internal/mcpclient"
)

// stubNixForSpawn installs a fake nix binary on PATH whose build prints
// a fake store output holding one executable shell script.
func stubNixForSpawn(t *testing.T) {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "store", "aaaa-fakepkg-1.0", "bin")
if err := os.MkdirAll(bin, 0o755); err != nil {
t.Fatal(err)
}
tool := filepath.Join(bin, "faketool")
if err := os.WriteFile(tool, []byte("#!/bin/sh\nexec cat\n"), 0o755); err != nil {
t.Fatal(err)
}
out := filepath.Dir(bin)
stub := "#!/bin/sh\n" +
"for a in \"$@\"; do case \"$a\" in *slow*) sleep 30;; esac; done\n" +
"echo \"" + out + "\"\n"
if err := os.WriteFile(filepath.Join(dir, "nix"), []byte(stub), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}

func TestNewStdioResolvesNixSpecAtSpawn(t *testing.T) {
stubNixForSpawn(t)
tr, err := mcpclient.NewStdio(mcpclient.StdioOptions{Command: "nix:nixpkgs#faketool", InheritEnv: true})
if err != nil {
t.Fatalf("NewStdio with a nix: spec: %v", err)
}
_ = tr.Close()
}

func TestNewStdioNixResolutionHonoursTheStartupContext(t *testing.T) {
stubNixForSpawn(t)
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
_, err := mcpclient.NewStdio(mcpclient.StdioOptions{
Command: "nix:slowref#tool", InheritEnv: true, Context: ctx,
})
if err == nil || !strings.Contains(err.Error(), "nix:slowref#tool") {
t.Fatalf("err = %v, want a context-bound resolution error naming the spec", err)
}
}

func TestSupervisedReattachDoesNotResolveNixSpec(t *testing.T) {
// A daemon reattaching to a live supervised nix server must not
// resolve the spec again: the child is already running, and a nix
// that has since left PATH (or a flake that no longer resolves)
// must not fail the reattach (Codex finding on #2585).
stubNixForSpawn(t)
opts := mcpclient.SupervisedOptions{
Name: "nixreattach", StateDir: t.TempDir(),
Command: "nix:nixpkgs#echotool", InheritEnv: true,
}
tr, err := mcpclient.NewSupervised(opts)
if err != nil {
t.Fatalf("first attach: %v", err)
}
pid := tr.PID()
defer killChild(t, tr)
if err := tr.Close(); err != nil {
t.Fatalf("detach: %v", err)
}
// nix vanishes: with resolution on the attach path, the reattach
// would now fail before ever reaching the live supervisor.
t.Setenv("PATH", t.TempDir())
// Close returns once the local connection is closed; the supervisor
// clears its attach slot when it notices the disconnect, which is
// asynchronous. Poll the reattach until the slot frees instead of
// assuming the release is instant (the #2609 flake, hit by this
// test in the PR stress run: under shuffle/load the immediate
// reattach was refused "already attached").
var tr2 *mcpclient.SupervisedTransport
deadline := time.Now().Add(10 * time.Second)
for {
tr2, err = mcpclient.NewSupervised(opts)
if err == nil {
break
}
if !strings.Contains(err.Error(), "already attached") || time.Now().After(deadline) {
t.Fatalf("reattach without nix on PATH: %v", err)
}
time.Sleep(10 * time.Millisecond)
}
defer tr2.Close()
if tr2.PID() != pid {
t.Fatalf("child pid changed across reattach: %d -> %d", pid, tr2.PID())
}
}

func TestSupervisedResolvesNixSpecForItsChild(t *testing.T) {
stubNixForSpawn(t)
tr, err := mcpclient.NewSupervised(mcpclient.SupervisedOptions{
Name: "nixecho", StateDir: t.TempDir(),
Command: "nix:nixpkgs#echotool", InheritEnv: true,
})
if err != nil {
t.Fatalf("NewSupervised with a nix: spec: %v", err)
}
defer killChild(t, tr)
defer tr.Close()
ctx := context.Background()
frame := []byte(`{"jsonrpc":"2.0","id":1,"method":"ping"}`)
if err := tr.Send(ctx, frame); err != nil {
t.Fatalf("send: %v", err)
}
got, err := tr.Recv()
if err != nil {
t.Fatalf("recv: %v", err)
}
if string(got) != string(frame) {
t.Fatalf("echo = %s, want %s", got, frame)
}
}

// Codex P2 (PR #2585): a supervised server whose own Env overrides
// PATH with one that excludes nix must still launch: the daemon
// resolves the spec with its own PATH and hands the resolved store
// path to the supervisor it spawns, instead of leaving the
// supervisor to resolve the spec again under the server's PATH.
// The stub tool here execs cat by absolute path, so the only thing
// the restricted PATH needs to hide is nix itself.
func TestSupervisedNixSpecWithServerPathExcludingNix(t *testing.T) {
cat, err := exec.LookPath("cat")
if err != nil {
t.Skip("no cat on PATH for the stub tool")
}
dir := t.TempDir()
bin := filepath.Join(dir, "store", "aaaa-fakepkg-1.0", "bin")
if err := os.MkdirAll(bin, 0o755); err != nil {
t.Fatal(err)
}
tool := "#!/bin/sh\nexec " + cat + "\n"
if err := os.WriteFile(filepath.Join(bin, "faketool"), []byte(tool), 0o755); err != nil {
t.Fatal(err)
}
stub := "#!/bin/sh\necho \"" + filepath.Dir(bin) + "\"\n"
if err := os.WriteFile(filepath.Join(dir, "nix"), []byte(stub), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))

tr, err := mcpclient.NewSupervised(mcpclient.SupervisedOptions{
Name: "nixrestrictedpath", StateDir: t.TempDir(),
Command: "nix:nixpkgs#faketool", InheritEnv: true,
Env: map[string]string{"PATH": "/usr/bin:/bin"},
})
if err != nil {
t.Fatalf("NewSupervised with a nix: spec and a nix-less server PATH: %v", err)
}
defer killChild(t, tr)
defer tr.Close()
ctx := context.Background()
frame := []byte(`{"jsonrpc":"2.0","id":1,"method":"ping"}`)
if err := tr.Send(ctx, frame); err != nil {
t.Fatalf("send: %v", err)
}
got, err := tr.Recv()
if err != nil {
t.Fatalf("recv: %v", err)
}
if string(got) != string(frame) {
t.Fatalf("echo = %s, want %s", got, frame)
}
}
50 changes: 48 additions & 2 deletions pkgs/mcpx/internal/mcpclient/supervised.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"time"

"github.com/dezren39/mcpx/internal/defaults"
"github.com/dezren39/mcpx/internal/progspec"
)

// Supervised stdio transport — Phase 1 of the mcpx supervisor design
Expand Down Expand Up @@ -233,7 +234,19 @@ func RunSupervisor(opts SupervisorOptions) error {
// plain os.Pipes, stderr into a 64 KiB ring.
func (s *supervisor) spawnChild() error {
opts := s.opts
cmd := exec.Command(opts.Command, opts.Args...)
// A nix: program spec resolves once, here at spawn, exactly as on
// the direct stdio path (progspec, #1921): the child is the built
// store path, never a nix process and never the literal spec string
// handed to exec (which could only fail not-found).
command := opts.Command
if progspec.IsNix(command) {
resolved, err := progspec.ResolvePathInDir(context.Background(), command, opts.Cwd)
Comment thread
dezren39 marked this conversation as resolved.
Resolved
if err != nil {
return fmt.Errorf("supervise: %w", err)
}
command = resolved
}
cmd := exec.Command(command, opts.Args...)
cmd.Dir = opts.Cwd
env := []string{}
if opts.InheritEnv {
Expand Down Expand Up @@ -510,6 +523,10 @@ type SupervisedOptions struct {
BufferSize int
// SpawnTimeout bounds waiting for a spawned supervisor's socket.
SpawnTimeout time.Duration
// Context, when set, bounds the fail-fast nix: program-spec check
// before a supervisor is spawned. The supervisor itself resolves the
// spec again when it spawns the child. Nil means context.Background.
Context context.Context
}

// SupervisedTransport speaks to a stdio child through its supervisor.
Expand Down Expand Up @@ -566,11 +583,40 @@ func newSupervised(opts SupervisedOptions, spawn spawnFunc) (*SupervisedTranspor
return nil, err
}
// Not live: remove a possible stale socket and spawn on demand.
spawnOpts := opts
if progspec.IsNix(opts.Command) {
// Fail fast, in this process, when the spec cannot resolve at
// all — and hand the resolved store path to the supervisor
// being spawned, in place of the spec. The supervisor runs
// with the SERVER's environment (opts.Env rides along), whose
// PATH may exclude nix entirely, so a supervisor left to
// resolve the spec again at child spawn can fail where this
// process succeeded (Codex review, PR #2585). A store path is
// immutable and self-contained, so handing it over is safe,
// and the supervisor's own restarts of the child stop
// depending on nix being findable. The resolution is FRESH
// (no memo read): a memoized path names whatever an earlier
// context resolved, which another process cannot trust.
// This check belongs HERE,
// not before the attach attempt above: a live supervisor with
// a healthy child needs no build, and a daemon reattaching
// after a restart must not be failed by a nix that has since
// left PATH or a flake that no longer resolves.
ctx := opts.Context
if ctx == nil {
ctx = context.Background()
}
resolved, err := progspec.ResolvePathFreshInDir(ctx, opts.Command, opts.Cwd)
if err != nil {
return nil, fmt.Errorf("supervised transport: %w", err)
}
spawnOpts.Command = resolved
}
_ = os.Remove(sock)
if spawn == nil {
spawn = spawnSupervisorProcess
}
if serr := spawn(sock, opts); serr != nil {
if serr := spawn(sock, spawnOpts); serr != nil {
return nil, fmt.Errorf("supervised transport for %s: spawn supervisor: %w", opts.Name, serr)
}
deadline := time.Now().Add(opts.SpawnTimeout)
Expand Down
2 changes: 1 addition & 1 deletion pkgs/mcpx/internal/pool/handoff.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ func (p *Pool) AdoptSessions(hs []InstanceHandoff) ([]*Adopted, error) {
if h.HTTP == nil {
continue
}
tr, err := p.dial()
tr, err := p.dial(context.Background())
if err != nil {
abortAll(out)
return nil, fmt.Errorf("instance %s: %w", h.ID, err)
Expand Down
Loading
Loading