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{}) + } + } +}