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
21 changes: 21 additions & 0 deletions LICENSE-COMMERCIAL.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions coding_bridge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__"]
56 changes: 56 additions & 0 deletions coding_bridge/embed.py
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
128 changes: 128 additions & 0 deletions tests/test_embed.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading