mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* 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.
262 lines
8.9 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|