Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cf21abc
mcpx: shebang-first interpreter dispatch + script.interpreter + scrip…
dezren39 Oct 30, 2026
2cd0551
fix(mcpx): address Codex findings on the shebang dispatch PR (#2167)
dezren39 Oct 30, 2026
534a14a
fix(mcpx): honour env shebang options -u and -i (#2167)
dezren39 Oct 30, 2026
98367b9
fix(mcpx): review findings on the shebang dispatch PR (#2167)
Oct 30, 2026
283126b
mcpx: shebang -C and PATH assignments honoured on both dispatch paths…
dezren39 Oct 30, 2026
38b556e
fix(mcpx): resolve shebang interpreters after choosing cwd (#2167)
dezren39 Oct 30, 2026
3819dd4
Merge remote-tracking branch 'origin/main' into queue/3201-2167-sheba…
dezren39 Oct 30, 2026
2742f41
Merge remote-tracking branch 'origin/main' into queue/3201-2167-sheba…
dezren39 Oct 30, 2026
52dbf17
fix(mcpx): honour env terminator assignments, raw -S payloads, empty …
dezren39 Oct 30, 2026
031db4d
mcpx: fix the four Codex P2s on the shebang dispatch PR (#2611)
dezren39 Oct 30, 2026
4fff16a
WIP: disposition CodeRabbit round on 031db4d4 (#2167)
Oct 30, 2026
a5dbbe0
Merge origin/main into queue/3201-2167-shebang-dispatch
dezren39 Oct 30, 2026
05b113e
fix(mcpx): disposition the CodeRabbit round on the dispatch PR (#2167)
Oct 30, 2026
39240a4
fix(mcpx): disposition the two Codex P2s on the dispatch PR (#2167)
Oct 30, 2026
fd64ab0
fix(mcpx): keep backslashes literal in single-quoted env -S payloads …
Oct 30, 2026
910a64b
Merge remote-tracking branch 'origin/main' into queue/3201-2167-sheba…
dezren39 Oct 31, 2026
bdd9231
mcpx: env -S splitter + workdir dispatch — 4 Codex findings (PR #2611)
dezren39 Oct 31, 2026
631b3a8
mcpx: lease dispatched workdirs + empty unquoted env expansions start…
dezren39 Oct 31, 2026
0847133
Merge remote-tracking branch 'origin/main' into queue/3201-2167-sheba…
dezren39 Oct 31, 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
17 changes: 17 additions & 0 deletions pkgs/mcpx/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,21 @@ field. This is deliberately not a setting: it is how `mcpx init` wrote
`"mode": "session"` into every new config for months without anyone noticing,
and a switch to turn the report off would have kept it quiet.

A script whose first line names its interpreter (`#!`) runs under that
interpreter, ahead of every setting below: a `-S`/`--split-string`
payload is re-split with shell quoting rules (so `-S sh -c "printf
hello"` keeps `printf hello` one argument; an unterminated quote fails
the run), `VAR=value` assignments reach the child even when the value
contains `/`, `env -u`/`env -i` apply (the child starts from the parent's
environment minus what they remove, or from an empty one), and
`env -C`/`env --chdir` moves the run, beating `script.cwd` the way the
interpreter choice beats `script.runtime`. An env option mcpx does not
implement fails the run rather than being dropped. A dispatched run
never inherits `MCPX_CLIENT`, `MCPX_RUNTIME`, `MCPX_ENTRY` or
`MCPX_DISPATCH` from an outer run, and its notice names anything else it
goes without (no generated client, no enforced permission profiles,
skipped launcher phases).

<!-- BEGIN GENERATED: settings.Registry(); `go test ./internal/settings -run TestTheDocumentationListsEverySetting -update` -->

### artifacts
Expand Down Expand Up @@ -711,9 +726,11 @@ and a switch to turn the report off would have kept it quiet.
| --- | --- | --- | --- | --- | --- | --- | --- |
| `script.before` | source | *(empty)* | client | no | `--script-before` | `MCPX_SCRIPT_BEFORE` | runs first, ahead of the globals being installed |
| `script.captureConsole` | bool | `true` | client | no | `--script-capture-console` | `MCPX_SCRIPT_CAPTURE_CONSOLE` | route console calls into the log |
| `script.cwd` | enum | `caller` | client | no | `--script-cwd` | `MCPX_SCRIPT_CWD` | where a script runs: caller, script, config or workdir |
| `script.env` | list | *(empty)* | client | no | `--script-env`, `--env` | `MCPX_SCRIPT_ENV` | KEY=VALUE pairs added to the script's environment |
| `script.imports` | enum | `report` | client | no | `--script-imports` | `MCPX_SCRIPT_IMPORTS` | resolve the script's import graph before running it |
| `script.importsOnDynamic` | enum | `warn` | client | no | `--script-imports-on-dynamic` | `MCPX_SCRIPT_IMPORTS_ON_DYNAMIC` | what to do about an import() whose specifier is computed |
| `script.interpreter` | string | *(empty)* | client | no | `--script-interpreter`, `--interpreter`, `--shell` | `MCPX_SCRIPT_INTERPRETER` | command line that runs scripts instead of a JavaScript runtime |
| `script.launcher` | source | *(empty)* | client | no | `--script-launcher` | `MCPX_SCRIPT_LAUNCHER` | replace the generated launcher entirely |
| `script.onError` | source | *(empty)* | client | no | `--script-on-error` | `MCPX_SCRIPT_ON_ERROR` | runs when the script throws; the error still propagates |
| `script.onSuccess` | source | *(empty)* | client | no | `--script-on-success` | `MCPX_SCRIPT_ON_SUCCESS` | runs when the script returns without throwing |
Expand Down
109 changes: 57 additions & 52 deletions pkgs/mcpx/docs/environment.md

Large diffs are not rendered by default.

30 changes: 29 additions & 1 deletion pkgs/mcpx/internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,15 @@ func (a *App) runScript(ctx context.Context, args []string, inline bool) error {
if a.Settings().Given("script.permissions") {
setPerms = a.Settings().String("script.permissions")
}
// script.interpreter and script.cwd come from the resolved set like
// every other script.* value: flag aliases (--interpreter/--shell),
// env and config files all fold into the same two strings.
interpreterPref := set.String("script.interpreter")
cwdPolicy := set.String("script.cwd")
configDir := ""
if cp := configPathOf(cfg); cp != "" {
configDir = filepath.Dir(cp)
}
opts := runner.Options{
// Set when something other than a terminal is collecting the output:
// the MCP server, which has to return it rather than print it.
Expand All @@ -961,6 +970,9 @@ func (a *App) runScript(ctx context.Context, args []string, inline bool) error {
GlobalsSource: globalsSrc,
CaptureConsole: shape.captureConsole,
Runtime: runtimePref,
Interpreter: interpreterPref,
CwdPolicy: cwdPolicy,
ConfigDir: configDir,
Setup: rtSetup,
Timeout: *timeout,
Prelude: prelude,
Expand Down Expand Up @@ -2272,13 +2284,29 @@ type scriptShape struct {
// inlineSource splices prefix and suffix lines around a snippet. A snippet is
// generated wholesale, so prefix lines share its scope and can declare
// bindings the snippet uses.
//
// A snippet's own shebang goes first, ahead of the prefix. Dispatch reads the
// first line to decide the interpreter (#2167), and a shell prefix line in
// front of it hid the shebang: `mcpx exec --prefix 'echo before' $'#!/bin/sh\necho hello'`
// then went to the JavaScript harness instead of sh (#2611 review). The
// shebang is still the program's own first line; the prefix runs after it,
// which is the order the interpreter would have imposed anyway.
func (sh scriptShape) inlineSource(args []string) string {
var body strings.Builder
var rest strings.Builder
rest.WriteString(strings.Join(args, " "))
if first := rest.String(); strings.HasPrefix(first, "#!") {
line, tail, _ := strings.Cut(first, "\n")
body.WriteString(line)
body.WriteString("\n")
rest.Reset()
rest.WriteString(tail)
}
for _, line := range sh.prefix {
body.WriteString(line)
body.WriteString("\n")
}
body.WriteString(strings.Join(args, " "))
body.WriteString(rest.String())
for _, line := range sh.suffix {
body.WriteString("\n")
body.WriteString(line)
Expand Down
12 changes: 11 additions & 1 deletion pkgs/mcpx/internal/cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ func (a *App) localStore() (*artifacts.Store, error) {
func (a *App) localService(store *artifacts.Store, socket string) *execsvc.Service {
set := a.Settings()
lim := execsvc.DefaultLimits()
lim.Interpreter = set.String("script.interpreter")
lim.CwdPolicy = set.String("script.cwd")
lim.Delivery = set.String("artifacts.delivery")
lim.InlineMaxBytes = set.Bytes("artifacts.inlineMaxBytes")
lim.ChunkBytes = set.Bytes("artifacts.chunkBytes")
Expand Down Expand Up @@ -554,7 +556,15 @@ func (a *App) execOnDaemon(ctx context.Context, c *Client, fs *flag.FlagSet, inl
opts := execsvc.Options{
Runtime: rf.runtime,
Permissions: rf.permissions,
Session: session,
// From the resolved set, so --interpreter/--shell, the env and
// config files reach local and remote exec the same way.
Interpreter: a.Settings().String("script.interpreter"),
// The caller's script.cwd policy travels with the request; the
// daemon's own policy is only the fallback for callers that send
// none. Without this a caller's --script-cwd (or env/config
// value) was silently ignored by remote runs.
CwdPolicy: a.Settings().String("script.cwd"),
Session: session,
// The caller's context, not the daemon's. A remote daemon has a
// different working directory and a different environment, and a
// script that reads either should see the one belonging to whoever
Expand Down
31 changes: 31 additions & 0 deletions pkgs/mcpx/internal/cli/inline_shebang_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cli

// A snippet's own shebang must survive the prefix splice: dispatch reads
// the first line to pick the interpreter, and a prefix line in front of a
// shebang sent a valid shell snippet to the JavaScript harness (#2611
// review).

import (
"strings"
"testing"
)

func TestInlineSourceKeepsAShebangFirst(t *testing.T) {
sh := scriptShape{prefix: []string{`echo before`}}
got := sh.inlineSource([]string{"#!/bin/sh\necho hello"})
if !strings.HasPrefix(got, "#!/bin/sh\n") {
t.Fatalf("the snippet's shebang must stay on line 1:\n%s", got)
}
if !strings.Contains(got, "echo before") || !strings.Contains(got, "echo hello") {
t.Fatalf("prefix and body must both survive:\n%s", got)
}
}

func TestInlineSourceWithoutShebangIsUnchanged(t *testing.T) {
sh := scriptShape{prefix: []string{"const a = 1"}, suffix: []string{"const b = 2"}}
got := sh.inlineSource([]string{"console.log(1)"})
want := "const a = 1\nconsole.log(1)\nconst b = 2"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
36 changes: 31 additions & 5 deletions pkgs/mcpx/internal/daemon/routes_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ func (s *Server) execSettings() *settings.Set {
}
set := settings.NewSet(sch)
if s.cfg != nil && s.cfg.Path != "" {
// The path the settings were read from, kept with them:
// script.cwd=config has to resolve against the directory
// of the file this policy came from, not of whichever file
// a later reload moved s.cfg to (#2611 review).
s.execSetPath = s.cfg.Path
if b, rerr := os.ReadFile(s.cfg.Path); rerr == nil {
var doc map[string]any
if json.Unmarshal(config.StripJSONC(b), &doc) == nil {
Expand Down Expand Up @@ -173,6 +178,8 @@ func (s *Server) ExecService() *execsvc.Service {
lim.Timeout = set.Duration("exec.timeout")
lim.Runtime = set.String("script.runtime")
lim.Permissions = set.String("script.permissions")
lim.Interpreter = set.String("script.interpreter")
lim.CwdPolicy = set.String("script.cwd")
Comment thread
dezren39 marked this conversation as resolved.
Resolved
// A malformed definition is reported by the run that needs it, rather
// than failing every other route that builds a service.
lim.Setup, lim.SetupErr = runner.ParseSetup(set.String("script.runtimes"),
Expand All @@ -187,12 +194,26 @@ func (s *Server) ExecService() *execsvc.Service {
lim.InterceptImages = set.Bool("artifacts.interceptImages")
lim.StdoutLimit = set.Bytes("exec.maxOutputBytes")

// script.cwd=config resolves against the directory holding the
// configuration the exec policy was built from -- the file
// execSettings read, not whichever file a reload has since moved
// s.cfg to. Without it every /v1/exec run under that policy fails in
// ResolveCwd before the script starts; with s.cfg it could point at
// the wrong directory after a reload (#2611 review).
configDir := ""
if s.execSetPath != "" {
configDir = filepath.Dir(s.execSetPath)
} else if cfg := s.currentConfig(); cfg != nil && cfg.Path != "" {
configDir = filepath.Dir(cfg.Path)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

svc := &execsvc.Service{
Calls: s.callRegistry(),
Src: registrySource{reg: s.reg, endpoint: s.endpoint},
Store: store,
Endpoint: s.endpoint,
Socket: s.paths.Socket,
Src: registrySource{reg: s.reg, endpoint: s.endpoint},
Store: store,
Endpoint: s.endpoint,
Socket: s.paths.Socket,
ConfigDir: configDir,
Calls: s.callRegistry(),
// The script the daemon runs is on the daemon's machine by
// definition, so a path it hands to artifact() is a path the store
// can link rather than read.
Expand Down Expand Up @@ -504,6 +525,11 @@ type artifactState struct {
artifactsErr error
execSetOnce sync.Once
execSet *settings.Set
// execSetPath is the configuration file execSettings built execSet
// from. script.cwd=config resolves against its directory, so a
// reload that moves s.cfg cannot redirect a policy already in force
// (#2611 review).
execSetPath string
}

// InlineArtifact renders one artifact as an MCP resource body.
Expand Down
57 changes: 57 additions & 0 deletions pkgs/mcpx/internal/e2e/dispatch_settings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package e2e_test

// Effect probes for the dispatch settings (#2167): the two knobs that
// decide whether a script runs under an interpreter and where it runs
// must reach the CLI from the environment like every other setting, or
// the settings registry entry is decoration.

import (
"os"
"path/filepath"
"strings"
"testing"
)

// MCPX_SCRIPT_INTERPRETER makes `mcpx exec` run the source under that
// command line instead of a JavaScript runtime. The source is not a
// TypeScript program, so without the setting the run cannot produce
// this output.
func TestScriptInterpreterIsReachableFromTheEnvironment(t *testing.T) {
e := newEnv(t, oneServer)
saved := e.envVars
e.envVars = append(append([]string{}, saved...), "MCPX_SCRIPT_INTERPRETER=sh")
defer func() { e.envVars = saved }()

out := e.run("exec", "echo from-the-configured-interpreter")
if !strings.Contains(out, "from-the-configured-interpreter") {
t.Fatalf("MCPX_SCRIPT_INTERPRETER should run the source under sh:\n%s", out)
}
}

// MCPX_SCRIPT_CWD=script moves a dispatched run to the script's own
// directory; the default (caller) leaves it where mcpx was invoked, so
// the two are distinguishable from the script's own pwd.
func TestScriptCwdIsReachableFromTheEnvironment(t *testing.T) {
e := newEnv(t, oneServer)
sub := filepath.Join(e.dir, "scripts")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
script := filepath.Join(sub, "where.sh")
if err := os.WriteFile(script, []byte("#!/bin/sh\npwd\n"), 0o755); err != nil {
t.Fatal(err)
}
saved := e.envVars
e.envVars = append(append([]string{}, saved...), "MCPX_SCRIPT_CWD=script")
defer func() { e.envVars = saved }()

out := e.run("run", script)
// The dispatch notice shares the stream; the script's pwd is the
// last line it printed.
lines := strings.Split(strings.TrimSpace(out), "\n")
got, _ := filepath.EvalSymlinks(strings.TrimSpace(lines[len(lines)-1]))
want, _ := filepath.EvalSymlinks(sub)
if got != want {
t.Fatalf("MCPX_SCRIPT_CWD=script: pwd = %q, want %q\n%s", got, want, out)
}
}
2 changes: 2 additions & 0 deletions pkgs/mcpx/internal/e2e/effect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ var implementedEffectProbes = map[string]string{
"pool.scope": "TestPoolDefaultsAreReachableFromTheEnvironment",
"output.json": "TestOutputJSONIsReachableFromTheEnvironment",
"script.prefix": "TestScriptPrefixIsReachableFromTheEnvironment",
"script.cwd": "TestScriptCwdIsReachableFromTheEnvironment",
"script.interpreter": "TestScriptInterpreterIsReachableFromTheEnvironment",
"script.env": "TestScriptEnvIsReachableFromTheEnvironment",
"script.captureConsole": "TestScriptCaptureConsoleOffDropsConsoleOutput",
"script.typecheck": "TestScriptTypecheckOnRefusesATypeError",
Expand Down
27 changes: 26 additions & 1 deletion pkgs/mcpx/internal/execsvc/execsvc.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,17 @@ type TaskOptions struct {
// caller declaring what it can receive is the thing that makes the script
// identical everywhere.
type Options struct {
Runtime string `json:"runtime,omitempty"`
Runtime string `json:"runtime,omitempty"`
// Interpreter is the script.interpreter command line for this run;
// empty takes the service's configured one. A shebang in the source
// or file beats it, as it beats every configuration surface.
Interpreter string `json:"interpreter,omitempty"`
// CwdPolicy is the caller's script.cwd policy for this run; empty
// takes the service's configured one. The CLI resolves its own set
// (flag aliases, env, config) and forwards the result, so a remote
// run honours the caller's policy instead of silently substituting
// the daemon's.
CwdPolicy string `json:"cwdPolicy,omitempty"`
Timeout string `json:"timeout,omitempty"`
Permissions string `json:"permissions,omitempty"`
Placeholders map[string]any `json:"placeholders,omitempty"`
Expand Down Expand Up @@ -372,6 +382,10 @@ type Result struct {
Runtime string `json:"runtime,omitempty"`
TimedOut bool `json:"timedOut,omitempty"`
Error string `json:"error,omitempty"`
// Notice carries the runner's one-line statement of what a
// dispatched (non-JavaScript) run does not have: no generated
// client, no enforced permission profiles.
Notice string `json:"notice,omitempty"`
}

// Source supplies the generated client the script is run against.
Expand All @@ -394,6 +408,10 @@ type Limits struct {
Timeout time.Duration
Runtime string
Permissions string
// Interpreter is the configured script.interpreter command line.
Interpreter string
// CwdPolicy is the configured script.cwd policy.
CwdPolicy string
// Setup holds declared runtimes, the auto order and user profiles.
Setup runner.Setup
// SetupErr is why Setup could not be read; every run reports it.
Expand Down Expand Up @@ -448,6 +466,9 @@ type Service struct {
BearerToken string
// Limits are the resolved settings.
Limits Limits
// ConfigDir is the directory holding the configuration in force,
// for the script.cwd "config" policy on runs through this service.
ConfigDir string
// WorkDir holds the generated client between runs, so the runtime's
// module cache stays warm. Empty means a temporary directory per run.
WorkDir string
Expand Down Expand Up @@ -526,6 +547,9 @@ func (s *Service) Run(ctx context.Context, req Request, sink Sink) (*Result, err
WorkDir: s.WorkDir,
WorkRoot: s.WorkRoot,
Runtime: firstNonEmpty(opts.Runtime, s.Limits.Runtime),
Interpreter: firstNonEmpty(opts.Interpreter, s.Limits.Interpreter),
Comment thread
dezren39 marked this conversation as resolved.
Resolved
CwdPolicy: firstNonEmpty(opts.CwdPolicy, s.Limits.CwdPolicy),
ConfigDir: s.ConfigDir,
Setup: s.Limits.Setup,
Timeout: timeout,
Permissions: firstNonEmpty(opts.Permissions, s.Limits.Permissions),
Expand Down Expand Up @@ -780,6 +804,7 @@ func (s *Service) RunWith(ctx context.Context, ropts runner.Options, opts Option
res.DurationMs = rres.Duration.Milliseconds()
res.Runtime = rres.Runtime
res.TimedOut = rres.TimedOut
res.Notice = rres.Notice
if rerr != nil {
res.Error = rerr.Error()
}
Expand Down
32 changes: 32 additions & 0 deletions pkgs/mcpx/internal/execsvc/execsvc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,35 @@ for (let i = 0; i < 200; i++) { console.log("out " + i); emit({ i }); }
t.Fatalf("the sink was entered concurrently %d times", n)
}
}

// A shebang file runs through the shared service entry point -- the one
// /v1/exec and the CLI both pass through -- under its own interpreter,
// with output and exit code propagating (#2167). No JavaScript runtime
// is involved, so this test never skips for one.
func TestRunWithShebangFile(t *testing.T) {
svc := service(t, store(t))
dir := t.TempDir()
path := filepath.Join(dir, "greet.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\necho execsvc-shebang-$MCPX_RUNTIME\nexit 5\n"), 0o755); err != nil {
t.Fatal(err)
}
var frames []execsvc.Frame
res, err := svc.RunWith(context.Background(), runner.Options{
File: path, Permissions: "all",
}, execsvc.Options{}, func(f execsvc.Frame) error {
frames = append(frames, f)
return nil
})
if err != nil {
t.Fatalf("RunWith: %v", err)
}
if res.ExitCode != 5 {
t.Errorf("ExitCode = %d, want 5", res.ExitCode)
}
if !strings.Contains(res.Stdout, "execsvc-shebang-sh") {
t.Errorf("Stdout = %q, want the script's output with MCPX_RUNTIME=sh", res.Stdout)
}
if res.Runtime != "sh" {
t.Errorf("Runtime = %q, want sh", res.Runtime)
}
}
7 changes: 7 additions & 0 deletions pkgs/mcpx/internal/execsvc/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ func (s *Service) runOneHook(ctx context.Context, phase string, h Hook, hctx hoo
}

opts := ropts
// The main script's interpreter preference (script.interpreter) is
// not the hook's: hooks are TypeScript by definition here, and a
// configured sh interpreter would otherwise execute every hook's
// TypeScript under sh (Codex review, PR #2611). A hook's own
// shebang still selects its interpreter -- rank 1 in the runner's
// dispatch order is read from the hook script itself.
opts.Interpreter = ""
// The hook gets the same runtime, permissions, working directory and
// content-addressed client as the script, and nothing of the parent's
// phases: a hook running the parent's onSuccess would recurse.
Expand Down
Loading
Loading