From 436db71b0ad0bf58d74bab3a1bfebfc78378d6d0 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sun, 7 Jun 2026 12:40:58 -0400 Subject: [PATCH 1/2] feat(agent): drive Claude Code over ACP with a Claude subscription Add an opt-in agent backend that routes prompts through the official Claude Code CLI via its ACP server adapter, so prompts run on a user's Claude subscription instead of the built-in API-key agent. Select it with DISCOBOT_AGENT_BACKEND=claude-code-acp; the server passes that flag and CLAUDE_CODE_OAUTH_TOKEN into the sandbox so the CLI authenticates itself. Also close three gaps in the ACP adapter that blocked real turns: - spawn the CLI with a bounded handshake timeout and surface its stderr, so a stalled start falls back to the default agent instead of wedging agent-api startup - send session/new with an empty mcpServers array; strict ACP servers reject a null value - carry a messageId on the start chunk and map the ACP stop reason onto Discobot's finishReason enum, which the UI stream schema validates strictly (an unmapped value showed an error block and hung the turn) --- agent-go/acp/agent/agent.go | 23 +++++++- agent-go/acp/agent/session.go | 7 ++- agent-go/cmd/agent-api/server/acp_backend.go | 62 ++++++++++++++++++++ agent-go/cmd/agent-api/server/runtime.go | 22 ++++++- server/internal/service/sandbox.go | 12 ++++ 5 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 agent-go/cmd/agent-api/server/acp_backend.go diff --git a/agent-go/acp/agent/agent.go b/agent-go/acp/agent/agent.go index 6d0cdf93a..75d3b3167 100644 --- a/agent-go/acp/agent/agent.go +++ b/agent-go/acp/agent/agent.go @@ -160,7 +160,9 @@ func (a *Agent) Prompt(ctx context.Context, threadID string, req discobotagent.P }) defer stopCancel() - if !yield(message.StartChunk{}, nil) { + // The UI stream requires a messageId on the start chunk; without it + // conversation-stream.ts rejects the whole turn. + if !yield(message.StartChunk{MessageID: "acp-" + discobotagent.GenerateID()}, nil) { return } parked := a.startPrompt(ctx, threadID, sessionID, prompt, req) @@ -306,7 +308,7 @@ func (a *Agent) finishPrompt(threadID string, parked *parkedPrompt, result promp yield(nil, result.err) return } - yield(message.ResponseFinishChunk{FinishReason: string(result.response.StopReason)}, nil) + yield(message.ResponseFinishChunk{FinishReason: mapStopReason(result.response.StopReason)}, nil) } func sendPromptEvent(ctx context.Context, events chan<- promptEvent, event promptEvent) bool { @@ -318,6 +320,23 @@ func sendPromptEvent(ctx context.Context, events chan<- promptEvent, event promp } } +// mapStopReason translates an ACP stop reason into Discobot's finishReason enum +// (stop|length|content-filter|tool-calls|error|other). The UI stream schema +// validates this value strictly, so an unmapped ACP reason (e.g. "end_turn") +// fails validation and the turn never completes. +func mapStopReason(reason protocol.StopReason) string { + switch reason { + case protocol.StopReasonMaxTokens, protocol.StopReasonMaxTurnRequests: + return "length" + case protocol.StopReasonRefusal: + return "content-filter" + case protocol.StopReasonEndTurn, protocol.StopReasonCancelled: + return "stop" + default: + return "stop" + } +} + func (a *Agent) Cancel(threadID string) bool { return a.sessionManager.Cancel(threadID) } diff --git a/agent-go/acp/agent/session.go b/agent-go/acp/agent/session.go index be9a18308..901bfbf16 100644 --- a/agent-go/acp/agent/session.go +++ b/agent-go/acp/agent/session.go @@ -371,7 +371,12 @@ func (m *sessionManager) ensure(ctx context.Context, threadID string) (protocol. } func (m *sessionManager) createSession(ctx context.Context, threadID string) (protocol.SessionID, error) { - result, err := m.client.NewSession(ctx, protocol.NewSessionRequest{Cwd: m.cwd}) + // MCPServers must serialize as an array, not null: strict ACP servers + // (e.g. claude-code-acp) reject `"mcpServers": null` with Invalid params. + result, err := m.client.NewSession(ctx, protocol.NewSessionRequest{ + Cwd: m.cwd, + MCPServers: []protocol.MCPServer{}, + }) if err != nil { return "", err } diff --git a/agent-go/cmd/agent-api/server/acp_backend.go b/agent-go/cmd/agent-api/server/acp_backend.go new file mode 100644 index 000000000..d5d66d381 --- /dev/null +++ b/agent-go/cmd/agent-api/server/acp_backend.go @@ -0,0 +1,62 @@ +package server + +import ( + "context" + "fmt" + "os" + "os/exec" + "time" + + gosdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + acpagent "github.com/obot-platform/discobot/agent-go/acp/agent" +) + +// acpClaudeCodeBackend is the DISCOBOT_AGENT_BACKEND value that routes prompts +// through the official Claude Code CLI's ACP server adapter instead of the +// built-in API-key agent. +const acpClaudeCodeBackend = "claude-code-acp" + +// claudeCodeACPCommand is the stdio ACP server installed in the sandbox image +// (see Dockerfile: npm install -g @zed-industries/claude-code-acp). +const claudeCodeACPCommand = "claude-code-acp" + +// acpConnectTimeout bounds the spawn + ACP initialize handshake so a stalled +// handshake can't wedge agent-api startup. On timeout we fall back to the +// default agent instead of blocking initialization forever. +const acpConnectTimeout = 20 * time.Second + +// connectClaudeCodeACP spawns claude-code-acp as a stdio subprocess and wraps it +// as a Discobot conversation agent. The child inherits this process's +// environment, so the Claude subscription token (CLAUDE_CODE_OAUTH_TOKEN) and the +// sandbox proxy settings (HTTPS_PROXY, NODE_EXTRA_CA_CERTS) reach the CLI and it +// authenticates against the user's subscription itself. +func connectClaudeCodeACP(cwd string, store acpagent.ThreadStore) (*acpagent.Agent, error) { + cmd := exec.Command(claudeCodeACPCommand) + // Surface claude-code-acp's own startup diagnostics in the agent-api journal. + cmd.Stderr = os.Stderr + transport := &gosdkmcp.CommandTransport{Command: cmd} + + type connectResult struct { + agent *acpagent.Agent + err error + } + done := make(chan connectResult, 1) + go func() { + agent, err := acpagent.Connect(context.Background(), transport, cwd, store) + done <- connectResult{agent: agent, err: err} + }() + + select { + case r := <-done: + if r.err != nil { + return nil, fmt.Errorf("connect %s: %w", claudeCodeACPCommand, r.err) + } + return r.agent, nil + case <-time.After(acpConnectTimeout): + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + return nil, fmt.Errorf("connect %s: timed out after %s (handshake stalled)", claudeCodeACPCommand, acpConnectTimeout) + } +} diff --git a/agent-go/cmd/agent-api/server/runtime.go b/agent-go/cmd/agent-api/server/runtime.go index 0a0128886..baa39d68b 100644 --- a/agent-go/cmd/agent-api/server/runtime.go +++ b/agent-go/cmd/agent-api/server/runtime.go @@ -9,10 +9,12 @@ import ( "log" "maps" "net/http" + "os" "strings" "github.com/go-chi/chi/v5" + acpagent "github.com/obot-platform/discobot/agent-go/acp/agent" "github.com/obot-platform/discobot/agent-go/agent" "github.com/obot-platform/discobot/agent-go/agentimpl" "github.com/obot-platform/discobot/agent-go/assets" @@ -57,6 +59,7 @@ type agentRuntime struct { sudoAuthorizer *credentials.SudoAuthorizer defaultAgent *agentimpl.DefaultAgent + acpAgent *acpagent.Agent conversations *agent.ConversationManager processManager *processes.Manager promptQueue *promptqueue.Manager @@ -203,7 +206,24 @@ func (r *agentRuntime) initAgent() { r.cfg.AgentCwd, mcpConfig, ) - r.conversations = agent.NewConversationManager(r.defaultAgent) + + // Spike: optionally route prompt execution through the official Claude Code + // CLI over ACP so prompts run on a Claude subscription instead of the + // built-in API-key agent. Thread management, MCP, and hooks stay on the + // default agent (they share the same thread store); only the conversation + // agent moves to ACP. Fall back to the default agent if the CLI can't start. + var convAgent agent.Agent = r.defaultAgent + if strings.TrimSpace(os.Getenv("DISCOBOT_AGENT_BACKEND")) == acpClaudeCodeBackend { + if acpAgent, err := connectClaudeCodeACP(r.cfg.AgentCwd, r.threadStore); err != nil { + log.Printf("discobot-agent-api: %s backend unavailable, using default agent: %v", acpClaudeCodeBackend, err) + } else { + r.acpAgent = acpAgent + convAgent = acpAgent + log.Printf("discobot-agent-api: routing prompts through the %s ACP backend", acpClaudeCodeBackend) + } + } + + r.conversations = agent.NewConversationManager(convAgent) r.processManager = processes.NewManager(r.cfg.AgentCwd) } diff --git a/server/internal/service/sandbox.go b/server/internal/service/sandbox.go index 894f488e1..ec892a19d 100644 --- a/server/internal/service/sandbox.go +++ b/server/internal/service/sandbox.go @@ -12,6 +12,7 @@ import ( "io" "log" "net/http" + "os" "sync" "time" @@ -848,6 +849,17 @@ func sandboxCreateEnv(sessionID, sharedSecret, trustKey string) map[string]strin if trustKey != "" { env["DISCOBOT_TRUST_KEY"] = trustKey } + // Spike: opt-in path to drive the official Claude Code CLI over ACP using a + // Claude subscription. Both vars are passed through from the server's own + // environment, flow to systemd PID 1, and reach discobot-agent-api (and the + // claude-code-acp child it spawns) via /run/discobot/agent-env. Leaving the + // Anthropic credential unset keeps the proxy from injecting an x-api-key + // header, so Claude Code authenticates with its own subscription token. + for _, key := range []string{"DISCOBOT_AGENT_BACKEND", "CLAUDE_CODE_OAUTH_TOKEN"} { + if value := os.Getenv(key); value != "" { + env[key] = value + } + } return env } From b0218bc11a57e02ea76b7c3de6e22efee736dc29 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sun, 7 Jun 2026 12:40:58 -0400 Subject: [PATCH 2/2] feat(sandbox): allow overriding sandbox container DNS resolvers Add a SANDBOX_DNS setting that sets the DNS servers on sandbox containers. Needed when the host resolver isn't reachable from the sandbox VM's network (for example a host running DNS on a loopback address that the VM gateway can't forward to), where the container otherwise can't resolve any names. --- server/internal/config/config.go | 2 ++ server/internal/sandbox/docker/provider.go | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 7139ea66f..764f3fadf 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -75,6 +75,7 @@ type Config struct { SandboxImage string // Default sandbox image for local runtimes SandboxImageRemote string // Default remotely-pullable sandbox image for remote runtimes SandboxProvider string // Default sandbox provider override + SandboxDNS []string // Optional DNS resolvers for sandbox containers (SANDBOX_DNS, comma-separated) SandboxIdleTimeout time.Duration // Auto-stop sandboxes after idle period IdleCheckInterval time.Duration // How often to check for idle sessions ThreadStatusSyncInterval time.Duration // How often to poll non-terminal session thread summaries @@ -238,6 +239,7 @@ func Load() (*Config, error) { cfg.SandboxImage = getEnv("SANDBOX_IMAGE", DefaultSandboxImage()) cfg.SandboxImageRemote = getEnv("SANDBOX_IMAGE_REMOTE", "") cfg.SandboxProvider = getEnv("SANDBOX_PROVIDER", "") + cfg.SandboxDNS = getEnvList("SANDBOX_DNS", nil) cfg.SandboxIdleTimeout = getEnvDuration("SANDBOX_IDLE_TIMEOUT", 1*time.Hour) cfg.IdleCheckInterval = getEnvDuration("IDLE_CHECK_INTERVAL", 5*time.Minute) cfg.ThreadStatusSyncInterval = getEnvDuration("THREAD_STATUS_SYNC_INTERVAL", 10*time.Second) diff --git a/server/internal/sandbox/docker/provider.go b/server/internal/sandbox/docker/provider.go index 14b997bf6..b5968aa41 100644 --- a/server/internal/sandbox/docker/provider.go +++ b/server/internal/sandbox/docker/provider.go @@ -666,6 +666,15 @@ func (p *Provider) Create(ctx context.Context, state []byte, sessionID string, o hostConfig.NetworkMode = containerTypes.NetworkMode(p.cfg.DockerNetwork) } + // Override the container's DNS resolvers when configured (SANDBOX_DNS). + // Needed when the VM's default resolver (the vmnet gateway) can't forward to + // the host's resolver — e.g. a host running DNS on 127.0.0.1 (VPN/local + // resolver), which the gateway can't reach. A public resolver routes out via + // NAT and avoids the broken gateway DNS proxy. + if len(p.cfg.SandboxDNS) > 0 { + hostConfig.DNS = append([]string(nil), p.cfg.SandboxDNS...) + } + // Raise the open file limit so processes inside the container don't hit // the default 1024 soft limit (tools like Claude Code can easily exhaust it). hostConfig.Ulimits = []*containerTypes.Ulimit{{