diff --git a/.naming-allowlist b/.naming-allowlist
index ac58d4a91..47a35f827 100644
--- a/.naming-allowlist
+++ b/.naming-allowlist
@@ -21,3 +21,5 @@ keyring(bcq
legacy bcq
# RELEASING.md — actual GitHub App name
bcq-release-bot
+# npm integrity hashes are base64 and can contain any letters
+./internal/connector/driver/acp/adapters/package-lock.json
diff --git a/.surface b/.surface
index 2df0a4638..4ba67ade4 100644
--- a/.surface
+++ b/.surface
@@ -5345,6 +5345,7 @@ FLAG basecamp config untrust --styled type=bool
FLAG basecamp config untrust --todolist type=string
FLAG basecamp config untrust --verbose type=count
FLAG basecamp connect --account type=string
+FLAG basecamp connect --acp-adapters type=string
FLAG basecamp connect --agent type=bool
FLAG basecamp connect --cache-dir type=string
FLAG basecamp connect --count type=bool
diff --git a/Makefile b/Makefile
index 1636023cf..965be15a7 100644
--- a/Makefile
+++ b/Makefile
@@ -130,6 +130,33 @@ qa-report:
echo ""; \
fi
+# The connector's acp driver runs pinned ACP adapters, installed here once by
+# an operator and never downloaded at dispatch time.
+# Where basecamp connect looks by default: an absolute $XDG_DATA_HOME, else
+# ~/.local/share (a relative XDG_DATA_HOME is ignored there too).
+ACP_ADAPTERS_DIR ?= $(if $(filter /%,$(XDG_DATA_HOME)),$(XDG_DATA_HOME),$(HOME)/.local/share)/basecamp/acp-adapters
+
+# Install the pinned ACP adapters (internal/connector/driver/acp/adapters).
+# --engine-strict: an adapter whose Node version requirement this machine does
+# not meet fails the install, not the first dispatch.
+.PHONY: acp-adapters
+acp-adapters:
+ @mkdir -p "$(ACP_ADAPTERS_DIR)"
+ cp internal/connector/driver/acp/adapters/package.json internal/connector/driver/acp/adapters/package-lock.json "$(ACP_ADAPTERS_DIR)/"
+ npm ci --prefix "$(ACP_ADAPTERS_DIR)" --ignore-scripts --no-audit --no-fund --engine-strict
+
+# The ACP adapter-compatibility test: eight checks through the acp driver
+# against each installed adapter (the spike's four, the worker shell's
+# environment, a decoy MCP server in the working directory, the task
+# token's bridge, and what an adapter does when a session's MCP server dies
+# mid-session). Sends real prompts (model quota); skipped
+# for an adapter that is not installed. ACP_TRANSCRIPTS=
keeps redacted
+# JSON-RPC transcripts.
+.PHONY: test-acp-compat
+test-acp-compat: check-toolchain
+ BASECAMP_ACP_ADAPTERS_DIR="$(ACP_ADAPTERS_DIR)" BASECAMP_ACP_TRANSCRIPTS="$(ACP_TRANSCRIPTS)" \
+ $(GOTEST) -tags acpcompat -run TestAdapterCompat -count=1 -timeout 30m -v ./internal/connector/driver/acp/
+
# Run tests with race detector
.PHONY: race-test
race-test: check-toolchain
@@ -294,6 +321,9 @@ provenance-check:
.PHONY: vet
vet: check-toolchain
$(GOVET) $(BUILD_TAGS) ./...
+ @# The adapter-compatibility test builds only with its own tag, so
+ @# nothing else would notice it rotting.
+ $(GOVET) -tags acpcompat ./internal/connector/driver/acp/
# Format code
.PHONY: fmt
diff --git a/internal/commands/connect_run.go b/internal/commands/connect_run.go
index 07b58fbf1..70c7ed2d3 100644
--- a/internal/commands/connect_run.go
+++ b/internal/commands/connect_run.go
@@ -25,6 +25,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/connector"
"github.com/basecamp/basecamp-cli/internal/connector/admission"
"github.com/basecamp/basecamp-cli/internal/connector/driver"
+ "github.com/basecamp/basecamp-cli/internal/connector/driver/acp"
"github.com/basecamp/basecamp-cli/internal/connector/driver/spawn"
"github.com/basecamp/basecamp-cli/internal/connector/ndjson"
"github.com/basecamp/basecamp-cli/internal/connector/setup"
@@ -38,6 +39,7 @@ type connectRunFlags struct {
shadow bool
since int64
driver string
+ adapters string
}
func addConnectRunFlags(cmd *cobra.Command, f *connectRunFlags) {
@@ -47,7 +49,8 @@ func addConnectRunFlags(cmd *cobra.Command, f *connectRunFlags) {
fl.Var((*repeatedString)(&f.projects), "project", "Only hear events in this project id (repeatable; default every project the agent can see)")
fl.BoolVar(&f.shadow, "shadow", false, "Admit and log in an isolated state directory; dispatch and post nothing")
fl.Int64Var(&f.since, "since", 0, "Enter the feed just after this event id, whatever the ledger holds")
- fl.StringVar(&f.driver, "driver", "", "Override connect.json's driver (spawn)")
+ fl.StringVar(&f.driver, "driver", "", "Override connect.json's driver (spawn or acp)")
+ fl.StringVar(&f.adapters, "acp-adapters", "", "Where the pinned ACP adapters are installed, for --driver acp (default $XDG_DATA_HOME/basecamp/acp-adapters)")
}
// connectStateHome is the directory holding the connector's state root, from
@@ -123,6 +126,23 @@ func connectSessionsPath(file setup.File) string {
return filepath.Join(base, "bcc-"+connector.StateDirName(file.AccountID, file.Agent.PersonID))
}
+// connectDriver is the driver connect.json (or --driver) names for its
+// worker. The acp driver runs the worker's pinned ACP adapter, found where it
+// was installed; nothing is downloaded here.
+func connectDriver(name, worker, adaptersDir string) (driver.Driver, error) {
+ if name != setup.DriverACP {
+ return spawn.New(worker, spawn.Options{})
+ }
+ if adaptersDir != "" && !filepath.IsAbs(adaptersDir) {
+ abs, err := filepath.Abs(adaptersDir)
+ if err != nil {
+ return nil, err
+ }
+ adaptersDir = abs
+ }
+ return acp.ForWorker(worker, adaptersDir, nil)
+}
+
func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
if !connectSupportedOS(runtime.GOOS) {
return output.ErrUsage("basecamp connect runs on macOS and Linux only: it ends a crashed connector's workers by process group and start time, which only those two can read")
@@ -165,8 +185,8 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
if f.driver != "" {
driverName = f.driver
}
- if !f.shadow && driverName != setup.DriverSpawn {
- return output.ErrUsage(fmt.Sprintf("driver %q is not available yet; use %q", driverName, setup.DriverSpawn))
+ if !f.shadow && driverName != setup.DriverSpawn && driverName != setup.DriverACP {
+ return output.ErrUsage(fmt.Sprintf("driver %q is not %q or %q", driverName, setup.DriverSpawn, setup.DriverACP))
}
account, err := connectAccount(app, name)
@@ -270,7 +290,7 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
return err
}
routes := newConnectRoutes(path, file, logger)
- worker, err := spawn.New(file.WorkerName(), spawn.Options{})
+ worker, err := connectDriver(driverName, file.WorkerName(), f.adapters)
if err != nil {
return output.ErrUsage(err.Error())
}
diff --git a/internal/commands/connect_run_test.go b/internal/commands/connect_run_test.go
index 1b3403421..23e4eab3d 100644
--- a/internal/commands/connect_run_test.go
+++ b/internal/commands/connect_run_test.go
@@ -16,6 +16,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/connector"
"github.com/basecamp/basecamp-cli/internal/connector/admission"
+ "github.com/basecamp/basecamp-cli/internal/connector/driver/acp"
"github.com/basecamp/basecamp-cli/internal/connector/setup"
)
@@ -193,3 +194,33 @@ func TestTheDoctorCheckReadsTheProfilesConnectorLayout(t *testing.T) {
assert.Equal(t, "warn", check.Status)
assert.Contains(t, check.Hint, "XDG_RUNTIME_DIR", "and says what to do about it")
}
+
+func TestConnectDriverRunsTheWorkersPinnedACPAdapterFromWhereItWasInstalled(t *testing.T) {
+ d, err := connectDriver(setup.DriverSpawn, setup.WorkerClaude, "")
+ require.NoError(t, err)
+ assert.Equal(t, setup.WorkerClaude, d.Name())
+
+ dir := t.TempDir()
+ _, err = connectDriver(setup.DriverACP, setup.WorkerClaude, dir)
+ require.ErrorIs(t, err, acp.ErrAdapterMissing, "an adapter that is not installed is never fetched")
+
+ pkg := filepath.Join(dir, "node_modules", filepath.FromSlash(acp.ClaudeAgentACP.Package))
+ require.NoError(t, os.MkdirAll(pkg, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(pkg, "package.json"),
+ []byte(`{"name":"`+acp.ClaudeAgentACP.Package+`","version":"`+acp.ClaudeAgentACP.Version+`"}`), 0o600))
+ bin := filepath.Join(dir, "node_modules", ".bin")
+ require.NoError(t, os.MkdirAll(bin, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(bin, acp.ClaudeAgentACP.Name), []byte("#!/bin/sh\n"), 0o700))
+ d, err = connectDriver(setup.DriverACP, setup.WorkerClaude, dir)
+ require.NoError(t, err)
+ assert.Equal(t, acp.Name, d.Name())
+
+ // A relative directory is the operator's, from where they run the command.
+ t.Chdir(filepath.Dir(dir))
+ d, err = connectDriver(setup.DriverACP, setup.WorkerClaude, filepath.Base(dir))
+ require.NoError(t, err)
+ assert.Equal(t, acp.Name, d.Name())
+
+ _, err = connectDriver(setup.DriverACP, "nobody", dir)
+ assert.Error(t, err)
+}
diff --git a/internal/connector/driver/acp/acp.go b/internal/connector/driver/acp/acp.go
new file mode 100644
index 000000000..6b5ee1814
--- /dev/null
+++ b/internal/connector/driver/acp/acp.go
@@ -0,0 +1,312 @@
+// Package acp is the connector as an Agent Client Protocol v1 client: one
+// adapter process per session, spoken to over newline-delimited JSON-RPC 2.0
+// on its stdio.
+//
+// A session is opened with initialize, session/new {cwd, mcpServers} (or
+// session/load / session/resume, where the agent advertises them), and put in
+// its adapter's asking mode with session/set_mode before anything is prompted.
+// Prompts are session/prompt; progress is session/update, reduced to kinds,
+// ids and counts; a turn is ended with session/cancel; and every
+// session/request_permission is answered by the connector's policy. The client
+// advertises no fs and no terminal capability, so the agent works through its
+// own tools and asks.
+//
+// # Invariants
+//
+// Beyond the driver package's, each held by a test in this package:
+//
+// 1. The adapter's environment is an allowlist. The adapter process gets
+// SessionConfig.Env plus the variables its Adapter names, by exact name;
+// every MCP server gets exactly its declared MCPServer.Env, sent as
+// mcpServers[].env. Nothing of the connector's own environment is passed
+// by inheritance, so a host token (CLAUDE_CODE_MESSAGING_TOKEN) never
+// reaches the adapter or anything it starts.
+// 2. No session runs outside its asking mode. After session/new or
+// session/load the driver sets the adapter's asking mode and reads the
+// mode back (session/set_config_option's configOptions, or a
+// current_mode_update); a session that does not offer the mode, or does
+// not confirm it, is ended with ErrUnsafeMode before NewSession returns.
+// A later report of any other mode ends the session the same way.
+// 3. Permission answers are chosen by option kind, never by id or label.
+// An allow is allow_once and never allow_always, so no answer outlives
+// the request; a refusal is reject_once (reject_always when that is all
+// that is offered). A request outside a turn, for another session, or
+// before the mode is confirmed is refused.
+// 4. A refusal is the driver's record, not the agent's stop reason. Every
+// refusal of a turn is on its PromptResult; a canceled stop the
+// connector did not ask for is reported as TurnRefusal when the turn had
+// refusals and as an error otherwise, never as TurnCanceled.
+// 5. Load is gated by what the agent advertised at initialize: session/load
+// when loadSession is true, session/resume when sessionCapabilities.resume
+// is present, otherwise an error. Its history replay is not progress.
+// 6. A configuration this driver cannot run — an adapter with no asking mode
+// for the policy's, a policy for another directory, an MCP server without
+// an absolute command, a Codex config that declares MCP servers — is
+// ErrUnusable beside ErrNotStarted: nothing started, and a retry would
+// fail the same way.
+// 7. The adapter is the pinned one: initialize must report protocol version
+// 1 and the Adapter's package and version, or the session is ended.
+// 8. Nothing the agent volunteers is kept: _auth/status_update (which
+// carries the account's email) is dropped unread, updates carry no text,
+// and agent-written text that reaches an error is redacted first.
+// 9. A session runs only on the MCP servers it was given, as far as its
+// adapter says. The adapter's own account of them is read (Claude Code's
+// init, forwarded; codex-acp's startup failures), and a server that did
+// not connect — or, for Claude, a first turn that ends with no init at
+// all — fails the turn with ErrMCPServerNotConnected and ends the worker.
+//
+// Three of these are rules rather than single checks, so each is stated once
+// and held in one place: the MCP isolation boundary in mcp.go, who may decide
+// a permission and on what evidence in permission.go, and what bounds every
+// buffer the driver keeps in limits.go.
+package acp
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "sync/atomic"
+ "time"
+
+ "github.com/basecamp/basecamp-cli/internal/connector/driver"
+)
+
+// Name is the driver's name, as connect.json and the ledger spell it.
+const Name = "acp"
+
+// ProtocolVersion is the ACP version the connector speaks.
+const ProtocolVersion = 1
+
+// Defaults.
+const (
+ DefaultHandshakeTimeout = 2 * time.Minute
+ DefaultCloseGrace = 5 * time.Second
+)
+
+// confirmGroupGone is driver.ConfirmGroupGone; a seam for this package's
+// tests.
+var confirmGroupGone = driver.ConfirmGroupGone
+
+// afterHandshake runs between a handshake returning and the session being
+// handed out. It does nothing; it is where this package's tests stand to
+// claim a failure in exactly that window.
+var afterHandshake = func(*session) {}
+
+// Errors.
+var (
+ // ErrLoadUnsupported is a session/load asked of an agent that advertises
+ // neither loadSession nor session resume.
+ ErrLoadUnsupported = errors.New("acp: the agent advertises neither session/load nor session/resume")
+ // ErrWrongAdapter is an agent that is not the pinned adapter.
+ ErrWrongAdapter = errors.New("acp: the agent is not the pinned adapter")
+)
+
+// Options configures the driver.
+type Options struct {
+ // Adapter is the pinned adapter the driver runs.
+ Adapter Adapter
+ // Binary is the adapter executable, absolute: Locate's answer.
+ Binary string
+ // Args are the adapter's arguments; none for the pinned adapters.
+ Args []string
+ // Lookup reads the connector's environment for Adapter.Env;
+ // os.LookupEnv when nil.
+ Lookup func(string) (string, bool)
+ // HandshakeTimeout bounds initialize, session/new or load, and setting the
+ // mode.
+ HandshakeTimeout time.Duration
+ // CloseGrace is how long the adapter has to exit after its input closes,
+ // and then after SIGTERM, before its process group is killed.
+ CloseGrace time.Duration
+
+ // trace is this package's tests' view of the wire.
+ trace func(dir string, line []byte)
+}
+
+// Driver starts ACP sessions with one adapter.
+type Driver struct {
+ opts Options
+ // loadSession is what the last initialize advertised: 0 unknown, 1 no,
+ // 2 yes.
+ loadSession atomic.Int32
+}
+
+var _ driver.Driver = (*Driver)(nil)
+
+// New builds the driver.
+func New(opts Options) (*Driver, error) {
+ switch {
+ case opts.Adapter.Name == "" || opts.Adapter.Package == "" || opts.Adapter.Version == "":
+ return nil, errors.New("acp: the driver needs a pinned adapter")
+ case !filepath.IsAbs(opts.Binary):
+ return nil, fmt.Errorf("acp: the adapter executable %q is not an absolute path", opts.Binary)
+ }
+ if opts.Lookup == nil {
+ opts.Lookup = os.LookupEnv
+ }
+ if opts.HandshakeTimeout <= 0 {
+ opts.HandshakeTimeout = DefaultHandshakeTimeout
+ }
+ if opts.CloseGrace <= 0 {
+ opts.CloseGrace = DefaultCloseGrace
+ }
+ return &Driver{opts: opts}, nil
+}
+
+// Name implements driver.Driver.
+func (d *Driver) Name() string { return Name }
+
+// Capabilities implements driver.Driver. LoadSession is what the installed
+// adapter advertised at its last initialize, and the pinned version's until
+// one has run; LoadSession itself checks again.
+func (d *Driver) Capabilities() driver.Capabilities {
+ load := d.opts.Adapter.LoadSession
+ switch d.loadSession.Load() {
+ case 1:
+ load = false
+ case 2:
+ load = true
+ }
+ return driver.Capabilities{LoadSession: load, FollowUpPrompts: true, PermissionCallback: true}
+}
+
+// NewSession implements driver.Driver.
+func (d *Driver) NewSession(ctx context.Context, cfg driver.SessionConfig) (driver.Session, error) {
+ return d.open(ctx, cfg, "")
+}
+
+// LoadSession implements driver.Driver.
+func (d *Driver) LoadSession(ctx context.Context, cfg driver.SessionConfig, sessionID string) (driver.Session, error) {
+ if !validSessionID(sessionID) {
+ // Through the session's redaction, even here: this is the one error
+ // path before the adapter's environment joins it, and the id it names
+ // came from outside.
+ red := driver.NewRedactor(cfg.Redaction)
+ return nil, red.Err(fmt.Errorf("%w: %w: %q is not an ACP session id",
+ driver.ErrNotStarted, driver.ErrUnusable, red.Sanitize(sessionID)))
+ }
+ return d.open(ctx, cfg, sessionID)
+}
+
+// open starts the adapter and opens (loadID empty) or loads a session. Once
+// the process exists, every failure ends its group and is not ErrNotStarted
+// (driver invariant 4).
+func (d *Driver) open(ctx context.Context, cfg driver.SessionConfig, loadID string) (driver.Session, error) {
+ if cfg.Policy == nil || !filepath.IsAbs(cfg.Cwd) {
+ return nil, fmt.Errorf("%w: %w: a session needs a policy and an absolute working directory", driver.ErrNotStarted, driver.ErrUnusable)
+ }
+ rules := cfg.Policy.Rules()
+ mode, ok := d.opts.Adapter.Modes[rules.Mode]
+ if !ok {
+ return nil, fmt.Errorf("%w: %w: %w: %s has no asking mode for policy mode %q", driver.ErrNotStarted, driver.ErrUnusable, driver.ErrUnsafeMode, d.opts.Adapter.Name, rules.Mode)
+ }
+ if filepath.Clean(rules.WorkDir) != filepath.Clean(cfg.Cwd) {
+ return nil, fmt.Errorf("%w: %w: the policy's working directory is not the session's", driver.ErrNotStarted, driver.ErrUnusable)
+ }
+ servers, err := wireServers(cfg.MCPServers)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w: %w", driver.ErrNotStarted, driver.ErrUnusable, err)
+ }
+ env := mergeEnv(cfg.Env, driver.BuildEnv(d.opts.Adapter.Env, d.opts.Lookup, nil))
+ env = setEnv(env, d.opts.Adapter.SetEnv)
+ if d.opts.Adapter.Preflight != nil {
+ // Read in the environment the adapter is about to run in, not this
+ // process's: what the preflight looks for (a CODEX_HOME, a HOME) is
+ // what the adapter will resolve its own configuration against.
+ if err := d.opts.Adapter.Preflight(cfg.Cwd, lookupIn(env)); err != nil {
+ // Configuration on this machine: the same session would fail the
+ // same way, so it is not retried.
+ return nil, fmt.Errorf("%w: %w: %w", driver.ErrNotStarted, driver.ErrUnusable, err)
+ }
+ }
+
+ // Everything this session says passes through the dispatcher's redaction,
+ // plus the environment built here, its MCP servers' environments and its
+ // private directory.
+ more := driver.Redaction{Env: slices.Clone(env), Dirs: []string{cfg.PrivateDir, cfg.SocketDir}}
+ for _, server := range cfg.MCPServers {
+ more.Env = append(more.Env, driver.EnvOf(server.Env)...)
+ }
+ red := driver.NewRedactor(cfg.Redaction.With(more))
+ worker, err := driver.StartWorker(ctx, cfg.Launcher, cfg.Scope, driver.Command{
+ Path: d.opts.Binary, Args: append([]string{}, d.opts.Args...), Env: env, Dir: cfg.Cwd,
+ })
+ if err != nil {
+ return nil, red.Err(err)
+ }
+ names := make([]string, 0, len(cfg.MCPServers))
+ for _, srv := range cfg.MCPServers {
+ names = append(names, srv.Name)
+ }
+ s := newSession(sessionOptions{
+ Worker: worker, Policy: cfg.Policy, AskMode: mode, Grace: d.opts.CloseGrace, Redactor: red,
+ MCPStatus: d.opts.Adapter.MCPStatus, MCPNames: names, Refusals: cfg.Refusals, trace: d.opts.trace,
+ })
+ hctx, cancel := context.WithTimeout(ctx, d.opts.HandshakeTimeout)
+ defer cancel()
+ if err := s.handshake(hctx, d, cfg, servers, loadID); err != nil {
+ s.abort()
+ // A session ended for a reason of its own — an MCP server that did
+ // not connect, a mode it left — reports that reason, not the closed
+ // stream it caused.
+ if own := s.failure(); own != nil {
+ err = own
+ }
+ if ctxErr := hctx.Err(); ctxErr != nil && !errors.Is(err, ctxErr) {
+ err = fmt.Errorf("%w (%w)", err, ctxErr)
+ }
+ // The one-owner rule's step 3: the adapter may have started the
+ // agent and its MCP servers before the handshake failed, and the
+ // caller settles this attempt on the error. A group that is not
+ // confirmed gone says so (driver.ErrGroupOutlivedLeader).
+ if gone := confirmGroupGone(worker.Process(), d.opts.CloseGrace); gone != nil {
+ err = fmt.Errorf("%w; %w", err, gone)
+ }
+ // A start that launched a process says which (driver invariant 4):
+ // the connector confirms its group gone before it settles anything.
+ return nil, &driver.StartError{Process: worker.Process(), Err: red.Err(fmt.Errorf("%w%s", err, s.stderrNote()))}
+ }
+ afterHandshake(s)
+ if own := s.failure(); own != nil {
+ // A failure claimed while the handshake was returning: the worker is
+ // already being ended, so the session is never handed out.
+ s.abort()
+ if gone := confirmGroupGone(worker.Process(), d.opts.CloseGrace); gone != nil {
+ own = fmt.Errorf("%w; %w", own, gone)
+ }
+ return nil, &driver.StartError{Process: worker.Process(), Err: red.Err(own)}
+ }
+ return s, nil
+}
+
+// handshake is initialize, the session, and its mode.
+func (s *session) handshake(ctx context.Context, d *Driver, cfg driver.SessionConfig, servers []wireServer, loadID string) error {
+ caps, err := s.initialize(ctx, d.opts.Adapter)
+ if err != nil {
+ return err
+ }
+ if caps.LoadSession || caps.Resume {
+ d.loadSession.Store(2)
+ } else {
+ d.loadSession.Store(1)
+ }
+ var opened sessionState
+ if loadID == "" {
+ opened, err = s.newSession(ctx, cfg.Cwd, servers, d.opts.Adapter.SessionMeta)
+ } else {
+ opened, err = s.loadSession(ctx, caps, loadID, cfg.Cwd, servers, d.opts.Adapter.SessionMeta)
+ }
+ if err != nil {
+ return err
+ }
+ if err := s.enterAskingMode(ctx, opened); err != nil {
+ return err
+ }
+ // Last, because it is the one check made on what the adapter is actually
+ // running rather than on what it was given, and it needs a session in its
+ // asking mode to ask.
+ return s.verifyMCPConfiguration(ctx, d.opts.Adapter)
+}
diff --git a/internal/connector/driver/acp/acp_test.go b/internal/connector/driver/acp/acp_test.go
new file mode 100644
index 000000000..c28781b94
--- /dev/null
+++ b/internal/connector/driver/acp/acp_test.go
@@ -0,0 +1,2510 @@
+//go:build unix
+
+package acp
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "maps"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/basecamp/basecamp-cli/internal/connector/driver"
+ "github.com/basecamp/basecamp-cli/internal/connector/driver/drivertest"
+)
+
+func TestMain(m *testing.M) {
+ if len(os.Args) > 2 && os.Args[1] == fakeAgentArg {
+ runFakeAgent(os.Args[2])
+ os.Exit(0)
+ }
+ if len(os.Args) > 1 && os.Args[1] == fakeChildArg {
+ runFakeChild()
+ os.Exit(0)
+ }
+ modeConfirmWait = 500 * time.Millisecond
+ os.Exit(m.Run())
+}
+
+const (
+ testPackage = "@example/fake-acp"
+ testVersion = "9.9.9"
+)
+
+var testAdapter = Adapter{
+ Name: "fake-acp",
+ Package: testPackage,
+ Version: testVersion,
+ Env: []string{"FAKE_AGENT_KEY"},
+ SetEnv: map[string]string{"FAKE_AGENT_SWITCH": "on"},
+ Modes: map[driver.PermissionMode]string{driver.ModeEditsInWorkDir: "ask"},
+ SessionMeta: map[string]any{
+ "vendor": map[string]any{"settingSources": []string{}},
+ },
+ LoadSession: true,
+}
+
+// recordingPolicy allows by a function and remembers what it was asked.
+type recordingPolicy struct {
+ workDir string
+ allow func(driver.PermissionRequest) bool
+
+ mu sync.Mutex
+ asked []driver.PermissionRequest
+}
+
+func (p *recordingPolicy) Rules() driver.PermissionRules {
+ return driver.PermissionRules{Mode: driver.ModeEditsInWorkDir, WorkDir: p.workDir}
+}
+
+func (p *recordingPolicy) Decide(_ context.Context, req driver.PermissionRequest) driver.PermissionDecision {
+ p.mu.Lock()
+ p.asked = append(p.asked, req)
+ p.mu.Unlock()
+ return driver.PermissionDecision{Allow: p.allow != nil && p.allow(req)}
+}
+
+func (p *recordingPolicy) requests() []driver.PermissionRequest {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return slices.Clone(p.asked)
+}
+
+type harness struct {
+ // withConfig is a test's last word on the session config.
+ withConfig func(driver.SessionConfig) driver.SessionConfig
+ fakeDir string
+ t *testing.T
+ sc scenario
+ dir string
+ policy *recordingPolicy
+ lookup map[string]string
+ grace time.Duration
+}
+
+// newHarness is a fake agent that answers initialize as the pinned adapter,
+// offers the asking mode, and confirms it by read-back, unless the test says
+// otherwise.
+func newHarness(t *testing.T) *harness {
+ t.Helper()
+ dir, err := filepath.EvalSymlinks(t.TempDir())
+ require.NoError(t, err)
+ // The fake agent's own files live apart from the session's working
+ // directory: its record holds what it was sent, the task token included,
+ // and the working directory is where no token may be.
+ fakeDir := t.TempDir()
+ return &harness{
+ fakeDir: fakeDir,
+ t: t,
+ dir: dir,
+ sc: scenario{
+ Record: filepath.Join(fakeDir, "record.json"), AgentName: testPackage, AgentVersion: testVersion,
+ Modes: []string{"auto", "ask", "bypassPermissions"}, CurrentMode: "bypassPermissions", ModeConfig: true, Confirm: "readback",
+ LoadSession: true,
+ },
+ policy: &recordingPolicy{workDir: dir},
+ lookup: map[string]string{},
+ grace: 2 * time.Second,
+ }
+}
+
+func (h *harness) driver() *Driver {
+ h.t.Helper()
+ raw, err := json.Marshal(h.sc)
+ require.NoError(h.t, err)
+ path := filepath.Join(h.fakeDir, "scenario.json")
+ require.NoError(h.t, os.WriteFile(path, raw, 0o600))
+ exe, err := os.Executable()
+ require.NoError(h.t, err)
+ d, err := New(Options{
+ Adapter: testAdapter, Binary: exe, Args: []string{fakeAgentArg, path},
+ Lookup: func(name string) (string, bool) { v, ok := h.lookup[name]; return v, ok },
+ HandshakeTimeout: 10 * time.Second, CloseGrace: h.grace,
+ })
+ require.NoError(h.t, err)
+ return d
+}
+
+func (h *harness) config() driver.SessionConfig {
+ cfg := driver.SessionConfig{
+ Cwd: h.dir,
+ Env: []string{"HOME=" + h.dir, "PATH=/usr/bin:/bin"},
+ MCPServers: []driver.MCPServer{{
+ Name: "basecamp", Command: "/usr/local/bin/basecamp", Args: []string{"mcp", "--profile", "agent"},
+ Env: map[string]string{"BASECAMP_CONNECT_TASK_TOKEN": "test-token-not-real", "HOME": h.dir},
+ }},
+ Policy: h.policy,
+ Scope: driver.Scope{WorkDir: h.dir},
+ PrivateDir: h.t.TempDir(),
+ }
+ if h.withConfig != nil {
+ cfg = h.withConfig(cfg)
+ }
+ return cfg
+}
+
+func (h *harness) open() driver.Session {
+ h.t.Helper()
+ s, err := h.driver().NewSession(context.Background(), h.config())
+ require.NoError(h.t, err)
+ h.t.Cleanup(func() { _ = s.Close() })
+ return s
+}
+
+// record is what the fake agent has written about its run so far. It waits
+// for the file: a process that has just been started may not have written it
+// yet on a loaded machine.
+func (h *harness) record() agentRecord {
+ h.t.Helper()
+ var rec agentRecord
+ var raw []byte
+ require.Eventually(h.t, func() bool {
+ var err error
+ raw, err = os.ReadFile(h.sc.Record)
+ return err == nil
+ }, 30*time.Second, 10*time.Millisecond, "the agent wrote no record")
+ require.NoError(h.t, json.Unmarshal(raw, &rec))
+ return rec
+}
+
+func (h *harness) turns(turns ...turnScript) { h.sc.Turns = turns }
+
+func raw(t *testing.T, v any) json.RawMessage {
+ t.Helper()
+ data, err := json.Marshal(v)
+ require.NoError(t, err)
+ return data
+}
+
+func permission(t *testing.T, call map[string]any, options ...[2]string) json.RawMessage {
+ t.Helper()
+ opts := make([]any, 0, len(options))
+ for _, o := range options {
+ opts = append(opts, map[string]any{"optionId": o[0], "name": "label " + o[0], "kind": o[1]})
+ }
+ return raw(t, map[string]any{"toolCall": call, "options": opts})
+}
+
+func standardOptions() [][2]string {
+ return [][2]string{{"allow-once", "allow_once"}, {"allow-always", "allow_always"}, {"reject", "reject_once"}}
+}
+
+func gone(pid int) bool {
+ return errors.Is(syscall.Kill(pid, 0), syscall.ESRCH)
+}
+
+func waitGone(t *testing.T, pid int) {
+ t.Helper()
+ require.Eventually(t, func() bool { return gone(pid) }, 10*time.Second, 20*time.Millisecond, "pid %d still exists", pid)
+}
+
+// ---------------------------------------------------------------- invariant 1
+
+func TestTheAdapterEnvironmentIsAnAllowlist(t *testing.T) {
+ h := newHarness(t)
+ h.lookup = map[string]string{
+ "FAKE_AGENT_KEY": "test-key-not-real",
+ "CLAUDE_CODE_MESSAGING_TOKEN": "test-host-token-not-real",
+ "BASECAMP_TOKEN": "test-basecamp-token-not-real",
+ }
+ h.sc.Probe = []string{"FAKE_AGENT_KEY", "FAKE_AGENT_SWITCH"}
+ cfg := h.config()
+ drivertest.RequireNoSecretFilesDuring(t, "test-token-not-real", []string{cfg.Cwd, cfg.PrivateDir}, func() {
+ s, err := h.driver().NewSession(context.Background(), cfg)
+ require.NoError(t, err)
+ _ = s.Close()
+ })
+
+ rec := h.record()
+ // The task token reaches the MCP server's declared environment, over the
+ // wire, and nowhere the adapter process itself keeps.
+ drivertest.RequireNoSecret(t, "test-token-not-real", drivertest.Places{Env: rec.EnvKV, Args: rec.Args, Dirs: []string{cfg.Cwd, cfg.PrivateDir}})
+ drivertest.RequireNoSecret(t, "test-host-token-not-real", drivertest.Places{Env: rec.EnvKV, Args: rec.Args})
+ drivertest.RequireNoSecret(t, "test-basecamp-token-not-real", drivertest.Places{Env: rec.EnvKV, Args: rec.Args})
+ assert.Equal(t, []string{"FAKE_AGENT_KEY", "FAKE_AGENT_SWITCH", "HOME", "PATH"}, rec.Env,
+ "the adapter gets the session's environment, its named variables and its own switches, and nothing else")
+ assert.Equal(t, "test-key-not-real", rec.Probe["FAKE_AGENT_KEY"])
+ assert.Equal(t, "on", rec.Probe["FAKE_AGENT_SWITCH"])
+
+ var params struct {
+ Cwd string `json:"cwd"`
+ MCPServers []wireServer `json:"mcpServers"`
+ Meta json.RawMessage `json:"_meta"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Params["session/new"], ¶ms))
+ assert.Equal(t, h.dir, params.Cwd)
+ require.Len(t, params.MCPServers, 1)
+ srv := params.MCPServers[0]
+ assert.Equal(t, []wireEnv{{Name: "BASECAMP_CONNECT_TASK_TOKEN", Value: "test-token-not-real"}, {Name: "HOME", Value: h.dir}}, srv.Env,
+ "every variable the MCP server needs is declared in mcpServers[].env, and nothing else")
+ assert.Equal(t, []string{"mcp", "--profile", "agent"}, srv.Args)
+ assert.NotContains(t, strings.Join(srv.Args, " "), "test-token-not-real", "no token in argv")
+ assert.JSONEq(t, `{"vendor":{"settingSources":[]}}`, string(params.Meta))
+}
+
+// ---------------------------------------------------------------- invariant 2
+
+func TestTheAskingModeIsSetAndReadBack(t *testing.T) {
+ h := newHarness(t)
+ s := h.open()
+ rec := h.record()
+ assert.Equal(t, []string{"initialize", "session/new", "session/set_mode", "session/set_config_option"}, rec.Methods)
+ var set struct {
+ ModeID string `json:"modeId"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Params["session/set_mode"], &set))
+ assert.Equal(t, "ask", set.ModeID)
+ assert.Equal(t, "sess-1", s.ID())
+}
+
+func TestTheAskingModeIsConfirmedByAModeUpdate(t *testing.T) {
+ h := newHarness(t)
+ h.sc.ModeConfig = false
+ h.sc.Confirm = "notify"
+ h.open()
+ assert.Equal(t, []string{"initialize", "session/new", "session/set_mode"}, h.record().Methods)
+}
+
+func TestASessionThatCannotBePutInItsAskingModeIsNotRun(t *testing.T) {
+ cases := map[string]func(*scenario){
+ "the mode is not offered": func(sc *scenario) { sc.Modes = []string{"auto", "bypassPermissions"} },
+ "the read-back reports the old mode": func(sc *scenario) { sc.Confirm = "stale" },
+ "no mode update follows": func(sc *scenario) { sc.ModeConfig = false; sc.Confirm = "none" },
+ "set_mode fails": func(sc *scenario) { sc.Confirm = "error" },
+ "the agent has no modes at all": func(sc *scenario) { sc.Modes = nil; sc.ModeConfig = false },
+ "a mode update overtakes the answer that confirms it": func(sc *scenario) {
+ sc.ModeBeforeSetAnswer = "bypassPermissions"
+ },
+ "only a stale mode update, no option": func(sc *scenario) { sc.ModeConfig = false; sc.Confirm = "stale" },
+ }
+ for name, mutate := range cases {
+ t.Run(name, func(t *testing.T) {
+ h := newHarness(t)
+ mutate(&h.sc)
+ s, err := h.driver().NewSession(context.Background(), h.config())
+ require.Error(t, err)
+ assert.Nil(t, s)
+ require.ErrorIs(t, err, driver.ErrUnsafeMode)
+ assert.NotErrorIs(t, err, driver.ErrNotStarted, "a process existed")
+ assert.NotContains(t, h.record().Methods, "session/prompt")
+ waitGone(t, h.record().PID)
+ })
+ }
+}
+
+func TestLeavingTheAskingModeMidTurnEndsTheSession(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{ModeChange: "bypassPermissions"}, {SleepMS: 5000}}, Stop: "end_turn"})
+ s := h.open()
+ _, err := s.Prompt(context.Background(), "go")
+ require.ErrorIs(t, err, driver.ErrUnsafeMode)
+ select {
+ case <-s.Done():
+ case <-time.After(5 * time.Second):
+ t.Fatal("the worker was not ended")
+ }
+ _, err = s.Prompt(context.Background(), "again")
+ require.ErrorIs(t, err, driver.ErrUnsafeMode)
+}
+
+func TestAPolicyModeTheAdapterHasNoAskingModeForStartsNothing(t *testing.T) {
+ h := newHarness(t)
+ d := h.driver()
+ d.opts.Adapter.Modes = map[driver.PermissionMode]string{}
+ _, err := d.NewSession(context.Background(), h.config())
+ require.ErrorIs(t, err, driver.ErrNotStarted)
+ require.ErrorIs(t, err, driver.ErrUnsafeMode)
+ require.ErrorIs(t, err, driver.ErrUnusable, "a configuration no retry can fix")
+ _, statErr := os.Stat(h.sc.Record)
+ assert.ErrorIs(t, statErr, os.ErrNotExist, "no process was started")
+}
+
+// ---------------------------------------------------------------- invariant 3
+
+func outcomeOf(t *testing.T, raw json.RawMessage) (string, string) {
+ t.Helper()
+ var o struct {
+ Outcome struct {
+ Outcome string `json:"outcome"`
+ OptionID string `json:"optionId"`
+ } `json:"outcome"`
+ }
+ require.NoError(t, json.Unmarshal(raw, &o))
+ return o.Outcome.Outcome, o.Outcome.OptionID
+}
+
+func TestPermissionOptionsAreChosenByKindNeverByIdOrLabel(t *testing.T) {
+ // Ids that lie about their kinds.
+ lying := [][2]string{{"reject", "allow_once"}, {"allow-once", "reject_once"}, {"yes", "allow_always"}}
+ call := map[string]any{"toolCallId": "call-1", "kind": "edit", "locations": []any{map[string]any{"path": "x"}}}
+
+ for _, tc := range []struct {
+ name string
+ allow bool
+ options [][2]string
+ want [2]string
+ }{
+ {"allowed picks allow_once", true, lying, [2]string{"selected", "reject"}},
+ {"refused picks reject_once", false, lying, [2]string{"selected", "allow-once"}},
+ {"allowed never picks allow_always", true, [][2]string{{"always", "allow_always"}, {"no", "reject_once"}}, [2]string{"selected", "no"}},
+ {"refused falls back to reject_always", false, [][2]string{{"once", "allow_once"}, {"never", "reject_always"}}, [2]string{"selected", "never"}},
+ {"nothing to refuse with is canceled", false, [][2]string{{"once", "allow_once"}}, [2]string{outcomeCanceled, ""}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ h := newHarness(t)
+ h.policy.allow = func(driver.PermissionRequest) bool { return tc.allow }
+ h.turns(turnScript{Steps: []step{{Permission: permission(t, call, tc.options...)}}, Stop: "end_turn"})
+ s := h.open()
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ rec := h.record()
+ require.Len(t, rec.Outcomes, 1)
+ outcome, option := outcomeOf(t, rec.Outcomes[0])
+ assert.Equal(t, tc.want, [2]string{outcome, option})
+ if tc.want[1] == "reject" {
+ assert.Empty(t, res.Refusals)
+ } else {
+ assert.Equal(t, []driver.Refusal{{ToolCallID: "call-1", Tool: "edit"}}, res.Refusals)
+ }
+ })
+ }
+}
+
+func TestARequestForAnotherSessionIsRefusedUnasked(t *testing.T) {
+ h := newHarness(t)
+ h.policy.allow = func(driver.PermissionRequest) bool { return true }
+ call := map[string]any{"toolCallId": "call-9", "kind": "edit"}
+ h.turns(turnScript{Steps: []step{{Permission: raw(t, map[string]any{
+ "sessionId": "someone-else", "toolCall": call,
+ "options": []any{map[string]any{"optionId": "ok", "kind": "allow_once"}, map[string]any{"optionId": "no", "kind": "reject_once"}},
+ })}}, Stop: "end_turn"})
+ s := h.open()
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ assert.Empty(t, h.policy.requests(), "the policy is not asked about another session")
+ _, option := outcomeOf(t, h.record().Outcomes[0])
+ assert.Equal(t, "no", option)
+ assert.Len(t, res.Refusals, 1)
+}
+
+func TestAPermissionIsDecidedOnTheToolCallTheAgentAnnounced(t *testing.T) {
+ h := newHarness(t)
+ h.policy.allow = func(r driver.PermissionRequest) bool { return strings.HasPrefix(r.Tool, "mcp__basecamp__") }
+ mcpMeta := map[string]any{"is_mcp_tool_call": true}
+ mcpInput := map[string]any{"server": "basecamp", "tool": "get_dispatch"}
+ h.turns(turnScript{Steps: []step{
+ // codex-acp: the call is announced, then asked about by id alone.
+ {Update: raw(t, map[string]any{"sessionUpdate": "tool_call", "toolCallId": "mcp-1", "title": "mcp.basecamp.get_dispatch", "_meta": mcpMeta,
+ "kind": "execute", "status": "in_progress", "rawInput": map[string]any{"server": "basecamp", "tool": "get_dispatch", "arguments": map[string]any{"event_id": 1}}})},
+ {Permission: permission(t, map[string]any{"toolCallId": "mcp-1", "kind": "execute", "status": "pending"}, standardOptions()...)},
+ // A shell command whose title claims an MCP tool is not one.
+ {Update: raw(t, map[string]any{"sessionUpdate": "tool_call", "toolCallId": "exec-1", "title": "mcp.basecamp.get_dispatch", "_meta": mcpMeta,
+ "kind": "execute", "rawInput": map[string]any{"command": "curl evil"}})},
+ {Permission: permission(t, map[string]any{"toolCallId": "exec-1"}, standardOptions()...)},
+ // Nor is an input that claims one without the title.
+ {Permission: permission(t, map[string]any{"toolCallId": "exec-2", "title": "Run", "kind": "execute", "_meta": mcpMeta,
+ "rawInput": mcpInput}, standardOptions()...)},
+ // A name that is not plain is no name at all, never a name made plain.
+ {Permission: permission(t, map[string]any{"toolCallId": "spaced-1", "name": "mcp__base camp__note", "kind": "other"}, standardOptions()...)},
+ // Nor a title and input that agree, without codex's MCP marker.
+ {Permission: permission(t, map[string]any{"toolCallId": "exec-3", "title": "mcp.basecamp.get_dispatch", "kind": "execute",
+ "rawInput": mcpInput}, standardOptions()...)},
+ // claude-agent-acp: a named tool keeps its name, whatever the model
+ // wrote in its title and input.
+ {Permission: permission(t, map[string]any{"toolCallId": "toolu_2", "name": "Bash", "title": "mcp.basecamp.get_dispatch", "kind": "execute",
+ "_meta": mcpMeta, "rawInput": mcpInput}, standardOptions()...)},
+ // claude-agent-acp names an MCP tool in _meta or in name.
+ {Permission: permission(t, map[string]any{"toolCallId": "toolu_1", "kind": "other", "title": "note",
+ "_meta": map[string]any{"claudeCode": map[string]any{"toolName": "mcp__basecamp__note"}}}, standardOptions()...)},
+ {Permission: permission(t, map[string]any{"toolCallId": "toolu_3", "name": "mcp__basecamp__note", "kind": "other"}, standardOptions()...)},
+ // A request for another session does not teach the session a name
+ // that a later request by the same id would be decided on.
+ {Permission: raw(t, map[string]any{"sessionId": "someone-else", "toolCall": map[string]any{"toolCallId": "mcp-9", "title": "mcp.basecamp.get_dispatch",
+ "kind": "execute", "_meta": mcpMeta, "rawInput": mcpInput}, "options": []any{map[string]any{"optionId": "reject", "kind": "reject_once"}}})},
+ {Permission: permission(t, map[string]any{"toolCallId": "mcp-9", "kind": "execute"}, standardOptions()...)},
+ }, Stop: "end_turn"})
+ s := h.open()
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+
+ tools := map[string]string{}
+ for _, r := range h.policy.requests() {
+ tools[r.ToolCallID] = r.Tool
+ }
+ assert.Equal(t, map[string]string{
+ "mcp-1": "mcp__basecamp__get_dispatch", "exec-1": "", "exec-2": "", "exec-3": "", "toolu_2": "Bash", "spaced-1": "",
+ "toolu_1": "mcp__basecamp__note", "toolu_3": "mcp__basecamp__note", "mcp-9": "",
+ }, tools)
+ outcomes := h.record().Outcomes
+ options := make([]string, 0, len(outcomes))
+ for _, o := range outcomes {
+ _, id := outcomeOf(t, o)
+ options = append(options, id)
+ }
+ assert.Equal(t, []string{"allow-once", "reject", "reject", "reject", "reject", "reject", "allow-once", "allow-once", "reject", "reject"}, options)
+ // mcp-9 was asked about twice, and a call refused twice is one refusal.
+ assert.Len(t, res.Refusals, 6)
+}
+
+func TestARequestOutsideATurnIsRefusedUnasked(t *testing.T) {
+ h := newHarness(t)
+ h.policy.allow = func(driver.PermissionRequest) bool { return true }
+ s := h.open().(*session)
+ // Feed the request straight in: no turn is in flight.
+ params := raw(t, map[string]any{"sessionId": "sess-1", "toolCall": map[string]any{"toolCallId": "c", "kind": "edit"},
+ "options": []any{map[string]any{"optionId": "ok", "kind": "allow_once"}, map[string]any{"optionId": "no", "kind": "reject_once"}}})
+ s.onRequest(json.RawMessage(`99`), "session/request_permission", params, s.claim("session/request_permission"))
+ assert.Empty(t, h.policy.requests())
+}
+
+// ---------------------------------------------------------------- invariant 4
+
+func TestARefusalIsNeverReportedAsACancel(t *testing.T) {
+ call := map[string]any{"toolCallId": "exec-1", "kind": "execute"}
+ t.Run("codex ends a refused turn as canceled", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{Permission: permission(t, call, standardOptions()...)}}, Stop: string(driver.TurnCanceled)})
+ res, err := h.open().Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ assert.Equal(t, driver.TurnRefusal, res.Stop)
+ assert.Equal(t, []driver.Refusal{{ToolCallID: "exec-1", Tool: "execute"}}, res.Refusals)
+ })
+ t.Run("claude ends it as end_turn, with the refusal on record", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{Permission: permission(t, call, standardOptions()...)}}, Stop: "end_turn"})
+ res, err := h.open().Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ assert.Equal(t, driver.TurnEndTurn, res.Stop)
+ assert.Len(t, res.Refusals, 1)
+ })
+ t.Run("a canceled stop nobody asked for is an error", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Stop: string(driver.TurnCanceled)})
+ res, err := h.open().Prompt(context.Background(), "go")
+ require.Error(t, err)
+ assert.NotEqual(t, driver.TurnCanceled, res.Stop)
+ })
+ t.Run("a cancel the connector asked for is canceled", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{Update: raw(t, map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "hi"}})}},
+ WaitForCancel: true, Stop: string(driver.TurnCanceled)})
+ s := h.open()
+ answers := make(chan driver.PromptResult, 1)
+ go func() {
+ res, err := s.Prompt(context.Background(), "go")
+ assert.NoError(t, err)
+ answers <- res
+ }()
+ <-s.Updates()
+ require.NoError(t, s.Cancel(context.Background()))
+ select {
+ case res := <-answers:
+ assert.Equal(t, driver.TurnCanceled, res.Stop)
+ case <-time.After(5 * time.Second):
+ t.Fatal("no answer after cancel")
+ }
+ })
+ t.Run("an unknown stop reason is an error", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Stop: "gave_up"})
+ _, err := h.open().Prompt(context.Background(), "go")
+ require.Error(t, err)
+ })
+}
+
+func TestACancelWithNoTurnEndsTheNextOneAndOnlyIt(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{WaitForCancel: true, Stop: string(driver.TurnCanceled)}, turnScript{Stop: "end_turn"})
+ s := h.open()
+ require.NoError(t, s.Cancel(context.Background()))
+ assert.NotContains(t, h.record().Methods, "session/cancel", "nothing is sent for a turn that is not there")
+
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ assert.Equal(t, driver.TurnCanceled, res.Stop, "the turn the cancel raced starts canceled")
+ assert.Contains(t, h.record().Methods, "session/cancel")
+
+ res, err = s.Prompt(context.Background(), "follow-up")
+ require.NoError(t, err)
+ assert.Equal(t, driver.TurnEndTurn, res.Stop, "a cancel ends one turn, not the session's every turn after it")
+ n := 0
+ for _, m := range h.record().Methods {
+ if m == "session/cancel" {
+ n++
+ }
+ }
+ assert.Equal(t, 1, n, "one cancel, for one turn")
+}
+
+func TestAPermissionIsNotAllowedOnceTheTurnIsCanceled(t *testing.T) {
+ h := newHarness(t)
+ started := make(chan struct{})
+ release := make(chan struct{})
+ var once sync.Once
+ h.policy.allow = func(driver.PermissionRequest) bool {
+ once.Do(func() { close(started) })
+ <-release
+ return true
+ }
+ h.turns(turnScript{Steps: []step{{Permission: permission(t, map[string]any{"toolCallId": "c1", "kind": "edit"}, standardOptions()...)}},
+ WaitForCancel: true, Stop: string(driver.TurnCanceled)}, turnScript{Stop: "end_turn"})
+ s := h.open()
+ answers := make(chan driver.PromptResult, 1)
+ go func() {
+ res, err := s.Prompt(context.Background(), "go")
+ assert.NoError(t, err)
+ answers <- res
+ }()
+ <-started
+ require.NoError(t, s.Cancel(context.Background()))
+ close(release)
+ select {
+ case res := <-answers:
+ assert.Equal(t, driver.TurnCanceled, res.Stop)
+ assert.Len(t, res.Refusals, 1, "a permission the policy allowed while the turn was canceled is refused")
+ case <-time.After(10 * time.Second):
+ t.Fatal("the canceled turn never ended")
+ }
+ _, option := outcomeOf(t, h.record().Outcomes[0])
+ assert.Equal(t, "reject", option)
+
+ // The cancel ended the turn it found; the next one is not born canceled.
+ res, err := s.Prompt(context.Background(), "follow-up")
+ require.NoError(t, err)
+ assert.Equal(t, driver.TurnEndTurn, res.Stop)
+ n := 0
+ for _, m := range h.record().Methods {
+ if m == "session/cancel" {
+ n++
+ }
+ }
+ assert.Equal(t, 1, n)
+}
+
+// ---------------------------------------------------------------- invariant 5
+
+func TestLoadIsGatedByWhatTheAgentAdvertises(t *testing.T) {
+ replay := []json.RawMessage{
+ raw(t, map[string]any{"sessionUpdate": "user_message_chunk", "content": map[string]any{"type": "text", "text": "old"}}),
+ raw(t, map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "old answer"}}),
+ raw(t, map[string]any{"sessionUpdate": "tool_call", "toolCallId": "t0", "kind": "read"}),
+ }
+ for _, tc := range []struct {
+ name string
+ load, resume bool
+ method string
+ }{
+ {"loadSession", true, false, "session/load"},
+ {"resume only", false, true, "session/resume"},
+ {"both prefers load", true, true, "session/load"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ h := newHarness(t)
+ h.sc.LoadSession, h.sc.Resume, h.sc.Replay = tc.load, tc.resume, replay
+ h.sc.SessionID = "sess-earlier"
+ d := h.driver()
+ s, err := d.LoadSession(context.Background(), h.config(), "sess-earlier")
+ require.NoError(t, err)
+ defer s.Close()
+ assert.Equal(t, "sess-earlier", s.ID())
+ rec := h.record()
+ assert.Contains(t, rec.Methods, tc.method)
+ assert.NotContains(t, rec.Methods, "session/new")
+ assert.True(t, d.Capabilities().LoadSession, "a session this driver can reload, by load or resume")
+ select {
+ case u := <-s.Updates():
+ t.Fatalf("a load's replay was reported as progress: %+v", u)
+ default:
+ }
+ assert.Contains(t, rec.Methods, "session/set_config_option", "a loaded session is put in its asking mode too")
+ })
+ }
+ t.Run("neither", func(t *testing.T) {
+ h := newHarness(t)
+ h.sc.LoadSession, h.sc.Resume = false, false
+ d := h.driver()
+ _, err := d.LoadSession(context.Background(), h.config(), "sess-earlier")
+ require.ErrorIs(t, err, ErrLoadUnsupported)
+ assert.False(t, d.Capabilities().LoadSession)
+ assert.NotErrorIs(t, err, driver.ErrNotStarted)
+ waitGone(t, h.record().PID)
+ })
+ t.Run("a session id the ledger could not have written starts nothing", func(t *testing.T) {
+ h := newHarness(t)
+ _, err := h.driver().LoadSession(context.Background(), h.config(), "../../etc; rm")
+ require.ErrorIs(t, err, driver.ErrNotStarted)
+ })
+}
+
+// ---------------------------------------------------------------- invariants 6 and 7, and driver invariant 4
+
+func TestOnlyAStartThatRanNothingIsErrNotStarted(t *testing.T) {
+ t.Run("missing binary", func(t *testing.T) {
+ h := newHarness(t)
+ d := h.driver()
+ d.opts.Binary = filepath.Join(h.dir, "no-such-adapter")
+ _, err := d.NewSession(context.Background(), h.config())
+ require.ErrorIs(t, err, driver.ErrNotStarted)
+ })
+ for name, mutate := range map[string]func(*scenario){
+ "initialize fails": func(sc *scenario) { sc.FailInitialize = true },
+ "another adapter": func(sc *scenario) { sc.AgentName = "@someone/else" },
+ "another adapter version": func(sc *scenario) { sc.AgentVersion = "9.9.10" },
+ "another protocol": func(sc *scenario) { sc.ProtocolVersion = 2 },
+ } {
+ t.Run(name, func(t *testing.T) {
+ h := newHarness(t)
+ mutate(&h.sc)
+ _, err := h.driver().NewSession(context.Background(), h.config())
+ require.Error(t, err)
+ assert.NotErrorIs(t, err, driver.ErrNotStarted)
+ assert.Equal(t, h.record().PID, driver.StartedProcess(err).PID, "a start that launched a process says which")
+ assert.NotContains(t, h.record().Methods, "session/new")
+ waitGone(t, h.record().PID)
+ })
+ }
+ t.Run("a handshake that never answers", func(t *testing.T) {
+ h := newHarness(t)
+ h.sc.Hang = "session/new"
+ d := h.driver()
+ d.opts.HandshakeTimeout = 3 * time.Second
+ _, err := d.NewSession(context.Background(), h.config())
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ assert.NotErrorIs(t, err, driver.ErrNotStarted)
+ waitGone(t, h.record().PID)
+ })
+}
+
+// ---------------------------------------------------------------- driver invariant 5
+
+func TestCloseEndsTheWholeProcessGroup(t *testing.T) {
+ h := newHarness(t)
+ h.sc.SpawnChild, h.sc.IgnoreStdinEOF, h.sc.IgnoreTerminate = true, true, true
+ h.grace = 200 * time.Millisecond
+ s := h.open()
+ rec := h.record()
+ require.NotZero(t, rec.ChildPID)
+ assert.Equal(t, rec.PID, s.Process().PGID)
+
+ closed := make(chan error, 1)
+ go func() { closed <- s.Close() }()
+ select {
+ case err := <-closed:
+ require.NoError(t, err)
+ case <-time.After(10 * time.Second):
+ _ = syscall.Kill(-rec.PID, syscall.SIGKILL)
+ t.Fatal("Close did not end an adapter that ignores EOF and SIGTERM")
+ }
+ require.NoError(t, s.Close(), "Close is idempotent")
+ select {
+ case <-s.Done():
+ case <-time.After(5 * time.Second):
+ t.Fatal("the adapter outlived Close")
+ }
+ waitGone(t, rec.PID)
+ waitGone(t, rec.ChildPID)
+ _, err := s.Prompt(context.Background(), "go")
+ require.ErrorIs(t, err, driver.ErrSessionEnded)
+}
+
+func TestAWorkerThatDiesMidTurnEndsThePrompt(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Hang: true})
+ s := h.open()
+ answers := make(chan error, 1)
+ go func() {
+ _, err := s.Prompt(context.Background(), "go")
+ answers <- err
+ }()
+ time.Sleep(100 * time.Millisecond)
+ require.NoError(t, syscall.Kill(s.Process().PID, syscall.SIGKILL))
+ select {
+ case err := <-answers:
+ require.ErrorIs(t, err, driver.ErrSessionEnded)
+ case <-time.After(5 * time.Second):
+ t.Fatal("Prompt did not return when the worker died")
+ }
+}
+
+// ---------------------------------------------------------------- invariant 8
+
+func TestNothingTheAgentVolunteersIsKept(t *testing.T) {
+ h := newHarness(t)
+ h.sc.AuthEmail = "person@example.com"
+ h.turns(
+ turnScript{Steps: []step{
+ {Update: raw(t, map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "secret words the connector never keeps"}})},
+ {Update: raw(t, map[string]any{"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "cat /home/person/.ssh/id_rsa", "kind": "read",
+ "status": "pending", "rawInput": map[string]any{"path": "/home/person/.ssh/id_rsa"}, "name": "Read person@example.com"})},
+ {Update: raw(t, map[string]any{"sessionUpdate": "tool_call_update", "toolCallId": "t2", "title": "cat /home/person/.ssh/id_rsa", "kind": "read"})},
+ {Update: raw(t, map[string]any{"sessionUpdate": "usage_update", "used": 1200, "size": 200000})},
+ {Update: raw(t, map[string]any{"sessionUpdate": "plan", "entries": []any{map[string]any{"content": "step one"}}})},
+ }, Stop: "end_turn", Usage: raw(t, map[string]any{"inputTokens": 12, "outputTokens": 34})},
+ turnScript{ErrorMessage: "quota exhausted for person@example.com"},
+ )
+ s := h.open()
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ assert.Equal(t, driver.Usage{InputTokens: 12, OutputTokens: 34, ContextUsed: 1200, ContextSize: 200000}, res.Usage)
+
+ var updates []driver.Update
+ for len(updates) < 6 {
+ select {
+ case u := <-s.Updates():
+ updates = append(updates, u)
+ case <-time.After(2 * time.Second):
+ t.Fatalf("only %d updates", len(updates))
+ }
+ }
+ kinds := make([]driver.UpdateKind, 0, len(updates))
+ for _, u := range updates {
+ kinds = append(kinds, u.Kind)
+ assert.NotContains(t, u.Tool, "@")
+ assert.NotContains(t, u.Tool, "ssh")
+ }
+ assert.Equal(t, []driver.UpdateKind{driver.UpdateAgentMessageChunk, driver.UpdateToolCall, driver.UpdateToolCallUpdate, driver.UpdateUsage, driver.UpdatePlan, driver.UpdateUsage}, kinds)
+ assert.Empty(t, updates[2].Tool, "a title is never a tool's name")
+ assert.Equal(t, len("secret words the connector never keeps"), updates[0].Chars)
+ assert.Equal(t, driver.ToolRead, updates[1].ToolKind)
+ assert.Equal(t, driver.ToolPending, updates[1].Status)
+
+ _, err = s.Prompt(context.Background(), "again")
+ require.Error(t, err)
+ assert.NotContains(t, err.Error(), "person@example.com")
+ assert.Contains(t, err.Error(), "quota exhausted")
+
+ h2 := newHarness(t)
+ h2.sc.AuthEmail, h2.sc.FailInitialize = "person@example.com", true
+ _, err = h2.driver().NewSession(context.Background(), h2.config())
+ require.Error(t, err)
+ assert.NotContains(t, err.Error(), "person@example.com")
+}
+
+// ---------------------------------------------------------------- turns
+
+func TestAPromptWhoseContextEndsLeavesTheTurnToFinish(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{SleepMS: 400}}, Stop: "end_turn"}, turnScript{Stop: "end_turn"})
+ s := h.open()
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ _, err := s.Prompt(ctx, "slow")
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ _, err = s.Prompt(context.Background(), "overlapping")
+ require.Error(t, err, "the first turn is still in flight")
+ require.Eventually(t, func() bool {
+ _, err := s.Prompt(context.Background(), "next")
+ return err == nil
+ }, 5*time.Second, 50*time.Millisecond)
+}
+
+func TestFollowUpsArePromptsInTheSameSession(t *testing.T) {
+ h := newHarness(t)
+ d := h.driver()
+ caps := d.Capabilities()
+ assert.True(t, caps.FollowUpPrompts)
+ assert.True(t, caps.PermissionCallback)
+ s, err := d.NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ for range 3 {
+ res, err := s.Prompt(context.Background(), "next")
+ require.NoError(t, err)
+ assert.Equal(t, driver.TurnEndTurn, res.Stop)
+ }
+ methods := h.record().Methods
+ n := 0
+ for _, m := range methods {
+ if m == "session/prompt" {
+ n++
+ }
+ }
+ assert.Equal(t, 3, n)
+ assert.Equal(t, Name, d.Name())
+}
+
+// ---------------------------------------------------------------- adapters
+
+func TestLocateFindsOnlyThePinnedVersion(t *testing.T) {
+ dir := t.TempDir()
+ a := Adapter{Name: "fake-acp", Package: "@example/fake-acp", Version: "1.2.3"}
+ _, err := Locate(dir, a)
+ require.ErrorIs(t, err, ErrAdapterMissing)
+ _, err = Locate("relative/dir", a)
+ require.Error(t, err)
+
+ pkg := filepath.Join(dir, "node_modules", "@example", "fake-acp")
+ require.NoError(t, os.MkdirAll(pkg, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(pkg, "package.json"), []byte(`{"name":"@example/fake-acp","version":"1.2.4"}`), 0o600))
+ _, err = Locate(dir, a)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "pinned")
+
+ require.NoError(t, os.WriteFile(filepath.Join(pkg, "package.json"), []byte(`{"name":"@example/fake-acp","version":"1.2.3"}`), 0o600))
+ _, err = Locate(dir, a)
+ require.ErrorIs(t, err, ErrAdapterMissing, "no executable yet")
+ bin := filepath.Join(dir, "node_modules", ".bin")
+ require.NoError(t, os.MkdirAll(bin, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(bin, "fake-acp"), []byte("#!/bin/sh\n"), 0o700))
+ got, err := Locate(dir, a)
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(bin, "fake-acp"), got)
+}
+
+func TestThePinnedAdapters(t *testing.T) {
+ for _, a := range Adapters() {
+ got, ok := AdapterNamed(a.Name)
+ require.True(t, ok)
+ assert.Equal(t, a.Package, got.Package)
+ assert.NotEmpty(t, a.Modes[driver.ModeEditsInWorkDir], a.Name)
+ for _, name := range a.Env {
+ assert.NotContains(t, []string{"CLAUDE_CODE_EXECUTABLE", "CODEX_PATH", "CLAUDE_CODE_MESSAGING_TOKEN", "BASECAMP_TOKEN"}, name,
+ "%s may not take a variable that swaps its pinned agent or carries the host's token", a.Name)
+ }
+ }
+ options := ClaudeAgentACP.SessionMeta["claudeCode"].(map[string]any)["options"].(map[string]any)
+ assert.Equal(t, true, options["strictMcpConfig"], "only the session's MCP servers")
+ assert.Equal(t, MCPStatusInit, ClaudeAgentACP.MCPStatus)
+ assert.Equal(t, []map[string]string{{"type": "system", "subtype": "init"}}, ClaudeAgentACP.SessionMeta["claudeCode"].(map[string]any)["emitRawSDKMessages"],
+ "the init, and only the init, is forwarded")
+ assert.Equal(t, MCPStatusStartupFailures, CodexACP.MCPStatus)
+ assert.Equal(t, []string{"EnterPlanMode", "ExitPlanMode"}, options["disallowedTools"], "a plan-mode switch would leave the verified mode")
+ assert.Equal(t, []string{}, options["settingSources"], "none of the host's settings")
+ assert.Equal(t, false, options["allowDangerouslySkipPermissions"])
+ assert.Equal(t, "true", CodexACP.SetEnv["DISABLE_MCP_CONFIG_FILTERING"], "the requested server is never dropped for a configured one")
+ assert.NotNil(t, CodexACP.Preflight)
+ assert.Equal(t, "0.78.0", ClaudeAgentACP.Version)
+ assert.Equal(t, "1.12.0", CodexACP.Version)
+
+ var manifest struct {
+ Dependencies map[string]string `json:"dependencies"`
+ }
+ data, err := os.ReadFile(filepath.Join("adapters", "package.json"))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(data, &manifest))
+ for _, a := range Adapters() {
+ assert.Equal(t, a.Version, manifest.Dependencies[a.Package], "adapters/package.json pins what the driver checks")
+ }
+ var codexCfg map[string]any
+ require.NoError(t, json.Unmarshal([]byte(CodexACP.SetEnv["CODEX_CONFIG"]), &codexCfg), "CODEX_CONFIG is JSON")
+
+ _, ok := AdapterNamed("nobody")
+ assert.False(t, ok)
+ dir, err := DefaultAdaptersDir(func(name string) (string, bool) {
+ return map[string]string{"HOME": "/home/agent"}[name], name == "HOME"
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "/home/agent/.local/share/basecamp/acp-adapters", dir)
+}
+
+// ---------------------------------------------------------------- hangs
+
+func TestAnAgentThatStopsReadingCannotHoldCancelOrClose(t *testing.T) {
+ h := newHarness(t)
+ h.sc.StopReadingAfter = "session/set_config_option"
+ h.grace = 300 * time.Millisecond
+ s := h.open()
+
+ prompted := make(chan error, 1)
+ go func() {
+ // Larger than the pipe and the agent's read buffer: the write sticks.
+ _, err := s.Prompt(context.Background(), strings.Repeat("x", 8<<20))
+ prompted <- err
+ }()
+ time.Sleep(200 * time.Millisecond)
+
+ canceled := make(chan error, 1)
+ go func() { canceled <- s.Cancel(context.Background()) }()
+ select {
+ case err := <-canceled:
+ require.Error(t, err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("Cancel waited on a stuck write")
+ }
+ closed := make(chan struct{})
+ go func() { _ = s.Close(); close(closed) }()
+ select {
+ case <-closed:
+ case <-time.After(10 * time.Second):
+ _ = syscall.Kill(-s.Process().PGID, syscall.SIGKILL)
+ t.Fatal("Close waited on a stuck write")
+ }
+ select {
+ case err := <-prompted:
+ require.Error(t, err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("the stuck prompt never returned")
+ }
+}
+
+func TestALineTooLongEndsTheWorker(t *testing.T) {
+ old := maxLine
+ maxLine = 1 << 20
+ t.Cleanup(func() { maxLine = old })
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{Update: raw(t, map[string]any{"sessionUpdate": "agent_message_chunk",
+ "content": map[string]any{"type": "text", "text": strings.Repeat("y", 2<<20)}})}}, Hang: true})
+ s := h.open()
+ _, err := s.Prompt(context.Background(), "go")
+ require.ErrorIs(t, err, driver.ErrSessionEnded)
+ select {
+ case <-s.Done():
+ case <-time.After(5 * time.Second):
+ t.Fatal("the worker outlived its unreadable stream")
+ }
+}
+
+func TestAModeChangeFailsTheTurnBeforeTheWorkerIsGone(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{ModeChange: "bypassPermissions"}}, Hang: true})
+ s := h.open().(*session)
+ release := make(chan struct{})
+ ended := make(chan struct{})
+ s.mu.Lock()
+ s.endUnsafe = func() {
+ <-release
+ s.worker.Terminate(0)
+ close(ended)
+ }
+ s.mu.Unlock()
+ answers := make(chan error, 1)
+ go func() {
+ _, err := s.Prompt(context.Background(), "go")
+ answers <- err
+ }()
+ select {
+ case err := <-answers:
+ require.ErrorIs(t, err, driver.ErrUnsafeMode, "the turn fails on the mode report, not on the worker's end")
+ case <-time.After(5 * time.Second):
+ close(release)
+ t.Fatal("the turn waited for the worker to be ended")
+ }
+ close(release)
+ select {
+ case <-ended:
+ case <-time.After(5 * time.Second):
+ t.Fatal("the worker was not ended")
+ }
+ <-s.Done()
+}
+
+// ---------------------------------------------------------------- foreign MCP configuration
+
+func TestCodexConfigThatDeclaresMCPServersRefusesTheSession(t *testing.T) {
+ root := t.TempDir()
+ home := filepath.Join(root, "home")
+ cwd := filepath.Join(root, "repo", "sub")
+ require.NoError(t, os.MkdirAll(filepath.Join(home, ".codex"), 0o700))
+ require.NoError(t, os.MkdirAll(cwd, 0o700))
+ lookup := func(name string) (string, bool) {
+ if name == "HOME" {
+ return home, true
+ }
+ return "", false
+ }
+ require.NoError(t, os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("model = \"x\"\n[projects.\"/tmp\"]\ntrust_level = \"trusted\"\n"), 0o600))
+ require.NoError(t, codexPreflight(cwd, lookup))
+
+ require.NoError(t, os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("[mcp_servers.basecamp]\ncommand = \"/bin/evil\"\n"), 0o600))
+ require.ErrorIs(t, codexPreflight(cwd, lookup), ErrForeignMCPConfig)
+ require.NoError(t, os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("['mcp_servers'.basecamp]\ncommand = \"/bin/evil\"\n"), 0o600))
+ require.ErrorIs(t, codexPreflight(cwd, lookup), ErrForeignMCPConfig, "a quoted key declares them too")
+ require.NoError(t, os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("[\"mcp\\u005fservers\".basecamp]\ncommand = \"/bin/evil\"\n"), 0o600))
+ require.ErrorIs(t, codexPreflight(cwd, lookup), ErrForeignMCPConfig, "a key with an escape is refused rather than read")
+ require.NoError(t, os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("[profiles.\"my profile\".mcp_servers.x]\ncommand = \"/bin/evil\"\n"), 0o600))
+ require.ErrorIs(t, codexPreflight(cwd, lookup), ErrForeignMCPConfig, "a quoted table path declares them too")
+ require.NoError(t, os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("[profiles . demo . mcp_servers . basecamp]\ncommand = \"/bin/evil\"\n"), 0o600))
+ require.ErrorIs(t, codexPreflight(cwd, lookup), ErrForeignMCPConfig, "TOML allows space around the dots")
+ require.NoError(t, os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("\ufeff[mcp_servers.basecamp]\ncommand = \"/bin/evil\"\n"), 0o600))
+ require.ErrorIs(t, codexPreflight(cwd, lookup), ErrForeignMCPConfig, "a byte order mark does not hide the first line")
+ require.NoError(t, os.WriteFile(filepath.Join(home, ".codex", "config.toml"),
+ []byte("profile = \"demo\"\nprofiles = { demo = { mcp_servers = { basecamp = { command = \"/bin/evil\" } } } }\n"), 0o600))
+ require.ErrorIs(t, codexPreflight(cwd, lookup), ErrForeignMCPConfig, "an inline table declares them on one line, at any depth")
+ require.NoError(t, os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("model = \"x\"\nwindows_path = \"C:\\\\codex\"\n"), 0o600))
+ require.NoError(t, codexPreflight(cwd, lookup), "an escape in a value is not a key")
+ require.NoError(t, os.Chmod(filepath.Join(home, ".codex", "config.toml"), 0o000))
+ require.ErrorIs(t, codexPreflight(cwd, lookup), ErrForeignMCPConfig, "a config this cannot read is refused, not assumed empty")
+ require.NoError(t, os.Chmod(filepath.Join(home, ".codex", "config.toml"), 0o600))
+ codexHome := filepath.Join(root, "codex-home")
+ require.NoError(t, os.MkdirAll(codexHome, 0o700))
+ withCodexHome := func(name string) (string, bool) {
+ if name == "CODEX_HOME" {
+ return codexHome, true
+ }
+ return lookup(name)
+ }
+ require.NoError(t, codexPreflight(cwd, withCodexHome), "CODEX_HOME replaces ~/.codex")
+
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "repo", ".codex"), 0o700))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "repo", ".codex", "config.toml"), []byte("mcp_servers.basecamp.command = \"/bin/evil\"\n"), 0o600))
+ require.ErrorIs(t, codexPreflight(cwd, withCodexHome), ErrForeignMCPConfig, "a project layer above the working directory counts")
+
+ h := newHarness(t)
+ d := h.driver()
+ d.opts.Adapter.Preflight = func(string, func(string) (string, bool)) error { return ErrForeignMCPConfig }
+ _, err := d.NewSession(context.Background(), h.config())
+ require.ErrorIs(t, err, ErrForeignMCPConfig)
+ require.ErrorIs(t, err, driver.ErrNotStarted)
+ require.ErrorIs(t, err, driver.ErrUnusable)
+ _, statErr := os.Stat(h.sc.Record)
+ assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was started")
+}
+
+func TestCloseGivesUpOnOutputAnEscapedDescendantHolds(t *testing.T) {
+ h := newHarness(t)
+ h.sc.EscapingChild, h.sc.IgnoreStdinEOF, h.sc.IgnoreTerminate = true, true, true
+ h.grace = 300 * time.Millisecond
+ s := h.open()
+ rec := h.record()
+ require.NotZero(t, rec.ChildPID)
+ t.Cleanup(func() { _ = syscall.Kill(rec.ChildPID, syscall.SIGKILL) })
+
+ closed := make(chan struct{})
+ go func() { _ = s.Close(); close(closed) }()
+ select {
+ case <-closed:
+ case <-time.After(10 * time.Second):
+ t.Fatal("Close waited on output a process outside the worker's group holds")
+ }
+ waitGone(t, rec.PID)
+ assert.False(t, gone(rec.ChildPID), "the escaped descendant is not this driver's to kill by name")
+}
+
+func TestAgentTextIsFitForALog(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{ErrorMessage: "quota for person@example.com\u001b[31mred\u009b31mred\nsecond line\ttab"})
+ s := h.open()
+ _, err := s.Prompt(context.Background(), "go")
+ require.Error(t, err)
+ for _, bad := range []string{"person@example.com", "\u001b", "\u009b", "\n", "\t"} {
+ assert.NotContains(t, err.Error(), bad)
+ }
+ assert.Contains(t, err.Error(), "quota for")
+}
+
+func TestAFloodOfPermissionRequestsIsBounded(t *testing.T) {
+ h := newHarness(t)
+ release := make(chan struct{})
+ var deciding atomic.Int32
+ h.policy.allow = func(driver.PermissionRequest) bool {
+ deciding.Add(1)
+ defer deciding.Add(-1)
+ <-release
+ return true
+ }
+ const flood = 60
+ h.turns(turnScript{
+ FloodPermissions: flood,
+ FloodCall: permission(t, map[string]any{"kind": "edit"}, standardOptions()...),
+ Stop: "end_turn",
+ })
+ s := h.open()
+ type answer struct {
+ res driver.PromptResult
+ err error
+ }
+ // The result comes back on a channel rather than being asserted where it
+ // arrives: a goroutine that outlives the test must not be the one to fail
+ // it.
+ answers := make(chan answer, 1)
+ go func() {
+ res, err := s.Prompt(context.Background(), "go")
+ answers <- answer{res, err}
+ }()
+ require.Eventually(t, func() bool { return deciding.Load() == maxDecisions }, 60*time.Second, 10*time.Millisecond,
+ "the session decides at most %d at once", maxDecisions)
+ // Every request but the ones stuck in a decision has been answered.
+ require.Eventually(t, func() bool { return len(h.record().Outcomes) >= flood-maxDecisions }, 60*time.Second, 20*time.Millisecond,
+ "a flood is answered as it arrives")
+ assert.LessOrEqual(t, deciding.Load(), int32(maxDecisions))
+ answered := h.record().Outcomes
+ close(release)
+ var got answer
+ select {
+ case got = <-answers:
+ case <-time.After(60 * time.Second):
+ t.Fatal("the flooded turn never ended")
+ }
+ require.NoError(t, got.err)
+ res := got.res
+ assert.NotEmpty(t, res.Refusals, "a request refused for want of room is still a refusal on the turn")
+ canceled := 0
+ for _, o := range answered {
+ if len(o) == 0 || string(o) == "null" {
+ continue
+ }
+ if outcome, _ := outcomeOf(t, o); outcome == outcomeCanceled {
+ canceled++
+ }
+ }
+ assert.Positive(t, canceled, "what reaches the policy past its bound is refused undecided")
+ allowed := 0
+ for _, o := range h.record().Outcomes {
+ if len(o) == 0 || string(o) == "null" {
+ continue
+ }
+ if _, option := outcomeOf(t, o); option == "allow-once" {
+ allowed++
+ }
+ }
+ assert.Positive(t, allowed, "while what fits is still decided")
+}
+
+// The connection answers at most maxHandlers requests at once, whatever the
+// agent sends: the rest are refused as they are read, so no flood of requests
+// becomes a flood of goroutines.
+func TestTheConnectionBoundsRequestsInFlight(t *testing.T) {
+ // What the client writes, the test reads; what the test writes, the
+ // client reads.
+ fromClient, toAgent := io.Pipe()
+ toClient, fromAgent := io.Pipe()
+ t.Cleanup(func() { _ = toAgent.Close(); _ = fromAgent.Close() })
+
+ c := newConn(toAgent)
+ var busy atomic.Int32
+ c.onBusy = func(string, json.RawMessage, any) { busy.Add(1) }
+ release := make(chan struct{})
+ var inFlight, peak atomic.Int32
+ c.onRequest = func(id json.RawMessage, _ string, _ json.RawMessage, _ any) {
+ n := inFlight.Add(1)
+ for {
+ p := peak.Load()
+ if n <= p || peak.CompareAndSwap(p, n) {
+ break
+ }
+ }
+ <-release
+ inFlight.Add(-1)
+ c.reply(id, map[string]any{"outcome": map[string]any{"outcome": outcomeCanceled}})
+ }
+ go func() { _ = c.read(toClient) }()
+
+ answers := make(chan int, 1)
+ go func() {
+ // Read what the client writes, so no reply of its own can block it.
+ refused := 0
+ scanner := bufio.NewScanner(fromClient)
+ for scanner.Scan() {
+ if strings.Contains(scanner.Text(), "too many requests") {
+ refused++
+ }
+ if strings.Contains(scanner.Text(), "outcome") {
+ break
+ }
+ }
+ answers <- refused
+ }()
+ for i := range 64 {
+ _, err := fmt.Fprintf(fromAgent, `{"jsonrpc":"2.0","id":%d,"method":"session/request_permission","params":{}}`+"\n", i)
+ require.NoError(t, err)
+ }
+ require.Eventually(t, func() bool { return int(inFlight.Load()) == maxHandlers }, 10*time.Second, 5*time.Millisecond)
+ time.Sleep(200 * time.Millisecond)
+ assert.Equal(t, int32(maxHandlers), peak.Load(), "no more goroutines than the bound, whatever arrives")
+ close(release)
+ select {
+ case refused := <-answers:
+ assert.Positive(t, refused, "what does not fit is refused as it is read")
+ assert.GreaterOrEqual(t, int(busy.Load()), refused, "and every one of those refusals is heard by the session")
+ case <-time.After(10 * time.Second):
+ t.Fatal("no answer reached the agent")
+ }
+}
+
+func TestWhatOneToolCallMayCostTheSession(t *testing.T) {
+ h := newHarness(t)
+ s := h.open().(*session)
+ // What a tool call costs is what it costs inside a turn: outside one,
+ // nothing of it is kept at all.
+ s.mu.Lock()
+ s.turn = &turn{done: make(chan struct{})}
+ s.mu.Unlock()
+ long := strings.Repeat("c", maxToolCallID+1)
+ locations := make([]any, 0, maxLocations*4)
+ for i := range maxLocations * 4 {
+ locations = append(locations, map[string]any{"path": fmt.Sprintf("/work/%d", i)})
+ }
+ u, ok := decodeUpdate(raw(t, map[string]any{
+ "sessionUpdate": "tool_call", "toolCallId": long, "kind": "edit", "status": "pending", "locations": locations,
+ }))
+ require.True(t, ok)
+ assert.Len(t, u.Locations, maxLocations, "a call carries as many paths as this driver carries, no more")
+ assert.True(t, u.Unplaceable, "and a call whose paths did not all fit is one the policy cannot place")
+ info := s.noteTool(u)
+ assert.Len(t, info.locations, maxLocations)
+ assert.True(t, info.unplaceable)
+ s.mu.Lock()
+ remembered := len(s.tools)
+ s.mu.Unlock()
+ assert.Zero(t, remembered, "an id past what an id can be is not a key to keep")
+
+ for i := range maxTools + 10 {
+ s.noteTool(sessionUpdate{ToolCallID: fmt.Sprintf("call-%d", i), Kind: "edit", Status: "pending"})
+ }
+ s.mu.Lock()
+ remembered = len(s.tools)
+ s.mu.Unlock()
+ assert.Equal(t, maxTools, remembered)
+}
+
+// A permission being decided as the turn ends is still on the turn's result:
+// the agent can answer the prompt before it hears the answer to its request.
+func TestARefusalDecidedAsTheTurnEndsIsOnItsResult(t *testing.T) {
+ h := newHarness(t)
+ deciding := make(chan struct{})
+ h.policy.allow = func(driver.PermissionRequest) bool {
+ close(deciding)
+ time.Sleep(300 * time.Millisecond)
+ return false
+ }
+ h.turns(turnScript{
+ FloodPermissions: 1,
+ FloodCall: permission(t, map[string]any{"kind": "edit"}, standardOptions()...),
+ StopWithoutWaiting: true,
+ Stop: "end_turn",
+ })
+ s := h.open()
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ select {
+ case <-deciding:
+ default:
+ t.Fatal("the policy was never asked")
+ }
+ assert.Len(t, res.Refusals, 1)
+}
+
+// A handshake that fails after the adapter started leaves nothing of its
+// process group behind by the time NewSession returns: the caller settles the
+// attempt on that error.
+func TestAFailedHandshakeLeavesNoGroupBehind(t *testing.T) {
+ // Several runs: the window this closes is a matter of milliseconds.
+ for run := range 4 {
+ h := newHarness(t)
+ h.sc.SpawnChild, h.sc.IgnoreTerminate = true, true
+ // Past initialize, so the agent has surely started and said so.
+ h.sc.Hang = "session/new"
+ d := h.driver()
+ d.opts.HandshakeTimeout = 3 * time.Second
+ d.opts.CloseGrace = 2 * time.Second
+ _, err := d.NewSession(context.Background(), h.config())
+ require.Error(t, err)
+ rec := h.record()
+ require.NotZero(t, rec.ChildPID)
+ assert.True(t, gone(rec.ChildPID) && gone(rec.PID),
+ "run %d: the adapter's group is gone when NewSession returns, not a moment later", run)
+ }
+}
+
+func TestARefusalRecordIsBounded(t *testing.T) {
+ h := newHarness(t)
+ s := h.open().(*session)
+ tr := &turn{done: make(chan struct{})}
+ s.mu.Lock()
+ s.turn = tr
+ s.mu.Unlock()
+ t.Cleanup(func() {
+ s.mu.Lock()
+ s.turn = nil
+ s.mu.Unlock()
+ })
+ long := strings.Repeat("x", 4*maxToolCallID)
+ for i := range maxRecorded + maxRefusals + 100 {
+ s.record(driver.PermissionRequest{ToolCallID: fmt.Sprintf("%s-%d", long, i), Kind: driver.ToolEdit}, tr)
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ assert.Len(t, tr.refusals, maxRefusals, "a turn holds so many refusals and no more")
+ assert.LessOrEqual(t, len(tr.refusals[0].ToolCallID), maxToolCallID, "a recorded id is cut, and then redacted")
+ assert.LessOrEqual(t, len(s.recorded), maxRecorded, "and a session remembers so many and no more")
+}
+
+// A cancel that arrives once the agent has answered the prompt, while the
+// session still waits on a decision, is not sent: that turn is over.
+func TestACancelAfterTheAgentAnsweredIsNotSent(t *testing.T) {
+ h := newHarness(t)
+ deciding := make(chan struct{})
+ h.policy.allow = func(driver.PermissionRequest) bool {
+ close(deciding)
+ time.Sleep(600 * time.Millisecond)
+ return false
+ }
+ h.turns(turnScript{
+ FloodPermissions: 1,
+ FloodCall: permission(t, map[string]any{"kind": "edit"}, standardOptions()...),
+ StopWithoutWaiting: true,
+ Stop: "end_turn",
+ })
+ s := h.open()
+ answers := make(chan driver.PromptResult, 1)
+ go func() {
+ res, err := s.Prompt(context.Background(), "go")
+ assert.NoError(t, err)
+ answers <- res
+ }()
+ <-deciding
+ // The agent answers the prompt 150ms after asking; the decision takes 600.
+ time.Sleep(350 * time.Millisecond)
+ require.NoError(t, s.Cancel(context.Background()))
+ res := <-answers
+ assert.Equal(t, driver.TurnEndTurn, res.Stop)
+ assert.NotContains(t, h.record().Methods, "session/cancel")
+}
+
+// A turn the agent has answered asks nothing more: a request that arrives
+// while the session waits on a decision still in flight is refused, not put
+// to the policy.
+func TestARequestAfterTheAgentAnsweredIsNotAllowed(t *testing.T) {
+ h := newHarness(t)
+ var calls atomic.Int32
+ h.policy.allow = func(req driver.PermissionRequest) bool {
+ if calls.Add(1) == 1 {
+ time.Sleep(800 * time.Millisecond)
+ }
+ return true
+ }
+ h.turns(turnScript{
+ FloodPermissions: 1,
+ FloodCall: permission(t, map[string]any{"kind": "edit", "locations": []any{map[string]any{"path": "x"}}}, standardOptions()...),
+ StopWithoutWaiting: true,
+ Stop: "end_turn",
+ LateRequest: permission(t, map[string]any{"toolCallId": "late", "kind": "edit"}, standardOptions()...),
+ })
+ s := h.open()
+ _, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ require.Eventually(t, func() bool { return len(h.record().Outcomes) == 2 }, 10*time.Second, 20*time.Millisecond)
+ for _, r := range h.policy.requests() {
+ assert.NotEqual(t, "late", r.ToolCallID, "a request after the answer is not put to the policy")
+ }
+ late := h.record().Outcomes
+ _, lastOption := outcomeOf(t, late[len(late)-1])
+ assert.NotEqual(t, "allow-once", lastOption)
+}
+
+// A cancel that arrives after the agent has answered does not turn the
+// agent's own stop into one the connector asked for.
+func TestACancelAfterTheAnswerDoesNotClaimTheStop(t *testing.T) {
+ h := newHarness(t)
+ h.policy.allow = func(driver.PermissionRequest) bool {
+ time.Sleep(700 * time.Millisecond)
+ return true
+ }
+ h.turns(turnScript{
+ FloodPermissions: 1,
+ FloodCall: permission(t, map[string]any{"kind": "edit", "locations": []any{map[string]any{"path": "x"}}}, standardOptions()...),
+ StopWithoutWaiting: true,
+ Stop: string(driver.TurnCanceled),
+ })
+ s := h.open()
+ type answer struct {
+ res driver.PromptResult
+ err error
+ }
+ answers := make(chan answer, 1)
+ go func() {
+ res, err := s.Prompt(context.Background(), "go")
+ answers <- answer{res, err}
+ }()
+ // The agent answers 150ms in; the decision runs to 700ms.
+ time.Sleep(400 * time.Millisecond)
+ require.NoError(t, s.Cancel(context.Background()))
+ a := <-answers
+ assert.NotEqual(t, driver.TurnCanceled, a.res.Stop, "the connector's cancel came after the agent had stopped")
+ // The decision still in flight came back allowed after the agent had
+ // answered, so it was refused; the agent's own canceled stop is that refusal.
+ require.NoError(t, a.err)
+ assert.Equal(t, driver.TurnRefusal, a.res.Stop)
+ assert.NotContains(t, h.record().Methods, "session/cancel")
+}
+
+func TestTheTurnEndWaitsForRequestsAlreadyRead(t *testing.T) {
+ h := newHarness(t)
+ s := h.open().(*session)
+ claimed := s.claim("session/request_permission")
+ assert.Nil(t, turnOf(claimed), "no turn in flight")
+ go func() {
+ time.Sleep(300 * time.Millisecond)
+ s.mu.Lock()
+ s.deciding--
+ s.mu.Unlock()
+ }()
+ start := time.Now()
+ s.drainDecisions()
+ assert.GreaterOrEqual(t, time.Since(start), 250*time.Millisecond, "a request read but not yet decided holds the turn's end")
+}
+
+func TestUpdatesCarryBoundedIDs(t *testing.T) {
+ h := newHarness(t)
+ s := h.open().(*session)
+ s.emit(driver.Update{Kind: driver.UpdateToolCall, ToolCallID: strings.Repeat("i", 10*maxToolCallID)})
+ select {
+ case u := <-s.Updates():
+ assert.LessOrEqual(t, len(u.ToolCallID), maxToolCallID, "an id is cut, and then redacted")
+ case <-time.After(2 * time.Second):
+ t.Fatal("no update")
+ }
+}
+
+// The driver asks for the group's confirmation with the worker it started,
+// and an answer that the group outlived its leader is in the error the caller
+// settles on.
+func TestAFailedHandshakeAsksForTheGroupsConfirmation(t *testing.T) {
+ h := newHarness(t)
+ h.sc.FailInitialize = true
+ var asked []driver.Process
+ old := confirmGroupGone
+ confirmGroupGone = func(p driver.Process, grace time.Duration) error {
+ asked = append(asked, p)
+ return driver.ErrGroupOutlivedLeader
+ }
+ t.Cleanup(func() { confirmGroupGone = old })
+ _, err := h.driver().NewSession(context.Background(), h.config())
+ require.ErrorIs(t, err, driver.ErrGroupOutlivedLeader)
+ require.Len(t, asked, 1)
+ assert.Equal(t, h.record().PID, asked[0].PGID, "the group of the adapter this session started")
+}
+
+// The prompt's answer settles its turn as it is read, on the reading
+// goroutine, so a request read right after it is outside the turn whatever
+// the turn's own goroutine has done yet.
+func TestAnAnswerSettlesItsTurnAsItIsRead(t *testing.T) {
+ h := newHarness(t)
+ h.policy.allow = func(driver.PermissionRequest) bool { return true }
+ s := h.open().(*session)
+ tr := &turn{done: make(chan struct{}), call: s.conn.register("session/prompt")}
+ s.mu.Lock()
+ s.turn = tr
+ s.mu.Unlock()
+ t.Cleanup(func() {
+ s.mu.Lock()
+ s.turn = nil
+ s.mu.Unlock()
+ })
+
+ s.onResponse(tr.call.id)
+ params := raw(t, map[string]any{"sessionId": "sess-1", "toolCall": map[string]any{"toolCallId": "after", "kind": "edit"},
+ "options": []any{map[string]any{"optionId": "ok", "kind": "allow_once"}, map[string]any{"optionId": "no", "kind": "reject_once"}}})
+ s.onRequest(json.RawMessage(`98`), "session/request_permission", params, s.claim("session/request_permission"))
+ assert.Empty(t, h.policy.requests(), "a request read after the answer is not put to the policy")
+}
+
+// The install fails on a Node version an adapter does not support, rather
+// than leaving an installation Locate accepts and the first dispatch cannot
+// run: npm only warns about engines without --engine-strict.
+func TestTheAdapterInstallRefusesAnUnsupportedNode(t *testing.T) {
+ makefile, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "Makefile"))
+ require.NoError(t, err)
+ var install string
+ for _, line := range strings.Split(string(makefile), "\n") {
+ if strings.Contains(line, "npm ci") && strings.Contains(line, "ACP_ADAPTERS_DIR") {
+ install = line
+ }
+ }
+ require.NotEmpty(t, install, "make acp-adapters installs with npm ci")
+ assert.Contains(t, install, "--engine-strict")
+ assert.Contains(t, install, "--ignore-scripts")
+
+ var lock struct {
+ Packages map[string]struct {
+ Engines map[string]string `json:"engines"`
+ } `json:"packages"`
+ }
+ raw, err := os.ReadFile(filepath.Join("adapters", "package-lock.json"))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(raw, &lock))
+ assert.NotEmpty(t, lock.Packages["node_modules/"+ClaudeAgentACP.Package].Engines["node"],
+ "the pinned adapter states the Node it needs, which --engine-strict enforces")
+}
+
+// A session whose MCP server did not connect does not go on: the worker
+// would run without the Basecamp tools and its task token, and a turn that
+// ends without them would be settled as finished.
+func TestASessionWhoseMCPServerDidNotConnectDoesNotGoOn(t *testing.T) {
+ withStatus := func(h *harness, status MCPStatus) *Driver {
+ d := h.driver()
+ d.opts.Adapter.MCPStatus = status
+ return d
+ }
+ t.Run("claude: the init reports every server connected", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{MCPInit: map[string]string{"basecamp": "connected"}}}, Stop: "end_turn"}, turnScript{Stop: "end_turn"})
+ s, err := withStatus(h, MCPStatusInit).NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ for range 2 {
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ assert.Equal(t, driver.TurnEndTurn, res.Stop)
+ }
+ })
+ for name, init := range map[string]map[string]string{
+ "claude: the server failed": {"basecamp": "failed"},
+ "claude: the server is pending": {"basecamp": "pending"},
+ "claude: the server is missing": {"other": "connected"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{MCPInit: init}, {SleepMS: 3000}}, Stop: "end_turn"})
+ s, err := withStatus(h, MCPStatusInit).NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ _, err = s.Prompt(context.Background(), "go")
+ require.ErrorIs(t, err, ErrMCPServerNotConnected)
+ select {
+ case <-s.Done():
+ case <-time.After(10 * time.Second):
+ t.Fatal("the worker was not ended")
+ }
+ })
+ }
+ t.Run("claude: a turn that ends with no init at all", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Stop: "end_turn"})
+ s, err := withStatus(h, MCPStatusInit).NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ _, err = s.Prompt(context.Background(), "go")
+ require.ErrorIs(t, err, ErrMCPServerNotConnected, "never told is not connected")
+ })
+ t.Run("codex: a startup failure", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{
+ {Update: raw(t, map[string]any{"sessionUpdate": "tool_call", "toolCallId": "mcp_startup.basecamp", "kind": "other",
+ "title": "mcp__basecamp__startup", "status": "failed"})},
+ {SleepMS: 3000},
+ }, Stop: "end_turn"})
+ s, err := withStatus(h, MCPStatusStartupFailures).NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ _, err = s.Prompt(context.Background(), "go")
+ require.ErrorIs(t, err, ErrMCPServerNotConnected)
+ })
+ t.Run("claude: a server the session never gave it", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{MCPInit: map[string]string{"basecamp": "connected", "elsewhere": "connected"}}, {SleepMS: 3000}}, Stop: "end_turn"})
+ s, err := withStatus(h, MCPStatusInit).NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ _, err = s.Prompt(context.Background(), "go")
+ require.ErrorIs(t, err, ErrMCPServerNotConnected)
+ assert.Contains(t, err.Error(), "never gave it")
+ })
+ t.Run("a failure while the session is opening is what the start reports", func(t *testing.T) {
+ h := newHarness(t)
+ h.sc.MCPInitAtSessionStart = map[string]string{"basecamp": "failed"}
+ _, err := withStatus(h, MCPStatusInit).NewSession(context.Background(), h.config())
+ require.ErrorIs(t, err, ErrMCPServerNotConnected, "not the closed stream that failure caused")
+ })
+ t.Run("an init naming another session vouches for nothing", func(t *testing.T) {
+ h := newHarness(t)
+ h.sc.MCPInitAtSessionStart = map[string]string{"basecamp": "connected"}
+ h.sc.MCPInitSessionID = "someone-elses-session"
+ h.turns(turnScript{Stop: "end_turn"})
+ s, err := withStatus(h, MCPStatusInit).NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ _, err = s.Prompt(context.Background(), "go")
+ require.ErrorIs(t, err, ErrMCPServerNotConnected, "this session was never told about its own servers")
+ })
+ t.Run("codex: a startup failure for a server nobody gave it", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{
+ {Update: raw(t, map[string]any{"sessionUpdate": "tool_call", "toolCallId": "mcp_startup.elsewhere", "kind": "other",
+ "title": "mcp__elsewhere__startup", "status": "failed"})},
+ {SleepMS: 3000},
+ }, Stop: "end_turn"})
+ s, err := withStatus(h, MCPStatusStartupFailures).NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ _, err = s.Prompt(context.Background(), "go")
+ require.ErrorIs(t, err, ErrMCPServerNotConnected)
+ assert.Contains(t, err.Error(), "never gave it")
+ })
+ t.Run("codex: no failure reported is no failure", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Stop: "end_turn"})
+ s, err := withStatus(h, MCPStatusStartupFailures).NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ _, err = s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ })
+}
+
+// An agent that asks faster than its refusals can be written has stopped
+// working with this client: the connection says so, and the session ends
+// rather than leaving requests unanswered for ever.
+func TestAnAgentThatOutrunsEvenItsRefusalsEndsTheSession(t *testing.T) {
+ t.Run("the connection reports the overflow", func(t *testing.T) {
+ oldBusy, oldHandlers := maxBusy, maxHandlers
+ maxBusy, maxHandlers = 2, 2
+ t.Cleanup(func() { maxBusy, maxHandlers = oldBusy, oldHandlers })
+
+ // A writer nobody reads: refusals queue up rather than going out.
+ _, toAgent := io.Pipe()
+ toClient, fromAgent := io.Pipe()
+ t.Cleanup(func() { _ = toAgent.Close(); _ = fromAgent.Close() })
+ c := newConn(toAgent)
+ release := make(chan struct{})
+ defer close(release)
+ c.onRequest = func(json.RawMessage, string, json.RawMessage, any) { <-release }
+ overflowed := make(chan struct{})
+ var once sync.Once
+ c.onOverflow = func() { once.Do(func() { close(overflowed) }) }
+ go func() { _ = c.read(toClient) }()
+
+ go func() {
+ for i := range 64 {
+ if _, err := fmt.Fprintf(fromAgent, `{"jsonrpc":"2.0","id":%d,"method":"session/request_permission","params":{}}`+"\n", i); err != nil {
+ return
+ }
+ }
+ }()
+ select {
+ case <-overflowed:
+ case <-time.After(20 * time.Second):
+ t.Fatal("an agent outrunning every bound was never reported")
+ }
+ })
+
+ t.Run("the session ends", func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Hang: true})
+ s := h.open()
+ answers := make(chan error, 1)
+ go func() {
+ _, err := s.Prompt(context.Background(), "go")
+ answers <- err
+ }()
+ require.Eventually(t, func() bool { return slices.Contains(h.record().Methods, "session/prompt") },
+ 10*time.Second, 50*time.Millisecond)
+ s.(*session).conn.onOverflow()
+ select {
+ case err := <-answers:
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unanswered")
+ case <-time.After(10 * time.Second):
+ t.Fatal("the turn did not end")
+ }
+ select {
+ case <-s.Done():
+ case <-time.After(10 * time.Second):
+ t.Fatal("the worker was not ended")
+ }
+ })
+}
+
+// A session that is not the one the connector asked for is the driver
+// package's own sentinel, so every driver settles it the same way.
+func TestAnUnverifiedSessionIsTheSharedSentinel(t *testing.T) {
+ require.ErrorIs(t, ErrMCPServerNotConnected, driver.ErrSessionUnverified)
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{MCPInit: map[string]string{"basecamp": "failed"}}, {SleepMS: 3000}}, Stop: "end_turn"})
+ d := h.driver()
+ d.opts.Adapter.MCPStatus = MCPStatusInit
+ s, err := d.NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ _, err = s.Prompt(context.Background(), "go")
+ require.ErrorIs(t, err, driver.ErrSessionUnverified)
+}
+
+// redactionSecret is the value fed through every error path. It is obviously
+// fake, and is planted where a real secret would be: in the session's
+// environment, in its MCP server's environment, in the name of its private
+// directory, and in what the agent writes back.
+const redactionSecret = "test-token-not-real-a71c3e"
+
+func redactionHarness(t *testing.T) *harness {
+ t.Helper()
+ h := newHarness(t)
+ h.sc.Secret = redactionSecret
+ h.withConfig = func(cfg driver.SessionConfig) driver.SessionConfig {
+ private := filepath.Join(cfg.PrivateDir, redactionSecret)
+ require.NoError(t, os.Mkdir(private, 0o700))
+ cfg.PrivateDir = private
+ cfg.Env = append(slices.Clone(cfg.Env), "FAKE_AGENT_SECRET="+redactionSecret)
+ cfg.MCPServers[0].Env["BASECAMP_CONNECT_TASK_TOKEN"] = redactionSecret
+ cfg.Redaction = driver.Redaction{Secrets: []string{redactionSecret}}
+ return cfg
+ }
+ return h
+}
+
+// The redaction rule (driver's redact.go): nothing this driver hands back
+// carries the secret, whichever way the session fails.
+func TestNoErrorPathCarriesTheSecretOut(t *testing.T) {
+ drivertest.RequireRedacted(t, redactionSecret, []drivertest.RedactionPath{
+ {Name: "start", Run: func(t *testing.T) drivertest.Crossing {
+ h := redactionHarness(t)
+ // The adapter is not the pinned one, and its stderr, which
+ // carries the secret, is in the failure.
+ h.sc.AgentVersion = "0.0.0"
+ _, err := h.driver().NewSession(context.Background(), h.config())
+ require.Error(t, err)
+ return drivertest.Crossing{Errors: []error{err}}
+ }},
+ {Name: "handshake", Run: func(t *testing.T) drivertest.Crossing {
+ h := redactionHarness(t)
+ h.sc.Confirm = "stale"
+ h.sc.CurrentMode = redactionSecret
+ h.sc.Modes = []string{"ask", redactionSecret}
+ _, err := h.driver().NewSession(context.Background(), h.config())
+ require.ErrorIs(t, err, driver.ErrUnsafeMode)
+ return drivertest.Crossing{Errors: []error{err}}
+ }},
+ {Name: "prompt", Run: func(t *testing.T) drivertest.Crossing {
+ h := redactionHarness(t)
+ h.turns(turnScript{Steps: []step{
+ {Update: raw(t, map[string]any{"sessionUpdate": "tool_call", "toolCallId": redactionSecret, "name": redactionSecret, "kind": "edit"})},
+ {Permission: permission(t, map[string]any{"toolCallId": redactionSecret, "name": redactionSecret, "kind": "edit"}, standardOptions()...)},
+ }, ErrorMessage: "the agent failed with " + redactionSecret})
+ s := h.open()
+ result, err := s.Prompt(context.Background(), "go")
+ require.Error(t, err)
+ return drivertest.Crossing{Errors: []error{err}, Results: []driver.PromptResult{result},
+ Updates: drainUpdates(s), Texts: []string{s.(*session).stderrNote()}}
+ }},
+ {Name: "cancel", Run: func(t *testing.T) drivertest.Crossing {
+ h := redactionHarness(t)
+ h.turns(turnScript{Steps: []step{{Update: raw(t, map[string]any{"sessionUpdate": "agent_message_chunk",
+ "content": map[string]any{"type": "text", "text": redactionSecret}})}}, WaitForCancel: true, Stop: string(driver.TurnCanceled)})
+ s := h.open()
+ results := make(chan driver.PromptResult, 1)
+ go func() {
+ res, err := s.Prompt(context.Background(), "go")
+ assert.NoError(t, err)
+ results <- res
+ }()
+ <-s.Updates()
+ err := s.Cancel(context.Background())
+ res := <-results
+ return drivertest.Crossing{Errors: []error{err}, Results: []driver.PromptResult{res},
+ Updates: drainUpdates(s), Texts: []string{s.(*session).stderrNote()}}
+ }},
+ {Name: "close", Run: func(t *testing.T) drivertest.Crossing {
+ h := redactionHarness(t)
+ s := h.open()
+ err := s.Close()
+ _, promptErr := s.Prompt(context.Background(), "go")
+ return drivertest.Crossing{Errors: []error{err, promptErr},
+ Updates: drainUpdates(s), Texts: []string{s.(*session).stderrNote()}}
+ }},
+ })
+}
+
+// drainUpdates is every update the session has emitted so far.
+func drainUpdates(s driver.Session) []driver.Update {
+ var out []driver.Update
+ for {
+ select {
+ case u, ok := <-s.Updates():
+ if !ok {
+ return out
+ }
+ out = append(out, u)
+ case <-time.After(200 * time.Millisecond):
+ return out
+ }
+ }
+}
+
+// A refusal is recorded as it is made, once per tool call id, so a worker
+// that dies before its result has already reported it (driver's "Refusals").
+func TestEveryRefusalIsRecordedOnceAsItIsMade(t *testing.T) {
+ h := newHarness(t)
+ recorder := &drivertest.Refusals{}
+ h.withConfig = func(cfg driver.SessionConfig) driver.SessionConfig {
+ cfg.Refusals = recorder
+ return cfg
+ }
+ call := map[string]any{"toolCallId": "call-1", "kind": "edit"}
+ // Two ids that are cut to the same first bytes are still two calls.
+ long := strings.Repeat("d", maxToolCallID)
+ h.turns(turnScript{Steps: []step{
+ {Permission: permission(t, call, standardOptions()...)},
+ // The same call asked about twice is one refusal.
+ {Permission: permission(t, call, standardOptions()...)},
+ {Permission: permission(t, map[string]any{"toolCallId": "call-2", "kind": "execute"}, standardOptions()...)},
+ {Permission: permission(t, map[string]any{"toolCallId": long + "-one", "kind": "edit"}, standardOptions()...)},
+ {Permission: permission(t, map[string]any{"toolCallId": long + "-two", "kind": "edit"}, standardOptions()...)},
+ }, Hang: true})
+ s := h.open()
+ go func() { _, _ = s.Prompt(context.Background(), "go") }()
+ require.Eventually(t, func() bool { return len(recorder.Recorded()) == 4 }, 10*time.Second, 20*time.Millisecond,
+ "each refusal is recorded as it is made, before the turn ends")
+ recorded := recorder.Recorded()
+ assert.Equal(t, []driver.Refusal{{ToolCallID: "call-1", Tool: "edit"}, {ToolCallID: "call-2", Tool: "execute"}}, recorded[:2])
+}
+
+// The dispatcher logs a worker's last output when it stops badly; it reads
+// it off the session, so the session must offer it.
+func TestTheDispatcherCanReadTheAdaptersLastWords(t *testing.T) {
+ h := newHarness(t)
+ h.sc.Secret = "the adapter's last words"
+ s := h.open()
+ tail, ok := s.(interface{ StderrTail() string })
+ require.True(t, ok, "the dispatcher probes for this method")
+ require.Eventually(t, func() bool { return strings.Contains(tail.StderrTail(), "last words") },
+ 10*time.Second, 50*time.Millisecond)
+}
+
+// A session that failed while its handshake was returning is ended, not
+// handed out: nothing prompts a worker the driver has already killed.
+func TestASessionAlreadyFailedIsNeverHandedOut(t *testing.T) {
+ h := newHarness(t)
+ // The failure lands while session/new is being answered; the handshake
+ // itself succeeds.
+ h.sc.MCPInitAtSessionStart = map[string]string{"basecamp": "failed"}
+ d := h.driver()
+ d.opts.Adapter.MCPStatus = MCPStatusInit
+ s, err := d.NewSession(context.Background(), h.config())
+ require.ErrorIs(t, err, ErrMCPServerNotConnected)
+ assert.Nil(t, s)
+ waitGone(t, h.record().PID)
+}
+
+// And a failure claimed in the window between the handshake returning and the
+// session being handed out: the seam stands where only a race could.
+func TestASessionThatFailsAsItIsHandedOutIsNotHandedOut(t *testing.T) {
+ h := newHarness(t)
+ failure := errors.New("acp: claimed as the handshake returned")
+ old := afterHandshake
+ afterHandshake = func(s *session) { s.fail(failure) }
+ t.Cleanup(func() { afterHandshake = old })
+
+ s, err := h.driver().NewSession(context.Background(), h.config())
+ assert.Nil(t, s)
+ require.ErrorIs(t, err, failure)
+ var start *driver.StartError
+ require.ErrorAs(t, err, &start, "a start that ran a process says which")
+ assert.NotZero(t, start.Process.PID)
+ waitGone(t, h.record().PID)
+}
+
+// A permission request this client cannot read is a refusal it made, and is
+// recorded like any other.
+func TestAnUnreadableRequestIsARefusalToo(t *testing.T) {
+ h := newHarness(t)
+ recorder := &drivertest.Refusals{}
+ h.withConfig = func(cfg driver.SessionConfig) driver.SessionConfig {
+ cfg.Refusals = recorder
+ return cfg
+ }
+ h.turns(turnScript{Steps: []step{{Permission: raw(t, []any{"not", "an", "object"})}}, Hang: true})
+ s := h.open()
+ go func() { _, _ = s.Prompt(context.Background(), "go") }()
+ require.Eventually(t, func() bool { return len(recorder.Recorded()) == 1 }, 10*time.Second, 20*time.Millisecond)
+ select {
+ case u := <-s.Updates():
+ assert.Equal(t, driver.UpdatePermission, u.Kind)
+ assert.False(t, u.Allowed)
+ case <-time.After(2 * time.Second):
+ t.Fatal("no update for a refusal")
+ }
+}
+
+// ---------------------------------------------------------------- what the agent writes is bounded
+
+// An adapter can send an account of its MCP servers for any session it likes,
+// as often as it likes, before the session's own id is known. What is held is
+// bounded in every direction: how many accounts, which ids may have one, and
+// how much of one is kept.
+func TestTheAccountsHeldBeforeASessionIsNamedAreBounded(t *testing.T) {
+ h := newHarness(t)
+ d := h.driver()
+ d.opts.Adapter.MCPStatus = MCPStatusInit
+ s := h.open().(*session)
+ s.mu.Lock()
+ s.id = ""
+ s.mcpStatus = MCPStatusInit
+ s.mu.Unlock()
+
+ init := func(id string, servers ...map[string]any) {
+ list := make([]any, 0, len(servers))
+ for _, srv := range servers {
+ list = append(list, srv)
+ }
+ s.onSDKMessage(raw(t, map[string]any{
+ "sessionId": id,
+ "message": map[string]any{"type": "system", "subtype": "init", "mcp_servers": list},
+ }))
+ }
+ // An id this session could never have been given is not held at all, so
+ // it does not even take a place among the few that are.
+ init(strings.Repeat("x", 4096), map[string]any{"name": "basecamp", "status": "connected"})
+ init("../../etc/passwd", map[string]any{"name": "basecamp", "status": "connected"})
+ s.mu.Lock()
+ assert.Empty(t, s.earlyInit, "no account is held for an id this session could not have")
+ s.mu.Unlock()
+
+ // Then a flood of accounts, each naming far more servers than the
+ // session was given, and each name far longer than a name.
+ long := strings.Repeat("l", 8192)
+ for i := range maxEarlyInit * 20 {
+ servers := make([]map[string]any, 0, 200)
+ for j := range 200 {
+ servers = append(servers, map[string]any{"name": fmt.Sprintf("%s-%d-%d", long, i, j), "status": long})
+ }
+ init(fmt.Sprintf("sess-%d", i), servers...)
+ }
+
+ s.mu.Lock()
+ held := len(s.earlyInit)
+ ids := slices.Collect(maps.Keys(s.earlyInit))
+ widest, longest := 0, 0
+ for _, a := range s.earlyInit {
+ width := len(a.statuses)
+ if a.foreign {
+ width++
+ }
+ widest = max(widest, width)
+ longest = max(longest, len(a.reason))
+ for name, status := range a.statuses {
+ longest = max(longest, len(name), len(status))
+ }
+ }
+ names := len(s.mcpNames)
+ s.mu.Unlock()
+ assert.LessOrEqual(t, held, maxEarlyInit, "no more accounts held than could ever be used")
+ for _, id := range ids {
+ assert.True(t, validSessionID(id), "an id this session could never be given is not held: %q", id)
+ }
+ assert.LessOrEqual(t, widest, names+1, "an account holds the session's own servers and the one name it did not give")
+ assert.LessOrEqual(t, longest, 512, "and none of it is the agent's to size")
+
+ // And what is held is still an account: the one that turns out to name
+ // this session vouches for its servers when the id arrives.
+ s.mu.Lock()
+ s.earlyInit = nil
+ s.mcpConfirmed = false
+ s.mu.Unlock()
+ init("sess-good", map[string]any{"name": "basecamp", "status": "connected"})
+ s.nameSession("sess-good")
+ s.mu.Lock()
+ confirmed, unsafe := s.mcpConfirmed, s.unsafe
+ s.mu.Unlock()
+ assert.NoError(t, unsafe, "an account of the servers the session gave is no reason to end it")
+ assert.True(t, confirmed, "and it is the account that vouches for them")
+}
+
+// A path no filesystem takes, and a mode no adapter has, are cut to what they
+// can be rather than kept whole.
+func TestALongPathAndALongModeAreCutToWhatTheyCanBe(t *testing.T) {
+ long := strings.Repeat("p", maxLocationPath*4)
+ u, ok := decodeUpdate(raw(t, map[string]any{
+ "sessionUpdate": "tool_call", "toolCallId": "c1", "kind": "edit",
+ "locations": []any{map[string]any{"path": "/work/" + long}},
+ }))
+ require.True(t, ok)
+ require.Len(t, u.Locations, 1)
+ assert.Len(t, u.Locations[0], maxLocationPath)
+ assert.True(t, strings.HasPrefix(u.Locations[0], "/work/"), "what is kept is the leading part, which is what the policy judges")
+
+ h := newHarness(t)
+ s := h.open().(*session)
+ s.reportMode(strings.Repeat("m", maxMode*4))
+ s.mu.Lock()
+ mode := s.mode
+ s.mu.Unlock()
+ assert.Len(t, mode, maxMode)
+}
+
+// ---------------------------------------------------------------- what a decision may rest on
+
+// A tool call announced where the session could not be asked about it — a
+// load's replayed history — tells the session nothing: a later request that
+// names only that call's id is decided without the name the replay carried.
+func TestAReplayedToolCallCannotNameALaterRequest(t *testing.T) {
+ h := newHarness(t)
+ h.policy.allow = func(r driver.PermissionRequest) bool { return strings.HasPrefix(r.Tool, "mcp__basecamp__") }
+ h.sc.SessionID = "sess-earlier"
+ h.sc.Replay = []json.RawMessage{
+ raw(t, map[string]any{"sessionUpdate": "tool_call", "toolCallId": "replayed-1", "kind": "other",
+ "name": "mcp__basecamp__note", "status": "in_progress"}),
+ }
+ h.turns(turnScript{Steps: []step{
+ {Permission: permission(t, map[string]any{"toolCallId": "replayed-1", "kind": "other"}, standardOptions()...)},
+ }, Stop: "end_turn"})
+
+ s, err := h.driver().LoadSession(context.Background(), h.config(), "sess-earlier")
+ require.NoError(t, err)
+ defer s.Close()
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+
+ requests := h.policy.requests()
+ require.Len(t, requests, 1)
+ assert.Empty(t, requests[0].Tool, "a call the replay named is not a call this session announced")
+ assert.NotEmpty(t, res.Refusals, "so it is decided on its kind, and refused")
+ outcomes := h.record().Outcomes
+ require.Len(t, outcomes, 1)
+ _, option := outcomeOf(t, outcomes[0])
+ assert.Equal(t, "reject", option)
+}
+
+// Two options of one id say nothing about which the agent would act on, so
+// none is selected and the request is answered as canceled.
+func TestOptionsSharingAnIDSelectNothing(t *testing.T) {
+ h := newHarness(t)
+ h.policy.allow = func(driver.PermissionRequest) bool { return true }
+ h.turns(turnScript{Steps: []step{
+ {Permission: permission(t, map[string]any{"toolCallId": "dup-1", "kind": "read"},
+ [2]string{"x", "allow_once"}, [2]string{"x", "reject_once"})},
+ }, Stop: "end_turn"})
+ s := h.open()
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ outcomes := h.record().Outcomes
+ require.Len(t, outcomes, 1)
+ kind, option := outcomeOf(t, outcomes[0])
+ assert.Equal(t, outcomeCanceled, kind, "nothing of that list is selected")
+ assert.Empty(t, option)
+ assert.NotEmpty(t, res.Refusals, "and it is a call this session did not allow")
+}
+
+// A request turned away at the connection's own bound is answered later, off
+// the reading goroutine; the turn it belongs to is the one it was read in.
+func TestARequestRefusedAtTheBoundCarriesTheTurnItWasReadIn(t *testing.T) {
+ fromClient, toAgent := io.Pipe()
+ toClient, fromAgent := io.Pipe()
+ t.Cleanup(func() { _ = toAgent.Close(); _ = fromAgent.Close() })
+ go func() { _, _ = io.Copy(io.Discard, fromClient) }()
+
+ c := newConn(toAgent)
+ mine := &claimed{turn: &turn{}}
+ c.claim = func(string) any { return mine }
+ heard := make(chan any, 1)
+ c.onBusy = func(_ string, _ json.RawMessage, got any) { heard <- got }
+ released := make(chan any, 1)
+ c.release = func(got any) { released <- got }
+ hold := make(chan struct{})
+ t.Cleanup(func() { close(hold) })
+ c.onRequest = func(json.RawMessage, string, json.RawMessage, any) { <-hold }
+ go func() { _ = c.read(toClient) }()
+
+ for i := range maxHandlers + 1 {
+ _, err := fmt.Fprintf(fromAgent, `{"jsonrpc":"2.0","id":%d,"method":"session/request_permission","params":{}}`+"\n", i)
+ require.NoError(t, err)
+ }
+ select {
+ case got := <-heard:
+ assert.Same(t, mine, got, "the refusal is recorded against what the request was read in")
+ case <-time.After(10 * time.Second):
+ t.Fatal("the refusal was never heard")
+ }
+ select {
+ case got := <-released:
+ assert.Same(t, mine, got, "and the turn's end stops waiting for it once it is answered")
+ case <-time.After(10 * time.Second):
+ t.Fatal("the claim was never given up")
+ }
+}
+
+// ---------------------------------------------------------------- nothing hangs
+
+// An agent that has stopped reading its input cannot hold a prompt past its
+// context, however much of the prompt is still in the pipe.
+func TestAPromptWhoseWriteIsStuckReturnsWithItsContext(t *testing.T) {
+ h := newHarness(t)
+ h.sc.StopReadingAfter = "session/set_config_option"
+ s := h.open()
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+ start := time.Now()
+ _, err := s.Prompt(ctx, strings.Repeat("prompt ", 1<<20))
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ assert.Less(t, time.Since(start), 10*time.Second)
+}
+
+// A cancel is one per turn: a second call ends nothing more and sends
+// nothing more.
+func TestASecondCancelIsNotASecondCancel(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{WaitForCancel: true, Stop: "cancelled"}) //nolint:misspell // ACP's wire value
+ s := h.open()
+ answers := make(chan error, 1)
+ go func() {
+ _, err := s.Prompt(context.Background(), "go")
+ answers <- err
+ }()
+ require.Eventually(t, func() bool { return slices.Contains(h.record().Methods, "session/prompt") },
+ 10*time.Second, 10*time.Millisecond)
+ require.NoError(t, s.Cancel(context.Background()))
+ require.NoError(t, s.Cancel(context.Background()), "a second cancel is not an error")
+ <-answers
+ cancels := 0
+ for _, m := range h.record().Methods {
+ if m == "session/cancel" {
+ cancels++
+ }
+ }
+ assert.Equal(t, 1, cancels, "one cancel per turn, whoever asks twice")
+}
+
+// ---------------------------------------------------------------- configuration
+
+// Two MCP servers of one name are one name in the agent's account of them, so
+// there is no session this driver can judge.
+func TestTwoMCPServersOfOneNameAreUnusable(t *testing.T) {
+ h := newHarness(t)
+ h.withConfig = func(cfg driver.SessionConfig) driver.SessionConfig {
+ cfg.MCPServers = append(cfg.MCPServers, cfg.MCPServers[0])
+ return cfg
+ }
+ _, err := h.driver().NewSession(context.Background(), h.config())
+ require.ErrorIs(t, err, driver.ErrUnusable)
+ require.ErrorIs(t, err, driver.ErrNotStarted)
+ _, statErr := os.Stat(h.sc.Record)
+ assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was started")
+}
+
+// What the preflight reads is the environment the adapter will run in, not
+// the connector's: a session's own environment is what the adapter resolves
+// its configuration against.
+func TestThePreflightReadsTheEnvironmentTheAdapterWillHave(t *testing.T) {
+ h := newHarness(t)
+ h.lookup["CODEX_HOME"] = "/connector/home"
+ h.withConfig = func(cfg driver.SessionConfig) driver.SessionConfig {
+ cfg.Env = append(cfg.Env, "CODEX_HOME=/session/home")
+ return cfg
+ }
+ seen := make(chan string, 1)
+ d := h.driver()
+ d.opts.Adapter.Preflight = func(_ string, lookup func(string) (string, bool)) error {
+ v, _ := lookup("CODEX_HOME")
+ seen <- v
+ return nil
+ }
+ s, err := d.NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+ assert.Equal(t, "/session/home", <-seen)
+}
+
+// A name the session never gave is not kept as a name, because a name put
+// through a sanitizer can come out as one the session did give: an account
+// naming "base\acamp" as connected vouches for nothing.
+func TestAForeignNameThatReadsAsAGivenOneVouchesForNothing(t *testing.T) {
+ h := newHarness(t)
+ s := h.open().(*session)
+ s.mu.Lock()
+ s.id = ""
+ s.mcpStatus = MCPStatusInit
+ s.mcpConfirmed = false
+ s.earlyInit = nil
+ s.mu.Unlock()
+
+ s.onSDKMessage(raw(t, map[string]any{
+ "sessionId": "sess-good",
+ "message": map[string]any{"type": "system", "subtype": "init", "mcp_servers": []any{
+ map[string]any{"name": "base\acamp", "status": "connected"},
+ }},
+ }))
+ s.nameSession("sess-good")
+
+ s.mu.Lock()
+ confirmed, unsafe := s.mcpConfirmed, s.unsafe
+ s.mu.Unlock()
+ assert.False(t, confirmed, "a server the session never gave vouches for no server it did")
+ require.ErrorIs(t, unsafe, ErrMCPServerNotConnected)
+}
+
+// A permission request read in no turn belongs to no turn: a prompt that
+// started after it was read did not ask for it, and its refusal is not on
+// that prompt's result. The ledger still has it.
+func TestARefusalReadInNoTurnIsOnNoTurnsResult(t *testing.T) {
+ h := newHarness(t)
+ recorder := &drivertest.Refusals{}
+ h.withConfig = func(cfg driver.SessionConfig) driver.SessionConfig {
+ cfg.Refusals = recorder
+ return cfg
+ }
+ s := h.open().(*session)
+ outside := s.claim("session/request_permission")
+ require.Nil(t, turnOf(outside), "no turn was in flight when it was read")
+ t.Cleanup(func() { s.release(outside) })
+
+ later := &turn{done: make(chan struct{})}
+ s.mu.Lock()
+ s.turn = later
+ s.mu.Unlock()
+ s.record(driver.PermissionRequest{ToolCallID: "outside-1", Tool: "Bash", Kind: driver.ToolExecute}, turnOf(outside))
+
+ s.mu.Lock()
+ refusals := len(later.refusals)
+ s.mu.Unlock()
+ assert.Zero(t, refusals, "a turn that began after the request was read did not ask for it")
+ assert.Equal(t, []driver.Refusal{{ToolCallID: "outside-1", Tool: "Bash"}}, recorder.Recorded(),
+ "and it is still the driver's own record")
+}
+
+// A refusal with no tool call id is counted every time it happens: only an id
+// can say that two refusals are one call.
+func TestRefusalsWithNoToolCallIDAreCountedEveryTime(t *testing.T) {
+ h := newHarness(t)
+ recorder := &drivertest.Refusals{}
+ h.withConfig = func(cfg driver.SessionConfig) driver.SessionConfig {
+ cfg.Refusals = recorder
+ return cfg
+ }
+ // Three requests naming no call at all, identical in every field.
+ nameless := map[string]any{"kind": "execute"}
+ h.turns(turnScript{Steps: []step{
+ {Permission: permission(t, nameless, standardOptions()...)},
+ {Permission: permission(t, nameless, standardOptions()...)},
+ {Permission: permission(t, nameless, standardOptions()...)},
+ }, Stop: "end_turn"})
+ s := h.open()
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ assert.Len(t, res.Refusals, 3, "three nameless denials are three refusals")
+ assert.Len(t, recorder.Recorded(), 3, "and three records")
+}
+
+// A call whose paths this driver could not carry whole is a call the policy
+// cannot place: it is refused without being asked, rather than judged on the
+// paths that fit. The policy allows an edit only when every path it names is
+// inside the working directory, so judging a subset is how a refusal becomes
+// an allow.
+func TestACallWhosePathsDoNotFitIsRefusedUnasked(t *testing.T) {
+ h := newHarness(t)
+ h.policy.allow = func(driver.PermissionRequest) bool { return true }
+ inside := make([]any, 0, maxLocations+1)
+ for i := range maxLocations {
+ inside = append(inside, map[string]any{"path": filepath.Join(h.dir, fmt.Sprintf("f%d", i))})
+ }
+ // The path that would have refused the call is the one past the cap.
+ tooMany := append(slices.Clone(inside), map[string]any{"path": "/etc/shadow"})
+ tooLong := []any{map[string]any{"path": filepath.Join(h.dir, strings.Repeat("s/", 3000)+"x")}}
+ h.turns(turnScript{Steps: []step{
+ {Permission: permission(t, map[string]any{"toolCallId": "many-1", "kind": "edit", "locations": tooMany}, standardOptions()...)},
+ {Permission: permission(t, map[string]any{"toolCallId": "long-1", "kind": "edit", "locations": tooLong}, standardOptions()...)},
+ // And a call announced with paths that did not fit is still
+ // unplaceable when the agent asks about it by id alone.
+ {Update: raw(t, map[string]any{"sessionUpdate": "tool_call", "toolCallId": "many-2", "kind": "edit",
+ "status": "in_progress", "locations": tooMany})},
+ {Permission: permission(t, map[string]any{"toolCallId": "many-2", "kind": "edit"}, standardOptions()...)},
+ }, Stop: "end_turn"})
+ s := h.open()
+ res, err := s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+
+ assert.Empty(t, h.policy.requests(), "a call the policy cannot place is not put to it")
+ outcomes := make([]string, 0, 3)
+ for _, o := range h.record().Outcomes {
+ kind, option := outcomeOf(t, o)
+ outcomes = append(outcomes, kind)
+ assert.Empty(t, option, "refused with no option of the agent's")
+ }
+ assert.Equal(t, []string{outcomeCanceled, outcomeCanceled, outcomeCanceled}, outcomes)
+ assert.Len(t, res.Refusals, 3, "and each is a refusal of this driver's")
+}
+
+// A tool call that has finished is forgotten whatever the session could be
+// asked at that moment: what it said of itself must not outlive it and
+// describe a call a later turn is asked about.
+func TestAFinishedToolCallIsForgottenEvenOutsideATurn(t *testing.T) {
+ h := newHarness(t)
+ s := h.open().(*session)
+ first := &turn{done: make(chan struct{})}
+ s.mu.Lock()
+ s.turn = first
+ s.mu.Unlock()
+ s.noteTool(sessionUpdate{ToolCallID: "X", Name: "mcp__basecamp__note", Kind: "read", Status: "in_progress"})
+ s.mu.Lock()
+ _, known := s.tools["X"]
+ s.mu.Unlock()
+ require.True(t, known, "a call announced in a turn is what the session knows of it")
+
+ // The turn is answered, and the call completes after it: outside any turn.
+ s.mu.Lock()
+ s.turn = nil
+ s.mu.Unlock()
+ s.noteTool(sessionUpdate{ToolCallID: "X", Status: "completed"})
+ s.mu.Lock()
+ _, stillKnown := s.tools["X"]
+ s.mu.Unlock()
+ assert.False(t, stillKnown, "a finished call is forgotten")
+
+ second := &turn{done: make(chan struct{})}
+ s.mu.Lock()
+ s.turn = second
+ s.mu.Unlock()
+ info := s.noteTool(sessionUpdate{ToolCallID: "X", Kind: "execute"})
+ assert.Empty(t, info.name, "so the next turn's request by that id inherits no name")
+ assert.Equal(t, driver.ToolExecute, info.kind)
+}
+
+// Whose account of the MCP servers this is, is decided under one lock: the
+// session's id can arrive while an account is being read, and an account read
+// as nobody's must not then be applied as this session's.
+func TestAnAccountIsNeverAppliedToTheSessionItDoesNotName(t *testing.T) {
+ h := newHarness(t)
+ s := h.open().(*session)
+ foreign := raw(t, map[string]any{
+ "sessionId": "sess-other",
+ "message": map[string]any{"type": "system", "subtype": "init", "mcp_servers": []any{
+ map[string]any{"name": "basecamp", "status": "connected"},
+ }},
+ })
+ // The two meet on a barrier: the account is read as nobody's just as the
+ // session's own id arrives.
+ for range 50000 {
+ s.mu.Lock()
+ s.id = ""
+ s.mcpStatus = MCPStatusInit
+ s.mcpConfirmed = false
+ s.earlyInit = nil
+ s.mu.Unlock()
+ ready, done := make(chan struct{}), make(chan struct{})
+ go func() {
+ close(ready)
+ s.onSDKMessage(foreign)
+ close(done)
+ }()
+ <-ready
+ s.nameSession("sess-real")
+ <-done
+ s.mu.Lock()
+ confirmed := s.mcpConfirmed
+ s.mu.Unlock()
+ if confirmed {
+ t.Fatal("another session's account vouched for this session's MCP servers")
+ }
+ }
+}
+
+// A cancel never reaches a turn whose prompt is still on its way: the turn
+// holds its place in the queue until its write is done, so no session/cancel
+// can be written for a prompt the agent has not been sent.
+func TestACancelDoesNotTouchATurnWhosePromptIsStillBeingWritten(t *testing.T) {
+ h := newHarness(t)
+ h.sc.StopReadingAfter = "session/set_config_option"
+ h.grace = 500 * time.Millisecond
+ s := h.open().(*session)
+ go func() { _, _ = s.Prompt(context.Background(), strings.Repeat("prompt ", 1<<20)) }()
+ require.Eventually(t, func() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.turn != nil
+ }, 10*time.Second, 5*time.Millisecond, "the turn is in flight")
+
+ err := s.Cancel(context.Background())
+ require.Error(t, err, "the agent is not reading, so the cancel could not be sent")
+ s.mu.Lock()
+ canceled := s.turn != nil && s.turn.canceled
+ s.mu.Unlock()
+ assert.False(t, canceled, "and it did not mark a turn whose prompt is still being written")
+}
+
+// ---------------------------------------------------------------- what the adapter says it got
+
+// chunk is one agent_message_chunk of text, as an adapter answers its own
+// read-back command.
+func chunk(t *testing.T, text string) json.RawMessage {
+ t.Helper()
+ return raw(t, map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": text}})
+}
+
+// The boundary's one guarantee: what the adapter says it is running is
+// compared with what the session declared, after it is running, and a
+// difference ends the session before anyone is handed it.
+func TestASessionRunsOnlyTheMCPServersTheAdapterSaysItGot(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ readback Readback
+ answer string
+ wantErr bool
+ }{
+ {"claude counts them and agrees", Readback{Command: "/mcp", Parse: claudeMCPReport},
+ "1 MCP server(s): 1 connected, 0 not connected, 0 disabled. Use `/mcp` in the terminal for details.", false},
+ {"claude counts one too many", Readback{Command: "/mcp", Parse: claudeMCPReport},
+ "2 MCP server(s): 2 connected, 0 not connected, 0 disabled.", true},
+ {"claude counts one unusable", Readback{Command: "/mcp", Parse: claudeMCPReport},
+ "1 MCP server(s): 0 connected, 1 not connected, 0 disabled.", true},
+ {"claude says nothing this can read", Readback{Command: "/mcp", Parse: claudeMCPReport},
+ "MCP is fine, trust me.", true},
+ {"codex names what the session gave", Readback{Command: "/mcp", Parse: codexMCPReport},
+ "Configured MCP servers:\n- basecamp", false},
+ {"codex names its own built-in too", Readback{Command: "/mcp", Parse: codexMCPReport, BuiltIn: []string{"codex_apps"}},
+ "Configured MCP servers:\n- codex_apps: 49 tools, 27 resources, auth=bearerToken\n- basecamp", false},
+ {"codex names a built-in nobody allowed", Readback{Command: "/mcp", Parse: codexMCPReport},
+ "Configured MCP servers:\n- codex_apps: 49 tools, 27 resources, auth=bearerToken\n- basecamp", true},
+ {"codex names a server of the host's", Readback{Command: "/mcp", Parse: codexMCPReport},
+ "Configured MCP servers:\n- basecamp\n- host-secrets: 3 tools", true},
+ {"codex does not have the session's own", Readback{Command: "/mcp", Parse: codexMCPReport},
+ "Configured MCP servers:\n- something-else", true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ h := newHarness(t)
+ h.turns(turnScript{Steps: []step{{Update: chunk(t, tc.answer)}}, Stop: "end_turn"})
+ d := h.driver()
+ d.opts.Adapter.Readback = tc.readback
+ s, err := d.NewSession(context.Background(), h.config())
+ if tc.wantErr {
+ require.ErrorIs(t, err, ErrMCPReadback)
+ require.ErrorIs(t, err, driver.ErrSessionUnverified, "a session that is not the one asked for")
+ assert.Nil(t, s)
+ waitGone(t, h.record().PID)
+ return
+ }
+ require.NoError(t, err)
+ defer s.Close()
+ assert.Contains(t, string(h.record().Params["session/prompt"]), "/mcp", "the adapter was asked")
+ select {
+ case u := <-s.Updates():
+ t.Fatalf("the read-back was reported as progress: %+v", u)
+ default:
+ }
+ })
+ }
+}
+
+// The adapter's own answer is read once and kept nowhere: the read-back's own
+// text is not in an update, and a chunk longer than the answer can be is cut.
+func TestTheReadbackTextIsReadOnceAndKeptNowhere(t *testing.T) {
+ h := newHarness(t)
+ long := strings.Repeat("x", maxReadback*4)
+ h.turns(turnScript{Steps: []step{
+ {Update: chunk(t, "Configured MCP servers:\n- basecamp\n"+long)},
+ }, Stop: "end_turn"}, turnScript{Steps: []step{{Update: chunk(t, "secret words")}}, Stop: "end_turn"})
+ d := h.driver()
+ d.opts.Adapter.Readback = Readback{Command: "/mcp", Parse: codexMCPReport}
+ s, err := d.NewSession(context.Background(), h.config())
+ require.NoError(t, err)
+ defer s.Close()
+
+ sess := s.(*session)
+ sess.mu.Lock()
+ collecting := sess.readback
+ sess.mu.Unlock()
+ assert.Nil(t, collecting, "nothing is collected once the answer has been read")
+
+ _, err = s.Prompt(context.Background(), "go")
+ require.NoError(t, err)
+ for {
+ select {
+ case u := <-s.Updates():
+ assert.NotContains(t, fmt.Sprintf("%+v", u), "secret words", "an update carries no text of the agent's")
+ continue
+ default:
+ }
+ break
+ }
+}
diff --git a/internal/connector/driver/acp/adapters.go b/internal/connector/driver/acp/adapters.go
new file mode 100644
index 000000000..aadb23dc0
--- /dev/null
+++ b/internal/connector/driver/acp/adapters.go
@@ -0,0 +1,438 @@
+package acp
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/basecamp/basecamp-cli/internal/connector/driver"
+ "github.com/basecamp/basecamp-cli/internal/connector/driver/claude"
+)
+
+// Adapter is one ACP agent adapter at a pinned version: what it is called,
+// what it may take from the connector's environment, and which of its modes
+// is the asking mode for each connector permission mode. Mode ids are not
+// portable across adapters, so they are named here and nowhere else.
+type Adapter struct {
+ // Name is the adapter's executable, as connect.json names it.
+ Name string
+ // Package is its npm package, and the agentInfo.name it reports at
+ // initialize.
+ Package string
+ // Version is the pinned version, and the agentInfo.version it must report.
+ Version string
+ // Env names what the adapter may take from the connector's environment
+ // besides driver.BaseEnv: where its agent's configuration lives and how it
+ // authenticates. Exact names only. Nothing that swaps the agent binary the
+ // adapter bundles (CLAUDE_CODE_EXECUTABLE, CODEX_PATH) is among them: the
+ // pin covers the agent too.
+ Env []string
+ // SetEnv are variables the driver itself sets for the adapter: its own
+ // switches, never a secret and never taken from the connector's
+ // environment.
+ SetEnv map[string]string
+ // Modes maps a connector permission mode to the adapter's asking mode:
+ // the mode in which the agent sends session/request_permission for what
+ // it would otherwise do unasked. A permission mode with no entry cannot
+ // be run.
+ Modes map[driver.PermissionMode]string
+ // SessionMeta is the _meta sent with session/new, session/load and
+ // session/resume: the adapter's own switches, for what ACP itself cannot
+ // say. Never a secret, never content.
+ SessionMeta map[string]any
+ // LoadSession is what the pinned version advertises, until a session
+ // reports what the installed one does.
+ LoadSession bool
+ // MCPStatus is how the adapter tells the client whether the session's MCP
+ // servers connected: MCPStatusInit (the agent's init message, which must
+ // report every server connected before the first turn ends) or
+ // MCPStatusStartupFailures (a failed startup is reported, success is
+ // not). The driver ends a session whose server did not connect.
+ MCPStatus MCPStatus
+ // Readback is how the adapter is asked for its own account of the MCP
+ // configuration the session is running, and how that answer is read. It
+ // is the session's one check on the boundary, made after the adapter is
+ // running (see mcp.go).
+ Readback Readback
+ // Preflight refuses, before anything starts, a session the adapter would
+ // run with configuration the connector cannot switch off: nil when there is
+ // none to check.
+ Preflight func(cwd string, lookup func(string) (string, bool)) error
+}
+
+// ClaudeAgentACP is Claude Code over ACP.
+//
+// Its asking mode is "default" (the adapter's "Manual": ask before every
+// change, inside the working directory too). Its session _meta turns off the
+// host's Claude Code settings, which would otherwise bring the host's
+// defaultMode, allow rules and hooks into the session; takes
+// bypassPermissions out of the session's mode catalog altogether; and makes
+// the session's mcpServers the only MCP servers it has (strictMcpConfig), so
+// a user-scope or project .mcp.json server, one named basecamp among them,
+// never loads beside or instead of the connector's.
+var ClaudeAgentACP = Adapter{
+ Name: "claude-agent-acp",
+ Package: "@agentclientprotocol/claude-agent-acp",
+ Version: "0.78.0",
+ Env: append([]string{}, claude.Env...),
+ Modes: map[driver.PermissionMode]string{
+ driver.ModeEditsInWorkDir: "default",
+ },
+ SessionMeta: map[string]any{
+ "claudeCode": map[string]any{
+ "emitRawSDKMessages": []map[string]string{{"type": "system", "subtype": "init"}},
+ "options": map[string]any{
+ "settingSources": []string{},
+ "allowDangerouslySkipPermissions": false,
+ "strictMcpConfig": true,
+ // A plan-mode switch is the model leaving the mode the driver
+ // verified, which ends the session; the worker has no one to
+ // present a plan to anyway.
+ "disallowedTools": []string{"EnterPlanMode", "ExitPlanMode"},
+ },
+ },
+ },
+ // Claude Code's init message, and only it, is forwarded: the driver
+ // reads each MCP server's name and status from it and nothing else.
+ MCPStatus: MCPStatusInit,
+ Readback: Readback{Command: "/mcp", Parse: claudeMCPReport},
+ LoadSession: true,
+}
+
+// CodexACP is Codex over ACP.
+//
+// Its asking mode is "read-only" (the adapter's "Ask for approval"). Codex
+// gates less than Claude in it: work inside the workspace goes through
+// unasked, and only what reaches outside it is put to the policy. Same policy,
+// different reach; neither is containment.
+//
+// codex-acp runs `codex app-server`, which has no --ignore-user-config, so
+// the host's config is switched off where a session's config can do it
+// (CODEX_CONFIG, which the adapter layers onto every thread it starts): the
+// host's plugins, hooks and apps, its skills' instructions, and the parts of
+// the environment a model's shell command would otherwise inherit.
+//
+// The adapter's modes fix the sandbox per turn, and "read-only" leaves /tmp
+// and $TMPDIR writable: Codex writes there unasked, where the policy never
+// sees it. The session also opens in the asking mode (INITIAL_AGENT_MODE)
+// rather than in the adapter's default, before the driver sets and confirms
+// it.
+var CodexACP = Adapter{
+ Name: "codex-acp",
+ Package: "@agentclientprotocol/codex-acp",
+ Version: "1.12.0",
+ Env: []string{"CODEX_HOME", "OPENAI_API_KEY", "CODEX_API_KEY", "OPENAI_BASE_URL"},
+ SetEnv: map[string]string{
+ "CODEX_CONFIG": codexConfig,
+ "INITIAL_AGENT_MODE": "read-only",
+ // Without it, codex-acp drops a requested MCP server whose name any
+ // config layer already uses, and the agent gets that one instead.
+ "DISABLE_MCP_CONFIG_FILTERING": "true",
+ },
+ Preflight: codexPreflight,
+ MCPStatus: MCPStatusStartupFailures,
+ // codex brings its own apps connector, which its /mcp lists whatever the
+ // session declared. Its tools are not offered to the session's model
+ // (features.apps is false in codexConfig, and compatibility check 9 asks
+ // the agent what it can call), so it is named here and nothing else is.
+ Readback: Readback{Command: "/mcp", Parse: codexMCPReport, BuiltIn: []string{"codex_apps"}},
+ Modes: map[driver.PermissionMode]string{
+ driver.ModeEditsInWorkDir: "read-only",
+ },
+ LoadSession: true,
+}
+
+// Readback is how an adapter is asked what MCP configuration it is actually
+// running, and how its answer is read. Both pinned adapters answer a command
+// of their own — claude-agent-acp's and codex-acp's "/mcp" — and both answer
+// it themselves, without the model: the turn costs no tokens, and the answer
+// is the adapter's, not something a prompt could talk it into.
+type Readback struct {
+ // Command is the prompt that asks for it. Empty means the adapter cannot
+ // be asked, and a session on it can only be checked as it runs.
+ Command string
+ // Parse reads the adapter's answer. An answer it cannot read is a session
+ // this driver will not vouch for, so a parse error ends the session.
+ Parse func(text string) (MCPReport, error)
+ // BuiltIn are servers the pinned adapter brings itself, which are in its
+ // answer whatever the session declared. Each one is here because its
+ // tools are not offered to the model — proven, per adapter, by the
+ // compatibility check — and for no other reason.
+ BuiltIn []string
+}
+
+// MCPReport is an adapter's own account of the MCP configuration a session is
+// running. An adapter that names its servers fills Names; one that only counts
+// them fills Count and Unusable.
+type MCPReport struct {
+ Names []string
+ Count int
+ Unusable int
+}
+
+// ErrMCPReadback is an adapter whose account of its own MCP configuration
+// cannot be read, or does not match what the session declared.
+var ErrMCPReadback = fmt.Errorf("%w: the agent is not running the MCP configuration the session declared", driver.ErrSessionUnverified)
+
+// claudeMCPReport reads claude-agent-acp's answer, which counts the servers
+// rather than naming them: "1 MCP server(s): 1 connected, 0 not connected, 0
+// disabled."
+var claudeMCPCounts = regexp.MustCompile(`(\d+) MCP server\(s\): (\d+) connected, (\d+) not connected, (\d+) disabled`)
+
+func claudeMCPReport(text string) (MCPReport, error) {
+ m := claudeMCPCounts.FindStringSubmatch(text)
+ if m == nil {
+ return MCPReport{}, fmt.Errorf("%w: its answer does not count them", ErrMCPReadback)
+ }
+ total, err1 := strconv.Atoi(m[1])
+ connected, err2 := strconv.Atoi(m[2])
+ unconnected, err3 := strconv.Atoi(m[3])
+ disabled, err4 := strconv.Atoi(m[4])
+ if err1 != nil || err2 != nil || err3 != nil || err4 != nil {
+ return MCPReport{}, fmt.Errorf("%w: its counts are not numbers", ErrMCPReadback)
+ }
+ if connected+unconnected+disabled != total {
+ return MCPReport{}, fmt.Errorf("%w: its counts do not add up", ErrMCPReadback)
+ }
+ return MCPReport{Count: total, Unusable: unconnected + disabled}, nil
+}
+
+// codexMCPReport reads codex-acp's answer, which names them:
+//
+// Configured MCP servers:
+// - codex_apps: 49 tools, 27 resources, auth=bearerToken
+// - basecamp
+var codexMCPHeader = "Configured MCP servers:"
+
+func codexMCPReport(text string) (MCPReport, error) {
+ _, list, found := strings.Cut(text, codexMCPHeader)
+ if !found {
+ return MCPReport{}, fmt.Errorf("%w: its answer does not list them", ErrMCPReadback)
+ }
+ report := MCPReport{}
+ for _, line := range strings.Split(list, "\n") {
+ line = strings.TrimSpace(line)
+ name, ok := strings.CutPrefix(line, "- ")
+ if !ok {
+ continue
+ }
+ if before, _, cut := strings.Cut(name, ":"); cut {
+ name = before
+ }
+ if name = strings.TrimSpace(name); name != "" {
+ report.Names = append(report.Names, name)
+ }
+ }
+ if len(report.Names) == 0 {
+ return MCPReport{}, fmt.Errorf("%w: it listed no server at all", ErrMCPReadback)
+ }
+ report.Count = len(report.Names)
+ return report, nil
+}
+
+// MCPStatus names how an adapter reports its MCP servers' startup.
+type MCPStatus string
+
+const (
+ // MCPStatusInit: claude-agent-acp forwards Claude Code's system/init
+ // message, with each MCP server's status, as a _claude/sdkMessage
+ // notification when the session asks for it.
+ MCPStatusInit MCPStatus = "init"
+ // MCPStatusStartupFailures: codex-acp reports a server that failed or
+ // was canceled at startup as a failed tool call named
+ // mcp_startup..
+ MCPStatusStartupFailures MCPStatus = "startup_failures"
+)
+
+// ErrMCPServerNotConnected is a session whose MCP server did not connect: the
+// worker would run without the tools the connector gave it, the Basecamp
+// tools and its task token among them.
+var ErrMCPServerNotConnected = fmt.Errorf("%w: an MCP server of the session did not connect", driver.ErrSessionUnverified)
+
+// ErrForeignMCPConfig is agent configuration that declares MCP servers of its
+// own, which the connector cannot keep out of a session.
+var ErrForeignMCPConfig = errors.New("acp: the agent's configuration declares MCP servers of its own")
+
+// escapedTOMLKey is a table header or a key whose name carries a backslash
+// escape.
+var escapedTOMLKey = regexp.MustCompile(`^\s*(\[\[?[^\]]*\\|[^=\n]*\\[^=\n]*=)`)
+
+// codexPreflight refuses a session when a Codex config layer declares MCP
+// servers: the user's ($CODEX_HOME, or ~/.codex), the system's, or a
+// project's .codex/config.toml in the working directory or above it. Codex
+// merges every layer into the session, and a server declared there would run
+// beside the connector's, or, named basecamp, in place of it with every tool
+// allowed; in the asking mode its tool calls need not be put to the policy at
+// all.
+//
+// It reads for the name, not the TOML: the name anywhere in the file — a
+// table header, a dotted key, an inline table, a profile, a comment — refuses
+// the session. Parsing it would mean matching Codex's own merge of profiles,
+// includes and overrides, and being wrong there is being wrong in the
+// direction that runs a foreign server. A false alarm refuses a session; a
+// miss would not.
+//
+// It covers the layers a file on this machine can hold. Codex also takes
+// configuration from layers this cannot read — an MDM profile, a cloud-managed
+// config, a plugin — so it is a guard, not a proof. What would be a proof is
+// the effective configuration the app server reports, which ACP does not carry.
+func codexPreflight(cwd string, lookup func(string) (string, bool)) error {
+ var files []string
+ home := ""
+ if v, ok := lookup("CODEX_HOME"); ok && v != "" {
+ // Codex reads a relative CODEX_HOME against the working directory.
+ home = v
+ if !filepath.IsAbs(v) {
+ home = filepath.Join(cwd, v)
+ }
+ } else if v, ok := lookup("HOME"); ok && filepath.IsAbs(v) {
+ home = filepath.Join(v, ".codex")
+ }
+ if home != "" {
+ files = append(files, filepath.Join(home, "config.toml"), filepath.Join(home, "managed_config.toml"))
+ }
+ files = append(files, "/etc/codex/config.toml", "/etc/codex/managed_config.toml")
+ for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
+ files = append(files, filepath.Join(dir, ".codex", "config.toml"))
+ if filepath.Dir(dir) == dir {
+ break
+ }
+ }
+ for _, file := range files {
+ raw, err := os.ReadFile(file) //nolint:gosec // G304: codex's own config locations
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ // A file that is there and cannot be read is not a file this can
+ // say anything about, and Codex may read it where this cannot.
+ return fmt.Errorf("%w: %s cannot be read: %w", ErrForeignMCPConfig, file, err)
+ }
+ text := strings.TrimPrefix(string(raw), "\ufeff")
+ if strings.Contains(text, "mcp_servers") {
+ return fmt.Errorf("%w: %s (codex-acp would load them into the session)", ErrForeignMCPConfig, file)
+ }
+ for _, line := range strings.Split(text, "\n") {
+ if escapedTOMLKey.MatchString(line) {
+ // TOML decodes escapes in a quoted key, so "mcp\u005fservers"
+ // is mcp_servers to Codex and something else to a reader. A
+ // key this cannot read plainly is refused rather than guessed.
+ return fmt.Errorf("%w: %s has a key this cannot read (an escape in a quoted key)", ErrForeignMCPConfig, file)
+ }
+ }
+ }
+ return nil
+}
+
+// codexConfig is the thread config codex-acp layers onto every session. The
+// features are the ones the codex spawn driver disables; the same host
+// surfaces reach an app-server thread.
+const codexConfig = `{"features":{"apps":false,"plugins":false,"remote_plugin":false,"hooks":false,` +
+ `"browser_use":false,"browser_use_external":false,"computer_use":false,"in_app_browser":false,` +
+ `"image_generation":false,"memories":false,"skill_mcp_dependency_install":false,"tool_suggest":false},` +
+ `"skills":{"bundled":{"enabled":false},"include_instructions":false},` +
+ `"shell_environment_policy":{"inherit":"core"},"web_search":"disabled"}`
+
+// Adapters are the pinned adapters the driver runs.
+func Adapters() []Adapter { return []Adapter{ClaudeAgentACP, CodexACP} }
+
+// AdapterNamed is the pinned adapter of that name.
+func AdapterNamed(name string) (Adapter, bool) {
+ for _, a := range Adapters() {
+ if a.Name == name {
+ return a, true
+ }
+ }
+ return Adapter{}, false
+}
+
+// workerAdapters is the adapter for each worker connect.json names.
+var workerAdapters = map[string]Adapter{
+ "claude": ClaudeAgentACP,
+ "codex": CodexACP,
+}
+
+// ForWorker is the acp driver for a connect.json worker: its pinned adapter,
+// located in adaptersDir (DefaultAdaptersDir when empty). lookup reads the
+// connector's environment; os.LookupEnv when nil.
+func ForWorker(worker, adaptersDir string, lookup func(string) (string, bool)) (*Driver, error) {
+ a, ok := workerAdapters[worker]
+ if !ok {
+ return nil, fmt.Errorf("acp: no ACP adapter for worker %q", worker)
+ }
+ if adaptersDir == "" {
+ dir, err := DefaultAdaptersDir(lookup)
+ if err != nil {
+ return nil, err
+ }
+ adaptersDir = dir
+ }
+ bin, err := Locate(adaptersDir, a)
+ if err != nil {
+ return nil, err
+ }
+ return New(Options{Adapter: a, Binary: bin, Lookup: lookup})
+}
+
+// ErrAdapterMissing is an adapter that is not installed where the connector
+// was told to look.
+var ErrAdapterMissing = errors.New("acp: adapter not installed")
+
+// DefaultAdaptersDir is where `make acp-adapters` installs the pinned
+// adapters unless told otherwise: $XDG_DATA_HOME/basecamp/acp-adapters, or
+// ~/.local/share/basecamp/acp-adapters.
+func DefaultAdaptersDir(lookup func(string) (string, bool)) (string, error) {
+ if lookup == nil {
+ lookup = os.LookupEnv
+ }
+ if data, ok := lookup("XDG_DATA_HOME"); ok && filepath.IsAbs(data) {
+ return filepath.Join(data, "basecamp", "acp-adapters"), nil
+ }
+ home, ok := lookup("HOME")
+ if !ok || !filepath.IsAbs(home) {
+ return "", errors.New("acp: no home directory to find the adapters under")
+ }
+ return filepath.Join(home, ".local", "share", "basecamp", "acp-adapters"), nil
+}
+
+// Locate finds adapter a installed in dir (an npm prefix, as `npm ci --prefix
+// dir` makes one) and checks it is the pinned version. It never installs
+// anything: an adapter is downloaded when an operator installs it, never when
+// a task is dispatched.
+func Locate(dir string, a Adapter) (string, error) {
+ if !filepath.IsAbs(dir) {
+ return "", fmt.Errorf("acp: the adapters directory %q is not absolute", dir)
+ }
+ manifest := filepath.Join(dir, "node_modules", filepath.FromSlash(a.Package), "package.json")
+ raw, err := os.ReadFile(manifest) //nolint:gosec // G304: the operator's adapters directory
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return "", fmt.Errorf("%w: %s@%s is not in %s (run make acp-adapters)", ErrAdapterMissing, a.Package, a.Version, dir)
+ }
+ return "", fmt.Errorf("acp: read %s: %w", manifest, err)
+ }
+ var pkg struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ }
+ if err := json.Unmarshal(raw, &pkg); err != nil {
+ return "", fmt.Errorf("acp: read %s: %w", manifest, err)
+ }
+ if pkg.Name != a.Package || pkg.Version != a.Version {
+ return "", fmt.Errorf("acp: %s has %s@%s installed; the connector is pinned to %s@%s", dir, pkg.Name, pkg.Version, a.Package, a.Version)
+ }
+ bin := filepath.Join(dir, "node_modules", ".bin", a.Name)
+ info, err := os.Stat(bin)
+ if err != nil {
+ return "", fmt.Errorf("%w: %s has no %s executable: %w", ErrAdapterMissing, dir, a.Name, err)
+ }
+ if info.IsDir() || info.Mode().Perm()&0o111 == 0 {
+ return "", fmt.Errorf("%w: %s is not executable", ErrAdapterMissing, bin)
+ }
+ return bin, nil
+}
diff --git a/internal/connector/driver/acp/adapters/package-lock.json b/internal/connector/driver/acp/adapters/package-lock.json
new file mode 100644
index 000000000..18575e463
--- /dev/null
+++ b/internal/connector/driver/acp/adapters/package-lock.json
@@ -0,0 +1,1775 @@
+{
+ "name": "basecamp-connect-acp-adapters",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "basecamp-connect-acp-adapters",
+ "dependencies": {
+ "@agentclientprotocol/claude-agent-acp": "0.78.0",
+ "@agentclientprotocol/codex-acp": "1.12.0"
+ }
+ },
+ "node_modules/@agentclientprotocol/claude-agent-acp": {
+ "version": "0.78.0",
+ "resolved": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.78.0.tgz",
+ "integrity": "sha512-ivWFMmadPFRbc0vn+80B04qomeLdvVieWFu2WK0JFXvHt12Uqdn3Ujjm7rERvM8w4hjxUb1u5vRotu1C/cquCA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@agentclientprotocol/sdk": "1.4.0",
+ "@anthropic-ai/claude-agent-sdk": "0.3.270",
+ "zod": "4.6.5"
+ },
+ "bin": {
+ "claude-agent-acp": "dist/index.js"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@agentclientprotocol/codex-acp": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.12.0.tgz",
+ "integrity": "sha512-au6YcgvZmoUMuFrJlSYfJrHEB9SW4YHwUUS8fchYBIY2uwq/lJXwebgP4di9ANJulmr+mv2FE0CraY1agi5YYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@agentclientprotocol/sdk": "^1.4.0",
+ "@openai/codex": "^0.154.0",
+ "diff": "^9.0.0",
+ "open": "^11.0.1",
+ "vscode-jsonrpc": "^9.0.1",
+ "zod": "^4.0.0"
+ },
+ "bin": {
+ "codex-acp": "dist/index.js"
+ }
+ },
+ "node_modules/@agentclientprotocol/sdk": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.4.0.tgz",
+ "integrity": "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ },
+ "node_modules/@anthropic-ai/claude-agent-sdk": {
+ "version": "0.3.270",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.270.tgz",
+ "integrity": "sha512-sSfcm5Nhb+WHeBCxqeHRRQMUKPmFTL+zgv5xcRUVaFMLttfNEbn3IZJE+fLJJmy4h3J8zdc5sXdSa1JxyB8ppQ==",
+ "license": "SEE LICENSE IN README.md",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.270",
+ "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.270",
+ "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.270",
+ "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.270",
+ "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.270",
+ "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.270",
+ "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.270",
+ "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.270"
+ },
+ "peerDependencies": {
+ "@anthropic-ai/sdk": ">=0.93.0",
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "zod": "^4.0.0"
+ }
+ },
+ "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
+ "version": "0.3.270",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.270.tgz",
+ "integrity": "sha512-nk7BP+i559rheYz9DIwAfevd4DulQXP0mXPP+MeO2fGuIGFmzhE/c0JRm9YswXv5HdaYJvSzjGIB7dVA01NehA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
+ "version": "0.3.270",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.270.tgz",
+ "integrity": "sha512-89Uql8Oalm52ojdZZeNLU24LKrU+WG9QR7d6YP9ly4aY0YvQUJDaTosbDkijQngUETbPBFdKuVp6fNM8x0Zt3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
+ "version": "0.3.270",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.270.tgz",
+ "integrity": "sha512-iHPYqwetyeO4tZPzXyKZz0hUh2fLpwu/+biGTxxynikG3XrYknovZ/znGDA3TxjSFurqHf5IIDA+SOjh9OPs0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
+ "version": "0.3.270",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.270.tgz",
+ "integrity": "sha512-2BlLk2MAohWG2h43RKcjCA4ooMfBxzKf4yyYfOVv1DtYr8zPU876MHCT1VXB2BaemjKA0pdYJJBzH6pncwx6MQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
+ "version": "0.3.270",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.270.tgz",
+ "integrity": "sha512-ADaqz2viyAd0GUxdupYLX/K0YJb46xckNpEeWxyLK/9+26b/R5stbaGDyL29fIzyq1ymnNUOaCgEWEemgX0kEA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
+ "version": "0.3.270",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.270.tgz",
+ "integrity": "sha512-mzH3lnbzrbDGrTf75jLEmkbvkKRLLgmjLaWvf3QuUsgcw+aU69aOY0mW33oOrsuq5zg330uI3B4e68f4LbxNIA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
+ "version": "0.3.270",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.270.tgz",
+ "integrity": "sha512-Pexeu26cLZByhs6VlrawNYAEu+QE2YptvwNkXsmpLRm7Q/C/M0N/BZBmuUcikXrQQ9cuVzSjuuTTpZt64mvtLA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
+ "version": "0.3.270",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.270.tgz",
+ "integrity": "sha512-9UyfFcUYsyUZqSe/xX9nIJ1Og6i8FxhlQ35BDi79Ik5He87XFxEIGUvJuMcl7Mq2e3panyhekELFE+9H79xKdw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@anthropic-ai/sdk": {
+ "version": "0.126.0",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.126.0.tgz",
+ "integrity": "sha512-VhiZl6rA/8uC+MgDaOhEcAWaIZ2tPnIY885jlZqxrGrutUW2nqCtophBtlsX0tk5kChBUjV/1NVjL3y0M4OwUg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "json-schema-to-ts": "^3.1.1",
+ "standardwebhooks": "^1.0.0"
+ },
+ "bin": {
+ "anthropic-ai-sdk": "bin/cli"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "zod": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@hono/node-server": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
+ "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
+ "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@hono/node-server": "^1.19.9 || ^2.0.5",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@openai/codex": {
+ "version": "0.154.0",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.154.0.tgz",
+ "integrity": "sha512-FV/x1OHXYv/ifjf3mXj9ThTTAWcUZN6cGIRQRhRxkKNOPuImu1WW0c8ev1vUkE9XGH90dEnYG1tBjIkxRikg0w==",
+ "license": "Apache-2.0",
+ "bin": {
+ "codex": "bin/codex.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@openai/codex-darwin-arm64": "npm:@openai/codex@0.154.0-darwin-arm64",
+ "@openai/codex-darwin-x64": "npm:@openai/codex@0.154.0-darwin-x64",
+ "@openai/codex-linux-arm64": "npm:@openai/codex@0.154.0-linux-arm64",
+ "@openai/codex-linux-x64": "npm:@openai/codex@0.154.0-linux-x64",
+ "@openai/codex-win32-arm64": "npm:@openai/codex@0.154.0-win32-arm64",
+ "@openai/codex-win32-x64": "npm:@openai/codex@0.154.0-win32-x64"
+ }
+ },
+ "node_modules/@openai/codex-darwin-arm64": {
+ "name": "@openai/codex",
+ "version": "0.154.0-darwin-arm64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.154.0-darwin-arm64.tgz",
+ "integrity": "sha512-HP/vJCH/t2hB9Kg6hotN9UglClJ6/z584fal5lEP14C9gNAgAQS4/kTQC7l5V+BA3TqwDPwINSjul28cX8AYXg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@openai/codex-darwin-x64": {
+ "name": "@openai/codex",
+ "version": "0.154.0-darwin-x64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.154.0-darwin-x64.tgz",
+ "integrity": "sha512-2aqz+72Hop8PF2RYglQ4JnGjm3OlRIrTykJIT0hyLeUgM6NCFy09RgTmqRCoWliKQZjEn9jjZqUEp7QujAj77g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@openai/codex-linux-arm64": {
+ "name": "@openai/codex",
+ "version": "0.154.0-linux-arm64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.154.0-linux-arm64.tgz",
+ "integrity": "sha512-KmTCB6ST484zeYlPpKP/K5P/gRaYmt6TihVD+zotoe6O9q0JSBP+FYvCz4A/zZXR7xDOHURTSjHp0sD8wWS0YQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@openai/codex-linux-x64": {
+ "name": "@openai/codex",
+ "version": "0.154.0-linux-x64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.154.0-linux-x64.tgz",
+ "integrity": "sha512-a4FI3A8sGtwGrOqltrPbrS2hajrHQG591EwmRfiRoLMb10VxdBtUGW4gu6IJVYENiYGA7k3P4jlRHEoCZU/s9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@openai/codex-win32-arm64": {
+ "name": "@openai/codex",
+ "version": "0.154.0-win32-arm64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.154.0-win32-arm64.tgz",
+ "integrity": "sha512-CRUmZnE0Y/a8aLMrrA681EytOGaPaF659wJAiI4I3hsbQjaeYBSPV7PkCjy4Qn5LR/fmwIUORVH+6JaBNQL+tw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@openai/codex-win32-x64": {
+ "name": "@openai/codex",
+ "version": "0.154.0-win32-x64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.154.0-win32-x64.tgz",
+ "integrity": "sha512-Stg2KEJPIKVqPPR1wCverGOR4ey3RR3cvakR07w7FNKQUMzmHaOZomRsP2bR1qOT/67yHsks9rB+MCMfIWXcRA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bundle-name": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
+ "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "run-applescript": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/default-browser": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz",
+ "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==",
+ "license": "MIT",
+ "dependencies": {
+ "bundle-name": "^4.1.0",
+ "default-browser-id": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
+ "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+ "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/diff": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
+ "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
+ "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.7.0",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
+ "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.13.8",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz",
+ "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.7.2",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
+ "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-in-ssh": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
+ "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-wsl": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
+ "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.2.12",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
+ "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/json-schema-to-ts": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
+ "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "ts-algebra": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/negotiator/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/open": {
+ "version": "11.0.4",
+ "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz",
+ "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "default-browser": "^5.5.1",
+ "define-lazy-prop": "^3.0.0",
+ "is-in-ssh": "^1.0.0",
+ "is-inside-container": "^1.0.0",
+ "powershell-utils": "^0.2.1",
+ "wsl-utils": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/powershell-utils": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz",
+ "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/run-applescript": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
+ "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/standardwebhooks": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
+ "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "fast-sha256": "^1.3.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/ts-algebra": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
+ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
+ "license": "MIT"
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vscode-jsonrpc": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz",
+ "integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/wsl-utils": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz",
+ "integrity": "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-wsl": "^3.1.0",
+ "powershell-utils": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/wsl-utils/node_modules/powershell-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
+ "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.6.5",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
+ "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
+ }
+ }
+ }
+}
diff --git a/internal/connector/driver/acp/adapters/package.json b/internal/connector/driver/acp/adapters/package.json
new file mode 100644
index 000000000..8aaff67d9
--- /dev/null
+++ b/internal/connector/driver/acp/adapters/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "basecamp-connect-acp-adapters",
+ "private": true,
+ "description": "The ACP adapters the connector's acp driver is pinned to. Installed with make acp-adapters; never downloaded at dispatch time.",
+ "dependencies": {
+ "@agentclientprotocol/claude-agent-acp": "0.78.0",
+ "@agentclientprotocol/codex-acp": "1.12.0"
+ }
+}
diff --git a/internal/connector/driver/acp/compat_helpers_test.go b/internal/connector/driver/acp/compat_helpers_test.go
new file mode 100644
index 000000000..76941a1e2
--- /dev/null
+++ b/internal/connector/driver/acp/compat_helpers_test.go
@@ -0,0 +1,53 @@
+//go:build unix
+
+package acp
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// hostDigestShowsToken reads compatibility check 5's host probe: the SHA-256
+// the worker's shell computed of the host token variable as it saw it. A
+// probe that is not a digest — no hashing tool on the machine (stock macOS has
+// shasum, not sha256sum), or a pipeline that printed nothing — proves nothing,
+// and is an error rather than a pass.
+func hostDigestShowsToken(probe, host string) (bool, error) {
+ digest := strings.TrimSpace(probe)
+ if digest == "NOHASH" {
+ return false, errors.New("the worker's shell has neither sha256sum nor shasum")
+ }
+ if len(digest) != 64 {
+ return false, fmt.Errorf("the probe wrote %d characters, not a SHA-256 digest", len(digest))
+ }
+ if _, err := hex.DecodeString(digest); err != nil {
+ return false, fmt.Errorf("the probe wrote something that is not a digest: %w", err)
+ }
+ sum := sha256.Sum256([]byte(host))
+ return digest == hex.EncodeToString(sum[:]), nil
+}
+
+func TestTheHostTokenProbeProvesNothingWithoutADigest(t *testing.T) {
+ host := "test-host-token-not-real"
+ sum := sha256.Sum256([]byte(host))
+ seen, err := hostDigestShowsToken(hex.EncodeToString(sum[:])+"\n", host)
+ require.NoError(t, err)
+ assert.True(t, seen)
+
+ empty := sha256.Sum256(nil)
+ seen, err = hostDigestShowsToken(hex.EncodeToString(empty[:]), host)
+ require.NoError(t, err)
+ assert.False(t, seen, "the digest of nothing: the shell did not see the token")
+
+ for _, probe := range []string{"", "\n", "NOHASH", "sha256sum: not found", strings.Repeat("z", 64)} {
+ _, err := hostDigestShowsToken(probe, host)
+ assert.Error(t, err, "%q is not a digest, and must not pass as proof", probe)
+ }
+}
diff --git a/internal/connector/driver/acp/compat_test.go b/internal/connector/driver/acp/compat_test.go
new file mode 100644
index 000000000..69cebef81
--- /dev/null
+++ b/internal/connector/driver/acp/compat_test.go
@@ -0,0 +1,823 @@
+//go:build acpcompat && (linux || darwin)
+
+package acp
+
+// The adapter-compatibility test: the card 23 spike's four checks, run through
+// this driver against the real pinned adapters; a fifth, that the worker's own
+// shell sees neither the task token nor the host's token; and a sixth, that an
+// MCP server the working directory declares never runs beside or instead of
+// the connector's; and a seventh, that the connector's token bridge reaches
+// its one-use socket from where the adapter starts MCP servers, with the
+// token in no process's environment or command line and in no file; and an
+// eighth, which reports what each adapter does when a session's MCP server
+// dies mid-session. It sends real prompts, so it
+// spends model quota on whatever account each adapter is logged in to, and it
+// is skipped unless the adapters are installed:
+//
+// make acp-adapters # npm ci the pinned adapters (once)
+// make test-acp-compat # the eight checks against both
+//
+// Environment: BASECAMP_ACP_ADAPTERS_DIR (required; the npm prefix),
+// BASECAMP_ACP_ADAPTER (one adapter name; both when unset),
+// BASECAMP_ACP_CHECKS (e.g. "1,3"; all when unset), and
+// BASECAMP_ACP_TRANSCRIPTS (a directory for redacted JSON-RPC transcripts).
+//
+// Credentials: the connector's task token is a dummy string throughout. The
+// adapters authenticate as whatever account they are logged in to on this
+// machine, which is what these prompts are billed to.
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/basecamp/basecamp-cli/internal/connector"
+ "github.com/basecamp/basecamp-cli/internal/connector/driver"
+ "github.com/basecamp/basecamp-cli/internal/connector/driver/drivertest"
+)
+
+const (
+ compatProbeVar = "BASECAMP_CONNECT_TASK_TOKEN"
+ compatDummyToken = "test-token-not-real-0000"
+ compatServer = "basecamp"
+ // hostTokenVar is a variable the host's Claude Code session carries and
+ // no worker may see.
+ hostTokenVar = "CLAUDE_CODE_MESSAGING_TOKEN"
+)
+
+func TestAdapterCompat(t *testing.T) {
+ dir := os.Getenv("BASECAMP_ACP_ADAPTERS_DIR")
+ if dir == "" {
+ t.Skip("BASECAMP_ACP_ADAPTERS_DIR is not set; run make test-acp-compat")
+ }
+ stub := buildStub(t)
+ checks := map[string]func(*testing.T, compatEnv){
+ "1": checkMCPEnv, "2": checkLoadAfterRestart, "3": checkPolicyPermission, "4": checkCancel,
+ "5": checkShellEnvironment, "6": checkDecoyMCPServer, "7": checkTokenBridge, "8": checkMCPRestart,
+ }
+ if only := os.Getenv("BASECAMP_ACP_ADAPTER"); only != "" {
+ if _, ok := AdapterNamed(only); !ok {
+ t.Fatalf("BASECAMP_ACP_ADAPTER %q names no pinned adapter", only)
+ }
+ }
+ want := strings.Split(envOr("BASECAMP_ACP_CHECKS", "1,2,3,4,5,6,7,8"), ",")
+ for _, adapter := range Adapters() {
+ if only := os.Getenv("BASECAMP_ACP_ADAPTER"); only != "" && only != adapter.Name {
+ continue
+ }
+ t.Run(adapter.Name, func(t *testing.T) {
+ bin, err := Locate(dir, adapter)
+ if errors.Is(err, ErrAdapterMissing) {
+ t.Skipf("%v", err)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, n := range want {
+ check, ok := checks[strings.TrimSpace(n)]
+ if !ok {
+ t.Fatalf("BASECAMP_ACP_CHECKS names no check %q", n)
+ }
+ t.Run("check"+strings.TrimSpace(n), func(t *testing.T) {
+ check(t, compatEnv{adapter: adapter, bin: bin, stub: stub, check: strings.TrimSpace(n)})
+ })
+ }
+ })
+ }
+}
+
+type compatEnv struct {
+ adapter Adapter
+ bin string
+ stub string
+ check string
+}
+
+func envOr(name, fallback string) string {
+ if v := os.Getenv(name); v != "" {
+ return v
+ }
+ return fallback
+}
+
+func buildStub(t *testing.T) string {
+ t.Helper()
+ out := filepath.Join(t.TempDir(), "stubmcp")
+ cmd := exec.CommandContext(context.Background(), "go", "build", "-o", out, "./testdata/stubmcp")
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("build stubmcp: %v", err)
+ }
+ return out
+}
+
+// driverFor builds a driver whose wire goes, redacted, to a transcript.
+func (e compatEnv) driverFor(t *testing.T, part string) *Driver {
+ t.Helper()
+ d, err := New(Options{Adapter: e.adapter, Binary: e.bin, CloseGrace: 5 * time.Second})
+ if err != nil {
+ t.Fatal(err)
+ }
+ redactor := driver.NewRedactor(driver.Redaction{})
+ if tdir := os.Getenv("BASECAMP_ACP_TRANSCRIPTS"); tdir != "" {
+ if err := os.MkdirAll(tdir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ // The transcripts hold prompts, tool text and host paths; only emails
+ // and credential-shaped runs are redacted. Owner-only, even when the
+ // directory was there before.
+ if err := os.Chmod(tdir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ name := fmt.Sprintf("%s-check%s%s.jsonl", e.adapter.Name, e.check, part)
+ f, err := os.OpenFile(filepath.Join(tdir, name), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = f.Close() })
+ var mu sync.Mutex
+ d.opts.trace = func(dir string, line []byte) {
+ mu.Lock()
+ defer mu.Unlock()
+ // Redacted at the sink: the adapters volunteer the account email.
+ _, _ = fmt.Fprintf(f, "{\"t\":%q,\"dir\":%q,\"msg\":%s}\n", time.Now().UTC().Format("15:04:05.000"), dir, redactor.Sanitize(string(line)))
+ }
+ }
+ return d
+}
+
+// compatPolicy is the v1 policy's shape with a switch for allowing what lies
+// outside the working directory, and a log of what it was asked.
+type compatPolicy struct {
+ workDir string
+ allowOutside atomic.Bool
+
+ mu sync.Mutex
+ asked []driver.PermissionRequest
+}
+
+func (p *compatPolicy) Rules() driver.PermissionRules {
+ return driver.PermissionRules{
+ Mode: driver.ModeEditsInWorkDir, WorkDir: p.workDir,
+ AllowKinds: []driver.ToolKind{driver.ToolRead, driver.ToolSearch, driver.ToolThink},
+ AllowMCPServers: []string{compatServer},
+ }
+}
+
+func (p *compatPolicy) Decide(_ context.Context, req driver.PermissionRequest) driver.PermissionDecision {
+ p.mu.Lock()
+ p.asked = append(p.asked, req)
+ p.mu.Unlock()
+ if strings.HasPrefix(req.Tool, "mcp__"+compatServer+"__") || p.allowOutside.Load() {
+ return driver.PermissionDecision{Allow: true}
+ }
+ inside := len(req.Locations) > 0
+ for _, loc := range req.Locations {
+ rel, err := filepath.Rel(p.workDir, loc)
+ inside = inside && err == nil && !strings.HasPrefix(rel, "..")
+ }
+ return driver.PermissionDecision{Allow: inside && (req.Kind == driver.ToolEdit || req.Kind == driver.ToolRead)}
+}
+
+func (p *compatPolicy) log(t *testing.T) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ for _, r := range p.asked {
+ t.Logf("asked: tool=%q kind=%s locations=%d options=%v", r.Tool, r.Kind, len(r.Locations), r.Options)
+ }
+}
+
+func (e compatEnv) config(t *testing.T, workDir, record string, policy driver.PermissionPolicy) driver.SessionConfig {
+ t.Helper()
+ serverEnv := driver.EnvMap(driver.BuildEnv(driver.BaseEnv, os.LookupEnv, map[string]string{compatProbeVar: compatDummyToken}))
+ return driver.SessionConfig{
+ Cwd: workDir,
+ Env: driver.BuildEnv(driver.BaseEnv, os.LookupEnv, nil),
+ MCPServers: []driver.MCPServer{{
+ Name: compatServer, Command: e.stub,
+ Args: []string{"--record", record, "--probe", compatProbeVar, "--fingerprint", hostTokenVar},
+ Env: serverEnv,
+ }},
+ Policy: policy,
+ Scope: driver.Scope{WorkDir: workDir},
+ PrivateDir: t.TempDir(),
+ }
+}
+
+type stubRecord struct {
+ PID int `json:"pid"`
+ ProbeVars map[string]string `json:"probe_vars"`
+ Fingerprints map[string]string `json:"fingerprints"`
+ EnvVarNames []string `json:"env_var_names"`
+ Methods []string `json:"methods"`
+ Notes []string `json:"notes"`
+}
+
+func readRecord(t *testing.T, path string, until func(stubRecord) bool, wait time.Duration) stubRecord {
+ t.Helper()
+ deadline := time.Now().Add(wait)
+ var rec stubRecord
+ for {
+ if raw, err := os.ReadFile(path); err == nil && json.Unmarshal(raw, &rec) == nil && until(rec) {
+ return rec
+ }
+ if time.Now().After(deadline) {
+ return rec
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+}
+
+func workDir(t *testing.T) string {
+ t.Helper()
+ dir, err := filepath.EvalSymlinks(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return dir
+}
+
+func outsideTmp(t *testing.T) string {
+ t.Helper()
+ cache, err := os.UserCacheDir()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(cache, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ dir, err := os.MkdirTemp(cache, "basecamp-acp-compat-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
+ return dir
+}
+
+func turnCtx(t *testing.T) context.Context {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+ return ctx
+}
+
+// Check 1: mcpServers[].env carries the token to the server, the server
+// connects, the host's own token does not reach it, the session is in its
+// asking mode, and closing the session ends the server with the adapter.
+func checkMCPEnv(t *testing.T, e compatEnv) {
+ wd := workDir(t)
+ record := filepath.Join(t.TempDir(), "record.json")
+ policy := &compatPolicy{workDir: wd}
+ d := e.driverFor(t, "")
+ s, err := d.NewSession(turnCtx(t), e.config(t, wd, record, policy))
+ if err != nil {
+ t.Fatalf("NewSession: %v", err)
+ }
+ rec := readRecord(t, record, func(r stubRecord) bool { return slices.Contains(r.Methods, "tools/list") }, 60*time.Second)
+ _ = s.Close()
+
+ if got := rec.ProbeVars[compatProbeVar]; got != compatDummyToken {
+ t.Errorf("the MCP server did not get %s from mcpServers[].env (got %q)", compatProbeVar, got)
+ }
+ if !slices.Contains(rec.Methods, "initialize") || !slices.Contains(rec.Methods, "tools/list") {
+ t.Errorf("the agent did not complete the MCP handshake: %v", rec.Methods)
+ }
+ // Claude Code gives every process it starts a messaging token of its own
+ // session; what must never arrive is the host's.
+ if host, ok := os.LookupEnv(hostTokenVar); ok {
+ sum := sha256.Sum256([]byte(host))
+ if rec.Fingerprints[hostTokenVar] == hex.EncodeToString(sum[:]) {
+ t.Errorf("the host's %s reached the MCP server", hostTokenVar)
+ }
+ } else {
+ t.Logf("%s is not set in this environment; the host-token half of check 1 proves nothing here", hostTokenVar)
+ }
+ t.Logf("MCP server env: %d variables", len(rec.EnvVarNames))
+ if rec.PID > 0 {
+ if err := syscall.Kill(rec.PID, 0); !errors.Is(err, syscall.ESRCH) {
+ t.Errorf("the MCP server (pid %d) outlived Close: %v", rec.PID, err)
+ }
+ }
+ if !d.Capabilities().PermissionCallback {
+ t.Error("the driver does not report the permission callback")
+ }
+}
+
+// Check 2: the session survives the connector: a fresh adapter process loads
+// it by id and it still knows what the first process's turn was told.
+func checkLoadAfterRestart(t *testing.T, e compatEnv) {
+ wd := workDir(t)
+ passphrase := "COMPAT-PASSPHRASE-4417"
+ policy := &compatPolicy{workDir: wd}
+
+ first := e.driverFor(t, "a")
+ s1, err := first.NewSession(turnCtx(t), e.config(t, wd, filepath.Join(t.TempDir(), "a.json"), policy))
+ if err != nil {
+ t.Fatalf("NewSession: %v", err)
+ }
+ id := s1.ID()
+ res, err := s1.Prompt(turnCtx(t), "Remember this passphrase for later: "+passphrase+". Reply with just the word OK. Do not use any tools.")
+ if err != nil || res.Stop != driver.TurnEndTurn {
+ _ = s1.Close()
+ t.Fatalf("seed prompt: %+v %v", res, err)
+ }
+ _ = s1.Close()
+ if !first.Capabilities().LoadSession {
+ t.Fatal("the adapter advertises no session/load or resume")
+ }
+
+ second := e.driverFor(t, "b")
+ record := filepath.Join(t.TempDir(), "b.json")
+ s2, err := second.LoadSession(turnCtx(t), e.config(t, wd, record, policy), id)
+ if err != nil {
+ t.Fatalf("LoadSession in a fresh process: %v", err)
+ }
+ defer s2.Close()
+ if s2.ID() != id {
+ t.Fatalf("loaded session id %q, want %q", s2.ID(), id)
+ }
+ res, err = s2.Prompt(turnCtx(t), "Call the note tool of the "+compatServer+" MCP server once, with the passphrase I asked you to remember as its text. Then stop.")
+ policy.log(t)
+ if err != nil {
+ t.Fatalf("prompt after load: %v", err)
+ }
+ rec := readRecord(t, record, func(r stubRecord) bool { return len(r.Notes) > 0 }, 10*time.Second)
+ if !slices.ContainsFunc(rec.Notes, func(n string) bool { return strings.Contains(n, passphrase) }) {
+ t.Fatalf("the loaded session did not recall the passphrase through the MCP tool (stop %s, %d notes, refusals %v)", res.Stop, len(rec.Notes), res.Refusals)
+ }
+}
+
+// Check 3: a permission is put to the policy and its answer holds both ways:
+// refused, the write does not happen and the turn is not reported canceled;
+// allowed, it does.
+func checkPolicyPermission(t *testing.T, e compatEnv) {
+ wd := workDir(t)
+ // Outside means outside /tmp too: codex-acp's modes leave /tmp writable
+ // unasked, so a refusal there is never put to the policy.
+ outside := outsideTmp(t)
+ refused := filepath.Join(outside, "refused.txt")
+ allowed := filepath.Join(outside, "allowed.txt")
+ policy := &compatPolicy{workDir: wd}
+ d := e.driverFor(t, "")
+ s, err := d.NewSession(turnCtx(t), e.config(t, wd, filepath.Join(t.TempDir(), "r.json"), policy))
+ if err != nil {
+ t.Fatalf("NewSession: %v", err)
+ }
+ defer s.Close()
+
+ // A live model may decline to attempt the write at all, which asks the
+ // policy nothing and proves nothing; the attempt is what is under test,
+ // so it is asked for again before the check gives a verdict.
+ var res driver.PromptResult
+ for attempt := range 2 {
+ ask := "Create a file at the absolute path " + refused + " containing the single word NO. Then stop."
+ if attempt > 0 {
+ ask = "Try again, and actually attempt the write this time: create a file at the absolute path " + refused +
+ " containing the single word NO, then stop. If a permission is refused, stop there."
+ }
+ res, err = s.Prompt(turnCtx(t), ask)
+ policy.log(t)
+ if err != nil {
+ t.Fatalf("refused phase: %v", err)
+ }
+ if _, err := os.Stat(refused); err == nil {
+ t.Fatalf("the policy refused, and the file was written anyway")
+ }
+ if len(res.Refusals) > 0 {
+ break
+ }
+ t.Logf("refused phase attempt %d: the agent asked nothing (stop %s)", attempt+1, res.Stop)
+ }
+ if len(res.Refusals) == 0 {
+ t.Fatalf("the agent never asked, or the refusal was not recorded (stop %s)", res.Stop)
+ }
+ if res.Stop == driver.TurnCanceled {
+ t.Fatalf("a policy refusal was reported as a cancel")
+ }
+ t.Logf("refused phase: stop %s, %d refusals", res.Stop, len(res.Refusals))
+
+ policy.allowOutside.Store(true)
+ res, err = s.Prompt(turnCtx(t), "Create a file at the absolute path "+allowed+" containing the single word YES. Then stop.")
+ policy.log(t)
+ if err != nil {
+ t.Fatalf("allowed phase: %v", err)
+ }
+ if _, err := os.Stat(allowed); err != nil {
+ t.Fatalf("the policy allowed, and the file was not written (stop %s, refusals %v)", res.Stop, res.Refusals)
+ }
+}
+
+// Check 4: session/cancel ends the turn in flight with a canceled stop.
+func checkCancel(t *testing.T, e compatEnv) {
+ wd := workDir(t)
+ policy := &compatPolicy{workDir: wd}
+ d := e.driverFor(t, "")
+ s, err := d.NewSession(turnCtx(t), e.config(t, wd, filepath.Join(t.TempDir(), "c.json"), policy))
+ if err != nil {
+ t.Fatalf("NewSession: %v", err)
+ }
+ defer s.Close()
+
+ type answer struct {
+ res driver.PromptResult
+ err error
+ }
+ answers := make(chan answer, 1)
+ go func() {
+ res, err := s.Prompt(turnCtx(t), "Write a very long essay, at least three thousand words, about the history of the typewriter. Do not use any tools.")
+ answers <- answer{res, err}
+ }()
+ select {
+ case <-s.Updates():
+ case <-time.After(90 * time.Second):
+ t.Fatal("no progress within 90s")
+ }
+ time.Sleep(1500 * time.Millisecond)
+ if err := s.Cancel(context.Background()); err != nil {
+ t.Fatalf("Cancel: %v", err)
+ }
+ select {
+ case a := <-answers:
+ if a.err != nil {
+ t.Fatalf("the canceled prompt errored: %v", a.err)
+ }
+ if a.res.Stop != driver.TurnCanceled {
+ t.Fatalf("stop %q after session/cancel, want %q", a.res.Stop, driver.TurnCanceled)
+ }
+ case <-time.After(90 * time.Second):
+ t.Fatal("the prompt did not return within 90s of session/cancel")
+ }
+}
+
+// Check 5: what the MCP server is given stays with the MCP server. The model's
+// shell sees neither the task token nor the host's Claude Code token.
+func checkShellEnvironment(t *testing.T, e compatEnv) {
+ wd := workDir(t)
+ policy := &compatPolicy{workDir: wd}
+ // The probe is a shell command, which claude-agent-acp asks about.
+ policy.allowOutside.Store(true)
+ d := e.driverFor(t, "")
+ s, err := d.NewSession(turnCtx(t), e.config(t, wd, filepath.Join(t.TempDir(), "s.json"), policy))
+ if err != nil {
+ t.Fatalf("NewSession: %v", err)
+ }
+ defer s.Close()
+ // A script, not a one-liner: what is under test is what the worker's
+ // shell holds, not how well a model retypes a pipeline.
+ script := "#!/bin/sh\n" +
+ "if [ -n \"$" + compatProbeVar + "\" ]; then echo PRESENT; else echo ABSENT; fi > token-probe.txt\n" +
+ "if command -v sha256sum >/dev/null 2>&1; then H=sha256sum; elif command -v shasum >/dev/null 2>&1; then H=\"shasum -a 256\"; else H=; fi\n" +
+ "if [ -n \"$H\" ]; then printf %s \"$" + hostTokenVar + "\" | $H | cut -c1-64 > host-probe.txt; else echo NOHASH > host-probe.txt; fi\n"
+ if err := os.WriteFile(filepath.Join(wd, "probe.sh"), []byte(script), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ res, err := s.Prompt(turnCtx(t), "Run `sh probe.sh` in the current working directory, once, and then stop. Do not read or change the script.")
+ policy.log(t)
+ if err != nil {
+ t.Fatalf("prompt: %v", err)
+ }
+ probe, err := os.ReadFile(filepath.Join(wd, "token-probe.txt"))
+ if err != nil {
+ t.Fatalf("the probe did not run (stop %s, refusals %v): %v", res.Stop, res.Refusals, err)
+ }
+ if strings.TrimSpace(string(probe)) != "ABSENT" {
+ t.Errorf("the model's shell sees %s", compatProbeVar)
+ }
+ if host, ok := os.LookupEnv(hostTokenVar); ok {
+ digest, err := os.ReadFile(filepath.Join(wd, "host-probe.txt"))
+ if err != nil {
+ t.Fatalf("the host probe did not run: %v", err)
+ }
+ seen, err := hostDigestShowsToken(string(digest), host)
+ if err != nil {
+ t.Fatalf("the host probe proves nothing: %v", err)
+ }
+ if seen {
+ t.Errorf("the model's shell sees the host's %s", hostTokenVar)
+ }
+ }
+}
+
+// Check 6: an MCP server the project declares (Claude's .mcp.json, Codex's
+// .codex/config.toml), named like the connector's, never runs. Claude runs
+// the session with the connector's server alone; the driver refuses a Codex
+// session before anything starts.
+func checkDecoyMCPServer(t *testing.T, e compatEnv) {
+ wd := workDir(t)
+ decoy := filepath.Join(t.TempDir(), "decoy.json")
+ record := filepath.Join(t.TempDir(), "real.json")
+ claudeDecoy := `{"mcpServers":{"` + compatServer + `":{"type":"stdio","command":"` + e.stub + `","args":["--record","` + decoy + `"]},` +
+ `"extra":{"type":"stdio","command":"` + e.stub + `","args":["--record","` + decoy + `"]}}}`
+ if err := os.WriteFile(filepath.Join(wd, ".mcp.json"), []byte(claudeDecoy), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(wd, ".codex"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ codexDecoy := "[mcp_servers." + compatServer + "]\ncommand = \"" + e.stub + "\"\nargs = [\"--record\", \"" + decoy + "\"]\n"
+ if err := os.WriteFile(filepath.Join(wd, ".codex", "config.toml"), []byte(codexDecoy), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ policy := &compatPolicy{workDir: wd}
+ d := e.driverFor(t, "")
+ s, err := d.NewSession(turnCtx(t), e.config(t, wd, record, policy))
+ if e.adapter.Name == CodexACP.Name {
+ if !errors.Is(err, ErrForeignMCPConfig) || !errors.Is(err, driver.ErrNotStarted) {
+ if s != nil {
+ _ = s.Close()
+ }
+ t.Fatalf("a Codex session with a project MCP server was not refused before it started: %v", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("NewSession: %v", err)
+ }
+ defer s.Close()
+ res, err := s.Prompt(turnCtx(t), "Call the note tool of the "+compatServer+" MCP server once, with the text decoy-check. Then stop.")
+ policy.log(t)
+ if err != nil {
+ t.Fatalf("prompt: %v", err)
+ }
+ rec := readRecord(t, record, func(r stubRecord) bool { return len(r.Notes) > 0 }, 10*time.Second)
+ if len(rec.Notes) == 0 {
+ t.Errorf("the connector's MCP server was not the one called (stop %s, refusals %v)", res.Stop, res.Refusals)
+ }
+ if _, err := os.Stat(decoy); err == nil {
+ t.Errorf("an MCP server from the working directory's .mcp.json ran")
+ }
+}
+
+// Check 7: the task token's carriage, as the dispatcher builds it. The MCP
+// server is the connector's bridge (`basecamp connect worker-mcp`), the token
+// is served once on a socket in the attempt's private directory, and the
+// socket is told the worker's process group only once NewSession returns —
+// the order the dispatcher uses. The bridge must reach the socket from
+// wherever the adapter starts it, the handoff must be delivered, and the
+// token must not be in any environment, command line or file of the worker's
+// processes. No Basecamp account is involved: the bridge's profile is a dummy
+// in a private config, so the `basecamp mcp` it becomes cannot authenticate —
+// which is also how this checks that a session whose MCP server did not
+// connect is refused rather than run.
+func checkTokenBridge(t *testing.T, e compatEnv) {
+ if runtime.GOOS != "linux" {
+ t.Skip("the process walk reads /proc")
+ }
+ wd := workDir(t)
+ bin := filepath.Join(t.TempDir(), "basecamp")
+ build := exec.CommandContext(context.Background(), "go", "build", "-o", bin, "github.com/basecamp/basecamp-cli/cmd/basecamp")
+ build.Stderr = os.Stderr
+ if err := build.Run(); err != nil {
+ t.Fatalf("build basecamp: %v", err)
+ }
+ config := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(config, "basecamp"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ profile := `{"profiles":{"compat-dummy":{"base_url":"https://example.invalid","account_id":"1"}}}`
+ if err := os.WriteFile(filepath.Join(config, "basecamp", "config.json"), []byte(profile), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ private, err := os.MkdirTemp(os.TempDir(), "acp-bridge-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(private) })
+ state := t.TempDir()
+ token := "test-token-not-real-" + strings.Repeat("b", 23)
+ tokens, err := connector.ServeTaskToken(private, token, 2*time.Minute)
+ if err != nil {
+ t.Fatalf("ServeTaskToken: %v", err)
+ }
+ defer tokens.Close()
+
+ serverEnv := driver.EnvMap(driver.BuildEnv(driver.BaseEnv, os.LookupEnv, map[string]string{
+ "XDG_CONFIG_HOME": config, "BASECAMP_NO_KEYRING": "1",
+ }))
+ policy := &compatPolicy{workDir: wd}
+ cfg := driver.SessionConfig{
+ Cwd: wd,
+ Env: driver.BuildEnv(driver.BaseEnv, os.LookupEnv, nil),
+ MCPServers: []driver.MCPServer{{
+ Name: compatServer, Command: bin,
+ Args: []string{"connect", "worker-mcp", "--profile", "compat-dummy", "--connect-state", state, "--socket", tokens.Path()},
+ Env: serverEnv,
+ }},
+ Policy: policy,
+ Scope: driver.Scope{WorkDir: wd},
+ PrivateDir: private,
+ }
+ d := e.driverFor(t, "")
+ var s driver.Session
+ var places drivertest.Places
+ drivertest.RequireNoSecretFilesDuring(t, token, []string{wd, private, state}, func() {
+ started := time.Now()
+ s, err = d.NewSession(turnCtx(t), cfg)
+ if err != nil {
+ t.Fatalf("NewSession: %v", err)
+ }
+ t.Logf("NewSession took %s", time.Since(started).Round(time.Millisecond))
+ tokens.AllowGroup(s.Process().PGID)
+ handed := make(chan connector.Handoff, 1)
+ go func() { handed <- tokens.Result() }()
+ deadline := time.After(90 * time.Second)
+ for {
+ places = addWorkerProcesses(places, s.Process().PID)
+ select {
+ case h := <-handed:
+ if h != connector.HandoffDelivered {
+ _ = s.Close()
+ t.Fatalf("the bridge did not take the token: %s", h)
+ }
+ t.Logf("handoff %s %s after NewSession began", h, time.Since(started).Round(time.Millisecond))
+ // The bridge execs `basecamp mcp` once it has the token: walk
+ // the tree again only when that process is there, so the
+ // server that holds the token is among what is checked.
+ mcpSeen := false
+ for wait := time.Now().Add(30 * time.Second); time.Now().Before(wait); time.Sleep(100 * time.Millisecond) {
+ places = addWorkerProcesses(places, s.Process().PID)
+ for _, args := range places.Args {
+ if strings.Contains(args, " mcp ") && strings.Contains(args, "--connect-token-fd") {
+ mcpSeen = true
+ }
+ }
+ if mcpSeen {
+ break
+ }
+ }
+ if !mcpSeen {
+ _ = s.Close()
+ t.Fatal("the bridge never became basecamp mcp")
+ }
+ // And the agent's own account of the server. The bridge's
+ // `basecamp mcp` cannot serve here — its profile is a dummy
+ // with no credentials — so the agent reports the server
+ // failed, and the driver must refuse to go on with a session
+ // whose MCP server did not connect (invariant 9). A session
+ // whose server does serve is the live end-to-end proof.
+ _, err := s.Prompt(turnCtx(t), "Reply with just the word OK. Do not use any tools.")
+ if !errors.Is(err, ErrMCPServerNotConnected) {
+ _ = s.Close()
+ t.Fatalf("a turn ran with an MCP server that did not connect: %v", err)
+ }
+ t.Logf("the turn was refused: %v; %d worker processes seen", err, len(places.Args))
+ _ = s.Close()
+ return
+ case <-deadline:
+ _ = s.Close()
+ t.Fatal("no handoff within 90s")
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ })
+ drivertest.RequireNoSecret(t, token, places)
+}
+
+// addWorkerProcesses adds the environment and command line of every process
+// descended from root, root included, to places.
+func addWorkerProcesses(places drivertest.Places, root int) drivertest.Places {
+ entries, err := os.ReadDir("/proc")
+ if err != nil {
+ return places
+ }
+ parent := map[int]int{}
+ for _, e := range entries {
+ pid, err := strconv.Atoi(e.Name())
+ if err != nil {
+ continue
+ }
+ raw, err := os.ReadFile("/proc/" + e.Name() + "/stat")
+ if err != nil {
+ continue
+ }
+ fields := strings.Fields(string(raw)[strings.LastIndexByte(string(raw), ')')+1:])
+ if len(fields) > 1 {
+ ppid, _ := strconv.Atoi(fields[1])
+ parent[pid] = ppid
+ }
+ }
+ for pid := range parent {
+ for p, n := pid, 0; p > 1 && n < 64; p, n = parent[p], n+1 {
+ if p != root {
+ continue
+ }
+ dir := "/proc/" + strconv.Itoa(pid)
+ if cmdline, err := os.ReadFile(dir + "/cmdline"); err == nil {
+ places.Args = append(places.Args, strings.ReplaceAll(string(cmdline), "\x00", " "))
+ }
+ if environ, err := os.ReadFile(dir + "/environ"); err == nil {
+ places.Env = append(places.Env, strings.Split(string(environ), "\x00")...)
+ }
+ break
+ }
+ }
+ return places
+}
+
+// checkMCPRestart reports what an adapter does when a session's MCP server
+// dies while the session is running: re-runs the server's command as a new
+// process, keeps talking to what is already there, or leaves the session
+// without the server. The connector's token bridge serves one handoff per
+// start of that command, so a re-run is the shape it is built for, and a
+// server left dead is the shape only ErrMCPServerNotConnected protects.
+//
+// The server it kills is the stub this check's own session declared, started
+// by the adapter this check started, in the worker's process group: it is
+// killed by the pid the stub itself recorded, and nothing else is signalled.
+func checkMCPRestart(t *testing.T, e compatEnv) {
+ wd := workDir(t)
+ record := filepath.Join(t.TempDir(), "record.json")
+ policy := &compatPolicy{workDir: wd}
+ d := e.driverFor(t, "")
+ s, err := d.NewSession(turnCtx(t), e.config(t, wd, record, policy))
+ if err != nil {
+ t.Fatalf("NewSession: %v", err)
+ }
+ defer func() { _ = s.Close() }()
+ first := readRecord(t, record, func(r stubRecord) bool { return slices.Contains(r.Methods, "tools/list") }, 90*time.Second)
+ if first.PID == 0 {
+ t.Fatal("the MCP server never started, so there is nothing to kill")
+ }
+ if err := syscall.Kill(first.PID, syscall.SIGKILL); err != nil {
+ t.Fatalf("kill the MCP server (pid %d): %v", first.PID, err)
+ }
+ for deadline := time.Now().Add(30 * time.Second); ; {
+ if errors.Is(syscall.Kill(first.PID, 0), syscall.ESRCH) {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("the MCP server (pid %d) did not die", first.PID)
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ t.Logf("killed the session's MCP server (pid %d)", first.PID)
+
+ // Asked twice: a model that answers without reaching for the tool tells
+ // us nothing about the adapter, and which of the two happened is not
+ // visible from here.
+ var res driver.PromptResult
+ var second stubRecord
+ for range 2 {
+ res, err = s.Prompt(turnCtx(t), "Use the basecamp MCP tool named note with the text after. "+
+ "You must call that tool. If calling it fails, say exactly UNAVAILABLE.")
+ second = readRecord(t, record, func(r stubRecord) bool {
+ return r.PID != 0 && r.PID != first.PID && slices.Contains(r.Methods, "initialize")
+ }, 30*time.Second)
+ if second.PID != 0 && second.PID != first.PID {
+ break
+ }
+ if err != nil {
+ break
+ }
+ }
+ switch {
+ case second.PID != 0 && second.PID != first.PID:
+ worker := s.Process()
+ t.Logf("RESTARTED: %s re-ran the server's command as a new process (pid %d after %d); "+
+ "a per-start handoff is the right shape. turn: stop=%v err=%v", e.adapter.Name, second.PID, first.PID, res.Stop, err)
+ // What the connector's token socket checks of a peer: its process
+ // group, or its descent from the worker's leader.
+ ppid, pgid := parentAndGroup(t, second.PID)
+ t.Logf("the restarted server: pid %d ppid %d pgid %d; the worker: pid %d pgid %d%s",
+ second.PID, ppid, pgid, worker.PID, worker.PGID,
+ map[bool]string{true: " (same group)", false: " (another group)"}[pgid == worker.PGID])
+ case errors.Is(err, ErrMCPServerNotConnected):
+ t.Logf("NOT RESTARTED, and reported: %s left the server dead and said so; the driver refused the turn: %v", e.adapter.Name, err)
+ default:
+ t.Logf("NO RESTART SEEN, and nothing reported: %s put no new server on the record across two turns, "+
+ "which ended stop=%v err=%v. Either the adapter left the server dead or the model never reached for the "+
+ "tool; neither is visible to the client, so nothing but the session's own account of its servers stands "+
+ "between a worker and a turn without its tools", e.adapter.Name, res.Stop, err)
+ }
+}
+
+// parentAndGroup is a process's parent and process group, as ps reports them.
+func parentAndGroup(t *testing.T, pid int) (int, int) {
+ t.Helper()
+ out, err := exec.CommandContext(context.Background(), "ps", "-o", "ppid=,pgid=", "-p", strconv.Itoa(pid)).Output()
+ if err != nil {
+ t.Logf("ps for pid %d: %v", pid, err)
+ return 0, 0
+ }
+ fields := strings.Fields(string(out))
+ if len(fields) != 2 {
+ return 0, 0
+ }
+ ppid, _ := strconv.Atoi(fields[0])
+ pgid, _ := strconv.Atoi(fields[1])
+ return ppid, pgid
+}
diff --git a/internal/connector/driver/acp/fakeagent_test.go b/internal/connector/driver/acp/fakeagent_test.go
new file mode 100644
index 000000000..b237c072c
--- /dev/null
+++ b/internal/connector/driver/acp/fakeagent_test.go
@@ -0,0 +1,528 @@
+//go:build unix
+
+package acp
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "os/signal"
+ "slices"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+)
+
+// The test binary doubles as a fake ACP agent: run as
+// ` -fake-acp-agent `, it speaks ACP on stdio as
+// the scenario says and records what it was started with and what it was
+// told. Arguments, not the environment, name the scenario, because the driver
+// under test passes the agent an allowlisted environment.
+const (
+ fakeAgentArg = "-fake-acp-agent"
+ fakeChildArg = "-fake-acp-child"
+)
+
+type scenario struct {
+ Record string `json:"record"`
+ // Probe names variables whose values are recorded (test values only).
+ Probe []string `json:"probe"`
+
+ ProtocolVersion int `json:"protocol_version"`
+ AgentName string `json:"agent_name"`
+ AgentVersion string `json:"agent_version"`
+ FailInitialize bool `json:"fail_initialize"`
+ LoadSession bool `json:"load_session"`
+ Resume bool `json:"resume"`
+ SessionID string `json:"session_id"`
+
+ Modes []string `json:"modes"`
+ CurrentMode string `json:"current_mode"`
+ ModeConfig bool `json:"mode_config"`
+ // Confirm is how a set mode is confirmed: "readback" (the config option
+ // answer reports it), "stale" (it reports the old mode), "notify" (a
+ // current_mode_update follows set_mode), "none", or "error" (set_mode
+ // fails).
+ Confirm string `json:"confirm"`
+ // ModeBeforeSetAnswer is a mode update sent on the wire just before the
+ // answer to the set that was supposed to confirm the asking mode.
+ ModeBeforeSetAnswer string `json:"mode_before_set_answer"`
+
+ // Replay are updates sent before a load's response.
+ Replay []json.RawMessage `json:"replay"`
+ // Turns script each prompt in order; the last repeats.
+ Turns []turnScript `json:"turns"`
+
+ // StopReadingAfter names a method after which the agent reads no more
+ // input.
+ StopReadingAfter string `json:"stop_reading_after"`
+ // MCPInitAtSessionStart is the init the agent forwards while it is
+ // answering session/new, with these server statuses.
+ MCPInitAtSessionStart map[string]string `json:"mcp_init_at_session_start,omitempty"`
+ // MCPInitSessionID is the session the early init names; the session's own
+ // id when empty.
+ MCPInitSessionID string `json:"mcp_init_session_id,omitempty"`
+ // Hang names a method the agent never answers.
+ Hang string `json:"hang"`
+ AuthEmail string `json:"auth_email"`
+ // Secret is written back where an agent writes text: its stderr.
+ Secret string `json:"secret"`
+ SpawnChild bool `json:"spawn_child"`
+ // EscapingChild starts the child in a session of its own, holding the
+ // agent's output: a process group kill does not reach it.
+ EscapingChild bool `json:"escaping_child"`
+ IgnoreStdinEOF bool `json:"ignore_stdin_eof"`
+ IgnoreTerminate bool `json:"ignore_terminate"`
+}
+
+type turnScript struct {
+ Steps []step `json:"steps"`
+ // Stop is the stop reason; with WaitForCancel it is sent once
+ // session/cancel arrives.
+ Stop string `json:"stop"`
+ Usage json.RawMessage `json:"usage,omitempty"`
+ WaitForCancel bool `json:"wait_for_cancel"`
+ ErrorMessage string `json:"error_message"`
+ // Hang never answers the prompt.
+ Hang bool `json:"hang"`
+ // FloodPermissions asks for this many permissions at once.
+ FloodPermissions int `json:"flood_permissions"`
+ FloodCall json.RawMessage `json:"flood_call,omitempty"`
+ // StopWithoutWaiting answers the prompt without waiting for the
+ // permissions it asked for.
+ StopWithoutWaiting bool `json:"stop_without_waiting"`
+ // LateRequest is a permission request sent just after the prompt is
+ // answered.
+ LateRequest json.RawMessage `json:"late_request,omitempty"`
+}
+
+type step struct {
+ Update json.RawMessage `json:"update,omitempty"`
+ SessionID string `json:"session_id"`
+ Permission json.RawMessage `json:"permission,omitempty"`
+ ModeChange string `json:"mode_change"`
+ // MCPInit sends Claude Code's init, forwarded as claude-agent-acp does,
+ // with these MCP server statuses.
+ MCPInit map[string]string `json:"mcp_init,omitempty"`
+ SleepMS int `json:"sleep_ms"`
+}
+
+type agentRecord struct {
+ PID int `json:"pid"`
+ ChildPID int `json:"child_pid"`
+ Env []string `json:"env"`
+ // EnvKV and Args are the whole environment and command line: the fake's
+ // environment holds test values only.
+ EnvKV []string `json:"env_kv"`
+ Args []string `json:"args"`
+ Probe map[string]string `json:"probe"`
+ Methods []string `json:"methods"`
+ Params map[string]json.RawMessage
+ Outcomes []json.RawMessage `json:"outcomes"`
+}
+
+type fakeAgent struct {
+ sc scenario
+ out *bufio.Writer
+
+ mu sync.Mutex
+ rec agentRecord
+ nextID int
+ pending map[int]chan json.RawMessage
+ mode string
+ prompts int
+ canceled chan struct{}
+ // cancelEarly is a cancel handled before the prompt it followed on the
+ // wire: the fake handles each message on its own goroutine, so the two
+ // can run in either order.
+ cancelEarly bool
+}
+
+func runFakeAgent(path string) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ os.Exit(3)
+ }
+ var sc scenario
+ if json.Unmarshal(raw, &sc) != nil {
+ os.Exit(3)
+ }
+ if sc.IgnoreTerminate {
+ signal.Ignore(syscall.SIGTERM)
+ }
+ if sc.Secret != "" {
+ _, _ = os.Stderr.WriteString("the adapter says: " + sc.Secret + "\n")
+ }
+ a := &fakeAgent{sc: sc, out: bufio.NewWriter(os.Stdout), pending: map[int]chan json.RawMessage{}, mode: sc.CurrentMode}
+ a.rec.PID = os.Getpid()
+ a.rec.Params = map[string]json.RawMessage{}
+ a.rec.Probe = map[string]string{}
+ a.rec.EnvKV = os.Environ()
+ a.rec.Args = os.Args
+ for _, kv := range os.Environ() {
+ name, _, _ := strings.Cut(kv, "=")
+ a.rec.Env = append(a.rec.Env, name)
+ if slices.Contains(sc.Probe, name) {
+ a.rec.Probe[name] = os.Getenv(name)
+ }
+ }
+ slices.Sort(a.rec.Env)
+ if sc.SpawnChild || sc.EscapingChild {
+ child := exec.CommandContext(context.Background(), os.Args[0], fakeChildArg)
+ if sc.EscapingChild {
+ child.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
+ child.Stdout = os.Stdout
+ }
+ if child.Start() == nil {
+ a.rec.ChildPID = child.Process.Pid
+ }
+ }
+ a.flush()
+
+ in := bufio.NewScanner(os.Stdin)
+ in.Buffer(make([]byte, 1<<20), 16<<20)
+ for in.Scan() {
+ var m struct {
+ ID json.RawMessage `json:"id"`
+ Method string `json:"method"`
+ Params json.RawMessage `json:"params"`
+ Result json.RawMessage `json:"result"`
+ }
+ if json.Unmarshal(in.Bytes(), &m) != nil {
+ continue
+ }
+ if m.Method == "" {
+ var id int
+ if json.Unmarshal(m.ID, &id) == nil {
+ a.mu.Lock()
+ ch := a.pending[id]
+ a.mu.Unlock()
+ if ch != nil {
+ ch <- m.Result
+ }
+ }
+ continue
+ }
+ a.mu.Lock()
+ a.rec.Methods = append(a.rec.Methods, m.Method)
+ a.rec.Params[m.Method] = m.Params
+ a.mu.Unlock()
+ a.flush()
+ go a.handle(m.ID, m.Method, m.Params)
+ if m.Method == sc.StopReadingAfter {
+ // Reads no more, and outlives no test: a run that is interrupted
+ // while the client's write is stuck would otherwise leave this
+ // process behind with nothing to end it.
+ stall()
+ }
+ }
+ if sc.IgnoreStdinEOF {
+ stall()
+ }
+}
+
+// stall is an agent that does nothing more, for longer than any test waits
+// and not forever.
+func stall() {
+ time.Sleep(10 * time.Minute)
+ os.Exit(0)
+}
+
+// runFakeChild is a process the fake agent leaves in its group: it ignores
+// SIGTERM, so only a group SIGKILL ends it.
+func runFakeChild() {
+ signal.Ignore(syscall.SIGTERM, syscall.SIGHUP)
+ time.Sleep(time.Hour)
+}
+
+func (a *fakeAgent) flush() {
+ a.mu.Lock()
+ data, _ := json.Marshal(a.rec)
+ a.mu.Unlock()
+ tmp := a.sc.Record + ".tmp"
+ if os.WriteFile(tmp, data, 0o600) == nil {
+ _ = os.Rename(tmp, a.sc.Record)
+ }
+}
+
+func (a *fakeAgent) send(v any) {
+ data, _ := json.Marshal(v)
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ _, _ = a.out.Write(append(data, '\n'))
+ _ = a.out.Flush()
+}
+
+func (a *fakeAgent) reply(id json.RawMessage, result any) {
+ a.send(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
+}
+
+func (a *fakeAgent) fail(id json.RawMessage, message string) {
+ a.send(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32603, "message": message}})
+}
+
+func (a *fakeAgent) update(sessionID string, update any) {
+ a.send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": sessionID, "update": update}})
+}
+
+func (a *fakeAgent) request(method string, params any) json.RawMessage {
+ a.mu.Lock()
+ a.nextID++
+ id := a.nextID
+ ch := make(chan json.RawMessage, 1)
+ a.pending[id] = ch
+ a.mu.Unlock()
+ a.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
+ return <-ch
+}
+
+// sendMCPInit forwards Claude Code's init the way claude-agent-acp does.
+func (a *fakeAgent) sendMCPInit(sessionID string, statuses map[string]string) {
+ servers := make([]any, 0, len(statuses))
+ for name, status := range statuses {
+ servers = append(servers, map[string]any{"name": name, "status": status})
+ }
+ a.send(map[string]any{"jsonrpc": "2.0", "method": "_claude/sdkMessage", "params": map[string]any{
+ "sessionId": sessionID, "message": map[string]any{"type": "system", "subtype": "init", "mcp_servers": servers,
+ "cwd": "/somewhere", "tools": []string{"Bash"}, "model": "x"}}})
+}
+
+func (a *fakeAgent) sessionID() string {
+ if a.sc.SessionID != "" {
+ return a.sc.SessionID
+ }
+ return "sess-1"
+}
+
+func (a *fakeAgent) modes() map[string]any {
+ available := make([]any, 0, len(a.sc.Modes))
+ for _, m := range a.sc.Modes {
+ available = append(available, map[string]any{"id": m, "name": m})
+ }
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return map[string]any{"currentModeId": a.mode, "availableModes": available}
+}
+
+func (a *fakeAgent) configOptions(current string) []any {
+ options := make([]any, 0, len(a.sc.Modes))
+ for _, m := range a.sc.Modes {
+ options = append(options, map[string]any{"value": m, "name": m})
+ }
+ return []any{
+ map[string]any{"id": "model", "category": "model", "type": "select", "currentValue": "x", "options": []any{map[string]any{"value": "x", "name": "x"}}},
+ map[string]any{"id": "mode", "category": "mode", "type": "select", "currentValue": current, "options": options},
+ }
+}
+
+func (a *fakeAgent) sessionState() map[string]any {
+ st := map[string]any{"sessionId": a.sessionID()}
+ if len(a.sc.Modes) > 0 {
+ st["modes"] = a.modes()
+ }
+ if a.sc.ModeConfig {
+ a.mu.Lock()
+ st["configOptions"] = a.configOptions(a.mode)
+ a.mu.Unlock()
+ }
+ return st
+}
+
+func (a *fakeAgent) handle(id json.RawMessage, method string, params json.RawMessage) {
+ sc := a.sc
+ if method == sc.Hang {
+ return
+ }
+ switch method {
+ case "initialize":
+ if sc.AuthEmail != "" {
+ a.send(map[string]any{"jsonrpc": "2.0", "method": "_auth/status_update", "params": map[string]any{"authStatus": map[string]any{"account": map[string]any{"email": sc.AuthEmail}}}})
+ }
+ if sc.FailInitialize {
+ a.fail(id, "initialize failed for "+sc.AuthEmail)
+ return
+ }
+ version := sc.ProtocolVersion
+ if version == 0 {
+ version = 1
+ }
+ caps := map[string]any{"loadSession": sc.LoadSession}
+ if sc.Resume {
+ caps["sessionCapabilities"] = map[string]any{"resume": map[string]any{}}
+ }
+ a.reply(id, map[string]any{"protocolVersion": version, "agentCapabilities": caps, "agentInfo": map[string]any{"name": sc.AgentName, "version": sc.AgentVersion}})
+ case "session/new":
+ if sc.MCPInitAtSessionStart != nil {
+ named := sc.MCPInitSessionID
+ if named == "" {
+ named = a.sessionID()
+ }
+ a.sendMCPInit(named, sc.MCPInitAtSessionStart)
+ }
+ a.reply(id, a.sessionState())
+ case "session/load", "session/resume":
+ for _, u := range sc.Replay {
+ a.update(a.sessionID(), u)
+ }
+ st := a.sessionState()
+ delete(st, "sessionId")
+ a.reply(id, st)
+ case "session/set_mode":
+ var p struct {
+ ModeID string `json:"modeId"`
+ }
+ _ = json.Unmarshal(params, &p)
+ switch sc.Confirm {
+ case "error":
+ a.fail(id, "no")
+ return
+ case "stale", "none":
+ default:
+ a.mu.Lock()
+ a.mode = p.ModeID
+ a.mu.Unlock()
+ }
+ if sc.Confirm == "notify" {
+ a.update(a.sessionID(), map[string]any{"sessionUpdate": "current_mode_update", "currentModeId": p.ModeID})
+ }
+ a.reply(id, map[string]any{})
+ case "session/set_config_option":
+ var p struct {
+ Value string `json:"value"`
+ }
+ _ = json.Unmarshal(params, &p)
+ a.mu.Lock()
+ if sc.Confirm != "stale" && sc.Confirm != "none" {
+ a.mode = p.Value
+ }
+ opts := a.configOptions(a.mode)
+ a.mu.Unlock()
+ if sc.ModeBeforeSetAnswer != "" {
+ a.update(a.sessionID(), map[string]any{"sessionUpdate": "current_mode_update", "currentModeId": sc.ModeBeforeSetAnswer})
+ }
+ a.reply(id, map[string]any{"configOptions": opts})
+ case "session/cancel":
+ a.mu.Lock()
+ if a.canceled != nil {
+ close(a.canceled)
+ a.canceled = nil
+ } else {
+ a.cancelEarly = true
+ }
+ a.mu.Unlock()
+ case "session/prompt":
+ a.prompt(id)
+ default:
+ if len(id) > 0 {
+ a.fail(id, "unknown method")
+ }
+ }
+}
+
+func (a *fakeAgent) prompt(id json.RawMessage) {
+ a.mu.Lock()
+ n := a.prompts
+ a.prompts++
+ canceled := make(chan struct{})
+ a.canceled = canceled
+ if a.cancelEarly {
+ a.cancelEarly = false
+ close(canceled)
+ a.canceled = nil
+ }
+ a.mu.Unlock()
+ if len(a.sc.Turns) == 0 {
+ a.reply(id, map[string]any{"stopReason": "end_turn"})
+ return
+ }
+ ts := a.sc.Turns[min(n, len(a.sc.Turns)-1)]
+ for _, st := range ts.Steps {
+ if st.SleepMS > 0 {
+ time.Sleep(time.Duration(st.SleepMS) * time.Millisecond)
+ }
+ sid := a.sessionID()
+ if st.SessionID != "" {
+ sid = st.SessionID
+ }
+ if len(st.Update) > 0 {
+ a.update(sid, st.Update)
+ }
+ if st.MCPInit != nil {
+ a.sendMCPInit(sid, st.MCPInit)
+ }
+ if st.ModeChange != "" {
+ a.update(sid, map[string]any{"sessionUpdate": "current_mode_update", "currentModeId": st.ModeChange})
+ }
+ if len(st.Permission) > 0 {
+ var p any
+ if json.Unmarshal(st.Permission, &p) != nil {
+ p = st.Permission
+ } else if object, ok := p.(map[string]any); ok {
+ if _, named := object["sessionId"]; !named {
+ object["sessionId"] = sid
+ }
+ p = object
+ }
+ outcome := a.request("session/request_permission", p)
+ a.mu.Lock()
+ a.rec.Outcomes = append(a.rec.Outcomes, outcome)
+ a.mu.Unlock()
+ a.flush()
+ }
+ }
+ if ts.FloodPermissions > 0 {
+ var wg sync.WaitGroup
+ for i := range ts.FloodPermissions {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ var p map[string]any
+ _ = json.Unmarshal(ts.FloodCall, &p)
+ p["sessionId"] = a.sessionID()
+ call, _ := p["toolCall"].(map[string]any)
+ call["toolCallId"] = fmt.Sprintf("flood-%d", i)
+ outcome := a.request("session/request_permission", p)
+ a.mu.Lock()
+ a.rec.Outcomes = append(a.rec.Outcomes, outcome)
+ a.mu.Unlock()
+ a.flush()
+ }()
+ }
+ if ts.StopWithoutWaiting {
+ // Long enough for the client to have the request in hand.
+ time.Sleep(150 * time.Millisecond)
+ } else {
+ wg.Wait()
+ }
+ a.flush()
+ }
+ if ts.Hang {
+ select {}
+ }
+ if ts.WaitForCancel {
+ <-canceled
+ }
+ if ts.ErrorMessage != "" {
+ a.fail(id, ts.ErrorMessage)
+ return
+ }
+ result := map[string]any{"stopReason": ts.Stop}
+ if len(ts.Usage) > 0 {
+ result["usage"] = ts.Usage
+ }
+ a.reply(id, result)
+ if len(ts.LateRequest) > 0 {
+ var p map[string]any
+ _ = json.Unmarshal(ts.LateRequest, &p)
+ p["sessionId"] = a.sessionID()
+ outcome := a.request("session/request_permission", p)
+ a.mu.Lock()
+ a.rec.Outcomes = append(a.rec.Outcomes, outcome)
+ a.mu.Unlock()
+ a.flush()
+ }
+}
diff --git a/internal/connector/driver/acp/limits.go b/internal/connector/driver/acp/limits.go
new file mode 100644
index 000000000..07fc070df
--- /dev/null
+++ b/internal/connector/driver/acp/limits.go
@@ -0,0 +1,120 @@
+package acp
+
+import "time"
+
+// What bounds every buffer this driver keeps
+//
+// An ACP agent writes all of it: the lines it sends, the ids and paths it
+// names, the options it offers, the requests it asks. None of it is the
+// agent's to grow without end, so every collection and every wait this
+// driver keeps is bounded here, in one place, rather than at the site that
+// happens to fill it.
+//
+// - Per line: maxLine caps a line read from the agent; a longer one ends
+// the session. agentText cuts the text of an error before it is
+// sanitized (rpc.go) and again after, to 120 runes.
+// - Per session: maxTools tool calls remembered, maxRecorded refusals
+// remembered as recorded, maxMode bytes of the mode last reported,
+// maxEarlyInit accounts of the MCP servers held until the session's id is
+// known, and updatesBuffer updates for a consumer that has not read them,
+// which are dropped rather than blocking it.
+// - Per turn: maxRefusals refusals kept on a result.
+// - Per tool call: maxToolCallID bytes of id, maxLocations paths, and
+// maxLocationPath bytes of each. A call whose paths do not all fit is
+// unplaceable: refused, never judged on the paths that did.
+// - Per option list: maxOptionDepth of nesting and maxConfigOptions
+// options, however they are grouped.
+// - At once: maxHandlers agent requests being answered, maxDecisions of
+// them at the policy, maxBusy refusals waiting to be written. An agent
+// that outruns the last of these ends its session, and the requests
+// dropped in that ending are neither answered nor recorded.
+// - Per error: stderrNoteLines of the adapter's stderr.
+// - Per read-back: maxReadback bytes of the adapter's own answer.
+// - In time: modeConfirmWait for a mode to be confirmed, decisionDrain for
+// the decisions still in flight when a turn ends, and Options.CloseGrace
+// for each wait Close and Cancel make on the worker. What follows the
+// grace — a process group's SIGKILL, the reader's last read — is bounded
+// by the driver package's own waits, not by this one.
+
+// maxLine is the longest line the connector reads from an agent. A session/load
+// replay or a large tool result can be long; a line past this ends the session
+// rather than growing without bound.
+// A variable so tests need not write one.
+var maxLine = 64 << 20
+
+// maxHandlers bounds the agent requests answered at once, and maxBusy the
+// refusals waiting to be written. A variable so tests need not send a
+// thousand requests.
+var (
+ maxHandlers = 16
+ maxBusy = 256
+)
+
+// updatesBuffer is how many updates wait for a consumer that has not read
+// them. An update is progress, not a record: past this the oldest are the
+// ones that no longer matter, so emit drops rather than let an agent's pace
+// be set by a reader's.
+const updatesBuffer = 256
+
+// maxOptionDepth bounds how deeply a select option's groups may nest: the
+// agent writes that JSON, and a deep one would otherwise recurse until the
+// process dies.
+const maxOptionDepth = 8
+
+// maxDecisions bounds the permission requests one session decides at once.
+const maxDecisions = 8
+
+// decisionDrain is how long a turn's end waits for permissions still being
+// decided.
+var decisionDrain = 2 * time.Second
+
+// maxRefusals bounds the refusals one turn records; past it, a refusal is
+// still an update. maxRecorded bounds the refusals a session remembers
+// having recorded, maxTools the tool calls it remembers, maxToolCallID the
+// id of one and maxLocations the paths it may name: the agent writes all of
+// them, and a session's memory is not its to grow.
+const (
+ maxRefusals = 1024
+ // Past maxRecorded a refusal is recorded again rather than remembered:
+ // recording one twice is a count too high.
+ maxRecorded = 4096
+ maxTools = 1024
+ maxToolCallID = 256
+ maxLocations = 64
+)
+
+// maxMode bounds the mode name a session keeps. The agent writes it, it is
+// only ever compared against the asking mode and shown in an error, and one
+// longer than this is not a mode any adapter has.
+const maxMode = 256
+
+// maxEarlyInit bounds the accounts of MCP servers held while the session's
+// own id is still unknown. An account is useful only if its id turns out to
+// be this session's, so a few are all that can ever be used; past this an
+// account is dropped, and a session whose own account was dropped fails its
+// first turn rather than running unvouched for.
+const maxEarlyInit = 8
+
+// maxLocationPath bounds a path an agent names for a tool call, and
+// maxConfigOptions the options it offers in one list or one update. A call
+// whose paths do not all fit — too many of them, or one too long — is a call
+// the policy cannot place, and is refused rather than judged on the part that
+// fits.
+const (
+ maxLocationPath = 4096
+ maxConfigOptions = 256
+)
+
+// maxReadback bounds the adapter's answer to its own read-back command, in
+// each chunk and in total. The answer is the adapter's own text and it is
+// read once, at the start of a session, so a few kilobytes is all of it.
+const maxReadback = 4 << 10
+
+// stderrNoteLines is how many of the adapter's last stderr lines an error
+// carries. The error becomes the attempt's own text, so this is a few lines
+// of why, not the whole of what a failing adapter printed.
+const stderrNoteLines = 5
+
+// modeConfirmWait is how long a session with no mode config option has to
+// report the mode it was set to. A variable so tests need not wait it out.
+var modeConfirmWait = 10 * time.Second
diff --git a/internal/connector/driver/acp/mcp.go b/internal/connector/driver/acp/mcp.go
new file mode 100644
index 000000000..fd65f83a6
--- /dev/null
+++ b/internal/connector/driver/acp/mcp.go
@@ -0,0 +1,372 @@
+package acp
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/url"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "github.com/basecamp/basecamp-cli/internal/connector/driver"
+)
+
+// The MCP isolation boundary
+//
+// A session runs on the MCP servers it was given and on no others, and each
+// of those runs on the environment it was given. Three places hold that
+// line:
+//
+// 1. What is declared. wireServers turns SessionConfig.MCPServers into the
+// session/new mcpServers[], each with its whole environment written out:
+// some adapters pass their own environment down to a server and some
+// pass almost nothing, so nothing a server needs is left to inheritance.
+// What a server may inherit is bounded by what the adapter itself was
+// given, which is an allowlist (invariant 1, held in Driver.open). A
+// server without a name or an absolute command is ErrUnusable, and so is
+// an environment name that is not one.
+//
+// 2. What the adapter must not add. The adapter is configured so it can
+// load no MCP server of the host's: claude-agent-acp is given
+// settingSources: [] and strictMcpConfig, and codex-acp is refused
+// before it starts when its config declares mcp_servers
+// (ErrForeignMCPConfig, from codexPreflight) and is run with
+// DISABLE_MCP_CONFIG_FILTERING so the servers it was given reach the
+// session whole. Both live with the adapters, in adapters.go.
+//
+// 3. What the adapter says it got. verifyMCPConfiguration asks the adapter
+// what MCP configuration it is actually running — both pinned adapters
+// answer their own "/mcp", themselves, with no model and no tokens — and
+// ends the session unless that answer is the servers the session gave it,
+// plus at most a server the pinned adapter brings itself whose tools are
+// not offered to the model. Everything in 1 and 2 is what the connector
+// asked for; this is the only place that knows what it got, so this is
+// the guarantee and the rest is how it is usually true.
+//
+// 4. What actually connected. Every account of the servers is read and
+// judged in this file, whichever adapter sends it and whatever shape it
+// arrives in: Claude Code's init, forwarded as an SDK message
+// (onSDKMessage), or codex-acp's failed mcp_startup. tool calls
+// (noteStartupFailure). reportMCPServers judges an account — a server
+// the session was given that did not connect, or a server it was never
+// given that is there anyway, fails the turn with
+// ErrMCPServerNotConnected and ends the worker (invariant 9) — and
+// mcpUnconfirmedLocked judges the absence of one, which only the end of a
+// turn can see. An account naming another session is not this session's
+// and is held or dropped, never applied.
+//
+// Ending the session ends the servers: the adapter starts them, the worker's
+// process group is ended as a group, and a server the adapter keeps outside
+// that group loses the stdio it was started with.
+// wireServer is ACP's stdio McpServer.
+type wireServer struct {
+ Name string `json:"name"`
+ Command string `json:"command"`
+ Args []string `json:"args"`
+ Env []wireEnv `json:"env"`
+}
+
+type wireEnv struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+// wireServers declares every server's whole environment (invariant 1): some
+// adapters pass their own environment down to MCP servers and some pass
+// almost nothing, so nothing a server needs is left to inheritance.
+func wireServers(servers []driver.MCPServer) ([]wireServer, error) {
+ out := make([]wireServer, 0, len(servers))
+ seen := make(map[string]bool, len(servers))
+ for _, srv := range servers {
+ if srv.Name == "" || !filepath.IsAbs(srv.Command) {
+ return nil, errors.New("acp: an MCP server needs a name and an absolute command")
+ }
+ if seen[srv.Name] {
+ // Two servers of one name are one name in the agent's account of
+ // them, so one could stand for the other: there is no session
+ // this driver can judge.
+ return nil, fmt.Errorf("acp: two MCP servers are named %q", srv.Name)
+ }
+ seen[srv.Name] = true
+ env := make([]wireEnv, 0, len(srv.Env))
+ for k, v := range srv.Env {
+ if k == "" || strings.ContainsAny(k, "=\x00") {
+ return nil, fmt.Errorf("acp: MCP server %q has an invalid environment name", srv.Name)
+ }
+ env = append(env, wireEnv{Name: k, Value: v})
+ }
+ slices.SortFunc(env, func(a, b wireEnv) int { return strings.Compare(a.Name, b.Name) })
+ args := srv.Args
+ if args == nil {
+ args = []string{}
+ }
+ out = append(out, wireServer{Name: srv.Name, Command: srv.Command, Args: args, Env: env})
+ }
+ return out, nil
+}
+
+// reportMCPServers takes the agent's own account of its MCP servers
+// (invariant 9): every server the session was given must be connected, and a
+// server it was never given must not be there at all.
+//
+// complete says whether statuses is the agent's whole account of them (an
+// init) or only what it said about one server (a startup failure).
+func (s *session) reportMCPServers(statuses map[string]string, complete bool) {
+ s.mu.Lock()
+ names := slices.Clone(s.mcpNames)
+ s.mu.Unlock()
+ for name, status := range statuses {
+ switch {
+ case !slices.Contains(names, name):
+ // strictMcpConfig and the Codex preflight are meant to leave the
+ // agent nothing else; a server it names is evidence they did not,
+ // whether this is its whole list or one startup report.
+ s.fail(fmt.Errorf("%w: the agent has a server the session never gave it, %q", ErrMCPServerNotConnected, s.conn.agentText(name)))
+ return
+ case status != "connected":
+ s.fail(fmt.Errorf("%w: %q is %q", ErrMCPServerNotConnected, name, s.conn.agentText(status)))
+ return
+ }
+ }
+ if !complete {
+ return
+ }
+ for _, name := range names {
+ if statuses[name] != "connected" {
+ s.fail(fmt.Errorf("%w: the agent did not report %q at all", ErrMCPServerNotConnected, name))
+ return
+ }
+ }
+ s.mu.Lock()
+ s.mcpConfirmed = true
+ s.mu.Unlock()
+}
+
+// onSDKMessage reads the one Claude Code message the session asks
+// claude-agent-acp to forward, its init, for each MCP server's name and
+// status. Everything else in it, and every other message, is dropped unread.
+func (s *session) onSDKMessage(params json.RawMessage) {
+ if s.mcpStatus != MCPStatusInit {
+ return
+ }
+ var n struct {
+ SessionID string `json:"sessionId"`
+ Message struct {
+ Type string `json:"type"`
+ Subtype string `json:"subtype"`
+ MCPServers []struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ } `json:"mcp_servers"`
+ } `json:"message"`
+ }
+ if json.Unmarshal(params, &n) != nil || n.SessionID == "" ||
+ n.Message.Type != "system" || n.Message.Subtype != "init" {
+ return
+ }
+ statuses := map[string]string{}
+ for _, srv := range n.Message.MCPServers {
+ statuses[srv.Name] = srv.Status
+ }
+ // Reduced before anything else: what arrives is the agent's to send, and
+ // as much of it as it likes, until the session's own id settles which one
+ // account matters. Both paths below apply the same reduced account, so
+ // neither can be the lenient one.
+ held := s.reduce(statuses)
+ // Whose account this is, is decided under one lock: the session's id can
+ // arrive between reading it and acting on it, and an account read as
+ // nobody's must not then be applied as this session's.
+ s.mu.Lock()
+ switch {
+ case s.id == "":
+ // The session's id is not known yet: this account is held until it
+ // is, so an init naming another session cannot vouch for this one. An
+ // id this session could never be given is not held at all, and
+ // neither is an account past the bound.
+ if validSessionID(n.SessionID) {
+ if s.earlyInit == nil {
+ s.earlyInit = map[string]earlyAccount{}
+ }
+ if _, ok := s.earlyInit[n.SessionID]; ok || len(s.earlyInit) < maxEarlyInit {
+ s.earlyInit[n.SessionID] = held
+ }
+ }
+ s.mu.Unlock()
+ case n.SessionID != s.id:
+ // Another session's account, and this session's id is known: it says
+ // nothing about this one.
+ s.mu.Unlock()
+ default:
+ s.mu.Unlock()
+ s.reportAccount(held)
+ }
+}
+
+// collect adds a chunk of the agent's own answer to a read-back command,
+// while one is being read and at no other time.
+func (s *session) collect(text string) {
+ if text == "" {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.readback == nil || s.readback.Len() >= maxReadback {
+ return
+ }
+ if room := maxReadback - s.readback.Len(); len(text) > room {
+ text = text[:room]
+ }
+ s.readback.WriteString(text)
+}
+
+// verifyMCPConfiguration is the boundary check: it asks the adapter what MCP
+// configuration it is actually running and compares that with what this
+// session declared. It runs once, after the adapter is up and in its asking
+// mode and before the session is handed to anyone, and a difference ends the
+// session.
+//
+// Everything before it — the servers written into session/new, the adapter's
+// own switches, the Codex preflight — is what the connector asked for. This
+// is what the adapter says it got. Only the second can be a guarantee, so a
+// difference is ErrMCPReadback (an unverified session) whether the cause is a
+// configuration layer this driver cannot read, an adapter that filtered what
+// it was given, or an answer it cannot parse.
+//
+// The one thing allowed beyond the session's own servers is a server the
+// pinned adapter brings itself (Readback.BuiltIn), which is there because its
+// tools are not offered to the session's model at all.
+func (s *session) verifyMCPConfiguration(ctx context.Context, a Adapter) error {
+ if a.Readback.Command == "" || a.Readback.Parse == nil {
+ return nil
+ }
+ s.mu.Lock()
+ s.readback = &strings.Builder{}
+ // The read-back is not progress: nothing of its turn is emitted, and
+ // nothing it says of a tool call is kept.
+ s.replaying = true
+ declared := slices.Clone(s.mcpNames)
+ s.mu.Unlock()
+ _, err := s.Prompt(ctx, a.Readback.Command)
+ s.mu.Lock()
+ text := s.readback.String()
+ s.readback = nil
+ s.replaying = false
+ s.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ report, err := a.Readback.Parse(text)
+ if err != nil {
+ return err
+ }
+ return matchesDeclared(report, declared, a.Readback.BuiltIn)
+}
+
+// matchesDeclared is the comparison itself: the servers the adapter says it
+// has, against the servers the session gave it and the ones its own adapter
+// brings.
+func matchesDeclared(report MCPReport, declared, builtIn []string) error {
+ allowed := append(slices.Clone(declared), builtIn...)
+ if report.Unusable > 0 {
+ return fmt.Errorf("%w: it reports %d of its servers unusable", ErrMCPReadback, report.Unusable)
+ }
+ if report.Names == nil {
+ // An adapter that counts its servers without naming them: the count
+ // is what there is to compare.
+ if report.Count != len(allowed) {
+ return fmt.Errorf("%w: it reports %d servers, the session gave %d", ErrMCPReadback, report.Count, len(allowed))
+ }
+ return nil
+ }
+ for _, name := range report.Names {
+ if !slices.Contains(allowed, name) {
+ return fmt.Errorf("%w: it has %q, which the session never gave it", ErrMCPReadback, name)
+ }
+ }
+ for _, name := range declared {
+ if !slices.Contains(report.Names, name) {
+ return fmt.Errorf("%w: it does not have %q, which the session gave it", ErrMCPReadback, name)
+ }
+ }
+ return nil
+}
+
+// earlyAccount is an account of the MCP servers that arrived before the
+// session's id did, reduced to what judging it needs: what the agent said of
+// each server this session was given, keyed by the session's own name for it,
+// and whether it named a server the session did not give. Neither the agent's
+// own names nor how many it sends are kept, so what is held is bounded by
+// what the session gave — and a name the session never gave is never kept as
+// a name at all, only as the reason it fails, because a name put through a
+// sanitizer can come out as one the session did give.
+type earlyAccount struct {
+ statuses map[string]string
+ foreign bool
+ // reason is the foreign name and status, sanitized, for the error only.
+ reason string
+}
+
+// reduce is that reduction.
+func (s *session) reduce(statuses map[string]string) earlyAccount {
+ s.mu.Lock()
+ names := slices.Clone(s.mcpNames)
+ s.mu.Unlock()
+ held := earlyAccount{statuses: make(map[string]string, len(names))}
+ for name, status := range statuses {
+ if i := slices.Index(names, name); i >= 0 {
+ // Keyed by the session's own name, which is the one thing here
+ // that is not the agent's text.
+ held.statuses[names[i]] = s.conn.agentText(status)
+ continue
+ }
+ if !held.foreign {
+ held.foreign = true
+ held.reason = fmt.Sprintf("%q is %q", s.conn.agentText(name), s.conn.agentText(status))
+ }
+ }
+ return held
+}
+
+// reportAccount applies a held account: a server the session never gave fails
+// it here, because that name was not kept, and the rest is judged by
+// reportMCPServers like any other account.
+func (s *session) reportAccount(a earlyAccount) {
+ if a.foreign {
+ s.fail(fmt.Errorf("%w: the agent has a server the session never gave it, %s", ErrMCPServerNotConnected, a.reason))
+ return
+ }
+ s.reportMCPServers(a.statuses, true)
+}
+
+// mcpUnconfirmedLocked reports the third case of the rule: a Claude session
+// whose turn ended with no account of its MCP servers at all. The other two
+// (a server that did not connect, a server the session never gave) are
+// reportMCPServers'; this one can only be seen when a turn ends, so
+// finishTurn asks it here rather than judging for itself.
+func (s *session) mcpUnconfirmedLocked() bool {
+ return s.mcpStatus == MCPStatusInit && len(s.mcpNames) > 0 && !s.mcpConfirmed
+}
+
+// noteStartupFailure reads codex-acp's account, which arrives as failed tool
+// calls named for the server that did not start, one at a time.
+//
+// A load's replayed history can carry one of these from the session's earlier
+// life, and nothing on the wire tells it apart from the failure of the server
+// this process has just started — both are session/update for the same
+// session, both during the load. So a replayed failure fails the load, which
+// is the safe way round: a session that cannot be loaded is started fresh,
+// and a startup failure taken for history would be a worker running without
+// the tools it was given.
+func (s *session) noteStartupFailure(u sessionUpdate) {
+ if s.mcpStatus != MCPStatusStartupFailures || !strings.HasPrefix(u.ToolCallID, "mcp_startup.") ||
+ (u.Status != string(driver.ToolFailed) && u.Status != outcomeCanceled) {
+ return
+ }
+ name := strings.TrimPrefix(u.ToolCallID, "mcp_startup.")
+ if unescaped, err := url.PathUnescape(name); err == nil {
+ name = unescaped
+ }
+ s.reportMCPServers(map[string]string{name: "failed"}, false)
+}
diff --git a/internal/connector/driver/acp/permission.go b/internal/connector/driver/acp/permission.go
new file mode 100644
index 000000000..0d4542ccb
--- /dev/null
+++ b/internal/connector/driver/acp/permission.go
@@ -0,0 +1,345 @@
+package acp
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "slices"
+
+ "github.com/basecamp/basecamp-cli/internal/connector/driver"
+)
+
+// Who may decide a permission, and on what evidence
+//
+// The connector's policy decides; the agent's request is evidence only of
+// what the agent asked for. onRequest is the only place a permission is
+// decided. One other path answers a request without deciding it, and records
+// the refusal it is: a request past the connection's handler bound is
+// answered busy (onBusy). Past even the queue of those, a request is dropped
+// unanswered and unrecorded and the session is ended — an agent that outruns
+// its own refusals is not working with this client.
+//
+// A request reaches the policy only when all of this holds: it names this
+// session's own id, it was read inside a turn that has not been answered
+// (the claim taken on the reading goroutine, not whatever turn is in flight
+// when this goroutine runs), the asking mode is confirmed, the session is
+// neither unsafe nor closed, and fewer than maxDecisions are already at the
+// policy. Anything else is refused without a decision — and a refusal is
+// this driver's own record, written by record, never read back from the
+// agent's stop reason.
+//
+// What of the request is trusted:
+//
+// - sessionId, compared against the id the agent itself gave at
+// session/new. It routes nothing; it is a guard.
+// - options[].kind, matched against ACP's kinds. An option id is carried
+// back to the agent as an opaque value and is never what selects.
+// - toolCall.toolCallId, as an opaque key for the call, cut to
+// maxToolCallID wherever it is kept or shown (the session's tool calls,
+// an update, a refusal) and digested where once-ness is decided.
+//
+// What is not, because an adapter can write anything:
+//
+// - The option ids and labels. The answer is chosen by kind — allow_once,
+// never allow_always, so no answer outlives its request — and a list
+// that gives one id to two options selects nothing at all.
+// - The call's title and raw input. Neither is kept, and neither names a
+// tool on its own: they are read only to corroborate codex-acp's MCP
+// calls, which arrive with no name, and only where the adapter's own
+// marking, the title and the input agree. What claude-agent-acp names in
+// _meta or in name is taken as it gives it — the adapter's word for its
+// own tool — and in either case only in a form the policy can key on
+// (plainName), never one made plain by dropping what is not.
+// - The locations. They are the agent's paths, cut to what a path can be,
+// and are kept against the call — and so reach a later request about it —
+// only while the session could be asked about that call at all
+// (mayAskLocked).
+//
+// The policy may take its time, so the conditions are rechecked before an
+// allow is sent: a session canceled, ended or found unsafe while it decided
+// allows nothing more.
+// mayAskLocked reports whether the session could be asked to decide something
+// for turn t right now: t is the turn in flight, the agent has not answered
+// it, no history is replaying, the mode is confirmed, and the session is
+// neither unsafe nor closed. It is the one condition on which a request is
+// put to the policy and the one on which evidence about a tool call is kept,
+// so an update the session could not be asked about cannot describe a call
+// that a later request is decided on.
+func (s *session) mayAskLocked(t *turn) bool {
+ return t != nil && s.turn == t && !t.settling && !s.replaying &&
+ s.verified && s.unsafe == nil && !s.closed
+}
+
+// onRequest answers the agent's requests. The client offers no fs and no
+// terminal, so a permission is the only request it serves.
+func (s *session) onRequest(id json.RawMessage, method string, params json.RawMessage, claim any) {
+ defer s.release(claim)
+ if method != "session/request_permission" {
+ s.conn.replyError(id, codeMethodNotFound, "method not supported by this client")
+ return
+ }
+ // The turn the request was read in, not whatever turn is in flight by
+ // the time this goroutine runs.
+ t := turnOf(claim)
+ var p struct {
+ SessionID string `json:"sessionId"`
+ ToolCall json.RawMessage `json:"toolCall"`
+ Options []struct {
+ OptionID string `json:"optionId"`
+ Kind string `json:"kind"`
+ } `json:"options"`
+ }
+ if err := json.Unmarshal(params, &p); err != nil {
+ // Unreadable, so nothing is allowed — which is a refusal this driver
+ // made, and it is recorded like any other.
+ s.record(driver.PermissionRequest{Kind: driver.ToolOther}, t)
+ s.emit(driver.Update{Kind: driver.UpdatePermission, ToolKind: driver.ToolOther})
+ s.conn.replyError(id, codeInvalidParams, "unreadable permission request")
+ return
+ }
+ call, _ := decodeUpdate(p.ToolCall)
+
+ select {
+ case s.decisions <- struct{}{}:
+ defer func() { <-s.decisions }()
+ default:
+ // More at once than a session has any business asking: refused
+ // without a decision, and recorded as the refusal it is.
+ s.refuse(id, driver.PermissionRequest{ToolCallID: call.ToolCallID, Tool: toolName(call), Kind: toolKind(call.Kind)}, t)
+ return
+ }
+
+ s.mu.Lock()
+ askable := s.mayAskLocked(t) && s.id != "" && p.SessionID == s.id
+ canceled := t != nil && t.canceled
+ s.mu.Unlock()
+
+ // Only a request the session can be asked is merged into what it knows
+ // of its tool calls: one for another session, or outside a turn, could
+ // otherwise name a call that a later request is decided on.
+ info := toolInfo{name: toolName(call), kind: toolKind(call.Kind), locations: call.Locations}
+ if askable {
+ info = s.noteTool(call)
+ }
+ req := driver.PermissionRequest{
+ ToolCallID: call.ToolCallID,
+ Tool: info.name,
+ Kind: info.kind,
+ Locations: slices.Clone(info.locations),
+ }
+ if info.unplaceable || call.Unplaceable {
+ // The policy allows such a call only when every path it names is
+ // inside the working directory, and this is a call whose paths this
+ // driver could not carry whole. It is refused without being asked,
+ // rather than judged on the paths that fit.
+ s.refuse(id, req, t)
+ return
+ }
+ for _, o := range p.Options {
+ req.Options = append(req.Options, driver.PermissionOption{ID: o.OptionID, Kind: driver.PermissionOptionKind(o.Kind)})
+ }
+
+ if canceled {
+ // A turn being canceled answers its open requests as canceled, as
+ // ACP asks of a client. It is still a call this session did not
+ // allow, so it is recorded as one.
+ s.refuse(id, req, t)
+ return
+ }
+ allow := askable && s.policy.Decide(context.Background(), req).Allow
+ if allow {
+ // The policy took its time; the session may have been canceled or
+ // found unsafe while it did, and neither allows anything more.
+ s.mu.Lock()
+ allow = s.turn == t && !t.settling && !t.canceled && s.unsafe == nil && !s.closed
+ s.mu.Unlock()
+ }
+ option := chooseOption(req.Options, allow)
+ if allow && option == "" {
+ // Allowing is only ever allow_once; without it, the answer is no.
+ allow = false
+ option = chooseOption(req.Options, false)
+ }
+ if !allow {
+ s.record(req, t)
+ }
+ s.emit(driver.Update{Kind: driver.UpdatePermission, ToolCallID: req.ToolCallID, Tool: req.Tool, ToolKind: req.Kind, Allowed: allow})
+ if option == "" {
+ s.conn.reply(id, map[string]any{"outcome": map[string]any{"outcome": outcomeCanceled}})
+ return
+ }
+ s.conn.reply(id, map[string]any{"outcome": map[string]any{"outcome": "selected", "optionId": option}})
+}
+
+// outcomeCanceled is ACP's permission outcome for a request not answered by
+// an option.
+const outcomeCanceled = "cancelled" //nolint:misspell // ACP's wire value
+
+// onBusy records a permission request refused at the connection's handler
+// bound as the refusal it is.
+func (s *session) onBusy(method string, params json.RawMessage, claim any) {
+ if method != "session/request_permission" {
+ return
+ }
+ var p struct {
+ ToolCall json.RawMessage `json:"toolCall"`
+ }
+ _ = json.Unmarshal(params, &p)
+ call, _ := decodeUpdate(p.ToolCall)
+ req := driver.PermissionRequest{ToolCallID: call.ToolCallID, Tool: toolName(call), Kind: toolKind(call.Kind)}
+ // On the turn the request was read in: this refusal is answered off the
+ // reading goroutine, so by now a later turn may be in flight.
+ s.record(req, turnOf(claim))
+ s.emit(driver.Update{Kind: driver.UpdatePermission, ToolCallID: req.ToolCallID, Tool: req.Tool, ToolKind: req.Kind})
+}
+
+// refuse answers a request the session will not put to the policy at all,
+// with no option of the agent's, and records it as the refusal it is.
+func (s *session) refuse(id json.RawMessage, req driver.PermissionRequest, t *turn) {
+ s.record(req, t)
+ s.emit(driver.Update{Kind: driver.UpdatePermission, ToolCallID: req.ToolCallID, Tool: req.Tool, ToolKind: req.Kind})
+ s.conn.reply(id, map[string]any{"outcome": map[string]any{"outcome": outcomeCanceled}})
+}
+
+// record puts a refusal on the turn it belongs to (invariant 4): the turn the
+// request was read in, which its claim carried. A request read in no turn
+// belongs to no turn — it is recorded in the ledger and on nothing else,
+// because a turn that started after it was read did not ask for it.
+func (s *session) record(req driver.PermissionRequest, t *turn) {
+ id := req.ToolCallID
+ if len(id) > maxToolCallID {
+ id = id[:maxToolCallID]
+ }
+ refusal := driver.Refusal{ToolCallID: s.red.Sanitize(id), Tool: s.red.Sanitize(refusalTool(req))}
+ // Once-ness is per the id the agent sent, by digest: two ids cut or
+ // redacted to the same text are still two calls.
+ key := sha256.Sum256([]byte(req.ToolCallID))
+
+ s.mu.Lock()
+ // An id the agent did not give cannot be told from another: such a
+ // refusal is recorded every time rather than folded into one.
+ first := req.ToolCallID == "" || !s.recorded[key]
+ if len(s.recorded) < maxRecorded {
+ s.recorded[key] = true
+ }
+ if t != nil && s.turn == t && len(t.refusals) < maxRefusals && (req.ToolCallID == "" || !t.seen[key]) {
+ if t.seen == nil {
+ t.seen = map[[sha256.Size]byte]bool{}
+ }
+ t.seen[key] = true
+ t.refusals = append(t.refusals, refusal)
+ }
+ recorder := s.recorder
+ s.mu.Unlock()
+
+ // The ledger, not a session's memory, is where a refusal is kept: a
+ // worker that exits before its result, or a turn cut short, ends that
+ // memory. Once per tool call id (driver's "Refusals"); the recorder owns
+ // what happens when the ledger refuses the write.
+ if first && recorder != nil {
+ _ = recorder.RecordRefusal(context.Background(), refusal)
+ }
+}
+
+// chooseOption selects by kind, never by id or label (invariant 3). A list
+// that gives one id to two options says nothing about which the agent will
+// act on, so nothing is selected from it and the request is answered as
+// canceled.
+func chooseOption(options []driver.PermissionOption, allow bool) string {
+ seen := make(map[string]bool, len(options))
+ for _, o := range options {
+ if o.ID == "" {
+ continue
+ }
+ if seen[o.ID] {
+ return ""
+ }
+ seen[o.ID] = true
+ }
+ want := []driver.PermissionOptionKind{driver.RejectOnce, driver.RejectAlways}
+ if allow {
+ want = []driver.PermissionOptionKind{driver.AllowOnce}
+ }
+ for _, kind := range want {
+ for _, o := range options {
+ if o.Kind == kind && o.ID != "" {
+ return o.ID
+ }
+ }
+ }
+ return ""
+}
+
+func refusalTool(req driver.PermissionRequest) string {
+ if req.Tool != "" {
+ return req.Tool
+ }
+ return string(req.Kind)
+}
+
+// toolInfo is what is known of one tool call.
+type toolInfo struct {
+ name string
+ kind driver.ToolKind
+ locations []string
+ // unplaceable is a call whose paths this driver could not carry whole, so
+ // the policy cannot place it. It is never allowed.
+ unplaceable bool
+}
+
+// noteTool merges what u says about its tool call into what the session
+// knows of it, and returns the result. A later message fills in what an
+// earlier one left out; it never blanks what was known.
+//
+// What it keeps is evidence a permission decision may rest on, so it is kept
+// only on the condition a request is put to the policy at all: an update read
+// outside a turn, or while a load replays a session's history, says what it
+// says of itself and leaves nothing behind for a later request to inherit.
+func (s *session) noteTool(u sessionUpdate) toolInfo {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ usable := u.ToolCallID != "" && len(u.ToolCallID) <= maxToolCallID
+ done := false
+ switch toolStatus(u.Status) {
+ case driver.ToolCompleted, driver.ToolFailed:
+ done = true
+ case driver.ToolPending, driver.ToolInProgress:
+ }
+ if usable && done {
+ // A call that has finished is forgotten whatever the session could be
+ // asked right now: what it said of itself must not outlive it and
+ // describe a call a later turn is asked about.
+ delete(s.tools, u.ToolCallID)
+ }
+ if !s.mayAskLocked(s.turn) {
+ info := toolInfo{
+ name: toolName(u), kind: toolKind(u.Kind),
+ locations: slices.Clone(u.Locations), unplaceable: u.Unplaceable,
+ }
+ if info.kind == "" {
+ info.kind = driver.ToolOther
+ }
+ return info
+ }
+ info := s.tools[u.ToolCallID]
+ if name := toolName(u); name != "" {
+ info.name = name
+ }
+ if u.Kind != "" {
+ info.kind = toolKind(u.Kind)
+ }
+ if info.kind == "" {
+ info.kind = driver.ToolOther
+ }
+ if len(u.Locations) > 0 || u.Unplaceable {
+ info.locations = slices.Clone(u.Locations)
+ info.unplaceable = u.Unplaceable
+ }
+ if !usable || done {
+ return info
+ }
+ if _, known := s.tools[u.ToolCallID]; known || len(s.tools) < maxTools {
+ s.tools[u.ToolCallID] = info
+ }
+ return info
+}
diff --git a/internal/connector/driver/acp/rpc.go b/internal/connector/driver/acp/rpc.go
new file mode 100644
index 000000000..bf15efc88
--- /dev/null
+++ b/internal/connector/driver/acp/rpc.go
@@ -0,0 +1,411 @@
+package acp
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strconv"
+ "sync"
+
+ "github.com/basecamp/basecamp-cli/internal/connector/driver"
+ "github.com/basecamp/basecamp-cli/internal/richtext"
+)
+
+// JSON-RPC 2.0 over newline-delimited JSON, hand-rolled: ACP v1's stdio
+// transport is one JSON object per line in each direction, and the surface
+// the connector uses is a handful of methods. The community Go SDKs track the
+// protocol's unstable drafts; a transcript of exactly what went over the wire
+// is worth more here than their generated types.
+
+// JSON-RPC error codes the client sends.
+const (
+ codeMethodNotFound = -32601
+ codeInvalidParams = -32602
+ // codeBusy is JSON-RPC's implementation-defined server error range.
+ codeBusy = -32000
+)
+
+type wireMessage struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID json.RawMessage `json:"id,omitempty"`
+ Method string `json:"method,omitempty"`
+ Params json.RawMessage `json:"params,omitempty"`
+ Result json.RawMessage `json:"result,omitempty"`
+ Error *wireError `json:"error,omitempty"`
+}
+
+type wireError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+// rpcError is an error response from the agent. Its message is the agent's
+// text, so it is redacted and cut short before it becomes an error string.
+type rpcError struct {
+ Method string
+ Code int
+ Message string
+}
+
+func (e *rpcError) Error() string {
+ return fmt.Sprintf("acp: %s: agent error %d: %s", e.Method, e.Code, e.Message)
+}
+
+// errConnClosed is a call on a connection whose agent has stopped writing.
+var errConnClosed = fmt.Errorf("%w: the agent closed its output", driver.ErrSessionEnded)
+
+// conn is one JSON-RPC connection to an agent process.
+type conn struct {
+ w io.Writer
+ writeMu sync.Mutex
+
+ mu sync.Mutex
+ nextID int64
+ pending map[int64]chan wireMessage
+ closed bool
+
+ // onNotification runs on the reading goroutine, in wire order, so a mode
+ // update is applied before the response that follows it is delivered.
+ onNotification func(method string, params json.RawMessage)
+ // onResponse runs on the reading goroutine before a response is handed
+ // to its caller, so what follows it on the wire is read knowing it came.
+ onResponse func(id int64)
+ // onBusy hears a request refused at the handler bound, before its answer
+ // is written, so the refusal is on the record. It is given what the
+ // request was read in, because it runs later than the reading of it.
+ onBusy func(method string, params json.RawMessage, claimed any)
+ // release gives up a claim taken for a request that was dropped without
+ // being answered at all.
+ release func(claimed any)
+ // onOverflow hears that even the refusals have backed up.
+ onOverflow func()
+ // onRequest runs on its own goroutine per request; it must answer with
+ // reply or replyError.
+ onRequest func(id json.RawMessage, method string, params json.RawMessage, claimed any)
+ // claim runs on the reading goroutine as a request is admitted, in wire
+ // order, and what it returns is handed to onRequest: the state the
+ // request arrived in, before anything read after it can change that.
+ claim func(method string) any
+
+ // handlers bounds the requests being answered at once: a flood of them
+ // spawns no more than this many goroutines, and the rest are refused as
+ // they are read.
+ handlers chan struct{}
+ // busy carries the requests refused at the bound to the one goroutine
+ // that records and answers them: neither happens on the reader, so an
+ // agent that floods requests cannot stall what the client reads.
+ busy chan busyRequest
+
+ done chan struct{}
+
+ // red is what every text of this connection that reaches an error or a
+ // log passes through.
+ red *driver.Redactor
+ // trace, set only by this package's tests, sees every line in each
+ // direction ("->" to the agent, "<-" from it).
+ trace func(dir string, line []byte)
+}
+
+func newConn(w io.Writer) *conn {
+ c := &conn{
+ w: w, pending: map[int64]chan wireMessage{},
+ handlers: make(chan struct{}, maxHandlers),
+ busy: make(chan busyRequest, maxBusy),
+ done: make(chan struct{}),
+ }
+ go c.answerBusy()
+ return c
+}
+
+// busyRequest is a request refused at the handler bound, with what it was
+// read in.
+type busyRequest struct {
+ id json.RawMessage
+ method string
+ params json.RawMessage
+ claimed any
+}
+
+// answerBusy records and answers the requests refused at the handler bound,
+// until the connection ends.
+func (c *conn) answerBusy() {
+ for {
+ select {
+ case r := <-c.busy:
+ if c.onBusy != nil {
+ c.onBusy(r.method, r.params, r.claimed)
+ }
+ c.replyError(r.id, codeBusy, "too many requests at once")
+ if c.release != nil {
+ c.release(r.claimed)
+ }
+ case <-c.done:
+ return
+ }
+ }
+}
+
+// read dispatches lines until r ends, then fails every pending call. It
+// returns the scanner's error: a line past maxLine, or a failed read.
+func (c *conn) read(r io.Reader) error {
+ scanner := bufio.NewScanner(r)
+ scanner.Buffer(make([]byte, 64<<10), maxLine)
+ defer func() {
+ c.mu.Lock()
+ c.closed = true
+ for id, ch := range c.pending {
+ close(ch)
+ delete(c.pending, id)
+ }
+ c.mu.Unlock()
+ close(c.done)
+ }()
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ if c.trace != nil {
+ c.trace("<-", line)
+ }
+ var m wireMessage
+ if json.Unmarshal(line, &m) != nil || m.JSONRPC != "2.0" {
+ continue
+ }
+ switch {
+ case m.Method != "" && len(m.ID) > 0:
+ if c.onRequest == nil {
+ c.replyError(m.ID, codeMethodNotFound, "method not supported by this client")
+ continue
+ }
+ // What the request was read in is taken here either way, on the
+ // reading goroutine and in wire order: the turn it belongs to is
+ // the turn in flight now, not whatever is in flight when it is
+ // answered.
+ var claimed any
+ if c.claim != nil {
+ claimed = c.claim(m.Method)
+ }
+ select {
+ case c.handlers <- struct{}{}:
+ default:
+ // Already answering as many as this client answers at once.
+ // Recorded and answered off the reader: an agent flooding
+ // requests while it has stopped reading its input must not
+ // stall what the client reads from it.
+ select {
+ case c.busy <- busyRequest{id: m.ID, method: m.Method, params: m.Params, claimed: claimed}:
+ default:
+ // More unanswered requests than any agent asks: it is not
+ // working with this client, and the session ends. This one
+ // is neither answered nor recorded; the session's end is
+ // the answer to all of them.
+ if c.release != nil {
+ c.release(claimed)
+ }
+ if c.onOverflow != nil {
+ c.onOverflow()
+ }
+ }
+ continue
+ }
+ id, method, params := m.ID, m.Method, m.Params
+ go func() {
+ defer func() { <-c.handlers }()
+ c.onRequest(id, method, params, claimed)
+ }()
+ case m.Method != "":
+ if c.onNotification != nil {
+ c.onNotification(m.Method, m.Params)
+ }
+ default:
+ id, err := strconv.ParseInt(string(m.ID), 10, 64)
+ if err != nil {
+ continue
+ }
+ c.mu.Lock()
+ ch := c.pending[id]
+ delete(c.pending, id)
+ c.mu.Unlock()
+ if ch != nil {
+ if c.onResponse != nil {
+ c.onResponse(id)
+ }
+ ch <- m
+ }
+ }
+ }
+ return scanner.Err()
+}
+
+// call sends a request and decodes its result into out. A ctx that ends
+// abandons the wait, not the request; out is written only when the result is
+// delivered to this caller.
+func (c *conn) call(ctx context.Context, method string, params, out any) error {
+ p := c.register(method)
+ type answer struct {
+ raw json.RawMessage
+ err error
+ }
+ answers := make(chan answer, 1)
+ go func() {
+ // The write is on this goroutine too: an agent that has stopped
+ // reading its input would otherwise hold the caller past its context.
+ if err := c.sendCall(p, params); err != nil {
+ answers <- answer{nil, err}
+ return
+ }
+ raw, err := p.result()
+ answers <- answer{raw, err}
+ }()
+ select {
+ case a := <-answers:
+ if a.err != nil || out == nil {
+ return a.err
+ }
+ if err := json.Unmarshal(a.raw, out); err != nil {
+ return fmt.Errorf("acp: %s: unreadable result: %w", method, err)
+ }
+ return nil
+ case <-ctx.Done():
+ c.abandon(p)
+ return ctx.Err()
+ }
+}
+
+// pendingCall is a request on the wire, waiting for its response.
+type pendingCall struct {
+ c *conn
+ id int64
+ method string
+ ch chan wireMessage
+}
+
+// register reserves an id and a response slot for a request not yet sent. On
+// a closed connection the slot is already closed.
+func (c *conn) register(method string) *pendingCall {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.nextID++
+ p := &pendingCall{c: c, id: c.nextID, method: method, ch: make(chan wireMessage, 1)}
+ if c.closed {
+ close(p.ch)
+ } else {
+ c.pending[p.id] = p.ch
+ }
+ return p
+}
+
+// sendCall writes a registered request.
+func (c *conn) sendCall(p *pendingCall, params any) error {
+ if err := c.send(map[string]any{"jsonrpc": "2.0", "id": p.id, "method": p.method, "params": params}); err != nil {
+ c.abandon(p)
+ return fmt.Errorf("%w: %s: %w", driver.ErrSessionEnded, p.method, err)
+ }
+ return nil
+}
+
+// result blocks until the response arrives, the call is abandoned, or the
+// connection ends.
+func (p *pendingCall) result() (json.RawMessage, error) {
+ m, ok := <-p.ch
+ if !ok {
+ return nil, errConnClosed
+ }
+ if m.Error != nil {
+ return nil, &rpcError{Method: p.method, Code: m.Error.Code, Message: p.c.agentText(m.Error.Message)}
+ }
+ return m.Result, nil
+}
+
+// wait is result decoded into out.
+func (p *pendingCall) wait(out any) error {
+ raw, err := p.result()
+ if err != nil || out == nil {
+ return err
+ }
+ if err := json.Unmarshal(raw, out); err != nil {
+ return fmt.Errorf("acp: %s: unreadable result: %w", p.method, err)
+ }
+ return nil
+}
+
+// abandon stops waiting for a call: its slot is closed, so whoever waits on
+// it gets errConnClosed, and a response that arrives later is dropped.
+func (c *conn) abandon(p *pendingCall) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if ch, ok := c.pending[p.id]; ok {
+ delete(c.pending, p.id)
+ close(ch)
+ }
+}
+
+// notifyIf writes a notification only if still() holds once the write lock is
+// taken: a notification that waited behind a stuck write is dropped if what
+// it was about has ended while it waited.
+func (c *conn) notifyIf(still func() bool, method string, params any) error {
+ data, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
+ if err != nil {
+ return err
+ }
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ if !still() {
+ return nil
+ }
+ if c.trace != nil {
+ c.trace("->", data)
+ }
+ _, err = c.w.Write(append(data, '\n'))
+ return err
+}
+
+func (c *conn) reply(id json.RawMessage, result any) {
+ _ = c.send(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
+}
+
+func (c *conn) replyError(id json.RawMessage, code int, message string) {
+ _ = c.send(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": message}})
+}
+
+func (c *conn) send(v any) error {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ if c.trace != nil {
+ c.trace("->", data)
+ }
+ if _, err := c.w.Write(append(data, '\n')); err != nil {
+ return err
+ }
+ return nil
+}
+
+// closeWrite closes the agent's input. Not under the write lock: a write
+// stuck on a full pipe holds that lock, and closing the pipe is what unblocks
+// it.
+func (c *conn) closeWrite(closer io.Closer) {
+ _ = closer.Close()
+}
+
+// agentText is text the agent wrote, made fit for an error string that ends
+// up in a log: redacted (driver invariant 6), stripped of the escapes and
+// controls a terminal would act on, on one line, and short.
+func (c *conn) agentText(s string) string {
+ // Cut first: a line from the agent may be megabytes, and none of it past
+ // the first few hundred bytes reaches the error anyway.
+ if len(s) > 4<<10 {
+ s = s[:4<<10]
+ }
+ out := []rune(richtext.SanitizeSingleLine(c.red.Sanitize(s)))
+ if len(out) > 120 {
+ out = out[:120]
+ }
+ return string(out)
+}
diff --git a/internal/connector/driver/acp/session.go b/internal/connector/driver/acp/session.go
new file mode 100644
index 000000000..911560ca9
--- /dev/null
+++ b/internal/connector/driver/acp/session.go
@@ -0,0 +1,1220 @@
+package acp
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/basecamp/basecamp-cli/internal/connector/driver"
+ "github.com/basecamp/basecamp-cli/internal/version"
+)
+
+// session is one adapter process and the one ACP session it serves.
+type session struct {
+ worker *driver.Worker
+ conn *conn
+ policy driver.PermissionPolicy
+ askMode string
+ grace time.Duration
+
+ updates chan driver.Update
+ readerEnd chan struct{}
+
+ // promptSem orders a prompt's request and a cancel's notification on the
+ // wire, so a cancel never reaches the agent before the prompt it ends. A
+ // channel, not a mutex, so a cancel can give up waiting on a prompt whose
+ // write is stuck.
+ promptSem chan struct{}
+ // decisions bounds the permission requests decided at once: an agent that
+ // floods them cannot spawn work without end, and what does not fit is
+ // refused.
+ decisions chan struct{}
+
+ mu sync.Mutex
+ id string
+ turn *turn
+ mode string
+ // modeSeq counts mode reports, so an answer to a set cannot overwrite a
+ // report that arrived after that set went out.
+ modeSeq int64
+ modeSeen chan struct{}
+ verified bool
+ // deciding counts the permission requests admitted and not yet answered,
+ // counted from the moment they are read.
+ deciding int
+ // canceled is a cancel that found no turn to end: the next turn starts
+ // canceled, and takes the flag with it.
+ canceled bool
+ unsafe error
+ // readback collects the text of the agent's own answer to a read-back
+ // command, and is nil at every other moment of a session's life.
+ readback *strings.Builder
+ // earlyInit holds an account of the MCP servers that arrived before the
+ // session's id did, by the id it named.
+ earlyInit map[string]earlyAccount
+ // mcpStatus, mcpNames and mcpConfirmed are how the session learns its MCP
+ // servers connected (Adapter.MCPStatus).
+ mcpStatus MCPStatus
+ mcpNames []string
+ mcpConfirmed bool
+ // red is what every error, update text and stderr tail of this session
+ // passes through.
+ red *driver.Redactor
+ // recorder records each refusal once, as it is made (driver's
+ // "Refusals"); recorded is the tool call ids already recorded.
+ recorder driver.RefusalRecorder
+ recorded map[[sha256.Size]byte]bool
+ replaying bool
+ updatesClosed bool
+ closed bool
+ context driver.Usage
+ // tools is what the agent said about each tool call it announced, so a
+ // permission request that names only the call's id is decided on the call.
+ tools map[string]toolInfo
+
+ closeOnce sync.Once
+ // endUnsafe ends the worker of a session found outside its asking mode;
+ // the worker's Terminate, replaced only by this package's tests.
+ endUnsafe func()
+}
+
+// turn is a prompt in flight.
+type turn struct {
+ done chan struct{}
+ // settling is set once the agent has answered the prompt: nothing more is
+ // sent for this turn.
+ settling bool
+ // call is the turn's session/prompt, registered before it is sent.
+ call *pendingCall
+ canceled bool
+ refusals []driver.Refusal
+ // seen is the tool calls already on refusals, by digest of the id the
+ // agent sent: a call the stream announced and the result repeats is one
+ // refusal, and two ids that are shown the same are still two calls.
+ seen map[[sha256.Size]byte]bool
+ result driver.PromptResult
+ err error
+}
+
+var _ driver.Session = (*session)(nil)
+
+// sessionOptions is everything a session is given before it reads a line:
+// nothing is set on it once its reader has started.
+type sessionOptions struct {
+ Worker *driver.Worker
+ Policy driver.PermissionPolicy
+ AskMode string
+ Grace time.Duration
+ Redactor *driver.Redactor
+ MCPStatus MCPStatus
+ MCPNames []string
+ Refusals driver.RefusalRecorder
+ trace func(string, []byte)
+}
+
+func newSession(opts sessionOptions) *session {
+ worker, red, trace := opts.Worker, opts.Redactor, opts.trace
+ s := &session{
+ worker: worker,
+ policy: opts.Policy,
+ askMode: opts.AskMode,
+ grace: opts.Grace,
+ mcpStatus: opts.MCPStatus,
+ mcpNames: opts.MCPNames,
+ recorder: opts.Refusals,
+ updates: make(chan driver.Update, updatesBuffer),
+ readerEnd: make(chan struct{}),
+ modeSeen: make(chan struct{}),
+ promptSem: make(chan struct{}, 1),
+ decisions: make(chan struct{}, maxDecisions),
+ tools: map[string]toolInfo{},
+ recorded: map[[sha256.Size]byte]bool{},
+ }
+ s.endUnsafe = func() { worker.Terminate(0) }
+ s.conn = newConn(worker.Stdin())
+ s.conn.red = red
+ s.red = red
+ s.conn.trace = trace
+ s.conn.onNotification = s.onNotification
+ s.conn.onRequest = s.onRequest
+ s.conn.claim = s.claim
+ s.conn.onResponse = s.onResponse
+ s.conn.onBusy = s.onBusy
+ s.conn.release = s.release
+ s.conn.onOverflow = func() {
+ s.fail(errors.New("acp: the agent has more requests unanswered than this client will hold"))
+ }
+ go func() {
+ if err := s.conn.read(worker.Stdout()); err != nil {
+ // A line past maxLine or a broken pipe: the session cannot go
+ // on, so its worker does not either.
+ s.worker.Terminate(0)
+ }
+ // Drain what is left so the agent never blocks on a full pipe.
+ _, _ = io.Copy(io.Discard, worker.Stdout())
+ s.mu.Lock()
+ s.updatesClosed = true
+ close(s.updates)
+ s.mu.Unlock()
+ close(s.readerEnd)
+ }()
+ return s
+}
+
+func (s *session) ID() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.id
+}
+
+func (s *session) Process() driver.Process { return s.worker.Process() }
+func (s *session) Updates() <-chan driver.Update { return s.updates }
+func (s *session) Done() <-chan struct{} { return s.worker.Done() }
+func (s *session) Exit() driver.Exit { return s.worker.Exit() }
+
+// ---------------------------------------------------------------- handshake
+
+type agentCaps struct {
+ LoadSession bool
+ Resume bool
+}
+
+func (s *session) initialize(ctx context.Context, a Adapter) (agentCaps, error) {
+ var r struct {
+ ProtocolVersion int `json:"protocolVersion"`
+ AgentCapabilities struct {
+ LoadSession bool `json:"loadSession"`
+ SessionCapabilities struct {
+ Resume json.RawMessage `json:"resume"`
+ } `json:"sessionCapabilities"`
+ } `json:"agentCapabilities"`
+ AgentInfo *struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ } `json:"agentInfo"`
+ }
+ err := s.conn.call(ctx, "initialize", map[string]any{
+ "protocolVersion": ProtocolVersion,
+ // No fs, no terminal: the agent works through its own tools, and asks.
+ "clientCapabilities": map[string]any{
+ "fs": map[string]any{"readTextFile": false, "writeTextFile": false},
+ "terminal": false,
+ },
+ "clientInfo": map[string]any{"name": "basecamp-connect", "version": version.Version},
+ }, &r)
+ if err != nil {
+ return agentCaps{}, err
+ }
+ if r.ProtocolVersion != ProtocolVersion {
+ return agentCaps{}, fmt.Errorf("acp: the agent answered protocol version %d, not %d", r.ProtocolVersion, ProtocolVersion)
+ }
+ if r.AgentInfo == nil || r.AgentInfo.Name != a.Package || r.AgentInfo.Version != a.Version {
+ name, ver := "", ""
+ if r.AgentInfo != nil {
+ name, ver = r.AgentInfo.Name, r.AgentInfo.Version
+ }
+ return agentCaps{}, fmt.Errorf("%w: it reports %s@%s, pinned is %s@%s", ErrWrongAdapter, s.conn.agentText(name), s.conn.agentText(ver), a.Package, a.Version)
+ }
+ resume := len(r.AgentCapabilities.SessionCapabilities.Resume) > 0 && string(r.AgentCapabilities.SessionCapabilities.Resume) != "null"
+ return agentCaps{LoadSession: r.AgentCapabilities.LoadSession, Resume: resume}, nil
+}
+
+// sessionState is what session/new, session/load and session/resume answer.
+type sessionState struct {
+ SessionID string `json:"sessionId"`
+ Modes *struct {
+ CurrentModeID string `json:"currentModeId"`
+ AvailableModes []struct {
+ ID string `json:"id"`
+ } `json:"availableModes"`
+ } `json:"modes"`
+ ConfigOptions []configOption `json:"configOptions"`
+}
+
+// configOption is a session config option, reduced to what finds the mode.
+type configOption struct {
+ ID string `json:"id"`
+ Category string `json:"category"`
+ Type string `json:"type"`
+ CurrentValue json.RawMessage `json:"currentValue"`
+ Options json.RawMessage `json:"options"`
+}
+
+func (s *session) newSession(ctx context.Context, cwd string, servers []wireServer, meta map[string]any) (sessionState, error) {
+ params := map[string]any{"cwd": cwd, "mcpServers": servers}
+ if meta != nil {
+ params["_meta"] = meta
+ }
+ var st sessionState
+ if err := s.conn.call(ctx, "session/new", params, &st); err != nil {
+ return st, err
+ }
+ if !validSessionID(st.SessionID) {
+ return st, errors.New("acp: session/new answered no usable session id")
+ }
+ s.nameSession(st.SessionID)
+ return st, nil
+}
+
+// nameSession is where the session's id becomes known: an account of the MCP
+// servers that arrived before it is applied now, and only the one that named
+// this session.
+func (s *session) nameSession(id string) {
+ s.mu.Lock()
+ s.id = id
+ early, held := s.earlyInit[id]
+ s.earlyInit = nil
+ s.mu.Unlock()
+ if held {
+ s.reportAccount(early)
+ }
+}
+
+// loadSession reopens a session by id, by the method the agent advertised
+// (invariant 5). The history the agent replays is not progress.
+func (s *session) loadSession(ctx context.Context, caps agentCaps, id, cwd string, servers []wireServer, meta map[string]any) (sessionState, error) {
+ var method string
+ switch {
+ case caps.LoadSession:
+ method = "session/load"
+ case caps.Resume:
+ method = "session/resume"
+ default:
+ return sessionState{}, ErrLoadUnsupported
+ }
+ s.mu.Lock()
+ s.replaying = true
+ s.mu.Unlock()
+ s.nameSession(id)
+ defer func() {
+ s.mu.Lock()
+ s.replaying = false
+ s.mu.Unlock()
+ }()
+ params := map[string]any{"sessionId": id, "cwd": cwd, "mcpServers": servers}
+ if meta != nil {
+ params["_meta"] = meta
+ }
+ var st sessionState
+ if err := s.conn.call(ctx, method, params, &st); err != nil {
+ return st, err
+ }
+ st.SessionID = id
+ return st, nil
+}
+
+// enterAskingMode puts the session in its adapter's asking mode and reads the
+// mode back (invariant 2). session/set_mode answers nothing, so the read-back
+// is session/set_config_option's full option list where the agent has a mode
+// option, and otherwise a current_mode_update.
+func (s *session) enterAskingMode(ctx context.Context, st sessionState) error {
+ offered := false
+ if st.Modes != nil {
+ for _, m := range st.Modes.AvailableModes {
+ offered = offered || m.ID == s.askMode
+ }
+ }
+ modeOpt := modeOption(st.ConfigOptions)
+ if modeOpt != nil && slices.Contains(optionValues(modeOpt.Options), s.askMode) {
+ offered = true
+ }
+ if !offered {
+ return fmt.Errorf("%w: the agent does not offer the asking mode %q", driver.ErrUnsafeMode, s.askMode)
+ }
+ if st.Modes != nil {
+ s.reportMode(st.Modes.CurrentModeID)
+ }
+ if v, ok := stringValue(modeOpt); ok {
+ s.reportMode(v)
+ }
+
+ if st.Modes != nil {
+ if err := s.conn.call(ctx, "session/set_mode", map[string]any{"sessionId": st.SessionID, "modeId": s.askMode}, nil); err != nil {
+ return fmt.Errorf("%w: session/set_mode: %w", driver.ErrUnsafeMode, err)
+ }
+ }
+ if modeOpt != nil {
+ var r struct {
+ ConfigOptions []configOption `json:"configOptions"`
+ }
+ s.mu.Lock()
+ seq := s.modeSeq
+ s.mu.Unlock()
+ err := s.conn.call(ctx, "session/set_config_option", map[string]any{"sessionId": st.SessionID, "configId": modeOpt.ID, "value": s.askMode}, &r)
+ if err != nil {
+ return fmt.Errorf("%w: session/set_config_option: %w", driver.ErrUnsafeMode, err)
+ }
+ v, ok := stringValue(modeOption(r.ConfigOptions))
+ if !ok {
+ return fmt.Errorf("%w: session/set_config_option answered no mode", driver.ErrUnsafeMode)
+ }
+ s.reportModeSince(v, seq)
+ } else {
+ wait, cancel := context.WithTimeout(ctx, modeConfirmWait)
+ defer cancel()
+ s.awaitMode(wait)
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.mode != s.askMode {
+ return fmt.Errorf("%w: asked for mode %q, the agent reports %q", driver.ErrUnsafeMode, s.askMode, s.conn.agentText(s.mode))
+ }
+ s.verified = true
+ return nil
+}
+
+// awaitMode waits for the agent to report the asking mode, or for ctx.
+func (s *session) awaitMode(ctx context.Context) {
+ for {
+ s.mu.Lock()
+ if s.mode == s.askMode {
+ s.mu.Unlock()
+ return
+ }
+ seen := s.modeSeen
+ s.mu.Unlock()
+ select {
+ case <-seen:
+ case <-s.readerEnd:
+ return
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+// reportMode records the mode the agent reports. Once the asking mode is
+// confirmed, any other mode makes the session unsafe: its turn fails with
+// ErrUnsafeMode and its process group is ended (invariant 2).
+func (s *session) reportMode(id string) { s.reportModeSince(id, -1) }
+
+// reportModeSince records a mode the agent reports. since is the sequence the
+// caller last saw: a report older than what has arrived since then is dropped,
+// so the answer to a set_config_option cannot undo a mode update that followed
+// it on the wire. A negative since always applies.
+func (s *session) reportModeSince(id string, since int64) {
+ s.mu.Lock()
+ if since >= 0 && s.modeSeq != since {
+ s.mu.Unlock()
+ return
+ }
+ s.modeSeq++
+ if len(id) > maxMode {
+ id = id[:maxMode]
+ }
+ s.mode = id
+ close(s.modeSeen)
+ s.modeSeen = make(chan struct{})
+ claimed := false
+ if s.verified && id != s.askMode {
+ // Claimed here, under the lock that saw the mode change: nothing
+ // starts a turn against an agent already known to have left it.
+ claimed = s.failLocked(fmt.Errorf("%w: the agent left mode %q for %q", driver.ErrUnsafeMode, s.askMode, s.conn.agentText(id)))
+ }
+ t := s.turn
+ end := s.endUnsafe
+ s.mu.Unlock()
+ if claimed {
+ s.endAfterTurn(t, end)
+ }
+}
+
+// fail ends a session that cannot go on: its turn fails with err, and its
+// worker is ended after. The first failure is the one reported.
+func (s *session) fail(err error) {
+ s.mu.Lock()
+ claimed := s.failLocked(err)
+ t := s.turn
+ end := s.endUnsafe
+ s.mu.Unlock()
+ if claimed {
+ s.endAfterTurn(t, end)
+ }
+}
+
+// failLocked claims the session's failure under the caller's own lock, so
+// nothing starts a turn between seeing the reason and recording it. It
+// reports whether this caller is the one that ends the session.
+func (s *session) failLocked(err error) bool {
+ if s.unsafe != nil {
+ return false
+ }
+ s.unsafe = err
+ return true
+}
+
+// failure is why the session ended, when it ended for a reason of its own.
+func (s *session) failure() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.unsafe
+}
+
+// endAfterTurn fails the turn first and ends the worker after, so whoever
+// waits on both hears the reason before the worker is gone.
+func (s *session) endAfterTurn(t *turn, end func()) {
+ go func() {
+ if t != nil {
+ s.conn.abandon(t.call)
+ // Bounded: a turn whose prompt is still stuck in a write the
+ // agent never reads must not keep the worker alive.
+ select {
+ case <-t.done:
+ case <-time.After(s.grace):
+ }
+ }
+ end()
+ }()
+}
+
+func modeOption(options []configOption) *configOption {
+ for i := range options {
+ if options[i].Category == "mode" && options[i].Type == "select" {
+ return &options[i]
+ }
+ }
+ return nil
+}
+
+func stringValue(o *configOption) (string, bool) {
+ if o == nil {
+ return "", false
+ }
+ var v string
+ if json.Unmarshal(o.CurrentValue, &v) != nil {
+ return "", false
+ }
+ return v, true
+}
+
+// optionValues are a select option's values, flat or grouped.
+func optionValues(raw json.RawMessage) []string { return optionValuesAt(raw, 0) }
+
+func optionValuesAt(raw json.RawMessage, depth int) []string {
+ if depth >= maxOptionDepth {
+ return nil
+ }
+ var items []struct {
+ Value *string `json:"value"`
+ Options json.RawMessage `json:"options"`
+ }
+ if json.Unmarshal(raw, &items) != nil {
+ return nil
+ }
+ var out []string
+ for _, it := range items {
+ if len(out) >= maxConfigOptions {
+ break
+ }
+ if it.Value != nil {
+ out = append(out, *it.Value)
+ }
+ if len(it.Options) > 0 {
+ out = append(out, optionValuesAt(it.Options, depth+1)...)
+ }
+ }
+ return out
+}
+
+// ---------------------------------------------------------------- turns
+
+// Prompt implements driver.Session.
+func (s *session) Prompt(ctx context.Context, prompt string) (driver.PromptResult, error) {
+ select {
+ case s.promptSem <- struct{}{}:
+ default:
+ // Nothing is on the wire: wait for the turn ahead, but not past this
+ // session's end or the caller's context.
+ select {
+ case s.promptSem <- struct{}{}:
+ case <-s.readerEnd:
+ return driver.PromptResult{}, driver.ErrSessionEnded
+ case <-ctx.Done():
+ return driver.PromptResult{}, ctx.Err()
+ }
+ }
+ s.mu.Lock()
+ var refuse error
+ switch {
+ case s.closed:
+ refuse = driver.ErrSessionEnded
+ case s.unsafe != nil:
+ refuse = s.unsafe
+ case !s.verified:
+ refuse = fmt.Errorf("%w: the mode was never confirmed", driver.ErrUnsafeMode)
+ case s.turn != nil:
+ refuse = errors.New("acp: a turn is already in flight")
+ }
+ if refuse != nil {
+ s.mu.Unlock()
+ <-s.promptSem
+ return driver.PromptResult{}, s.red.Err(refuse)
+ }
+ t := &turn{done: make(chan struct{}), call: s.conn.register("session/prompt")}
+ // A cancel that arrived before the turn it was meant for ends this one,
+ // and only this one.
+ t.canceled = s.canceled
+ s.canceled = false
+ s.turn = t
+ id := s.id
+ s.mu.Unlock()
+
+ answer := t.call
+ // The write is on its own goroutine, and the turn's place in the queue is
+ // held until it is done: an agent that has stopped reading its input
+ // cannot hold this caller past its context, and no cancel of this turn
+ // goes out before the prompt it cancels.
+ go func() {
+ err := s.conn.sendCall(answer, map[string]any{
+ "sessionId": id,
+ "prompt": []any{map[string]any{"type": "text", "text": prompt}},
+ })
+ s.mu.Lock()
+ canceled := t.canceled
+ s.mu.Unlock()
+ <-s.promptSem
+ if canceled && err == nil {
+ go func() {
+ _ = s.conn.notifyIf(func() bool { return s.inFlight(t) }, "session/cancel", map[string]any{"sessionId": id})
+ }()
+ }
+ s.finishTurn(t, answer, err)
+ }()
+
+ select {
+ case <-t.done:
+ return t.result, t.err
+ case <-ctx.Done():
+ return driver.PromptResult{}, ctx.Err()
+ }
+}
+
+// finishTurn waits for the prompt's response and settles the turn, whether or
+// not anyone is still waiting on Prompt.
+func (s *session) finishTurn(t *turn, answer *pendingCall, sendErr error) {
+ var resp struct {
+ StopReason string `json:"stopReason"`
+ Usage *struct {
+ InputTokens int64 `json:"inputTokens"`
+ OutputTokens int64 `json:"outputTokens"`
+ } `json:"usage"`
+ }
+ err := sendErr
+ if err == nil {
+ err = answer.wait(&resp)
+ }
+ s.mu.Lock()
+ t.settling = true
+ s.mu.Unlock()
+
+ s.drainDecisions()
+ s.mu.Lock()
+ if s.turn == t {
+ s.turn = nil
+ }
+ refusals := slices.Clone(t.refusals)
+ canceled := t.canceled
+ unsafe := s.unsafe
+ usage := s.context
+ unconfirmed := s.mcpUnconfirmedLocked()
+ s.mu.Unlock()
+ if unsafe == nil && err == nil && unconfirmed {
+ // A turn ended and the agent never said its MCP servers connected:
+ // nothing it did can be vouched for, and nothing more is asked of it.
+ unsafe = fmt.Errorf("%w: the agent never reported its MCP servers", ErrMCPServerNotConnected)
+ s.fail(unsafe)
+ }
+
+ result := driver.PromptResult{Refusals: refusals, Usage: usage}
+ if resp.Usage != nil {
+ result.Usage.InputTokens = resp.Usage.InputTokens
+ result.Usage.OutputTokens = resp.Usage.OutputTokens
+ }
+ switch {
+ case unsafe != nil:
+ err = unsafe
+ case err != nil:
+ default:
+ result.Stop, err = s.stopOf(resp.StopReason, canceled, len(refusals))
+ if err == nil && resp.Usage != nil {
+ u := result.Usage
+ s.emit(driver.Update{Kind: driver.UpdateUsage, Usage: &u})
+ }
+ }
+ t.result, t.err = result, s.red.Err(err)
+ close(t.done)
+}
+
+// drainDecisions waits, briefly, for the permissions being decided to be
+// answered, so a refusal made as the turn ends is still on its result
+// (invariant 4).
+func (s *session) drainDecisions() {
+ deadline := time.Now().Add(decisionDrain)
+ for time.Now().Before(deadline) {
+ s.mu.Lock()
+ n := s.deciding
+ s.mu.Unlock()
+ if n == 0 {
+ return
+ }
+ time.Sleep(time.Millisecond)
+ }
+}
+
+// claim is taken on the reading goroutine as a permission request is
+// admitted: the turn it arrived in, and a count the turn's end waits on. A
+// request read before the prompt's answer belongs to that turn, however late
+// its goroutine runs.
+func (s *session) claim(method string) any {
+ if method != "session/request_permission" {
+ return nil
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.deciding++
+ return &claimed{turn: s.turn}
+}
+
+// claimed is what a permission request was read in: the turn it belongs to,
+// counted among the session's decisions until it is answered. A request
+// refused at the connection's own bound carries one too, so its refusal is
+// recorded against the turn it arrived in and that turn's end waits for it.
+type claimed struct{ turn *turn }
+
+// release gives up a claim, whether the request it was taken for was
+// answered by the policy, refused unasked, or dropped unanswered.
+func (s *session) release(c any) {
+ if c == nil {
+ return
+ }
+ s.mu.Lock()
+ s.deciding--
+ s.mu.Unlock()
+}
+
+// turnOf is the turn a claim was taken in, or nil.
+func turnOf(c any) *turn {
+ if got, ok := c.(*claimed); ok {
+ return got.turn
+ }
+ return nil
+}
+
+// stopOf maps ACP's stop reason to the driver's (invariant 4).
+func (s *session) stopOf(reason string, canceled bool, refusals int) (driver.TurnStop, error) {
+ switch driver.TurnStop(reason) {
+ case driver.TurnEndTurn, driver.TurnMaxTokens, driver.TurnMaxTurnRequests, driver.TurnRefusal:
+ return driver.TurnStop(reason), nil
+ case driver.TurnCanceled:
+ switch {
+ case canceled:
+ return driver.TurnCanceled, nil
+ case refusals > 0:
+ // codex-acp ends a turn it was refused in as canceled.
+ return driver.TurnRefusal, nil
+ }
+ return "", errors.New("acp: the agent ended the turn as canceled, and the connector asked for no cancel")
+ }
+ return "", fmt.Errorf("acp: the agent ended the turn with an unknown stop reason %q", s.conn.agentText(reason))
+}
+
+// Cancel implements driver.Session: session/cancel for the turn in flight.
+//
+// A cancel waits at most for ctx or the close grace, whichever ends first,
+// both for the prompt's own write and for its notification's, so an agent that
+// has stopped reading its input cannot hold the caller.
+func (s *session) Cancel(ctx context.Context) error {
+ grace := time.NewTimer(s.grace)
+ defer grace.Stop()
+ stuck := errors.New("acp: the agent is not reading its input; the cancel could not be sent")
+ select {
+ case s.promptSem <- struct{}{}:
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-grace.C:
+ return stuck
+ }
+ s.mu.Lock()
+ t := s.turn
+ settling := t != nil && t.settling
+ // One cancel per turn: the caller that ends the turn is the one that
+ // sends the notification, so a second call cannot put another
+ // session/cancel on the wire for a turn already canceled.
+ mine := t != nil && !settling && !t.canceled
+ if mine {
+ t.canceled = true
+ }
+ // A cancel with no turn in flight is remembered for the next one: the
+ // dispatcher asked for this session to stop, and the turn it meant to end
+ // may be a moment from starting. A turn the agent has already answered is
+ // over; its stop stands as the agent gave it.
+ s.canceled = t == nil
+ id := s.id
+ s.mu.Unlock()
+ // The prompt this cancel ends is on the wire; a later prompt cannot start
+ // while its turn is in flight.
+ <-s.promptSem
+ if !mine {
+ return nil
+ }
+ sent := make(chan error, 1)
+ go func() {
+ // The turn this cancel was for may have ended while the write waited;
+ // it is checked again once the write is ours, so a cancel is never
+ // sent for a turn the connector did not mean.
+ sent <- s.conn.notifyIf(func() bool { return s.inFlight(t) }, "session/cancel", map[string]any{"sessionId": id})
+ }()
+ select {
+ case err := <-sent:
+ return err
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-grace.C:
+ return stuck
+ }
+}
+
+// Close implements driver.Session: the adapter's input is closed, it is given
+// grace to exit, and its process group is ended either way, which takes the
+// agent and every MCP server it started with it.
+func (s *session) Close() error {
+ s.closeOnce.Do(func() {
+ s.mu.Lock()
+ s.closed = true
+ s.mu.Unlock()
+ s.conn.closeWrite(s.worker.Stdin())
+ select {
+ case <-s.worker.Done():
+ case <-time.After(s.grace):
+ }
+ s.worker.Terminate(s.grace)
+ s.awaitReader()
+ })
+ return nil
+}
+
+// awaitReader waits for the session's reader to finish, and gives up on the
+// worker's output when something outside its process group still holds the
+// pipe: the worker is gone, and its output is no longer worth waiting for.
+func (s *session) awaitReader() {
+ select {
+ case <-s.readerEnd:
+ return
+ case <-time.After(s.grace):
+ }
+ s.worker.CloseStdout()
+ select {
+ case <-s.readerEnd:
+ case <-time.After(s.grace):
+ // The reader is not coming back: the worker is gone and its output
+ // abandoned, so nothing is waiting on it that the caller needs.
+ }
+}
+
+// abort ends a session that failed its handshake, without grace.
+func (s *session) abort() {
+ s.closeOnce.Do(func() {
+ s.mu.Lock()
+ s.closed = true
+ s.mu.Unlock()
+ s.worker.Terminate(0)
+ s.awaitReader()
+ })
+}
+
+// StderrTail is what may be passed on of the adapter's stderr: the
+// dispatcher logs it when a worker stops badly.
+func (s *session) StderrTail() string { return s.worker.StderrTail(s.red) }
+
+// stderrNote is the end of the adapter's stderr, redacted, for an error.
+func (s *session) stderrNote() string {
+ // More than the last line, because an adapter that fails to start says
+ // why on one line and prints a stack trace after it; not every line,
+ // because this becomes the attempt's own error text.
+ lines := s.worker.StderrLines(s.red)
+ if len(lines) == 0 {
+ return ""
+ }
+ if len(lines) > stderrNoteLines {
+ lines = lines[len(lines)-stderrNoteLines:]
+ }
+ return " (adapter stderr: " + strings.Join(lines, " | ") + ")"
+}
+
+// StderrLines is every bounded line of the adapter's stderr. An ACP agent
+// reports its refusals over the protocol, never here, so this is diagnostics
+// for a worker that stopped badly, not a record.
+func (s *session) StderrLines() []string { return s.worker.StderrLines(s.red) }
+
+// ---------------------------------------------------------------- from the agent
+
+// sessionUpdate is the part of a session/update (or a permission request's
+// tool call) the driver reads. Text, titles beyond an MCP call's, raw inputs
+// beyond an MCP call's server and tool, and outputs are never decoded into
+// anything kept.
+type sessionUpdate struct {
+ SessionUpdate string
+ ToolCallID string
+ Kind string
+ Status string
+ Name string
+ MetaToolName string
+ // MCPCall is codex-acp's _meta.is_mcp_tool_call.
+ MCPCall bool
+ Title string
+ MCPServer string
+ MCPTool string
+ Locations []string
+ // Unplaceable is a call this driver cannot carry the paths of whole:
+ // more paths than maxLocations, or one longer than maxLocationPath. The
+ // policy places a call by every path it names, so a call whose paths are
+ // not all here is one the policy cannot place.
+ Unplaceable bool
+ Used *int64
+ Size *int64
+ Chars int
+ // Text is the text of a chunk, kept only so an adapter's answer to its
+ // own read-back command can be read (mcp.go). Nothing else reads it, and
+ // no update this driver emits carries it.
+ Text string
+ CurrentModeID string
+ ConfigOptions []configOption
+}
+
+// decodeUpdate reads an update field by field, so one field of an unexpected
+// shape costs that field, not the update: an agent that sends a mode report
+// beside something this client does not know still has its mode read.
+func decodeUpdate(raw json.RawMessage) (sessionUpdate, bool) {
+ var fields map[string]json.RawMessage
+ if json.Unmarshal(raw, &fields) != nil {
+ return sessionUpdate{}, false
+ }
+ var u sessionUpdate
+ str := func(key string) string {
+ var v string
+ _ = json.Unmarshal(fields[key], &v)
+ return v
+ }
+ u.SessionUpdate = str("sessionUpdate")
+ u.ToolCallID = str("toolCallId")
+ u.Kind = str("kind")
+ u.Status = str("status")
+ u.Name = str("name")
+ u.Title = str("title")
+ u.CurrentModeID = str("currentModeId")
+ var meta struct {
+ ClaudeCode struct {
+ ToolName string `json:"toolName"`
+ } `json:"claudeCode"`
+ MCPCall bool `json:"is_mcp_tool_call"`
+ }
+ if json.Unmarshal(fields["_meta"], &meta) == nil {
+ u.MetaToolName = meta.ClaudeCode.ToolName
+ u.MCPCall = meta.MCPCall
+ }
+ var input struct {
+ Server string `json:"server"`
+ Tool string `json:"tool"`
+ }
+ if json.Unmarshal(fields["rawInput"], &input) == nil {
+ u.MCPServer, u.MCPTool = input.Server, input.Tool
+ }
+ var locations []json.RawMessage
+ if json.Unmarshal(fields["locations"], &locations) == nil {
+ if len(locations) > maxLocations {
+ // More paths than this driver carries. The policy allows a call
+ // only when every path it names is inside the working directory,
+ // so judging it on the ones that fit would allow a call by
+ // leaving out the path that refuses it.
+ u.Unplaceable = true
+ locations = locations[:maxLocations]
+ }
+ for _, l := range locations {
+ var loc struct {
+ Path string `json:"path"`
+ }
+ if json.Unmarshal(l, &loc) != nil || loc.Path == "" {
+ continue
+ }
+ if len(loc.Path) > maxLocationPath {
+ // A pathname longer than the driver carries is not a path
+ // this call can be placed by either: what is cut off can be
+ // the part that leaves the working directory, and a tool that
+ // normalizes before it opens would still reach it.
+ u.Unplaceable = true
+ loc.Path = loc.Path[:maxLocationPath]
+ }
+ u.Locations = append(u.Locations, loc.Path)
+ }
+ }
+ var n int64
+ if json.Unmarshal(fields["used"], &n) == nil && len(fields["used"]) > 0 {
+ used := n
+ u.Used = &used
+ }
+ if json.Unmarshal(fields["size"], &n) == nil && len(fields["size"]) > 0 {
+ size := n
+ u.Size = &size
+ }
+ var block struct {
+ Text string `json:"text"`
+ }
+ if json.Unmarshal(fields["content"], &block) == nil {
+ u.Chars = len(block.Text)
+ u.Text = block.Text
+ if len(u.Text) > maxReadback {
+ u.Text = u.Text[:maxReadback]
+ }
+ }
+ var options []json.RawMessage
+ if json.Unmarshal(fields["configOptions"], &options) == nil {
+ if len(options) > maxConfigOptions {
+ options = options[:maxConfigOptions]
+ }
+ for _, o := range options {
+ var opt configOption
+ if json.Unmarshal(o, &opt) == nil {
+ u.ConfigOptions = append(u.ConfigOptions, opt)
+ }
+ }
+ }
+ return u, true
+}
+
+// onNotification handles the agent's notifications in wire order. Only
+// session/update is read; _auth/status_update, which carries the account's
+// email, and every extension are dropped unread (invariant 8).
+func (s *session) onNotification(method string, params json.RawMessage) {
+ if method == "_claude/sdkMessage" {
+ s.onSDKMessage(params)
+ return
+ }
+ if method != "session/update" {
+ return
+ }
+ var n struct {
+ SessionID string `json:"sessionId"`
+ Update json.RawMessage `json:"update"`
+ }
+ if json.Unmarshal(params, &n) != nil || !s.ours(n.SessionID) {
+ return
+ }
+ u, ok := decodeUpdate(n.Update)
+ if !ok {
+ return
+ }
+ s.noteStartupFailure(u)
+ switch u.SessionUpdate {
+ case "current_mode_update":
+ s.reportMode(u.CurrentModeID)
+ case "config_option_update":
+ if v, ok := stringValue(modeOption(u.ConfigOptions)); ok {
+ s.reportMode(v)
+ }
+ case "tool_call", "tool_call_update":
+ info := s.noteTool(u)
+ kind := driver.UpdateToolCall
+ if u.SessionUpdate == "tool_call_update" {
+ kind = driver.UpdateToolCallUpdate
+ }
+ s.emit(driver.Update{Kind: kind, ToolCallID: u.ToolCallID, Tool: info.name, ToolKind: info.kind, Status: toolStatus(u.Status)})
+ case "usage_update":
+ s.mu.Lock()
+ if u.Used != nil {
+ s.context.ContextUsed = *u.Used
+ }
+ if u.Size != nil {
+ s.context.ContextSize = *u.Size
+ }
+ usage := s.context
+ s.mu.Unlock()
+ s.emit(driver.Update{Kind: driver.UpdateUsage, Usage: &usage})
+ case "agent_message_chunk":
+ s.collect(u.Text)
+ s.emit(driver.Update{Kind: driver.UpdateAgentMessageChunk, Chars: u.Chars})
+ case "plan":
+ s.emit(driver.Update{Kind: driver.UpdatePlan})
+ }
+}
+
+// ours reports whether a message names this session. One adapter process
+// serves one session, so this is a guard, not routing.
+func (s *session) ours(id string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.id == "" || id == s.id
+}
+
+func (s *session) emit(u driver.Update) {
+ u.At = time.Now()
+ if len(u.ToolCallID) > maxToolCallID {
+ u.ToolCallID = u.ToolCallID[:maxToolCallID]
+ }
+ // Ids and names are the agent's own text: nothing of a worker's leaves
+ // through an update either (the redaction rule).
+ u.ToolCallID = s.red.Sanitize(u.ToolCallID)
+ u.Tool = s.red.Sanitize(u.Tool)
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.updatesClosed || s.replaying {
+ return
+ }
+ select {
+ case s.updates <- u:
+ default:
+ }
+}
+
+// onResponse marks a turn settling the moment its prompt's answer is read,
+// on the reading goroutine: a request read after that answer is outside the
+// turn, however soon the turn's own goroutine runs.
+func (s *session) onResponse(id int64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if t := s.turn; t != nil && t.call != nil && t.call.id == id {
+ t.settling = true
+ }
+}
+
+// inFlight reports whether t is still the turn the agent is working on.
+func (s *session) inFlight(t *turn) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.turn == t && !t.settling
+}
+
+// toolName is the agent's name for the tool, where it says one: never the
+// call's title or input, which carry what the call does.
+//
+// claude-agent-acp names its tools in _meta or in name (mcp____
+// for an MCP tool), and a name it gives is final: its titles and raw inputs
+// are the model's to write. codex-acp gives an MCP call no name; it marks it
+// in _meta and titles it "mcp.." beside a raw input of
+// {server, tool}. Only a call with no name, so marked, whose title and input
+// agree, is given the MCP tool's name.
+func toolName(u sessionUpdate) string {
+ if u.MetaToolName != "" {
+ return plainName(u.MetaToolName)
+ }
+ if u.Name != "" {
+ return plainName(u.Name)
+ }
+ if u.MCPCall && u.MCPServer != "" && u.MCPTool != "" && u.Title == "mcp."+u.MCPServer+"."+u.MCPTool &&
+ plainName(u.MCPServer) == u.MCPServer && plainName(u.MCPTool) == u.MCPTool &&
+ !strings.Contains(u.MCPServer, "__") && !strings.Contains(u.MCPServer, ".") {
+ return "mcp__" + u.MCPServer + "__" + u.MCPTool
+ }
+ return ""
+}
+
+// plainName is a tool name the policy can key on, or nothing. A name is never
+// made plain by dropping what is not: "mcp__base camp__x" must not become the
+// allowed "mcp__basecamp__x", so a name with anything outside the set is no
+// name at all, and the call is decided on its kind.
+func plainName(s string) string {
+ if s == "" || len(s) > 100 {
+ return ""
+ }
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-', r == '.':
+ default:
+ return ""
+ }
+ }
+ return s
+}
+
+func toolKind(kind string) driver.ToolKind {
+ switch k := driver.ToolKind(kind); k {
+ case driver.ToolRead, driver.ToolEdit, driver.ToolDelete, driver.ToolMove, driver.ToolSearch,
+ driver.ToolExecute, driver.ToolThink, driver.ToolFetch, driver.ToolOther:
+ return k
+ }
+ return driver.ToolOther
+}
+
+func toolStatus(status string) driver.ToolStatus {
+ switch st := driver.ToolStatus(status); st {
+ case driver.ToolPending, driver.ToolInProgress, driver.ToolCompleted, driver.ToolFailed:
+ return st
+ }
+ return ""
+}
+
+// validSessionID is an id the ledger can keep and a later process can hand
+// back: short, and plain.
+func validSessionID(id string) bool {
+ if id == "" || len(id) > 128 {
+ return false
+ }
+ for _, r := range id {
+ if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.' && r != ':' {
+ return false
+ }
+ }
+ return true
+}
+
+// mergeEnv adds the adapter's own variables to the dispatcher's allowlisted
+// environment. A variable the dispatcher set wins.
+func mergeEnv(base, extra []string) []string {
+ have := map[string]bool{}
+ for _, kv := range base {
+ k, _, _ := strings.Cut(kv, "=")
+ have[k] = true
+ }
+ out := slices.Clone(base)
+ if out == nil {
+ out = []string{}
+ }
+ for _, kv := range extra {
+ k, _, _ := strings.Cut(kv, "=")
+ if !have[k] {
+ out = append(out, kv)
+ }
+ }
+ slices.Sort(out)
+ return out
+}
+
+// lookupIn reads a variable from an environment already built, so whatever
+// reads it sees what the adapter will.
+func lookupIn(env []string) func(string) (string, bool) {
+ return func(name string) (string, bool) {
+ for i := len(env) - 1; i >= 0; i-- {
+ if after, ok := strings.CutPrefix(env[i], name+"="); ok {
+ return after, true
+ }
+ }
+ return "", false
+ }
+}
+
+// setEnv sets the adapter's own switches over whatever env holds of the same
+// name.
+func setEnv(env []string, set map[string]string) []string {
+ if len(set) == 0 {
+ return env
+ }
+ out := make([]string, 0, len(env)+len(set))
+ for _, kv := range env {
+ k, _, _ := strings.Cut(kv, "=")
+ if _, ok := set[k]; !ok {
+ out = append(out, kv)
+ }
+ }
+ for k, v := range set {
+ out = append(out, k+"="+v)
+ }
+ slices.Sort(out)
+ return out
+}
diff --git a/internal/connector/driver/acp/testdata/stubmcp/main.go b/internal/connector/driver/acp/testdata/stubmcp/main.go
new file mode 100644
index 000000000..0a24df6fe
--- /dev/null
+++ b/internal/connector/driver/acp/testdata/stubmcp/main.go
@@ -0,0 +1,167 @@
+// stubmcp is a minimal stdio MCP server for the ACP adapter-compatibility
+// test, ported from the card 23 spike. It records what it was started with and
+// what it was asked, so a check can tell "spawned" from "spawned and
+// connected", and see what the agent sent its one tool.
+//
+// Data minimization: the record holds the value of only the probe variables
+// named on its command line, which the test sets to dummy values. Every other
+// variable is recorded by name only, so a real credential in the environment
+// it inherited never reaches the record.
+package main
+
+import (
+ "bufio"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "os"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+type record struct {
+ PID int `json:"pid"`
+ ProbeVars map[string]string `json:"probe_vars"`
+ // Fingerprints are SHA-256 digests of the variables named by
+ // --fingerprint: enough to tell whose value a variable carries, without
+ // the value.
+ Fingerprints map[string]string `json:"fingerprints"`
+ EnvVarNames []string `json:"env_var_names"`
+ Methods []string `json:"methods"`
+ Notes []string `json:"notes"`
+}
+
+var (
+ mu sync.Mutex
+ rec record
+ path string
+)
+
+func main() {
+ probes := flag.String("probe", "", "comma-separated variable names whose values may be recorded")
+ fingerprints := flag.String("fingerprint", "", "comma-separated variable names whose values are recorded as digests")
+ flag.StringVar(&path, "record", "", "where to write the record")
+ flag.Parse()
+
+ rec.PID = os.Getpid()
+ rec.ProbeVars = map[string]string{}
+ for _, name := range strings.Split(*probes, ",") {
+ if name = strings.TrimSpace(name); name == "" {
+ continue
+ }
+ if v, ok := os.LookupEnv(name); ok {
+ rec.ProbeVars[name] = v
+ }
+ }
+ rec.Fingerprints = map[string]string{}
+ for _, name := range strings.Split(*fingerprints, ",") {
+ if name = strings.TrimSpace(name); name == "" {
+ continue
+ }
+ if v, ok := os.LookupEnv(name); ok {
+ sum := sha256.Sum256([]byte(v))
+ rec.Fingerprints[name] = hex.EncodeToString(sum[:])
+ }
+ }
+ for _, kv := range os.Environ() {
+ name, _, _ := strings.Cut(kv, "=")
+ rec.EnvVarNames = append(rec.EnvVarNames, name)
+ }
+ sort.Strings(rec.EnvVarNames)
+ flush()
+ serve()
+}
+
+func flush() {
+ if path == "" {
+ return
+ }
+ data, err := json.MarshalIndent(&rec, "", " ")
+ if err != nil {
+ return
+ }
+ tmp := path + ".tmp"
+ if os.WriteFile(tmp, data, 0o600) == nil {
+ _ = os.Rename(tmp, path)
+ }
+}
+
+func serve() {
+ in := bufio.NewScanner(os.Stdin)
+ in.Buffer(make([]byte, 1<<20), 16<<20)
+ out := bufio.NewWriter(os.Stdout)
+ reply := func(id json.RawMessage, result any) {
+ if len(id) == 0 {
+ return
+ }
+ data, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
+ _, _ = out.Write(append(data, '\n'))
+ _ = out.Flush()
+ }
+ for in.Scan() {
+ var m struct {
+ ID json.RawMessage `json:"id"`
+ Method string `json:"method"`
+ Params json.RawMessage `json:"params"`
+ }
+ if json.Unmarshal(in.Bytes(), &m) != nil {
+ continue
+ }
+ mu.Lock()
+ rec.Methods = append(rec.Methods, m.Method)
+ flush()
+ mu.Unlock()
+
+ switch m.Method {
+ case "initialize":
+ var p struct {
+ ProtocolVersion string `json:"protocolVersion"`
+ }
+ _ = json.Unmarshal(m.Params, &p)
+ if p.ProtocolVersion == "" {
+ p.ProtocolVersion = "2025-06-18"
+ }
+ reply(m.ID, map[string]any{
+ "protocolVersion": p.ProtocolVersion,
+ "capabilities": map[string]any{"tools": map[string]any{}},
+ "serverInfo": map[string]any{"name": "acp-compat-stub", "version": "0.1.0"},
+ })
+ case "tools/list":
+ reply(m.ID, map[string]any{"tools": []any{map[string]any{
+ "name": "note",
+ "description": "Records a short note for the test harness.",
+ "inputSchema": map[string]any{
+ "type": "object",
+ "properties": map[string]any{"text": map[string]any{"type": "string"}},
+ "required": []string{"text"},
+ },
+ }}})
+ case "tools/call":
+ var p struct {
+ Arguments struct {
+ Text string `json:"text"`
+ } `json:"arguments"`
+ }
+ _ = json.Unmarshal(m.Params, &p)
+ mu.Lock()
+ rec.Notes = append(rec.Notes, p.Arguments.Text)
+ flush()
+ mu.Unlock()
+ reply(m.ID, map[string]any{
+ "content": []any{map[string]any{"type": "text", "text": "noted at " + time.Now().UTC().Format(time.RFC3339)}},
+ "isError": false,
+ })
+ case "ping":
+ reply(m.ID, map[string]any{})
+ case "resources/list":
+ reply(m.ID, map[string]any{"resources": []any{}})
+ case "prompts/list":
+ reply(m.ID, map[string]any{"prompts": []any{}})
+ default:
+ reply(m.ID, map[string]any{})
+ }
+ }
+}