mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -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.
200 lines
5.4 KiB
Go
200 lines
5.4 KiB
Go
package acp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// connPair wires two Conns together over in-memory pipes and serves both.
|
|
func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
|
t.Helper()
|
|
ar, bw := io.Pipe() // b -> a
|
|
br, aw := io.Pipe() // a -> b
|
|
a = NewConn(ar, aw)
|
|
b = NewConn(br, bw)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() { _ = a.Serve(ctx) }()
|
|
go func() { _ = b.Serve(ctx) }()
|
|
return a, b, func() {
|
|
cancel()
|
|
_ = aw.Close()
|
|
_ = bw.Close()
|
|
}
|
|
}
|
|
|
|
func TestConnRequestResponse(t *testing.T) {
|
|
a, b, stop := connPair(t)
|
|
defer stop()
|
|
|
|
b.Handle("add", func(_ context.Context, params json.RawMessage) (any, error) {
|
|
var in struct{ X, Y int }
|
|
if err := json.Unmarshal(params, &in); err != nil {
|
|
return nil, RPCError(codeInvalidParams, "bad params")
|
|
}
|
|
return map[string]int{"sum": in.X + in.Y}, nil
|
|
})
|
|
|
|
var out struct{ Sum int }
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := a.Call(ctx, "add", map[string]int{"X": 2, "Y": 3}, &out); err != nil {
|
|
t.Fatalf("call: %v", err)
|
|
}
|
|
if out.Sum != 5 {
|
|
t.Fatalf("sum = %d, want 5", out.Sum)
|
|
}
|
|
}
|
|
|
|
func TestConnNotification(t *testing.T) {
|
|
a, b, stop := connPair(t)
|
|
defer stop()
|
|
|
|
got := make(chan string, 1)
|
|
b.HandleNotify("ping", func(_ context.Context, params json.RawMessage) {
|
|
var in struct{ Msg string }
|
|
_ = json.Unmarshal(params, &in)
|
|
got <- in.Msg
|
|
})
|
|
|
|
if err := a.Notify("ping", map[string]string{"Msg": "hello"}); err != nil {
|
|
t.Fatalf("notify: %v", err)
|
|
}
|
|
select {
|
|
case msg := <-got:
|
|
if msg != "hello" {
|
|
t.Fatalf("got %q, want hello", msg)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("notification not delivered")
|
|
}
|
|
}
|
|
|
|
func TestConnMethodNotFound(t *testing.T) {
|
|
a, _, stop := connPair(t)
|
|
defer stop()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
err := a.Call(ctx, "does_not_exist", nil, nil)
|
|
var re *rpcError
|
|
if !asRPCError(err, &re) {
|
|
t.Fatalf("expected rpcError, got %v", err)
|
|
}
|
|
if re.Code != codeMethodNotFound {
|
|
t.Fatalf("code = %d, want %d", re.Code, codeMethodNotFound)
|
|
}
|
|
}
|
|
|
|
func TestConnHandlerError(t *testing.T) {
|
|
a, b, stop := connPair(t)
|
|
defer stop()
|
|
b.Handle("boom", func(_ context.Context, _ json.RawMessage) (any, error) {
|
|
return nil, RPCError(codeInvalidParams, "nope")
|
|
})
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
err := a.Call(ctx, "boom", nil, nil)
|
|
var re *rpcError
|
|
if !asRPCError(err, &re) || re.Code != codeInvalidParams {
|
|
t.Fatalf("expected invalid-params rpcError, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestConnBidirectionalDuringHandler proves that while one peer is inside a
|
|
// request handler it can issue an outbound request back to the caller and the
|
|
// caller answers it — exactly the session/prompt -> session/request_permission
|
|
// pattern. If the read loop blocked on the handler, this would deadlock.
|
|
func TestConnBidirectionalDuringHandler(t *testing.T) {
|
|
a, b, stop := connPair(t)
|
|
defer stop()
|
|
|
|
// a answers an "approve?" callback.
|
|
a.Handle("approve", func(_ context.Context, _ json.RawMessage) (any, error) {
|
|
return map[string]bool{"ok": true}, nil
|
|
})
|
|
|
|
// b's "run" handler calls back to a mid-flight.
|
|
b.Handle("run", func(ctx context.Context, _ json.RawMessage) (any, error) {
|
|
var approval struct{ OK bool }
|
|
if err := b.Call(ctx, "approve", nil, &approval); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]bool{"ran": approval.OK}, nil
|
|
})
|
|
|
|
var out struct{ Ran bool }
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := a.Call(ctx, "run", nil, &out); err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
if !out.Ran {
|
|
t.Fatal("expected ran=true via mid-handler callback")
|
|
}
|
|
}
|
|
|
|
// TestConnSurvivesMalformedLine proves a single bad ndjson line yields a -32700
|
|
// and does NOT tear down the connection — a following valid request still works.
|
|
func TestConnSurvivesMalformedLine(t *testing.T) {
|
|
clientR, serverW := io.Pipe() // server -> client
|
|
serverR, clientW := io.Pipe() // client -> server
|
|
server := NewConn(serverR, serverW)
|
|
server.Handle("ping", func(_ context.Context, _ json.RawMessage) (any, error) { return "pong", nil })
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer func() {
|
|
cancel()
|
|
_ = serverW.Close()
|
|
_ = clientW.Close()
|
|
}()
|
|
go func() { _ = server.Serve(ctx) }()
|
|
|
|
go func() {
|
|
_, _ = clientW.Write([]byte("this is not json\n"))
|
|
_, _ = clientW.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"ping"}` + "\n"))
|
|
}()
|
|
|
|
dec := json.NewDecoder(clientR)
|
|
var sawParseError, sawPong bool
|
|
for i := 0; i < 2; i++ {
|
|
var msg struct {
|
|
Result any `json:"result"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- dec.Decode(&msg) }()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("decode response %d: %v", i, err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("timed out waiting for a response")
|
|
}
|
|
if msg.Error != nil && msg.Error.Code == codeParseError {
|
|
sawParseError = true
|
|
}
|
|
if r, ok := msg.Result.(string); ok && r == "pong" {
|
|
sawPong = true
|
|
}
|
|
}
|
|
if !sawParseError {
|
|
t.Error("expected a -32700 parse error for the malformed line")
|
|
}
|
|
if !sawPong {
|
|
t.Error("expected the valid request to still be answered (connection survived the bad line)")
|
|
}
|
|
}
|
|
|
|
func asRPCError(err error, target **rpcError) bool {
|
|
re, ok := err.(*rpcError)
|
|
if ok {
|
|
*target = re
|
|
}
|
|
return ok
|
|
}
|