Files
zero/internal/acp/agent_test.go
KRATOS 5b348056a4 feat(acp): Agent Client Protocol surface — drive ZERO as an editor backend (#288)
* feat(acp): JSON-RPC 2.0 ndjson transport for the ACP surface

Bidirectional JSON-RPC 2.0 peer over stdio (newline-delimited JSON): serves
inbound requests/notifications via registered handlers and issues outbound
requests/notifications. Handlers run on their own goroutines so a long-running
request (session/prompt) never blocks delivery of session/cancel or a permission
response. Stdlib-only, no new dependencies.

Derived solely from the public JSON-RPC 2.0 + ACP specs.

* feat(acp): ACP wire types derived from the public spec

Protocol message types for initialize, session/{new,load,prompt,cancel,update,
set_mode,set_config_option}, request_permission, content blocks, tool-call
updates, plan entries, permission options, and session modes — method names and
field shapes taken from the public ACP spec (agentclientprotocol.com schema/v1).
Model selection is exposed both via the spec's session/set_config_option and a
vendor _zero/set_model alias for clients that prefer it.

* feat(acp): translate ZERO agent events to ACP session/update payloads

Pure mappers (unit-tested) from ZERO's agent callbacks to ACP updates: text->
agent_message_chunk, reasoning->agent_thought_chunk, tool call->tool_call(in_progress),
tool result->tool_call_update(completed/failed) with content + changed-file
locations, and plan items->plan entries. A notifier wires the mappers to a live
connection per session.

* feat(acp): map ZERO permission prompts to ACP session/request_permission

Translate ZERO's permission decisions into ACP PermissionOptions (optionId carries
the ZERO action verbatim for a lossless round trip) and map the client's outcome
back to a ZERO PermissionDecision: cancelled->cancel, selected->the chosen action,
anything unrecognized fails closed to deny. Builds the embedded toolCall too.

* feat(acp): ACP Agent server driving ZERO's core

Implements the ACP methods over the JSON-RPC peer: initialize (capability +
version negotiation), session/new, session/load (with history replay for resume),
session/prompt (drives the real agent.Run with streaming callbacks -> session/update,
and an OnPermissionRequest that round-trips through session/request_permission),
session/cancel, session/set_mode (permission/autonomy mode), and model selection
via session/set_config_option plus the vendor _zero/set_model alias. Dependencies
are injected so the editor only hosts the thread while ZERO keeps auth/model/keys
(BYOK). Includes an end-to-end test (initialize+new+prompt over stdio pipes through
agent.Run with a fake provider) asserting the streamed updates.

* feat(acp): wire the 'zero acp' subcommand

Add 'zero acp', which serves ACP over stdio so editors (Zed, JetBrains, Neovim,
...) can drive ZERO as a backend. Reuses ZERO's real deps — config resolution,
provider construction, the core tool registry, and the session store — so
provider/model/keys stay owned by ZERO (BYOK). Registered in the command
dispatch and the help listing.

* fix(acp): drain in-flight handlers on stream close

On stdin EOF the read loop returned before the dispatch goroutines flushed their
responses, so piped/finite ndjson input (and editors that close the stream right
after a request) dropped the reply. Serve now cancels in-flight handlers (so any
blocked outbound Call unblocks via ctx) and waits for them before returning.

* fix(acp): address review — sandbox confinement, mode/cwd/transport hardening

