Skip to content

feat(a4): ACP stdio JSON-RPC dispatcher (Zed/JetBrains/Neovim agent server) - #16

Merged
ravituringworks merged 3 commits into
mainfrom
wave5/a4-acp-server-mode
May 14, 2026
Merged

feat(a4): ACP stdio JSON-RPC dispatcher (Zed/JetBrains/Neovim agent server)#16
ravituringworks merged 3 commits into
mainfrom
wave5/a4-acp-server-mode

Conversation

@ravituringworks

Copy link
Copy Markdown
Collaborator

Summary

Wave 5 / A4 of v13 fitgap §16.1 — VibeCLI as an Agent Client Protocol server speaking Zed's ACP v0.11+ (JSON-RPC 2.0 over stdio). Once Phase 54 ships and the daemon registers in the live ACP Registry (built into Zed + JetBrains in Q1 2026), Zed/JetBrains/Neovim users discover VibeCLI natively alongside Claude Code, Codex CLI, GitHub Copilot CLI, OpenCode, Gemini CLI.

This PR is distinct from the existing acp.rs HTTP surface — that's an older HTTP/SSE shape. Zed's ACP is JSON-RPC over stdio, similar in spirit to LSP.

Scope

In: the dispatcher half — JSON-RPC envelope handling + the five always-on methods that hosts call during initialization and session lifecycle.

Out (intentional, sized for one PR):

  • prompt + sessionUpdate notifications — full agent loop integration with provider / agent_runtime. The dispatcher recognises prompt and rejects it with METHOD_NOT_IMPLEMENTED (-32001) rather than METHOD_NOT_FOUND, so hosts see partial-impl honestly.
  • Stdin/stdout plumbing — a thin shell over dispatch that lives in the CLI subcommand or a separate binary. Isolated from the dispatcher logic; trivial follow-up.
  • Live registration in the ACP Registry — external step once the wiring above lands.

Implementation

stdin (line-delimited JSON)
    ↓
parse_request → AcpRequest | AcpResponse(parse_error)
    ↓                                ↓
AcpServer::dispatch                stdout
    ↓
run_method ─┬─ "initialize"      → handle_initialize
            ├─ "authenticate"    → handle_authenticate
            ├─ "newSession"      → handle_new_session
            ├─ "loadSession"     → handle_load_session   (or SESSION_NOT_FOUND)
            ├─ "cancel"          → handle_cancel
            ├─ "prompt"          → MethodNotImplemented (recognised, not yet shipped)
            ├─ "setSessionMode"  → MethodNotImplemented
            └─ ...               → MethodNotFound
    ↓
AcpResponse (Ok | Err) → stdout

AcpServer holds session state in Arc<Mutex<...>> so handlers can be cloned across threads without lifetime gymnastics. Sessions are in-memory for this slice; persistence ties into crate::session_store in a follow-up.

JSON-RPC error code mapping

Variant Code When
MethodNotFound -32601 Unknown method name
MethodNotImplemented -32001 (ACP ext) Recognised method that's not yet shipped
SessionNotFound -32002 (ACP ext) Well-formed sessionId that doesn't exist
InvalidParams -32602 Method called with wrong shape
Internal -32603 Lock poisoned, etc.
ParseError -32700 Invalid JSON; carries id: null
InvalidRequest -32600 jsonrpc field != "2.0"

The two ACP extensions (-32001, -32002) are in the JSON-RPC implementation-defined range (-32000 to -32099) and don't conflict with any standard JSON-RPC error.

Tests (red → green)

6 BDD scenarios in acp_stdio::tests, all committed before any impl in the red commit:

# Scenario What it pins
1 initialize returns protocolVersion + agentCapabilities + serverInfo.name = "vibecli" Handshake shape
2 Two newSession calls produce unique ids; session_count grows Sessions are tracked
3 loadSession on unknown id → SESSION_NOT_FOUND (-32002) ACP extension code, error message
4 Unknown method → METHOD_NOT_FOUND (-32601) Standard JSON-RPC error
5 Malformed JSON via parse_request → response with code: -32700, id: null Parse-error envelope
6 jsonrpc: "1.0"INVALID_REQUEST (-32600) Version validation

Tests run in milliseconds — no Rust workspace cold-compile required.

Test plan

  • cargo test -p vibecli --lib acp_stdio::tests — all 6 scenarios pass
  • cargo check -p vibecli clean
  • Manual: pipe a JSON-RPC initialize request to the eventual vibecli acp serve subcommand, observe the v0.11.0 handshake response

Patent-distance posture

