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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ jobs:
python plugin/gateway-core/test/v2/execution_status_live_smoke.py
python plugin/gateway-core/test/v2/direct_shell_activation_live_smoke.py

- name: Measure native V2 interruption settlement
if: matrix.opencode == '2.2.5'
run: python scripts/gateway_v2_native_settlement_probe.py --opencode-bin "$(command -v opencode)"

windows-bounded-subprocess:
runs-on: windows-latest
steps:
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ OPENCODE_RESUME_E2E_VERSION := 1.18.18
help: ## Show available targets
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf "%-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST)

PYTHON_TARGETS := data-lifecycle-help validate build-agents build-agents-check build-agents-prune release-index-update docs-automation-summary-update docs-automation-check pages-readiness-check release-note-validation-check release-note-quality-check plan-hygiene-check wave-linkage-check wave-handoff-summary wave-completion-update quality-fast quality-strict quality-off quality-status tasker-production-live-smoke rolling-decomposition-live gateway-status gateway-enable gateway-disable gateway-doctor gateway-secret-redaction-smoke gateway-v2-dependency-probe gateway-v2-dependency-profile-probe gateway-v2-auxiliary-redaction-probe gateway-v2-execution-status-live-smoke gateway-v2-slice-smoke gateway-resume-redaction-e2e gateway-resume-redaction-e2e-prebuilt gateway-execution-status-live-smoke gateway-turn-watch gateway-turn-watch-webhook harness-wave2-task4-smoke notify-icons-generate notify-icons-select reservation-status task-lease-status selftest doctor doctor-json sqlite-doctor sqlite-doctor-json devtools-status hooks-install install-test install-test-full release-check release
PYTHON_TARGETS := data-lifecycle-help validate build-agents build-agents-check build-agents-prune release-index-update docs-automation-summary-update docs-automation-check pages-readiness-check release-note-validation-check release-note-quality-check plan-hygiene-check wave-linkage-check wave-handoff-summary wave-completion-update quality-fast quality-strict quality-off quality-status tasker-production-live-smoke rolling-decomposition-live gateway-status gateway-enable gateway-disable gateway-doctor gateway-secret-redaction-smoke gateway-v2-dependency-probe gateway-v2-dependency-profile-probe gateway-v2-auxiliary-redaction-probe gateway-v2-native-settlement-probe gateway-v2-execution-status-live-smoke gateway-v2-slice-smoke gateway-resume-redaction-e2e gateway-resume-redaction-e2e-prebuilt gateway-execution-status-live-smoke gateway-turn-watch gateway-turn-watch-webhook harness-wave2-task4-smoke notify-icons-generate notify-icons-select reservation-status task-lease-status selftest doctor doctor-json sqlite-doctor sqlite-doctor-json devtools-status hooks-install install-test install-test-full release-check release

$(PYTHON_TARGETS): python-check

Expand Down Expand Up @@ -118,6 +118,10 @@ gateway-v2-auxiliary-redaction-probe: python-check ## Measure four V2 request ki
npm --prefix plugin/gateway-core run build
$(PYTHON) scripts/gateway_v2_auxiliary_redaction_probe.py --opencode-bin "$(OPENCODE_BIN)"

.PHONY: gateway-v2-native-settlement-probe
gateway-v2-native-settlement-probe: python-check ## Measure V2 interruption and wait ordering without authority
$(PYTHON) scripts/gateway_v2_native_settlement_probe.py --opencode-bin "$(OPENCODE_BIN)"

.PHONY: gateway-v2-execution-status-live-smoke
gateway-v2-execution-status-live-smoke: python-check ## Test the experimental V2 status writer and sidebar
npm --prefix plugin/gateway-core run build
Expand Down
4 changes: 4 additions & 0 deletions docs/plan/doing/opencode-v2-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ An accepted interruption invalidates any earlier settlement wait: if that wait
succeeds, the adapter starts another before releasing quarantine. This local
ordering fence does not prove the host's `session.wait` identifies or settles a
particular interruption.
The [native settlement probe](../../specs/opencode-v2-native-settlement-probe.md)
measures two accepted interruptions and native waits in an isolated 2.2.5
runtime, using injected error events and deliberately delayed wait delivery. It
does not establish complete lifecycle or provider protection.
Interruption is session-wide and reports an interrupted outcome. On 2.2.5 and
2.2.6, isolated controls observed a stopped plain rejection, redacted feedback,
forged-cause containment, blocked retry, generate and compaction requests after
Expand Down
66 changes: 66 additions & 0 deletions docs/specs/opencode-v2-native-settlement-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Native V2 interruption settlement probe

