From 1e3a1c94ea0c74f6ca448d1460ba67d187c823bb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 28 Sep 2026 06:08:40 +0000 Subject: [PATCH 1/8] docs(codex): propose native remote-list provider policy with isolated probes Publish the #5848 implementation alternatives and executable specifications. Prefer an opt-in native remote-only list policy over a shared-backend relay. Keep the existing runtime, authentication, routing and history untouched. Validation: 60 isolated Python tests passed; native/Bun/mobile validation remains outstanding. This is an RFC and research unit, not a production fix for #5848. --- .../000_plan.md | 32 +++ .../010_design.md | 134 ++++++++++ .../020_verification.md | 50 ++++ .../probes/native_policy.py | 70 ++++++ .../probes/remote_list_probe.py | 160 ++++++++++++ .../probes/test_loopback_bridge.py | 228 ++++++++++++++++++ .../probes/test_native_policy.py | 153 ++++++++++++ .../probes/test_probe.py | 196 +++++++++++++++ 8 files changed, 1023 insertions(+) create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/000_plan.md create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/010_design.md create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/020_verification.md create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/remote_list_probe.py create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py diff --git a/devlog/_plan/260928_remote_thread_provider_policy/000_plan.md b/devlog/_plan/260928_remote_thread_provider_policy/000_plan.md new file mode 100644 index 00000000000..530fa2bd582 --- /dev/null +++ b/devlog/_plan/260928_remote_thread_provider_policy/000_plan.md @@ -0,0 +1,32 @@ +# 5848: native remote-list provider policy before a backend relay + +Status: **proposal and executable specifications only; no production fix**. +Date: 2026-09-28. + +Related: [#5848](https://github.com/lidge-jun/opencodex/issues/5848), +[duplicate #5906](https://github.com/lidge-jun/opencodex/issues/5906), +[upstream #48358](https://github.com/openai/codex/issues/48358). + +## Proposed decision + +Prefer a narrowly scoped, operator-opt-in policy in **native Codex app-server** +for remote `thread/list` requests that do not supply a provider array. Keep the +normal default-provider behavior, all existing authorization, and history intact. +The mobile client's explicit all-provider request remains the simplest upstream +client correction. Compare both with the previously tested local backend relay; +do not turn that experimental relay into an installed feature in this PR. + +The current OpenCodex runtime and [ADR-5848](../../../structure/decisions/ADR-5848-provider-table-remote-history-visibility.md) +remain unchanged. This proposal does **not** close #5848. + +## Work remaining + +- [x] Identify the native remote-connection boundary and existing provider predicate. +- [x] Specify precedence and test the native-policy proposal offline. +- [x] Retain and rerun the isolated loopback relay alternative. +- [ ] Obtain upstream agreement on the native configuration/API contract. +- [ ] Implement config/schema, trusted-origin plumbing, and Rust regressions upstream. +- [ ] Verify native pagination, managed policy, account changes, and actual mobile resume. +- [ ] Establish released-version/capability detection before adding any OpenCodex toggle. + +See [design](010_design.md) and [verification](020_verification.md). diff --git a/devlog/_plan/260928_remote_thread_provider_policy/010_design.md b/devlog/_plan/260928_remote_thread_provider_policy/010_design.md new file mode 100644 index 00000000000..a0f8ca22474 --- /dev/null +++ b/devlog/_plan/260928_remote_thread_provider_policy/010_design.md @@ -0,0 +1,134 @@ +# Design comparison and proposed native contract + +This is an RFC, not an accepted architecture change or a released setting. +`probes/native_policy.py` is an executable specification, **not native Rust code**. +No probe is imported by OpenCodex or registered in its runtime/CLI/test suite. + +## Evidence and scope + +The source baseline is OpenAI Codex +`1cc7e2361237ce7244430ee1d581c77f95c57ac8` and OpenCodex `dev` +`eb7f0f0970c2298f8b2d66d170c4d4be869f301b`. + +- [Native list predicate](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server/src/request_processors/thread_processor.rs#L5456-L5530): explicit nonempty arrays filter to those ids; `[]` removes the provider predicate; omission defaults to the configured provider, except for related-thread queries. +- [Remote connection origin](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs): native remote sessions open with `ConnectionOrigin::RemoteControl`; messages become native transport events. +- [Original rationale, #5658](https://github.com/openai/codex/pull/5658): provider annotations/filtering address cross-provider resume/decryption failures. Visibility is not proof that a conversation is safe to resume under a different provider. +- [OpenCodex mitigation, #6007](https://github.com/lidge-jun/opencodex/pull/6007): warnings and documentation without rewriting native history or intercepting native RPC. + +## Alternatives + +| Approach | Can leave official mobile app unchanged? | Additional ownership | Recommendation | +| --- | --- | --- | --- | +| Mobile explicitly sends `modelProviders: []` (or a chosen list) | No | Client's list behavior and resume UX | Simplest client correction; continue upstream tracking. | +| Native, opt-in, remote-only default list policy | Yes, once a compatible native build ships | Native config and request policy, no extra service | **Preferred implementation proposal** when the host needs explicit control. | +| Local relay selected through `chatgpt_base_url` | Potentially; live service not verified | Shared backend traffic, token forwarding, connection lifecycle | Research fallback, not a default or currently supported remedy. | +| Globally change omitted filter to all providers | Yes | Changes behavior for every caller | Do not use: loses the existing default isolation behavior. | +| Rewrite `openai` history tags to `opencodex` | Yes in some observations | Native SQLite/rollout state | Do not use: violates the retained history-writer boundary. | + +The native proposal minimizes new connection and credential handling; this is an +engineering recommendation, not evidence of upstream acceptance or deployment. +Neither upstream route can be delivered by modifying OpenCodex's `/v1` inference +proxy alone. This PR publishes the comparison rather than disguising a probe as a fix. + +## Proposed native setting (name subject to upstream review) + +**Illustrative only; do not add this to current Codex/OpenCodex configuration.** + +```toml +[remote_control] +thread_list_model_providers = ["openai", "opencodex"] +``` + +Absent setting means opt-out. An explicitly empty array means all providers; +a nonempty array means exactly those ids. Do not automatically infer equivalence +from provider names, model names, or a shared URL. The setting changes listing, +not authentication, permission checks, routing, compaction, or resume semantics. +An operator who only needs two provider ids need not opt into every provider. + +### Precedence + +1. Existing authentication and managed remote-control policy still run first. +2. A request's explicit provider **array**, including `[]`, always wins. +3. With no array, parent/ancestor queries retain their current no-default-filter behavior. +4. Only a server-identified remote connection may use the operator's configured policy. +5. With no policy, and for every non-remote connection, preserve the existing default. + +Typed Rust `Option>` treats omission and JSON `null` alike. This +proposal deliberately applies its default to both. The raw relay probe preserves +all present JSON keys, including null; that difference is documented and must not +be mistaken for identical behavior between the two alternatives. + +Use the native `ConnectionOrigin` attached to the connection. Never infer remote +origin from clientInfo.name, a user-agent, JSON fields, or a caller-supplied header. +The Python enum tests only model this trusted input; they do not prove authentication. + +### Native implementation boundary + +The upstream change must add and validate the config contract, snapshot it with +the native app-server's effective configuration, carry the trusted connection +origin to the `thread/list` handler, and resolve the provider predicate before +calling the existing store pagination. A new RPC layer is unnecessary. + +Conceptual selection, **not a drop-in patch**: + +```text +if request supplies provider array: use that array +else if parent/ancestor query: use existing related-thread behavior +else if trusted origin is RemoteControl and operator policy is configured: + use the configured array +else: use existing configured-default behavior +``` + +Do not add a global exception to `list_threads_common` for all callers. Do not +change `thread/start`, `thread/resume`, `turn/start`, stored provider metadata, +or request error handling. Keep source/cwd/archive/project filters and ordering. +The new configuration's trust/precedence should prevent project content from +silently opting an operator into a broader default; upstream must choose and test +the appropriate configuration layers and any managed restrictions. + +Hold one effective policy throughout a listing/pagination sequence. A changed +policy requires a fresh listing cursor; do not combine pages obtained under +incompatible filters. Native tests must pin behavior with actual cursor semantics. +The fixture tests here use integer pagination, not native opaque cursors. + +## Local relay alternative: what was and was not shown + +[Native startup](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server/src/lib.rs) +uses `config.chatgpt_base_url` for remote control. The +[URL protocol](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs) +accepts loopback URLs. A host-side relay is therefore a concrete design alternative: + +```text +unchanged mobile <-> existing ChatGPT backend <-> local relay <-> native app-server +``` + +Connection establishment starts on the host. Enroll/pair/refresh can be forwarded +rather than reimplemented. However, `chatgpt_base_url` is shared with +[authentication configuration](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/core/src/config/auth_keyring.rs) +and other backend consumers. A WebSocket-only implementation is insufficient. +[Enrollment persistence](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs) +also keys state by URL/account/client, so changing the URL can affect enrollment +selection; whether live re-pairing is required remains unverified. + +The retained relay fixture accepts only literal `127.0.0.1` HTTP upstreams and +synthetic credentials. It is not a production service and cannot be configured +for ChatGPT. It handles regular and single-chunk frames; multi-chunk messages are +passed unchanged. Actual protocol-v3 segmentation, native reconnect/ACK/cursors, +account changes, managed network policy, and full backend compatibility remain +release gates, not claims supported by these tests. See native +[WebSocket handling](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs). + +No global base-URL mutation, token-pool integration, production listener, +configuration migration, history write, or authentication bypass is proposed here. + +## Upstream and downstream follow-through + +An upstream implementation needs Rust config/schema, origin-scoping, explicit/null/ +related-query, pagination, and managed-policy tests. Keep existing defaults intact. +Document the omitted/default behavior and regenerate affected schema/TS fixtures. + +After an accepted implementation is released, OpenCodex may consider an opt-in +integration with positive capability/version evidence and precise configuration +ownership/restoration. Merely writing an unknown TOML key is not a feature test. +Until then, retain #6007's warnings and keep #5848 open. Do not suppress the warning +because an experimental setting was written or a fixture passed. diff --git a/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md b/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md new file mode 100644 index 00000000000..76564b99534 --- /dev/null +++ b/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md @@ -0,0 +1,50 @@ +# Verification + +Date: 2026-09-28. Environment: Python 3.13.5, aiohttp 3.13.3, Linux. +All inputs, accounts, tokens, thread ids, and database rows are synthetic. +No native Codex process, user home, installed config, or real service was accessed. + +From the repository root: + +```sh +cd devlog/_plan/260928_remote_thread_provider_policy/probes +python -m unittest -v test_native_policy test_probe test_loopback_bridge +``` + +**Executed: 60 tests, 0 failures.** + +- 22 tests specify the proposed native policy: opt-out, trusted-origin scoping, + explicit arrays, null semantics, parent/ancestor exceptions, immutable policy, + exact ids, validation, and synthetic paginated fixture reads. +- 29 retained raw-frame tests cover the relay alternative's ordinary/single-chunk + rewrites, byte-identical pass-through, limits, and unsupported inputs. +- 9 retained localhost HTTP/WebSocket tests use a mock host and mock backend, + checking two-way traffic, fixture auth/headers, explicit filters, enrollment, + unrelated HTTP, and fixture endpoint restrictions. + +The 22 + 29 offline tests use the Python standard library only. The 9 socket tests +need aiohttp already installed; no production dependency manifest is changed. + +```sh +python -m unittest -v test_native_policy test_probe +``` + +The synthetic database has 5,200 `openai` rows, one `opencodex` row, and one `other` +row. Default filtering yields one; the two-id opt-in yields 5,201; all providers +yields 5,202. Page sizes 1, 37, and 1,000 are exercised. The fixture dump hash stays +unchanged after reads. This demonstrates the specification, not native SQLite +schema compatibility or actual mobile results. + +## Not executed / not established + +- Native Rust implementation, compilation, config/schema generation, or native tests. +- Actual ChatGPT mobile pairing, full pagination, resume, token renewal, or reconnect. +- OpenCodex Bun typecheck, full suite, structure gate, or repository privacy gate: + Bun and a full checkout are unavailable in the execution environment. A git + checkout attempt failed at DNS resolution. Focused Python tests are not a + substitute for required repository gates; the PR must remain draft. +- Production multi-segment relay support or management/security review. + +The PR adds only this research unit, not a runtime fix. No workflow, executable +configuration, release artifact, or native storage is changed. Required exact-head +CI and independent review remain outstanding even if these probes pass. diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py new file mode 100644 index 00000000000..d010f74df61 --- /dev/null +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py @@ -0,0 +1,70 @@ +"""Executable specification for a PROPOSED native Codex policy, not an integration. + +No socket, authentication, config, history, or rollout access. The real Rust +implementation must obtain origin from its authenticated server-side connection, +not from JSON, clientInfo.name, or an HTTP header. This enum only models that input. +None as the result means no provider predicate, not no results. +""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class ConnectionOrigin(Enum): + STDIO = "stdio" + WEBSOCKET = "websocket" + IN_PROCESS = "in_process" + REMOTE_CONTROL = "remote_control" + + +def _validate_ids(value: tuple[str, ...], label: str) -> None: + if not isinstance(value, tuple): + raise ValueError(f"{label} must be a tuple") + if any(not isinstance(item, str) or not item.strip() for item in value): + raise ValueError(f"{label} must contain non-empty strings") + if len(set(value)) != len(value): + raise ValueError(f"{label} must not contain duplicate identifiers") + + +@dataclass(frozen=True) +class RemoteListPolicy: + # None: opt-out; (): explicitly all; non-empty: exactly these provider ids. + providers: tuple[str, ...] | None = None + + def __post_init__(self) -> None: + if self.providers is not None: + _validate_ids(self.providers, "providers") + + +def resolve_provider_filter( + *, + origin: ConnectionOrigin, + default_provider: str, + requested: tuple[str, ...] | None = None, + parent_thread_id: str | None = None, + ancestor_thread_id: str | None = None, + policy: RemoteListPolicy = RemoteListPolicy(), +) -> tuple[str, ...] | None: + """Model precedence after typed request decoding and existing authorization. + + Rust Option> decodes both omission and JSON null as None. They + intentionally have the same semantics in this native-policy proposal. The + separate raw-frame relay probe instead preserves every present JSON key. + Empty requested tuple is an explicit client request for all providers. + The policy applies only to thread/list; callers must not reuse it for resume. + """ + if not isinstance(origin, ConnectionOrigin): + raise ValueError("origin must be supplied by the trusted connection context") + _validate_ids((default_provider,), "default_provider") + if requested is not None: + # Preserve every typed client array, including duplicate/empty ids. + # Native Vec accepts them; this proposal must not add rejection. + if not isinstance(requested, tuple) or any(not isinstance(item, str) for item in requested): + raise ValueError("requested must model a typed string array") + return requested or None + if parent_thread_id is not None or ancestor_thread_id is not None: + return None + if origin is ConnectionOrigin.REMOTE_CONTROL and policy.providers is not None: + return policy.providers or None + return (default_provider,) diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/remote_list_probe.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/remote_list_probe.py new file mode 100644 index 00000000000..a2622cdf18e --- /dev/null +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/remote_list_probe.py @@ -0,0 +1,160 @@ +"""Offline research probe; NOT an installed OpenCodex feature or production proxy. + +Based on openai/codex commit 1cc7e2361237ce7244430ee1d581c77f95c57ac8. +Only changes omitted thread/list.modelProviders in explicit opt-in mode. +No network, authentication, filesystem, database, or rollout writes occur here. +Complete multi-segment messages need a separate bounded streaming reassembler; +this probe deliberately passes those messages through unchanged. +""" +from __future__ import annotations + +import base64 +import binascii +import json +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class Policy: + enabled: bool = False + # An empty tuple explicitly requests all providers, matching modelProviders: []. + providers: tuple[str, ...] = ("openai", "opencodex") + max_wire_bytes: int = 150 * 1024 + + def __post_init__(self) -> None: + if not isinstance(self.providers, tuple) or any( + not isinstance(p, str) or not p.strip() for p in self.providers + ): + raise ValueError("providers must be a tuple of non-empty strings") + if len(set(self.providers)) != len(self.providers): + raise ValueError("duplicate provider identifiers") + if self.max_wire_bytes < 64: + raise ValueError("max_wire_bytes must be at least 64") + + +@dataclass(frozen=True) +class Outcome: + text: str + changed: bool + reason: str + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON member") + result[key] = value + return result + + +def _reject_constant(value: str) -> None: + raise ValueError("non-standard JSON constant") + + +def decode(text: str | bytes) -> Any: + return json.loads(text, object_pairs_hook=_unique_object, + parse_constant=_reject_constant) + + +def encode(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), + allow_nan=False) + + +def _patch_message(message: Any, policy: Policy) -> tuple[bool, str]: + if not isinstance(message, dict) or message.get("method") != "thread/list": + return False, "not_thread_list" + request_id = message.get("id") + if isinstance(request_id, bool) or not isinstance(request_id, (int, str)): + return False, "not_request" + if "result" in message or "error" in message: + return False, "ambiguous_request" + params = message.get("params", {}) + if not isinstance(params, dict): + return False, "non_object_params" + # Preserve explicit client choices, including an explicit null. + if "modelProviders" in params: + return False, "explicit_filter_preserved" + # Native related-thread queries deliberately bypass the omitted-provider default. + if params.get("parentThreadId") is not None or params.get("ancestorThreadId") is not None: + return False, "relation_query_preserved" + params["modelProviders"] = list(policy.providers) + message["params"] = params + return True, "omitted_filter_patched" + + +def rewrite_backend_frame(text: str, policy: Policy) -> Outcome: + """Process ONE backend-to-host text frame. No authentication decision is made. + + The caller must already enforce the authorized connection and user opt-in. + Unmodified and unsupported messages are returned byte-for-byte unchanged. + """ + if not isinstance(text, str): + raise TypeError("text frame must be str") + if not policy.enabled: + return Outcome(text, False, "disabled") + try: + if len(text.encode("utf-8")) > policy.max_wire_bytes: + return Outcome(text, False, "input_size_limit") + envelope = decode(text) + if not isinstance(envelope, dict): + return Outcome(text, False, "not_envelope") + if not isinstance(envelope.get("client_id"), str) or not envelope["client_id"]: + return Outcome(text, False, "missing_client_id") + kind = envelope.get("type") + if kind == "client_message": + changed, reason = _patch_message(envelope.get("message"), policy) + elif kind == "client_message_chunk": + if type(envelope.get("segment_count")) is not int or type(envelope.get("segment_id")) is not int: + return Outcome(text, False, "invalid_segment_index") + if envelope["segment_count"] != 1 or envelope["segment_id"] != 0: + return Outcome(text, False, "multi_segment_not_implemented") + size = envelope.get("message_size_bytes") + if type(size) is not int or size < 0 or size > policy.max_wire_bytes: + return Outcome(text, False, "invalid_segment_size") + payload_b64 = envelope.get("message_chunk_base64") + if not isinstance(payload_b64, str): + return Outcome(text, False, "invalid_segment_payload") + payload = base64.b64decode(payload_b64, validate=True) + if len(payload) != size: + return Outcome(text, False, "segment_length_mismatch") + message = decode(payload) + changed, reason = _patch_message(message, policy) + if changed: + encoded_payload = encode(message).encode("utf-8") + envelope["message_size_bytes"] = len(encoded_payload) + envelope["message_chunk_base64"] = base64.b64encode(encoded_payload).decode("ascii") + else: + return Outcome(text, False, "unrelated_envelope") + if not changed: + return Outcome(text, False, reason) + output = encode(envelope) + if len(output.encode("utf-8")) > policy.max_wire_bytes: + return Outcome(text, False, "output_size_limit") + return Outcome(output, True, reason) + except (ValueError, TypeError, UnicodeError, binascii.Error, RecursionError): + return Outcome(text, False, "invalid_frame_preserved") + + +def make_envelope(message: dict[str, Any], *, chunk: bool = False) -> dict[str, Any]: + """Synthetic fixture builder; all identifiers below are test-only.""" + envelope: dict[str, Any] = { + "client_id": "mock-client", "stream_id": "mock-stream", + "seq_id": 7, "cursor": "mock-backend-cursor", + } + if chunk: + payload = encode(message).encode("utf-8") + envelope.update(type="client_message_chunk", segment_id=0, segment_count=1, + message_size_bytes=len(payload), + message_chunk_base64=base64.b64encode(payload).decode("ascii")) + else: + envelope.update(type="client_message", message=message) + return envelope + + +def extract_message(envelope: dict[str, Any]) -> dict[str, Any]: + if envelope["type"] == "client_message": + return envelope["message"] + return decode(base64.b64decode(envelope["message_chunk_base64"], validate=True)) diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py new file mode 100644 index 00000000000..1d2c840c095 --- /dev/null +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py @@ -0,0 +1,228 @@ +"""Loopback-only mock transport proof. NEVER connects to ChatGPT/OpenAI. + +This is a test fixture, not a production proxy. Native Codex is not executed. +The mock backend validates a hard-coded, non-secret fixture token. +""" +from __future__ import annotations +import asyncio +import contextlib +import unittest +from urllib.parse import urlsplit + +import aiohttp +from aiohttp import web +from remote_list_probe import Policy, decode, encode, make_envelope, extract_message, rewrite_backend_frame + +BASE = "/backend-api" +WS_PATH = BASE + "/wham/remote/control/server" +MOCK_AUTH = "Bearer NOT-A-REAL-TOKEN-MOCK-ONLY" + + +def assert_loopback_only(base: str) -> None: + url = urlsplit(base) + if url.scheme != "http" or url.hostname != "127.0.0.1" or not url.port or url.username or url.password: + raise ValueError("this research fixture permits only HTTP at literal 127.0.0.1") + if url.query or url.fragment or url.path: + raise ValueError("mock base must contain only origin") + + +def selected_headers(headers) -> dict[str, str]: + return {key: value for key, value in headers.items() + if key.lower() in ("authorization", "chatgpt-account-id", "content-type") + or key.lower().startswith("x-codex-")} + + +class LoopbackFixtureRelay: + def __init__(self, mock_backend: str, session: aiohttp.ClientSession, policy: Policy): + assert_loopback_only(mock_backend) + self.backend = mock_backend + self.session = session + self.policy = policy + + async def handle(self, request: web.Request) -> web.StreamResponse: + # Test-only origin/host guard. No remote bind and no arbitrary upstream selection. + if request.headers.get("Origin"): + return web.Response(status=403, text="browser-origin-blocked") + if not request.host.startswith("127.0.0.1:"): + return web.Response(status=403, text="unexpected-host") + if request.path == WS_PATH and request.headers.get("Upgrade", "").lower() == "websocket": + return await self.handle_websocket(request) + target = self.backend + request.raw_path + async with self.session.request(request.method, target, + data=await request.read(), + headers=selected_headers(request.headers), + allow_redirects=False) as response: + body = await response.read() + headers = {key: value for key, value in response.headers.items() + if key.lower() in ("content-type", "x-request-id", "retry-after")} + return web.Response(status=response.status, body=body, headers=headers) + + async def handle_websocket(self, request: web.Request) -> web.StreamResponse: + target = self.backend + request.raw_path + try: + upstream = await self.session.ws_connect(target, + headers=selected_headers(request.headers), autoping=False) + except aiohttp.WSServerHandshakeError as exc: + return web.Response(status=exc.status, text="mock-upstream-rejected") + downstream = web.WebSocketResponse(autoping=False) + await downstream.prepare(request) + + async def pump(source, destination, transform: bool): + async for message in source: + if message.type == aiohttp.WSMsgType.TEXT: + text = rewrite_backend_frame(message.data, self.policy).text if transform else message.data + await destination.send_str(text) + elif message.type == aiohttp.WSMsgType.BINARY: + await destination.send_bytes(message.data) + elif message.type == aiohttp.WSMsgType.PING: + await destination.ping(message.data) + elif message.type == aiohttp.WSMsgType.PONG: + await destination.pong(message.data) + else: + break + + tasks = [asyncio.create_task(pump(upstream, downstream, True)), + asyncio.create_task(pump(downstream, upstream, False))] + try: + done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in done: + task.result() + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await upstream.close() + await downstream.close() + return downstream + + +async def start_loopback_app(app: web.Application): + runner = web.AppRunner(app, access_log=None) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = runner.addresses[0][1] + return runner, f"http://127.0.0.1:{port}" + + +class LoopbackBridgeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.frames = [] + self.received = asyncio.Queue() + self.handshake_headers = {} + self.http_received = [] + self.backend_app = web.Application() + self.backend_app.router.add_route("*", "/backend-api/{tail:.*}", self.mock_backend) + self.backend_runner, self.backend_base = await start_loopback_app(self.backend_app) + self.relay_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=5)) + self.relay = LoopbackFixtureRelay(self.backend_base, self.relay_session, Policy(enabled=True)) + relay_app = web.Application() + relay_app.router.add_route("*", "/backend-api/{tail:.*}", self.relay.handle) + self.relay_runner, self.relay_base = await start_loopback_app(relay_app) + self.host = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=5)) + + async def asyncTearDown(self): + await self.host.close() + await self.relay_runner.cleanup() + await self.relay_session.close() + await self.backend_runner.cleanup() + + async def mock_backend(self, request: web.Request): + if request.headers.get("Authorization") != MOCK_AUTH: + return web.Response(status=401, text="mock-auth-required") + if request.path == WS_PATH and request.headers.get("Upgrade", "").lower() == "websocket": + self.handshake_headers = dict(request.headers) + websocket = web.WebSocketResponse(autoping=False) + await websocket.prepare(request) + for frame in self.frames: + await websocket.send_str(frame) + async for message in websocket: + if message.type == aiohttp.WSMsgType.TEXT: + await self.received.put(message.data) + elif message.type == aiohttp.WSMsgType.PING: + await websocket.pong(message.data) + return websocket + body = await request.read() + self.http_received.append((request.path, body, selected_headers(request.headers))) + if request.path.endswith("/enroll"): + return web.json_response({"server_id": "mock-server", "environment_id": "mock-env", + "remote_control_token": "MOCK-ONLY", "expires_at": "2099-01-01T00:00:00Z"}, + status=201, headers={"x-request-id": "mock-http-request"}) + return web.Response(status=200, body=b"mock-unrelated-backend-endpoint") + + def add_request(self, params=None, *, chunk=False): + request = {"id": 42, "method": "thread/list", "params": params or {"limit": 20}} + self.frames.append(encode(make_envelope(request, chunk=chunk))) + + async def receive_host_frame(self, headers=None): + async with self.host.ws_connect(self.relay_base + WS_PATH, + headers=headers or {"Authorization": MOCK_AUTH}) as host: + message = await asyncio.wait_for(host.receive(), 2) + self.assertEqual(message.type, aiohttp.WSMsgType.TEXT) + return message.data + + async def test_backend_to_host_rewrite_and_host_reply_passthrough(self): + self.add_request() + async with self.host.ws_connect(self.relay_base + WS_PATH, + headers={"Authorization": MOCK_AUTH}) as host: + incoming = await asyncio.wait_for(host.receive(), 2) + envelope = decode(incoming.data) + self.assertEqual(extract_message(envelope)["params"]["modelProviders"], ["openai", "opencodex"]) + response = '{ "type":"server_message", "client_id":"mock-client", "stream_id":"mock-stream", "seq_id":9, "message":{"id":42,"result":{"data":[]}} }' + await host.send_str(response) + self.assertEqual(await asyncio.wait_for(self.received.get(), 2), response) + + async def test_auth_and_protocol_handshake_headers_preserved(self): + self.add_request() + headers = {"Authorization": MOCK_AUTH, "chatgpt-account-id": "mock-account", + "x-codex-protocol-version": "3", "x-codex-server-id": "mock-server", + "x-codex-installation-id": "mock-installation", "x-codex-subscribe-cursor": "mock-resume-cursor"} + await self.receive_host_frame(headers) + actual = {k.lower(): v for k, v in self.handshake_headers.items()} + for name, value in headers.items(): + self.assertEqual(actual[name.lower()], value) + + async def test_invalid_auth_stays_denied(self): + with self.assertRaises(aiohttp.WSServerHandshakeError) as ctx: + await self.host.ws_connect(self.relay_base + WS_PATH, + headers={"Authorization": "Bearer WRONG-MOCK"}) + self.assertEqual(ctx.exception.status, 401) + + async def test_explicit_filter_frame_is_byte_identical(self): + self.add_request({"modelProviders": ["other"], "limit": 20}) + self.assertEqual(await self.receive_host_frame(), self.frames[0]) + + async def test_single_chunk_transport(self): + self.add_request(chunk=True) + incoming = decode(await self.receive_host_frame()) + self.assertEqual(extract_message(incoming)["params"]["modelProviders"], ["openai", "opencodex"]) + self.assertEqual(incoming["seq_id"], 7) + self.assertEqual(incoming["cursor"], "mock-backend-cursor") + + async def test_enrollment_http_forwarding_with_fake_credentials(self): + body = b'{"name":"mock-host","installation_id":"mock-installation"}' + async with self.host.post(self.relay_base + WS_PATH + "/enroll", data=body, + headers={"Authorization": MOCK_AUTH, "chatgpt-account-id": "mock-account"}) as response: + self.assertEqual(response.status, 201) + self.assertEqual(response.headers["x-request-id"], "mock-http-request") + self.assertEqual((await response.json())["server_id"], "mock-server") + self.assertEqual(self.http_received[0][1], body) + + async def test_unrelated_http_backend_endpoint_forwarded(self): + async with self.host.get(self.relay_base + BASE + "/mock/account", headers={"Authorization": MOCK_AUTH}) as response: + self.assertEqual(response.status, 200) + self.assertEqual(await response.read(), b"mock-unrelated-backend-endpoint") + + async def test_cross_origin_browser_request_denied(self): + async with self.host.get(self.relay_base + BASE + "/mock/account", headers={"Authorization": MOCK_AUTH, "Origin": "https://example.invalid"}) as response: + self.assertEqual(response.status, 403) + self.assertFalse(self.http_received) + + async def test_fixture_rejects_real_service_upstream(self): + for base in ("https://chatgpt.com", "http://example.invalid", "http://0.0.0.0:1234"): + with self.subTest(base=base): + with self.assertRaises(ValueError): + LoopbackFixtureRelay(base, self.relay_session, Policy(enabled=True)) + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py new file mode 100644 index 00000000000..e2a9ccf4079 --- /dev/null +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py @@ -0,0 +1,153 @@ +"""Offline contract tests. This does NOT run Rust, native Codex, or a mobile app.""" +from __future__ import annotations + +import hashlib +import json +import sqlite3 +import unittest +from dataclasses import FrozenInstanceError +from native_policy import ConnectionOrigin as Origin, RemoteListPolicy, resolve_provider_filter + + +class NativePolicyTests(unittest.TestCase): + def resolve(self, **kwargs): + defaults = {"origin": Origin.REMOTE_CONTROL, "default_provider": "opencodex"} + defaults.update(kwargs) + return resolve_provider_filter(**defaults) + + def test_opt_out_keeps_existing_default_for_every_origin(self): + for origin in Origin: + with self.subTest(origin=origin): + self.assertEqual(self.resolve(origin=origin), ("opencodex",)) + + def test_only_native_remote_origin_uses_opt_in(self): + policy = RemoteListPolicy(("openai", "opencodex")) + for origin in Origin: + with self.subTest(origin=origin): + expected = policy.providers if origin is Origin.REMOTE_CONTROL else ("opencodex",) + self.assertEqual(self.resolve(origin=origin, policy=policy), expected) + + def test_explicit_nonempty_filter_wins_over_every_policy(self): + for policy in (RemoteListPolicy(), RemoteListPolicy(()), RemoteListPolicy(("openai",))): + for origin in Origin: + with self.subTest(policy=policy, origin=origin): + for requested in (("other",), ("other", "other"), ("",), (" other ",)): + self.assertEqual(self.resolve(origin=origin, requested=requested, policy=policy), requested) + + def test_explicit_empty_filter_is_all_for_every_origin(self): + for origin in Origin: + with self.subTest(origin=origin): + self.assertIsNone(self.resolve(origin=origin, requested=(), policy=RemoteListPolicy(("openai",)))) + + def test_opt_in_all_is_not_the_default(self): + self.assertIsNone(self.resolve(policy=RemoteListPolicy(()))) + self.assertEqual(self.resolve(), ("opencodex",)) + + def test_parent_query_keeps_native_no_default_filter(self): + for policy in (RemoteListPolicy(), RemoteListPolicy(("opencodex",))): + self.assertIsNone(self.resolve(parent_thread_id="fixture-parent", policy=policy)) + + def test_ancestor_query_keeps_native_no_default_filter(self): + for policy in (RemoteListPolicy(), RemoteListPolicy(("opencodex",))): + self.assertIsNone(self.resolve(ancestor_thread_id="fixture-ancestor", policy=policy)) + + def test_explicit_filter_still_wins_for_related_queries(self): + for relation in ({"parent_thread_id": "p"}, {"ancestor_thread_id": "a"}): + self.assertEqual(self.resolve(requested=("other",), policy=RemoteListPolicy(()), **relation), ("other",)) + + def test_null_and_omitted_match_typed_native_option_semantics(self): + for wire in ('{}', '{"modelProviders": null}'): + requested = json.loads(wire).get("modelProviders") + self.assertIsNone(requested) + self.assertEqual(self.resolve(requested=requested, policy=RemoteListPolicy(("openai",))), ("openai",)) + + def test_provider_names_are_not_hardcoded(self): + self.assertEqual(self.resolve(default_provider="custom"), ("custom",)) + self.assertEqual(self.resolve(policy=RemoteListPolicy(("azure-team", "local"))), ("azure-team", "local")) + + def test_provider_ids_are_exact_not_aliases(self): + self.assertEqual(self.resolve(policy=RemoteListPolicy(("OpenAI", " openai "))), ("OpenAI", " openai ")) + + def test_policy_cannot_be_mutated_during_pagination(self): + policy = RemoteListPolicy(("openai",)) + with self.assertRaises(FrozenInstanceError): + policy.providers = () + + def test_rejects_invalid_policy(self): + for value in (["openai"], "openai", ("",), (" ",), (1,), ("openai", "openai")): + with self.subTest(value=value), self.assertRaises(ValueError): + RemoteListPolicy(value) + + def test_rejects_untrusted_origin_string(self): + with self.assertRaises(ValueError): + self.resolve(origin="remote_control") + + def test_rejects_invalid_default_provider(self): + for value in ("", " ", None, 1): + with self.subTest(value=value), self.assertRaises(ValueError): + self.resolve(default_provider=value) + + def test_rejects_invalid_requested_filter(self): + for value in (["other"], "other", (1,), (None,)): + with self.subTest(value=value), self.assertRaises(ValueError): + self.resolve(requested=value) + + +class SyntheticPaginationTests(unittest.TestCase): + """Fixture SQL illustrates the contract, not the native Codex store schema.""" + def setUp(self): + self.db = sqlite3.connect(":memory:") + self.addCleanup(self.db.close) + self.db.execute("CREATE TABLE threads (id INTEGER PRIMARY KEY, provider TEXT NOT NULL)") + self.db.executemany("INSERT INTO threads VALUES (?, ?)", + [(i, "openai") for i in range(1, 5201)] + [(5201, "opencodex"), (5202, "other")]) + self.before = self.digest() + + def digest(self): + return hashlib.sha256("\n".join(self.db.iterdump()).encode()).hexdigest() + + def list_all(self, policy, limit=37, requested=None): + providers = resolve_provider_filter(origin=Origin.REMOTE_CONTROL, + default_provider="opencodex", requested=requested, policy=policy) + rows, last_id = [], 0 + while True: + values = [last_id] + sql = "SELECT id, provider FROM threads WHERE id > ?" + if providers is not None: + sql += " AND provider IN (" + ",".join("?" for _ in providers) + ")" + values.extend(providers) + sql += " ORDER BY id LIMIT ?" + values.append(limit) + page = self.db.execute(sql, values).fetchall() + if not page: + break + rows.extend(page) + last_id = page[-1][0] + self.assertEqual(self.digest(), self.before, "read must not retag fixture rows") + self.assertEqual(len({row[0] for row in rows}), len(rows)) + return rows + + def test_default_remains_one_thread(self): + self.assertEqual(self.list_all(RemoteListPolicy()), [(5201, "opencodex")]) + + def test_opt_in_two_provider_list_returns_5201_rows(self): + rows = self.list_all(RemoteListPolicy(("openai", "opencodex"))) + self.assertEqual(len(rows), 5201) + self.assertNotIn("other", {row[1] for row in rows}) + + def test_explicit_all_returns_5202_rows(self): + self.assertEqual(len(self.list_all(RemoteListPolicy(()))), 5202) + + def test_explicit_client_filter_not_broadened(self): + self.assertEqual(self.list_all(RemoteListPolicy(()), requested=("other",)), [(5202, "other")]) + + def test_unlisted_provider_returns_empty_without_fallback(self): + self.assertEqual(self.list_all(RemoteListPolicy(("not-in-fixture",))), []) + + def test_every_page_uses_same_policy_without_duplicates(self): + policy = RemoteListPolicy(("openai", "opencodex")) + self.assertEqual(self.list_all(policy, limit=1), self.list_all(policy, limit=1000)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py new file mode 100644 index 00000000000..a8e4ff7a485 --- /dev/null +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py @@ -0,0 +1,196 @@ +from __future__ import annotations +import base64 +import hashlib +import json +import sqlite3 +import unittest +from copy import deepcopy +from remote_list_probe import Policy, decode, encode, make_envelope, extract_message, rewrite_backend_frame + + +class RewriteTests(unittest.TestCase): + def setUp(self): + self.policy = Policy(enabled=True) + self.request = {"id": 27, "method": "thread/list", "params": {"limit": 20, "cursor": "opaque"}} + + def frame(self, request=None, chunk=False): + return encode(make_envelope(deepcopy(self.request if request is None else request), chunk=chunk)) + + def result(self, request=None, chunk=False, policy=None): + return rewrite_backend_frame(self.frame(request, chunk), policy or self.policy) + + def assert_preserved(self, raw): + outcome = rewrite_backend_frame(raw, self.policy) + self.assertFalse(outcome.changed) + self.assertEqual(raw, outcome.text) + + def test_opt_in_required(self): + raw = self.frame() + self.assertEqual(rewrite_backend_frame(raw, Policy()).text, raw) + self.assertFalse(rewrite_backend_frame(raw, Policy()).changed) + + def test_plain_omitted_filter(self): + out = self.result() + self.assertTrue(out.changed) + self.assertEqual(extract_message(decode(out.text))["params"]["modelProviders"], ["openai", "opencodex"]) + + def test_explicit_all_provider_mode(self): + out = self.result(policy=Policy(enabled=True, providers=())) + self.assertEqual(extract_message(decode(out.text))["params"]["modelProviders"], []) + + def test_preserves_envelope_metadata(self): + before = decode(self.frame()) + after = decode(self.result().text) + for key in ("client_id", "stream_id", "seq_id", "cursor"): + self.assertEqual(before[key], after[key]) + + def test_preserves_all_other_parameters(self): + self.request["params"].update(archived=True, cwd="C:/mock", sourceKinds=["cli"], + sortDirection="asc", searchTerm="mock", isPinned=False) + after = extract_message(decode(self.result().text)) + del after["params"]["modelProviders"] + self.assertEqual(after, self.request) + + def test_explicit_filter_preserved(self): + self.request["params"]["modelProviders"] = ["other"] + self.assert_preserved(self.frame()) + + def test_explicit_empty_filter_preserved(self): + self.request["params"]["modelProviders"] = [] + self.assert_preserved(self.frame()) + + def test_explicit_null_filter_preserved(self): + self.request["params"]["modelProviders"] = None + self.assert_preserved(self.frame()) + + def test_missing_params_created(self): + del self.request["params"] + self.assertTrue(self.result().changed) + + def test_non_object_params_preserved(self): + for value in (None, [], "not-an-object", 1): + with self.subTest(value=value): + self.request["params"] = value + self.assert_preserved(self.frame()) + + def test_resume_and_other_methods_unchanged(self): + for method in ("thread/resume", "thread/start", "turn/start", "turn/interrupt", "initialize", "thread/search"): + with self.subTest(method=method): + self.request["method"] = method + self.assert_preserved(self.frame()) + + def test_notification_without_id_unchanged(self): + del self.request["id"] + self.assert_preserved(self.frame()) + + def test_request_id_preserved_exactly(self): + for request_id in ("request-string", 9007199254740993, 0, -1): + with self.subTest(request_id=request_id): + self.request["id"] = request_id + self.assertEqual(extract_message(decode(self.result().text))["id"], request_id) + + def test_response_like_object_unchanged(self): + self.request["result"] = {"data": []} + self.assert_preserved(self.frame()) + + def test_relation_query_bypass_preserved(self): + for key in ("parentThreadId", "ancestorThreadId"): + with self.subTest(key=key): + self.request["params"] = {key: "mock-parent"} + self.assert_preserved(self.frame()) + + def test_null_relation_does_not_block(self): + self.request["params"]["parentThreadId"] = None + self.assertTrue(self.result().changed) + + def test_ping_ack_close_unchanged(self): + for kind in ("ping", "ack", "client_closed", "unknown_future_event"): + with self.subTest(kind=kind): + self.assert_preserved(encode({"type": kind, "client_id": "mock-client", "seq_id": 3})) + + def test_invalid_json_preserved(self): + for raw in ("{broken", "[]", "null", '{"type":"client_message","client_id":"x","message":NaN}'): + with self.subTest(raw=raw): + self.assert_preserved(raw) + + def test_duplicate_members_preserved(self): + raw = self.frame().replace('"method":"thread/list"', '"method":"thread/list","method":"thread/resume"') + self.assert_preserved(raw) + + def test_unknown_envelope_fields_preserved(self): + envelope = decode(self.frame()) + envelope["future_field"] = {"tag": [1, 2, 3]} + out = rewrite_backend_frame(encode(envelope), self.policy) + self.assertEqual(decode(out.text)["future_field"], envelope["future_field"]) + + def test_single_chunk_transformed_with_correct_byte_size(self): + self.request["params"]["searchTerm"] = "ν•œκΈ€πŸ™‚" + out = self.result(chunk=True) + self.assertTrue(out.changed) + after = decode(out.text) + payload = base64.b64decode(after["message_chunk_base64"], validate=True) + self.assertEqual(len(payload), after["message_size_bytes"]) + self.assertEqual(decode(payload)["params"]["modelProviders"], ["openai", "opencodex"]) + self.assertEqual(after["seq_id"], 7) + + def test_single_chunk_existing_filter_unchanged(self): + self.request["params"]["modelProviders"] = ["other"] + self.assert_preserved(self.frame(chunk=True)) + + def test_multi_segment_deliberately_not_implemented(self): + envelope = decode(self.frame(chunk=True)) + envelope["segment_count"] = 2 + out = rewrite_backend_frame(encode(envelope), self.policy) + self.assertEqual(out.reason, "multi_segment_not_implemented") + self.assertFalse(out.changed) + + def test_bad_chunk_length_unchanged(self): + envelope = decode(self.frame(chunk=True)) + envelope["message_size_bytes"] += 1 + self.assert_preserved(encode(envelope)) + + def test_bad_chunk_base64_unchanged(self): + envelope = decode(self.frame(chunk=True)) + envelope["message_chunk_base64"] = "not@@base64" + self.assert_preserved(encode(envelope)) + + def test_oversized_input_unchanged(self): + raw = self.frame() + out = rewrite_backend_frame(raw, Policy(enabled=True, max_wire_bytes=64)) + self.assertFalse(out.changed) + self.assertEqual(out.text, raw) + + def test_oversized_output_unchanged(self): + raw = self.frame() + out = rewrite_backend_frame(raw, Policy(enabled=True, max_wire_bytes=len(raw.encode()))) + self.assertFalse(out.changed) + self.assertEqual(out.reason, "output_size_limit") + + def test_invalid_policy_rejected(self): + for providers in (("",), ("a", "a"), (1,), ["a"]): + with self.subTest(providers=providers): + with self.assertRaises(ValueError): + Policy(enabled=True, providers=providers) + + def test_synthetic_sqlite_visibility_and_no_mutation(self): + # Synthetic fixture, NOT the user's database and NOT native Codex. + db = sqlite3.connect(":memory:") + try: + db.execute("CREATE TABLE threads (id INTEGER, model_provider TEXT)") + db.executemany("INSERT INTO threads VALUES (?, ?)", [(i, "openai") for i in range(5200)]) + db.execute("INSERT INTO threads VALUES (5200, 'opencodex')") + db.execute("INSERT INTO threads VALUES (5201, 'unrelated')") + before = hashlib.sha256("\n".join(db.iterdump()).encode()).hexdigest() + self.assertEqual(db.execute("SELECT count(*) FROM threads WHERE model_provider = 'opencodex'").fetchone()[0], 1) + providers = extract_message(decode(self.result().text))["params"]["modelProviders"] + placeholders = ",".join("?" for _ in providers) + scoped = db.execute(f"SELECT count(*) FROM threads WHERE model_provider IN ({placeholders})", providers).fetchone()[0] + self.assertEqual(scoped, 5201) + self.assertEqual(db.execute("SELECT count(*) FROM threads").fetchone()[0], 5202) + after = hashlib.sha256("\n".join(db.iterdump()).encode()).hexdigest() + self.assertEqual(before, after) + finally: + db.close() + +if __name__ == "__main__": + unittest.main(verbosity=2) From a0f933c78232f10cb42a8e31071b88edb510a924 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:19:53 +0900 Subject: [PATCH 2/8] docs-codex: reject malformed thread ids in the native-policy spec --- .../probes/native_policy.py | 8 ++++++++ .../probes/test_native_policy.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py index d010f74df61..00dd5c737bf 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py @@ -57,6 +57,14 @@ def resolve_provider_filter( if not isinstance(origin, ConnectionOrigin): raise ValueError("origin must be supplied by the trusted connection context") _validate_ids((default_provider,), "default_provider") + for label, tid in ( + ("parent_thread_id", parent_thread_id), + ("ancestor_thread_id", ancestor_thread_id), + ): + if tid is not None and not tid.strip(): + raise ValueError(f"{label} must be a non-empty thread id") + if parent_thread_id is not None and ancestor_thread_id is not None: + raise ValueError("parent_thread_id and ancestor_thread_id are mutually exclusive") if requested is not None: # Preserve every typed client array, including duplicate/empty ids. # Native Vec accepts them; this proposal must not add rejection. diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py index e2a9ccf4079..61af3a5a2b8 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py @@ -51,6 +51,15 @@ def test_ancestor_query_keeps_native_no_default_filter(self): for policy in (RemoteListPolicy(), RemoteListPolicy(("opencodex",))): self.assertIsNone(self.resolve(ancestor_thread_id="fixture-ancestor", policy=policy)) + def test_related_thread_ids_follow_native_validation(self): + # Upstream thread_list_response_inner rejects malformed ids and rejects + # parent+ancestor together; a malformed id must not silently widen the list. + for relation in ({"parent_thread_id": ""}, {"ancestor_thread_id": " "}): + with self.subTest(relation=relation), self.assertRaises(ValueError): + self.resolve(**relation) + with self.assertRaises(ValueError): + self.resolve(parent_thread_id="p", ancestor_thread_id="a") + def test_explicit_filter_still_wins_for_related_queries(self): for relation in ({"parent_thread_id": "p"}, {"ancestor_thread_id": "a"}): self.assertEqual(self.resolve(requested=("other",), policy=RemoteListPolicy(()), **relation), ("other",)) From 3b2911795b4e4dbeae52d66dfbbcc44802ed233f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:06:18 +0900 Subject: [PATCH 3/8] docs(codex): validate UUID thread ids, fix probe counts, and run probes in CI (#6157) --- .github/workflows/devlog-probes.yml | 43 +++++++++++++++++++ .../020_verification.md | 12 ++++-- .../probes/native_policy.py | 13 +++++- .../probes/test_native_policy.py | 22 +++++++--- .../probes/test_probe.py | 2 +- 5 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/devlog-probes.yml diff --git a/.github/workflows/devlog-probes.yml b/.github/workflows/devlog-probes.yml new file mode 100644 index 00000000000..611454f40a6 --- /dev/null +++ b/.github/workflows/devlog-probes.yml @@ -0,0 +1,43 @@ +name: devlog probes + +# Runs the offline unit probes that live beside a devlog plan. The suite exists so +# an exact-head gate actually executes the research fixtures a docs PR cites +# instead of leaving them as locally-verified-only claims. + +permissions: + contents: read + +on: + pull_request: + paths: + - "devlog/**/probes/**" + - ".github/workflows/devlog-probes.yml" + +concurrency: + group: devlog-probes-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unittest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: aiohttp for socket fixtures (best effort) + run: python -m pip install aiohttp + continue-on-error: true + - name: Run every discovered probe suite + shell: bash + run: | + set -euo pipefail + found=0 + while IFS= read -r -d '' dir; do + found=1 + echo "== probes: $dir" + (cd "$dir" && python -m unittest discover -s . -p 'test_*.py' -v) + done < <(find devlog -type d -name probes -print0) + if [[ "$found" -eq 0 ]]; then + echo "no devlog probe directories present" + fi diff --git a/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md b/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md index 76564b99534..28f3dc7bcef 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md +++ b/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md @@ -11,20 +11,24 @@ cd devlog/_plan/260928_remote_thread_provider_policy/probes python -m unittest -v test_native_policy test_probe test_loopback_bridge ``` -**Executed: 60 tests, 0 failures.** +**Executed: 61 tests, 0 failures.** -- 22 tests specify the proposed native policy: opt-out, trusted-origin scoping, +- 23 tests specify the proposed native policy: opt-out, trusted-origin scoping, explicit arrays, null semantics, parent/ancestor exceptions, immutable policy, - exact ids, validation, and synthetic paginated fixture reads. + exact ids, UUID thread-id validation, and synthetic paginated fixture reads. - 29 retained raw-frame tests cover the relay alternative's ordinary/single-chunk rewrites, byte-identical pass-through, limits, and unsupported inputs. - 9 retained localhost HTTP/WebSocket tests use a mock host and mock backend, checking two-way traffic, fixture auth/headers, explicit filters, enrollment, unrelated HTTP, and fixture endpoint restrictions. -The 22 + 29 offline tests use the Python standard library only. The 9 socket tests +The 23 + 29 offline tests use the Python standard library only. The 9 socket tests need aiohttp already installed; no production dependency manifest is changed. +Hosted CI runs the same command for every devlog/**/probes directory it finds +(.github/workflows/devlog-probes.yml): the offline unit tests always run, and +the aiohttp socket tests run when aiohttp is present on the runner image. + ```sh python -m unittest -v test_native_policy test_probe ``` diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py index 00dd5c737bf..8f2aa1066f7 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from enum import Enum +from uuid import UUID class ConnectionOrigin(Enum): @@ -27,6 +28,14 @@ def _validate_ids(value: tuple[str, ...], label: str) -> None: raise ValueError(f"{label} must not contain duplicate identifiers") +def _validate_thread_id(value: str, label: str) -> None: + """Upstream parses ThreadId as a UUID; a non-UUID string must not widen the list.""" + try: + UUID(value) + except (ValueError, AttributeError, TypeError): + raise ValueError(f"{label} must be a UUID thread id") + + @dataclass(frozen=True) class RemoteListPolicy: # None: opt-out; (): explicitly all; non-empty: exactly these provider ids. @@ -61,8 +70,8 @@ def resolve_provider_filter( ("parent_thread_id", parent_thread_id), ("ancestor_thread_id", ancestor_thread_id), ): - if tid is not None and not tid.strip(): - raise ValueError(f"{label} must be a non-empty thread id") + if tid is not None: + _validate_thread_id(tid, label) if parent_thread_id is not None and ancestor_thread_id is not None: raise ValueError("parent_thread_id and ancestor_thread_id are mutually exclusive") if requested is not None: diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py index 61af3a5a2b8..eba438a51e4 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py @@ -45,23 +45,35 @@ def test_opt_in_all_is_not_the_default(self): def test_parent_query_keeps_native_no_default_filter(self): for policy in (RemoteListPolicy(), RemoteListPolicy(("opencodex",))): - self.assertIsNone(self.resolve(parent_thread_id="fixture-parent", policy=policy)) + self.assertIsNone(self.resolve(parent_thread_id="00000000-0000-4000-8000-0000000000aa", policy=policy)) def test_ancestor_query_keeps_native_no_default_filter(self): for policy in (RemoteListPolicy(), RemoteListPolicy(("opencodex",))): - self.assertIsNone(self.resolve(ancestor_thread_id="fixture-ancestor", policy=policy)) + self.assertIsNone(self.resolve(ancestor_thread_id="00000000-0000-4000-8000-0000000000bb", policy=policy)) def test_related_thread_ids_follow_native_validation(self): # Upstream thread_list_response_inner rejects malformed ids and rejects # parent+ancestor together; a malformed id must not silently widen the list. - for relation in ({"parent_thread_id": ""}, {"ancestor_thread_id": " "}): + for relation in ( + {"parent_thread_id": ""}, + {"ancestor_thread_id": " "}, + {"parent_thread_id": "fixture-parent"}, + {"ancestor_thread_id": "fixture-ancestor"}, + {"parent_thread_id": "p"}, + ): with self.subTest(relation=relation), self.assertRaises(ValueError): self.resolve(**relation) with self.assertRaises(ValueError): - self.resolve(parent_thread_id="p", ancestor_thread_id="a") + self.resolve( + parent_thread_id="00000000-0000-4000-8000-0000000000aa", + ancestor_thread_id="00000000-0000-4000-8000-0000000000bb", + ) def test_explicit_filter_still_wins_for_related_queries(self): - for relation in ({"parent_thread_id": "p"}, {"ancestor_thread_id": "a"}): + for relation in ( + {"parent_thread_id": "00000000-0000-4000-8000-0000000000aa"}, + {"ancestor_thread_id": "00000000-0000-4000-8000-0000000000bb"}, + ): self.assertEqual(self.resolve(requested=("other",), policy=RemoteListPolicy(()), **relation), ("other",)) def test_null_and_omitted_match_typed_native_option_semantics(self): diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py index a8e4ff7a485..ffdb0c6e24d 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py @@ -96,7 +96,7 @@ def test_response_like_object_unchanged(self): def test_relation_query_bypass_preserved(self): for key in ("parentThreadId", "ancestorThreadId"): with self.subTest(key=key): - self.request["params"] = {key: "mock-parent"} + self.request["params"] = {key: "00000000-0000-4000-8000-0000000000aa"} self.assert_preserved(self.frame()) def test_null_relation_does_not_block(self): From 787ef3069805b5e5b3832e919d75649414cd3a8c Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:21:23 +0900 Subject: [PATCH 4/8] ci(probes): pin actions to full commit SHAs per repo policy (#6157) --- .github/workflows/devlog-probes.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/devlog-probes.yml b/.github/workflows/devlog-probes.yml index 611454f40a6..29446bae9ec 100644 --- a/.github/workflows/devlog-probes.yml +++ b/.github/workflows/devlog-probes.yml @@ -21,8 +21,8 @@ jobs: unittest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.x" - name: aiohttp for socket fixtures (best effort) From bb282758b429814d8b22ec00b6b7ef1a5e52f5c2 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:52:16 +0900 Subject: [PATCH 5/8] fix(probes): enforce upstream UUID shapes, deterministic aiohttp, consistent verification counts (#6157) --- .github/workflows/devlog-probes.yml | 3 +-- .../020_verification.md | 21 +++++++++------ .../__pycache__/native_policy.cpython-314.pyc | Bin 0 -> 6627 bytes .../remote_list_probe.cpython-314.pyc | Bin 0 -> 11283 bytes .../test_native_policy.cpython-314.pyc | Bin 0 -> 17811 bytes .../__pycache__/test_probe.cpython-314.pyc | Bin 0 -> 18804 bytes .../probes/native_policy.py | 16 ++++++++++++ .../probes/test_native_policy.py | 24 ++++++++++++++++++ 8 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/native_policy.cpython-314.pyc create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/remote_list_probe.cpython-314.pyc create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/test_native_policy.cpython-314.pyc create mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/test_probe.cpython-314.pyc diff --git a/.github/workflows/devlog-probes.yml b/.github/workflows/devlog-probes.yml index 29446bae9ec..f7e716a3c83 100644 --- a/.github/workflows/devlog-probes.yml +++ b/.github/workflows/devlog-probes.yml @@ -25,9 +25,8 @@ jobs: - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.x" - - name: aiohttp for socket fixtures (best effort) + - name: aiohttp for socket fixtures (required; the socket suite imports it unconditionally) run: python -m pip install aiohttp - continue-on-error: true - name: Run every discovered probe suite shell: bash run: | diff --git a/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md b/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md index 28f3dc7bcef..aee77879afe 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md +++ b/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md @@ -11,7 +11,7 @@ cd devlog/_plan/260928_remote_thread_provider_policy/probes python -m unittest -v test_native_policy test_probe test_loopback_bridge ``` -**Executed: 61 tests, 0 failures.** +**Inventory: 61 tests across three suites.** - 23 tests specify the proposed native policy: opt-out, trusted-origin scoping, explicit arrays, null semantics, parent/ancestor exceptions, immutable policy, @@ -23,11 +23,15 @@ python -m unittest -v test_native_policy test_probe test_loopback_bridge unrelated HTTP, and fixture endpoint restrictions. The 23 + 29 offline tests use the Python standard library only. The 9 socket tests -need aiohttp already installed; no production dependency manifest is changed. +need aiohttp; no production dependency manifest is changed. -Hosted CI runs the same command for every devlog/**/probes directory it finds -(.github/workflows/devlog-probes.yml): the offline unit tests always run, and -the aiohttp socket tests run when aiohttp is present on the runner image. +Author-local execution on 2026-09-28 covered the 52 standard-library tests +(test_native_policy + test_probe); the socket suite was not part of that recorded +run. Hosted exact-head execution is authoritative for the remaining 9: the +devlog-probes workflow added by this PR installs aiohttp deterministically (the +step fails the job if install fails) and discovers every devlog/**/probes +directory. On head 787ef30698 the hosted unittest job ran all 61 tests with 0 +failures. ```sh python -m unittest -v test_native_policy test_probe @@ -49,6 +53,7 @@ schema compatibility or actual mobile results. substitute for required repository gates; the PR must remain draft. - Production multi-segment relay support or management/security review. -The PR adds only this research unit, not a runtime fix. No workflow, executable -configuration, release artifact, or native storage is changed. Required exact-head -CI and independent review remain outstanding even if these probes pass. +The PR adds this research unit plus one workflow that runs it: devlog-probes.yml +is a new CI lane, so the executable contract now executes on exact head rather +than only locally. No executable configuration, release artifact, or native +storage is changed. Independent review remains outstanding even with green CI. diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/native_policy.cpython-314.pyc b/devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/native_policy.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a552271f3153121ba4b3334eab183f94c7fee2a9 GIT binary patch literal 6627 zcmb_gU2Ggz6~42x|GVC`lQ@nWJD$YO&nEFEwH@O8)X23r#Bpl7Pn>gSXT32A zDuU7OojdpU+;h**cg~qaLzKXi`Q=YP_vI!+{)ivm6LBlT&R?OjL}X%-mx(Nl2-kg< zuLJA85%Id;^7Fbl61X0;g1qh@30)6cVO|f6M6AfI$ix9}+1>Y1A_w~f_uGm!6Zj6b z5IKA+*e6z7<8lOAqAf&gv>N0X)Z@IKxaEh@4RYdQbv~b+g#N}ppF6j&x{;NXo8*AJ z5B~e%f8bW~DDnEv`Q>Jqa}d|md{(0z=p?BY<;$>W<}S@wEM-=sl3AvCwW#J5OEq+< zXfR2UCgkyn@yX1vq(kd%Dh(M0dRHnNnwnoYE9r(MDY~TU7M){!S~?mXGbGc-}(uB`QL9vv) zrkMEQ&%oyRo}L~V9#evFZKpnY`7`IcFDc!{!R~9fzR>?#cl~GIfAjft&ug#NPws{9 z>=)`={>Rm`uf1Af`sGSRE&M-;PGw)pS7o2Ssv08foPf_Ia*J33=(6wSc1x56%P;$^ zfGk=;*>8p9fEAX5paUVNai}I7<6NMefN2#}!-?FXv#uOFQB}{uHKKev2}VnXMROoi z7KrXWGM0)s;am=-Czo@gxg5wxMZIQTNCZ~o+#+}Fu znP(@*hek6~PIP1}2MRZonVfVIa^|`5sZ4HYd~8Y{zv(pRawdvLKBrg~Q)ep{HFG%@ zhqLbB@tw~NU(ODV4`)7|eZgR_Sd3CLYvk{C-|1$>{DuC${%nEX){MDquB<6~w&xS) zFZJ~2s;9B$LFEc~NG_{6SHZLJJqydYx?gQfmls$FHsnP+RLp}=e1&Yp_b+8O8V@gx zY&5km-PmY8{-rU7U3_33Z>M@gBnWk|e8Nr_RF=pLdD++hB|-|&)<&Ke<^@@heX=O~ z!HxybiFi+cL2Ks(ZYx@aE+(xCScT7+7CS4Y)9G7IKvQO^=7`YZ1R1qJ+#EkDCFc)l zP7thFNwG|%;23McwcvKDDhGGPBxpivOjmdwlAv%?*yiPO#}i`maqvMsKF?#!3t8kBf3676 z`)`m!8_wteAJqaYH`c8DH4ui{s+^ajF+z@j|9Y+|f^YG;2Jj|(W_kN~GYgNS$NvaD zRgXzX7S?y48RD(uy|cAZyM6t4;Fce^exQDRS2GtIC2s9!!LmyH!nq828zR4YQHH(( zm1Dq-B{D^>T_mI|)bU+-GT%>=D}s0CIgnH2=!`H?y9aM0Qsb{T`ZT#qmI57Q^b#SQ zucVV)5LMxxFqRU*SeU8~s;cKHgNp*81*ufa_k4~oecth_7A=`53@0R44kK6Rs7~*e z*?=SEK)zM5{4}Q)%=A^w$SayTkgknD3d}SVe}UuL(a{g5e=_y<;&Rvb#noijTC8h5 z)^Er9-y44a#=RRqyt#I&e=XMki|K!u2!39d1_j}JN1&Lxw%FnF7L6FXQr*zIX{l^2 zNJy(bXD*&}2cV% zP)usw^RcI3AUh0&%YYLu0~#JVHG3NRaYgfWD3-`(toa@K?(}-Bb0yZfnK-nZS-xl= z>#78dao-211CNi~9c6EoHhyF*jWdE*~RGqb2VEr(P3&%WE zmdKyDu1v1Sj;_RxuB8UnQ^WUD!#|&1PmQjmb`{}@-FkI3@r)gQX3HlA1~$X-x2C@y z`}=16;Jb+*MeJD510q1Pr)zb?MQH!0iI?-C5F1R84->}+d&Md4V41N<^*nUd9?`7{ zJ3UaT17sI1u?qwPA=xc}Wax2aKZGX%9`Xd`AOJMM1!pn`TQ`CG6FlS?`{%(uzF&G( zLPzgJJnUjV0yWUm0v*B*>fMu|#yQ+gbd~gDX{FP?m#}m_nz#yknfv*M4~Z{uaT|+e zi@$d53H0*^wG2p%?8s0-5+~gDM@kA71W-birQ`{tWWhrhpi|(L@SpOHv1eedl#jo- zeZ^S9q*{?%o0=I0fM;4cRaY%Qr$-)h!n%HbL^N1SWFwaR#=utxR$|9D2yUW*i@7}SZlI3xCgo1O9Z`g(nh*|=ph%p&4?7_u#L?4>0TOYNt=0L#z(=#0)Ibd zd#iemumBeWWARe>vj?_A@@EF9^^!ABgze!fY>#l*zGs%lpW2>#sgHUi=7lL=9n|G~ z0rLPUpc%0Ux5dxSTX|a@ESRV{U+*M@K=^T-&k0PR{_c4wC@HM>*uE#w0Cx(i5mSB! ztbGnsE0`$BQ}DcbK>kGwGA9cyMuy*Kao-dufg_8+Dn2EBf)3!}Nzt{`yM^9-c~Q8U*YH>3kbF=ZhYafwBf z>H<*=w>XYuXRwB7k{!T;V}>xhfpva6+$Etms`EgH;nNWlavytL-O{D;V4 zyS>{Er#B+)cE{6pxMwFIwgkT#eJcR2E1rD&lbi95?~1>UpV^KmGb_n6@0&j~?{k)~_T=9;-#34-`?-ED zzj7|Wxv$M`zrMO}#Ey?_BoD18B|9lC58GjBBh-Q$oxHSiGV`0{{*6$>TGP?>rt|lk z&aZ~fe=xItA#?viX63^38_DMNWXJtv$9nRlojmznh+$7|#M|uH5!-)+d(%{t6UyZZ zM&3;)7D2cfrl=6gI~?RP zV+Xv6JqXJQUv(3h1MJh#i~i31HWXW;APC#Opy2-~L4?jflcT>Q&G7pzi6BBa{GI5x zqFcT|AhsihT75QYesI7qoOs82K;ZRJ0@G<9?Zeph0P)4%=~@$79>QW5F{O5KMB= zfqiu(=iH$pD}U6zwM$od*bK=Nw%y z8>UJEZD)XRu1<^I)2SCkaqkLS1hDQe!Fo6k^m_v&RjPQoVyKtUdNJpNdMT}!aAi<0 zr*$9ahk6CAmvWU*ucGxbF2Gf{G29;L*^50Xi&D<*gH{dw?&mBGBv>2!2+$vym`EiB zo|Sl+k4f<goY*LVwSSrOQ*pW0Z42TK-8au(ql#Il)qJ+SSOk7Ed z0#>ihw7T6VV=_QPhX?~=$+kEh6BnnalM34&k9YAMp=0eGp{_XJ)zR7689KuA?TMq^ z?eVUziKmXnk9Ni4-L38sAvMFsr((h+FS8XobTgZX zDY0?Dy&3lA&IlR z5YI6tEv$Nj*&c?<9Jxpo2CjzXQn_MngzKT$ILk#lXFUjCIj`d2Y|ygPRtQqG!Z}A-Ps*U@K;jeWF(qS#e_)5u_I4)bVntAT2%O`&X7?9|7e=-I0Ef)m?j5= z?1~`cd~13}^G2h&=RJhf0W%HlE5gz;6aZQI6=v$p?ZTKX0W$+`MFwu1)qt2{BCv*@ zBE?2+_q`iqj50s+(eT|@fyZ`YU^8Z!VqkQMv2wn=^tJBtUB=n-<2VO!s`E>y8A1;K z7EDwRbe|(*WUz~nG*fW0OvF6dou~LDIl&lIn*=_>vkNn$IUShCj+m!rKtD;YkvaQ* za{ejcCOj?DK#p3IOpqB4S~Yi4rs_b5^O_ZIuV#lVnGRYsOY0GhNy~`QL00lZp%wK^ z@&ZU?sZVP%c|0w`O-Kq!B^qt*Nr~}TO73el0YPaK`3PjchAnRqOSk*qde7fxzT;kO zevH?(}<{id5uKM1}%z2*)5?WO;fQStvF+s`oHuY+uCaJJRBkur#B zSz*U{HpWu1!-^BEAPOz~bXu7~p$rmPo~=uONJ6#1qb#Pfhy}d@s$wDu+GV!>L5Y-~ zRAEyf%@y`o=Wx&^;mws08O=@?)2uOJMstT_sSJNwl0-?frg#B}$14&r6AB}n%%>)( zC_1PUt~d%(#3Ykeu^aKIg!n6%V2*71_AEAjul2jF?>({ZJF@CK^5MYW4BSQHZS?m4 z{iPpY+O|Ud!+{S6e)#3?`^0k8{oj~^dbGO<+di!U0T zNJ_GT_KF}zqY}yj4SEaIAM_&qDRm{(OquR-#gTslX5bE9K%v z$p;``lq8aIMK=$ymm(ZwBziKa6zb_kkt(1{Gu)}X53)IOpE#KEyO?G7wryc~YnV3a z79J3@Cids($TkqKVNyy4fy!4wkVy>}4uBLiguba#Ig%IN7uv)00MCOhg5olaW(PA{ znHMJKo*x+o>unm$x|@tt5Ay|W5^@8cnG6P`X1&bMX!a{8A!J19Aj{??Xh}39Bwxw! zQE~ibKCVc;@Re@011fW5qjvw?;Loe}U3br0H{ibZ(!1BGmLJV}k8V`$o0s44+;x&b z&6bOJ%jbsj6fpK;Qh=osex>u1dH|MkB0Vdq6r0F2k2)YU4rJtBJRpV%-NkHiU<-?- zQ?!-G4!~%_%R{-V8Y+cGP2mXuwfCYlhZ`;=Vp4*o1dogOY7rC(4-n{%I2Vf8QZV0; zq)`*FA0Q}}$fF=s-4)r&cGcaHwRPk%`<>{9hc@d0eZ#)SQma;{AD+Qu>DLWFCjBf- z7l?PJYhfR#NTzGaPg{8`0hT5Q7OH*Xf^4M2``;WhD8GO4+9Z%a{< zwIX<&i&sH+3UD|~v&I|`TBJc3s@W5n>9j0;4eAe&Tvxo(bI?yWRRa~^_#Dakyx<3;~T7$&1wMakl4p1g}0N){-Vt7wt!1PKF8eIFp%&6ImKG236j&I%gtuqA?~ z08J4zd}s<;zBEq9#N7bKMwp;joP=?Jn@w-&&u4tMKV)b2He$?~wie)OeK@7`=O5>V zH639q)ulQX#juKXSNb^(dQ5?(Q}Y^(nCG#349VlpZ6ONu`aS5-%^0v_^!H2&U=0gIjk`YuAoSY)7gSL2 zR4z=uqpW)xS3Ql(&i6exJ-5!NhkEbSt{ocQ@HJ&!O&ev^Z|{3^-{P2BRzG)c(^4dTZZ&jq!*?X>IZ=sIHqo zvtp~<@C38A;QyZlr3zG6SV;Q3=%fbxW9^6+@pKMQvfn2i%+q%RT=Kl_njjt1$3^Do0oj3~S zE)eFUmBuXjk~4+k!$oZZ%y1Bw7S7p>W_B(;5(8HrgRo`Sb&CwTrL7<~-QB=F36`D; zs6ahEWTeL!8D

&?nXD)6CEZFqaqSI?W{hlFqj0F|!pLvl(T?+~YZBvf(SuQDTfW z%ECEzJ$+vxeYoO6x?ZBneG*I-O7oHjz7eYUcjKqh!Wnm^c$tA++7Zc9#bNXo;yhVR zS9e?@=wX}${;VMvV!suCmp>LCzp--KS)lTr59h;OZ*4_>od_D6G>z67wR{PW5GPm3 zRdwZzue7V+j+;t9?06`J0|hJPZ>%{oT@*1n_rjYGa%R|O+?E7b#vpl2i4r#N!Km4n z$2l)$((VFm-3I00PL`p;QLuA1Kia6l&dm{{Cg#RhkY&{c^Z9YUq2jzJoXW;M`Mu}& zvfFut3+0-*qj267^%U&G^XNUBSp9aAb#ETmf?asH%0k>&5~(*RQ1CQuH2aVYG>slI zYWXsPJ}{a>b_Yq&`?0?udN79iWS@aY#!?G9B}q3-R zy0y}zkj^Mk8L*3{k`Rm4ylA@e!WBLRo?NXco=SoR3u z=$k_G=_n-N*+7X5K?6!783QH<(LI_=`tzbv;%Sf`&WhgBC?%Wp=KxI2-Cuz+nIoTj ziMMJY@y@`yhh6osnrzN!Uf@r9*xs;@2UYTL3LvOf)h zka^|CtJhy$>{5LVs;6=3%6e1JN>k68r)Sf@cd>J+GJEK_>OYb7p4jw5B<8K2c^8Ic z4!wJ7-4|T-1#?i#C91zG>+Pc7zSsQS=A~EFy`djHzwUc#)%Vn$>Q4@@_lMW|!{($X zRDVy_+q2;d+<5K!Ym0-h{JafGLm~dHdNnpIzFoRZ4Tl`KUVh`)Yq|EtyjL zQ=5UhMP=!QZ1aE`IF&6u1*R|C&xOP}5F z?|ZxF&7LgVsrrx3pTb2?y)pZ)U-dOE+m>H>&von6`iaq%6QgULqvqI?YG5E+I~ z9tn|c>Q(pl&7a@&*DhXuFSPD&TlKe@u&33)V77E{v#Msj>fmbC!KGJkkF8amoPTE1 zSH95o#%qhpny+CO=*0_fi2wv-x}eteB5nS|SG8GxXgToxw)Oh%mHO`60kyt=!MEAo zc`K^6pI&ff_q42(wr+-=S`Q7Zh6e78sG+g-&`T?!m$H$#8cHBU+e+z?-~7rr_#V~Y zoAvhM%zD?dL-n_1y=|Lifkl^Ec3|Ft40Pqj*ROwl@q+4mLiHS6u38WFtpxklJbgRQ z8X52koI_akHD_JTzuELwy;JmV&yT7sxj8ZCRo7 z3mI?vN0y_{c94&{TOj|3gCWTOajzHirjtG>{;BH_#gsz{bxyx`vvq|9Y5#T(eWzIQT#)2w!sWHersC|E;7=`_v(1*agv|(gB1-MS{433T0gjq|II*7KA7Hs& zcr{r@VU?YUE4oB3F%k1k95(;QkazoyA{}-lun|qjQ54}j68xVmw=(DH%FPaOe+a!j z1P?FRjm3totwRP&W}kk3MgV&R;-&EKsB83pUgMc$3Y_#OSTJDNT8v+A(Y3o)1Xcz7^$GNMu3v9!4w-)imAV7fh$zka=^(?}(mSOT zOee{heFHLxB2pEW!43tpYnaVqCP4=?<($!+}*i9lOk%DqH#sfS}B`02TP3f`b%QEL>bVf2(%GRl0Cs;l=F! z&Mmu@b0NETrNj>W5PFurkVzz^)uc?bDOmrKZ2W72Do2d$J}q}TD=A7KY0A(09rD?SX&#~`X` zn7Bw5N*eTiL%drQw{Ip@< zlf+v2^QvboYa63f3sz{(Xfz?l^*=~VWbk6bzZmePtRpp#wL!=G@Vdi4Ki^htjWU-1y+KZ6X^ z5X0QHI2qf$Qo=O+k`(`(_zUP9V=RG%s-R`*|s&lUye3x477r1VHfpu=oHei6SJDlKGNE%c4o~A&L}8tsqjeX~7_|Bq4zSvWm0fI6fmdre3~_S%q^>~$d>?9~JM!-kN7T&o!{9?l7w4x2;f z!nMmH0Kzen(}%5tZg#j4{`3vkS)aWA->7xobmEDPk`lZ zj$x;b3;S(Mm=87B{O*wVJZn1?@URyfYyr;Zw#^16ygt_E4hEa-v|w`k{eci%74Y*8 z_+8k?1?E`)fS(V!{gZ4j#|1dMTF5&T@cY?Gylj~BPJ8`A9>dNCLhO*24~+zT-pNZs zDaY~w-+9&*Nm3|&fvG>i(U zmQe%MF&dzH5=ztm-nB`CoV*qMmj6lsy4p0kY0-DR1fm#_0&^#s=Xg*^FTEOH1 zEoAb67BK}tiaifr#D9ZR?f()3U#z`}h5tVYN z956Ja+N}zzMpYw{cq*shV3clE*gCG4&oJuU)TpYP)=)4Yji3uiGwflf++klxnNPtO z2(tc3)-*vI}OkJrUc#D3vO-zSe!?9`?L1Fg@-H`rQ8U z=Jq}NoAeNbAOSGyb($@dMmPp}=dBdt9?`7*t)&*VMQ2nvBE^2LFDgzQRG(Uiw zs1yzwp#k32_~1j*UxI&mRNrBW3euz02+l>?HVT3%FT%%Y7adlG)h?=4x~iIjyE1W@ z8d3O~=rFl2M%60G4J>UM|70MkrUs9|IH@XV-%8o2UDOV#i)px{g7N~WRZ`XDZ_q_i zkEtsuFKt)fq&Wmh2kgzTw`;gu*a><*Jc05IYn;&=}2cc%Y_AD?iC^fEyzD4G(!(xzrD~b`|UI6q~y0Oeu1Kb4KK%0oLSYYFt_M zj7DCiL3&(S;~*_(Kw4B;_^x8;b)`j>=xGEOX4-ay@_@`&078ry(oVt8qco!ps^Q7_ zK4}}Y;%Z6T#%dVVUTN&3>LiI%Gw_!raXv}n)Ei3NmlTQ90Y(85xxHLDJl3uvf+Xm~ zweJAws)1%Ai`*l4fu6&aLZp)0f=5;$f>G8JJcrtqNG8ZGk<40bKn4j0_F@ne59{GI z@UzZllSFl=C}Mu!B^h8OL=uJ473Nvqg^~=q>OeLD-OSyk@K`gp7<1daQ%{ zQDQTpAvJ=SoVJZ^1(a0nm5?}DW(VtK8BLR2r`SH|md|e}!?TJE>xqzp>?)#mQ!PrI zkz>z=0XJC>Ip^STssUS3sO_4Yw4e=yW>^kxZG;v=ppmlEb9x&&W3y}11wOlgb337v zG}#tm7z7{eDlaI|fS;WWhAv6=lQyJNdL<6Xxh%+s;1)3FlQeBURJ*skbSi_5Q#Umag6xiY>|+__xb`O)?#7mTZA z6)R=+(X#rbqBkmTR@@$qxTlvYV)mKH8UAir=(lRB?kO4&yW|NvTHFbcy`=_m`x*K- z#huViq`31TkFtM9*VwH!zN0mDmsnDe8&4A1|2Hzs6pfB@`@f^n)#Hj3Co~4E3`v6J z^@RpJ{v~0qB)4eTZ=mJ9io9wG)^c`uV^JcxU2q7JhPs}hs$drN`~dF&fgx}zieV*O zk)r0C33e)Sks@4P-sSfBTx*nkPZkXQLcMIqksJrIu8OaT=G822d%fYchFen~sN%;b zBc4F)cyO^HR(md<$E~aD8@sCYcdAWYds5L7QHV&)E9BvTOMsf`8?;KiOFlEMSb4xh z>8^xjD7O%MFf-O==u^gM(8LmsqAiHlKi~`klEh-7JvCelv?TEp!33^DMakoU>O|wM zRx!G~e!@dwCHQt=qO-E?CbVYZqNpgd+(i&-FFXj0af5lqP#!guFKoNfaJ?bkbnrG8 zYwBKTh&^&BZs;MW%c6#|g`yi3*DJnTwN!Q2u#ZC(nzDe=#3Af>f%KYCOY+1dS`tgE zMFnaFBq;0h&S;P`G*K(kG$+_K2(~pbwZZU#RfLf8aN8bfYs7tHE0TRk@Ta)B-FB6b z7j$#1A7_?0KVDf;AiF?|5MxK-8JI(gNt3b)_Yi(bU4g~{gIct>{lp;!-pOo}fM4j& z!o4rhM-vQ@-$!OWf$omjuEAKz5SZNNycKh0)LgmXx#7F+iyWAUoISVTi&b%V%{*fC zQTqQ9Gbbj1nFSc<;6vclWMU_G5ax>O#92cTk{u-xL6ti<#l36@`h|LF#*u8Ae+NC0 zSswWpx@!(+Awe`k(Pa4P{riJyPPL>Dnbj` zhCvOJ4@Vw2(q`5XW38iF#whFfEThbT_~U={8;-T^hIeFgMu8WbeAg+-J`PCxi_*K} zdmqF1W(wfwFdXT!ylp#UUeD5pyA&HpVj%3+>&{Ne_^RDw}OCcglM)6z14QQOC^X$-iaP66hd=jSU>@BSB;b1RT|8*-L_Y z!tEixjYtkahu|>{!cNdk2Lc}Mkk~?=iH{?0mR&Qq9TcsoaM#nqbG6UPDjD~vs!>xX zHn;n^xyBQWqvOPhfu0Vzecdj%r*xS`{Xb_{IYiT2!{JK_Ho0%yXBx*`ozm}mkB8xg zIe$ku?DcGFxL~wkEJe2Z8MhZA9zxEj+ZPtY_M*jzb_fJuhzW88uQ$%RePG?sf_sbH z;D(T(iN_rU!sB|uD2ozk5zQ3BU)Gb*kvzBvK40L19DOYjnhwJjDz%-dEiHH)ngbN#BNI#Tm!#M}%Gk*#eJb33#w7Dmk5ADDFIIrE3_ zxNT=8eltT@gtJI;dtd&B&SIa=1gq+XnZ-R)DcvVM5PFzut^o@n_H=;GQoAZ0b+ z4Hq4h9`it@EziB8xo4(I9znl)aT5LNw&mis)uOVMqV3V5?Ten*ec$&*>$+}D-g*jl z@uF_%xTHN=-1fi%ca!e!6;QbaE0(ILrRvqW70b?L%TBQDULJaBXtkz)b?d-AEoCYH zj8a>2;ojU`QOnNH+sOMj4@a7ZAHp<c0S}Gghahj)mNs!V=3&@nC{pNaQu#~s-H5wbFi$h(fYFz1026wvbDe1_)m33 z{dVnp8dG1f^*yT*u6(b|)VI_0UZbh+@xu4^;g#=Grv3u!`^BdII?MZZQ-6ms#fMOW z0HP0(Y_WlM_tUPxgZ=59y*Bvp_?u* zD>OpA00F&OyadHl-&8lxzzBIT5?}@X3fEhI#C({nw}FWH$OEmKtTO{;$zL&7M9mco z&Ku*ej7KYHZXJnK&cyr2A}61W^*;s25!W;{N6ruv)Cl*j^&rrO03C09@G*HFqXDag zMfQ!uZ*j|%YpY4hr)A2;KxRc^K8f0AumnaGkDmDNnQfU&F?ZFtMIITWL{bwGcW6rp z$+RRS=xFL5s-AsL$eo28o^M1-lk&iRg1UQkc7Au&vlCRu7CS%T(*mt)COcr>N};Ki81ej;GlugmTCxB|1@5X3!Q zv+mI3j4O002*;8)3K0Upb{Egiy8R*VBrgnR5wky}GngWZ$Jf40eN@yw-}5mbXwEA+ zi>?}f#fhA0||R>0`_**T#kDVt+Wv9o<*6MgiPNFybN8%6_lNRvP` zgG7pE+?)r~%N}8G7Us$+8<2{OH_qbYo&X|+iW0G)TNf89V&zY~y(?~Tq=kv%jgLnT zo`^M$;uT|&Q^C82bL;R6LE$pG1(}f@>0>J^N>*{&W=>kA~a@^pUjtE!oDYg078~l zUpcc_ztnqc@9myQ_h_tmY`#~ndgw*ZW#@AK&ZVA}hR34~kH2+hrD0&XVc=t^e<+UU z?EooiJOVDu&S?Hl@FYr3(w{U8z$IY5?}UGme6Y#4!gF4)>gv$n(VDwe1oXv9I^YBFeQ6Ru#83fY& zu!wEX{7~HdDDl6*gMxn_q7#Ks@V&y#Kmppw>&IbL|5J+}L?V+dO*3lwiNMyh%BJ++ zN*opte}l-_=Ad9+fdVZFVL7+}G9FhNEcV@aj9B2{IG{JzEPo;;Je*2d_oB6wzL4cY zDA^dwYAtPa;|ut_lR&`iEw10h+9y$qtL7#4iaDOwnvMYWwE%!F#UcX0KNN0u{VO`B z4nR}UIiPV`{$J!$OYjmRrz2G$qR4@PPP{oZ*6g%{dlvTGCM8R$Jdrbg+3fIi$x6TR zEAUW7%T!(QE|xBByQPm6@7uhPErwCZ8qcXxD)149n9yge} z4p{H(CC8nn?mQE*;vDu;!Qdk9gUco4xLn}mhJ7p?n_Vs}P(;T9epy{G1sos3YG+7< zy2M~f70xA87E#0Bp8t5AdEn9UL-ywXsL0K2BEk=L2?<0 zQ0#(;3YdbEE_VoUevvYP=^+@wB<5%8|GSB8$US%p@gQCkRHCI8sk7g0zDf=e;7(OK1M0O zG#@1Ulu0{LBN}Dyl!HvQmU5pYB9!T#4Dp~uq$%rNO1wyUIG8@;R{7Uy(4KN2A&)u% z)y7R!Sk(vl&U5*UrbXpY4Z`f8@f=nKX-3EBn^YJ}lsEZa^89?cHL=H~f*Z_~66IpF zTcNjV(xaEM8|DlhvryH|UFw+YJ;d~OIeTr+uI`~;U?J8y@V0ud$2Q<__V)KOwh?CF zXcu$JHrRWr!6s`$o3r<%lYD~UI59M2pQ{;g9PeeEc+;@b%V^ioiQeP3`pyPhr~T7o zMX;{WF2}mjufm>`V%$`H%HWAcXt3u%Df2m>H^jCGda(2GI|M=HnGkZ^9?u~k>-L9( zf_Bmu;8{V>USua>0ShMS0HRfw1TE+GPqTu~vfo6`zYKTsUj_m$UqSh`-QU{1xHDGJIIsD-9-jnLco1gJ{Cj=co<8E$ihu~+x z>*ub(pacU52ImRS1_hn?afD!;VK0iTPPhBHzrZ`Hv$9sa>l$3dqgnypT1#Gj{;SVJ zIgIzZcS-Yx{-%DVeqXeHU#z|(R`u9QRd=+iJ66>bv-Yl7hojcvnDto1aExHKV=@^% zaMclBya-5@Ty36kr7|HDLybKOZoJe}P}2mTHF4|~YE;)m2lb39Xc*P75CehAaRUYh zGFj$`{-a>L$@41nCmBB&UGlGnB(A^{^qIIls#M7X=73IFb591GOpCXpDoxu)6hus% z82gHhZX@d;ThKUa+#%g0elKx!>!>#4$c^$0z{rh^u>tG0r0q17{41y%{ClEN3gz>O zJ**3K8qzj-j^S)c+9~*%T$$peMl$p|UM}A?3Y9h|a>A-Ts0Uy!&;51k*v_g3flHD` z$dTVD`F^=go;`Tm1mfoa;^)ZUi=fRfhLwrwieAH9ti9A_(+*Az>rB8{4o!gHILoJ~zCt z7y2f#Fvt#p`V=$}@?-@yUV$sK4mL5(FQtME@+=888%`uv(efr@qxbZw-KwDt6HG#$S~^vd}d@y_#FFT2guC6x0?Lbj=@HwU&Ko-L{%n7%APgH2KEN z&6z09d92+LYjP}?IwHrM%caicJm(elYF^1T%T>#QH=YN19&4n~zHGKb59WfGkG^zt zVSC(cTP-ZTw)g7ZYn@j+7h7V5yH^VLMGN=E3OnX|J}E3+%`3*N_uxx|3!1pOa<$}< zh341MyZaAr&e6{dRO{}u*P0do_V$F@Ig{e0+ z(aN@1dHY-H+dV%q#tq%8*1~zBgsa+#5`MQNzvrMiMd}qL{3J};ADXw2VMSijjH-!F zENfCatR~izgZmmhM$Rz*2#&?5d3qkGngqb)NCpJa+~lJIKPy#u2>Y@M!3KA}fwHlc zysI*iP!3#qy)wRIZ*0f@Sb0a}@sY@ZV|NYA+7yN20{k=S0bmrQEPWdXMJjHI>R@?h zNCG26)G<@U8q9XYB-p9}i884Q2D1JD!>S}0u02gBtB4dBPX4qHvUZYjtpcdkim_*6 z;G$9M;BbndIl-a|XyD!iB7d;w2!saw5X(fR$Nepm+dxtixMH?LD&G!W2qeD5B+D5v z35tq%S6lO*M_mLQ4zJJG&G+;-IF@5TAj)YieEGSTo{LvDMw)tKm3=X5|GY+F{;P!x z&hM5l_QVZ#rEQ_~O=qmO<4xz%ND{l_k;IH< zkr@ZpXU6(>gJnYTs7jM^tvJ^=p1%IH{LN0R@`+e^*W0ak4TsmRHFT4SN%{=wB=dYi ztFqeQGHJ`0QPqH+qX<3rYG6+EZt6(&xpRd#QK@ zKWv!bp!NZ@zenia5gFvUd$r>rCh$ z1NCdgvT2PtKFo#pZ}j>bC2Io<0T3`VPh=aJ$gm#~0dgF&v#!Xa`2EiXZ)gU;`kew% zop4W{6&&kN>KELWBZ@HYj}#tJ_JzEE+wX`UG?d9ICj{r+zZ(DXGoA)+@Avx^e&et zFbPrB>)1q0S~52%6x6>5v_@1(WINl^8`{Ty050Ee`8n$Y9)?{q5dxcDUt=?Wv#XaKof5O6R8 zUnT;)H+0Ef$VK5H7^R*HV-YlrAMuU*OC(Pr`6iw>g#B1HgfJVMy;U-k9CegFUf literal 0 HcmV?d00001 diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/test_probe.cpython-314.pyc b/devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/test_probe.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e005de22f909ecb804f12239d0cd0edb1a2d67c GIT binary patch literal 18804 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 literal 0 HcmV?d00001 diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py index 8f2aa1066f7..80859ac8395 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py @@ -10,6 +10,20 @@ from dataclasses import dataclass from enum import Enum from uuid import UUID +import re + +# Rust's Uuid::parse_str accepts exactly four textual shapes - hyphenated, simple, +# braced-hyphenated and the urn:uuid prefix. Python's UUID() is looser: it tolerates +# arbitrary hyphen placement inside a 32-hex payload, so wrapper/hyphen variants +# must be rejected by shape before parsing instead of leaning on the constructor. +_UUID_ACCEPTED_SHAPES = re.compile( + r"(?:" + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + r"|[0-9a-fA-F]{32}" + r"|\{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\}" + r"|urn:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + r")\Z" +) class ConnectionOrigin(Enum): @@ -30,6 +44,8 @@ def _validate_ids(value: tuple[str, ...], label: str) -> None: def _validate_thread_id(value: str, label: str) -> None: """Upstream parses ThreadId as a UUID; a non-UUID string must not widen the list.""" + if not isinstance(value, str) or not _UUID_ACCEPTED_SHAPES.match(value): + raise ValueError(f"{label} must be a UUID thread id") try: UUID(value) except (ValueError, AttributeError, TypeError): diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py index eba438a51e4..58d07be97f2 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py @@ -63,6 +63,30 @@ def test_related_thread_ids_follow_native_validation(self): ): with self.subTest(relation=relation), self.assertRaises(ValueError): self.resolve(**relation) + # Python's UUID() tolerates hyphen shapes Rust Uuid::parse_str rejects: + # shifted hyphens, a bare 32-hex blob inside braces, an uppercased URN, and + # a braced simple string all stay invalid no matter the provider shape. + malformed = ( + "000-00000-0000-4000-8000-0000000000aa", + "{000000000000400080000000000000aa}", + "URN:UUID:00000000-0000-4000-8000-0000000000aa", + "{000000000000-4000-8000-0000000000aa}", + ) + for key in ("parent_thread_id", "ancestor_thread_id"): + for bad in malformed: + for requested in (None, ("openai",)): + with self.subTest(key=key, bad=bad, requested=requested), self.assertRaises(ValueError): + self.resolve(**{key: bad}, requested=requested) + # The four shapes upstream accepts stay valid for both relations. + for key in ("parent_thread_id", "ancestor_thread_id"): + for good in ( + "00000000-0000-4000-8000-0000000000aa", + "000000000000400080000000000000aa", + "{00000000-0000-4000-8000-0000000000aa}", + "urn:uuid:00000000-0000-4000-8000-0000000000aa", + ): + with self.subTest(key=key, good=good): + self.assertIsNone(self.resolve(**{key: good})) with self.assertRaises(ValueError): self.resolve( parent_thread_id="00000000-0000-4000-8000-0000000000aa", From 96148ab724a02f3b1e3010345ead91f92f1f2035 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:53:21 +0900 Subject: [PATCH 6/8] chore(probes): drop accidentally committed __pycache__ (#6157) --- .../__pycache__/native_policy.cpython-314.pyc | Bin 6627 -> 0 bytes .../remote_list_probe.cpython-314.pyc | Bin 11283 -> 0 bytes .../test_native_policy.cpython-314.pyc | Bin 17811 -> 0 bytes .../__pycache__/test_probe.cpython-314.pyc | Bin 18804 -> 0 bytes 4 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/native_policy.cpython-314.pyc delete mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/remote_list_probe.cpython-314.pyc delete mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/test_native_policy.cpython-314.pyc delete mode 100644 devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/test_probe.cpython-314.pyc diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/native_policy.cpython-314.pyc b/devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/native_policy.cpython-314.pyc deleted file mode 100644 index a552271f3153121ba4b3334eab183f94c7fee2a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6627 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 diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/test_probe.cpython-314.pyc b/devlog/_plan/260928_remote_thread_provider_policy/probes/__pycache__/test_probe.cpython-314.pyc deleted file mode 100644 index 7e005de22f909ecb804f12239d0cd0edb1a2d67c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18804 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 From 858add4fa7aeabb1a50c6f65ea3f6a72a9cb8674 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:05:21 +0900 Subject: [PATCH 7/8] ci(devlog): persist no checkout credentials; probe bridge forwards close codes (lidge-jun#6157) The devlog-probes workflow runs pull-request Python fixtures, so the checkout must not retain the GITHUB_TOKEN git credential. The loopback bridge's pump now forwards the peer's close code to its destination instead of leaving the other side hanging on an already-ended socket. --- .github/workflows/devlog-probes.yml | 2 ++ .../probes/test_loopback_bridge.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/devlog-probes.yml b/.github/workflows/devlog-probes.yml index f7e716a3c83..cc852a89817 100644 --- a/.github/workflows/devlog-probes.yml +++ b/.github/workflows/devlog-probes.yml @@ -22,6 +22,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.x" diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py index 1d2c840c095..dcdc19ac8fb 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py @@ -80,6 +80,11 @@ async def pump(source, destination, transform: bool): await destination.pong(message.data) else: break + # Forward the peer's close so the other side sees the same code + # instead of hanging on a connection that already ended. + close_code = getattr(source, "close_code", None) or 1000 + if not destination.closed: + await destination.close(code=close_code) tasks = [asyncio.create_task(pump(upstream, downstream, True)), asyncio.create_task(pump(downstream, upstream, False))] From f43e66b395b82b05620bc1bdbf8eb332af16b116 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:29:18 +0900 Subject: [PATCH 8/8] test(devlog): cover bidirectional close-code forwarding in the bridge fixture (lidge-jun#6157) The pump close-forwarding path had no coverage: host-initiated closes now assert the backend sees 1001, and backend-initiated closes assert the host sees 1011. --- .../probes/test_loopback_bridge.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py index dcdc19ac8fb..2438a4334a6 100644 --- a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py +++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py @@ -116,6 +116,9 @@ async def asyncSetUp(self): self.received = asyncio.Queue() self.handshake_headers = {} self.http_received = [] + self.backend_closed = asyncio.Event() + self.backend_close_code = None + self.backend_close_on_connect = None self.backend_app = web.Application() self.backend_app.router.add_route("*", "/backend-api/{tail:.*}", self.mock_backend) self.backend_runner, self.backend_base = await start_loopback_app(self.backend_app) @@ -141,11 +144,16 @@ async def mock_backend(self, request: web.Request): await websocket.prepare(request) for frame in self.frames: await websocket.send_str(frame) + if self.backend_close_on_connect is not None: + await websocket.close(code=self.backend_close_on_connect) + return websocket async for message in websocket: if message.type == aiohttp.WSMsgType.TEXT: await self.received.put(message.data) elif message.type == aiohttp.WSMsgType.PING: await websocket.pong(message.data) + self.backend_close_code = websocket.close_code + self.backend_closed.set() return websocket body = await request.read() self.http_received.append((request.path, body, selected_headers(request.headers))) @@ -204,6 +212,24 @@ async def test_single_chunk_transport(self): self.assertEqual(incoming["seq_id"], 7) self.assertEqual(incoming["cursor"], "mock-backend-cursor") + async def test_host_close_code_reaches_backend(self): + async with self.host.ws_connect(self.relay_base + WS_PATH, + headers={"Authorization": MOCK_AUTH}) as host: + await host.close(code=1001) + await asyncio.wait_for(self.backend_closed.wait(), 5) + self.assertEqual(self.backend_close_code, 1001) + + async def test_backend_close_code_reaches_host(self): + self.backend_close_on_connect = 1011 + async with self.host.ws_connect(self.relay_base + WS_PATH, + headers={"Authorization": MOCK_AUTH}) as host: + async for message in host: + if message.type in (aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSING): + break + self.assertEqual(host.close_code, 1011) + async def test_enrollment_http_forwarding_with_fake_credentials(self): body = b'{"name":"mock-host","installation_id":"mock-installation"}' async with self.host.post(self.relay_base + WS_PATH + "/enroll", data=body,