A4 implements the public Zed Agent Client Protocol specification (open, on agent-client-protocol.org). No UX surface, no overlap with Cursor / Copilot patents. JSON-RPC 2.0 itself is decades old and unencumbered.

Cross-cutting invariants

  • Module declarationpub mod acp_stdio; in lib.rs, mod acp_stdio; in main.rs per CLAUDE.md.
  • No new deps — workspace serde + serde_json + anyhow are already pulled.
  • Distinct from existing acp.rs — that's the HTTP/SSE ACP surface; this is the stdio JSON-RPC one. Both names start with acp_ to keep them adjacent in the module list.

🤖 Generated with Claude Code

ravituringworks and others added 2 commits May 8, 2026 17:52
New module acp_stdio.rs targeting Zed's Agent Client Protocol v0.11+
(JSON-RPC 2.0 over stdio) — distinct from the existing acp.rs HTTP
surface. Type signatures + 6 BDD scenarios; impl bodies todo!() so
tests panic at runtime — TDD red.

Methods scaffolded:
  - initialize    — handshake, returns protocolVersion + capabilities
  - authenticate  — no-auth advertisement
  - newSession    — create + return session id
  - loadSession   — resume existing or SESSION_NOT_FOUND
  - cancel        — best-effort cancel hook (full hookup with prompt)

Scenarios:
  1. initialize handshake exposes protocolVersion + agentCapabilities
  2. newSession returns unique session ids and grows session_count
  3. loadSession on unknown id → SESSION_NOT_FOUND (-32002)
  4. Unknown method → METHOD_NOT_FOUND (-32601)
  5. Malformed JSON → PARSE_ERROR response with id=null
  6. jsonrpc != "2.0" → INVALID_REQUEST (-32600)

Phase 53 P0 framing: VibeCLI as an ACP server callable from Zed +
JetBrains + Neovim. Scope of THIS slice is the dispatcher (parse +
route + envelope). The prompt / sessionUpdate slice (full agent loop
integration) is tracked separately so it can land alongside the
agent_runtime work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes A4 from v13 fitgap §16.1 — VibeCLI as an ACP server speaking
Zed's Agent Client Protocol v0.11+ (JSON-RPC 2.0 over stdio). After
Phase 54, the daemon advertises an ACP entry that Zed + JetBrains +
Neovim can register against, joining the live registry alongside
Claude Code, Codex CLI, GitHub Copilot CLI, OpenCode, Gemini CLI.

Implementation:
- AcpServer with Arc<Mutex<...>> session state — cheap clone, no
  lifetime gymnastics for handlers.
- dispatch — JSON-RPC 2.0 envelope handling. Notifications (no id)
  run for side effects with no response; requests always return one.
  HandlerError variants map to JSON-RPC error codes:
    MethodNotFound        → -32601
    MethodNotImplemented  → -32001 (ACP extension; partial impl honest)
    SessionNotFound       → -32002 (ACP extension)
    InvalidParams         → -32602
    Internal              → -32603

- handle_initialize     — protocolVersion 0.11.0, agentCapabilities
                          (loadSession, promptCapabilities,
                          mcpCapabilities), serverInfo (name=vibecli +
                          CARGO_PKG_VERSION).
- handle_authenticate   — { authenticated: true } stub.
- handle_new_session    — monotonic counter → vibecli-acp-{:016x};
                          recorded in BTreeMap.
- handle_load_session   — looks up id, SESSION_NOT_FOUND if missing.
- handle_cancel         — { cancelled: true } stub.

prompt + setSessionMode are recognised and rejected with
METHOD_NOT_IMPLEMENTED rather than METHOD_NOT_FOUND so the host
surfaces the partial-impl state honestly.

parse_request — separate function so callers can write the parse
error envelope directly to stdout without re-translating.

Tests (6 BDD scenarios, all in the red commit):
  1. initialize handshake
  2. newSession unique ids
  3. loadSession unknown → SESSION_NOT_FOUND
  4. unknown method → METHOD_NOT_FOUND
  5. malformed JSON → PARSE_ERROR with id=null
  6. wrong jsonrpc version → INVALID_REQUEST

Out of scope (intentional, sized for one PR):
- prompt + sessionUpdate notifications — full agent loop integration
  with provider, agent_runtime, etc. Tracked separately so it can
  land alongside the agent_runtime work.
- Stdin/stdout plumbing (line-delimited JSON read/write) — a thin
  shell over dispatch that lives in the CLI subcommand or a separate
  binary. Trivial follow-up; isolated from the dispatcher logic.
- ACP Registry registration — manual external step once dispatcher
  wiring lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ravituringworks
