Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8188707
refactor(upstream): add typed adapter boundary (#2)
Oct 25, 2026
a2f8691
fix(upstream): allow receipt-only submissions safely (#2)
Oct 25, 2026
767e706
feat(upstream): add opt-in OpenCode V2 session transport (#3)
Oct 25, 2026
a1362e8
fix(card): avoid advertising V2 execution capabilities (#3)
Oct 25, 2026
5d83233
test(upstream): cover V2 session status failures (#3)
Oct 25, 2026
73ffcd8
fix(execution): enforce upstream outcome safety
Oct 25, 2026
8d5494a
fix(compatibility): gate extension capabilities by API version (#6)
Oct 25, 2026
7845d0e
test(compatibility): retain session-slice fail-closed wording (#6)
Oct 25, 2026
cc8470d
style(tests): format session capability assertion
Oct 25, 2026
b129d3a
feat(upstream): add bounded exclusive V2 execution engine (#4)
Oct 25, 2026
a2c76f3
feat(v2): enable guarded exclusive execution and interventions (#4, #5)
Oct 25, 2026
6a907ae
test(v2): mark synthetic credentials and source digest as false posit…
Oct 25, 2026
16e99ea
test(v2): mark second synthetic upstream password fixture
Oct 25, 2026
880299d
docs(v2): retain author correlation evidence referenced by profile
Oct 25, 2026
4a496e5
test(compatibility): identify synthetic fixtures for secret scanning
Oct 25, 2026
8e06272
test(v2): drop redundant digest scanner baseline entry
Oct 25, 2026
a633808
fix(v2): reject ambiguous exclusive workspace changes
Oct 25, 2026
2694e0f
fix(v2): require committed ownership before preferred-session reuse
Oct 25, 2026
8111d76
fix(v2): reject malformed reserved metadata without fallback
Oct 25, 2026
4f47c4d
test(v2): separate SQLite startup from cancellation deadlines
Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ curl http://127.0.0.1:8000/.well-known/agent-card.json

## Capabilities

The following capability list describes the default OpenCode API V1 mode. With
`OPENCODE_API_VERSION=v2`, only internal experimental session transport is
available: A2A message execution fails before upstream requests, streaming and
all extensions are disabled. A separate opt-in
[`OPENCODE_V2_EXECUTION=exclusive` profile](docs/v2-exclusive-execution.md)
adds restricted text execution and streaming read/question interventions in a
dedicated private instance pair. It does not support shared upstream sessions,
arbitrary tools, model overrides, or automatic recovery after event loss. Read
the deployment preconditions and [compatibility matrix](docs/compatibility.md#runtime-support)
before enabling it.

- A2A HTTP+JSON endpoints such as `/message:send` and `/message:stream`
- A2A JSON-RPC support on `POST /`
- SDK-owned A2A task surfaces such as `GET /tasks`, task push notification config routes, JSON-RPC `GetExtendedAgentCard`, and HTTP `GET /extendedAgentCard`
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This repository wraps OpenCode as an A2A runtime. It exposes A2A HTTP+JSON and J
- LLM provider keys are consumed by the `opencode` process. Prompt injection or indirect exfiltration attempts may still expose sensitive values.
- Payload logging is opt-in. When `A2A_LOG_PAYLOADS=true`, operators should treat logs as potentially sensitive operational data.
- This project does not ship host bootstrap or process-manager wrappers as an official product capability. Operators remain responsible for file permissions, secret storage, service users, and supervisor-specific hardening.
- Experimental V2 exclusive execution additionally requires a dedicated private network namespace, unshared upstream credentials, one gateway process, and no autonomous session writers. Loopback URL validation is not topology verification. See [the restricted profile](docs/v2-exclusive-execution.md) before enabling it; shared everyday OpenCode services are unsupported.
- Release artifacts are built in a read-only job, audited, and checksummed before separate least-privilege PyPI and GitHub Release jobs can upload them. Releases include a CycloneDX runtime SBOM.

## Threat Model
Expand Down
195 changes: 194 additions & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,202 @@ This document defines the compatibility promises `opencode-a2a` currently uphold
## Runtime Support

- Python versions: 3.11, 3.12, 3.13, 3.14
- A2A SDK line: `1.x.y`
- A2A Python SDK: `a2a-sdk==1.2.3` (SDK package version; not an OpenCode API version)
- Supported A2A protocol line: `1.0`
- OpenCode runtime line: `1.18.x` (verified with `1.18.19`)
- Experimental exclusive execution profile: OpenCode `2.0.28` only; see the
[deployment contract](./v2-exclusive-execution.md).

By default, OpenCode V2 is an opt-in, experimental session-transport slice selected with
`OPENCODE_API_VERSION=v2`. It implements `/api/info`, `/api/location`, and
session create/get/list transport only; A2A prompt execution and all
OpenCode-specific JSON-RPC extensions remain unavailable. This remains an
explicit safety gate, not a claim that V2 prompt execution works.

The additional selector `OPENCODE_V2_EXECUTION=exclusive` enables a separately
verified restricted profile: dedicated private upstream, gateway-created
single-flight sessions, text execution, and streaming read/question
interventions. It does not remove the default gate or solve general shared-session
correlation. The following legacy/default-V2 discussion describes selection
**without** that explicit profile; its earlier general correlation NO-GO remains
valid. See the scoped acceptance matrix below.

The V2 selector is **OpenCode's upstream HTTP API version**, not "A2A SDK V2".
The runtime continues to expose A2A protocol 1.0 interfaces. Its V2 Agent Card
contains no extension declarations and retains only an informational execution
status skill. The A2A Python SDK card contract used here requires populated
default input/output mode fields and a skill entry; those required card fields
are not a claim that V2 accepts A2A messages. `SendMessage` enters the SDK task
lifecycle and returns a failed task with the fail-closed explanation before
session creation or prompt submission. `SendStreamingMessage` is rejected as
unsupported because the card declares `streaming=false`. Neither path makes an
upstream HTTP request. Direct
`opencode.*` JSON-RPC calls return `-32601 Method not found` without an upstream
HTTP request. The same extension hiding applies to shared interrupt callbacks.

| Capability family | OpenCode API V1 (`1.18.x`, verified `1.18.19`) | OpenCode API V2 (`2.0.28` session-transport exercise only) |
| --- | --- | --- |
| A2A protocol interfaces | HTTP+JSON and JSON-RPC, protocol `1.0`; current core behavior | Interfaces remain protocol `1.0`; message send/stream fails before upstream HTTP |
| Shared session/model/stream/interrupt extensions | Declared and implemented as documented | Omitted from cards/OpenAPI; direct calls are unsupported |
| `opencode.sessions.*` (17 catalog methods) | Supported; `opencode.sessions.shell` is deployment-conditional | Omitted and rejected before upstream HTTP |
| Provider/model discovery (2 methods) | Supported | Omitted and rejected before upstream HTTP |
| Project/workspace/worktree (9 methods) | Project discovery stable; workspace/worktree discovery experimental; five mutations deployment-conditional | Omitted and rejected before upstream HTTP |
| Interrupt recovery (2 methods) | Adapter-local and caller-scoped | Omitted and rejected before upstream HTTP |
| `a2a.interrupt.*` callbacks (3 methods) | Supported through the shared interrupt extension | Omitted and rejected before upstream HTTP |
| Internal V2 session transport | Not applicable | `/api/info`, `/api/location`, session create/get/list only; exercised against `2.0.28`, not a model execution test |

V2 support does **not** claim A2A execution, task-scoped cancellation,
permission/form interventions, request-scoped model overrides, structured tool
execution, or outbound `a2a_call` support. `2.0.28` evidence is limited to
session transport plus an upstream-only correlation probe; no V2 model-backed
A2A acceptance run is claimed. Parent epic
#1 and the original unrestricted #4/#5 acceptance criteria remain open. The
exclusive profile's narrower evidence does not silently close those criteria.

### Exclusive-profile acceptance matrix

Evidence is recorded in [the sanitized acceptance record](./evidence/v2-exclusive-acceptance.json).
`PASS` applies only to the described tested subset; `NOT VERIFIED` is not a
support claim. V1 remains the stable default. Upstream review/merge and the
epic's final scope decision are separate from these execution results.

| Capability | V1 | V2 exclusive mode | Evidence / limitation |
| --- | --- | --- | --- |
| Session creation | PASS | PASS | Real pinned servers; V2 session-only smoke and model admission |
| A2A streaming | PASS | PASS | Real V1 JSON-RPC; real V2 REST and JSON-RPC, assistant artifacts only |
| A2A non-streaming | PASS | PASS | Real model, terminal state and final artifacts rather than admission |
| Follow-up | PASS | PASS | Remembered nonce; V2 requires a healthy live gateway session |
| Concurrent sessions | NOT VERIFIED | PASS | Separate V2 contexts run without output mixing |
| Shared-session concurrency | NOT VERIFIED | UNSUPPORTED | V1 risk #8 remains; V2 serializes gateway requests and excludes other writers |
| Cancellation | PASS | PASS | V1 durable canceled state; V2 observed active interruption and local queued cancellation |
| Permissions | PASS | PASS | V1 bash allow/reject; V2 read once/reject only, no blanket or persistent grant |
| Forms / questions | PASS | PASS | V1 question reply; V2 question string/multiselect and invalid/stale/cross-owner rejection |
| Request-scoped model selection | NOT VERIFIED | UNSUPPORTED | V2 backend model configured explicitly; overrides rejected before upstream calls |
| `a2a_call` | NOT VERIFIED | UNSUPPORTED | V2 tool policy and discovery exclude it |
| Disconnect recovery | NOT VERIFIED | UNSUPPORTED | V2 gap/terminal loss yields failure and quarantine, not replay or successful recovery |
| Lost-event / ambiguous-admission safety | NOT VERIFIED | PASS | Real upstream plus transport fault injection; one admission, no false success or resend |
| Unary interventions / rich forms | NOT VERIFIED | UNSUPPORTED | Explicit rejection/quarantine; only streaming supported question fields are exposed |
| Reasoning blocks | NOT VERIFIED | NOT VERIFIED | Deterministic native mapping tests pass; accepted live run observed text/tool blocks only |

V1 cancellation remains an A2A terminal-state contract with best-effort upstream
abort; it is not a rollback or exactly-once side-effect guarantee. V2 native
pending-inbox deletion is not independently advertised as successful cancellation:
without terminal proof it fails uncertain and retains quarantine. Restart,
retired-context continuation, and transparent upstream feed reconnection are not
supported by the exclusive profile. See [reproduction commands and detailed
preconditions](./v2-exclusive-execution.md#reproduce-the-live-evidence).

The pinned OpenCode `2.0.28` source confirms one useful correlation link: a
caller-supplied `Session.Message.ID` is reconciled as the inbox ID, and the
`InboxDelivered` projector writes the visible `Session.Message.User` with that
same ID. That identifies admission and delivery, but does not directly bind
later assistant steps or session execution terminals to the input. Reusing the
same ID in the same session/type reconciles to the first admission, including
after delivery; a retry must therefore reuse the exact original payload and ID.

```text
POST prompt(id) -> inbox.enqueued(inboxID=id) -> inbox.delivered(inboxID=id)
-> projected user message(id) [proven]
-> step.started(assistantMessageID)
-> step.ended(assistantMessageID)
-> execution.terminal(sessionID) [not input-scoped]
```

`Step.Started` / `Step.Ended` carry an assistant message ID, not the input ID;
`session.execution.*` carries only the session ID and represents a process-local
busy period. The runner delivers inputs at step boundaries. At an input
boundary, it prioritizes pending steers, delivers at most one queued input, then
also promotes steers that arrived during delivery. Thus `delivery=queue` and
the gateway's local session lock do not exclude another OpenCode client from
the same step context. A conservative live observer could infer a step belongs
to an input only while that input is the sole newly delivered prompt in the
step's context, then fail uncertain on interleaving. That inference is not
implemented or verified here.

Recovery is a separate blocker. The experimental
`GET /api/experimental/session/{sessionID}/log?after=...&follow=true` uses an
exclusive aggregate sequence and a replay-to-live `log.synced` watermark, but
v2.0.28 stores event rows only when the internal `events.persist` option is
enabled. The stock CLI does not set that option and the bus default is false;
the log therefore cannot replay durable step events in the default server
configuration. Persisted rows have no configured TTL and are deleted with the
session. `GET /api/event` is a separate volatile feed that misses events during
disconnection and has no replay cursor. Message projections can show current
assistant snapshots, but do not expose the durable event sequence needed to
reconstruct a missed input-to-step boundary with certainty.

Cancellation is also not task-scoped: `DELETE .../inbox/{inboxID}` only cancels
an undelivered item and returns no indication whether it was available;
`POST .../interrupt` interrupts active execution for the whole session. The
adapter cannot safely use that interrupt for one A2A task when another client
may have queued work. The adapter therefore rejects before creating a session
or submitting a prompt rather than risking cross-task completion, false
cancellation, or duplicate admission after an uncertain timeout.

Evidence links: pinned [prompt implementation](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/session/session.ts#L145-L175), [inbox reconciliation and delivery](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/session/inbox.ts#L154-L201), [delivery ordering](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/session/inbox.ts#L491-L529), [input projector](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/session/projector.ts#L618-L648), [step and execution event schemas](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/schema/src/session-event.ts#L203-L370), [assistant and idle message schemas](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/schema/src/session-message.ts#L212-L295), [bus persistence and log replay](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/core/src/bus.ts#L174-L204), and [stock CLI server options](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/cli/src/server-process.ts#L94-L134). The generated [v2.0.28 OpenAPI](https://github.com/anomalyco/opencode/blob/v2.0.28/packages/protocol/openapi.json) specifies the endpoint envelopes. A v2.0.30 source spot-check found the same prompt-ID reconciliation, no input ID in assistant/step/terminal schemas, and the same `events.persist=false` bus default.

### Live upstream Phase 1 correlation probe

A sanitized, isolated upstream-only experiment against OpenCode `2.0.28` used
`openai/gpt-6-luna-fast`, a temporary workspace, and restrictive session
permissions. It did **not** use the A2A adapter; the API credential was passed
only to the isolated server through a mode-restricted temporary file and was
neither printed nor included in prompt text. The event subscription returned
HTTP 200 before prompt admission. Two distinct `delivery=queue` inputs were
admitted; the second was submitted after the first `session.step.started`. Both
receipt IDs matched the caller-supplied IDs, both IDs appeared in
`session.inbox.delivered` and the projected user messages, and separate
assistant messages were projected. Both expected sentinel strings appeared
across the assistant history; this probe did not retain raw assistant text or
prove which individual message contained which sentinel. The observed order
was:

```text
inbox.enqueued(A) -> inbox.delivered(A) -> step.started(S1)
-> inbox.enqueued(B) -> step.ended(S1) -> inbox.delivered(B)
-> step.started(S2) -> step.ended(S2)
-> session.execution.succeeded(session only)
```

`S1` and `S2` denote the assistant message IDs carried by step events; the
events do not identify either step as caused by input A or B.
The single terminal event followed both prompts and contained no input ID;
assistant message schemas also had no input-link field. A separate pending
input test confirmed exact same-ID retry returned the original receipt and
deleting that still-undelivered inbox item returned `204`. After closing the
volatile feed, the experimental session log returned HTTP 200 with only a
`log.synced` frame and no replayed step/input event. This demonstrates one
controlled queue ordering, **not** a contract that every input has a distinct
assistant or terminal result, nor a completed A2A execution E2E.
The feed was closed after the observed terminal event; a mid-execution
disconnect/recovery scenario was not exercised.

A property-whitelisted, sanitized event trace and boolean run assertions are
recorded in
[`docs/evidence/opencode-v2.0.28-correlation-probe.json`](./evidence/opencode-v2.0.28-correlation-probe.json).
It omits credentials, session/workspace IDs, prompt and model text, sentinel
strings, and raw event payloads. It documents one observed run, not a general
upstream guarantee.

**Phase 1 decision: FAIL.** Do not enable V2 A2A prompts from this evidence.
The smallest feasible upstream remedy is to propagate a stable input/execution
ID through assistant-step and terminal events, provide replayable or
input-addressable terminal status, and make active cancellation input-scoped
(or document an enforceable exclusive-session contract). Until then, ambiguous
or disconnected outcomes must remain fail-closed.

V1 stays the default, and the adapter does not auto-detect or fall back between
versions. Reproduce the V2 session-transport check with
`bash ./scripts/live_opencode_v2_session_smoke.sh`. It is pinned to
OpenCode `2.0.28`, verifies Basic-auth rejection plus `/api/info`, `/api/location`,
and session create/get/list through the application-selected V2 adapter, and
submits no model prompt. This smoke does not add V2 to the supported runtime
line above. V2
session model selection changes persistent session state, so request-scoped
model overrides are not supported; V2 `a2a_call` execution parity is also not
claimed. OpenCode V2 allows `external_directory` by default. Before any future
release of V2 prompt execution, operators must configure an explicit
restrictive upstream V2 `permissions` policy.

This is the single canonical place where the supported upstream OpenCode version line is declared. Keep it updated together with the live integration smoke check (`./scripts/live_opencode_smoke.sh`) and do not duplicate the version line elsewhere.

Expand Down
Loading