From 191aaf3e86f0d95a8e94eab1dda236b1e087cc9f Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Fri, 30 Oct 2026 06:22:47 -0500 Subject: [PATCH 1/7] mcpx: optional bearer-token auth for /v1 and /mcp (#1975) transport.bearerToken registry setting (env-sourced via the derived MCPX_TRANSPORT_BEARER_TOKEN; value never logged or echoed). Daemon: requireBearer middleware in both handler chains refuses 401 without/with a wrong token, constant-time compare; unset is a pass-through, byte-for-byte today. Tokens under 32 chars are refused at NewServer startup with an error naming the setting and the minimum. First-party callers: cli.Client.authorize attaches the header from the same setting, used by open() and the events stream; scripts and the plugin drive the CLI. Effect guard: exemption recorded for transport.bearerToken (with/without proven at the daemon HTTP layer; an e2e probe should replace it), pin 180 -> 181. --- pkgs/mcpx/docs/configuration.md | 1 + pkgs/mcpx/internal/cli/bearer_test.go | 45 ++++++++++++ pkgs/mcpx/internal/cli/client.go | 13 ++++ pkgs/mcpx/internal/daemon/bearer.go | 75 ++++++++++++++++++++ pkgs/mcpx/internal/daemon/bearer_test.go | 90 ++++++++++++++++++++++++ pkgs/mcpx/internal/daemon/server.go | 7 +- pkgs/mcpx/internal/settings/effect.go | 3 +- pkgs/mcpx/internal/settings/transport.go | 17 +++++ 8 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 pkgs/mcpx/internal/cli/bearer_test.go create mode 100644 pkgs/mcpx/internal/daemon/bearer.go create mode 100644 pkgs/mcpx/internal/daemon/bearer_test.go diff --git a/pkgs/mcpx/docs/configuration.md b/pkgs/mcpx/docs/configuration.md index ef3b9b6f0..589de4f05 100644 --- a/pkgs/mcpx/docs/configuration.md +++ b/pkgs/mcpx/docs/configuration.md @@ -643,6 +643,7 @@ and a switch to turn the report off would have kept it quiet. | setting | kind | default | scope | hot | flag | variable | governs | | --- | --- | --- | --- | --- | --- | --- | --- | | `transport.allowedOrigins` | list | *(empty)* | daemon | no | `--transport-allowed-origins` | `MCPX_TRANSPORT_ALLOWED_ORIGINS` | browser Origins, beyond loopback, that may reach /mcp | +| `transport.bearerToken` | string | *(empty)* | daemon | no | `--transport-bearer-token` | `MCPX_TRANSPORT_BEARER_TOKEN` | when set, every /v1 and /mcp request must carry this token as a Bearer credential | | `transport.sessionMetaKeys` | list | `dev.mcpx/session,ai.opencode/sessionID` | daemon | yes | `--transport-session-meta-keys` | `MCPX_TRANSPORT_SESSION_META_KEYS` | the _meta keys a client may name its session with, first present wins | | `transport.sseKeepAlive` | duration | `15s` | daemon | no | `--transport-sse-keep-alive` | `MCPX_TRANSPORT_SSE_KEEP_ALIVE` | how often a quiet MCP event stream carries a comment line | | `transport.stdioDrain` | duration | `30s` | client | no | `--transport-stdio-drain` | `MCPX_TRANSPORT_STDIO_DRAIN` | how long `mcpx serve` finishes in-flight requests after its input closes | diff --git a/pkgs/mcpx/internal/cli/bearer_test.go b/pkgs/mcpx/internal/cli/bearer_test.go new file mode 100644 index 000000000..dacb1e906 --- /dev/null +++ b/pkgs/mcpx/internal/cli/bearer_test.go @@ -0,0 +1,45 @@ +package cli + +import ( + "net/http" + "testing" + + "github.com/dezren39/mcpx/internal/daemon" + "github.com/dezren39/mcpx/internal/settings" +) + +// #1975: the CLI attaches the same transport.bearerToken the daemon +// requires, from its own resolved settings; unset, no header is added. + +func TestClientAuthorize(t *testing.T) { + const fixture = "fixture-token-0123456789abcdef01" + build := func(tok string) *Client { + sch, err := settings.New(settings.Registry()) + if err != nil { + t.Fatal(err) + } + ov := map[string]string{} + if tok != "" { + ov[daemon.BearerTokenSetting] = tok + } + return &Client{set: settings.NewSet(sch).WithOverrides(ov, "test")} + } + + req, err := http.NewRequest(http.MethodGet, "http://mcpx/v1/log", nil) + if err != nil { + t.Fatal(err) + } + build(fixture).authorize(req) + if got := req.Header.Get("Authorization"); got != "Bearer "+fixture { + t.Errorf("Authorization = %q, want the configured bearer token", got) + } + + req2, err := http.NewRequest(http.MethodGet, "http://mcpx/v1/log", nil) + if err != nil { + t.Fatal(err) + } + build("").authorize(req2) + if got := req2.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization = %q with no token configured, want no header", got) + } +} diff --git a/pkgs/mcpx/internal/cli/client.go b/pkgs/mcpx/internal/cli/client.go index 7e8dc56ac..1c18da697 100644 --- a/pkgs/mcpx/internal/cli/client.go +++ b/pkgs/mcpx/internal/cli/client.go @@ -119,6 +119,17 @@ func callHeader(set *settings.Set) string { return string(b) } +// authorize attaches the configured daemon bearer token to a request +// (#1975): the same transport.bearerToken setting the daemon checks, +// resolved from this client's own settings (environment or config), so +// a caller and its daemon agree by construction. Unset, no header -- +// the request is byte-for-byte what it was before the setting existed. +func (c *Client) authorize(req *http.Request) { + if tok := daemon.BearerToken(c.set); tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } +} + // NewClient builds a socket-backed API client. func NewClient(paths daemon.Paths, configPath string) *Client { return NewClientAt(paths, configPath, "") @@ -372,6 +383,7 @@ func (c *Client) open(ctx context.Context, method, path string, body []byte, con if v := mcpclient.CallerVersionFrom(ctx); v != "" { req.Header.Set(daemon.CallerVersionHeader, v) } + c.authorize(req) resp, err := c.hc.Do(req) if err != nil { if isDialErr(err) { @@ -1117,6 +1129,7 @@ func (c *Client) Stream(ctx context.Context, f events.Filter, fn func(events.Eve return err } req.Header.Set("Accept", "text/event-stream") + c.authorize(req) // No timeout on a stream; the context bounds it instead. The // client's ordinary timeout would cut a healthy stream off. streamClient := &http.Client{Transport: c.hc.Transport} diff --git a/pkgs/mcpx/internal/daemon/bearer.go b/pkgs/mcpx/internal/daemon/bearer.go new file mode 100644 index 000000000..e70975177 --- /dev/null +++ b/pkgs/mcpx/internal/daemon/bearer.go @@ -0,0 +1,75 @@ +package daemon + +import ( + "crypto/subtle" + "fmt" + "net/http" + "strings" + + "github.com/dezren39/mcpx/internal/settings" +) + +// Optional bearer-token auth for the daemon's HTTP surfaces (#1975). +// +// The daemon otherwise trusts any caller that can reach its socket or +// loopback port: the socket's file permissions are the only access +// control, and a TCP port has none. transport.bearerToken is the cheap, +// deliberate first step -- a single static shared token, not OAuth +// (#1950) and not per-server upstream auth (#1926). +// +// The token's value is a secret in everything but storage: it is never +// logged, never echoed in an error, and errors name only the setting. +// Comparison is constant-time so the check itself is not an oracle. + +// BearerTokenSetting is the registry path of the token. +const BearerTokenSetting = "transport.bearerToken" + +// MinBearerTokenLength is the shortest token the daemon accepts at +// startup. A static token is only as strong as its entropy; 32 +// characters is the floor the issue set. +const MinBearerTokenLength = 32 + +// BearerToken resolves the configured token from a settings set, or "" +// when auth is off. First-party clients read the same setting through +// here so the daemon and its callers can never drift on where the +// token lives. +func BearerToken(set *settings.Set) string { + if set == nil { + return "" + } + return set.String("transport.bearerToken") +} + +// ValidateBearerToken is the startup check: a token that is set but +// too short is a configuration error, reported with the setting name +// and the minimum -- never the value. +func ValidateBearerToken(set *settings.Set) error { + tok := BearerToken(set) + if tok != "" && len(tok) < MinBearerTokenLength { + return fmt.Errorf("%s: token of %d characters is too short; at least %d characters required (the value is not shown)", + BearerTokenSetting, len(tok), MinBearerTokenLength) + } + return nil +} + +// requireBearer wraps the daemon's whole HTTP surface (every /v1 and +// /mcp route, on every listener, since both handler chains pass +// through here). With no token configured it is a pass-through: the +// daemon behaves exactly as it did before the setting existed. +func (s *Server) requireBearer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tok := BearerToken(s.set) + if tok == "" { + next.ServeHTTP(w, r) + return + } + got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(tok)) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + writeErr(w, http.StatusUnauthorized, fmt.Errorf( + "this daemon requires a bearer token (%s)", BearerTokenSetting)) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/pkgs/mcpx/internal/daemon/bearer_test.go b/pkgs/mcpx/internal/daemon/bearer_test.go new file mode 100644 index 000000000..18bdacd3c --- /dev/null +++ b/pkgs/mcpx/internal/daemon/bearer_test.go @@ -0,0 +1,90 @@ +package daemon + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/dezren39/mcpx/internal/settings" +) + +// #1975: with transport.bearerToken set, every daemon request needs +// the token; unset, nothing changes. Fixture tokens only -- never a +// real credential. + +const fixtureToken = "fixture-token-0123456789abcdef01" // exactly 32 chars + +func bearerSet(t *testing.T, tok string) *settings.Set { + t.Helper() + sch, err := settings.New(settings.Registry()) + if err != nil { + t.Fatal(err) + } + ov := map[string]string{} + if tok != "" { + ov[BearerTokenSetting] = tok + } + return settings.NewSet(sch).WithOverrides(ov, "test") +} + +func TestRequireBearer(t *testing.T) { + ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + cases := []struct { + name string + set string + header string + want int + }{ + {"unset, no header", "", "", http.StatusOK}, + {"unset, stray header ignored", "", "Bearer whatever", http.StatusOK}, + {"set, no header", fixtureToken, "", http.StatusUnauthorized}, + {"set, wrong token", fixtureToken, "Bearer wrong-token-0123456789abcdef0", http.StatusUnauthorized}, + {"set, wrong scheme", fixtureToken, "Basic " + fixtureToken, http.StatusUnauthorized}, + {"set, correct token", fixtureToken, "Bearer " + fixtureToken, http.StatusOK}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := &Server{set: bearerSet(t, tc.set)} + req := httptest.NewRequest(http.MethodGet, "/v1/log", nil) + if tc.header != "" { + req.Header.Set("Authorization", tc.header) + } + rec := httptest.NewRecorder() + s.requireBearer(ok).ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Fatalf("status %d, want %d (body %q)", rec.Code, tc.want, rec.Body.String()) + } + if tc.want == http.StatusUnauthorized { + if rec.Header().Get("WWW-Authenticate") != "Bearer" { + t.Errorf("401 without a WWW-Authenticate: Bearer header") + } + if strings.Contains(rec.Body.String(), fixtureToken) { + t.Errorf("401 body leaks the token: %q", rec.Body.String()) + } + } + }) + } +} + +func TestValidateBearerToken(t *testing.T) { + if err := ValidateBearerToken(bearerSet(t, "")); err != nil { + t.Fatalf("unset token refused: %v", err) + } + if err := ValidateBearerToken(bearerSet(t, fixtureToken)); err != nil { + t.Fatalf("32-char token refused: %v", err) + } + short := "short-fixture" + err := ValidateBearerToken(bearerSet(t, short)) + if err == nil { + t.Fatal("short token accepted") + } + if !strings.Contains(err.Error(), BearerTokenSetting) || !strings.Contains(err.Error(), "32") { + t.Errorf("error should name the setting and the minimum length: %v", err) + } + if strings.Contains(err.Error(), short) { + t.Errorf("error leaks the token value: %v", err) + } +} diff --git a/pkgs/mcpx/internal/daemon/server.go b/pkgs/mcpx/internal/daemon/server.go index 3351585b6..f6a77eb15 100644 --- a/pkgs/mcpx/internal/daemon/server.go +++ b/pkgs/mcpx/internal/daemon/server.go @@ -215,6 +215,9 @@ func NewServer(opts Options) (*Server, error) { } opts.Settings = settings.NewSet(sch) } + if err := ValidateBearerToken(opts.Settings); err != nil { + return nil, err + } // logging.trace, which is MCPX_TRACE. It was read here by name, so a // configuration file or `mcpx daemon --logging-trace` could not turn it on. if opts.Settings.Bool("logging.trace") { @@ -431,7 +434,7 @@ func (s *Server) startHTTP() { mux := http.NewServeMux() s.routes(mux) srv := &http.Server{ - Handler: s.refuseBrowserPages(s.trackActivity(mux)), + Handler: s.refuseBrowserPages(s.requireBearer(s.trackActivity(mux))), ReadHeaderTimeout: s.set.Duration("http.readHeaderTimeout"), ConnState: s.observeConn, } @@ -503,7 +506,7 @@ func (s *Server) ServeInline(ctx context.Context, socket string) error { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() s.routes(mux) - return s.refuseBrowserPages(mux) + return s.refuseBrowserPages(s.requireBearer(mux)) } // InProcessHandler returns Handler with the daemon's lifetime tied to diff --git a/pkgs/mcpx/internal/settings/effect.go b/pkgs/mcpx/internal/settings/effect.go index 2ca378c11..2a3a8354d 100644 --- a/pkgs/mcpx/internal/settings/effect.go +++ b/pkgs/mcpx/internal/settings/effect.go @@ -133,6 +133,7 @@ var EffectExemptions = map[string]string{ "proto.askTTL": "protocol negotiation/wire behaviour between mcpx and a peer: era fixtures cover negotiation end-to-end, but no per-setting with/without probe exists yet", "proto.stateTTL": "protocol negotiation/wire behaviour between mcpx and a peer: era fixtures cover negotiation end-to-end, but no per-setting with/without probe exists yet", "proto.sessionIdle": "protocol negotiation/wire behaviour between mcpx and a peer: era fixtures cover negotiation end-to-end, but no per-setting with/without probe exists yet", + "transport.bearerToken": "auth gate (#1975): the with/without difference is proven at the daemon HTTP layer (internal/daemon bearer tests: unset passes, wrong token 401, correct token passes), but no e2e probe yet starts a token'd daemon and drives a caller against it; replace this exemption with that probe", "transport.allowedOrigins": "HTTP/SSE transport behaviour (origins, keep-alive, drain): needs a browser-origin or timing fixture the harness does not drive", "transport.sessionMetaKeys": "HTTP/SSE transport behaviour (origins, keep-alive, drain): needs a browser-origin or timing fixture the harness does not drive", "transport.sseKeepAlive": "HTTP/SSE transport behaviour (origins, keep-alive, drain): needs a browser-origin or timing fixture the harness does not drive", @@ -247,7 +248,7 @@ var EffectExemptions = map[string]string{ // EffectExemptionPin is the ratchet: the exemption count at the time // the guard landed. It may only ever be lowered, and only in the PR // that removes an exemption by adding the probe that replaces it. -const EffectExemptionPin = 180 +const EffectExemptionPin = 181 // CheckEffectCoverage returns one violation per way the probe and // exemption maps fail to cover reg exactly: a setting with neither, diff --git a/pkgs/mcpx/internal/settings/transport.go b/pkgs/mcpx/internal/settings/transport.go index a1ebec72c..1e1048b52 100644 --- a/pkgs/mcpx/internal/settings/transport.go +++ b/pkgs/mcpx/internal/settings/transport.go @@ -6,6 +6,23 @@ import "github.com/dezren39/mcpx/internal/defaults" // it, how long a quiet stream is kept alive, and how it shuts down. func transportSettings() []Setting { return []Setting{ + { + Path: "transport.bearerToken", Scope: ScopeDaemon, Kind: KindString, Default: "", + Commands: []string{"daemon"}, + Name: "Bearer token", + Short: "when set, every /v1 and /mcp request must carry this token as a Bearer credential", + Long: "Optional shared-secret auth for the daemon's HTTP surfaces. Unset (the " + + "default), the daemon behaves exactly as it always has: the socket's file " + + "permissions are the access control. Set, every /v1 and /mcp request -- on the " + + "socket and the loopback listeners alike -- must carry 'Authorization: Bearer " + + "' or is refused 401; the comparison is constant-time. First-party " + + "callers (the CLI, and through it scripts and the plugin) read the same setting " + + "and attach the header. Source it from the environment " + + "(MCPX_TRANSPORT_BEARER_TOKEN) so the token need not live in a configuration " + + "file. A token shorter than 32 characters is refused at daemon startup. The " + + "value is never logged or echoed in an error. This is a static shared token by " + + "design -- not OAuth (#1950) and not per-server upstream auth (#1926).", + }, { Path: "transport.allowedOrigins", Scope: ScopeDaemon, Kind: KindList, Default: defaults.CSV(defaults.TransportAllowedOrigins), Repeatable: true, From b3c4ba03d4de3a27ebe7717a79c868dfa875f961 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Fri, 30 Oct 2026 07:08:46 -0500 Subject: [PATCH 2/7] mcpx: disposition Codex P1+2xP2 on bearer-token auth (#1975) P1: generated script client (TRANSPORT choke point) and the OpenCode plugin DaemonClient attach Authorization: Bearer from MCPX_TRANSPORT_BEARER_TOKEN; the CLI runner propagates its resolved transport.bearerToken into script env so config-file tokens arrive too. P2: the takeover successor presents the configured token to the predecessor's gate (takeoverRequest helper, exact-rendering test). P2: with bearer auth on, the incoming Authorization header no longer overrides the upstream provider key on /v1/chat/completions. Unset, every path is byte-for-byte as before. --- .opencode/plugin/mcpx/daemon.ts | 17 +++++- pkgs/mcpx/internal/cli/commands.go | 9 +++ pkgs/mcpx/internal/codegen/emit.go | 20 +++++++ pkgs/mcpx/internal/daemon/bearer_test.go | 17 ++++++ pkgs/mcpx/internal/daemon/routes_proxy.go | 8 ++- .../mcpx/internal/daemon/routes_proxy_test.go | 55 +++++++++++++++++++ pkgs/mcpx/internal/daemon/takeover.go | 15 ++++- pkgs/mcpx/plugin/opencode/mcpx/daemon.test.ts | 46 ++++++++++++++++ pkgs/mcpx/plugin/opencode/mcpx/daemon.ts | 17 +++++- 9 files changed, 200 insertions(+), 4 deletions(-) diff --git a/.opencode/plugin/mcpx/daemon.ts b/.opencode/plugin/mcpx/daemon.ts index 86be6b32e..703dea7e6 100644 --- a/.opencode/plugin/mcpx/daemon.ts +++ b/.opencode/plugin/mcpx/daemon.ts @@ -380,6 +380,21 @@ const withTimeout = (ms: number): RequestInit => { } } +/** + * #1975: attach the daemon Bearer [REDACTED] when one is configured + * (MCPX_TRANSPORT_BEARER_TOKEN, the transport.bearerToken setting + * env-sourced). Unset, the init passes through untouched, so plugin calls + * are byte-for-byte what they were before the setting existed. Read at + * call time so tests can set and clear the variable around a call. + */ +export const withDaemonAuth = (init: RequestInit): RequestInit => { + const tok = (globalThis as any).process?.env?.MCPX_TRANSPORT_BEARER_TOKEN ?? "" + if (!tok) return init + const headers = new Headers(init.headers as any) + headers.set("Authorization", `Bearer ${tok}`) + return { ...init, headers } +} + /** * A client that reuses its connection. * @@ -414,7 +429,7 @@ export class DaemonClient { /** The raw response, for the few callers that need the status code. */ async raw(path: string, init: RequestInit = {}): Promise { - return fetch(this.url(path), this.init(init)) + return fetch(this.url(path), this.init(withDaemonAuth(init))) } async get(path: string, timeoutMs?: number): Promise { diff --git a/pkgs/mcpx/internal/cli/commands.go b/pkgs/mcpx/internal/cli/commands.go index 6c853cff8..1552988d9 100644 --- a/pkgs/mcpx/internal/cli/commands.go +++ b/pkgs/mcpx/internal/cli/commands.go @@ -1008,6 +1008,15 @@ func (a *App) runScript(ctx context.Context, args []string, inline bool) error { "MCPX_INPUT": inputReport(a.stdoutOverride == nil), }, } + // #1975: propagate the resolved daemon Bearer [REDACTED] to scripts, so the + // generated client's TRANSPORT wrapper can authenticate. Resolved from + // the CLI's own settings rather than the environment alone, so a + // config-file token reaches scripts too. Unset, no variable is set: + // os.Environ() inheritance in the runner already covers an env-sourced + // token, and scripts stay byte-for-byte as before. + if tok := daemon.BearerToken(a.Settings()); tok != "" { + opts.Env["MCPX_TRANSPORT_BEARER_TOKEN"] = tok + } opts.TypeCheck = shape.typecheck opts.Imports = shape.imports opts.ImportsOnDynamic = shape.importsOnDynamic diff --git a/pkgs/mcpx/internal/codegen/emit.go b/pkgs/mcpx/internal/codegen/emit.go index 3323c15c2..4614a06bb 100644 --- a/pkgs/mcpx/internal/codegen/emit.go +++ b/pkgs/mcpx/internal/codegen/emit.go @@ -355,6 +355,26 @@ const makeFetcher = (): { fetch: Fetcher; via: string } => { const TRANSPORT = makeFetcher(); +/** + * #1975: when the daemon requires a Bearer [REDACTED] (transport.bearerToken), + * every call through TRANSPORT carries it. The token is read from the + * script's environment (MCPX_TRANSPORT_BEARER_TOKEN), which the CLI runner + * populates from its own resolved settings -- a config-file token reaches + * scripts that way too, not only an env-sourced one. Unset, fetch is + * untouched: requests are byte-for-byte what they were before the setting + * existed. Wrapping here covers every call site (/v1/call, /v1/batch, + * /v1/resource, /v1/artifacts) through the one choke point. + */ +const DAEMON_TOKEN = env("MCPX_TRANSPORT_BEARER_TOKEN"); +if (DAEMON_TOKEN) { + const direct = TRANSPORT.fetch; + TRANSPORT.fetch = (u, i) => { + const headers = new Headers((i as any)?.headers); + headers.set("Authorization", "Bearer " + DAEMON_TOKEN); + return direct(u, { ...(i as any), headers }); + }; +} + /** Where calls are going, for a script that wants to know. */ export const transport = (): string => TRANSPORT.via; diff --git a/pkgs/mcpx/internal/daemon/bearer_test.go b/pkgs/mcpx/internal/daemon/bearer_test.go index 18bdacd3c..b987c361c 100644 --- a/pkgs/mcpx/internal/daemon/bearer_test.go +++ b/pkgs/mcpx/internal/daemon/bearer_test.go @@ -88,3 +88,20 @@ func TestValidateBearerToken(t *testing.T) { t.Errorf("error leaks the token value: %v", err) } } + +func TestTakeoverRequest(t *testing.T) { + // Unset: byte-for-byte the request the successor always wrote. + got := takeoverRequest("") + want := "POST " + takeoverRoute + " HTTP/1.1\r\nHost: mcpx\r\nContent-Length: 0\r\n\r\n" + if got != want { + t.Fatalf("unset rendering changed:\n%q\nwant:\n%q", got, want) + } + // Set: the predecessor's gate protects /v1/takeover too, so the + // successor presents the token. + got = takeoverRequest(fixtureToken) + want = "POST " + takeoverRoute + " HTTP/1.1\r\nHost: mcpx\r\nContent-Length: 0\r\n" + + "Authorization: Bearer " + fixtureToken + "\r\n\r\n" + if got != want { + t.Fatalf("set rendering wrong:\n%q\nwant:\n%q", got, want) + } +} diff --git a/pkgs/mcpx/internal/daemon/routes_proxy.go b/pkgs/mcpx/internal/daemon/routes_proxy.go index b973b80e3..ccc9d39dd 100644 --- a/pkgs/mcpx/internal/daemon/routes_proxy.go +++ b/pkgs/mcpx/internal/daemon/routes_proxy.go @@ -150,7 +150,13 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { if apiKey == "" { apiKey = os.Getenv("OPENAI_API_KEY") } - if authHdr := r.Header.Get("Authorization"); authHdr != "" { + // #1975: with daemon bearer auth on, the Authorization header is the + // daemon credential the gate just verified -- not the caller's upstream + // key. The configured upstream key stands; otherwise the daemon token + // would be forwarded to the provider (and the provider key would fail + // the gate). Unset, a caller-supplied header still selects the key, + // exactly as before. + if authHdr := r.Header.Get("Authorization"); authHdr != "" && BearerToken(s.set) == "" { apiKey = strings.TrimPrefix(authHdr, "Bearer ") } diff --git a/pkgs/mcpx/internal/daemon/routes_proxy_test.go b/pkgs/mcpx/internal/daemon/routes_proxy_test.go index a970193a0..6866ac6d8 100644 --- a/pkgs/mcpx/internal/daemon/routes_proxy_test.go +++ b/pkgs/mcpx/internal/daemon/routes_proxy_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/dezren39/mcpx/internal/logstore" + "github.com/dezren39/mcpx/internal/settings" ) func TestHandleChatCompletionsSemanticToolInjection(t *testing.T) { @@ -80,6 +81,60 @@ func TestHandleChatCompletionsSemanticToolInjection(t *testing.T) { } } +func TestProxyKeepsUpstreamKeyWhenBearerSet(t *testing.T) { + // #1975: with daemon bearer auth on, the Authorization header the + // gate verified is the daemon credential -- the upstream must still + // get the configured provider key, not the daemon token. Fixture + // values only, never real credentials. + const providerKey = "provider-key-fixture-0123456789ab" + var gotAuth string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-mock", + "choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "ok"}}}, + }) + })) + defer upstream.Close() + + sch, err := settings.New(settings.Registry()) + if err != nil { + t.Fatal(err) + } + // NOTE: proxy.maxTools and proxy.upstreamUrl are not declared in the + // settings schema (pre-existing gap: a schema-built set without these + // overrides panics on the typed readers in handleChatCompletions -- + // filed as a follow-up). WithOverrides resolves undeclared paths, so + // the test pins exactly the values under test. + set := settings.NewSet(sch).WithOverrides(map[string]string{ + BearerTokenSetting: fixtureToken, + "proxy.apiKey": providerKey, + "proxy.maxTools": "5", + "proxy.upstreamUrl": "", + }, "test") + reg := &Registry{ + testTools: []ToolInfo{ + {Namespace: "browser", Tool: "click", Function: "browser.click", Description: "Click button or link on web page"}, + }, + } + s := &Server{reg: reg, set: set} + + reqBody := []byte(`{"model": "gpt-4o", "messages": [{"role": "user", "content": "click the button"}]}`) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(reqBody)) + req.Header.Set("X-Upstream-Url", upstream.URL) + req.Header.Set("Authorization", "Bearer "+fixtureToken) + w := httptest.NewRecorder() + + s.handleChatCompletions(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if want := "Bearer " + providerKey; gotAuth != want { + t.Fatalf("upstream got %q, want provider key %q (daemon token must not leak upstream)", gotAuth, want) + } +} + func TestHandleChatCompletionsUpstreamProxy(t *testing.T) { // Create mock upstream LLM server upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkgs/mcpx/internal/daemon/takeover.go b/pkgs/mcpx/internal/daemon/takeover.go index b0d1f71d7..395d5a087 100644 --- a/pkgs/mcpx/internal/daemon/takeover.go +++ b/pkgs/mcpx/internal/daemon/takeover.go @@ -575,6 +575,19 @@ func (in *takeoverIn) close() { // It returns once the predecessor has committed and the HTTP listeners are // serving. Until then the predecessor is unchanged, and a failure leaves it // running while this process reports the reason. +// takeoverRequest renders the raw HTTP takeover request the successor +// writes to the predecessor's socket. #1975: with bearer auth on, the +// predecessor's gate protects /v1/takeover too, so the successor must +// present the configured token or the handover is refused 401. Unset, +// the request is byte-for-byte what it always was. +func takeoverRequest(tok string) string { + req := "POST " + takeoverRoute + " HTTP/1.1\r\nHost: mcpx\r\nContent-Length: 0\r\n" + if tok != "" { + req += "Authorization: Bearer " + tok + "\r\n" + } + return req + "\r\n" +} + func (s *Server) TakeOver() (*Handover, error) { // Takeover is a bind-side lifecycle too (#1886, Codex on PR // #2561): a successor started with MCPX_SOCKET takes over the @@ -594,7 +607,7 @@ func (s *Server) TakeOver() (*Handover, error) { return nil, err } _ = uc.SetDeadline(time.Now().Add(timeout)) - if _, err := io.WriteString(uc, "POST "+takeoverRoute+" HTTP/1.1\r\nHost: mcpx\r\nContent-Length: 0\r\n\r\n"); err != nil { + if _, err := io.WriteString(uc, takeoverRequest(BearerToken(s.set))); err != nil { return fail(err) } // The predecessor sends nothing after its headers until it has our hello, diff --git a/pkgs/mcpx/plugin/opencode/mcpx/daemon.test.ts b/pkgs/mcpx/plugin/opencode/mcpx/daemon.test.ts index 0dc881c9d..ea0333dd5 100644 --- a/pkgs/mcpx/plugin/opencode/mcpx/daemon.test.ts +++ b/pkgs/mcpx/plugin/opencode/mcpx/daemon.test.ts @@ -16,7 +16,9 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { + DaemonClient, discover, + withDaemonAuth, goDurationMs, liveCandidates, rank, @@ -433,3 +435,47 @@ describe("goDurationMs", () => { expect(goDurationMs("-30s")).toBeUndefined() }) }) + +describe("withDaemonAuth (#1975)", () => { + const TOKEN = "fixture-token-0123456789abcdef01" + const realFetch = globalThis.fetch + const realToken = process.env.MCPX_TRANSPORT_BEARER_TOKEN + const seen: RequestInit[] = [] + + const stubFetch = () => { + seen.length = 0 + ;(globalThis as any).fetch = async (_url: any, init: any) => { + seen.push(init ?? {}) + return new Response("{}") + } + } + + afterEach(() => { + ;(globalThis as any).fetch = realFetch + if (realToken === undefined) delete process.env.MCPX_TRANSPORT_BEARER_TOKEN + else process.env.MCPX_TRANSPORT_BEARER_TOKEN = realToken + }) + + test("raw attaches the daemon Bearer [REDACTED] when configured", async () => { + stubFetch() + process.env.MCPX_TRANSPORT_BEARER_TOKEN = TOKEN + const c = new DaemonClient({ kind: "url", base: "http://127.0.0.1:1" }) + await c.raw("/v1/status") + expect(new Headers(seen[0].headers as any).get("Authorization")).toBe(`Bearer ${TOKEN}`) + }) + + test("raw sends no Authorization header when unset", async () => { + stubFetch() + delete process.env.MCPX_TRANSPORT_BEARER_TOKEN + const c = new DaemonClient({ kind: "url", base: "http://127.0.0.1:1" }) + await c.raw("/v1/status") + expect(new Headers(seen[0].headers as any).get("Authorization")).toBeNull() + }) + + test("an existing Authorization header is replaced, not duplicated", async () => { + stubFetch() + process.env.MCPX_TRANSPORT_BEARER_TOKEN = TOKEN + const out = withDaemonAuth({ headers: { Authorization: "Bearer stale" } }) + expect(new Headers(out.headers as any).get("Authorization")).toBe(`Bearer ${TOKEN}`) + }) +}) diff --git a/pkgs/mcpx/plugin/opencode/mcpx/daemon.ts b/pkgs/mcpx/plugin/opencode/mcpx/daemon.ts index 86be6b32e..703dea7e6 100644 --- a/pkgs/mcpx/plugin/opencode/mcpx/daemon.ts +++ b/pkgs/mcpx/plugin/opencode/mcpx/daemon.ts @@ -380,6 +380,21 @@ const withTimeout = (ms: number): RequestInit => { } } +/** + * #1975: attach the daemon Bearer [REDACTED] when one is configured + * (MCPX_TRANSPORT_BEARER_TOKEN, the transport.bearerToken setting + * env-sourced). Unset, the init passes through untouched, so plugin calls + * are byte-for-byte what they were before the setting existed. Read at + * call time so tests can set and clear the variable around a call. + */ +export const withDaemonAuth = (init: RequestInit): RequestInit => { + const tok = (globalThis as any).process?.env?.MCPX_TRANSPORT_BEARER_TOKEN ?? "" + if (!tok) return init + const headers = new Headers(init.headers as any) + headers.set("Authorization", `Bearer ${tok}`) + return { ...init, headers } +} + /** * A client that reuses its connection. * @@ -414,7 +429,7 @@ export class DaemonClient { /** The raw response, for the few callers that need the status code. */ async raw(path: string, init: RequestInit = {}): Promise { - return fetch(this.url(path), this.init(init)) + return fetch(this.url(path), this.init(withDaemonAuth(init))) } async get(path: string, timeoutMs?: number): Promise { From 10e52c40bbdc73793a0877322b5012eba4eb39ca Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Fri, 30 Oct 2026 07:35:30 -0500 Subject: [PATCH 3/7] mcpx: authorize the remaining CLI request paths (Codex P2 on #1975) Client.stream (exec.go), Client.streamOp (opcmd.go) and the direct requests in feedback.go (3) and embeddings.go (3) built daemon requests that reached Do() without authorize(), so they 401'd whenever transport.bearerToken was configured. All eight sites now call the shared authorize() helper before sending. TestEveryDaemonRequestIsAuthorized (bearer_test.go) scans the package and fails if any request-construction site reaches Do() unauthorized again. go test ./internal/cli/ passes on the laptop. --- pkgs/mcpx/internal/cli/bearer_test.go | 45 +++++++++++++++++++++++++++ pkgs/mcpx/internal/cli/embeddings.go | 3 ++ pkgs/mcpx/internal/cli/exec.go | 1 + pkgs/mcpx/internal/cli/feedback.go | 3 ++ pkgs/mcpx/internal/cli/opcmd.go | 1 + 5 files changed, 53 insertions(+) diff --git a/pkgs/mcpx/internal/cli/bearer_test.go b/pkgs/mcpx/internal/cli/bearer_test.go index dacb1e906..aeb4785be 100644 --- a/pkgs/mcpx/internal/cli/bearer_test.go +++ b/pkgs/mcpx/internal/cli/bearer_test.go @@ -2,6 +2,9 @@ package cli import ( "net/http" + "os" + "path/filepath" + "strings" "testing" "github.com/dezren39/mcpx/internal/daemon" @@ -43,3 +46,45 @@ func TestClientAuthorize(t *testing.T) { t.Errorf("Authorization = %q with no token configured, want no header", got) } } + +// TestEveryDaemonRequestIsAuthorized pins the Codex P2 on PR #2711: +// authorizing open()/Stream() was not enough — Client.stream (exec.go), +// Client.streamOp (opcmd.go) and the direct requests in feedback.go and +// embeddings.go each built daemon requests that reached Do() without +// authorize(), so they 401'd whenever transport.bearerToken was set. +// Every request-construction site in this package must call authorize() +// before the request is sent; a new unauthenticated path fails here. +func TestEveryDaemonRequestIsAuthorized(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(string(data), "\n") + for i, ln := range lines { + if !strings.Contains(ln, "http.NewRequestWithContext") { + continue + } + found := false + for j := i + 1; j < len(lines) && j <= i+20; j++ { + if strings.Contains(lines[j], ".Do(") { + break + } + if strings.Contains(lines[j], "authorize(") { + found = true + break + } + } + if !found { + t.Errorf("%s:%d: daemon request created without authorize() before it is sent", file, i+1) + } + } + } +} diff --git a/pkgs/mcpx/internal/cli/embeddings.go b/pkgs/mcpx/internal/cli/embeddings.go index 2fb301552..1a451d869 100644 --- a/pkgs/mcpx/internal/cli/embeddings.go +++ b/pkgs/mcpx/internal/cli/embeddings.go @@ -39,6 +39,7 @@ func (a *App) CmdEmbeddings(ctx context.Context, args []string) error { httpReq, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(ep, "/")+"/v1/embeddings", bytes.NewReader(reqBody)) if err == nil { httpReq.Header.Set("Content-Type", "application/json") + c.authorize(httpReq) resp, err := http.DefaultClient.Do(httpReq) if err == nil && resp.StatusCode == http.StatusOK { defer resp.Body.Close() @@ -91,6 +92,7 @@ func (a *App) CmdEmbeddings(ctx context.Context, args []string) error { if err != nil { return err } + c.authorize(httpReq) resp, err := http.DefaultClient.Do(httpReq) if err != nil { return err @@ -141,6 +143,7 @@ func (a *App) CmdEmbeddings(ctx context.Context, args []string) error { return err } httpReq.Header.Set("Content-Type", "application/json") + c.authorize(httpReq) resp, err := http.DefaultClient.Do(httpReq) if err != nil { return err diff --git a/pkgs/mcpx/internal/cli/exec.go b/pkgs/mcpx/internal/cli/exec.go index 145db88a4..5f07e86d2 100644 --- a/pkgs/mcpx/internal/cli/exec.go +++ b/pkgs/mcpx/internal/cli/exec.go @@ -466,6 +466,7 @@ func (c *Client) stream(ctx context.Context, method, path string, body any, acce // the work does. The client's own timeout would cut it off mid-run. hc := *c.hc hc.Timeout = 0 + c.authorize(req) resp, err := hc.Do(req) if err != nil { if isDialErr(err) { diff --git a/pkgs/mcpx/internal/cli/feedback.go b/pkgs/mcpx/internal/cli/feedback.go index 719497d61..8ccec7176 100644 --- a/pkgs/mcpx/internal/cli/feedback.go +++ b/pkgs/mcpx/internal/cli/feedback.go @@ -74,6 +74,7 @@ func (a *App) CmdFeedback(ctx context.Context, args []string) error { } httpReq.Header.Set("Content-Type", "application/json") + c.authorize(httpReq) resp, err := http.DefaultClient.Do(httpReq) if err != nil { return err @@ -138,6 +139,7 @@ func (a *App) CmdInteractions(ctx context.Context, args []string) error { if err != nil { return err } + c.authorize(httpReq) resp, err := http.DefaultClient.Do(httpReq) if err != nil { return err @@ -229,6 +231,7 @@ func (a *App) CmdInteractions(ctx context.Context, args []string) error { if err != nil { return err } + c.authorize(httpReq) resp, err := http.DefaultClient.Do(httpReq) if err != nil { return err diff --git a/pkgs/mcpx/internal/cli/opcmd.go b/pkgs/mcpx/internal/cli/opcmd.go index 9ff2d34e0..ba17f6b1b 100644 --- a/pkgs/mcpx/internal/cli/opcmd.go +++ b/pkgs/mcpx/internal/cli/opcmd.go @@ -539,6 +539,7 @@ func (c *Client) streamOp(ctx context.Context, method, path string, body []byte, req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "text/event-stream") + c.authorize(req) resp, err := (&http.Client{Transport: c.hc.Transport}).Do(req) if err != nil { if isDialErr(err) { From 55681826051f480aedcc41324fd76048f544d2f8 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Fri, 30 Oct 2026 08:13:06 -0500 Subject: [PATCH 4/7] mcpx: declare MCPX_TRANSPORT_BEARER_TOKEN in ScriptEnv (#1975) --- pkgs/mcpx/docs/environment.md | 1 + pkgs/mcpx/internal/settings/scriptenv.go | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/pkgs/mcpx/docs/environment.md b/pkgs/mcpx/docs/environment.md index abdd001ff..c7d125dd3 100644 --- a/pkgs/mcpx/docs/environment.md +++ b/pkgs/mcpx/docs/environment.md @@ -105,6 +105,7 @@ and read by name for that reason. | `MCPX_HOOK_EVENT_URI` | the declared-hook runner, for a hook it runs | empty when the event names no resource | the resource URI the event is about | the hook itself | | `MCPX_ENDPOINT` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`); an event consumer | always | the daemon's HTTP endpoint | the generated client; the plugin as a fallback for MCPX_DAEMON_ENDPOINT | | `MCPX_SOCKET` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`); the plugin loader, for a discovered plugin | empty when the daemon is remote, since its socket is not reachable from here | the daemon's unix socket, which is faster than the endpoint. Also where a daemon binds and a client dials, for anyone who sets it by hand | the generated client, `mcpx` run inside the script or shell, the plugin | +| `MCPX_TRANSPORT_BEARER_TOKEN` | `mcpx exec` / `mcpx run` | only when transport.bearerToken is configured | the daemon's bearer token, handed to the script so the generated client can authenticate to a daemon that requires it (#1975); the token itself stays out of the generated code | the generated client, `mcpx` run inside the script or shell, the plugin; also the setting `transport.bearerToken` | | `MCPX_ARTIFACTS_LOCAL` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`) | always | 1 when the daemon shares this filesystem, so artifact({path}) may hand over a path instead of the bytes | the generated client | | `MCPX_CWD` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`) | the exec service only when the caller sent its working directory | the directory the caller ran mcpx from, which a remote run cannot discover | the generated client (paths.cwd, and cwd scoping) | | `MCPX_PID` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`) | always | the pid of the mcpx process that started the script | the generated client, for pid scoping | diff --git a/pkgs/mcpx/internal/settings/scriptenv.go b/pkgs/mcpx/internal/settings/scriptenv.go index 7329e999b..80046c509 100644 --- a/pkgs/mcpx/internal/settings/scriptenv.go +++ b/pkgs/mcpx/internal/settings/scriptenv.go @@ -208,6 +208,16 @@ func ScriptEnv() []EnvVar { "Also where a daemon binds and a client dials, for anyone who sets it by hand", ReadBy: client + ", " + nested + ", the plugin", }, + { + Name: "MCPX_TRANSPORT_BEARER_TOKEN", + SetBy: []Setter{SetByCLI}, + When: "only when transport.bearerToken is configured", + Meaning: "the daemon's bearer token, handed to the script so the " + + "generated client can authenticate to a daemon that requires " + + "it (#1975); the token itself stays out of the generated code", + Setting: "transport.bearerToken", + ReadBy: client + ", " + nested + ", the plugin", + }, { Name: "MCPX_ARTIFACTS_LOCAL", SetBy: []Setter{SetByCLI, SetByExec}, From 4b73ecbffdaef700adf8857e650605a7d086ef40 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Fri, 30 Oct 2026 14:56:43 -0500 Subject: [PATCH 5/7] mcpx: pass the resolved bearer token to daemon-hosted scripts (Codex P2 on #1975) --- pkgs/mcpx/internal/daemon/consumer.go | 17 +++++++---- pkgs/mcpx/internal/daemon/routes_exec.go | 10 +++++-- .../internal/execsvc/bearer_internal_test.go | 28 +++++++++++++++++++ pkgs/mcpx/internal/execsvc/execsvc.go | 9 ++++++ 4 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 pkgs/mcpx/internal/execsvc/bearer_internal_test.go diff --git a/pkgs/mcpx/internal/daemon/consumer.go b/pkgs/mcpx/internal/daemon/consumer.go index 93852cc0e..e2b97f87f 100644 --- a/pkgs/mcpx/internal/daemon/consumer.go +++ b/pkgs/mcpx/internal/daemon/consumer.go @@ -557,6 +557,17 @@ func (s *Server) execScript(ctx context.Context, source, session string) (*ExecR rctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + env := map[string]string{ + "MCPX_SESSION": session, + "MCPX_SESSION_ID": session, + "MCPX_ENDPOINT": s.endpoint, + } + if tok := BearerToken(s.set); tok != "" { + // #1975: recipes run against this daemon; with bearer auth on + // they need the resolved token, not only an exported env var. + env["MCPX_TRANSPORT_BEARER_TOKEN"] = tok + } + res, rerr := runner.Run(rctx, runner.Options{ Source: source, Prelude: prelude(nss), @@ -565,11 +576,7 @@ func (s *Server) execScript(ctx context.Context, source, session string) (*ExecR Timeout: timeout, Stdout: &out, Stderr: &errOut, - Env: map[string]string{ - "MCPX_SESSION": session, - "MCPX_SESSION_ID": session, - "MCPX_ENDPOINT": s.endpoint, - }, + Env: env, }) result := &ExecResult{Stdout: out.String(), Stderr: errOut.String()} if rerr != nil { diff --git a/pkgs/mcpx/internal/daemon/routes_exec.go b/pkgs/mcpx/internal/daemon/routes_exec.go index 3781aed2d..8eabe7170 100644 --- a/pkgs/mcpx/internal/daemon/routes_exec.go +++ b/pkgs/mcpx/internal/daemon/routes_exec.go @@ -194,8 +194,14 @@ func (s *Server) ExecService() *execsvc.Service { // 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. - Local: true, - Limits: lim, + Local: true, + // #1975: scripts this daemon hosts must be able to authenticate + // back to it. Hand the service the token this server enforces, + // resolved from the same set the bearer middleware reads, so a + // file- or flag-configured token reaches daemon-hosted scripts + // and not only ones the daemon's environment exported. + BearerToken: BearerToken(s.set), + Limits: lim, // Kept between runs so the runtime's module cache stays warm, and // one directory per catalogue so two configurations cannot // overwrite each other's generated client. diff --git a/pkgs/mcpx/internal/execsvc/bearer_internal_test.go b/pkgs/mcpx/internal/execsvc/bearer_internal_test.go new file mode 100644 index 000000000..b3a49746e --- /dev/null +++ b/pkgs/mcpx/internal/execsvc/bearer_internal_test.go @@ -0,0 +1,28 @@ +package execsvc + +import "testing" + +// #1975 (Codex P2 on PR #2711): a daemon that resolved its bearer token +// from a config file or flag must hand that token to the scripts it +// hosts, or every call the generated client makes back to the daemon +// is a 401. scriptEnv is where that hand-off happens. +func TestScriptEnvCarriesBearerTokenWhenConfigured(t *testing.T) { + const tok = "0123456789abcdef0123456789abcdef" + s := &Service{BearerToken: tok} + env := s.scriptEnv("run-1", "exec-run-1", Options{}) + if got := env["MCPX_TRANSPORT_BEARER_TOKEN"]; got != tok { + t.Errorf("configured: MCPX_TRANSPORT_BEARER_TOKEN = %q, want the resolved token", got) + } + // An explicit caller value in Options.Env wins, as with MCPX_EPHEMERAL. + env = s.scriptEnv("run-2", "exec-run-2", Options{Env: map[string]string{"MCPX_TRANSPORT_BEARER_TOKEN": "caller"}}) + if got := env["MCPX_TRANSPORT_BEARER_TOKEN"]; got != "caller" { + t.Errorf("explicit env: MCPX_TRANSPORT_BEARER_TOKEN = %q, want %q", got, "caller") + } + // No token configured: the key stays absent, so an exported + // environment value (or its absence) is untouched. + s = &Service{} + env = s.scriptEnv("run-3", "exec-run-3", Options{}) + if _, ok := env["MCPX_TRANSPORT_BEARER_TOKEN"]; ok { + t.Errorf("unconfigured: MCPX_TRANSPORT_BEARER_TOKEN present, want absent") + } +} diff --git a/pkgs/mcpx/internal/execsvc/execsvc.go b/pkgs/mcpx/internal/execsvc/execsvc.go index 2bcefae7e..82fe4ea13 100644 --- a/pkgs/mcpx/internal/execsvc/execsvc.go +++ b/pkgs/mcpx/internal/execsvc/execsvc.go @@ -317,6 +317,12 @@ type Service struct { // decides whether artifact({path}) can be registered by link rather than // by transfer, and it is a fact only the caller knows. Local bool + // BearerToken is the daemon's resolved transport.bearerToken (#1975). + // scriptEnv hands it to the script as MCPX_TRANSPORT_BEARER_TOKEN so + // the generated client can authenticate its calls back to the daemon + // when the token was configured by file or flag rather than exported + // in the daemon's own environment. Empty means auth is off. + BearerToken string // Limits are the resolved settings. Limits Limits // WorkDir holds the generated client between runs, so the runtime's @@ -757,6 +763,9 @@ func (s *Service) scriptEnv(runID, session string, opts Options) map[string]stri // run (exec-, or one the CLI minted) is not named here. "MCPX_EPHEMERAL": boolEnv(opts.Session == ""), } + if s.BearerToken != "" { + env["MCPX_TRANSPORT_BEARER_TOKEN"] = s.BearerToken + } if opts.Cwd != "" { env["MCPX_CWD"] = opts.Cwd } From d02a018d6efcab3601566f80f00bc10bcabda369 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Fri, 30 Oct 2026 15:06:06 -0500 Subject: [PATCH 6/7] mcpx: resolve the target daemon's bearer token for record-targeted clients (Codex P2 on #1975) --- pkgs/mcpx/internal/cli/bearer_test.go | 69 +++++++++++++++++++++++++++ pkgs/mcpx/internal/cli/client.go | 62 +++++++++++++++++++++++- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/pkgs/mcpx/internal/cli/bearer_test.go b/pkgs/mcpx/internal/cli/bearer_test.go index aeb4785be..632b8474a 100644 --- a/pkgs/mcpx/internal/cli/bearer_test.go +++ b/pkgs/mcpx/internal/cli/bearer_test.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "net/http" "os" "path/filepath" @@ -88,3 +89,71 @@ func TestEveryDaemonRequestIsAuthorized(t *testing.T) { } } } + +// TestTargetBearerTokenResolvesTheTargetDaemon pins the Codex P2 on +// PR #2711 (round on 4b73ecbf): stop --all and daemons build clients +// from each daemon record's ConfigPath, but the client presented the +// invoking project's token, so a daemon configured with its own +// file token 401'd the probe and was skipped / reported stopped. +func TestTargetBearerTokenResolvesTheTargetDaemon(t *testing.T) { + const targetTok = "target-token-0123456789abcdef0123" + const invokerTok = "invoker-token-0123456789abcdef01" + write := func(name, tok string) string { + path := filepath.Join(t.TempDir(), name) + doc := map[string]any{"transport": map[string]any{"bearerToken": tok}} + b, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + return path + } + target := write("target.json", targetTok) + invoker := write("invoker.json", invokerTok) + + sch, err := settings.New(settings.Registry()) + if err != nil { + t.Fatal(err) + } + own := settings.NewSet(sch) + doc, err := readJSONFile(invoker) + if err != nil { + t.Fatal(err) + } + if err := sch.ApplyFile(own, doc, invoker, 0); err != nil { + t.Fatal(err) + } + if got := daemon.BearerToken(own); got != invokerTok { + t.Fatalf("setup: invoker set token = %q, want the invoker fixture", got) + } + + // The invoking project's file token must not be presented to the + // target daemon; the target's own file token is the credential. + tok, ok := targetBearerToken(target, own) + if !ok || tok != targetTok { + t.Errorf("targetBearerToken = %q, %v; want the target daemon's token", tok, ok) + } + + // An invocation-scoped (runtime-layer) token still wins: it is + // the token this process was told to use. + scoped := settings.NewSet(sch).WithOverrides( + map[string]string{daemon.BearerTokenSetting: invokerTok}, "test") + tok, ok = targetBearerToken(target, scoped) + if !ok || tok != invokerTok { + t.Errorf("scoped targetBearerToken = %q, %v; want the invocation token", tok, ok) + } + + // And a client built for the recorded daemon authorizes with the + // target token end to end. + c := NewClientAt(daemon.Paths{}, target, "unix:///nonexistent") + req, err := http.NewRequest(http.MethodGet, "http://mcpx/v1/log", nil) + if err != nil { + t.Fatal(err) + } + c.authorize(req) + if got := req.Header.Get("Authorization"); got != "Bearer "+targetTok { + t.Errorf("record-targeted Authorization = %q, want the target token", got) + } +} diff --git a/pkgs/mcpx/internal/cli/client.go b/pkgs/mcpx/internal/cli/client.go index 1c18da697..f0a8bb735 100644 --- a/pkgs/mcpx/internal/cli/client.go +++ b/pkgs/mcpx/internal/cli/client.go @@ -66,6 +66,13 @@ type Client struct { resolveSocket bool // set is this process's resolved configuration. set *settings.Set + // authToken, when authResolved is true, is the bearer token of THIS + // client's target daemon, resolved from the target's own + // configuration (targetBearerToken). Record-targeted clients + // (stop --all, daemons) talk to daemons other projects configured; + // their token is that daemon's, not the invoking project's. + authToken string + authResolved bool // callSettings is the JSON header carrying whatever call-scoped settings // this invocation actually chose. Computed once: it cannot change within // a run, and recomputing it per request would put a schema walk on the @@ -125,11 +132,61 @@ func callHeader(set *settings.Set) string { // a caller and its daemon agree by construction. Unset, no header -- // the request is byte-for-byte what it was before the setting existed. func (c *Client) authorize(req *http.Request) { - if tok := daemon.BearerToken(c.set); tok != "" { + tok := daemon.BearerToken(c.set) + if c.authResolved { + tok = c.authToken + } + if tok != "" { req.Header.Set("Authorization", "Bearer "+tok) } } +// targetBearerToken resolves the bearer token of the daemon a client +// targets through configPath (#1975, Codex on PR #2711). Clients built +// for a recorded daemon -- stop --all, daemons -- construct through +// NewClientAt with that daemon's config path, but a token resolved +// from the invoking project's settings is the wrong credential for +// another project's daemon: the probe 401s, stop silently skips a +// live daemon, and daemons reports it stopped. +// +// Resolution mirrors the daemon's own: the target's configuration +// files, then the environment. An invocation-scoped token in the +// client's own settings (environment, preset, flag or runtime layer +// -- anything but a file) still wins: it is the token this process +// was told to use. A token only the invoking project's file carries +// is NOT sent: the target daemon never saw it. +// +// Best effort by design: an unreadable target file resolves to no +// token rather than failing a fleet-wide listing or stop. +func targetBearerToken(configPath string, own *settings.Set) (string, bool) { + if v, ok := own.Value(daemon.BearerTokenSetting); ok && + v.Origin.Layer != settings.LayerFile && v.Origin.Layer != settings.LayerDefault { + return v.Raw, true + } + sch, err := settings.New(settings.Registry()) + if err != nil { + return "", false + } + set := settings.NewSet(sch) + files, ferr := configFilesFarthestFirst(configPath) + if ferr != nil { + return "", false + } + for i, path := range files { + doc, rerr := readJSONFile(path) + if rerr != nil { + continue + } + if aerr := sch.ApplyFile(set, doc, path, i); aerr != nil { + return "", false + } + } + if eerr := sch.ApplyEnv(set, settings.Environ()); eerr != nil { + return "", false + } + return daemon.BearerToken(set), true +} + // NewClient builds a socket-backed API client. func NewClient(paths daemon.Paths, configPath string) *Client { return NewClientAt(paths, configPath, "") @@ -150,6 +207,9 @@ func NewClientAt(paths daemon.Paths, configPath, endpoint string) *Client { c := &Client{paths: paths, cfg: configPath, set: set, callSettings: callHeader(set), endpoint: strings.TrimRight(endpoint, "/")} + if configPath != "" { + c.authToken, c.authResolved = targetBearerToken(configPath, set) + } timeout := set.Duration("http.requestTimeout") idle := set.Duration("http.idleConnTimeout") From 663dbf8a07700f4a2d471effecdb00496db9f03f Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Fri, 30 Oct 2026 15:14:46 -0500 Subject: [PATCH 7/7] mcpx: resolve the target daemon's token through its config chain, not only its nearest file (Codex P2 on #1975) --- pkgs/mcpx/internal/cli/bearer_test.go | 25 ++++++++++++++ pkgs/mcpx/internal/cli/client.go | 47 ++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/pkgs/mcpx/internal/cli/bearer_test.go b/pkgs/mcpx/internal/cli/bearer_test.go index 632b8474a..6526717bb 100644 --- a/pkgs/mcpx/internal/cli/bearer_test.go +++ b/pkgs/mcpx/internal/cli/bearer_test.go @@ -145,6 +145,31 @@ func TestTargetBearerTokenResolvesTheTargetDaemon(t *testing.T) { t.Errorf("scoped targetBearerToken = %q, %v; want the invocation token", tok, ok) } + // Inherited token: the recorded file carries no token, but a + // parent directory's .mcpx.json does -- the daemon found by + // discovery inherits it, and so must the client (Codex on + // d02a018d). + parent := t.TempDir() + sub := filepath.Join(parent, "sub") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + docB, err := json.Marshal(map[string]any{"transport": map[string]any{"bearerToken": targetTok}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent, ".mcpx.json"), docB, 0o600); err != nil { + t.Fatal(err) + } + near := filepath.Join(sub, ".mcpx.json") + if err := os.WriteFile(near, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + empty := settings.NewSet(sch) + if tok, ok := targetBearerToken(near, empty); !ok || tok != targetTok { + t.Errorf("inherited targetBearerToken = %q, %v; want the parent config's token", tok, ok) + } + // And a client built for the recorded daemon authorizes with the // target token end to end. c := NewClientAt(daemon.Paths{}, target, "unix:///nonexistent") diff --git a/pkgs/mcpx/internal/cli/client.go b/pkgs/mcpx/internal/cli/client.go index f0a8bb735..eeaea1ece 100644 --- a/pkgs/mcpx/internal/cli/client.go +++ b/pkgs/mcpx/internal/cli/client.go @@ -16,6 +16,7 @@ import ( "net/url" "os" "os/exec" + "path/filepath" "strconv" "strings" "sync" @@ -150,11 +151,16 @@ func (c *Client) authorize(req *http.Request) { // live daemon, and daemons reports it stopped. // // Resolution mirrors the daemon's own: the target's configuration -// files, then the environment. An invocation-scoped token in the -// client's own settings (environment, preset, flag or runtime layer -// -- anything but a file) still wins: it is the token this process -// was told to use. A token only the invoking project's file carries -// is NOT sent: the target daemon never saw it. +// CHAIN, then the environment. The chain matters: a daemon started +// with ordinary discovery inherits the setting from a parent or user +// config when its nearest file does not carry it, so resolving only +// the recorded file would miss the inherited token and 401 the +// daemon's own ordinary clients (Codex on PR #2711, round on +// d02a018d). An invocation-scoped token in the client's own settings +// (environment, preset, flag or runtime layer -- anything but a +// file) still wins: it is the token this process was told to use. A +// token only the invoking project's file carries is NOT sent: the +// target daemon never saw it. // // Best effort by design: an unreadable target file resolves to no // token rather than failing a fleet-wide listing or stop. @@ -168,10 +174,7 @@ func targetBearerToken(configPath string, own *settings.Set) (string, bool) { return "", false } set := settings.NewSet(sch) - files, ferr := configFilesFarthestFirst(configPath) - if ferr != nil { - return "", false - } + files := targetConfigFiles(configPath) for i, path := range files { doc, rerr := readJSONFile(path) if rerr != nil { @@ -187,6 +190,32 @@ func targetBearerToken(configPath string, own *settings.Set) (string, bool) { return daemon.BearerToken(set), true } +// targetConfigFiles is the farthest-first file list a target daemon's +// settings resolve from: the discovery chain of the recorded file's +// directory (the walk the daemon itself was found by), with the +// recorded file appended as the nearest layer when discovery cannot +// see it (an MCPX_CONFIG bypass names a file outside the walk). +func targetConfigFiles(configPath string) []string { + var files []string + if chain, _, err := config.ResolveChainFrom(filepath.Dir(configPath)); err == nil { + for i := len(chain) - 1; i >= 0; i-- { + if _, serr := os.Stat(chain[i]); serr == nil { + files = append(files, chain[i]) + } + } + } + found := false + for _, f := range files { + if f == configPath { + found = true + } + } + if !found { + files = append(files, configPath) + } + return files +} + // NewClient builds a socket-backed API client. func NewClient(paths daemon.Paths, configPath string) *Client { return NewClientAt(paths, configPath, "")