From 5a7d90f80f47647ae524055322eb75bebd90b9c2 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 15 Oct 2026 07:51:56 +0900 Subject: [PATCH 1/4] test(codex): pin unsupported generation against weak native evidence Native Codex 0.162.1 exposes no per-incarnation identity and no atomic original-generation precondition for queued input: the writer lock file is empty, sessionId survives resume, thread/queue/add takes only the thread, input and a client message ID, and queued rows are consumed by whichever process has the thread loaded. Keep Codex generation unsupported and add negative fixtures proving that stable writer evidence, lock content, queue and client message IDs, direct-input capability, sessionId, turn IDs and writer restarts never mint a target generation or start any effect. Document the pinned source evidence. --- docs/cli-reference.md | 2 + docs/i18n-manifest.json | 2 +- docs/ja/cli-reference.md | 2 + docs/ko/cli-reference.md | 2 + docs/zh-CN/cli-reference.md | 2 + tests/codex/test_generation_unsupported.py | 149 +++++++++++++++++++++ 6 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 tests/codex/test_generation_unsupported.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index beb9023f..34834728 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -121,6 +121,8 @@ persisted queue row. Their existing separate identity contracts remain intact. The broader cross-agent work in #180 remains open; this development feature is not shipped in v1.0.4 and adds no automatic fallback or resubmission. +The 2026-10-15 review of native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) found no per-incarnation identity and no atomic original-generation precondition. The thread writer lock file carries no content, a thread's `sessionId` survives resume, `thread/queue/add` accepts only the thread, input and a client message ID, and queued rows are consumed by whichever process currently has the thread loaded. `turn/steer` with `expectedTurnId` is atomic only for an active turn on one app-server and is not used as a generation. Writer PID, start-time and lock rechecks, queue IDs, `canAcceptDirectInput` and `sessionId` therefore never produce a Codex `targetGeneration`; Codex rows stay `unsupported` until native Codex exposes an incarnation token that its queue checks atomically. + `--message TEXT` (short form `-m`) names the text sent to the destination. `--output-format text|json` selects the command result format, not the message format. It is available on `list`, `send`, `doctor`, and `update`; the default is diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index d497e8a4..32614ef8 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "7fbc82834883a13e91f3a31b058296eefca7117bd7dd08409e44faccc82e1f03", + "docs/cli-reference.md": "037c2a5cd5e8d44dee7b351faca4930b8ed548348193f08d8606b49dafd34c4e", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 36c4fcad..e8931c66 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -35,6 +35,8 @@ Codex キュー、Antigravity とペアリング済み機器には、この受 Codex の書き込みプロセスを再確認しても、後からキューを消費するプロセスを原子的に制限できません。 #180 全体は未完了です。v1.0.4 の公開機能ではなく、自動フォールバックや再送も追加しません。 +2026-10-15 の native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) の確認では、プロセス世代ごとの識別子や原子的な元世代の前提条件は見つかりませんでした。スレッドの書き込みロックファイルには内容がなく、スレッドの `sessionId` は再開後も維持され、`thread/queue/add` が受け取るのはスレッド、入力、クライアントのメッセージ ID だけです。キューに保存された項目は、その時点でスレッドを読み込んでいるプロセスが消費します。`expectedTurnId` を伴う `turn/steer` は 1 つの app-server のアクティブなターンに対してのみ原子的なため、世代として使いません。そのため、書き込みプロセスの PID・開始時刻・ロックの再確認、キュー ID、`canAcceptDirectInput`、`sessionId` から Codex の `targetGeneration` を作ることはなく、native Codex がキューで原子的に検査するプロセス世代トークンを提供するまで、Codex の行は `unsupported` のままです。 + コマンドの詳細な動作、インストールオプション、トランスポートの制限、および過去の検証結果について以下に説明します。 機械consumerは[v1互換性契約](compatibility-v1.md)にも従ってください。 diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index 0865cf65..c018fa36 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -35,6 +35,8 @@ Codex 큐, Antigravity와 페어링 기기는 이 수신 프로세스 세대 보 Codex의 작성자 재확인만으로 나중에 큐를 소비하는 작성자를 원자적으로 제한할 수는 없습니다. #180의 전체 작업은 아직 열려 있습니다. v1.0.4에 발행된 기능이 아니며 자동 대체 전송·재전송도 없습니다. +2026-10-15 native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) 검토에서는 프로세스 세대별 식별자나 원자적 원본 세대 전제조건을 찾지 못했습니다. 스레드 쓰기 잠금 파일에는 내용이 없고, 스레드의 `sessionId`는 재개 후에도 유지되며, `thread/queue/add`는 스레드, 입력, 클라이언트 메시지 ID만 받습니다. 큐에 저장된 항목은 그 시점에 스레드를 불러온 프로세스가 소비합니다. `expectedTurnId`를 쓰는 `turn/steer`는 한 app-server의 활성 턴에 대해서만 원자적이므로 세대로 사용하지 않습니다. 따라서 쓰기 프로세스의 PID·시작 시각·잠금 재검사, 큐 ID, `canAcceptDirectInput`, `sessionId`로는 Codex `targetGeneration`을 만들지 않으며, native Codex가 큐에서 원자적으로 검사하는 프로세스 세대 토큰을 제공하기 전까지 Codex 행은 `unsupported`로 유지됩니다. + 명령어의 상세 동작, 설치 옵션, 전송 한계 및 과거 검증 내역은 아래와 같습니다. 기계 소비자는 [v1 호환성 계약](compatibility-v1.md)도 따라야 합니다. diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index 3e7b457e..e4557c75 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -31,6 +31,8 @@ Codex 队列、Antigravity 和配对设备不具备此接收进程代次保证 wake 前拒绝,并保留各自既有契约。重新核实 Codex 写入进程,不能原子地限制之后消费 持久队列的进程。#180 的完整工作仍未完成;该功能尚未在 v1.0.4 发布,不会自动回退或重发。 +2026-10-15 对 native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) 的审查未发现按进程代次区分的标识,也未发现原子的原始代次前置条件。线程写入锁文件没有内容,线程的 `sessionId` 在恢复后保持不变,`thread/queue/add` 只接受线程、输入和客户端消息 ID,队列中保存的条目由当时加载该线程的进程消费。带 `expectedTurnId` 的 `turn/steer` 仅对单个 app-server 上的活动轮次是原子的,因此不将其用作代次。所以写入进程的 PID、启动时间和锁的复查、队列 ID、`canAcceptDirectInput` 以及 `sessionId` 都不会生成 Codex `targetGeneration`;在 native Codex 提供可在队列中原子检查的进程代次令牌之前,Codex 行保持 `unsupported`。 + 以下是详细的命令行为、安装选项、传输限制和历史验证记录。 机器consumer还应遵循[v1兼容性契约](compatibility-v1.md)。 diff --git a/tests/codex/test_generation_unsupported.py b/tests/codex/test_generation_unsupported.py new file mode 100644 index 00000000..984473cd --- /dev/null +++ b/tests/codex/test_generation_unsupported.py @@ -0,0 +1,149 @@ +"""Negative fixtures: weak Codex evidence is never promoted to a generation. + +Native Codex 0.162.1 (rust-v0.162.1, 092d3acd6bec) exposes no per-incarnation +identity and no atomic original-generation precondition for queued input: +the writer lock file is empty, `sessionId` survives resume, `thread/queue/add` +takes only thread/input/client message ID, and queued rows are consumed by +whichever process has the thread loaded. These offline tests prove that +session-peer keeps Codex generation `unsupported` whatever weak evidence is +present. They are not evidence of native delivery or consumption. +""" +import argparse +import contextlib +import io +import json +import os +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest import mock + +import session_peer as peer +from tests.codex.support import THREAD + +CLAUDE_SHAPED = "tg1:" + "a" * 64 + + +class CodexGenerationUnsupported(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name).resolve() + for name in ("codex_system_config_paths", "codex_managed_preference_paths"): + if hasattr(peer, name): + layer = mock.patch.object(peer, name, return_value=[]) + layer.start() + self.addCleanup(layer.stop) + + def invoke(self, *argv): + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = peer.main([*argv, "--json", "--no-update-notice"]) + return code, json.loads(out.getvalue()) + + def strongest_weak_row(self, **extra): + # Every field a caller might mistake for an incarnation guarantee. + return {"agent": "codex", "id": THREAD, "name": "worker", "status": "active", + "archived": False, "cwd": "/fixture", "codexHome": str(self.root), + "activity": "live_writer", "writerLock": "held", "ownerPid": 4242, + "ownerStable": True, "ownerStartTime": 1700000000.25, + "queueId": "01900000-0000-7000-8000-0000000000aa", + "clientUserMessageId": "01900000-0000-7000-8000-0000000000bb", + "canAcceptDirectInput": True, "sessionId": THREAD, + "queueRevision": 7, "expectedTurnId": "01900000-0000-7000-8000-0000000000cc", + **extra} + + def test_listing_never_mints_codex_generation_from_writer_queue_or_session_evidence(self): + row = self.strongest_weak_row() + with mock.patch.object(peer.CodexAdapter, "list", return_value={ + "sessions": [row], "discovery": {"status": "ok"}}), \ + mock.patch.object(peer, "claude_generation") as minted: + code, result = self.invoke("list", "--agent", "codex", "--with-target-generation") + self.assertEqual(code, 0) + listed = result["sessions"][0] + self.assertIsNone(listed["targetGeneration"]) + self.assertEqual(listed["generationStatus"], "unsupported") + minted.assert_not_called() + + def test_real_discovery_with_stable_writer_and_lock_content_stays_unsupported(self): + with contextlib.closing(sqlite3.connect(self.root / "state_5.sqlite")) as conn: + conn.execute("CREATE TABLE threads (id TEXT, title TEXT, cwd TEXT, updated_at INTEGER, archived INTEGER, rollout_path TEXT)") + conn.execute("INSERT INTO threads VALUES (?,?,?,?,?,?)", + (THREAD, "fixture", str(self.root), 1, 0, "unused")) + conn.commit() + locks = self.root / "thread-writer-locks" + locks.mkdir() + # Native writers leave the lock empty; any content is not a token either. + for content in (b"", b"pid=4242 generation=" + b"f" * 64, CLAUDE_SHAPED.encode()): + with self.subTest(content=content[:16]): + (locks / f"{THREAD}.lock").write_bytes(content) + stable = {"activity": "live_writer", "writerLock": "held", "ownerPid": 4242, + "ownerStable": True, "ownerStartTime": 1700000000.25, + "reason": "stable_live_writer"} + with mock.patch.object(peer, "inspect_codex_writer", return_value=stable), \ + mock.patch.object(peer, "claude_generation") as minted: + code, result = self.invoke("list", "--agent", "codex", "--codex-home", + str(self.root), "--with-target-generation") + self.assertEqual(code, 0) + rows = [row for row in result["sessions"] if row.get("id") == THREAD] + self.assertEqual(len(rows), 1) + self.assertIsNone(rows[0]["targetGeneration"]) + self.assertEqual(rows[0]["generationStatus"], "unsupported") + minted.assert_not_called() + + def test_writer_restart_between_list_and_send_cannot_upgrade_a_pin(self): + # A successor resuming the same thread is the race the issue describes. + for owner in (4242, 4243): + with mock.patch.object(peer.CodexAdapter, "list", return_value={ + "sessions": [self.strongest_weak_row(ownerPid=owner)], + "discovery": {"status": "ok"}}): + code, result = self.invoke("list", "--agent", "codex", "--with-target-generation") + self.assertEqual(code, 0) + self.assertIsNone(result["sessions"][0]["targetGeneration"]) + for token in (CLAUDE_SHAPED, "tg1:" + "0" * 64): + for options in ([], ["--dry-run"], ["--wake"], ["--host", "test-host"]): + with self.subTest(token=token[:8], options=options), \ + mock.patch.object(peer, "queue_codex") as queue, \ + mock.patch.object(peer, "run_codex_wake") as wake, \ + mock.patch.object(peer.SshTransport, "execute") as remote, \ + mock.patch.object(peer, "tailscale_status"), \ + mock.patch.object(peer.subprocess, "run") as child: + code, result = self.invoke("send", "--to", "codex:" + THREAD, + "--target-generation", token, "--message=x", + "--no-from", "--no-reply-to", *options) + self.assertEqual(code, 1) + self.assertEqual(result["reason"], "unsupported_target_generation") + self.assertIs(result["submitted"], False) + self.assertIs(result["retryAllowed"], False) + self.assertNotIn("lastSeenTarget", result) + for effect in (queue, wake, remote, child): + effect.assert_not_called() + + def test_successful_queue_result_never_carries_a_generation(self): + queued = {"ok": True, "target": {"agent": "codex", "id": THREAD}, "chars": 1, + "dryRun": False, "codexHome": str(self.root), "submitted": True, + "consumptionConfirmed": False, "status": "queued", + "queueId": "01900000-0000-7000-8000-0000000000aa"} + with mock.patch.object(peer, "queue_codex", return_value=queued): + code, result = self.invoke("send", "--to", "codex:" + THREAD, "--message=x", + "--no-from", "--no-reply-to") + self.assertEqual(code, 0) + self.assertEqual(result["status"], "queued") + self.assertEqual(result["queueId"], queued["queueId"]) + self.assertNotIn("targetGeneration", result) + self.assertIs(result["consumptionConfirmed"], False) + + def test_codex_adapter_contract_has_no_generation_capability(self): + adapter = peer.AGENTS.for_target("codex:" + THREAD) + args = argparse.Namespace(to="codex:" + THREAD, target_generation=CLAUDE_SHAPED, + wake=False, host=[]) + with self.assertRaises(peer.CcPeerError) as caught: + adapter.validate_send(args, "x") + self.assertEqual(caught.exception.details["reason"], "unsupported_target_generation") + args.target_generation = None + adapter.validate_send(args, "x") + + +if __name__ == "__main__": + unittest.main() From f9d1effbd9a63d1dee2314bcc93681056c99a15e Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 15 Oct 2026 08:03:40 +0900 Subject: [PATCH 2/4] test(codex): isolate generation fixtures and scope source findings --- docs/cli-reference.md | 2 +- docs/i18n-manifest.json | 2 +- docs/ja/cli-reference.md | 2 +- docs/ko/cli-reference.md | 2 +- docs/zh-CN/cli-reference.md | 2 +- tests/codex/test_generation_unsupported.py | 38 +++++++++++++--------- 6 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 34834728..d2a9f719 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -121,7 +121,7 @@ persisted queue row. Their existing separate identity contracts remain intact. The broader cross-agent work in #180 remains open; this development feature is not shipped in v1.0.4 and adds no automatic fallback or resubmission. -The 2026-10-15 review of native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) found no per-incarnation identity and no atomic original-generation precondition. The thread writer lock file carries no content, a thread's `sessionId` survives resume, `thread/queue/add` accepts only the thread, input and a client message ID, and queued rows are consumed by whichever process currently has the thread loaded. `turn/steer` with `expectedTurnId` is atomic only for an active turn on one app-server and is not used as a generation. Writer PID, start-time and lock rechecks, queue IDs, `canAcceptDirectInput` and `sessionId` therefore never produce a Codex `targetGeneration`; Codex rows stay `unsupported` until native Codex exposes an incarnation token that its queue checks atomically. +The 2026-10-15 review of the queued-input paths in native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) did not identify a supported precondition that atomically checks the original writer incarnation. In the inspected source, the lock-acquisition path does not write an incarnation token, a thread's `sessionId` survives resume, and `thread/queue/add` accepts a thread, input and a client message ID rather than a generation precondition. The inspected `turn/steer` path checks `expectedTurnId` under the active-turn lock; session-peer does not use that turn-specific check as a writer generation. These findings are limited to the pinned paths, not a proof about every native API or future version. Writer PID, start-time and lock rechecks, queue IDs, `canAcceptDirectInput` and `sessionId` do not establish the required original-incarnation guarantee; session-peer continues to report Codex `targetGeneration` as `unsupported`. #180 remains open for a separately validated native contract. `--message TEXT` (short form `-m`) names the text sent to the destination. `--output-format text|json` selects the command result format, not the message diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index 32614ef8..ed4bade2 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "037c2a5cd5e8d44dee7b351faca4930b8ed548348193f08d8606b49dafd34c4e", + "docs/cli-reference.md": "7f54c0d10f8252417e09e6c77edc20e8958e6f8b0bc4342210d836558f7f8c50", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index e8931c66..fed7d454 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -35,7 +35,7 @@ Codex キュー、Antigravity とペアリング済み機器には、この受 Codex の書き込みプロセスを再確認しても、後からキューを消費するプロセスを原子的に制限できません。 #180 全体は未完了です。v1.0.4 の公開機能ではなく、自動フォールバックや再送も追加しません。 -2026-10-15 の native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) の確認では、プロセス世代ごとの識別子や原子的な元世代の前提条件は見つかりませんでした。スレッドの書き込みロックファイルには内容がなく、スレッドの `sessionId` は再開後も維持され、`thread/queue/add` が受け取るのはスレッド、入力、クライアントのメッセージ ID だけです。キューに保存された項目は、その時点でスレッドを読み込んでいるプロセスが消費します。`expectedTurnId` を伴う `turn/steer` は 1 つの app-server のアクティブなターンに対してのみ原子的なため、世代として使いません。そのため、書き込みプロセスの PID・開始時刻・ロックの再確認、キュー ID、`canAcceptDirectInput`、`sessionId` から Codex の `targetGeneration` を作ることはなく、native Codex がキューで原子的に検査するプロセス世代トークンを提供するまで、Codex の行は `unsupported` のままです。 +2026-10-15 に native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) のキュー入力経路を確認しましたが、元の書き込みプロセスの世代を原子的に検査する、利用可能な前提条件は見つかりませんでした。確認したソースでは、ロック取得経路は世代トークンを記録せず、スレッドの `sessionId` は再開後も維持され、`thread/queue/add` は世代の前提条件ではなくスレッド・入力・クライアントメッセージ ID を受け取ります。確認した `turn/steer` 経路はアクティブターンのロック内で `expectedTurnId` を検査しますが、session-peer はこのターン固有の検査を書き込みプロセスの世代として使いません。これは固定した経路に限った調査であり、すべての native API や将来のバージョンで機能が存在しないことを証明しません。PID・開始時刻・ロックの再確認、キュー ID、`canAcceptDirectInput`、`sessionId` は必要な元世代の保証を確立しないため、Codex の `targetGeneration` は引き続き `unsupported` と報告します。#180 は別途検証された native 契約のため未完了のままです。 コマンドの詳細な動作、インストールオプション、トランスポートの制限、および過去の検証結果について以下に説明します。 機械consumerは[v1互換性契約](compatibility-v1.md)にも従ってください。 diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index c018fa36..226c6c2e 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -35,7 +35,7 @@ Codex 큐, Antigravity와 페어링 기기는 이 수신 프로세스 세대 보 Codex의 작성자 재확인만으로 나중에 큐를 소비하는 작성자를 원자적으로 제한할 수는 없습니다. #180의 전체 작업은 아직 열려 있습니다. v1.0.4에 발행된 기능이 아니며 자동 대체 전송·재전송도 없습니다. -2026-10-15 native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) 검토에서는 프로세스 세대별 식별자나 원자적 원본 세대 전제조건을 찾지 못했습니다. 스레드 쓰기 잠금 파일에는 내용이 없고, 스레드의 `sessionId`는 재개 후에도 유지되며, `thread/queue/add`는 스레드, 입력, 클라이언트 메시지 ID만 받습니다. 큐에 저장된 항목은 그 시점에 스레드를 불러온 프로세스가 소비합니다. `expectedTurnId`를 쓰는 `turn/steer`는 한 app-server의 활성 턴에 대해서만 원자적이므로 세대로 사용하지 않습니다. 따라서 쓰기 프로세스의 PID·시작 시각·잠금 재검사, 큐 ID, `canAcceptDirectInput`, `sessionId`로는 Codex `targetGeneration`을 만들지 않으며, native Codex가 큐에서 원자적으로 검사하는 프로세스 세대 토큰을 제공하기 전까지 Codex 행은 `unsupported`로 유지됩니다. +2026-10-15 native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs)의 큐 입력 경로를 검토했지만, 원래 쓰기 프로세스의 세대를 원자적으로 검사하는 지원 가능한 전제조건은 찾지 못했습니다. 확인한 소스에서 잠금 획득 경로는 세대 토큰을 기록하지 않고, 스레드의 `sessionId`는 재개 후에도 유지되며, `thread/queue/add`는 세대 전제조건 대신 스레드·입력·클라이언트 메시지 ID를 받습니다. 확인한 `turn/steer` 경로는 활성 턴 잠금 안에서 `expectedTurnId`를 검사하지만, session-peer는 이 턴 전용 검사를 쓰기 프로세스의 세대로 사용하지 않습니다. 이는 고정된 경로에 한정된 조사이며 모든 native API나 향후 버전의 기능 부재를 증명하지 않습니다. PID·시작 시각·잠금 재검사, 큐 ID, `canAcceptDirectInput`, `sessionId`로는 필요한 원본 세대 보장을 확립할 수 없으므로 Codex `targetGeneration`은 계속 `unsupported`로 보고합니다. #180은 별도로 검증된 native 계약을 위해 열린 상태로 유지합니다. 명령어의 상세 동작, 설치 옵션, 전송 한계 및 과거 검증 내역은 아래와 같습니다. 기계 소비자는 [v1 호환성 계약](compatibility-v1.md)도 따라야 합니다. diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index e4557c75..f17193c9 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -31,7 +31,7 @@ Codex 队列、Antigravity 和配对设备不具备此接收进程代次保证 wake 前拒绝,并保留各自既有契约。重新核实 Codex 写入进程,不能原子地限制之后消费 持久队列的进程。#180 的完整工作仍未完成;该功能尚未在 v1.0.4 发布,不会自动回退或重发。 -2026-10-15 对 native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) 的审查未发现按进程代次区分的标识,也未发现原子的原始代次前置条件。线程写入锁文件没有内容,线程的 `sessionId` 在恢复后保持不变,`thread/queue/add` 只接受线程、输入和客户端消息 ID,队列中保存的条目由当时加载该线程的进程消费。带 `expectedTurnId` 的 `turn/steer` 仅对单个 app-server 上的活动轮次是原子的,因此不将其用作代次。所以写入进程的 PID、启动时间和锁的复查、队列 ID、`canAcceptDirectInput` 以及 `sessionId` 都不会生成 Codex `targetGeneration`;在 native Codex 提供可在队列中原子检查的进程代次令牌之前,Codex 行保持 `unsupported`。 +2026-10-15 对 native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) 的队列输入路径进行了审查,但未找到可用的、原子检查原始写入进程代次的前置条件。在已检查的源码中,获取锁的路径不写入代次令牌,线程的 `sessionId` 在恢复后保持不变,而 `thread/queue/add` 接受线程、输入和客户端消息 ID,而非代次前置条件。已检查的 `turn/steer` 路径在活动轮次锁内检查 `expectedTurnId`,但 session-peer 不将这种轮次专用检查用作写入进程代次。这些结论仅限于固定的源码路径,并不证明所有 native API 或未来版本都不存在该功能。PID、启动时间和锁的复查、队列 ID、`canAcceptDirectInput`、`sessionId` 不能确立所需的原始代次保证,因此 Codex 的 `targetGeneration` 继续报告为 `unsupported`。#180 保持未完成,以等待另行验证的 native 契约。 以下是详细的命令行为、安装选项、传输限制和历史验证记录。 机器consumer还应遵循[v1兼容性契约](compatibility-v1.md)。 diff --git a/tests/codex/test_generation_unsupported.py b/tests/codex/test_generation_unsupported.py index 984473cd..66302647 100644 --- a/tests/codex/test_generation_unsupported.py +++ b/tests/codex/test_generation_unsupported.py @@ -1,10 +1,9 @@ """Negative fixtures: weak Codex evidence is never promoted to a generation. -Native Codex 0.162.1 (rust-v0.162.1, 092d3acd6bec) exposes no per-incarnation -identity and no atomic original-generation precondition for queued input: -the writer lock file is empty, `sessionId` survives resume, `thread/queue/add` -takes only thread/input/client message ID, and queued rows are consumed by -whichever process has the thread loaded. These offline tests prove that +The reviewed queued-input paths in native Codex 0.162.1 (rust-v0.162.1, +092d3acd6bec) did not establish a supported atomic original-incarnation +precondition. This is not a proof about every native API or future version. +These offline tests prove that session-peer keeps Codex generation `unsupported` whatever weak evidence is present. They are not evidence of native delivery or consumption. """ @@ -30,6 +29,11 @@ def setUp(self): temp = tempfile.TemporaryDirectory() self.addCleanup(temp.cleanup) self.root = Path(temp.name).resolve() + environment = mock.patch.dict(os.environ, { + "HOME": str(self.root), "USERPROFILE": str(self.root), + "CODEX_HOME": str(self.root)}, clear=True) + environment.start() + self.addCleanup(environment.stop) for name in ("codex_system_config_paths", "codex_managed_preference_paths"): if hasattr(peer, name): layer = mock.patch.object(peer, name, return_value=[]) @@ -66,7 +70,7 @@ def test_listing_never_mints_codex_generation_from_writer_queue_or_session_evide self.assertEqual(listed["generationStatus"], "unsupported") minted.assert_not_called() - def test_real_discovery_with_stable_writer_and_lock_content_stays_unsupported(self): + def test_saved_discovery_with_arbitrary_lock_content_stays_unsupported(self): with contextlib.closing(sqlite3.connect(self.root / "state_5.sqlite")) as conn: conn.execute("CREATE TABLE threads (id TEXT, title TEXT, cwd TEXT, updated_at INTEGER, archived INTEGER, rollout_path TEXT)") conn.execute("INSERT INTO threads VALUES (?,?,?,?,?,?)", @@ -78,11 +82,11 @@ def test_real_discovery_with_stable_writer_and_lock_content_stays_unsupported(se for content in (b"", b"pid=4242 generation=" + b"f" * 64, CLAUDE_SHAPED.encode()): with self.subTest(content=content[:16]): (locks / f"{THREAD}.lock").write_bytes(content) - stable = {"activity": "live_writer", "writerLock": "held", "ownerPid": 4242, - "ownerStable": True, "ownerStartTime": 1700000000.25, - "reason": "stable_live_writer"} - with mock.patch.object(peer, "inspect_codex_writer", return_value=stable), \ - mock.patch.object(peer, "claude_generation") as minted: + # Saved-row listing does not call inspect_codex_writer. This + # case tests lock content only; stable writer fields are covered + # by the explicit adapter-row fixture above. + with mock.patch.object(peer, "claude_generation") as minted, \ + mock.patch.object(peer, "inspect_codex_writer", wraps=peer.inspect_codex_writer) as inspected: code, result = self.invoke("list", "--agent", "codex", "--codex-home", str(self.root), "--with-target-generation") self.assertEqual(code, 0) @@ -91,9 +95,10 @@ def test_real_discovery_with_stable_writer_and_lock_content_stays_unsupported(se self.assertIsNone(rows[0]["targetGeneration"]) self.assertEqual(rows[0]["generationStatus"], "unsupported") minted.assert_not_called() + inspected.assert_not_called() - def test_writer_restart_between_list_and_send_cannot_upgrade_a_pin(self): - # A successor resuming the same thread is the race the issue describes. + def test_changed_writer_rows_stay_unsupported_and_arbitrary_pins_are_refused(self): + # Two synthetic listings and a separate pin refusal, not a live race. for owner in (4242, 4243): with mock.patch.object(peer.CodexAdapter, "list", return_value={ "sessions": [self.strongest_weak_row(ownerPid=owner)], @@ -107,7 +112,7 @@ def test_writer_restart_between_list_and_send_cannot_upgrade_a_pin(self): mock.patch.object(peer, "queue_codex") as queue, \ mock.patch.object(peer, "run_codex_wake") as wake, \ mock.patch.object(peer.SshTransport, "execute") as remote, \ - mock.patch.object(peer, "tailscale_status"), \ + mock.patch.object(peer, "tailscale_status") as network_discovery, \ mock.patch.object(peer.subprocess, "run") as child: code, result = self.invoke("send", "--to", "codex:" + THREAD, "--target-generation", token, "--message=x", @@ -117,7 +122,7 @@ def test_writer_restart_between_list_and_send_cannot_upgrade_a_pin(self): self.assertIs(result["submitted"], False) self.assertIs(result["retryAllowed"], False) self.assertNotIn("lastSeenTarget", result) - for effect in (queue, wake, remote, child): + for effect in (queue, wake, remote, child, network_discovery): effect.assert_not_called() def test_successful_queue_result_never_carries_a_generation(self): @@ -125,10 +130,11 @@ def test_successful_queue_result_never_carries_a_generation(self): "dryRun": False, "codexHome": str(self.root), "submitted": True, "consumptionConfirmed": False, "status": "queued", "queueId": "01900000-0000-7000-8000-0000000000aa"} - with mock.patch.object(peer, "queue_codex", return_value=queued): + with mock.patch.object(peer, "queue_codex", return_value=queued) as queue: code, result = self.invoke("send", "--to", "codex:" + THREAD, "--message=x", "--no-from", "--no-reply-to") self.assertEqual(code, 0) + queue.assert_called_once() self.assertEqual(result["status"], "queued") self.assertEqual(result["queueId"], queued["queueId"]) self.assertNotIn("targetGeneration", result) From aaf75af1008a5c9db2fb74d2bb01ceaaf1411edd Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 15 Oct 2026 14:31:28 +0900 Subject: [PATCH 3/4] test(codex): isolate forced preferences in generation fixtures --- tests/codex/test_generation_unsupported.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/codex/test_generation_unsupported.py b/tests/codex/test_generation_unsupported.py index 66302647..7d0d4fd1 100644 --- a/tests/codex/test_generation_unsupported.py +++ b/tests/codex/test_generation_unsupported.py @@ -34,7 +34,10 @@ def setUp(self): "CODEX_HOME": str(self.root)}, clear=True) environment.start() self.addCleanup(environment.stop) - for name in ("codex_system_config_paths", "codex_managed_preference_paths"): + # Isolate both the older file-backed and newer forced-preference + # readers so these generation fixtures never inspect host MDM state. + for name in ("codex_system_config_paths", "codex_managed_preference_paths", + "codex_managed_preference_values"): if hasattr(peer, name): layer = mock.patch.object(peer, name, return_value=[]) layer.start() From 88e19f947c27c030a537b899c7f56522840f84f8 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 16 Oct 2026 09:17:11 +0900 Subject: [PATCH 4/4] test(codex): exercise actual queue result generation --- tests/codex/test_generation_unsupported.py | 52 ++++++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/tests/codex/test_generation_unsupported.py b/tests/codex/test_generation_unsupported.py index 7d0d4fd1..46b0b275 100644 --- a/tests/codex/test_generation_unsupported.py +++ b/tests/codex/test_generation_unsupported.py @@ -14,12 +14,13 @@ import os from pathlib import Path import sqlite3 +import subprocess import tempfile import unittest from unittest import mock import session_peer as peer -from tests.codex.support import THREAD +from tests.codex.support import THREAD, isolate_native_layers CLAUDE_SHAPED = "tg1:" + "a" * 64 @@ -129,19 +130,52 @@ def test_changed_writer_rows_stay_unsupported_and_arbitrary_pins_are_refused(sel effect.assert_not_called() def test_successful_queue_result_never_carries_a_generation(self): - queued = {"ok": True, "target": {"agent": "codex", "id": THREAD}, "chars": 1, - "dryRun": False, "codexHome": str(self.root), "submitted": True, - "consumptionConfirmed": False, "status": "queued", - "queueId": "01900000-0000-7000-8000-0000000000aa"} - with mock.patch.object(peer, "queue_codex", return_value=queued) as queue: + # Exercise the real CLI -> queue_codex -> _queue_codex result producer. + # Only native execution, writer evidence and fixture-local prerequisites + # are replaced; a prebuilt application result would make this vacuous. + isolate_native_layers(self, self.root) + rollout = self.root / "rollout.jsonl" + rollout.write_text("{}\n", encoding="utf-8") + database = self.root / "state_5.sqlite" + with contextlib.closing(sqlite3.connect(database)) as conn: + conn.execute("CREATE TABLE threads (id TEXT, title TEXT, cwd TEXT, updated_at INTEGER, archived INTEGER, rollout_path TEXT)") + conn.execute("INSERT INTO threads VALUES (?,?,?,?,?,?)", + (THREAD, "fixture", str(self.root), 1, 0, str(rollout))) + conn.commit() + original_database = database.read_bytes() + queue_id = "01900000-0000-7000-8000-0000000000aa" + executable = str(self.root / "fixture-codex") + writer = {"activity": "live_writer", "writerLock": "held", "ownerPid": 4242, + "ownerStable": True, "ownerStartTime": "fixture-start", + "reason": "stable_live_writer"} + completed = subprocess.CompletedProcess([], 0, + "Queued message " + queue_id + " for thread " + THREAD + ".\n", "") + with mock.patch.object(peer, "known_codex_homes", return_value=[self.root]), \ + mock.patch.object(peer, "inspect_codex_writer", return_value=writer), \ + mock.patch.object(peer, "codex_executable", return_value=executable), \ + mock.patch.object(peer.subprocess, "run", return_value=completed) as child, \ + mock.patch.object(peer.subprocess, "Popen") as other_child: code, result = self.invoke("send", "--to", "codex:" + THREAD, "--message=x", - "--no-from", "--no-reply-to") + "--codex-home", str(self.root), "--no-from", "--no-reply-to") self.assertEqual(code, 0) - queue.assert_called_once() + child.assert_called_once() + framed = peer.peer_delivery_message("x", "codex") + self.assertEqual(child.call_args.args[0], + [executable, "queue", "--thread", THREAD, "--message=" + framed]) + self.assertIn("\n| x\nEND QUOTED PEER BODY", child.call_args.args[0][-1]) + self.assertEqual(child.call_args.kwargs["cwd"], str(self.root)) + self.assertEqual(child.call_args.kwargs["env"]["CODEX_HOME"], str(self.root)) + if not peer.IS_WINDOWS: + self.assertEqual(child.call_args.kwargs["env"]["PWD"], str(self.root)) + other_child.assert_not_called() + self.assertIs(result["ok"], True) + self.assertEqual(result["target"], {"agent": "codex", "id": THREAD}) self.assertEqual(result["status"], "queued") - self.assertEqual(result["queueId"], queued["queueId"]) + self.assertIs(result["submitted"], True) + self.assertEqual(result["queueId"], queue_id) self.assertNotIn("targetGeneration", result) self.assertIs(result["consumptionConfirmed"], False) + self.assertEqual(database.read_bytes(), original_database) def test_codex_adapter_contract_has_no_generation_capability(self): adapter = peer.AGENTS.for_target("codex:" + THREAD)