From 6b618a0177ad2da0bae8eb3a32461e918f6001f4 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Fri, 27 Mar 2026 09:49:56 -0400 Subject: [PATCH 1/3] fix: align runtime contracts and outbound auth (#346) --- CODE_OF_CONDUCT.md | 29 ++++++++++++++++++++++++ README.md | 4 ++++ SUPPORT.md | 26 +++++++++++++++++++++ docs/guide.md | 12 ++++++++++ pyproject.toml | 2 +- src/opencode_a2a/config.py | 1 + src/opencode_a2a/contracts/extensions.py | 14 ++++++------ src/opencode_a2a/server/agent_card.py | 6 +++-- src/opencode_a2a/server/application.py | 1 + tests/config/test_settings.py | 2 ++ tests/server/test_a2a_client_manager.py | 7 ++++++ tests/server/test_agent_card.py | 17 ++++++++++++++ 12 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SUPPORT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..44d7f4d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/README.md b/README.md index dfe9c55..36fec64 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..935956b --- /dev/null +++ b/SUPPORT.md @@ -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. diff --git a/docs/guide.md b/docs/guide.md index cff60e8..44abf83 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -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) @@ -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 @@ -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): diff --git a/pyproject.toml b/pyproject.toml index 387fef3..0aea1e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" authors = [ - { name = "liujuanjuan1984@Intelligent-Internet" }, + { name = "Intelligent Internet" }, ] keywords = ["a2a", "opencode", "fastapi", "json-rpc", "sse"] classifiers = [ diff --git a/src/opencode_a2a/config.py b/src/opencode_a2a/config.py index acc2634..4a1e169 100644 --- a/src/opencode_a2a/config.py +++ b/src/opencode_a2a/config.py @@ -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, diff --git a/src/opencode_a2a/contracts/extensions.py b/src/opencode_a2a/contracts/extensions.py index 6682600..37440c9 100644 --- a/src/opencode_a2a/contracts/extensions.py +++ b/src/opencode_a2a/contracts/extensions.py @@ -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" @@ -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", diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index bb60cd1..b1f3e20 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -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." diff --git a/src/opencode_a2a/server/application.py b/src/opencode_a2a/server/application.py index 178bd75..e1e563d 100644 --- a/src/opencode_a2a/server/application.py +++ b/src/opencode_a2a/server/application.py @@ -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, } ) diff --git a/tests/config/test_settings.py b/tests/config/test_settings.py index 74f9c8f..67eba0f 100644 --- a/tests/config/test_settings.py +++ b/tests/config/test_settings.py @@ -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", @@ -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") diff --git a/tests/server/test_a2a_client_manager.py b/tests/server/test_a2a_client_manager.py index 82e769e..be9938e 100644 --- a/tests/server/test_a2a_client_manager.py +++ b/tests/server/test_a2a_client_manager.py @@ -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, @@ -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" diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index 9152e77..33ca78d 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -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" @@ -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"] == { From aa2f641bed1862b866c7c9a40f16be651900b600 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Fri, 27 Mar 2026 09:55:38 -0400 Subject: [PATCH 2/3] test: cover server-side basic auth for a2a_call (#346) --- .../test_opencode_agent_session_binding.py | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/tests/execution/test_opencode_agent_session_binding.py b/tests/execution/test_opencode_agent_session_binding.py index 189ef05..2175157 100644 --- a/tests/execution/test_opencode_agent_session_binding.py +++ b/tests/execution/test_opencode_agent_session_binding.py @@ -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, @@ -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, @@ -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( @@ -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 From 437b2228b46ff6d420278ae4fed7908a023f9b99 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Fri, 27 Mar 2026 10:02:05 -0400 Subject: [PATCH 3/3] fix: restore author metadata semantics --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0aea1e2..387fef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" authors = [ - { name = "Intelligent Internet" }, + { name = "liujuanjuan1984@Intelligent-Internet" }, ] keywords = ["a2a", "opencode", "fastapi", "json-rpc", "sse"] classifiers = [