Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1e3a1c9
docs(codex): propose native remote-list provider policy with isolated…
luvs01 Sep 28, 2026
a0f933c
docs-codex: reject malformed thread ids in the native-policy spec
luvs01 Sep 28, 2026
3b29117
docs(codex): validate UUID thread ids, fix probe counts, and run prob…
luvs01 Sep 28, 2026
787ef30
ci(probes): pin actions to full commit SHAs per repo policy (#6157)
luvs01 Sep 28, 2026
bb28275
fix(probes): enforce upstream UUID shapes, deterministic aiohttp, con…
luvs01 Sep 28, 2026
96148ab
chore(probes): drop accidentally committed __pycache__ (#6157)
luvs01 Sep 28, 2026
858add4
ci(devlog): persist no checkout credentials; probe bridge forwards cl…
luvs01 Sep 28, 2026
f43e66b
test(devlog): cover bidirectional close-code forwarding in the bridge…
luvs01 Sep 28, 2026
5dc1a73
Merge current dev and refresh remote-list probe verification
luvs01 Sep 30, 2026
bd317d0
docs: distinguish PR head metadata from merge-ref checkout evidence
luvs01 Sep 30, 2026
16d779c
Merge current dev and refresh remote-list probe evidence
luvs01 Oct 1, 2026
b3ff2be
Merge current dev snapshot update
luvs01 Oct 1, 2026
d4405a8
Merge current dev and refresh remote-list RFC verification
luvs01 Oct 2, 2026
f53277d
Merge upstream dev 3bb7a561 into PR #6157
luvs01 Oct 13, 2026
d767d2f
Merge upstream dev 6da27743 into PR #6157
luvs01 Oct 13, 2026
2427603
Merge remote-tracking branch 'origin/dev' into pr-6157-work
Oct 15, 2026
278799c
docs(codex): keep the remote-list RFC probes local-only
Oct 15, 2026
2db37d5
docs(codex): rewrite the remote-list RFC verification as a local record
Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions devlog/_plan/260928_remote_thread_provider_policy/000_plan.md
Original file line number Diff line number Diff line change
@@ -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).
134 changes: 134 additions & 0 deletions devlog/_plan/260928_remote_thread_provider_policy/010_design.md
Original file line number Diff line number Diff line change
@@ -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<Vec<String>>` 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Verification

Status: local evidence for an RFC. The probes are executable specifications that
run on demand; no CI workflow runs them, and nothing in the OpenCodex build, test,
or runtime path imports them. All inputs, accounts, tokens, thread ids, and
database rows are synthetic. No native Codex process, user home, installed
config, or real service is accessed.

## How to run

From the repository root:

```sh
cd devlog/_plan/260928_remote_thread_provider_policy/probes
python3 -m unittest -v test_native_policy test_probe
```

The socket suite needs aiohttp. Install it into a scratch directory, not the
repository or a system site-packages:

```sh
python3 -m pip install --target "$SCRATCH_DIR" aiohttp
PYTHONPATH="$SCRATCH_DIR" python3 -m unittest -v test_native_policy test_probe test_loopback_bridge
```

## Inventory

63 tests across three suites:

- 23 tests (`test_native_policy`) specify the proposed native policy: opt-out,
trusted-origin scoping, explicit arrays, null semantics, parent/ancestor
exceptions, immutable policy, exact ids, UUID thread-id validation, and
synthetic paginated fixture reads.
- 29 raw-frame tests (`test_probe`) cover the relay alternative's
ordinary/single-chunk rewrites, byte-identical pass-through, limits, and
unsupported inputs.
- 11 localhost HTTP/WebSocket tests (`test_loopback_bridge`) use a mock host and
mock backend, checking two-way traffic, fixture auth/headers, explicit filters,
enrollment, unrelated HTTP, fixture endpoint restrictions, and both directions
of close-code forwarding.

The first 52 use the Python standard library only.

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.

## Recorded runs

| Date | Environment | Suites | Result |
| --- | --- | --- | --- |
| 2026-09-28 | author, local | native policy + raw frame | 52 passed |
| 2026-10-13 | Linux, Python 3.12.14, aiohttp 3.14.4 | all three | 63 passed |
| 2026-10-15 | macOS, Python 3.9.6, aiohttp 3.13.5 | all three | 63 passed |

Each run reflects the probe source at that time; a later edit to a probe needs a
fresh local run.

## 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.
- Production multi-segment relay support or management/security review of a relay.
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""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
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):
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")


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):
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.
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<Vec<String>> 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")
for label, tid in (
("parent_thread_id", parent_thread_id),
("ancestor_thread_id", ancestor_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:
# Preserve every typed client array, including duplicate/empty ids.
# Native Vec<String> 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,)
Loading
Loading