diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 394b00d..3812dc5 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 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 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 d1318c8..f844b79 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": "4f5e228e914ba1b1a7277e27688288a93cc9fa1dad261fa0511a895663dd6d5d", + "docs/cli-reference.md": "a1aae5b881f4aa939c995d7bc06819d04f808da3b31fd0d5da66c817e1b47e82", "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 e7de6c0..c9628f4 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 を受け取ります。確認した `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 5888577..2ab8340 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를 받습니다. 확인한 `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 adda94d..e4b12e2 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,而非代次前置条件。已检查的 `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 new file mode 100644 index 0000000..46b0b27 --- /dev/null +++ b/tests/codex/test_generation_unsupported.py @@ -0,0 +1,192 @@ +"""Negative fixtures: weak Codex evidence is never promoted to a generation. + +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. +""" +import argparse +import contextlib +import io +import json +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, isolate_native_layers + +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() + 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) + # 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() + 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_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 (?,?,?,?,?,?)", + (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) + # 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) + 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() + inspected.assert_not_called() + + 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)], + "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") as network_discovery, \ + 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, network_discovery): + effect.assert_not_called() + + def test_successful_queue_result_never_carries_a_generation(self): + # 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", + "--codex-home", str(self.root), "--no-from", "--no-reply-to") + self.assertEqual(code, 0) + 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.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) + 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()