Blockers:
- Confine shell/file tools: runTurn now builds a SCOPED registry + sandbox engine
  per session (BuildWorkspace mirrors exec's buildExecSandboxEngine + NewScope) and
  passes Options.Sandbox — ACP no longer runs bash/exec_command unconfined.
- Reject PermissionModeUnsafe over ACP (set_mode + advertised modes are auto/ask
  only); Unsafe stays gated to the operator, so a client can't self-grant no-prompt
  host access.
- Fix data race on acpSession.history: registerSession sets history before
  publishing the session and reuses an already-live session instead of overwriting.
- deliver() now deletes the pending entry and sends non-blocking, so a duplicate/
  late response frame can't wedge the read loop.

Should-fix:
- Serialize prompt turns per session (turnMu) so concurrent prompts can't interleave
  history or clobber the cancel slot.
- Validate client cwd (ResolveWorkspaceRoot mirrors exec; rejects filesystem root +
  home) before it becomes the file-tool confinement root.
- Malformed JSON no longer tears down the connection: framing is per-line; a bad
  line replies -32700 (id null) and the loop continues. Also validate jsonrpc:"2.0".
- Permission outcome binds to the offered set only (dropped the isKnownDecision
  fallback) — a client can't return a broader grant than was presented; fail closed.
- Drop the non-conformant SessionCapabilities advertisement (no resume handler) and
  the non-conformant SessionConfigOption model selector; model switching stays
  available via the vendor _zero/set_model.

Adds regression tests: sandbox+scoped-registry wiring, invalid-cwd rejection,
Unsafe-mode rejection, and malformed-line resilience. Full go test ./... -race green.
2026-06-21 17:55:44 +05:30

262 lines
8.9 KiB
Go

package acp
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// fakeProvider streams a canned assistant message and ends the turn — enough to
// drive the real agent.Run loop without a live model.
type fakeProvider struct{ text string }
func (f fakeProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 4)
go func() {
defer close(ch)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: f.text}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
}()
return ch, nil
}
func testDeps(t *testing.T) Deps {
t.Helper()
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
return Deps{
ResolveConfig: func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
model := "fake-model"
if o.Provider.Model != "" {
model = o.Provider.Model
}
return config.ResolvedConfig{
Provider: config.ProviderProfile{Name: "fake", Model: model},
MaxTurns: 4,
}, nil
},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return fakeProvider{text: "Hello from ZERO"}, nil
},
RunAgent: agent.Run,
BuildWorkspace: func(string, config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
r := tools.NewRegistry()
r.Register(tools.NewUpdatePlanTool())
return r, nil, nil
},
ResolveWorkspaceRoot: func(cwd string) (string, error) { return cwd, nil },
Store: store,
AgentInfo: Implementation{Name: "zero", Version: "test"},
}
}
// clientHarness wires a client Conn to an Agent over in-memory pipes and collects
// session/update text chunks.
type clientHarness struct {
client *Conn
updates chan string
stop func()
}
func newHarness(t *testing.T, deps Deps) *clientHarness {
t.Helper()
ar, bw := io.Pipe() // agent -> client
br, aw := io.Pipe() // client -> agent
agentConn := NewConn(ar, aw)
client := NewConn(br, bw)
a := NewAgent(agentConn, deps)
h := &clientHarness{client: client, updates: make(chan string, 128)}
client.HandleNotify(MethodSessionUpdate, func(_ context.Context, params json.RawMessage) {
var probe struct {
Update struct {
SessionUpdate string `json:"sessionUpdate"`
Content struct {
Text string `json:"text"`
} `json:"content"`
} `json:"update"`
}
if json.Unmarshal(params, &probe) != nil {
return
}
if probe.Update.SessionUpdate == UpdateAgentMessageChunk {
h.updates <- probe.Update.Content.Text
}
})
ctx, cancel := context.WithCancel(context.Background())
go func() { _ = a.Serve(ctx) }()
go func() { _ = client.Serve(ctx) }()
h.stop = func() {
cancel()
_ = aw.Close()
_ = bw.Close()
}
return h
}
func TestACPEndToEndPrompt(t *testing.T) {
h := newHarness(t, testDeps(t))
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// initialize
var initRes InitializeResult
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
t.Fatalf("initialize: %v", err)
}
if initRes.ProtocolVersion != ProtocolVersion {
t.Fatalf("protocol version = %d", initRes.ProtocolVersion)
}
if !initRes.AgentCapabilities.LoadSession || !initRes.AgentCapabilities.PromptCapabilities.Image {
t.Fatalf("unexpected capabilities: %+v", initRes.AgentCapabilities)
}
// session/new
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
if newRes.SessionID == "" {
t.Fatal("session/new returned empty sessionId")
}
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
}
// session/prompt
var promptRes PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: newRes.SessionID,
Prompt: []ContentBlock{TextBlock("hi")},
}, &promptRes); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if promptRes.StopReason != StopEndTurn {
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
}
// The streamed agent_message_chunk(s) should carry the assistant text.
if got := drainText(t, h.updates); !strings.Contains(got, "Hello from ZERO") {
t.Fatalf("streamed text = %q, want it to contain the assistant message", got)
}
}
func TestACPUnknownSessionPromptErrors(t *testing.T) {
h := newHarness(t, testDeps(t))
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: "nope", Prompt: []ContentBlock{TextBlock("x")}}, &PromptResult{})
if err == nil {
t.Fatal("expected error for unknown session")
}
}
func TestACPSetModeUpdatesSession(t *testing.T) {
h := newHarness(t, testDeps(t))
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
// auto/ask are accepted.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode ask: %v", err)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
}
// An unknown mode must be rejected.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: "bogus"}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected error for unknown mode")
}
}
// TestACPRunTurnWiresSandboxAndScopedRegistry proves the sandbox engine and the
// scoped registry from BuildWorkspace actually reach agent.Options — i.e. ACP
// shell tools run confined, not unconfined on the host.
func TestACPRunTurnWiresSandboxAndScopedRegistry(t *testing.T) {
deps := testDeps(t)
reg := tools.NewRegistry()
reg.Register(tools.NewUpdatePlanTool())
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
deps.BuildWorkspace = func(string, config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
return reg, engine, nil
}
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
captured = opts
return agent.Result{FinalAnswer: "ok"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: newRes.SessionID, Prompt: []ContentBlock{TextBlock("hi")}}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if captured.Sandbox != engine {
t.Fatal("sandbox engine was not wired into agent.Options (shell tools would run unconfined)")
}
if captured.Registry != reg {
t.Fatal("scoped registry was not wired into agent.Options")
}
}
// TestACPRejectsInvalidCwd confirms session/new fails when the workspace root
// resolver rejects the client cwd (e.g. filesystem root).
func TestACPRejectsInvalidCwd(t *testing.T) {
deps := testDeps(t)
deps.ResolveWorkspaceRoot = func(string) (string, error) {
return "", fmt.Errorf("cwd must not be the filesystem root")
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: "/", McpServers: []McpServer{}}, &NewSessionResult{}); err == nil {
t.Fatal("expected session/new to reject an invalid cwd")
}
}
// drainText collects streamed chunks for a short window and concatenates them.
func drainText(t *testing.T, ch <-chan string) string {
t.Helper()
var b strings.Builder
deadline := time.After(2 * time.Second)
for {
select {
case s := <-ch:
b.WriteString(s)
if strings.Contains(b.String(), "Hello from ZERO") {
return b.String()
}
case <-deadline:
return b.String()
}
}
}