From dba238da613b3da4da0b6d16e75df853d2c462d5 Mon Sep 17 00:00:00 2001 From: Ace Data Cloud Dev Date: Sat, 8 Aug 2026 22:48:03 +0800 Subject: [PATCH] feat: expose embedded session host Co-Authored-By: Claude Opus 5 (1M context) --- LICENSE-COMMERCIAL.md | 21 +++++++ README.md | 2 +- coding_bridge/__init__.py | 6 ++ coding_bridge/embed.py | 56 +++++++++++++++++ pyproject.toml | 4 +- tests/test_embed.py | 128 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 LICENSE-COMMERCIAL.md create mode 100644 coding_bridge/embed.py create mode 100644 tests/test_embed.py diff --git a/LICENSE-COMMERCIAL.md b/LICENSE-COMMERCIAL.md new file mode 100644 index 0000000..f9c1335 --- /dev/null +++ b/LICENSE-COMMERCIAL.md @@ -0,0 +1,21 @@ +# AceDataCloud Commercial License + +Copyright (C) 2026 AceDataCloud. All rights reserved. + +AceDataCloud and entities under its common control are granted a perpetual, +worldwide, royalty-free license to use, reproduce, modify, distribute, deploy, +and create proprietary derivative works of this software as part of +AceDataCloud-operated products and services, without the source-disclosure +requirements of the GNU Affero General Public License. + +This commercial license does not grant rights to third parties. Everyone else +may use this software under the GNU Affero General Public License version 3 or +later, as described in `LICENSE`. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3f7ef9d..a2958a7 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ pytest -q ## Attribution & license -Licensed under **AGPL-3.0-or-later** (see [LICENSE](https://github.com/AceDataCloud/CodingBridge/blob/main/LICENSE)). +Licensed under **AGPL-3.0-or-later OR the AceDataCloud Commercial License**. Public users may use the AGPL terms in [LICENSE](https://github.com/AceDataCloud/CodingBridge/blob/main/LICENSE); AceDataCloud-operated products may use [LICENSE-COMMERCIAL.md](LICENSE-COMMERCIAL.md). The remote permission-relay design — forwarding a coding agent's tool-approval decision to a remote approver — was inspired by diff --git a/coding_bridge/__init__.py b/coding_bridge/__init__.py index f32aef0..8775a60 100644 --- a/coding_bridge/__init__.py +++ b/coding_bridge/__init__.py @@ -7,7 +7,13 @@ from importlib.metadata import PackageNotFoundError, version +from .config import Settings +from .embed import SessionHost +from .protocol import Action, Event + try: __version__ = version("coding-bridge") except PackageNotFoundError: __version__ = "0+unknown" + +__all__ = ["Action", "Event", "SessionHost", "Settings", "__version__"] diff --git a/coding_bridge/embed.py b/coding_bridge/embed.py new file mode 100644 index 0000000..7b60a1b --- /dev/null +++ b/coding_bridge/embed.py @@ -0,0 +1,56 @@ +"""Embedding surface for hosting Coding Bridge sessions without the relay transport.""" +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any + +from .config import Settings +from .connection import BridgeConnection +from .protocol import Action, Event, event_payload +from .providers.base import EmitFn, ProviderFactory + +CwdPolicy = Callable[[str | None], str] + + +class SessionHost(BridgeConnection): + """Run Coding Bridge sessions behind a caller-owned transport.""" + + def __init__( + self, + settings: Settings, + emit: EmitFn, + *, + provider_factory: ProviderFactory | None = None, + providers: Iterable[str] = ("claude",), + cwd_policy: CwdPolicy | None = None, + ) -> None: + super().__init__(settings, "embedded", provider_factory=provider_factory) + self._host_emit = emit + self._providers = frozenset(providers) + self._cwd_policy = cwd_policy or (lambda cwd: cwd or settings.default_cwd) + + async def send_payload(self, payload: dict[str, Any]) -> None: + """Deliver one inner event to the embedding application's transport.""" + await self._host_emit(payload) + + async def dispatch(self, payload: dict[str, Any]) -> None: + """Dispatch one browser action after applying host-owned policy.""" + action = payload.get("action") + prepared = dict(payload) + if action == Action.SESSION_START: + provider = prepared.get("provider") or "claude" + if provider not in self._providers: + await self.send_payload( + event_payload( + Event.SESSION_ERROR, + prepared.get("session_id"), + message=f"unsupported provider: {provider}", + ) + ) + return + prepared["provider"] = provider + prepared["cwd"] = self._cwd_policy(prepared.get("cwd")) + await self._dispatch(prepared) + + +__all__ = ["CwdPolicy", "SessionHost"] diff --git a/pyproject.toml b/pyproject.toml index 193506f..e44d6d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,8 @@ version = "0.1.0" description = "Node daemon for AceDataCloud Coding Bridge — run Claude Code on your own machine and drive it from the web." readme = "README.md" requires-python = ">=3.10" -license = "AGPL-3.0-or-later" -license-files = ["LICENSE"] +license = "AGPL-3.0-or-later OR LicenseRef-AceDataCloud-Commercial" +license-files = ["LICENSE", "LICENSE-COMMERCIAL.md"] authors = [{ name = "AceDataCloud", email = "dev@acedata.cloud" }] keywords = ["claude", "claude-code", "coding-agent", "remote", "websocket", "acedatacloud"] classifiers = [ diff --git a/tests/test_embed.py b/tests/test_embed.py new file mode 100644 index 0000000..13b38ef --- /dev/null +++ b/tests/test_embed.py @@ -0,0 +1,128 @@ +from types import SimpleNamespace + +import pytest + +from coding_bridge.config import Settings +from coding_bridge.embed import SessionHost +from coding_bridge.protocol import Action, Event + + +class FakeProvider: + name = "claude" + + def __init__(self, session_id, emit, ask_permission): + self.session_id = session_id + self.emit = emit + self.ask_permission = ask_permission + self.calls = [] + + async def start(self, prompt, **kwargs): + self.calls.append(("start", prompt, kwargs)) + + async def send(self, prompt, **kwargs): + self.calls.append(("send", prompt, kwargs)) + + async def edit(self, prompt, **kwargs): + self.calls.append(("edit", prompt, kwargs)) + + async def interrupt(self): + self.calls.append(("interrupt",)) + + async def aclose(self): + self.calls.append(("close",)) + + +@pytest.fixture +def embedded_host(tmp_path): + events = [] + providers = [] + + async def emit(payload): + events.append(payload) + + def factory(_name, session_id, emit, ask_permission): + provider = FakeProvider(session_id, emit, ask_permission) + providers.append(provider) + return provider + + settings = Settings(config_dir=tmp_path, default_cwd=str(tmp_path)) + settings.turn_retry_limit = 0 + host = SessionHost( + settings, + emit, + provider_factory=factory, + cwd_policy=lambda _cwd: str(tmp_path / "workspace"), + ) + return SimpleNamespace(host=host, events=events, providers=providers) + + +@pytest.mark.asyncio +async def test_embedded_host_starts_session_with_host_cwd(embedded_host): + await embedded_host.host.dispatch( + { + "action": Action.SESSION_START, + "session_id": "local-1", + "provider": "claude", + "cwd": "/untrusted", + "prompt": "inspect the logs", + "permission_mode": "default", + } + ) + session = embedded_host.host.sessions["local-1"] + await session._task + + assert session.cwd.endswith("workspace") + assert embedded_host.providers[0].calls[0][0:2] == ("start", "inspect the logs") + assert embedded_host.events[0]["event"] == Event.SESSION_STARTED + + +@pytest.mark.asyncio +async def test_embedded_host_rejects_provider_outside_allowlist(embedded_host): + await embedded_host.host.dispatch( + { + "action": Action.SESSION_START, + "session_id": "local-2", + "provider": "codex", + "prompt": "hello", + } + ) + + assert embedded_host.host.sessions == {} + assert embedded_host.events == [ + { + "event": Event.SESSION_ERROR, + "session_id": "local-2", + "message": "unsupported provider: codex", + } + ] + + +@pytest.mark.asyncio +async def test_embedded_host_dispatches_follow_up_and_interrupt(embedded_host): + await embedded_host.host.dispatch( + { + "action": Action.SESSION_START, + "session_id": "local-3", + "prompt": "first", + } + ) + session = embedded_host.host.sessions["local-3"] + await session._task + + await embedded_host.host.dispatch( + { + "action": Action.SESSION_SEND, + "session_id": "local-3", + "prompt": "second", + } + ) + await session._task + await embedded_host.host.dispatch( + {"action": Action.SESSION_INTERRUPT, "session_id": "local-3"} + ) + + assert [call[0] for call in embedded_host.providers[0].calls] == [ + "start", + "send", + "interrupt", + ]