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/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{{ 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 }