From 5bdff934509b79651e9c3dc1d70a8cfe5df0dac3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 17 Aug 2026 08:17:22 +0000 Subject: [PATCH] feat(opencode): support PrivacyCode as a compatible install target PrivacyCode reuses OpenCode's plugin loader unchanged, so the same plugin and the same bridge protocol govern it. A target records only what differs: the executable name, the global configuration directory, and the ledger location. The engine label stays distinct so one ledger never conflates two engines and a later measurement can compare them rather than pool them. Installing binds the copied plugin to its engine, so both engines can be installed at once without interfering, and install now refuses a source file that is not a MARGINAL plugin. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 +- docs/integrations/opencode.md | 27 ++++ plugins/marginal/runtime/marginal_runtime.pyz | Bin 622462 -> 624326 bytes plugins/marginal/runtime/provenance.json | 2 +- src/marginal/cli.py | 12 +- .../integrations/opencode/__init__.py | 5 +- .../integrations/opencode/installer.py | 33 ++++- src/marginal/integrations/opencode/targets.py | 20 ++- .../integrations/opencode/test_privacycode.py | 123 ++++++++++++++++++ 9 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 tests/integrations/opencode/test_privacycode.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e737e7a..d11193d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/integrations/opencode.md b/docs/integrations/opencode.md index 96973e2..f380341 100644 --- a/docs/integrations/opencode.md +++ b/docs/integrations/opencode.md @@ -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 @@ -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. diff --git a/plugins/marginal/runtime/marginal_runtime.pyz b/plugins/marginal/runtime/marginal_runtime.pyz index c3bed70857f4467cf8681b01d901d66c0c51f8a1..c93eb5f84c37e01f1576f05136652d3b19819884 100644 GIT binary patch delta 2953 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 diff --git a/plugins/marginal/runtime/provenance.json b/plugins/marginal/runtime/provenance.json index e6dc26e..6fafac6 100644 --- a/plugins/marginal/runtime/provenance.json +++ b/plugins/marginal/runtime/provenance.json @@ -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"} diff --git a/src/marginal/cli.py b/src/marginal/cli.py index 55dff38..15475e2 100644 --- a/src/marginal/cli.py +++ b/src/marginal/cli.py @@ -154,7 +154,9 @@ 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) @@ -162,7 +164,9 @@ def _build_parser() -> argparse.ArgumentParser: 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) @@ -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 @@ -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 diff --git a/src/marginal/integrations/opencode/__init__.py b/src/marginal/integrations/opencode/__init__.py index a70f03f..db712e0 100644 --- a/src/marginal/integrations/opencode/__init__.py +++ b/src/marginal/integrations/opencode/__init__.py @@ -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", @@ -52,6 +54,7 @@ "is_marginal_plugin", "main", "parse_request", + "render_plugin", "resolve_target", "serve", "tool_call_end", diff --git a/src/marginal/integrations/opencode/installer.py b/src/marginal/integrations/opencode/installer.py index 5c151e0..6135355 100644 --- a/src/marginal/integrations/opencode/installer.py +++ b/src/marginal/integrations/opencode/installer.py @@ -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) @@ -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.""" @@ -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( @@ -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, diff --git a/src/marginal/integrations/opencode/targets.py b/src/marginal/integrations/opencode/targets.py index ff608ad..9c0fda9 100644 --- a/src/marginal/integrations/opencode/targets.py +++ b/src/marginal/integrations/opencode/targets.py @@ -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: diff --git a/tests/integrations/opencode/test_privacycode.py b/tests/integrations/opencode/test_privacycode.py new file mode 100644 index 0000000..1a6a15f --- /dev/null +++ b/tests/integrations/opencode/test_privacycode.py @@ -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)