Status: **measurement only**, not a security boundary or authorization. The
experimental adapter and V2 gateway remain unconfigured and unqualified.

## Run

With Python 3.11+, the existing built gateway files, and OpenCode **2.2.5**:

```sh
make gateway-v2-native-settlement-probe OPENCODE_BIN=/path/to/opencode
```

The target does not build or install dependencies. If the adapter source changed,
build `plugin/gateway-core` first. The script accepts `--temp-parent <directory>`
and emits one JSON report when invoked directly. Other executable versions fail
instead of inheriting these observations.

**Run within an independently verified OS sandbox when local data must be
preserved.** Private homes, channel credentials and identity checks do not stop
same-user interference or establish filesystem containment. Independently
verify filesystem-write, outbound-network and signal restrictions; this probe
is not itself a sandbox.

## What is measured

An authenticated private server loads only an owned fixture plugin alongside
built-ins and uses a synthetic localhost provider. The controller creates two
distinct sessions and verifies server PID before each API mutation. A fixture
pre-context hook holds the target execution. The fixture **injects** a synthetic
tool-error event into the registered adapter callback, but delegates its
`interrupt` and `wait` calls to the actual host session APIs.

After the first interruption is accepted and the first native wait returns, the
fixture deliberately holds that result before delivering it to the adapter. A
second target execution starts, then a second real interruption is accepted. A
separate peer session reaches the provider with a synthetic marker. The first
wait result is released; the probe requires a second native wait, checks the six
captured guard callbacks while its result is held, and checks release after that
result is delivered. The six guard checks are direct callback invocations, not
six independently observed native provider requests. Trace and controller
traffic use per-run authenticated localhost channels; reports omit session IDs,
credentials, request bodies and arbitrary native diagnostics.

The control intentionally delays wait delivery and injects errors. It does not
establish that this scheduling occurs naturally, that `session.wait` identifies
an interruption generation, or that the host settled independently running
shells, background jobs, another service, or another provider transport. It
does not test unload/restart, crash recovery, or every request kind and version.

## Retention and result limits

All case and version fixture roots are retained on success and failure. Reports
include their basenames, selected executable and adapter hashes, and checked
process/socket teardown. Retained data contains synthetic credentials, prompts
and runtime metadata; inspect it before sharing or cleaning it up. Teardown
does not prove no process escaped the checked group. Hash and fixture identity
checks do not prevent transient replacement or same-user races.

On 2026-11-15, a restricted tmux run of the selected 2.2.5 executable observed
two accepted native interruptions and the required two native waits; the peer
marker reached the local provider. A separate retained baseline control using
the adapter from commit `c52a8832` observed the old guard reopen after the
delayed first result and before a second wait. That baseline control is not part
of the reusable command. Both are bounded synthetic observations, not proof of
gateway protection or compatibility for other executables reporting 2.2.5.
60 changes: 44 additions & 16 deletions scripts/gateway_v2_auxiliary_redaction_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import tempfile
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -272,14 +273,15 @@ def fixture_plugin(root: Path, port: int, key: str, *, protected: bool) -> str:


def prepare_fixture(
root: Path, port: int, key: str, provider_key: str, *, protected: bool
root: Path, port: int, key: str, provider_key: str, *, protected: bool,
plugin_source: str | None = None,
) -> dict[str, Any]:
for name in ("home", "tmp", "bin"):
(root / name).mkdir(mode=0o700)
write_fixture(
root,
"project/owned/index.ts",
fixture_plugin(root, port, key, protected=protected),
plugin_source if plugin_source is not None else fixture_plugin(root, port, key, protected=protected),
)
rules = [{"action": "*", "resource": "*", "effect": "deny"}]
config = {
Expand Down Expand Up @@ -478,17 +480,40 @@ def all_kinds_seen() -> bool:
wait_until(all_kinds_seen, state, "provider_observation_incomplete")


def run_case(binary: str, parent: Path, mode: str) -> dict[str, Any]:
root = Path(tempfile.mkdtemp(prefix="v2-aux-redaction-", dir=parent))
identity = root.lstat()
state = CaptureState(mode, root, time.monotonic() + PRIMARY_SECONDS)
@dataclass(frozen=True)
class CaseHooks:
"""Owned fixture callbacks sharing the same bounded runtime and teardown."""

state: Callable[..., CaptureState]
provider: Callable[..., CaptureProvider]
prepare: Callable[..., dict[str, Any]]
exercise: Callable[..., None]
assess: Callable[..., dict[str, int]]
prefix: str = "v2-aux-redaction-"


def run_case(
binary: str, parent: Path, mode: str, *, hooks: CaseHooks | None = None,
) -> dict[str, Any]:
if hooks is None:
hooks = CaseHooks(CaptureState, CaptureProvider, prepare_fixture, exercise, assess)
if not hooks.prefix.startswith("v2-") or not hooks.prefix.endswith("-") or any(
char not in "abcdefghijklmnopqrstuvwxyz0123456789-" for char in hooks.prefix
) or len(hooks.prefix) > 64:
raise ProbeFailure("fixture_prefix_invalid")
root = Path(tempfile.mkdtemp(prefix=hooks.prefix, dir=parent))
report: dict[str, Any] = {"case": mode, "measurement": "FAIL", "retained_fixture": root.name}
try:
identity = root.lstat()
state = hooks.state(mode, root, time.monotonic() + PRIMARY_SECONDS)
except (ProbeFailure, OSError, ValueError, TypeError, RuntimeError):
report.update({"reason": "fixture_initialization_failed", "teardown_complete": False})
return report
provider: CaptureProvider | None = None
provider_thread: threading.Thread | None = None
provider_started = False
runtime: PrivateRuntime | None = None
completed = False
report: dict[str, Any] = {"case": mode, "measurement": "FAIL"}

def expire() -> None:
state.failure = state.failure or "case_timeout"
state.stop()
Expand All @@ -501,13 +526,13 @@ def expire() -> None:
watchdog.daemon = True
try:
watchdog.start()
provider = CaptureProvider(state)
provider = hooks.provider(state)
provider_thread = threading.Thread(
target=provider.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True
)
provider_thread.start()
provider_started = True
config = prepare_fixture(
config = hooks.prepare(
root,
provider.server_port,
state.control_key,
Expand All @@ -516,7 +541,7 @@ def expire() -> None:
)
runtime = PrivateRuntime(binary, root, state.deadline)
runtime.start()
exercise(runtime, root, config, state)
hooks.exercise(runtime, root, config, state)
completed = True
except (
ProbeFailure,
Expand All @@ -541,18 +566,21 @@ def expire() -> None:
try:
if provider_started:
provider.shutdown()
provider.close_connections()
except OSError:
except (OSError, RuntimeError):
cleanup_errors.append("provider-stop")
try:
provider.close_connections()
except (OSError, RuntimeError):
cleanup_errors.append("provider-connections")
if runtime is not None:
try:
runtime.close(deadline)
except (ProbeFailure, OSError, subprocess.SubprocessError):
except (ProbeFailure, OSError, RuntimeError, subprocess.SubprocessError):
cleanup_errors.append("runtime-stop")
if provider is not None:
try:
provider.server_close()
except OSError:
except (OSError, RuntimeError):
cleanup_errors.append("provider-close")
for worker in provider.workers:
worker.join(max(0, deadline - time.monotonic()))
Expand All @@ -574,7 +602,7 @@ def expire() -> None:
state.observations
):
raise ProbeFailure("provider_response_incomplete")
counts = assess(mode, state.trace_rows, state.observations)
counts = hooks.assess(mode, state.trace_rows, state.observations)
report.update(
{"measurement": "PASS", "provider_requests_by_kind": counts}
)
Expand Down
Loading
Loading