ravituringworks marked this pull request as ready for review May 11, 2026 05:28
# Conflicts:
#	vibecli/vibecli-cli/src/lib.rs
#	vibecli/vibecli-cli/src/main.rs
@ravituringworks
ravituringworks merged commit e9dc09a into main May 14, 2026
ravituringworks added a commit that referenced this pull request May 14, 2026
)

What ships
----------
* New module `vibecli/vibecli-cli/src/redact.rs`:
  - `Redact<T>` newtype with `Serialize`/`Deserialize` (transparent —
    so configs round-trip unchanged on disk) and *redacted* `Debug` /
    `Display` impls — `format!("{cfg:?}")` on a struct containing
    `api_key: Redact<String>` prints `api_key: [redacted]` instead of
    leaking the key into the daemon log file.
  - No `Deref` / `DerefMut` on purpose: callers must explicitly opt-in
    to the plaintext via `.expose()` / `.into_inner()`. Auto-deref
    would defeat the point — `format!("{}", *r)` would still leak.
  - Constant-time `PartialEq` for the common `Redact<String>` and
    `Redact<Vec<u8>>` shapes (via `subtle::ConstantTimeEq`) so two
    redacted tokens don't reveal a byte-level timing channel when
    compared directly. Other `Redact<T>` users don't get `PartialEq`
    by default — derive it themselves if they need it.
  - 8 unit tests cover redacted Debug, redacted Display, struct
    inheritance, `Option<Redact<T>>`, serde round-trip, `.expose()`
    /`.into_inner()`, and the constant-time PartialEq correctness.

* New SAST rule `.semgrep/credential-logging.yml`:
  - `tracing-format-leaks-credential` (ERROR) — catches
    `tracing::{info,warn,error,debug,trace}!("…{api_key}…", …)` and
    the same for `bearer` / `secret` / `password` / `refresh_token` /
    `access_token` / `client_secret`. Scope is daemon + Tauri +
    workspace crates; tests / BDD fixtures excluded.
  - `println-leaks-credential` (WARNING) — same name pattern in
    `println!` / `eprintln!`. `banner.rs` excluded because it echoes
    *names* of configured providers (booleans), never keys.

Migration plan
--------------
Existing `api_key: Option<String>` fields stay as-is for now — wrap
them in `Redact<…>` when their owning struct is next touched. New
code uses the newtype directly. The semgrep gate makes accidental
regression visible at PR review time before the redacted newtype
migration is complete.

Why not migrate everything now: ~50 config structs hold credential
fields. Each migration touches the field, its `Default` impl, its
serde round-trip (transparent passes through but explicit map
serializers may need to call `.expose()`), and any builder. That's a
multi-day sweep with non-trivial conflict surface against ongoing
feature work. Shipping the newtype + the gate first is the right
shape: zero-risk infrastructure now, opportunistic migration later.

Doc updates
-----------
- `docs/security/threat-model.md` row #16 → ✅
- Stale rows #4 / #5 / #13 / #15 also reconciled to ✅ (those CI
  gates were already in `.github/workflows/security.yml` from the
  earlier Phase-3 batch — §8 just hadn't been updated).
- Two new change-log entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ravituringworks added a commit that referenced this pull request May 19, 2026
Full sweep of `tracing::*!` / `eprintln!` interpolation sites across
daemon (`vibecli-cli/src/`), agent crate (`vibeui/crates/vibe-ai/src/`),
Tauri commands, and provider crates for log-line leakage of user
content / model output.

One real leak found and fixed: `vibe-ai/src/agent.rs::run` logged
`summary = %summary` at agent-task-complete time where `summary` is
the model's `task_complete` argument. If a user pasted a secret into
their task description ("ignore my password 'hunter2'"), the model
could echo it in its final summary and the daemon would land it in
a plaintext log file under `RUST_LOG=info`.

Fixed by replacing the interpolation with `summary_len = summary.len()`,
matching the `response_len = accumulated.len()` pattern already in
use at line 840 for the same reason. The full summary still flows
through the event channel to the UI — only the log line is redacted.

All other audited sites log only IDs / counts / error chains / static
policy strings; the Slice F `Tainted<T>` helpers (`log_fingerprint`,
`audit_id`, `audit_summary`) cover the typed sites. Threat-model row
#16 marked ✅.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ravituringworks
ravituringworks deleted the wave5/a4-acp-server-mode branch May 29, 2026 07:17
ravituringworks added a commit that referenced this pull request Jun 13, 2026
chore(security): suppress lru RUSTSEC-2026-0002 + triage Dependabot #14/#15/#16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant