Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 29 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Code of Conduct

This project expects respectful, technically focused collaboration.

## Expected Behavior

- Assume good intent and communicate directly.
- Keep discussions specific, evidence-based, and relevant to the repository.
- Use welcoming language in public issues, pull requests, and review comments.
- Respect maintainers' time by providing reproducible reports and clear context.

## Unacceptable Behavior

- Harassment, discrimination, or personal attacks.
- Doxxing, threats, or sustained hostile behavior.
- Repeated spam, bad-faith disruption, or intentionally misleading reports.
- Sharing secrets, tokens, or private data in public threads.

## Reporting

For normal collaboration problems, open an issue or discussion with enough
context to review the situation. For security-sensitive or private concerns,
follow the disclosure path in [SECURITY.md](SECURITY.md).

## Enforcement

Repository maintainers may edit, hide, lock, or remove content that violates
this policy, and may restrict participation when needed to keep collaboration
safe and productive.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ curl http://127.0.0.1:8000/.well-known/agent-card.json
- A2A HTTP+JSON endpoints such as `/v1/message:send` and
`/v1/message:stream`
- A2A JSON-RPC support on `POST /`
- SDK-owned A2A task surfaces such as `GET /v1/tasks`, task push notification
config routes, and JSON-RPC `agent/getAuthenticatedExtendedCard`
- Peering capabilities: can act as a client via `opencode-a2a call`
- Autonomous tool execution: supports `a2a_call` tool for outbound agent-to-agent communication
- SSE streaming with normalized `text`, `reasoning`, and `tool_call` blocks
Expand Down Expand Up @@ -173,6 +175,8 @@ Read before deployment:

- [SECURITY.md](SECURITY.md)
- [docs/guide.md](docs/guide.md)
- [SUPPORT.md](SUPPORT.md)
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)

## Further Reading

Expand Down
26 changes: 26 additions & 0 deletions SUPPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Support

## When To Open An Issue

Open a GitHub issue when you can provide:

- a clear problem statement
- the command, request, or configuration involved
- expected behavior versus actual behavior
- relevant logs or payload snippets with secrets removed

## Before You Ask

- Read [README.md](README.md) for scope and deployment expectations.
- Read [docs/guide.md](docs/guide.md) for protocol contracts and examples.
- Read [SECURITY.md](SECURITY.md) before reporting auth, deployment, or secret-related concerns.

## Security Concerns

Do not post active secrets, bearer tokens, or sensitive workspace data in a
public issue. Use the disclosure guidance in [SECURITY.md](SECURITY.md).

## Commercial Or SLA Support

This repository does not currently advertise a separate SLA or managed support
channel. GitHub issues are the default public support path.
12 changes: 12 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,23 @@ Key variables to understand protocol behavior:
- `A2A_CLIENT_USE_CLIENT_PREFERENCE`: whether the outbound client prefers its own transport choices.
- `A2A_CLIENT_BEARER_TOKEN`: optional bearer token attached to outbound peer
calls made by the embedded A2A client and `a2a_call` tool path.
- `A2A_CLIENT_BASIC_AUTH`: optional Basic auth credential attached to outbound
peer calls made by the embedded A2A client and `a2a_call` tool path.
- `A2A_CLIENT_SUPPORTED_TRANSPORTS`: ordered outbound transport preference list.
- `A2A_TASK_STORE_BACKEND`: runtime state backend. Supported values: `database`,
`memory`. Default: `database`.
- `A2A_TASK_STORE_DATABASE_URL`: database URL used by the default durable
backend. Default: `sqlite+aiosqlite:///./opencode-a2a.db`.
- Runtime authentication is bearer-token only via `A2A_BEARER_TOKEN`.
- Runtime authentication also applies to `/health`; the public unauthenticated
discovery surface remains `/.well-known/agent-card.json` and `/.well-known/agent.json`.
- The same outbound client flags are also honored by the server-side embedded
A2A client used for peer calls and `a2a_call` tool execution:
- `A2A_CLIENT_TIMEOUT_SECONDS`
- `A2A_CLIENT_CARD_FETCH_TIMEOUT_SECONDS`
- `A2A_CLIENT_USE_CLIENT_PREFERENCE`
- `A2A_CLIENT_BEARER_TOKEN`
- `A2A_CLIENT_BASIC_AUTH`
- `A2A_CLIENT_SUPPORTED_TRANSPORTS`

## Client Initialization Facade (Preview)
Expand Down Expand Up @@ -333,6 +338,10 @@ Current behavior:
- Shared metadata extension URIs such as session binding and streaming are
listed under `extensions.extension_uris`.
- `all_jsonrpc_methods` is the runtime truth for the current deployment.
- The current SDK-owned core JSON-RPC surface includes
`agent/getAuthenticatedExtendedCard` and `tasks/pushNotificationConfig/*`.
- The current SDK-owned REST surface also includes `GET /v1/tasks` and the
task push notification config routes.

When `A2A_ENABLE_SESSION_SHELL=false`, `opencode.sessions.shell` is omitted from
`all_jsonrpc_methods` and exposed only through
Expand Down Expand Up @@ -642,6 +651,9 @@ No extra custom REST endpoint is introduced.
suppressed for `method=opencode.sessions.*`
- Endpoint discovery: prefer `additional_interfaces[]` with
`transport=jsonrpc` from Agent Card
- The runtime still delegates SDK-owned JSON-RPC methods such as
`agent/getAuthenticatedExtendedCard` and `tasks/pushNotificationConfig/*`
to the base A2A implementation; they are not OpenCode-specific extensions.
- Notification behavior: for `opencode.sessions.*`, requests without `id`
return HTTP `204 No Content`
- Result format (query methods):
Expand Down
1 change: 1 addition & 0 deletions src/opencode_a2a/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ class Settings(BaseSettings):
default=False, alias="A2A_CLIENT_USE_CLIENT_PREFERENCE"
)
a2a_client_bearer_token: str | None = Field(default=None, alias="A2A_CLIENT_BEARER_TOKEN")
a2a_client_basic_auth: str | None = Field(default=None, alias="A2A_CLIENT_BASIC_AUTH")
a2a_client_cache_ttl_seconds: float = Field(
default=900.0,
ge=0.0,
Expand Down
14 changes: 7 additions & 7 deletions src/opencode_a2a/contracts/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from dataclasses import dataclass
from typing import Any

from a2a.server.apps.jsonrpc.jsonrpc_app import JSONRPCApplication

from ..profile.runtime import SESSION_SHELL_TOGGLE, RuntimeProfile

SHARED_SESSION_BINDING_FIELD = "metadata.shared.session.id"
Expand Down Expand Up @@ -213,19 +215,17 @@ class WorkspaceControlMethodContract:
key: SESSION_QUERY_METHODS[key] for key in SESSION_CONTROL_METHOD_KEYS
}

