From e56bae2bb8bd79431e9d05518cf004f8cd1082aa Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:25:40 +0900 Subject: [PATCH 1/3] feat(wake): bound wake chains and persist per-target activation limits --- docs/i18n-manifest.json | 2 +- docs/ja/wake.md | 12 ++ docs/ko/wake.md | 12 ++ docs/wake.md | 12 ++ docs/zh-CN/wake.md | 12 ++ session_peer.py | 144 ++++++++++++++++++++- session_peer_core/adapters.py | 2 + session_peer_core/cli.py | 4 + session_peer_core/codex.py | 24 +++- session_peer_core/wake_chain.py | 112 +++++++++++++++++ session_peer_mcp.py | 7 ++ tests/codex/test_wake.py | 3 +- tests/codex/test_wake_chain.py | 194 +++++++++++++++++++++++++++++ tests/fixtures/codex_app_server.py | 4 + tests/mcp_integration/test_mcp.py | 5 +- tools/generate_session_peer.py | 1 + 16 files changed, 535 insertions(+), 15 deletions(-) create mode 100644 session_peer_core/wake_chain.py create mode 100644 tests/codex/test_wake_chain.py diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index c974d5ad..2691387d 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -47,7 +47,7 @@ "docs/releases/v1.0.4.md": "5185d9ff93a98077621ec017e54f73138dcf05fe9de121b073f9a097240b40b4", "docs/validation/69-rc.md": "e490c6a9ad0fec9108b91c74922cedac7ddf214ed405f8f5523da22f48a4b222", "docs/validation/85-antigravity.md": "468ba1702a60c57079730ab5b7545b0b1968f11cc6832f0ac88460664ba87723", - "docs/wake.md": "744f5da5009e824a7b408b499f56f2c8acc695c9b8e7d9421c7e28a7a658d52e", + "docs/wake.md": "f8d67f59172d19d69900f2fc1371a09d0a1251155db20d7d7f0223d6870118c6", "plugins/session-peer/README.md": "a4eeae54caac9ad824d740f7f319561f060bc9c2bbbbf7def2bdd8e36dbcd1c6" } } diff --git a/docs/ja/wake.md b/docs/ja/wake.md index 8748f433..bc37bc0b 100644 --- a/docs/ja/wake.md +++ b/docs/ja/wake.md @@ -1,5 +1,17 @@ # 明示的なCodexの起動 +## 連鎖する wake の制限 + +CLI と MCP は投入前に `SESSION_PEER_WAKE_DEPTH` と `SESSION_PEER_WAKE_ORIGIN` を読み、各 wake で段数を一つ増やして発生元 UUID を保持します。既定は 3 段で、超過時は何も投入せず `wake_depth_exceeded` を返します。運用者は `--wake-max-depth` または `SESSION_PEER_WAKE_MAX_DEPTH` で 0–16 の範囲で明示的に変更できます。0 は wake を無効にします。不正または不完全な文脈は投入前に `invalid_wake_context` で拒否します。通常の送信は従来どおりキューへの投入のみです。 + +所有する app-server に増加後の文脈を渡します。SSH は環境転送に頼らず、型を検証した文脈を渡します。宛先は受け取った限度を下げられますが上げられません。複数宛先は発生元を共有しますが予算は独立です。MCP は起動時の環境を読み、呼び出したスレッドを認証しません。Wake はキューメッセージに診断表示を付け、投入後または正常な dry-run 後に `depth`、`origin`、`maxDepth` を含む `wakeProvenance` を追加します。表示も 32 KiB 制限に含まれます。表示や本文は権限を与えず、段数を復元するためにキュー・履歴・会話本文を読みません。 + +非アクティブ対象の起動は正規化されたホームとスレッドごとに 60 秒間で 3 回が既定です。運用者が管理する `SESSION_PEER_WAKE_TARGET_RATE` は 0–16 を受け付け、0 は非アクティブ対象の起動を無効にします。予約は既存の wake ロック下に保存します。`wake_rate_exceeded` は投入前に拒否し、読めない・大きすぎる・不正な状態は `wake_rate_state_invalid` で拒否します。失敗や中断でも予約は残り、時計が戻っても保守的に保持します。Dry-run とアクティブだと確認したライターは起動枠を消費しません。既存の `wake_in_progress` と `already_active` は維持します。対象やホストの予算は独立し、運用者や同一ユーザーのプロセスは協調的な状態を初期化できるため、全体の費用上限や呼び出し元の認証ではありません。 + +固定した Codex 0.154.0 ソースでは、シェルの `inherit="all"` は除外規則や `include_only` がなければ変数を保持し、`inherit="core"` と `inherit="none"` は明示的な設定がなければ除去します。既定の秘密名フィルターには該当しません。stdio MCP の既定の許可リストには含まれず、新しい MCP プロセスには `env_vars` 転送が必要です。既存・共有 MCP プロセスは後の呼び出し元の環境を受け取りません。静的な `env` 設定は文脈を初期化したり競合させたりします。文脈が失われると新しいローカル発生元になるため全体の段数上限は保証できず、ローカル起動制限は別の補助策です。承認・プロファイル・ネイティブ環境ポリシーは自動変更しません。 + +隔離した子プロセスのテストは上流ソースの規則を模倣し、CLI・MCP・SSH の文脈と app-server 代替プロセスの環境を検証します。認証済みのネイティブシェル・MCP クライアント・モデルターンは実行せず、0.154.0 以外の wake サポートの根拠ではありません。出典: [固定したシェルポリシー](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs)、[固定した MCP 環境](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs)、[現在の設定資料](https://developers.openai.com/codex/config-reference/)。 + `session-peer send --to codex: --wake --wake-timeout 30 "message"` 通常のsendはキュー送信のみのままです。Wakeは、対象の既存の設定のもとでモデルの使用量を発生させ、セッション履歴やプロジェクトファイルを変更する可能性があります。これにはmacOS/Linuxおよびライフサイクルとネイティブライターの排他制御がテストされたバージョンである**Codex CLI 0.154.0**が必要です。他のバージョンは、個別に検証されるまで安全側に倒して失敗します。このバージョン境界はwakeにのみ適用されます。 diff --git a/docs/ko/wake.md b/docs/ko/wake.md index 9bed938f..7f96606b 100644 --- a/docs/ko/wake.md +++ b/docs/ko/wake.md @@ -1,5 +1,17 @@ # 명시적 Codex wake +## 연속 wake 제한 + +CLI와 MCP는 큐 제출 전에 `SESSION_PEER_WAKE_DEPTH`와 `SESSION_PEER_WAKE_ORIGIN`을 읽습니다. 각 wake는 단계를 하나 늘리고 원본 UUID를 유지합니다. 기본 한도는 3단계이며 초과하면 제출 없이 `wake_depth_exceeded`를 반환합니다. 운영자는 `--wake-max-depth` 또는 `SESSION_PEER_WAKE_MAX_DEPTH`로 0–16 범위에서 명시적으로 낮추거나 높일 수 있습니다. 0은 wake를 비활성화합니다. 잘못되거나 불완전한 문맥은 제출 전에 `invalid_wake_context`로 거부합니다. 일반 전송은 기존처럼 큐에만 제출합니다. + +소유한 app-server에는 증가한 문맥을 전달합니다. SSH는 환경 변수 전달 설정 없이 형식이 검증된 문맥을 전달하며, 목적지는 받은 한도를 낮출 수 있지만 높이지 못합니다. 여러 목적지는 원본 식별자를 공유하되 예산은 독립적입니다. MCP는 시작 시점의 환경을 읽으며 호출 스레드의 신원을 인증하지 않습니다. Wake는 큐 메시지 앞에 진단용 표시를 붙이고, 제출 또는 정상 dry-run 이후 `depth`, `origin`, `maxDepth`를 담은 `wakeProvenance` 결과를 추가합니다. 표시도 32 KiB 메시지 제한에 포함됩니다. 표시와 본문은 권한을 부여하지 않으며, 단계를 복원하려고 큐·기록·대화 본문을 읽지 않습니다. + +비활성 대상의 활성화는 정규 홈과 스레드별로 60초당 3회가 기본입니다. 운영자가 관리하는 `SESSION_PEER_WAKE_TARGET_RATE`는 0–16을 받으며 0은 비활성 대상의 활성화를 막습니다. 예약은 기존 wake 잠금 아래 저장됩니다. `wake_rate_exceeded`는 큐 제출 전에 거부하고, 읽을 수 없거나 너무 크거나 잘못된 상태는 `wake_rate_state_invalid`로 거부합니다. 실패·중단된 시도도 예약을 유지하고 시계가 뒤로 움직여도 보수적으로 유지합니다. Dry-run과 활성 상태가 확인된 작성기는 활성화 예산을 쓰지 않습니다. 기존 `wake_in_progress`와 `already_active` 동작은 유지됩니다. 대상·호스트별 예산은 독립적이고 운영자나 같은 사용자 프로세스가 협조적 상태를 초기화할 수 있으므로 전역 비용 한도나 호출자 인증은 아닙니다. + +고정된 Codex 0.154.0 소스에서 셸의 `inherit="all"`은 제외 규칙이나 `include_only`가 없으면 이 변수를 유지하며, `inherit="core"`와 `inherit="none"`은 명시적 설정 없이는 제거합니다. 기본 비밀 이름 필터에는 걸리지 않습니다. stdio MCP의 기본 허용 목록에는 없으므로 새 MCP 프로세스에는 `env_vars` 전달을 명시해야 합니다. 기존·공유 MCP 프로세스는 나중 호출자의 환경을 받지 못합니다. 정적인 `env` 설정은 문맥을 초기화하거나 충돌시킬 수 있습니다. 필터로 문맥이 사라지면 새 로컬 원본을 만들므로 전역 단계 한도를 보장할 수 없으며, 로컬 활성화 제한은 별도의 보완책입니다. 승인·프로필·네이티브 환경 정책은 자동 변경하지 않습니다. + +격리된 하위 프로세스 테스트는 상위 소스의 규칙을 모사하고 CLI·MCP·SSH 문맥과 app-server 대체 프로세스의 환경을 검증합니다. 인증된 네이티브 셸·MCP 클라이언트·모델 턴은 실행하지 않으며 0.154.0 이후 버전의 wake 지원 근거는 아닙니다. 출처: [고정된 셸 정책](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs), [고정된 MCP 환경](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs), [현재 구성 설명서](https://developers.openai.com/codex/config-reference/). + `session-peer send --to codex: --wake --wake-timeout 30 "message"` 일반 전송은 큐 전용으로 유지됩니다. Wake는 대상의 기존 구성에 따라 모델 사용량을 발생시키고 세션 기록 및 프로젝트 파일을 수정할 수 있습니다. 수명 주기 및 네이티브 작성기 배제가 테스트된 버전인 macOS/Linux 및 **Codex CLI 0.154.0**이 필요합니다. 다른 버전은 독립적으로 검증될 때까지 안전하게 실패(fail closed)합니다. 이 버전 경계는 wake에만 적용됩니다. diff --git a/docs/wake.md b/docs/wake.md index 96415883..6a1ac535 100644 --- a/docs/wake.md +++ b/docs/wake.md @@ -1,5 +1,17 @@ # Explicit Codex wake +## Chained wake limits + +Before queueing, CLI and MCP read `SESSION_PEER_WAKE_DEPTH` and `SESSION_PEER_WAKE_ORIGIN`. Each wake adds one hop and retains the origin UUID. The default limit is 3 hops; exceeding it returns `wake_depth_exceeded` with nothing submitted. Operators can explicitly lower or raise `--wake-max-depth` or `SESSION_PEER_WAKE_MAX_DEPTH` within 0–16; 0 disables wakes. Invalid or incomplete context returns `invalid_wake_context` before submission. Ordinary queue-only sends remain unchanged. + +The owned app-server receives the incremented context. SSH carries typed context without requiring environment forwarding; the destination can lower, not raise, the carried limit. One origin is retained across fanout, but destinations have independent budgets. MCP reads its startup environment, not the calling thread's identity. Wake adds a diagnostic queue-message prefix and, after submission or successful dry-run, a `wakeProvenance` result containing `depth`, `origin`, and `maxDepth`. The prefix counts toward the 32 KiB message limit. Neither prefix nor body grants permission. No queue/history/transcript content is read to recover depth. + +Inactive activation also defaults to 3 attempts per canonical home/thread in 60 seconds. Operator-owned `SESSION_PEER_WAKE_TARGET_RATE` accepts 0–16; 0 disables inactive activation. Reservations persist under the existing wake lock. `wake_rate_exceeded` refuses before queueing; unreadable, oversized or malformed state returns `wake_rate_state_invalid`. Failed/interrupted attempts retain slots; clock rollback conservatively keeps reservations. Dry-run and known active writers consume no activation slot. Existing `wake_in_progress` and `already_active` behavior remains. Different targets/hosts have separate budgets; operators or same-user processes can reset cooperative state. This is not a global cost quota or caller authentication. + +In the pinned Codex 0.154.0 source, shell `inherit="all"` retains these variables unless exclusion or `include_only` filters them; `inherit="core"` and `inherit="none"` drop them unless explicitly set. Default secret-name exclusions do not match them. Stdio MCP's default whitelist omits them; a new MCP process requires explicit `env_vars` forwarding. Existing/shared MCP processes do not receive a later caller's environment. Static `env` overrides may reset or conflict with context. Filtered/missing context starts a new local origin, so the hop limit cannot guarantee a global chain bound. The local activation rate is a separate backstop. No approval, profile or native environment policy is changed automatically. + +Isolated subprocess fixtures mirror these upstream rules and verify CLI/MCP/SSH context and the app-server stand-in environment. They do not run a credentialed native shell, MCP client or model turn and do not establish wake support beyond 0.154.0. Sources: [pinned shell policy](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs), [pinned MCP environment](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs), [current configuration reference](https://developers.openai.com/codex/config-reference/). + `session-peer send --to codex: --wake --wake-timeout 30 "message"` Plain send remains queue-only. Wake can incur model usage and modify session history and project files under the target's existing configuration. It requires macOS/Linux and **Codex CLI 0.154.0**, the version whose lifecycle and native writer exclusion have been tested. Other versions fail closed until independently validated. This version boundary applies only to wake. diff --git a/docs/zh-CN/wake.md b/docs/zh-CN/wake.md index f332cdf5..b0cd06ac 100644 --- a/docs/zh-CN/wake.md +++ b/docs/zh-CN/wake.md @@ -1,5 +1,17 @@ # 显式 Codex 唤醒 +## 连续 wake 的限制 + +CLI 和 MCP 在入队前读取 `SESSION_PEER_WAKE_DEPTH` 和 `SESSION_PEER_WAKE_ORIGIN`,每次 wake 将层数加一并保留来源 UUID。默认上限为 3 层,超过时返回 `wake_depth_exceeded`,不提交内容。运维人员可以通过 `--wake-max-depth` 或 `SESSION_PEER_WAKE_MAX_DEPTH` 在 0–16 范围内显式调整;0 禁用 wake。无效或不完整的上下文在提交前以 `invalid_wake_context` 拒绝。普通发送仍只入队,行为不变。 + +所拥有的 app-server 接收递增后的上下文。SSH 通过验证类型的参数传递上下文,不依赖环境转发;目标端可以降低携带的上限,但不能提高。多个目标保持同一来源,但预算独立。MCP 读取启动时的环境,不能认证调用线程。Wake 在队列消息前添加诊断标记,并在提交或成功的 dry-run 后添加包含 `depth`、`origin` 和 `maxDepth` 的 `wakeProvenance`。标记也计入 32 KiB 上限。标记和正文都不授予权限;不会读取队列、历史或对话正文来恢复层数。 + +非活动目标的激活默认按规范化 home 和线程计算,每 60 秒最多 3 次。运维人员管理的 `SESSION_PEER_WAKE_TARGET_RATE` 接受 0–16;0 禁止激活非活动目标。预留记录存于现有 wake 锁下。`wake_rate_exceeded` 在入队前拒绝,无法读取、过大或格式错误的状态以 `wake_rate_state_invalid` 拒绝。失败或中断仍占用名额,时钟回退时保守地保留记录。Dry-run 和已确认活动的写入进程不消耗激活名额。现有 `wake_in_progress` 和 `already_active` 不变。不同目标或主机的预算独立,运维人员或同用户进程可重置协作状态,所以这不是全局费用上限或调用者认证。 + +固定的 Codex 0.154.0 源码中,shell 的 `inherit="all"` 在没有排除规则或 `include_only` 过滤时保留这些变量;`inherit="core"` 和 `inherit="none"` 在没有显式设置时删除。默认敏感名称过滤不匹配它们。stdio MCP 默认允许列表不包含它们,新 MCP 进程需要显式 `env_vars` 转发。已有或共享 MCP 进程不会获得后来调用者的环境。静态 `env` 配置可能重置上下文或产生冲突。上下文丢失后创建新的本地来源,因而无法保证全局层数上限;本地激活速率是独立的补充防护。不自动修改审批、配置或原生环境策略。 + +隔离子进程测试模拟上游源码规则,验证 CLI、MCP、SSH 上下文和 app-server 替代进程的环境,不运行已认证的原生 shell、MCP 客户端或模型轮次,不构成 0.154.0 之外的 wake 支持证据。来源:[固定 shell 策略](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs)、[固定 MCP 环境](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs)、[当前配置参考](https://developers.openai.com/codex/config-reference/)。 + `session-peer send --to codex: --wake --wake-timeout 30 "message"` 普通 send 仍为仅入队。Wake 可能会产生模型使用量,并在目标现有配置下修改会话历史记录和项目文件。它需要 macOS/Linux 以及 **Codex CLI 0.154.0**,即生命周期和原生写入器排他性经过测试的版本。在经过独立验证之前,其他版本会遵循故障关闭。此版本边界仅适用于 wake。 diff --git a/session_peer.py b/session_peer.py index 2d96f50d..28b6da96 100644 --- a/session_peer.py +++ b/session_peer.py @@ -96,6 +96,120 @@ class NoTargetError(CcPeerError): """A requested saved session cannot be resolved.""" +# Cooperative wake cost controls, not permission or caller-authentication data. +WAKE_DEFAULT_MAX_DEPTH = 3 +WAKE_HARD_MAX_DEPTH = 16 +WAKE_CHAIN_ENV = ("SESSION_PEER_WAKE_DEPTH", "SESSION_PEER_WAKE_ORIGIN", + "SESSION_PEER_WAKE_MAX_DEPTH") + + +def wake_chain_integer(value, label: str) -> int: + if not isinstance(value, str) or not re.fullmatch(r"0|[1-9][0-9]?", value): + raise wake_refused("invalid_wake_context", f"Invalid {label}; expected an integer in 0..16") + number = int(value) + if number > WAKE_HARD_MAX_DEPTH: + raise wake_refused("invalid_wake_context", f"Invalid {label}; expected an integer in 0..16") + return number + + +def wake_chain_origin(value) -> str: + if not isinstance(value, str) or not re.fullmatch( + r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", value): + raise wake_refused("invalid_wake_context", "Wake origin must be a canonical UUID") + return str(uuid.UUID(value)) + + +def wake_chain_context(args: argparse.Namespace) -> dict: + """Validate before effect; retain one origin across an ordered fanout.""" + cached = getattr(args, "_wake_chain_context", None) + if cached is not None: + return cached + explicit_limit = getattr(args, "wake_max_depth", None) + if explicit_limit is not None and (type(explicit_limit) is not int + or not 0 <= explicit_limit <= WAKE_HARD_MAX_DEPTH): + raise wake_refused("invalid_wake_context", "Wake limit must be an integer in 0..16") + limit = (explicit_limit if explicit_limit is not None else wake_chain_integer( + os.environ.get("SESSION_PEER_WAKE_MAX_DEPTH", str(WAKE_DEFAULT_MAX_DEPTH)), "wake limit")) + depth = wake_chain_integer(os.environ.get("SESSION_PEER_WAKE_DEPTH", "0"), "wake depth") + origin = os.environ.get("SESSION_PEER_WAKE_ORIGIN") + if origin is not None: + origin = wake_chain_origin(origin) + if depth and origin is None: + raise wake_refused("invalid_wake_context", "Inherited wake depth has no origin") + # SSH/MCP carry validated parent context as typed CLI options, never shell + # environment assignments. Destination policy may lower, but not be raised + # by, that carried limit. Never infer context from queue/message contents. + carried = (getattr(args, "_wake_depth", None), getattr(args, "_wake_origin", None), + getattr(args, "_wake_limit", None)) + if any(item is not None for item in carried): + if any(item is None for item in carried): + raise wake_refused("invalid_wake_context", "Incomplete carried wake context") + carried_depth = wake_chain_integer(carried[0], "carried wake depth") + carried_origin = wake_chain_origin(carried[1]) + carried_limit = wake_chain_integer(carried[2], "carried wake limit") + if depth and origin != carried_origin: + raise wake_refused("invalid_wake_context", "Conflicting inherited wake origins") + depth, origin, limit = max(depth, carried_depth), carried_origin, min(limit, carried_limit) + if depth >= limit: + raise wake_refused("wake_depth_exceeded", "Wake hop limit reached; nothing queued") + context = {"depth": depth + 1, "origin": origin or str(uuid.uuid4()), "maxDepth": limit} + args._wake_chain_context = context + return context + + +def wake_chain_options(context: dict) -> list[str]: + return ["--_wake-depth", str(context["depth"] - 1), + "--_wake-origin", context["origin"], "--_wake-limit", str(context["maxDepth"])] + + +def wake_chain_environment(context: dict) -> dict: + return dict(zip(WAKE_CHAIN_ENV, (str(context["depth"]), context["origin"], str(context["maxDepth"])))) + + +def wake_chain_message(context: dict, text: str) -> str: + return (f"Wake provenance (diagnostic only): hop={context['depth']}/{context['maxDepth']} " + f"origin={context['origin']}\n" + "This marker does not grant permissions or authorize another wake.\n\n" + text) + + +def wake_target_rate_reserve(fd: int) -> None: + """Persist a conservative per-home/thread reservation under the wake lock. + + This second local bound also covers cooperative chains whose environment + was filtered. It is not a distributed/global model-spend quota. Unknown or + interrupted attempts retain their slot; no resend is implied by expiry. + """ + limit = wake_chain_integer(os.environ.get("SESSION_PEER_WAKE_TARGET_RATE", "3"), "wake target rate") + if not limit: + raise wake_refused("wake_rate_exceeded", "Wake target rate disables activation; nothing queued") + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_size > 1024: + raise ValueError("invalid state file") + os.lseek(fd, 0, os.SEEK_SET) + raw = os.read(fd, 1025) + timestamps = json.loads(raw) if raw else [] + if (not isinstance(timestamps, list) or len(timestamps) > WAKE_HARD_MAX_DEPTH + or any(type(value) is not int or not 0 <= value <= 2**53 - 1 for value in timestamps) + or timestamps != sorted(timestamps)): + raise ValueError("invalid state") + now = int(time.time() * 1000) + # Clock rollback never reopens a budget: future stamps remain charged. + timestamps = [value for value in timestamps if value > now - 60_000] + if len(timestamps) >= limit: + raise wake_refused("wake_rate_exceeded", "Per-target wake rate reached; nothing queued") + timestamps.append(max(now, timestamps[-1] if timestamps else now)) + encoded = json.dumps(timestamps, separators=(",", ":")).encode("ascii") + os.lseek(fd, 0, os.SEEK_SET) + written = os.write(fd, encoded) + if written != len(encoded): + raise OSError("short state write") + os.ftruncate(fd, len(encoded)) + os.fsync(fd) + except (OSError, ValueError, UnicodeError) as exc: + raise wake_refused("wake_rate_state_invalid", "Cannot safely reserve wake target rate; nothing queued") from exc + + # -------------------------------------------------------------------------- # Codex discovery, home ownership, queue submission and wake. # -------------------------------------------------------------------------- @@ -948,7 +1062,7 @@ def codex_wake_guard(root: Path, thread_id: str): fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError as exc: raise wake_refused("wake_in_progress", "Another session-peer wake is in progress; nothing submitted") from exc - yield + yield fd finally: os.close(fd) @@ -996,7 +1110,8 @@ def signal_group(number): process.wait(timeout=CODEX_WAKE_CLEANUP_WAIT) -def run_codex_wake(executable: str, root: Path, thread_id: str, cwd: str, timeout: float) -> dict: +def run_codex_wake(executable: str, root: Path, thread_id: str, cwd: str, timeout: float, + *, wake_context: dict | None = None) -> dict: """Resume one thread, await one turn, and own/clean up the native process.""" process = None def cancelled(signum, frame): @@ -1010,7 +1125,8 @@ def cancelled(signum, frame): # into CLI JSON. Only lifecycle states and bounded error text escape. with tempfile.TemporaryFile() as diagnostics: process = subprocess.Popen([executable, "app-server"], cwd=cwd, - env=dict(os.environ, CODEX_HOME=str(root)), stdin=subprocess.PIPE, + env=dict(os.environ, CODEX_HOME=str(root), + **(wake_chain_environment(wake_context) if wake_context else {})), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=diagnostics, start_new_session=True) selector = selectors.DefaultSelector() selector.register(process.stdout, selectors.EVENT_READ) @@ -1089,6 +1205,8 @@ def send(value: dict) -> None: def queue_codex(args: argparse.Namespace, text: str) -> dict: if not getattr(args, "wake", False): return _queue_codex(args, text) + provenance = wake_chain_context(args) + text = wake_chain_message(provenance, text) thread_id = codex_thread(args.to) check_codex_message(text) try: @@ -1106,22 +1224,29 @@ def queue_codex(args: argparse.Namespace, text: str) -> dict: selected.codex_home = str(root) if args.dry_run: result = _queue_codex(selected, text) + result["wakeProvenance"] = dict(provenance) result["wake"] = {"status": "validated", "reason": "dry_run", "cwd": preflight["cwd"]} return result - with codex_wake_guard(root, thread_id): + with codex_wake_guard(root, thread_id) as guard_fd: revalidate_codex_home(root, thread_id, resolution) preflight = codex_wake_preflight(root, thread_id, executable) + # A known active writer only receives the existing queue submission; + # it does not start another native process or consume an activation. + initially_active = preflight.get("writer", {}).get("activity") == "live_writer" + if not initially_active: + wake_target_rate_reserve(guard_fd) result = _queue_codex(selected, text) + result["wakeProvenance"] = dict(provenance) result["codexHomeResolution"] = resolution try: writer = inspect_codex_writer(root, thread_id) if writer["activity"] == "live_writer": wake = {"status": "already_active", "reason": "stable_live_writer"} - elif writer["activity"] == "unknown": + elif writer["activity"] == "unknown" or initially_active: wake = {"status": "refused", "reason": "writer_changed_after_submission"} else: wake = run_codex_wake(executable, root, thread_id, preflight["cwd"], - getattr(args, "wake_timeout", 30)) + getattr(args, "wake_timeout", 30), wake_context=provenance) except KeyboardInterrupt: wake = {"status": "unknown", "reason": "activation_interrupted"} except Exception as exc: @@ -1139,6 +1264,7 @@ def codex_remote_options(args: argparse.Namespace) -> list[str]: argv.extend([flag, value]) if getattr(args, "wake", False): argv += ["--wake", "--wake-timeout", str(args.wake_timeout)] + argv += wake_chain_options(wake_chain_context(args)) if getattr(args, "allow_inactive_codex_home", False): argv.append("--allow-inactive-codex-home") return argv @@ -3279,6 +3405,8 @@ def identity(self, target: str, context: ExecutionContext) -> SessionIdentity: def validate_send(self, args: argparse.Namespace, text: str | None = None) -> None: super().validate_send(args, text) + if getattr(args, "wake", False): + wake_chain_context(args) if text is not None: check_codex_message(text) @@ -5142,6 +5270,10 @@ def add_common(sub: argparse.ArgumentParser) -> None: "--no-from", action="store_true", help="send without the From: header" ) sending.add_argument("--wake", action="store_true", help="explicitly resume a Codex thread; may use models and modify history") + sending.add_argument("--wake-max-depth", type=int, choices=range(0, 17), metavar="HOPS", + help="explicit wake chain limit, 0..16 (default: 3; 0 disables wakes)") + for flag in ("--_wake-depth", "--_wake-origin", "--_wake-limit"): + sending.add_argument(flag, help=argparse.SUPPRESS) sending.add_argument("--wake-timeout", type=int, choices=range(1, 61), default=30, metavar="SECONDS", help="wake deadline, 1..60 seconds (default: 30)") sending.add_argument("--dry-run", action="store_true", help="resolve the target, send nothing") diff --git a/session_peer_core/adapters.py b/session_peer_core/adapters.py index 8d04609f..0c8dfba0 100644 --- a/session_peer_core/adapters.py +++ b/session_peer_core/adapters.py @@ -171,6 +171,8 @@ def identity(self, target: str, context: ExecutionContext) -> SessionIdentity: def validate_send(self, args: argparse.Namespace, text: str | None = None) -> None: super().validate_send(args, text) + if getattr(args, "wake", False): + wake_chain_context(args) if text is not None: check_codex_message(text) diff --git a/session_peer_core/cli.py b/session_peer_core/cli.py index 73abbc11..d39af5c5 100644 --- a/session_peer_core/cli.py +++ b/session_peer_core/cli.py @@ -110,6 +110,10 @@ def add_common(sub: argparse.ArgumentParser) -> None: "--no-from", action="store_true", help="send without the From: header" ) sending.add_argument("--wake", action="store_true", help="explicitly resume a Codex thread; may use models and modify history") + sending.add_argument("--wake-max-depth", type=int, choices=range(0, 17), metavar="HOPS", + help="explicit wake chain limit, 0..16 (default: 3; 0 disables wakes)") + for flag in ("--_wake-depth", "--_wake-origin", "--_wake-limit"): + sending.add_argument(flag, help=argparse.SUPPRESS) sending.add_argument("--wake-timeout", type=int, choices=range(1, 61), default=30, metavar="SECONDS", help="wake deadline, 1..60 seconds (default: 30)") sending.add_argument("--dry-run", action="store_true", help="resolve the target, send nothing") diff --git a/session_peer_core/codex.py b/session_peer_core/codex.py index d639ba54..6efe3f44 100644 --- a/session_peer_core/codex.py +++ b/session_peer_core/codex.py @@ -851,7 +851,7 @@ def codex_wake_guard(root: Path, thread_id: str): fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError as exc: raise wake_refused("wake_in_progress", "Another session-peer wake is in progress; nothing submitted") from exc - yield + yield fd finally: os.close(fd) @@ -899,7 +899,8 @@ def signal_group(number): process.wait(timeout=CODEX_WAKE_CLEANUP_WAIT) -def run_codex_wake(executable: str, root: Path, thread_id: str, cwd: str, timeout: float) -> dict: +def run_codex_wake(executable: str, root: Path, thread_id: str, cwd: str, timeout: float, + *, wake_context: dict | None = None) -> dict: """Resume one thread, await one turn, and own/clean up the native process.""" process = None def cancelled(signum, frame): @@ -913,7 +914,8 @@ def cancelled(signum, frame): # into CLI JSON. Only lifecycle states and bounded error text escape. with tempfile.TemporaryFile() as diagnostics: process = subprocess.Popen([executable, "app-server"], cwd=cwd, - env=dict(os.environ, CODEX_HOME=str(root)), stdin=subprocess.PIPE, + env=dict(os.environ, CODEX_HOME=str(root), + **(wake_chain_environment(wake_context) if wake_context else {})), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=diagnostics, start_new_session=True) selector = selectors.DefaultSelector() selector.register(process.stdout, selectors.EVENT_READ) @@ -992,6 +994,8 @@ def send(value: dict) -> None: def queue_codex(args: argparse.Namespace, text: str) -> dict: if not getattr(args, "wake", False): return _queue_codex(args, text) + provenance = wake_chain_context(args) + text = wake_chain_message(provenance, text) thread_id = codex_thread(args.to) check_codex_message(text) try: @@ -1009,22 +1013,29 @@ def queue_codex(args: argparse.Namespace, text: str) -> dict: selected.codex_home = str(root) if args.dry_run: result = _queue_codex(selected, text) + result["wakeProvenance"] = dict(provenance) result["wake"] = {"status": "validated", "reason": "dry_run", "cwd": preflight["cwd"]} return result - with codex_wake_guard(root, thread_id): + with codex_wake_guard(root, thread_id) as guard_fd: revalidate_codex_home(root, thread_id, resolution) preflight = codex_wake_preflight(root, thread_id, executable) + # A known active writer only receives the existing queue submission; + # it does not start another native process or consume an activation. + initially_active = preflight.get("writer", {}).get("activity") == "live_writer" + if not initially_active: + wake_target_rate_reserve(guard_fd) result = _queue_codex(selected, text) + result["wakeProvenance"] = dict(provenance) result["codexHomeResolution"] = resolution try: writer = inspect_codex_writer(root, thread_id) if writer["activity"] == "live_writer": wake = {"status": "already_active", "reason": "stable_live_writer"} - elif writer["activity"] == "unknown": + elif writer["activity"] == "unknown" or initially_active: wake = {"status": "refused", "reason": "writer_changed_after_submission"} else: wake = run_codex_wake(executable, root, thread_id, preflight["cwd"], - getattr(args, "wake_timeout", 30)) + getattr(args, "wake_timeout", 30), wake_context=provenance) except KeyboardInterrupt: wake = {"status": "unknown", "reason": "activation_interrupted"} except Exception as exc: @@ -1042,6 +1053,7 @@ def codex_remote_options(args: argparse.Namespace) -> list[str]: argv.extend([flag, value]) if getattr(args, "wake", False): argv += ["--wake", "--wake-timeout", str(args.wake_timeout)] + argv += wake_chain_options(wake_chain_context(args)) if getattr(args, "allow_inactive_codex_home", False): argv.append("--allow-inactive-codex-home") return argv diff --git a/session_peer_core/wake_chain.py b/session_peer_core/wake_chain.py new file mode 100644 index 00000000..31655830 --- /dev/null +++ b/session_peer_core/wake_chain.py @@ -0,0 +1,112 @@ +# Cooperative wake cost controls, not permission or caller-authentication data. +WAKE_DEFAULT_MAX_DEPTH = 3 +WAKE_HARD_MAX_DEPTH = 16 +WAKE_CHAIN_ENV = ("SESSION_PEER_WAKE_DEPTH", "SESSION_PEER_WAKE_ORIGIN", + "SESSION_PEER_WAKE_MAX_DEPTH") + + +def wake_chain_integer(value, label: str) -> int: + if not isinstance(value, str) or not re.fullmatch(r"0|[1-9][0-9]?", value): + raise wake_refused("invalid_wake_context", f"Invalid {label}; expected an integer in 0..16") + number = int(value) + if number > WAKE_HARD_MAX_DEPTH: + raise wake_refused("invalid_wake_context", f"Invalid {label}; expected an integer in 0..16") + return number + + +def wake_chain_origin(value) -> str: + if not isinstance(value, str) or not re.fullmatch( + r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", value): + raise wake_refused("invalid_wake_context", "Wake origin must be a canonical UUID") + return str(uuid.UUID(value)) + + +def wake_chain_context(args: argparse.Namespace) -> dict: + """Validate before effect; retain one origin across an ordered fanout.""" + cached = getattr(args, "_wake_chain_context", None) + if cached is not None: + return cached + explicit_limit = getattr(args, "wake_max_depth", None) + if explicit_limit is not None and (type(explicit_limit) is not int + or not 0 <= explicit_limit <= WAKE_HARD_MAX_DEPTH): + raise wake_refused("invalid_wake_context", "Wake limit must be an integer in 0..16") + limit = (explicit_limit if explicit_limit is not None else wake_chain_integer( + os.environ.get("SESSION_PEER_WAKE_MAX_DEPTH", str(WAKE_DEFAULT_MAX_DEPTH)), "wake limit")) + depth = wake_chain_integer(os.environ.get("SESSION_PEER_WAKE_DEPTH", "0"), "wake depth") + origin = os.environ.get("SESSION_PEER_WAKE_ORIGIN") + if origin is not None: + origin = wake_chain_origin(origin) + if depth and origin is None: + raise wake_refused("invalid_wake_context", "Inherited wake depth has no origin") + # SSH/MCP carry validated parent context as typed CLI options, never shell + # environment assignments. Destination policy may lower, but not be raised + # by, that carried limit. Never infer context from queue/message contents. + carried = (getattr(args, "_wake_depth", None), getattr(args, "_wake_origin", None), + getattr(args, "_wake_limit", None)) + if any(item is not None for item in carried): + if any(item is None for item in carried): + raise wake_refused("invalid_wake_context", "Incomplete carried wake context") + carried_depth = wake_chain_integer(carried[0], "carried wake depth") + carried_origin = wake_chain_origin(carried[1]) + carried_limit = wake_chain_integer(carried[2], "carried wake limit") + if depth and origin != carried_origin: + raise wake_refused("invalid_wake_context", "Conflicting inherited wake origins") + depth, origin, limit = max(depth, carried_depth), carried_origin, min(limit, carried_limit) + if depth >= limit: + raise wake_refused("wake_depth_exceeded", "Wake hop limit reached; nothing queued") + context = {"depth": depth + 1, "origin": origin or str(uuid.uuid4()), "maxDepth": limit} + args._wake_chain_context = context + return context + + +def wake_chain_options(context: dict) -> list[str]: + return ["--_wake-depth", str(context["depth"] - 1), + "--_wake-origin", context["origin"], "--_wake-limit", str(context["maxDepth"])] + + +def wake_chain_environment(context: dict) -> dict: + return dict(zip(WAKE_CHAIN_ENV, (str(context["depth"]), context["origin"], str(context["maxDepth"])))) + + +def wake_chain_message(context: dict, text: str) -> str: + return (f"Wake provenance (diagnostic only): hop={context['depth']}/{context['maxDepth']} " + f"origin={context['origin']}\n" + "This marker does not grant permissions or authorize another wake.\n\n" + text) + + +def wake_target_rate_reserve(fd: int) -> None: + """Persist a conservative per-home/thread reservation under the wake lock. + + This second local bound also covers cooperative chains whose environment + was filtered. It is not a distributed/global model-spend quota. Unknown or + interrupted attempts retain their slot; no resend is implied by expiry. + """ + limit = wake_chain_integer(os.environ.get("SESSION_PEER_WAKE_TARGET_RATE", "3"), "wake target rate") + if not limit: + raise wake_refused("wake_rate_exceeded", "Wake target rate disables activation; nothing queued") + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_size > 1024: + raise ValueError("invalid state file") + os.lseek(fd, 0, os.SEEK_SET) + raw = os.read(fd, 1025) + timestamps = json.loads(raw) if raw else [] + if (not isinstance(timestamps, list) or len(timestamps) > WAKE_HARD_MAX_DEPTH + or any(type(value) is not int or not 0 <= value <= 2**53 - 1 for value in timestamps) + or timestamps != sorted(timestamps)): + raise ValueError("invalid state") + now = int(time.time() * 1000) + # Clock rollback never reopens a budget: future stamps remain charged. + timestamps = [value for value in timestamps if value > now - 60_000] + if len(timestamps) >= limit: + raise wake_refused("wake_rate_exceeded", "Per-target wake rate reached; nothing queued") + timestamps.append(max(now, timestamps[-1] if timestamps else now)) + encoded = json.dumps(timestamps, separators=(",", ":")).encode("ascii") + os.lseek(fd, 0, os.SEEK_SET) + written = os.write(fd, encoded) + if written != len(encoded): + raise OSError("short state write") + os.ftruncate(fd, len(encoded)) + os.fsync(fd) + except (OSError, ValueError, UnicodeError) as exc: + raise wake_refused("wake_rate_state_invalid", "Cannot safely reserve wake target rate; nothing queued") from exc diff --git a/session_peer_mcp.py b/session_peer_mcp.py index 7e2cf7d0..ddee0fcf 100644 --- a/session_peer_mcp.py +++ b/session_peer_mcp.py @@ -235,6 +235,13 @@ async def send_message(self, destination: str, target: str, message: str, argv.append("--dry-run") if wake: argv += ["--wake", "--wake-timeout", str(wake_timeout)] + # Shared MCP startup context is only a cooperative cost hint, not + # authenticated identity of the calling thread. No model-supplied + # max-depth override; use the operator-owned process environment. + try: + argv += core.wake_chain_options(core.wake_chain_context(argparse.Namespace())) + except core.CcPeerError as exc: + return {"ok": False, "error": str(exc), **exc.details} # A shared MCP process cannot authenticate the invoking thread. Never # advertise the session that happened to launch it as the caller. return await self.invoke(argv, "From: session-peer MCP (caller session unavailable)\n\n" + message) diff --git a/tests/codex/test_wake.py b/tests/codex/test_wake.py index dabaf3a1..9b76b52b 100644 --- a/tests/codex/test_wake.py +++ b/tests/codex/test_wake.py @@ -90,7 +90,8 @@ def test_queue_id_survives_wake_failure_without_retry(self): self.assertTrue(result['submitted']) self.assertFalse(result['consumptionConfirmed']) queue.assert_called_once() - wake.assert_called_once_with('codex', self.root, THREAD, str(self.root), 1) + wake.assert_called_once_with('codex', self.root, THREAD, str(self.root), 1, + wake_context=result['wakeProvenance']) def test_active_writer_only_queues(self): active = {'activity':'live_writer', 'writerLock':'held', 'ownerPid':42, diff --git a/tests/codex/test_wake_chain.py b/tests/codex/test_wake_chain.py new file mode 100644 index 00000000..d998cd53 --- /dev/null +++ b/tests/codex/test_wake_chain.py @@ -0,0 +1,194 @@ +"""Cooperative wake cost controls; stand-ins, not credentialed native turns.""" +import argparse +import asyncio +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import AsyncMock, patch + +import session_peer as peer +import session_peer_mcp as mcp_peer +from tests.codex.support import THREAD + +ORIGIN = '01900000-0000-7000-8000-000000000002' +CONTEXT = {'depth': 2, 'origin': ORIGIN, 'maxDepth': 3} + + +class WakeChain(unittest.TestCase): + def setUp(self): + clean = {key: value for key, value in os.environ.items() + if not key.startswith('SESSION_PEER_WAKE_')} + env = patch.dict(os.environ, clean, clear=True) + env.start() + self.addCleanup(env.stop) + + def args(self, **values): + return argparse.Namespace(wake=True, wake_timeout=1, to='codex:'+THREAD, **values) + + def test_default_origin_stable_and_explicit_limit(self): + args = self.args() + context = peer.wake_chain_context(args) + self.assertEqual((context['depth'], context['maxDepth']), (1, 3)) + self.assertEqual(peer.wake_chain_origin(context['origin']), context['origin']) + self.assertEqual(peer.wake_chain_context(args), context) + self.assertEqual(peer.wake_chain_context(self.args(wake_max_depth=8))['maxDepth'], 8) + with self.assertRaises(peer.CcPeerError) as error: + peer.wake_chain_context(self.args(wake_max_depth=0)) + self.assertEqual(error.exception.details['wake']['reason'], 'wake_depth_exceeded') + + def test_exceeded_and_invalid_context_refuse_before_any_queue(self): + cases = [({'SESSION_PEER_WAKE_DEPTH': '3', 'SESSION_PEER_WAKE_ORIGIN': ORIGIN}, 'wake_depth_exceeded'), + ({'SESSION_PEER_WAKE_DEPTH': '2'}, 'invalid_wake_context'), + ({'SESSION_PEER_WAKE_DEPTH': '01'}, 'invalid_wake_context'), + ({'SESSION_PEER_WAKE_DEPTH': '-1'}, 'invalid_wake_context'), + ({'SESSION_PEER_WAKE_DEPTH': '17'}, 'invalid_wake_context'), + ({'SESSION_PEER_WAKE_MAX_DEPTH': '999999999999999999'}, 'invalid_wake_context'), + ({'SESSION_PEER_WAKE_ORIGIN': '\x1b[31m'}, 'invalid_wake_context')] + for environment, reason in cases: + with self.subTest(environment=environment), patch.dict(os.environ, environment), \ + patch.object(peer, '_queue_codex') as queue, patch.object(peer, 'run_codex_wake') as wake: + with self.assertRaises(peer.CcPeerError) as error: + peer.queue_codex(self.args(dry_run=False), 'hello') + self.assertEqual(error.exception.details['wake']['reason'], reason) + self.assertFalse(error.exception.details['submitted']) + queue.assert_not_called() + wake.assert_not_called() + + def test_ssh_context_does_not_double_increment_or_raise_destination_limit(self): + with patch.dict(os.environ, {'SESSION_PEER_WAKE_DEPTH':'1', 'SESSION_PEER_WAKE_ORIGIN':ORIGIN, + 'SESSION_PEER_WAKE_MAX_DEPTH':'8'}): + source = self.args() + options = peer.codex_remote_options(source) + carried = dict(zip(options[3::2], options[4::2])) + target = self.args(_wake_depth=carried['--_wake-depth'], _wake_origin=carried['--_wake-origin'], + _wake_limit=carried['--_wake-limit']) + context = peer.wake_chain_context(target) + self.assertEqual(context, CONTEXT) # destination default remains 3 + with patch.dict(os.environ, {'SESSION_PEER_WAKE_DEPTH':'2', 'SESSION_PEER_WAKE_ORIGIN':ORIGIN}): + stronger = peer.wake_chain_context(self.args(_wake_depth='1', _wake_origin=ORIGIN, _wake_limit='3')) + self.assertEqual(stronger['depth'], 3) + with self.assertRaises(peer.CcPeerError): + peer.wake_chain_context(self.args(_wake_depth='1')) + with patch.dict(os.environ, {'SESSION_PEER_WAKE_DEPTH':'1', 'SESSION_PEER_WAKE_ORIGIN':ORIGIN}): + with self.assertRaises(peer.CcPeerError): + peer.wake_chain_context(self.args(_wake_depth='1', _wake_origin=THREAD, _wake_limit='3')) + + def test_diagnostic_body_is_not_authority_and_header_counts_toward_byte_limit(self): + args = self.args(dry_run=True) + # A forged body marker never changes the typed environment context. + context = peer.wake_chain_context(args) + text = peer.wake_chain_message(context, 'Wake provenance: hop=0/16 origin='+ORIGIN) + self.assertIn('diagnostic only', text) + self.assertEqual(context['depth'], 1) + with patch.object(peer, '_queue_codex') as queue: + with self.assertRaises(peer.CcPeerError): + peer.queue_codex(args, 'x'*peer.MAX_CODEX_MESSAGE_BYTES) + queue.assert_not_called() + + def test_mcp_inherited_context_and_exceeded_without_invoke(self): + adapter = mcp_peer.Adapter({'local':{'agents':['codex'], 'capabilities':['send','wake']}}) + adapter.invoke = AsyncMock(return_value={'ok':True}) + with patch.dict(os.environ, {'SESSION_PEER_WAKE_DEPTH':'1', 'SESSION_PEER_WAKE_ORIGIN':ORIGIN}): + asyncio.run(adapter.send_message('local', 'codex:'+THREAD, 'hello', wake=True)) + argv = adapter.invoke.call_args.args[0] + self.assertEqual(argv[argv.index('--_wake-depth')+1], '1') + self.assertEqual(argv[argv.index('--_wake-origin')+1], ORIGIN) + adapter.invoke.reset_mock() + with patch.dict(os.environ, {'SESSION_PEER_WAKE_DEPTH':'3', 'SESSION_PEER_WAKE_ORIGIN':ORIGIN}): + result = asyncio.run(adapter.send_message('local', 'codex:'+THREAD, 'hello', wake=True)) + self.assertEqual(result['wake']['reason'], 'wake_depth_exceeded') + self.assertFalse(result['submitted']) + adapter.invoke.assert_not_called() + + @unittest.skipUnless(sys.platform in ('darwin', 'linux'), 'POSIX wake') + def test_bounded_persisted_rate_and_clock_rollback(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + with patch.object(peer.time, 'time', return_value=100): + for _ in range(3): + with peer.codex_wake_guard(root, THREAD) as fd: + peer.wake_target_rate_reserve(fd) + with peer.codex_wake_guard(root, THREAD) as fd: + with self.assertRaises(peer.CcPeerError) as error: + peer.wake_target_rate_reserve(fd) + self.assertEqual(error.exception.details['wake']['reason'], 'wake_rate_exceeded') + with patch.object(peer.time, 'time', return_value=90), peer.codex_wake_guard(root, THREAD) as fd: + with self.assertRaises(peer.CcPeerError): + peer.wake_target_rate_reserve(fd) + with patch.object(peer.time, 'time', return_value=161), peer.codex_wake_guard(root, THREAD) as fd: + peer.wake_target_rate_reserve(fd) + state = root/'session-peer'/'wake-locks'/(THREAD+'.lock') + self.assertEqual(json.loads(state.read_text()), [161000]) + state.write_text('corrupt') + with peer.codex_wake_guard(root, THREAD) as fd: + with self.assertRaises(peer.CcPeerError) as error: + peer.wake_target_rate_reserve(fd) + self.assertEqual(error.exception.details['wake']['reason'], 'wake_rate_state_invalid') + + @unittest.skipUnless(sys.platform in ('darwin', 'linux'), 'POSIX wake') + def test_rate_rejection_precedes_queue_and_preserves_active_and_dryrun(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + inactive = {'activity':'inactive'} + submitted = {'ok':True, 'submitted':True, 'queueId':'fixture-only', 'consumptionConfirmed':False} + with patch.object(peer, 'resolve_codex_home', return_value=(root, {})), \ + patch.object(peer, 'revalidate_codex_home'), \ + patch.object(peer, 'codex_executable', return_value='fixture-codex'), \ + patch.object(peer, 'codex_wake_preflight', return_value={'cwd':folder, 'writer':inactive}) as preflight, \ + patch.object(peer, 'inspect_codex_writer', return_value=inactive) as inspect, \ + patch.object(peer, '_queue_codex', side_effect=lambda *args:dict(submitted)) as queue, \ + patch.object(peer, 'run_codex_wake', return_value={'status':'completed'}) as wake: + for _ in range(3): + result = peer.queue_codex(self.args(dry_run=False), 'hello') + self.assertEqual(result['wakeProvenance']['depth'], 1) + self.assertIn('hop=1/3', queue.call_args.args[1]) + with self.assertRaises(peer.CcPeerError) as error: + peer.queue_codex(self.args(dry_run=False), 'refused') + self.assertEqual(error.exception.details['wake']['reason'], 'wake_rate_exceeded') + self.assertEqual(queue.call_count, 3) + self.assertEqual(wake.call_count, 3) + preflight.return_value = {'cwd':folder, 'writer':{'activity':'live_writer'}} + inspect.return_value = {'activity':'live_writer'} + with patch.dict(os.environ, {'SESSION_PEER_WAKE_TARGET_RATE':'0'}): + active = peer.queue_codex(self.args(dry_run=False), 'already active') + validated = peer.queue_codex(self.args(dry_run=True), 'dryrun') + self.assertEqual(active['wake']['status'], 'already_active') + self.assertEqual(validated['wake']['status'], 'validated') + self.assertEqual(wake.call_count, 3) + + @unittest.skipUnless(sys.platform in ('darwin', 'linux'), 'POSIX app-server fixture') + def test_app_server_real_child_receives_incremented_environment(self): + fixture = Path(__file__).parents[1]/'fixtures'/'codex_app_server.py' + real_popen = subprocess.Popen + def spawn(argv, **kwargs): + if argv[:1] == ['ps']: + return real_popen(argv, **kwargs) + expected = peer.wake_chain_environment(CONTEXT) + self.assertEqual({key:kwargs['env'][key] for key in peer.WAKE_CHAIN_ENV}, expected) + kwargs['env']['WAKE_TEST_EXPECT_CHAIN'] = json.dumps(expected) + return real_popen([sys.executable, str(fixture)], **kwargs) + with tempfile.TemporaryDirectory() as folder, patch.object(peer.subprocess, 'Popen', side_effect=spawn): + result = peer.run_codex_wake('codex', Path(folder), THREAD, folder, 1, wake_context=CONTEXT) + self.assertEqual(result['status'], 'completed') + + def test_policy_stand_in_retains_all_but_core_and_default_mcp_filter_it(self): + # Mirrors the pinned upstream 0.154 shell/MCP whitelist rules, NOT a + # credentialed Codex shell/tool session or upstream Rust test execution. + environment = dict(os.environ, **peer.wake_chain_environment(CONTEXT)) + policies = [('inherit-all', environment, True), + ('inherit-core', {key:value for key,value in environment.items() + if key in ('PATH','HOME','USER','SHELL')}, False), + ('mcp-default', {key:value for key,value in environment.items() + if key in ('HOME','PATH','USER','SHELL','LANG')}, False), + ('mcp-env-vars', environment, True), + ('include-only-PATH', {'PATH':environment.get('PATH','')}, False)] + script = 'import os,json;print(json.dumps({k:v for k,v in os.environ.items() if k.startswith("SESSION_PEER_WAKE_")}))' + for name, filtered, retained in policies: + with self.subTest(policy=name): + done = subprocess.run([sys.executable, '-I', '-c', script], env=filtered, + capture_output=True, check=True, text=True) + self.assertEqual(json.loads(done.stdout), peer.wake_chain_environment(CONTEXT) if retained else {}) diff --git a/tests/fixtures/codex_app_server.py b/tests/fixtures/codex_app_server.py index e5cd86e0..e5e38edd 100644 --- a/tests/fixtures/codex_app_server.py +++ b/tests/fixtures/codex_app_server.py @@ -4,6 +4,10 @@ import sys import time +if os.environ.get('WAKE_TEST_EXPECT_CHAIN'): + for key, expected in json.loads(os.environ['WAKE_TEST_EXPECT_CHAIN']).items(): + assert os.environ.get(key) == expected + for line in sys.stdin: value = json.loads(line) if value.get('method') == 'initialize': diff --git a/tests/mcp_integration/test_mcp.py b/tests/mcp_integration/test_mcp.py index e86ee2a5..0c039885 100644 --- a/tests/mcp_integration/test_mcp.py +++ b/tests/mcp_integration/test_mcp.py @@ -257,7 +257,10 @@ async def test_wake_passes_only_bounded_native_options(self): adapter=mcp_peer.Adapter({'local':{'agents':['codex'],'capabilities':['send','wake'],'codexHome':'/custom'}}) adapter.invoke=AsyncMock(return_value={'ok':True}) await adapter.send_message('local','codex:'+THREAD,'hello',wake=True,wake_timeout=12) - self.assertEqual(adapter.invoke.call_args.args[0][-3:],['--wake','--wake-timeout','12']) + argv = adapter.invoke.call_args.args[0] + self.assertEqual(argv[argv.index('--wake'):argv.index('--wake')+3], + ['--wake','--wake-timeout','12']) + self.assertEqual(argv[argv.index('--_wake-depth')+1], '0') for timeout in (0,61): with self.assertRaises(mcp_peer.PolicyError): await adapter.send_message('local','codex:'+THREAD,'hello',wake=True,wake_timeout=timeout) diff --git a/tools/generate_session_peer.py b/tools/generate_session_peer.py index d2ee612f..6d56cd0a 100644 --- a/tools/generate_session_peer.py +++ b/tools/generate_session_peer.py @@ -15,6 +15,7 @@ DEFAULT_OUTPUT = ROOT / "session_peer.py" SEGMENTS = ( "common.py", + "wake_chain.py", "codex.py", "claude.py", "replies.py", From f5502a0c4b75604b8f83554ae5984a749c03cf35 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:31:44 +0900 Subject: [PATCH 2/3] fix(wake): refuse unsafe activation guard state --- session_peer.py | 27 +++++++++++++++++++++++---- session_peer_core/codex.py | 20 +++++++++++++++++--- session_peer_core/wake_chain.py | 7 ++++++- tests/codex/test_wake_chain.py | 21 +++++++++++++++++++++ 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/session_peer.py b/session_peer.py index 28b6da96..8805df97 100644 --- a/session_peer.py +++ b/session_peer.py @@ -172,6 +172,11 @@ def wake_chain_message(context: dict, text: str) -> str: "This marker does not grant permissions or authorize another wake.\n\n" + text) +def wake_state_file_private(info) -> bool: + return (stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() + and info.st_mode & 0o077 == 0 and info.st_nlink == 1) + + def wake_target_rate_reserve(fd: int) -> None: """Persist a conservative per-home/thread reservation under the wake lock. @@ -184,7 +189,7 @@ def wake_target_rate_reserve(fd: int) -> None: raise wake_refused("wake_rate_exceeded", "Wake target rate disables activation; nothing queued") try: info = os.fstat(fd) - if not stat.S_ISREG(info.st_mode) or info.st_size > 1024: + if not wake_state_file_private(info) or info.st_size > 1024: raise ValueError("invalid state file") os.lseek(fd, 0, os.SEEK_SET) raw = os.read(fd, 1025) @@ -1054,9 +1059,23 @@ def codex_wake_preflight(root: Path, thread_id: str, executable: str) -> dict: @contextlib.contextmanager def codex_wake_guard(root: Path, thread_id: str): directory = root / "session-peer" / "wake-locks" - directory.mkdir(parents=True, exist_ok=True, mode=0o700) - fd = os.open(directory / (thread_id + ".lock"), - os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600) + try: + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + for path, unsafe_bits in ((directory.parent, 0o022), (directory, 0o077)): + info = path.lstat() + if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() + or info.st_mode & unsafe_bits): + raise ValueError("unsafe wake directory") + fd = os.open(directory / (thread_id + ".lock"), + os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600) + try: + if not wake_state_file_private(os.fstat(fd)): + raise ValueError("unsafe wake state file") + except (OSError, ValueError): + os.close(fd) + raise + except (OSError, ValueError) as exc: + raise wake_refused("wake_rate_state_invalid", "Wake state ownership or permissions are unsafe; nothing queued") from exc try: try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) diff --git a/session_peer_core/codex.py b/session_peer_core/codex.py index 6efe3f44..9bff5baa 100644 --- a/session_peer_core/codex.py +++ b/session_peer_core/codex.py @@ -843,9 +843,23 @@ def codex_wake_preflight(root: Path, thread_id: str, executable: str) -> dict: @contextlib.contextmanager def codex_wake_guard(root: Path, thread_id: str): directory = root / "session-peer" / "wake-locks" - directory.mkdir(parents=True, exist_ok=True, mode=0o700) - fd = os.open(directory / (thread_id + ".lock"), - os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600) + try: + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + for path, unsafe_bits in ((directory.parent, 0o022), (directory, 0o077)): + info = path.lstat() + if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() + or info.st_mode & unsafe_bits): + raise ValueError("unsafe wake directory") + fd = os.open(directory / (thread_id + ".lock"), + os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600) + try: + if not wake_state_file_private(os.fstat(fd)): + raise ValueError("unsafe wake state file") + except (OSError, ValueError): + os.close(fd) + raise + except (OSError, ValueError) as exc: + raise wake_refused("wake_rate_state_invalid", "Wake state ownership or permissions are unsafe; nothing queued") from exc try: try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) diff --git a/session_peer_core/wake_chain.py b/session_peer_core/wake_chain.py index 31655830..fd04566d 100644 --- a/session_peer_core/wake_chain.py +++ b/session_peer_core/wake_chain.py @@ -74,6 +74,11 @@ def wake_chain_message(context: dict, text: str) -> str: "This marker does not grant permissions or authorize another wake.\n\n" + text) +def wake_state_file_private(info) -> bool: + return (stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() + and info.st_mode & 0o077 == 0 and info.st_nlink == 1) + + def wake_target_rate_reserve(fd: int) -> None: """Persist a conservative per-home/thread reservation under the wake lock. @@ -86,7 +91,7 @@ def wake_target_rate_reserve(fd: int) -> None: raise wake_refused("wake_rate_exceeded", "Wake target rate disables activation; nothing queued") try: info = os.fstat(fd) - if not stat.S_ISREG(info.st_mode) or info.st_size > 1024: + if not wake_state_file_private(info) or info.st_size > 1024: raise ValueError("invalid state file") os.lseek(fd, 0, os.SEEK_SET) raw = os.read(fd, 1025) diff --git a/tests/codex/test_wake_chain.py b/tests/codex/test_wake_chain.py index d998cd53..3b1505da 100644 --- a/tests/codex/test_wake_chain.py +++ b/tests/codex/test_wake_chain.py @@ -160,6 +160,27 @@ def test_rate_rejection_precedes_queue_and_preserves_active_and_dryrun(self): self.assertEqual(validated['wake']['status'], 'validated') self.assertEqual(wake.call_count, 3) + @unittest.skipUnless(sys.platform in ('darwin', 'linux'), 'POSIX private wake state') + def test_unsafe_state_and_directory_permissions_refuse_without_repair(self): + with tempfile.TemporaryDirectory() as folder, patch.object(peer, '_queue_codex') as queue: + root = Path(folder) + with peer.codex_wake_guard(root, THREAD): + pass + directory = root/'session-peer'/'wake-locks' + state = directory/(THREAD+'.lock') + state.chmod(0o666) + with self.assertRaises(peer.CcPeerError) as error: + with peer.codex_wake_guard(root, THREAD): + self.fail('unsafe state accepted') + self.assertEqual(error.exception.details['wake']['reason'], 'wake_rate_state_invalid') + self.assertEqual(state.stat().st_mode & 0o777, 0o666) + state.chmod(0o600) + directory.chmod(0o777) + with self.assertRaises(peer.CcPeerError): + with peer.codex_wake_guard(root, THREAD): + self.fail('unsafe directory accepted') + queue.assert_not_called() + @unittest.skipUnless(sys.platform in ('darwin', 'linux'), 'POSIX app-server fixture') def test_app_server_real_child_receives_incremented_environment(self): fixture = Path(__file__).parents[1]/'fixtures'/'codex_app_server.py' From c009972a39cc435dad434ab3ba3d5a53e90fdb52 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 10:04:04 +0900 Subject: [PATCH 3/3] test(wake): verify pinned native environment policies without models --- docs/i18n-manifest.json | 2 +- docs/ja/wake.md | 2 +- docs/ko/wake.md | 2 +- docs/wake.md | 2 +- docs/zh-CN/wake.md | 2 +- tests/codex/test_wake_native_environment.py | 144 ++++++++++++++++++++ tests/fixtures/codex_mcp_environment.py | 33 +++++ 7 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 tests/codex/test_wake_native_environment.py create mode 100644 tests/fixtures/codex_mcp_environment.py diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index 2691387d..3a0cdf09 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -47,7 +47,7 @@ "docs/releases/v1.0.4.md": "5185d9ff93a98077621ec017e54f73138dcf05fe9de121b073f9a097240b40b4", "docs/validation/69-rc.md": "e490c6a9ad0fec9108b91c74922cedac7ddf214ed405f8f5523da22f48a4b222", "docs/validation/85-antigravity.md": "468ba1702a60c57079730ab5b7545b0b1968f11cc6832f0ac88460664ba87723", - "docs/wake.md": "f8d67f59172d19d69900f2fc1371a09d0a1251155db20d7d7f0223d6870118c6", + "docs/wake.md": "cde135da2ea7be21c815bb265e694f737dbe467d8e76bb82eb118e3b86e29d22", "plugins/session-peer/README.md": "a4eeae54caac9ad824d740f7f319561f060bc9c2bbbbf7def2bdd8e36dbcd1c6" } } diff --git a/docs/ja/wake.md b/docs/ja/wake.md index bc37bc0b..2eca8271 100644 --- a/docs/ja/wake.md +++ b/docs/ja/wake.md @@ -10,7 +10,7 @@ CLI と MCP は投入前に `SESSION_PEER_WAKE_DEPTH` と `SESSION_PEER_WAKE_ORI 固定した Codex 0.154.0 ソースでは、シェルの `inherit="all"` は除外規則や `include_only` がなければ変数を保持し、`inherit="core"` と `inherit="none"` は明示的な設定がなければ除去します。既定の秘密名フィルターには該当しません。stdio MCP の既定の許可リストには含まれず、新しい MCP プロセスには `env_vars` 転送が必要です。既存・共有 MCP プロセスは後の呼び出し元の環境を受け取りません。静的な `env` 設定は文脈を初期化したり競合させたりします。文脈が失われると新しいローカル発生元になるため全体の段数上限は保証できず、ローカル起動制限は別の補助策です。承認・プロファイル・ネイティブ環境ポリシーは自動変更しません。 -隔離した子プロセスのテストは上流ソースの規則を模倣し、CLI・MCP・SSH の文脈と app-server 代替プロセスの環境を検証します。認証済みのネイティブシェル・MCP クライアント・モデルターンは実行せず、0.154.0 以外の wake サポートの根拠ではありません。出典: [固定したシェルポリシー](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs)、[固定した MCP 環境](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs)、[現在の設定資料](https://developers.openai.com/codex/config-reference/)。 +隔離した子プロセスのテストは CLI・MCP・SSH の文脈と app-server 代替プロセスの環境を検証します。別途明示的に選ぶネイティブテストは、macOS arm64 用 Codex 0.154.0 の実際の環境構築経路を確認します。`command/exec` のシェルポリシー 6 ケースと、専用 stdio MCP テストサーバーを起動する `mcpServerStatus/list` の 4 ケースが対象です。このサーバーに受領確認機能はありません。合成した環境情報だけを調べ、ユーザー資格情報・スレッド・ターン・プロンプト・モデル・MCP ツール呼び出し・wake は使用しません。別途用意した固定バイナリを `SESSION_PEER_CODEX154_ENV_BINARY` に指定し、`python3 -m unittest tests.codex.test_wake_native_environment` を実行します。ダウンロードやインストールは行わず、異なるバイナリは拒否します。未指定ではスキップし、実行証拠にはなりません。リリース圧縮ファイルの SHA-256 は `344310a0a591c1b192e04feff304321a69907c9498baaac331ca7e16ebcef9d7` で、展開後のバイナリハッシュもテストに固定しています。これは当該プラットフォーム・バージョンの環境経路のみの証拠であり、モデルターン・実際の wake・他のプラットフォーム・後続バージョンの証拠ではありません。出典: [固定したシェルポリシー](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs)、[固定した MCP 環境](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs)、[現在の設定資料](https://developers.openai.com/codex/config-reference/)。 `session-peer send --to codex: --wake --wake-timeout 30 "message"` diff --git a/docs/ko/wake.md b/docs/ko/wake.md index 7f96606b..27be77c8 100644 --- a/docs/ko/wake.md +++ b/docs/ko/wake.md @@ -10,7 +10,7 @@ CLI와 MCP는 큐 제출 전에 `SESSION_PEER_WAKE_DEPTH`와 `SESSION_PEER_WAKE_ 고정된 Codex 0.154.0 소스에서 셸의 `inherit="all"`은 제외 규칙이나 `include_only`가 없으면 이 변수를 유지하며, `inherit="core"`와 `inherit="none"`은 명시적 설정 없이는 제거합니다. 기본 비밀 이름 필터에는 걸리지 않습니다. stdio MCP의 기본 허용 목록에는 없으므로 새 MCP 프로세스에는 `env_vars` 전달을 명시해야 합니다. 기존·공유 MCP 프로세스는 나중 호출자의 환경을 받지 못합니다. 정적인 `env` 설정은 문맥을 초기화하거나 충돌시킬 수 있습니다. 필터로 문맥이 사라지면 새 로컬 원본을 만들므로 전역 단계 한도를 보장할 수 없으며, 로컬 활성화 제한은 별도의 보완책입니다. 승인·프로필·네이티브 환경 정책은 자동 변경하지 않습니다. -격리된 하위 프로세스 테스트는 상위 소스의 규칙을 모사하고 CLI·MCP·SSH 문맥과 app-server 대체 프로세스의 환경을 검증합니다. 인증된 네이티브 셸·MCP 클라이언트·모델 턴은 실행하지 않으며 0.154.0 이후 버전의 wake 지원 근거는 아닙니다. 출처: [고정된 셸 정책](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs), [고정된 MCP 환경](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs), [현재 구성 설명서](https://developers.openai.com/codex/config-reference/). +격리된 하위 프로세스 테스트는 CLI·MCP·SSH 문맥과 app-server 대체 프로세스의 환경을 검증합니다. 별도로 명시적 선택이 필요한 네이티브 테스트는 macOS arm64용 Codex 0.154.0의 실제 환경 구성 경로를 검증합니다. `command/exec`의 셸 정책 6가지와 전용 stdio MCP 대체 서버를 시작하는 `mcpServerStatus/list`의 4가지를 확인하며, 이 서버에는 수신 확인 기능이 없습니다. 합성 환경 정보만 확인하고 사용자 자격증명·스레드·턴·프롬프트·모델·MCP 도구 호출·wake는 사용하지 않습니다. 별도로 준비한 고정 실행 파일을 `SESSION_PEER_CODEX154_ENV_BINARY`로 지정한 뒤 `python3 -m unittest tests.codex.test_wake_native_environment`를 실행합니다. 테스트는 다운로드나 설치를 수행하지 않으며 다른 실행 파일은 거부합니다. 지정하지 않으면 건너뛰며, 이는 실제 실행 증거가 아닙니다. 릴리스 압축 파일의 SHA-256은 `344310a0a591c1b192e04feff304321a69907c9498baaac331ca7e16ebcef9d7`이고 추출된 실행 파일 해시도 테스트에 고정했습니다. 이 검증은 해당 플랫폼과 버전의 환경 전달 경로만 입증하며 모델 턴·실제 wake·다른 플랫폼·후속 Codex 버전을 입증하지 않습니다. 출처: [고정된 셸 정책](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs), [고정된 MCP 환경](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs), [현재 구성 설명서](https://developers.openai.com/codex/config-reference/). `session-peer send --to codex: --wake --wake-timeout 30 "message"` diff --git a/docs/wake.md b/docs/wake.md index 6a1ac535..0bbb3e18 100644 --- a/docs/wake.md +++ b/docs/wake.md @@ -10,7 +10,7 @@ Inactive activation also defaults to 3 attempts per canonical home/thread in 60 In the pinned Codex 0.154.0 source, shell `inherit="all"` retains these variables unless exclusion or `include_only` filters them; `inherit="core"` and `inherit="none"` drop them unless explicitly set. Default secret-name exclusions do not match them. Stdio MCP's default whitelist omits them; a new MCP process requires explicit `env_vars` forwarding. Existing/shared MCP processes do not receive a later caller's environment. Static `env` overrides may reset or conflict with context. Filtered/missing context starts a new local origin, so the hop limit cannot guarantee a global chain bound. The local activation rate is a separate backstop. No approval, profile or native environment policy is changed automatically. -Isolated subprocess fixtures mirror these upstream rules and verify CLI/MCP/SSH context and the app-server stand-in environment. They do not run a credentialed native shell, MCP client or model turn and do not establish wake support beyond 0.154.0. Sources: [pinned shell policy](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs), [pinned MCP environment](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs), [current configuration reference](https://developers.openai.com/codex/config-reference/). +Isolated subprocess fixtures verify CLI/MCP/SSH context and the app-server stand-in environment. A separate opt-in native fixture verifies the actual Codex 0.154.0 macOS arm64 environment builders: six `command/exec` shell-policy cases and four `mcpServerStatus/list` cases that start a private, receipt-less stdio MCP fixture. Only synthetic environment metadata is inspected; no user credentials, thread, turn, prompt, model, MCP tool invocation or wake is used. Set `SESSION_PEER_CODEX154_ENV_BINARY` to an independently provisioned pinned binary and run `python3 -m unittest tests.codex.test_wake_native_environment`; the test performs no download or installation and rejects a wrong binary. Without opt-in these tests skip, which is not native execution evidence. The release archive SHA-256 is `344310a0a591c1b192e04feff304321a69907c9498baaac331ca7e16ebcef9d7`; the extracted binary hash is pinned in the test. This proves only these environment paths on that platform/version, not a model turn, native wake, other platforms or later Codex versions. Sources: [pinned shell policy](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs), [pinned MCP environment](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs), [current configuration reference](https://developers.openai.com/codex/config-reference/). `session-peer send --to codex: --wake --wake-timeout 30 "message"` diff --git a/docs/zh-CN/wake.md b/docs/zh-CN/wake.md index b0cd06ac..3d17929c 100644 --- a/docs/zh-CN/wake.md +++ b/docs/zh-CN/wake.md @@ -10,7 +10,7 @@ CLI 和 MCP 在入队前读取 `SESSION_PEER_WAKE_DEPTH` 和 `SESSION_PEER_WAKE_ 固定的 Codex 0.154.0 源码中,shell 的 `inherit="all"` 在没有排除规则或 `include_only` 过滤时保留这些变量;`inherit="core"` 和 `inherit="none"` 在没有显式设置时删除。默认敏感名称过滤不匹配它们。stdio MCP 默认允许列表不包含它们,新 MCP 进程需要显式 `env_vars` 转发。已有或共享 MCP 进程不会获得后来调用者的环境。静态 `env` 配置可能重置上下文或产生冲突。上下文丢失后创建新的本地来源,因而无法保证全局层数上限;本地激活速率是独立的补充防护。不自动修改审批、配置或原生环境策略。 -隔离子进程测试模拟上游源码规则,验证 CLI、MCP、SSH 上下文和 app-server 替代进程的环境,不运行已认证的原生 shell、MCP 客户端或模型轮次,不构成 0.154.0 之外的 wake 支持证据。来源:[固定 shell 策略](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs)、[固定 MCP 环境](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs)、[当前配置参考](https://developers.openai.com/codex/config-reference/)。 +隔离子进程测试验证 CLI、MCP、SSH 上下文及 app-server 替代进程的环境。另有需明确选择的原生测试,验证 macOS arm64 版 Codex 0.154.0 的实际环境构建路径:`command/exec` 的 6 种 shell 策略,以及通过 `mcpServerStatus/list` 启动专用 stdio MCP 测试服务器的 4 种情况。该服务器没有回执功能。仅检查合成环境信息,不使用用户凭据、线程、轮次、提示词、模型、MCP 工具调用或 wake。将单独准备的固定二进制文件指定为 `SESSION_PEER_CODEX154_ENV_BINARY`,然后运行 `python3 -m unittest tests.codex.test_wake_native_environment`。测试不下载或安装任何内容,并拒绝不匹配的二进制文件。未指定时跳过,不能视为原生执行证据。发行压缩包的 SHA-256 为 `344310a0a591c1b192e04feff304321a69907c9498baaac331ca7e16ebcef9d7`,解压后的二进制哈希也固定在测试中。这仅证明该平台和版本的环境路径,不证明模型轮次、实际 wake、其他平台或后续 Codex 版本。来源:[固定 shell 策略](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/protocol/src/shell_environment.rs)、[固定 MCP 环境](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/rmcp-client/src/utils.rs)、[当前配置参考](https://developers.openai.com/codex/config-reference/)。 `session-peer send --to codex: --wake --wake-timeout 30 "message"` diff --git a/tests/codex/test_wake_native_environment.py b/tests/codex/test_wake_native_environment.py new file mode 100644 index 00000000..1b5a3794 --- /dev/null +++ b/tests/codex/test_wake_native_environment.py @@ -0,0 +1,144 @@ +"""Opt-in actual Codex 0.154.0 environment builders, without models or wakes. + +Set SESSION_PEER_CODEX154_ENV_BINARY to the pre-provisioned macOS arm64 binary. +No download/install occurs. An explicit wrong binary fails rather than skips. +The only RPCs are initialize, command/exec (env metadata), mcpServerStatus/list. +No user home, credentials, thread, turn, prompt, model or MCP tool call is used. +""" +import hashlib +import json +import os +from pathlib import Path +import platform +import selectors +import signal +import stat +import subprocess +import sys +import tempfile +import time +import unittest + +SOURCE_COMMIT = "6b9826e3aa83b1a5947db50f4332cb9c65f1b340" +RELEASE_ARCHIVE_SHA256 = "344310a0a591c1b192e04feff304321a69907c9498baaac331ca7e16ebcef9d7" +BINARY_SHA256 = "4f85982624b3898c8991cb80c0981b2aa71070e3537046c9a95950318a95afcc" +BINARY_OPTION = "SESSION_PEER_CODEX154_ENV_BINARY" +MARKERS = {"SESSION_PEER_WAKE_DEPTH": "1", "SESSION_PEER_WAKE_MAX_DEPTH": "3", + "SESSION_PEER_WAKE_ORIGIN": "00000000-0000-4000-8000-000000000001"} + + +@unittest.skipUnless(os.environ.get(BINARY_OPTION), "explicit pinned native environment fixture not configured") +class NativeWakeEnvironment(unittest.TestCase): + @classmethod + def setUpClass(cls): + if sys.platform != "darwin" or platform.machine() != "arm64": + raise AssertionError("this executable proof is pinned to macOS arm64, not other platforms") + cls.binary = Path(os.environ[BINARY_OPTION]).resolve(strict=True) + info = cls.binary.stat() + if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o022 or info.st_uid not in (0, os.getuid()): + raise AssertionError("unsafe explicitly selected native fixture binary") + with cls.binary.open("rb") as source: + digest = hashlib.sha256() + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + if digest.hexdigest() != BINARY_SHA256: + raise AssertionError("explicit native binary does not match pinned release asset") + + def setUp(self): + temp = tempfile.TemporaryDirectory(prefix="codex-native-wake-env-", dir=os.environ.get("SESSION_PEER_TEST_TMP")) + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.home = self.root / "home" + self.home.mkdir(mode=0o700) + self.environment = {"PATH": "/usr/bin:/bin", "HOME": str(self.home), "CODEX_HOME": str(self.home), + "ORDINARY_TEST": "ordinary", "NODE_REPL_AUTH_TOKEN": "synthetic-not-a-token", **MARKERS} + + def request(self, process, request, buffers): + process.stdin.write((json.dumps(request) + "\n").encode("utf-8")) + process.stdin.flush() + deadline = time.monotonic() + 10 + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ, "stdout") + selector.register(process.stderr, selectors.EVENT_READ, "stderr") + while time.monotonic() < deadline: + while b"\n" in buffers["stdout"]: + line, _, rest = buffers["stdout"].partition(b"\n") + buffers["stdout"] = bytearray(rest) + response = json.loads(line.decode("utf-8")) + if response.get("id") == request["id"]: + self.assertNotIn("error", response) + return response["result"] + for key, _ in selector.select(max(0, deadline - time.monotonic())): + chunk = os.read(key.fileobj.fileno(), 4096) + if not chunk: + selector.unregister(key.fileobj) + continue + buffers[key.data].extend(chunk) + self.assertLessEqual(len(buffers[key.data]), 1024 * 1024, "bounded fixture output exceeded") + if not selector.get_map(): + break + self.fail("bounded native RPC failed: " + bytes(buffers["stderr"][:2048]).decode("utf-8", "replace")) + + def run_native(self, config, *, mcp=False): + argv = [str(self.binary), "app-server", "-c", "analytics.enabled=false", + "-c", 'sandbox_mode="danger-full-access"'] + for override in config: + argv.extend(["-c", override]) + process = subprocess.Popen(argv, cwd=self.root, env=self.environment, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) + buffers = {"stdout": bytearray(), "stderr": bytearray()} + try: + self.request(process, {"id": 1, "method": "initialize", "params": { + "clientInfo": {"name": "private-environment-fixture", "version": "1"}, + "capabilities": {"experimentalApi": True}}}, buffers) + process.stdin.write(b'{"method":"initialized"}\n') + process.stdin.flush() + if mcp: + result = self.request(process, {"id": 2, "method": "mcpServerStatus/list", "params": {"limit": 10}}, buffers) + row = next(item for item in result["data"] if item["name"] == "fixture") + tool = next(iter(row["tools"].values())) + values = json.loads(tool["description"]) + else: + result = self.request(process, {"id": 2, "method": "command/exec", "params": { + "command": ["/usr/bin/env"], "cwd": str(self.root), "timeoutMs": 3000, + "outputBytesCap": 32768}}, buffers) + self.assertEqual(result["exitCode"], 0) + values = dict(line.split("=", 1) for line in result["stdout"].splitlines() if "=" in line) + self.assertNotIn("NODE_REPL_AUTH_TOKEN", values) + return {key: values[key] for key in (*MARKERS, "ORDINARY_TEST") if key in values} + finally: + process.stdin.close() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=2) + process.stdout.close() + process.stderr.close() + + def test_actual_shell_policy_builder(self): + cases = [("all", [], {**MARKERS, "ORDINARY_TEST": "ordinary"}), ("core", [], {}), ("none", [], {}), + ("all", ['include_only=["SESSION_PEER_WAKE_*"]'], MARKERS), + ("all", ['exclude=["SESSION_PEER_WAKE_*"]'], {"ORDINARY_TEST": "ordinary"}), + ("none", ['include_only=["SESSION_PEER_WAKE_*"]'], {})] + for inherit, filters, expected in cases: + with self.subTest(inherit=inherit, filters=filters): + config = ['shell_environment_policy.inherit=' + json.dumps(inherit)] + config += ["shell_environment_policy." + item for item in filters] + self.assertEqual(self.run_native(config), expected) + + def test_actual_mcp_startup_builder(self): + fixture = Path(__file__).resolve().parents[1] / "fixtures" / "codex_mcp_environment.py" + for inherit, opt_in in [("all", False), ("core", False), ("all", True), ("none", True)]: + with self.subTest(inherit=inherit, env_vars=opt_in): + config = ['shell_environment_policy.inherit=' + json.dumps(inherit), + 'mcp_servers.fixture.command=' + json.dumps(sys.executable), + 'mcp_servers.fixture.args=' + json.dumps([str(fixture)]), + 'mcp_servers.fixture.startup_timeout_sec=3'] + if opt_in: + config.append('mcp_servers.fixture.env_vars=' + json.dumps([*MARKERS, "NODE_REPL_AUTH_TOKEN"])) + self.assertEqual(self.run_native(config, mcp=True), MARKERS if opt_in else {}) diff --git a/tests/fixtures/codex_mcp_environment.py b/tests/fixtures/codex_mcp_environment.py new file mode 100644 index 00000000..45eefc1b --- /dev/null +++ b/tests/fixtures/codex_mcp_environment.py @@ -0,0 +1,33 @@ +"""Receipt-less, model-free MCP startup fixture; exposes only synthetic markers.""" +import json +import os +import sys + +names = ("SESSION_PEER_WAKE_DEPTH", "SESSION_PEER_WAKE_ORIGIN", + "SESSION_PEER_WAKE_MAX_DEPTH", "ORDINARY_TEST", "NODE_REPL_AUTH_TOKEN") +observed = {name: os.environ[name] for name in names if name in os.environ} +for line in sys.stdin: + request = json.loads(line) + if "id" not in request: + continue + method = request["method"] + if method == "initialize": + result = {"protocolVersion": request["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "private-environment-fixture", "version": "1"}} + elif method == "tools/list": + result = {"tools": [{"name": "environment_probe", "description": json.dumps(observed), + "inputSchema": {"type": "object"}}]} + elif method == "resources/list": + result = {"resources": []} + elif method == "resources/templates/list": + result = {"resourceTemplates": []} + elif method == "prompts/list": + result = {"prompts": []} + elif method == "ping": + result = {} + else: + print(json.dumps({"jsonrpc": "2.0", "id": request["id"], + "error": {"code": -32601, "message": "fixture method unsupported"}}), flush=True) + continue + print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}), flush=True)