diff --git a/apps/cptr/cptr/services/command_sandbox.py b/apps/cptr/cptr/services/command_sandbox.py deleted file mode 100644 index 4e40c950..00000000 --- a/apps/cptr/cptr/services/command_sandbox.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Direct Coding process-isolation profiles. - -The sandbox is intentionally applied only to ChatGPT Direct Coding/test commands. -Interactive legacy terminals and delegated-agent execution keep their existing -runtime unless they explicitly opt into the same profile. -""" - -from __future__ import annotations - -import os -import shlex -import shutil -from dataclasses import dataclass -from pathlib import Path - - -class SandboxUnavailable(RuntimeError): - """Raised when an explicitly selected isolation profile cannot run.""" - - -@dataclass(frozen=True) -class SandboxCommand: - argv: list[str] | None - shell_command: str | None - profile: str - - -_ALLOWED = {"host", "auto", "bubblewrap", "systemd", "container", "vm"} - - -def configured_profile() -> str: - value = os.getenv("CPTR_DIRECT_CODING_SANDBOX", "bubblewrap").strip().lower() or "bubblewrap" - if value not in _ALLOWED: - raise SandboxUnavailable( - f"unsupported CPTR_DIRECT_CODING_SANDBOX={value!r}; expected one of {sorted(_ALLOWED)}" - ) - if value == "auto": - if shutil.which("bwrap"): - return "bubblewrap" - if shutil.which("systemd-run"): - return "systemd" - return "host" - return value - - -def _original_argv(command: str, argv: list[str] | None) -> list[str]: - return list(argv) if argv is not None else ["/bin/sh", "-lc", command] - - -def _bubblewrap( - *, command: str, argv: list[str] | None, workspace: Path, work_dir: Path, allow_network: bool -) -> SandboxCommand: - binary = shutil.which("bwrap") - if not binary: - raise SandboxUnavailable( - "bubblewrap sandbox requested but bwrap is not installed; install bubblewrap or choose host/systemd" - ) - workspace = workspace.resolve() - work_dir = work_dir.resolve() - if not work_dir.is_relative_to(workspace): - raise SandboxUnavailable("sandbox working directory must remain inside the workspace") - wrapped = [ - binary, - "--die-with-parent", - "--new-session", - "--unshare-pid", - "--unshare-ipc", - "--unshare-uts", - "--ro-bind", - "/", - "/", - "--dev-bind", - "/dev", - "/dev", - "--proc", - "/proc", - "--tmpfs", - "/tmp", - "--bind", - str(workspace), - str(workspace), - "--chdir", - str(work_dir), - ] - if not allow_network: - wrapped.append("--unshare-net") - wrapped.extend(["--", *_original_argv(command, argv)]) - return SandboxCommand(argv=wrapped, shell_command=None, profile="bubblewrap") - - -def _systemd( - *, command: str, argv: list[str] | None, work_dir: Path, allow_network: bool -) -> SandboxCommand: - binary = shutil.which("systemd-run") - if not binary: - raise SandboxUnavailable("systemd sandbox requested but systemd-run is not installed") - wrapped = [ - binary, - "--user", - "--scope", - "--quiet", - "--pipe", - "--wait", - "--collect", - "--property=NoNewPrivileges=yes", - "--property=PrivateTmp=yes", - "--property=TasksMax=1024", - f"--working-directory={work_dir}", - ] - if not allow_network: - # IPAddressDeny is best-effort on transient user scopes. Managed Heidi - # deployments prefer bubblewrap when hard network isolation is required. - wrapped.append("--property=IPAddressDeny=any") - wrapped.extend(["--", *_original_argv(command, argv)]) - return SandboxCommand(argv=wrapped, shell_command=None, profile="systemd") - - -def _container( - *, command: str, argv: list[str] | None, workspace: Path, work_dir: Path, allow_network: bool -) -> SandboxCommand: - podman = shutil.which("podman") - if not podman: - raise SandboxUnavailable("container sandbox requested but podman is not installed") - image = os.getenv("CPTR_DIRECT_CODING_CONTAINER_IMAGE", "").strip() - if not image: - raise SandboxUnavailable( - "container sandbox requires CPTR_DIRECT_CODING_CONTAINER_IMAGE to name a trusted development image" - ) - workspace = workspace.resolve() - relative = work_dir.resolve().relative_to(workspace) - container_cwd = Path("/workspace") / relative - wrapped = [ - podman, - "run", - "--rm", - "--read-only", - "--security-opt=no-new-privileges", - "--cap-drop=all", - "--pids-limit=1024", - "--tmpfs=/tmp:rw,nosuid,nodev,size=1g", - f"--volume={workspace}:/workspace:rw,Z", - f"--workdir={container_cwd}", - "--network=host" if allow_network else "--network=none", - image, - *_original_argv(command, argv), - ] - return SandboxCommand(argv=wrapped, shell_command=None, profile="container") - - -def _vm( - *, command: str, argv: list[str] | None, workspace: Path, work_dir: Path, allow_network: bool -) -> SandboxCommand: - runner = os.getenv("CPTR_DIRECT_CODING_VM_RUNNER", "").strip() - if not runner: - raise SandboxUnavailable( - "VM sandbox requires CPTR_DIRECT_CODING_VM_RUNNER; configure a trusted runner that accepts the documented argv" - ) - runner_argv = shlex.split(runner) - if not runner_argv or not shutil.which(runner_argv[0]): - raise SandboxUnavailable("configured CPTR_DIRECT_CODING_VM_RUNNER is not executable") - wrapped = [ - *runner_argv, - "--workspace", - str(workspace.resolve()), - "--cwd", - str(work_dir.resolve()), - "--network", - "allow" if allow_network else "deny", - "--", - *_original_argv(command, argv), - ] - return SandboxCommand(argv=wrapped, shell_command=None, profile="vm") - - -def sandbox_command( - *, - command: str, - argv: list[str] | None, - workspace: str | Path, - work_dir: str | Path, - allow_network: bool, - profile: str | None = None, -) -> SandboxCommand: - selected = (profile or configured_profile()).strip().lower() - workspace_path = Path(workspace).resolve() - work_dir_path = Path(work_dir).resolve() - if selected == "host": - return SandboxCommand(argv=argv, shell_command=None if argv is not None else command, profile="host") - if selected == "bubblewrap": - return _bubblewrap( - command=command, - argv=argv, - workspace=workspace_path, - work_dir=work_dir_path, - allow_network=allow_network, - ) - if selected == "systemd": - return _systemd(command=command, argv=argv, work_dir=work_dir_path, allow_network=allow_network) - if selected == "container": - return _container( - command=command, - argv=argv, - workspace=workspace_path, - work_dir=work_dir_path, - allow_network=allow_network, - ) - if selected == "vm": - return _vm( - command=command, - argv=argv, - workspace=workspace_path, - work_dir=work_dir_path, - allow_network=allow_network, - ) - raise SandboxUnavailable(f"unsupported sandbox profile: {selected}") diff --git a/apps/cptr/cptr/utils/tools.py b/apps/cptr/cptr/utils/tools.py index fc14d801..fd1e9e4c 100644 --- a/apps/cptr/cptr/utils/tools.py +++ b/apps/cptr/cptr/utils/tools.py @@ -52,7 +52,6 @@ preexec_for, ) from cptr.services.execution_manager import command_session_registry -from cptr.services.command_sandbox import SandboxUnavailable, sandbox_command from cptr.utils.runtime import Runtime, FileError try: @@ -1727,23 +1726,6 @@ async def run_command( env = {**os.environ, "PAGER": "cat", "GIT_PAGER": "cat"} preexec = None - if __context__.get("direct_coding"): - try: - sandboxed = sandbox_command( - command=command, - argv=__argv, - workspace=workspace, - work_dir=work_dir, - allow_network=bool(__context__.get("allow_network")), - profile=__context__.get("sandbox_profile"), - ) - except SandboxUnavailable as e: - return f"Error: direct coding sandbox unavailable: {e}" - __argv = sandboxed.argv - if sandboxed.shell_command is not None: - command = sandboxed.shell_command - __use_pty = False - master_fd = None try: diff --git a/apps/cptr/tests/test_command_sandbox.py b/apps/cptr/tests/test_command_sandbox.py deleted file mode 100644 index 911d9952..00000000 --- a/apps/cptr/tests/test_command_sandbox.py +++ /dev/null @@ -1,65 +0,0 @@ -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - -import pytest - -from cptr.routers.coding import _command_context -from cptr.services.command_sandbox import SandboxUnavailable, configured_profile, sandbox_command - - -def test_direct_coding_command_context_marks_sandbox_and_network_policy(): - request = SimpleNamespace() - context = _command_context( - request=request, - user_id="user_1", - workspace_id="ws_1", - workspace_path="/tmp/workspace", - worker_id="worker_1", - allow_network=True, - ) - assert context["direct_coding"] is True - assert context["allow_network"] is True - assert context["direct_worker_id"] == "worker_1" - - -def test_bubblewrap_denies_network_and_writes_only_workspace(tmp_path: Path): - workspace = tmp_path / "repo" - workspace.mkdir() - work_dir = workspace / "src" - work_dir.mkdir() - with patch("cptr.services.command_sandbox.shutil.which", return_value="/usr/bin/bwrap"): - wrapped = sandbox_command( - command="printf hello", - argv=None, - workspace=workspace, - work_dir=work_dir, - allow_network=False, - profile="bubblewrap", - ) - assert wrapped.profile == "bubblewrap" - assert wrapped.shell_command is None - assert wrapped.argv is not None - assert "--unshare-net" in wrapped.argv - assert ["--bind", str(workspace.resolve()), str(workspace.resolve())] == wrapped.argv[ - wrapped.argv.index("--bind") : wrapped.argv.index("--bind") + 3 - ] - assert wrapped.argv[-4:] == ["--", "/bin/sh", "-lc", "printf hello"] - - -def test_default_direct_coding_sandbox_is_fail_closed_bubblewrap(monkeypatch): - monkeypatch.delenv("CPTR_DIRECT_CODING_SANDBOX", raising=False) - assert configured_profile() == "bubblewrap" - - -def test_explicit_unavailable_sandbox_fails_closed(tmp_path: Path): - with patch("cptr.services.command_sandbox.shutil.which", return_value=None): - with pytest.raises(SandboxUnavailable, match="bubblewrap"): - sandbox_command( - command="true", - argv=None, - workspace=tmp_path, - work_dir=tmp_path, - allow_network=False, - profile="bubblewrap", - ) diff --git a/docs/superpowers/plans/2026-08-31-remove-direct-coding-sandbox.md b/docs/superpowers/plans/2026-08-31-remove-direct-coding-sandbox.md new file mode 100644 index 00000000..da6a55e9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-remove-direct-coding-sandbox.md @@ -0,0 +1,96 @@ +# Direct Coding Sandbox Removal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the entire CPTR Direct Coding sandbox abstraction and make Direct Coding always use native host execution under the existing owner-full authority model. + +**Architecture:** `run_command` keeps the existing identity, cwd, PTY/subprocess, session, logging, and cancellation machinery and no longer wraps Direct Coding commands in an isolation profile. Installer, compatibility, verification, tests, and docs lose the sandbox configuration surface entirely. + +**Tech Stack:** Python/FastAPI CPTR, Bash installer/verification, JSON compatibility contract, pytest, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-08-31-remove-direct-coding-sandbox-design.md` + +## Global Constraints + +- Keep `owner-full` as the default control profile. +- Do not add MCP resources capability, `ui.resourceUri`, or any Apps UI entrypoint. +- Preserve command sessions, cwd semantics, identity handling, PTY/subprocess execution, logging, cancellation, Git/FDX/OAuth behavior. +- Remove rather than deprecate/no-op the Direct Coding sandbox configuration surface. + +--- + +### Task 1: Add removal regression contract + +**Files:** +- Create: `tests/test_direct_coding_host_contract.py` + +**Interfaces:** +- Consumes: repository source tree. +- Produces: a CI contract that fails while sandbox implementation/configuration remains. + +- [ ] Add assertions that runtime/install/compatibility sources do not contain `command_sandbox`, `CPTR_DIRECT_CODING_SANDBOX`, `HEIDI_SANDBOX_PROFILE`, `CPTR_DIRECT_CODING_CONTAINER_IMAGE`, `CPTR_DIRECT_CODING_VM_RUNNER`, or Bubblewrap/`bwrap` dependency wiring. +- [ ] Run root pytest through PR CI and verify RED against current code. +- [ ] Commit the failing contract. + +### Task 2: Switch Direct Coding to native host execution + +**Files:** +- Modify: `apps/cptr/cptr/utils/tools.py` +- Delete: `apps/cptr/cptr/services/command_sandbox.py` +- Delete: `apps/cptr/tests/test_command_sandbox.py` + +**Interfaces:** +- Consumes: existing `run_command` native PTY/subprocess path. +- Produces: Direct Coding commands that execute directly using that path. + +- [ ] Remove sandbox imports from `tools.py`. +- [ ] Remove the `direct_coding` sandbox wrapping block without changing identity/cwd/session behavior. +- [ ] Delete sandbox implementation and sandbox-specific tests. +- [ ] Verify CPTR tests in PR CI. + +### Task 3: Remove installer/config/verification sandbox surface + +**Files:** +- Modify: `scripts/install-core.sh` +- Modify: `scripts/install-lib.sh` +- Modify: `scripts/verify-stack.sh` +- Modify related installer contract tests if they encode sandbox behavior. + +**Interfaces:** +- Consumes: existing managed installation flow. +- Produces: owner-full host-native CPTR configuration with no sandbox variables/dependency installation. + +- [ ] Remove `SANDBOX_PROFILE` and all sandbox env persistence. +- [ ] Replace sandbox-specific dependency installation with only still-required security dependencies (`age`, `age-keygen`, `setcap`/`libcap2-bin`). +- [ ] Remove verification checks for sandbox profile/Bubblewrap. +- [ ] Keep owner-full default behavior unchanged. +- [ ] Verify installer/root tests and shell syntax in PR CI. + +### Task 4: Update compatibility/docs contracts + +**Files:** +- Modify: `release/compatibility.json` +- Modify docs/readmes returned by repository-wide sandbox searches. + +**Interfaces:** +- Consumes: current compatibility schema. +- Produces: host-native Direct Coding documentation with no selectable sandbox profiles. + +- [ ] Remove sandbox profile/default fields from compatibility metadata. +- [ ] Describe Direct Coding as host-native owner-authorized execution. +- [ ] Remove Bubblewrap/systemd/container/VM sandbox documentation. +- [ ] Verify `scripts/verify-compatibility.py` and root tests in PR CI. + +### Task 5: Repository-wide closure verification + +**Files:** +- Modify: `tests/test_direct_coding_host_contract.py` only if a false-positive exemption is required for historical design/plan documentation. + +**Interfaces:** +- Consumes: complete branch. +- Produces: evidence that active sandbox code/configuration is gone. + +- [ ] Search active source/config/docs for removed identifiers and confirm no applicable references remain. +- [ ] Run complete PR CI: installer, MCP, CPTR, FDX. +- [ ] Review PR diff for unrelated changes and MCP UI regressions. +- [ ] Only after green CI, mark PR ready and merge. diff --git a/docs/superpowers/specs/2026-08-31-remove-direct-coding-sandbox-design.md b/docs/superpowers/specs/2026-08-31-remove-direct-coding-sandbox-design.md new file mode 100644 index 00000000..0e0809ea --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-remove-direct-coding-sandbox-design.md @@ -0,0 +1,55 @@ +# Host-Native Direct Coding Design + +## Goal + +Heidi/CPTR gives ChatGPT an authenticated coding workspace on the user's local computer. Direct Coding is host-native execution, not a sandbox product. + +## Execution model + +`ChatGPT -> Heidi MCP -> CPTR owner-full -> selected CPTR workspace -> native host subprocess/PTY` + +`owner-full` remains the default control profile. Commands use the existing CPTR identity, working-directory, session, logging, cancellation, and process-group machinery directly. + +## Required removals + +Remove the entire Direct Coding sandbox abstraction, including Bubblewrap, systemd sandboxing, container/VM sandbox profiles, automatic sandbox selection, installer/package dependencies used only for sandboxing, sandbox environment variables, compatibility metadata, verification logic, tests, and documentation. + +Delete `apps/cptr/cptr/services/command_sandbox.py`. Direct Coding must not import or call it. + +Remove these configuration surfaces where they exist: + +- `CPTR_DIRECT_CODING_SANDBOX` +- `HEIDI_SANDBOX_PROFILE` +- `CPTR_DIRECT_CODING_CONTAINER_IMAGE` +- `CPTR_DIRECT_CODING_VM_RUNNER` +- Bubblewrap / `bwrap` dependency checks and install steps + +## Preserved behavior + +The change must preserve: + +- default `owner-full` deployment policy; +- selected-workspace cwd semantics; +- native host toolchain and environment access; +- PAM/non-PAM identity handling; +- PTY and non-PTY native execution; +- command sessions, streaming/logging, cancellation, and process-group isolation; +- workspace registration/path semantics; +- MCP tool inventory and the tool-only invariant (no MCP resources capability and no `ui.resourceUri`); +- FDX, Git, OAuth, deployment, and workspace lifecycle behavior unrelated to sandboxing. + +## Compatibility + +Legacy sandbox environment variables are intentionally removed rather than retained as no-op compatibility flags. Existing deployments should stop persisting them on the next managed install/update. + +`release/compatibility.json` must describe Direct Coding as host-native and must not advertise selectable sandbox profiles. + +## Acceptance criteria + +1. Repository production/runtime/config/docs contain no active Direct Coding sandbox implementation or selectable sandbox profile. +2. `command_sandbox.py` and its sandbox-specific tests are deleted. +3. Direct Coding `run_command` reaches the existing native host subprocess/PTY path directly. +4. Managed installer no longer installs Bubblewrap or writes sandbox configuration. +5. `owner-full` remains the default profile. +6. A regression test rejects reintroduction of the removed sandbox surface. +7. CPTR, installer/compatibility, MCP, and FDX CI remain green. diff --git a/release/compatibility.json b/release/compatibility.json index fcbcdb64..1198efe1 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -17,15 +17,20 @@ "state_migration": "automatic-forward-compatible" }, "deployment": { - "topologies": ["all-in-one", "split-tailscale"], - "split_roles": ["backend", "mcp"], + "topologies": [ + "all-in-one", + "split-tailscale" + ], + "split_roles": [ + "backend", + "mcp" + ], "linux_systemd": true, - "supervisors": ["systemd", "foreground", "podman-quadlet"] - }, - "sandbox": { - "default_managed_profile": "bubblewrap", - "supported_profiles": ["host", "auto", "bubblewrap", "systemd", "container", "vm"], - "network_default": "deny" + "supervisors": [ + "systemd", + "foreground", + "podman-quadlet" + ] }, "migrations": [ "MCP v2.1.3 makes cptr_open_live_workbench explicitly optional so normal ChatGPT Direct Coding starts with the task-relevant tool instead of a Workbench activation prerequisite.", @@ -35,6 +40,7 @@ "The owner-full control profile is the deployment default for fresh installs and upgrades, adding command:external plus confirmed managed-workspace deletion; standard and developer remain explicit opt-down profiles, and legacy full remains an owner-full alias.", "Git workspaces warm FDX automatically after provisioning; FDX failure remains a non-fatal fallback to normal CPTR Direct Coding.", "Existing CPTR data under ~/.cptr is preserved; Heidi takes a pre-upgrade backup before activation.", - "FDX protocol 2 remains required for persistent structured resident reads." + "FDX protocol 2 remains required for persistent structured resident reads.", + "Direct Coding executes natively on the authorized CPTR host under the selected control profile; the Bubblewrap/systemd/container/VM sandbox execution profiles and their configuration surface were removed." ] } diff --git a/scripts/install-core.sh b/scripts/install-core.sh index b064c077..92814fee 100755 --- a/scripts/install-core.sh +++ b/scripts/install-core.sh @@ -265,7 +265,6 @@ if [[ "$PUBLIC_DEPLOYMENT" == 1 ]]; then fi random_mcp_token -SANDBOX_PROFILE="${HEIDI_SANDBOX_PROFILE:-bubblewrap}" step "Writing owner-only configuration" if [[ "$INCLUDES_BACKEND" == 1 ]]; then @@ -276,7 +275,6 @@ if [[ "$INCLUDES_BACKEND" == 1 ]]; then env_line CPTR_FDX_REQUEST_TIMEOUT_SECONDS 20 env_line CPTR_FDX_DAEMON_IDLE_TTL_SECONDS 600 env_line CPTR_FDX_MAX_DAEMONS 8 - env_line CPTR_DIRECT_CODING_SANDBOX "$SANDBOX_PROFILE" env_line PATH "$HEIDI_HOME/current/venv/bin:$HEIDI_HOME/current/runtime/node/bin:$HEIDI_HOME/current/bin:$HOME/.local/bin:$HOME/.cargo/bin:/usr/local/bin:/usr/bin:/bin" env_line PYTHONUNBUFFERED 1 } >"$CPTR_ENV_FILE"; chmod 600 "$CPTR_ENV_FILE" @@ -454,7 +452,6 @@ HEIDI_SERVICE_UNITS="${HEIDI_SERVICE_UNITS% }" env_line HEIDI_PUBLIC_ORIGIN "$PUBLIC_ORIGIN" env_line HEIDI_MCP_URL "$MCP_URL" env_line HEIDI_CONTROL_PROFILE "$CONTROL_PROFILE" - env_line HEIDI_SANDBOX_PROFILE "$SANDBOX_PROFILE" env_line HEIDI_PUBLIC_TRANSPORT "$PUBLIC_TRANSPORT" env_line HEIDI_MCP_DOMAIN "$MCP_DOMAIN" env_line HEIDI_MCP_ALLOWED_EMAIL "$MCP_ALLOWED_EMAIL" diff --git a/scripts/install-lib.sh b/scripts/install-lib.sh index 0f36d327..fe1b0bce 100755 --- a/scripts/install-lib.sh +++ b/scripts/install-lib.sh @@ -67,11 +67,11 @@ PY } ensure_host_security_dependencies() { - local packages=(bubblewrap age) + local packages=(age) need_cmd setcap || packages+=(libcap2-bin) - if ! need_cmd bwrap || ! need_cmd age || ! need_cmd age-keygen || ! need_cmd setcap; then - step "Installing sandbox, encryption, and capability dependencies" - apt_install "${packages[@]}" || fail "bubblewrap, age, and libcap are required for the managed production profile" + if ! need_cmd age || ! need_cmd age-keygen || ! need_cmd setcap; then + step "Installing encryption and capability dependencies" + apt_install "${packages[@]}" || fail "age and libcap are required for the managed production profile" fi } diff --git a/scripts/verify-stack.sh b/scripts/verify-stack.sh index 597b580d..e56f02fc 100755 --- a/scripts/verify-stack.sh +++ b/scripts/verify-stack.sh @@ -188,27 +188,12 @@ check_mcp() { fi } -check_sandbox() { - [[ "$ROLE" == mcp ]] && return 0 - local profile="${HEIDI_SANDBOX_PROFILE:-bubblewrap}" - case "$profile" in - bubblewrap) - if command -v bwrap >/dev/null 2>&1 && bwrap --version >/dev/null 2>&1; then pass "Direct Coding bubblewrap sandbox available"; else fail_check dependency "Direct Coding sandbox" "bubblewrap is selected but bwrap is unavailable"; fi - ;; - host) pass "Direct Coding sandbox profile: host (explicit reduced isolation)" ;; - systemd) command -v systemd-run >/dev/null 2>&1 && pass "Direct Coding systemd-run sandbox available" || fail_check dependency "Direct Coding sandbox" "systemd-run unavailable" ;; - container) command -v podman >/dev/null 2>&1 && pass "Direct Coding Podman sandbox available" || fail_check dependency "Direct Coding sandbox" "podman unavailable" ;; - vm) [[ -n "${CPTR_DIRECT_CODING_VM_RUNNER:-}" ]] && pass "Direct Coding VM runner configured" || fail_check dependency "Direct Coding sandbox" "VM runner is not configured" ;; - *) fail_check compatibility "Direct Coding sandbox" "unsupported profile $profile" ;; - esac -} - check_tailscale check_compatibility case "$TOPOLOGY:$ROLE" in - split-tailscale:backend) check_backend; check_sandbox ;; + split-tailscale:backend) check_backend ;; split-tailscale:mcp) check_mcp ;; - *) check_backend; check_sandbox; check_mcp ;; + *) check_backend; check_mcp ;; esac if ((${#FAIL_LABELS[@]})); then diff --git a/tests/test_direct_coding_host_contract.py b/tests/test_direct_coding_host_contract.py new file mode 100644 index 00000000..089f514d --- /dev/null +++ b/tests/test_direct_coding_host_contract.py @@ -0,0 +1,53 @@ +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + +ACTIVE_SURFACES = ( + ROOT / "apps/cptr/cptr/utils/tools.py", + ROOT / "scripts/install-core.sh", + ROOT / "scripts/install-lib.sh", + ROOT / "scripts/verify-stack.sh", + ROOT / "release/compatibility.json", +) + +FORBIDDEN_DIRECT_CODING_SANDBOX_MARKERS = ( + "cptr.services.command_sandbox", + "CPTR_DIRECT_CODING_SANDBOX", + "HEIDI_SANDBOX_PROFILE", + "CPTR_DIRECT_CODING_CONTAINER_IMAGE", + "CPTR_DIRECT_CODING_VM_RUNNER", + "sandbox_profile", + "check_sandbox()", + "default_managed_profile", + '"bubblewrap"', + "bwrap", +) + + +def test_direct_coding_has_no_sandbox_implementation_or_configuration_surface() -> None: + implementation = ROOT / "apps/cptr/cptr/services/command_sandbox.py" + legacy_tests = ROOT / "apps/cptr/tests/test_command_sandbox.py" + + assert not implementation.exists(), "Direct Coding sandbox implementation must be removed" + assert not legacy_tests.exists(), "sandbox-specific CPTR tests must be removed" + + offenders: list[str] = [] + for path in ACTIVE_SURFACES: + text = path.read_text(encoding="utf-8") + for marker in FORBIDDEN_DIRECT_CODING_SANDBOX_MARKERS: + if marker in text: + offenders.append(f"{path.relative_to(ROOT)}: {marker}") + + assert not offenders, "active sandbox surface remains:\n" + "\n".join(offenders) + + +def test_compatibility_contract_has_no_direct_coding_sandbox_profile() -> None: + compatibility = json.loads((ROOT / "release/compatibility.json").read_text(encoding="utf-8")) + assert "sandbox" not in compatibility + + +def test_owner_full_remains_the_managed_default() -> None: + installer = (ROOT / "scripts/install-core.sh").read_text(encoding="utf-8") + assert "state_default HEIDI_CONTROL_PROFILE owner-full" in installer diff --git a/tests/test_installer_contract.py b/tests/test_installer_contract.py index b548c876..ca3bb29b 100644 --- a/tests/test_installer_contract.py +++ b/tests/test_installer_contract.py @@ -181,14 +181,15 @@ def test_bootstrap_revalidates_all_signed_source_files_before_reusing_release(): assert "target.is_symlink() or not target.is_dir()" in source -def test_compatibility_manifest_matches_canonical_runtime_inventory_and_sandbox(): +def test_compatibility_manifest_matches_canonical_runtime_inventory_and_host_native_direct_coding(): compatibility = json.loads(read("release/compatibility.json")) verifier = load_compatibility_verifier() result = verifier.verify(ROOT, compatibility["heidi_version"]) assert result["mcp_tool_count"] == compatibility["mcp"]["registered_action_count"] assert "cptr_workspace_lifecycle" in verifier.compact_tool_names(ROOT) assert compatibility["deployment"]["topologies"] == ["all-in-one", "split-tailscale"] - assert "sandbox" in compatibility + assert "sandbox" not in compatibility + assert any("executes natively on the authorized CPTR host" in item for item in compatibility["migrations"]) def test_installer_defaults_to_owner_full_profile():