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 c3bed70..c93eb5f 100644 Binary files a/plugins/marginal/runtime/marginal_runtime.pyz and b/plugins/marginal/runtime/marginal_runtime.pyz differ 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)