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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ All notable changes to MARGINAL are documented here. The project follows Semanti
- an OpenCode plugin labeled **Observe**, running inside the engine process and speaking
newline-delimited JSON to one bridge child process over pipes. Tool output never reaches MARGINAL:
the plugin forwards a digest plus an allowlist of outcome signals;
- `marginal install claude-code`, `marginal install opencode`, and their `uninstall` counterparts.
- PrivacyCode as an OpenCode-compatible install target, governed by the same adapter and plugin. It
keeps a distinct engine label, plugin path, and ledger root, so one ledger never conflates two
engines;
- `marginal install claude-code`, `marginal install opencode`, `marginal install privacycode`, and
their `uninstall` counterparts.

### Changed

Expand Down
27 changes: 27 additions & 0 deletions docs/integrations/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ hook. Shadow Mode is the only mode this integration supports today.

Validated against OpenCode 1.18.18 on Linux.

Also validated against PrivacyCode 1.18.10, an OpenCode-compatible fork. See
[Compatible forks](#compatible-forks).

## Install

```bash
Expand Down Expand Up @@ -92,3 +95,27 @@ OpenCode's own logs, database, and telemetry are outside this boundary.
```bash
marginal ledger-report ~/.local/share/marginal/opencode/ledger/*/*.jsonl
```

## Compatible forks

OpenCode's plugin loader is reused unchanged by compatible forks, so the same plugin and the same bridge
protocol govern them. A target records only what differs: the executable name, the global configuration
directory, and the ledger location.

| Target | Executable | Plugin path | Ledger root |
|---|---|---|---|
| `opencode` | `opencode` | `~/.config/opencode/plugins/marginal.js` | `~/.local/share/marginal/opencode` |
| `privacycode` | `privacycode` | `~/.config/privacycode/plugins/marginal.js` | `~/.local/share/marginal/privacycode` |

```bash
marginal install privacycode
marginal uninstall privacycode
```

Each target keeps a distinct engine label, so one ledger never conflates two engines and a later
measurement can compare them instead of pooling them. Installing binds the copied plugin to its engine,
so both can be installed at once without interfering. `MARGINAL_PRIVACYCODE_DATA` overrides the ledger
location for that target alone.

Adding another compatible fork means one `OpenCodeTarget` entry. Nothing about the governance contract
is per-target, so a fork that has diverged in its plugin API is not a target — it is a new adapter.
Binary file modified plugins/marginal/runtime/marginal_runtime.pyz
Binary file not shown.
2 changes: 1 addition & 1 deletion plugins/marginal/runtime/provenance.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"builder":"scripts/build_codex_plugin.py","python_requires":">=3.10","schema_version":1,"sha256":"6b616ab35e22de6a6d021b9f3ae4c3da2f84bbe0afda1b895f4a7704e0daccee","source_hash":"001fdf044af0a01863db2a599bd527a4a1f23bd0daa09b699fed73e7f2b353b5"}
{"builder":"scripts/build_codex_plugin.py","python_requires":">=3.10","schema_version":1,"sha256":"30c3268e4d1c051917f74f4b92ca60e1edd27f762096affcbb8b06fa7846271a","source_hash":"129c4898ab0da07d88b941adcb3c57d38ce69eb8a648ba5f4878266036855fba"}
12 changes: 8 additions & 4 deletions src/marginal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,19 @@ def _build_parser() -> argparse.ArgumentParser:
public_eval.add_argument("--seed", type=int, default=42)

install_parser = subparsers.add_parser("install", help="install a native integration")
install_parser.add_argument("target", choices=["codex", "claude-code", "opencode"])
install_parser.add_argument(
"target", choices=["codex", "claude-code", "opencode", "privacycode"]
)
install_parser.add_argument("--repository", default="SignalLayerLabs/Marginal")
install_parser.add_argument("--ref", default="main")
install_parser.add_argument("--data-dir", type=Path)
install_parser.add_argument("--autopilot-consent", action="store_true")
install_parser.add_argument("--json", action="store_true", dest="as_json")

uninstall_parser = subparsers.add_parser("uninstall", help="remove a native integration")
uninstall_parser.add_argument("target", choices=["codex", "claude-code", "opencode"])
uninstall_parser.add_argument(
"target", choices=["codex", "claude-code", "opencode", "privacycode"]
)
uninstall_parser.add_argument("--purge-data", action="store_true")
uninstall_parser.add_argument("--yes", action="store_true")
uninstall_parser.add_argument("--data-dir", type=Path)
Expand Down Expand Up @@ -218,7 +222,7 @@ def main(argv: Sequence[str] | None = None) -> int:
print(claude_result.message or claude_result.error_code or claude_result.selector)
return 0 if claude_result.installed else 1

if args.target == "opencode":
if args.target in {"opencode", "privacycode"}:
from .integrations.opencode.installer import install as install_opencode
from .integrations.opencode.targets import resolve_target

Expand Down Expand Up @@ -255,7 +259,7 @@ def main(argv: Sequence[str] | None = None) -> int:
print(claude_result.message or claude_result.error_code or claude_result.selector)
return 0 if not claude_result.installed else 1

if args.target == "opencode":
if args.target in {"opencode", "privacycode"}:
from .integrations.opencode.installer import uninstall as uninstall_opencode
from .integrations.opencode.targets import resolve_target

Expand Down
5 changes: 4 additions & 1 deletion src/marginal/integrations/opencode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@
inspect_opencode,
install,
is_marginal_plugin,
render_plugin,
uninstall,
)
from .normalization import ENGINE, classify_outcome, tool_call_end, tool_call_start
from .targets import OPENCODE, TARGETS, OpenCodeTarget, resolve_target
from .targets import OPENCODE, PRIVACYCODE, TARGETS, OpenCodeTarget, resolve_target

__all__ = [
"ENGINE",
"OPENCODE",
"PRIVACYCODE",
"TARGETS",
"BridgeService",
"OpenCodeDoctorReport",
Expand All @@ -52,6 +54,7 @@
"is_marginal_plugin",
"main",
"parse_request",
"render_plugin",
"resolve_target",
"serve",
"tool_call_end",
Expand Down
33 changes: 31 additions & 2 deletions src/marginal/integrations/opencode/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@

_VERSION_PATTERN = re.compile(r"(\d+\.\d+\.\d+)")
_MINIMUM_VERSION = (1, 18, 0)
_TARGET_PATTERN = re.compile(
r'(const ENGINE_TARGET = process\.env\.MARGINAL_TARGET \|\| ")[A-Za-z0-9._-]+(")'
)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -119,6 +122,24 @@ def _version_tuple(version: str) -> tuple[int, ...]:
return ()


def render_plugin(source: str, target: OpenCodeTarget) -> str:
"""Return the plugin source with its bridge target bound to one engine.

The bundled plugin defaults to OpenCode so the shipped file is valid as written.
Installing for a compatible fork rewrites exactly that default, so the plugin
reports the right engine without depending on an environment variable.
"""

rendered, replacements = _TARGET_PATTERN.subn(
lambda match: f"{match.group(1)}{target.engine}{match.group(2)}",
source,
count=1,
)
if replacements != 1:
raise ValueError("plugin source does not declare a bridge target")
return rendered


def is_marginal_plugin(path: Path) -> bool:
"""Return True only for a plugin file MARGINAL wrote."""

Expand Down Expand Up @@ -185,6 +206,14 @@ def install(
error_code="PLUGIN_SOURCE_MISSING",
message=str(origin),
)
if not is_marginal_plugin(origin):
return OpenCodeInstallation(
installed=False,
changed=False,
target=target.name,
error_code="PLUGIN_SOURCE_UNRECOGNIZED",
message=str(origin),
)
destination = target.plugin_path()
if destination.exists() and not is_marginal_plugin(destination):
return OpenCodeInstallation(
Expand All @@ -197,11 +226,11 @@ def install(
)
try:
destination.parent.mkdir(parents=True, exist_ok=True)
payload = origin.read_text(encoding="utf-8")
payload = render_plugin(origin.read_text(encoding="utf-8"), target)
changed = not destination.exists() or destination.read_text(encoding="utf-8") != payload
if changed:
destination.write_text(payload, encoding="utf-8")
except OSError as exc:
except (OSError, ValueError) as exc:
return OpenCodeInstallation(
installed=False,
changed=False,
Expand Down
20 changes: 19 additions & 1 deletion src/marginal/integrations/opencode/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,25 @@ def data_root(self) -> Path:
data_directory_name="opencode",
)

TARGETS: dict[str, OpenCodeTarget] = {OPENCODE.name: OPENCODE}
PRIVACYCODE = OpenCodeTarget(
name="privacycode",
engine="privacycode",
executable="privacycode",
config_directory_name="privacycode",
data_directory_name="privacycode",
)
"""PrivacyCode, an OpenCode-compatible fork.

It reuses OpenCode's plugin loader unchanged, so the governance contract is
identical. Only the executable name, the global configuration directory, and the
ledger location differ. The engine label stays distinct so a ledger never conflates
two engines, and so a future measurement can compare them rather than pool them.
"""

TARGETS: dict[str, OpenCodeTarget] = {
OPENCODE.name: OPENCODE,
PRIVACYCODE.name: PRIVACYCODE,
}


def resolve_target(name: str) -> OpenCodeTarget:
Expand Down
123 changes: 123 additions & 0 deletions tests/integrations/opencode/test_privacycode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""PrivacyCode is an OpenCode-compatible fork, governed by the same adapter.

These tests pin what must stay identical (the plugin, the bridge protocol, the
governance contract) and what must stay distinct (engine label, install location,
ledger location), so the two engines are never conflated in one ledger.
"""

from pathlib import Path

import pytest

from marginal.integrations.opencode import installer
from marginal.integrations.opencode.bridge import BridgeService
from marginal.integrations.opencode.targets import (
OPENCODE,
PRIVACYCODE,
TARGETS,
resolve_target,
)
from marginal.ledger import read_decision_ledger

from .conftest import SESSION_ID, RequestFactory


@pytest.fixture
def homes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
monkeypatch.delenv("MARGINAL_OPENCODE_DATA", raising=False)
monkeypatch.delenv("MARGINAL_PRIVACYCODE_DATA", raising=False)
return tmp_path


def test_privacycode_is_a_supported_target() -> None:
assert resolve_target("privacycode") is PRIVACYCODE
assert set(TARGETS) == {"opencode", "privacycode"}
assert PRIVACYCODE.engine != OPENCODE.engine


def test_the_two_targets_never_share_a_location(homes: Path) -> None:
assert PRIVACYCODE.plugin_path() != OPENCODE.plugin_path()
assert PRIVACYCODE.data_root() != OPENCODE.data_root()
assert PRIVACYCODE.plugin_path().parent == homes / "config" / "privacycode" / "plugins"
assert PRIVACYCODE.data_root() == homes / "data" / "marginal" / "privacycode"


def test_the_data_root_override_is_target_specific(
homes: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MARGINAL_PRIVACYCODE_DATA", str(homes / "elsewhere"))
assert PRIVACYCODE.data_root() == homes / "elsewhere"
assert OPENCODE.data_root() == homes / "data" / "marginal" / "opencode"


def test_installing_binds_the_plugin_to_its_engine(homes: Path) -> None:
result = installer.install(target=PRIVACYCODE)
assert result.installed is True
assert result.target == "privacycode"
installed = Path(result.path).read_text(encoding="utf-8")
assert 'process.env.MARGINAL_TARGET || "privacycode"' in installed
assert '|| "opencode"' not in installed


def test_the_shipped_plugin_still_defaults_to_opencode() -> None:
source = installer.bundled_plugin_path().read_text(encoding="utf-8")
assert 'process.env.MARGINAL_TARGET || "opencode"' in source


def test_both_engines_can_be_installed_side_by_side(homes: Path) -> None:
assert installer.install(target=OPENCODE).installed is True
assert installer.install(target=PRIVACYCODE).installed is True
assert OPENCODE.plugin_path().is_file()
assert PRIVACYCODE.plugin_path().is_file()

assert installer.uninstall(target=PRIVACYCODE).installed is False
assert not PRIVACYCODE.plugin_path().exists()
assert OPENCODE.plugin_path().is_file()


def test_rendering_rejects_a_source_without_a_target_declaration() -> None:
with pytest.raises(ValueError):
installer.render_plugin("// marginal-opencode-plugin\n", PRIVACYCODE)


def test_an_unrecognized_source_is_never_installed(homes: Path, tmp_path: Path) -> None:
foreign = tmp_path / "foreign.js"
foreign.write_text("export const Other = async () => ({})\n", encoding="utf-8")
result = installer.install(target=PRIVACYCODE, source=foreign)
assert result.installed is False
assert result.error_code == "PLUGIN_SOURCE_UNRECOGNIZED"
assert not PRIVACYCODE.plugin_path().exists()


def test_a_missing_cli_is_a_normal_answer(homes: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(installer.shutil, "which", lambda name: None)
report = installer.inspect_opencode(target=PRIVACYCODE)
assert report.target == "privacycode"
assert report.blocking_reasons == ("PRIVACYCODE_NOT_FOUND",)


def test_the_ledger_records_the_forked_engine(homes: Path, requests: RequestFactory) -> None:
service = BridgeService(target=PRIVACYCODE, data_root=homes / "ledger-root")
assert service.handle("status", {}) == {
"engine": "privacycode",
"sessions": 0,
"capability_level": "observe",
}
service.handle("session_start", requests.session())
service.handle("tool_start", requests.tool_start())
service.handle("tool_end", requests.tool_end(signals={"exit": 0}))
service.handle("session_end", requests.session())
records = read_decision_ledger(sorted(service.data_root.rglob("*.jsonl"))[0])
assert {record["engine"] for record in records} == {"privacycode"}
assert [record["event"] for record in records][-1] == "hook_session_end"


def test_the_bridge_entry_point_accepts_the_forked_target(homes: Path) -> None:
from marginal.integrations.opencode.bridge import BridgeService as Service

service = Service(target=resolve_target("privacycode"), data_root=homes / "data")
assert service.target.engine == "privacycode"
assert service.session_ids == ()
assert SESSION_ID not in str(service.data_root)