CORE_JSONRPC_METHODS: tuple[str, ...] = (
"message/send",
"message/stream",
"tasks/get",
"tasks/cancel",
"tasks/resubscribe",
)
CORE_JSONRPC_METHODS: tuple[str, ...] = tuple(JSONRPCApplication.METHOD_TO_MODEL)
CORE_HTTP_ENDPOINTS: tuple[str, ...] = (
"POST /v1/message:send",
"POST /v1/message:stream",
"GET /v1/tasks",
"GET /v1/tasks/{id}",
"POST /v1/tasks/{id}:cancel",
"GET /v1/tasks/{id}:subscribe",
"GET /v1/tasks/{id}/pushNotificationConfigs",
"POST /v1/tasks/{id}/pushNotificationConfigs",
"GET /v1/tasks/{id}/pushNotificationConfigs/{push_id}",
)
WIRE_CONTRACT_UNSUPPORTED_METHOD_DATA_FIELDS: tuple[str, ...] = (
"type",
Expand Down
6 changes: 4 additions & 2 deletions src/opencode_a2a/server/agent_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ def _build_agent_card_description(settings: Settings, runtime_profile: RuntimePr
base = (settings.a2a_description or "").strip() or "OpenCode A2A runtime."
summary = (
"Supports HTTP+JSON and JSON-RPC transports, streaming-first A2A messaging "
"(message/send, message/stream), task APIs (tasks/get, tasks/cancel, "
"tasks/resubscribe; REST mapping: GET /v1/tasks/{id}:subscribe), shared "
"(message/send, message/stream), authenticated extended Agent Card "
"(agent/getAuthenticatedExtendedCard), task APIs (tasks/get, tasks/cancel, "
"tasks/resubscribe, push notification config methods; REST mappings "
"include GET /v1/tasks and GET /v1/tasks/{id}:subscribe), shared "
"session-binding/model-selection/streaming contracts, provider-private "
"OpenCode session/provider/model/workspace-control/interrupt recovery "
"extensions, and shared interrupt callback extensions."
Expand Down
1 change: 1 addition & 0 deletions src/opencode_a2a/server/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ def __init__(self, settings: Settings) -> None:
),
"A2A_CLIENT_USE_CLIENT_PREFERENCE": settings.a2a_client_use_client_preference,
"A2A_CLIENT_BEARER_TOKEN": settings.a2a_client_bearer_token,
"A2A_CLIENT_BASIC_AUTH": settings.a2a_client_basic_auth,
"A2A_CLIENT_SUPPORTED_TRANSPORTS": settings.a2a_client_supported_transports,
}
)
Expand Down
2 changes: 2 additions & 0 deletions tests/config/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def test_settings_valid():
"A2A_ENABLE_SESSION_SHELL": "true",
"OPENCODE_MAX_CONCURRENT_REQUESTS": "12",
"OPENCODE_MAX_CONCURRENT_STREAMS": "3",
"A2A_CLIENT_BASIC_AUTH": "user:pass",
"A2A_SANDBOX_MODE": "danger-full-access",
"A2A_SANDBOX_FILESYSTEM_SCOPE": "unrestricted",
"A2A_SANDBOX_WRITABLE_ROOTS": "/srv/workspaces/alpha,/tmp/opencode",
Expand All @@ -54,6 +55,7 @@ def test_settings_valid():
assert settings.opencode_max_concurrent_requests == 12
assert settings.opencode_max_concurrent_streams == 3
assert settings.a2a_enable_session_shell is True
assert settings.a2a_client_basic_auth == "user:pass"
assert settings.a2a_sandbox_mode == "danger-full-access"
assert settings.a2a_sandbox_filesystem_scope == "unrestricted"
assert settings.a2a_sandbox_writable_roots == ("/srv/workspaces/alpha", "/tmp/opencode")
Expand Down
99 changes: 98 additions & 1 deletion tests/execution/test_opencode_agent_session_binding.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import asyncio
from base64 import b64encode
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock

import httpx
import pytest
from a2a.client.errors import A2AClientHTTPError, A2AClientJSONRPCError
from a2a.types import JSONRPCError, JSONRPCErrorResponse, Task
from a2a.types import (
Artifact,
JSONRPCError,
JSONRPCErrorResponse,
Part,
Task,
TaskArtifactUpdateEvent,
TaskState,
TaskStatus,
TextPart,
)

from opencode_a2a.client import A2AClient
from opencode_a2a.client.errors import (
A2AClientResetRequiredError,
A2APeerProtocolError,
Expand All @@ -16,6 +30,7 @@
from opencode_a2a.execution.executor import OpencodeAgentExecutor
from opencode_a2a.execution.tool_error_mapping import map_a2a_tool_exception
from opencode_a2a.opencode_upstream_client import OpencodeMessage
from opencode_a2a.server import application as app_module
from tests.support.helpers import (
DummyChatOpencodeUpstreamClient,
DummyEventQueue,
Expand Down Expand Up @@ -534,6 +549,77 @@ def borrow_client(self, url: str):
assert results[0]["error_meta"]["http_status"] == 401


@pytest.mark.asyncio
async def test_agent_a2a_call_uses_server_side_basic_auth_headers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_sdk_client = _FakeOutboundClient(
events=[
(
Task(
id="remote-task",
context_id="remote-ctx",
status=TaskStatus(state=TaskState.working),
),
TaskArtifactUpdateEvent(
task_id="remote-task",
context_id="remote-ctx",
artifact=Artifact(
artifact_id="artifact-1",
name="response",
parts=[Part(root=TextPart(text="remote response"))],
),
),
)
]
)
monkeypatch.setattr(A2AClient, "_build_client", AsyncMock(return_value=fake_sdk_client))

manager = app_module.A2AClientManager(
SimpleNamespace(
a2a_client_timeout_seconds=30.0,
a2a_client_card_fetch_timeout_seconds=5.0,
a2a_client_use_client_preference=False,
a2a_client_bearer_token=None,
a2a_client_basic_auth="user:pass",
a2a_client_supported_transports=("JSONRPC", "HTTP+JSON"),
a2a_client_cache_ttl_seconds=60.0,
a2a_client_cache_maxsize=1,
)
)
executor = OpencodeAgentExecutor(
DummyChatOpencodeUpstreamClient(),
streaming_enabled=False,
a2a_client_manager=manager,
)

results = await executor._maybe_handle_tools(
{
"parts": [
{
"type": "tool",
"tool": "a2a_call",
"callID": "c-basic",
"state": {
"status": "calling",
"input": {"url": "http://remote", "message": "hello"},
},
}
]
}
)

assert results is not None
assert results[0]["output"] == "remote response"
_, _, kwargs = fake_sdk_client.send_message_inputs[0]
assert kwargs["context"] is not None
assert kwargs["context"].state["headers"]["Authorization"] == (
f"Basic {b64encode(b'user:pass').decode()}"
)

await manager.close_all()


def test_map_a2a_tool_exception_protocol_and_unavailable_variants() -> None:
rpc_error = A2AClientJSONRPCError(
JSONRPCErrorResponse(
Expand Down Expand Up @@ -568,3 +654,14 @@ def test_map_a2a_tool_exception_additional_variants() -> None:
assert unsupported_payload["error_code"] == "a2a_unsupported_operation"
assert reset_payload["error_code"] == "a2a_retryable_unavailable"
assert generic_payload["error_code"] == "a2a_call_failed"


class _FakeOutboundClient:
def __init__(self, events: list[object]) -> None:
self._events = list(events)
self.send_message_inputs: list[tuple[object, object, object]] = []

async def send_message(self, message, *args: object, **kwargs: object):
self.send_message_inputs.append((message, args, kwargs))
for event in self._events:
yield event
7 changes: 7 additions & 0 deletions tests/server/test_a2a_client_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def _make_settings(**overrides: object) -> SimpleNamespace:
"a2a_client_card_fetch_timeout_seconds": 5.0,
"a2a_client_use_client_preference": False,
"a2a_client_bearer_token": None,
"a2a_client_basic_auth": None,
"a2a_client_supported_transports": ("JSONRPC", "HTTP+JSON"),
"a2a_client_cache_ttl_seconds": 60.0,
"a2a_client_cache_maxsize": 2,
Expand Down Expand Up @@ -197,3 +198,9 @@ async def close(self) -> None:
assert first_client is not second_client
assert created[0].closed is True
assert created[1].closed is False


def test_client_manager_loads_basic_auth_into_client_settings() -> None:
manager = app_module.A2AClientManager(_make_settings(a2a_client_basic_auth="user:pass"))

assert manager.client_settings.basic_auth == "user:pass"
17 changes: 17 additions & 0 deletions tests/server/test_agent_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,16 @@ def test_agent_card_injects_profile_into_extensions() -> None:
assert shell_policy["availability"] == "disabled"
assert shell_policy["retention"] == "deployment-conditional"
assert shell_policy["toggle"] == "A2A_ENABLE_SESSION_SHELL"
assert compatibility.params["method_retention"]["agent/getAuthenticatedExtendedCard"] == {
"surface": "core",
"availability": "always",
"retention": "required",
}
assert compatibility.params["method_retention"]["tasks/pushNotificationConfig/get"] == {
"surface": "core",
"availability": "always",
"retention": "required",
}
assert compatibility.params["service_behaviors"] == expected_service_behaviors
assert compatibility.params["service_behaviors"]["classification"] == (
"service-level-semantic-enhancement"
Expand All @@ -436,6 +446,13 @@ def test_agent_card_injects_profile_into_extensions() -> None:
assert PROVIDER_DISCOVERY_EXTENSION_URI in wire_contract.params["extensions"]["extension_uris"]
assert WORKSPACE_CONTROL_EXTENSION_URI in wire_contract.params["extensions"]["extension_uris"]
assert INTERRUPT_RECOVERY_EXTENSION_URI in wire_contract.params["extensions"]["extension_uris"]
assert "agent/getAuthenticatedExtendedCard" in wire_contract.params["all_jsonrpc_methods"]
assert "tasks/pushNotificationConfig/get" in wire_contract.params["all_jsonrpc_methods"]
assert "GET /v1/tasks" in wire_contract.params["core"]["http_endpoints"]
assert (
"GET /v1/tasks/{id}/pushNotificationConfigs"
in wire_contract.params["core"]["http_endpoints"]
)
assert "opencode.sessions.shell" not in wire_contract.params["all_jsonrpc_methods"]
assert wire_contract.params["service_behaviors"] == expected_service_behaviors
assert wire_contract.params["extensions"]["conditionally_available_methods"] == {
Expand Down