diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e7132ac6..418bb935 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -425,9 +425,9 @@ session-peer send --host mac --to 'codex:' \ Omit `--host mac` for local use. Remove `--dry-run` only when ready to submit. Quoting `~` keeps expansion on the destination; an absolute remote path also -works. `--codex-home` explicitly chooses that copy but is not proof of activity. -This explicit-home form also works with releases that predate active-writer -resolution. +works. Current releases treat `--codex-home` as a destination constraint and +still validate every matching known home's writer evidence. Older releases +accept the explicit-home syntax but do not provide this safety guarantee. The duplicate-home rejection baseline shipped in v0.6.1. The active-writer selection, revalidation, and detailed `codexHomeResolution` evidence described @@ -451,28 +451,35 @@ forwarded by `--host`. On Windows, use absolute Windows paths with backslashes escaped as required by JSON. Empty configuration arrays are allowed; malformed configuration is an error for implicit sends. -If the UUID occurs in multiple known homes, session-peer examines the exact -`thread-writer-locks/.lock` in each matching home. It independently probes -the kernel advisory lock and correlates the opener through two stable `lsof` -observations. Exactly one stable, same-user Codex writer selects that home; the -evidence is checked again immediately before queue submission. A free or stale -lock does not win merely because its file exists. - -Zero or multiple live writers, missing `lsof`, permission failures, changing -PIDs/inodes and conflicting evidence fail closed before queueing. Archived saved -copies still count. Unreadable or incompatible known databases and missing -configured databases also prevent implicit submission. Choose `--codex-home` -explicitly to bypass unrelated inventory and activity checks. Resolved symlink -aliases and repeated paths count as one home. - -With no competing saved home, native queue behavior is preserved. `list` -aggregates known homes unless `--codex-home` selects exactly one. There is no general filesystem scan or process -environment inspection, and process arguments are not exposed. Activity -inspection runs on the destination machine, including over SSH. Platforms -without POSIX `flock` or `lsof` cannot automatically resolve competing homes and -must use `--codex-home`. Unconfigured/custom layouts and copies created after the -check can still be missed. Use `session-peer doctor` to inspect the selected home, -bounded candidates, executable, and supported state DB schema without submitting. +For every send and dry-run, session-peer finds every known home that saves the +UUID and examines its exact `thread-writer-locks/.lock`. This includes a +single candidate and an explicit `--codex-home`. It independently probes the +kernel advisory lock and correlates the opener through two stable `lsof` +observations. An implicit send selects the only stable, same-user Codex writer. +An explicit home must be that writer; if another home owns the only live writer, +the command returns a structured conflict and never changes the home silently. +The selected PID, process start time, and held lock are checked again immediately +before queue submission. A free or stale lock does not win merely because its +file exists. + +Unknown evidence, multiple live writers, missing `lsof`, permission failures, +changing PIDs/start times/inodes, and conflicting evidence fail closed before +queueing. Archived saved copies still count. Unreadable or incompatible known +databases and missing configured databases also prevent submission. If every +saved copy is inactive, intentionally queueing for a future resume requires an +explicit `--codex-home` together with `--allow-inactive-codex-home`; `--wake` +already serves as the explicit activation opt-in. The inactive flag without an +explicit home is rejected. Resolved symlink aliases and repeated paths count as +one home. + +`list` aggregates known homes unless `--codex-home` selects exactly one. There +is no general filesystem scan or process environment inspection, and process +arguments are not exposed. Activity inspection runs on the destination machine, +including over SSH. Platforms without POSIX `flock` or `lsof` cannot establish +live-writer ownership and fail closed when that evidence is required. +Unconfigured/custom layouts and copies created after the check can still be +missed. Use `session-peer doctor` to inspect the selected home, bounded +candidates, executable, and supported state DB schema without submitting. ### Submission and JSON results @@ -505,8 +512,8 @@ Within the common envelope, Codex send JSON includes `target: {agent, id}`, - `codexHome`: the resolved absolute destination home, not a sender-side guess. - `codexHomeResolution`: schema-versioned `status`, `selected`, `reason`, and bounded candidate evidence. Status is `explicit`, `selected`, `ambiguous`, or - `unknown`; candidates expose saved-thread, writer-lock and stable owner PID - facts without process arguments or environment values. + `unknown`; candidates expose saved-thread, writer-lock, stable owner PID and + process start-time facts without process arguments or environment values. - `submitted`: `true` only after successful queue CLI completion, `false` for dry-run. - `consumptionConfirmed`: always `false`; neither queued nor validated establishes consumption. diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index f38b9884..9e1bd740 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -9,7 +9,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "6937c0208464dc536ce810abd70e449b65cb72e1d937a3d818339f46a9ab11d4", "docs/architecture/agent-transports.md": "859af9c28d2cd12830bb1a084d9a5d03f3ede637a52e7ff7271f509edfaeaf7b", - "docs/cli-reference.md": "85016f5d378ed7dca97753ac420c68cb1e6ebcdf81680a15547239dc1492fa95", + "docs/cli-reference.md": "bdd93721d00f2df1d684f3cd99c7a5addeb965b58e439a3d1a9d09eabfb15b18", "docs/diagnostics.md": "e7c757655a08c3a0180e375a7bdd67c034f6916e800968054aa1cc98ec1ca4aa", "docs/download-metrics.md": "ded60e7b84d14ce779f8d639516b4e390358a06ef76b677dc9c1f997fb626a9f", "docs/mcp.md": "e99689b8e6a222babc9438be2b2aabee953c3e0b2f967c6582065569529c32da", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 0e2969f8..0326223d 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -424,9 +424,9 @@ session-peer send --host mac --to 'codex:' \ ローカルで使用する場合は `--host mac` を省略します。実際に送信する準備ができたときにのみ `--dry-run` を削除してください。 `~` をクォートすることで送信先での展開が維持されます。絶対リモートパスも -機能します。`--codex-home` は明示的にそのコピーを選択しますが、アクティビティの証明にはなりません。 -この明示的ホームの形式は、アクティブライターの解決機能より前のリリースでも -機能します。 +機能します。現在のリリースは `--codex-home` を送信先の制約として扱いながら、一致するすべての +既知ホームのライターエビデンスを検証します。以前のリリースも明示的ホームの構文を受け付けますが、 +この安全性は保証しません。 重複ホームの拒否ベースラインは v0.6.1 でリリースされました。後述のアクティブライターの 選択、再検証、および詳細な `codexHomeResolution` エビデンスは v0.6.2 でリリースされています。 @@ -449,27 +449,31 @@ export SESSION_PEER_CODEX_HOMES='["/srv/codex/account-a", "/srv/codex/account-b" 絶対 Windows パスを使用します。空の設定配列は許可されます。設定が不正な形式である場合、 暗黙的な送信ではエラーになります。 -UUID が複数の既知のホームに存在する場合、session-peer は一致する各ホーム内の正確な -`thread-writer-locks/.lock` を検査します。カーネルのアドバイザリロックを個別にプローブし、 -2 回の安定した `lsof` 観測を通じてオープンしているプロセスを関連付けます。同一ユーザーの安定した -Codex ライターがちょうど 1 つだけ存在する場合にそのホームが選択され、そのエビデンスは -キューへの投入直前に再度チェックされます。空きロックや古いロックは、単にそのファイルが -存在するというだけでは選択されません。 - -アクティブなライターが 0 または複数存在する場合、`lsof` の欠落、権限エラー、PID/inode の変動、 -および競合するエビデンスがある場合は、キューイングの前にフェイルクローズ(安全側に倒して失敗)します。アーカイブされた保存済みコピーも -カウントされます。読み取り不能または互換性のない既知のデータベースや、設定されたデータベースの欠落も、 -暗黙的な送信を妨げます。無関係なインベントリやアクティビティのチェックをバイパスするには、`--codex-home` を -明示的に選択してください。解決されたシンボリックリンクのエイリアスおよび重複するパスは、1 つのホームとしてカウントされます。 - -競合する保存済みホームがない場合、ネイティブのキュー動作が維持されます。`list` は、 -`--codex-home` が正確に 1 つを選択しない限り、既知のホームを集約します。一般的なファイルシステムスキャンやプロセス -環境の検査は行われず、プロセス引数は公開されません。アクティビティの -検査は、SSH 経由を含め、送信先マシン上で実行されます。POSIX の -`flock` または `lsof` を備えていないプラットフォームでは、競合するホームを自動的に解決できないため、 -`--codex-home` を使用する必要があります。未設定/カスタムのレイアウトや、チェック後に作成された -コピーは見落とされる可能性があります。送信を行わずに、選択されたホーム、限定された候補、 -実行可能ファイル、およびサポートされている状態 DB スキーマを検査するには、`session-peer doctor` を使用してください。 +すべての送信と dry-run で、session-peer は UUID を保存する既知のホームをすべて検出し、 +各ホームの正確な `thread-writer-locks/.lock` を検査します。候補が 1 つの場合や明示的な +`--codex-home` も対象です。カーネルのアドバイザリロックを個別にプローブし、2 回の安定した +`lsof` 観測を通じてオープンしているプロセスを関連付けます。暗黙的な送信では、同一ユーザーの +安定した Codex ライターが唯一存在するホームを選択します。明示したホームはそのライターと一致する +必要があり、別のホームが唯一のライブライターを所有する場合、コマンドは構造化された競合を返し、 +ホームを暗黙に変更しません。選択された PID、プロセス開始時刻、および保持中のロックは、キューへの +投入直前に再確認されます。空きロックや古いロックは、単にファイルが存在するだけでは選択されません。 + +不明なエビデンス、複数のライブライター、`lsof` の欠落、権限エラー、PID/開始時刻/inode の変動、 +および競合するエビデンスは、キューイング前にフェイルクローズします。アーカイブされた保存済みコピーも +カウントされます。読み取り不能または互換性のない既知のデータベースや、設定されたデータベースの欠落も +送信を妨げます。すべての保存済みコピーが非アクティブな場合、将来の resume 用に意図してキューへ +入れるには、明示的な `--codex-home` と `--allow-inactive-codex-home` を併用する必要があります。 +`--wake` はすでに明示的なアクティベーションの opt-in として扱われます。明示したホームなしで +inactive フラグだけを指定すると拒否されます。解決されたシンボリックリンクのエイリアスおよび +重複するパスは 1 つのホームとしてカウントされます。 + +`list` は `--codex-home` が正確に 1 つを選択しない限り、既知のホームを集約します。一般的な +ファイルシステムスキャンやプロセス環境の検査は行われず、プロセス引数は公開されません。アクティビティの +検査は SSH 経由を含め送信先マシン上で実行されます。POSIX の `flock` または `lsof` を備えていない +プラットフォームではライブライターの所有権を確立できず、そのエビデンスが必要な場合はフェイルクローズ +します。未設定/カスタムのレイアウトや、チェック後に作成されたコピーは見落とされる可能性があります。 +送信を行わずに、選択されたホーム、限定された候補、実行可能ファイル、およびサポートされている状態 DB +スキーマを検査するには、`session-peer doctor` を使用してください。 ### 送信と JSON 結果 @@ -501,7 +505,7 @@ Codex メッセージは、測定された Codex サーバーの制限ではな - `codexHomeResolution`: スキーマバージョン付きの `status`、`selected`、`reason`、および 限定された候補のエビデンス。ステータスは `explicit`、`selected`、`ambiguous`、または `unknown` です。候補には、プロセスの引数や環境変数の値を含めることなく、 - 保存されたスレッド、ライターロック、および安定した所有者 PID の事実が開示されます。 + 保存されたスレッド、ライターロック、安定した所有者 PID、およびプロセス開始時刻の事実が開示されます。 - `submitted`: キュー CLI が正常に完了した後にのみ `true`、dry-run の場合は `false`。 - `consumptionConfirmed`: 常に `false`。queued も validated も消費を確立するものではありません。 diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index 0411063f..4ddb80c5 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -420,9 +420,9 @@ session-peer send --host mac --to 'codex:' \ 로컬에서 사용할 때는 `--host mac`을 생략합니다. 제출할 준비가 되었을 때만 `--dry-run`을 제거하세요. `~`를 따옴표로 감싸면 대상 시스템에서 확장이 유지되며, 절대 원격 경로를 -사용해도 됩니다. `--codex-home`은 해당 복사본을 명시적으로 선택하지만 활성 상태를 -증명하지는 않습니다. 이 명시적 홈 형식은 활성 작성기 해결 기능 이전의 릴리스에서도 -작동합니다. +사용해도 됩니다. 현재 릴리스는 `--codex-home`을 대상 제약으로 처리하면서도 일치하는 +모든 알려진 홈의 작성기 증거를 검증합니다. 이전 릴리스도 명시적 홈 문법은 받아들이지만 +이 안전 보장은 제공하지 않습니다. 중복 홈 거부 기준은 v0.6.1에 도입되었습니다. 아래에 설명된 활성 작성기 선택, 재검증 및 상세한 `codexHomeResolution` 증거는 v0.6.2에 도입되었습니다. v0.6.1에서는 동일한 @@ -445,28 +445,31 @@ export SESSION_PEER_CODEX_HOMES='["/srv/codex/account-a", "/srv/codex/account-b" Windows 경로를 사용하세요. 빈 구성 배열은 허용되며, 형식이 잘못된 구성은 암시적 전송 시 오류가 됩니다. -UUID가 여러 알려진 홈에 존재하는 경우, session-peer는 일치하는 각 홈의 정확한 -`thread-writer-locks/.lock`을 검사합니다. 커널 권고 잠금을 독립적으로 조사하고 -두 번의 안정적인 `lsof` 관찰을 통해 연 프로세스를 상호 연관시킵니다. 정확히 하나의 -안정적인 동일 사용자 Codex 작성기가 해당 홈을 선택하며, 이 증거는 큐 제출 직전에 -다시 확인됩니다. 잠금이 비어 있거나 오래된 경우 단지 해당 파일이 존재한다는 이유만으로 -선택되지 않습니다. - -활성 작성기가 0개이거나 여러 개인 경우, `lsof` 누락, 권한 실패, PID/inode 변경 및 -상충되는 증거가 있는 경우 큐에 넣기 전에 안전하게 차단됩니다. 보관된 저장 복사본도 -여전히 계산에 포함됩니다. 읽을 수 없거나 호환되지 않는 알려진 데이터베이스 및 누락된 -구성 데이터베이스 역시 암시적 제출을 방지합니다. 관련 없는 인벤토리 및 활동 검사를 -우회하려면 `--codex-home`을 명시적으로 선택하세요. 확인된 심볼릭 링크 별칭 및 반복된 -경로는 하나의 홈으로 계산됩니다. - -경쟁하는 저장된 홈이 없는 경우 기본 큐 동작이 유지됩니다. `list`는 `--codex-home`이 -정확히 하나를 선택하지 않는 한 알려진 홈들을 집계합니다. 일반적인 파일 시스템 검색이나 -프로세스 환경 검사는 없으며, 프로세스 인수는 노출되지 않습니다. 활동 검사는 SSH를 -포함하여 대상 머신에서 실행됩니다. POSIX `flock` 또는 `lsof`가 없는 플랫폼은 -경쟁 홈을 자동으로 해결할 수 없으므로 `--codex-home`을 사용해야 합니다. -구성되지 않은/사용자 정의 레이아웃 및 검사 후 생성된 복사본은 여전히 누락될 수 -있습니다. 제출하지 않고 선택한 홈, 제한된 후보, 실행 파일 및 지원되는 상태 DB 스키마를 -검사하려면 `session-peer doctor`를 사용하세요. +모든 전송과 dry-run에서 session-peer는 UUID를 저장한 모든 알려진 홈을 찾고 각 홈의 +정확한 `thread-writer-locks/.lock`을 검사합니다. 단일 후보와 명시적 +`--codex-home`도 포함됩니다. 커널 권고 잠금을 독립적으로 조사하고 두 번의 안정적인 +`lsof` 관찰을 통해 연 프로세스를 상호 연관시킵니다. 암시적 전송은 안정적인 동일 사용자 +Codex 작성기가 정확히 하나인 홈을 선택합니다. 명시한 홈은 그 작성기와 일치해야 하며, +다른 홈이 유일한 활성 작성기를 소유하면 명령은 구조화된 충돌을 반환하고 홈을 몰래 +바꾸지 않습니다. 선택된 PID, 프로세스 시작 시각, 유지 중인 잠금은 큐 제출 직전에 다시 +확인됩니다. 잠금이 비어 있거나 오래된 경우 단지 파일이 존재한다는 이유만으로 선택되지 +않습니다. + +알 수 없는 증거, 여러 활성 작성기, `lsof` 누락, 권한 실패, PID/시작 시각/inode 변경 및 +상충되는 증거는 큐에 넣기 전에 안전하게 차단됩니다. 보관된 저장 복사본도 계산에 +포함됩니다. 읽을 수 없거나 호환되지 않는 알려진 데이터베이스 및 누락된 구성 데이터베이스도 +제출을 방지합니다. 모든 저장 복사본이 비활성이면 미래 resume용 큐를 의도적으로 넣을 때 +명시적 `--codex-home`과 `--allow-inactive-codex-home`을 함께 사용해야 합니다. `--wake`는 +이미 명시적인 활성화 opt-in으로 작동합니다. 명시 home 없이 inactive 플래그만 사용하면 +거부됩니다. 확인된 심볼릭 링크 별칭 및 반복된 경로는 하나의 홈으로 계산됩니다. + +`list`는 `--codex-home`이 정확히 하나를 선택하지 않는 한 알려진 홈들을 집계합니다. +일반적인 파일 시스템 검색이나 프로세스 환경 검사는 없으며 프로세스 인수는 노출되지 +않습니다. 활동 검사는 SSH를 포함하여 대상 머신에서 실행됩니다. POSIX `flock` 또는 +`lsof`가 없는 플랫폼은 live-writer 소유권을 확립할 수 없으며 그 증거가 필요할 때 +안전하게 차단됩니다. 구성되지 않은/사용자 정의 레이아웃 및 검사 후 생성된 복사본은 +여전히 누락될 수 있습니다. 제출하지 않고 선택한 홈, 제한된 후보, 실행 파일 및 지원되는 +상태 DB 스키마를 검사하려면 `session-peer doctor`를 사용하세요. ### 제출 및 JSON 결과 @@ -498,8 +501,8 @@ dry-run 시 `validated`), `chars`, `dryRun` 및 선택적 `queueId`를 포함합 - `codexHome`: 발신자 측의 추측이 아닌 확인된 절대 대상 홈. - `codexHomeResolution`: 스키마 버전 관리형 `status`, `selected`, `reason` 및 제한된 후보 증거. 상태는 `explicit`, `selected`, `ambiguous`, `unknown` 중 - 하나입니다. 후보는 프로세스 인수나 환경 값 없이 저장된 스레드, 작성기 잠금 및 - 안정적인 소유자 PID 팩트를 노출합니다. + 하나입니다. 후보는 프로세스 인수나 환경 값 없이 저장된 스레드, 작성기 잠금, + 안정적인 소유자 PID 및 프로세스 시작 시각 팩트를 노출합니다. - `submitted`: 큐 CLI가 성공적으로 완료된 후에만 `true`, dry-run의 경우 `false`. - `consumptionConfirmed`: 항상 `false`. 큐에 추가됨이나 유효성 검증됨 모두 소비를 증명하지는 않습니다. diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index 1bf11022..3e69028d 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -288,7 +288,7 @@ session-peer send --host mac --to 'codex:' \ --dry-run --json "message" ``` -本地使用时请省略 `--host mac`。仅在准备提交时才移除 `--dry-run`。给 `~` 加上引号可使其在目标端进行展开;绝对远程路径同样有效。`--codex-home` 显式选择该副本,但并不是活跃的证据。这种显式指定目录的形式也适用于早于活跃写入者解析功能的版本。 +本地使用时请省略 `--host mac`。仅在准备提交时才移除 `--dry-run`。给 `~` 加上引号可使其在目标端进行展开;绝对远程路径同样有效。当前版本将 `--codex-home` 视为目标约束,同时仍会验证所有匹配已知目录的写入者证据。旧版本也接受显式目录语法,但不提供这项安全保证。 重复目录拒绝基线在 v0.6.1 中发布。下文描述的活跃写入者选择、重新验证以及详细的 `codexHomeResolution` 证据在 v0.6.2 中提供;当同一个线程 UUID 存在于多个已知目录中时,v0.6.1 需要显式指定 `--codex-home`。 @@ -304,11 +304,11 @@ export SESSION_PEER_CODEX_HOMES='["/srv/codex/account-a", "/srv/codex/account-b" 请在目标命令的环境中进行配置;本地 export 的变量不会被 `--host` 转发。在 Windows 上,使用符合 JSON 要求的带反斜杠转义的绝对 Windows 路径。允许配置空数组;对于隐式发送,格式错误的配置会引发错误。 -如果该 UUID 出现在多个已知目录中,session-peer 会检查每个匹配目录中确切的 `thread-writer-locks/.lock`。它会独立探测内核建议锁,并通过两次稳定的 `lsof` 观察来关联打开该文件的进程。只有恰好存在一个稳定的、同一用户的 Codex 写入者时才会选择该目录;在队列提交之前,会立即再次检查该证据。空闲或过期的锁不会仅仅因为其文件存在而胜出。 +对于每次发送和预检运行,session-peer 都会查找保存该 UUID 的所有已知目录,并检查每个目录中确切的 `thread-writer-locks/.lock`。单个候选目录和显式 `--codex-home` 也包括在内。它会独立探测内核建议锁,并通过两次稳定的 `lsof` 观察来关联打开该文件的进程。隐式发送会选择唯一具有稳定同用户 Codex 写入者的目录。显式目录必须与该写入者一致;如果另一个目录拥有唯一的活跃写入者,命令将返回结构化冲突,且绝不会暗中更换目录。在队列提交前会再次检查所选 PID、进程启动时间以及持有的锁。空闲或过期的锁不会仅仅因为文件存在而胜出。 -零个或多个活跃写入者、缺少 `lsof`、权限失败、变动的 PID/inode 以及冲突的证据都会在排队前以安全方式失败(fail-closed)。已归档的保存副本仍然计算在内。无法读取或不兼容的已知数据库以及缺失的已配置数据库也会阻止隐式提交。显式选择 `--codex-home` 可绕过不相关的清单和活动检查。解析后的符号链接别名和重复路径视为同一个目录。 +未知证据、多个活跃写入者、缺少 `lsof`、权限失败、PID/启动时间/inode 变化以及冲突证据都会在排队前以安全方式失败(fail-closed)。已归档的保存副本仍然计算在内。无法读取或不兼容的已知数据库以及缺失的已配置数据库也会阻止提交。如果所有保存副本都处于非活动状态,要有意为未来 resume 排队,必须同时使用显式 `--codex-home` 和 `--allow-inactive-codex-home`;`--wake` 已被视为显式激活 opt-in。没有显式目录时仅使用 inactive 标志会被拒绝。解析后的符号链接别名和重复路径视为同一个目录。 -在没有相互竞争的保存目录时,保留原生队列行为。除非 `--codex-home` 精确选择了一个目录,否则 `list` 会汇总已知目录。不存在常规的文件系统扫描或进程环境检查,且进程参数不会被公开。活动检查在目标机器上运行,包括通过 SSH。缺少 POSIX `flock` 或 `lsof` 的平台无法自动解决竞争目录问题,必须使用 `--codex-home`。未配置/自定义的布局以及在检查后创建的副本仍可能被遗漏。使用 `session-peer doctor` 可在不提交的情况下检查所选目录、有界候选列表、可执行文件以及受支持的状态数据库模式。 +除非 `--codex-home` 精确选择一个目录,否则 `list` 会汇总已知目录。不存在常规的文件系统扫描或进程环境检查,且进程参数不会公开。活动检查在目标机器上运行,包括通过 SSH。缺少 POSIX `flock` 或 `lsof` 的平台无法建立活跃写入者所有权,并会在需要该证据时以安全方式失败。未配置/自定义的布局以及在检查后创建的副本仍可能被遗漏。使用 `session-peer doctor` 可在不提交的情况下检查所选目录、有界候选列表、可执行文件以及受支持的状态数据库模式。 ### 提交与 JSON 结果 @@ -321,7 +321,7 @@ Codex 消息限制为 32 KiB 的 UTF-8 文本(包括发送者/回复头), 在通用信封内,Codex 发送 JSON 包含 `target: {agent, id}`、`status: queued`(或预检运行下的 `validated`)、`chars`、`dryRun` 以及可选的 `queueId`。它还包括: - `codexHome`:解析后的绝对目标目录,而非发送端的推测。 -- `codexHomeResolution`:带模式版本的 `status`、`selected`、`reason` 和有界候选证据。状态为 `explicit`、`selected`、`ambiguous` 或 `unknown`;候选者公开已保存线程、写入者锁和稳定所有者 PID 的事实信息,而不包含进程参数或环境变量值。 +- `codexHomeResolution`:带模式版本的 `status`、`selected`、`reason` 和有界候选证据。状态为 `explicit`、`selected`、`ambiguous` 或 `unknown`;候选者公开已保存线程、写入者锁、稳定所有者 PID 和进程启动时间的事实信息,而不包含进程参数或环境变量值。 - `submitted`:仅在成功完成队列 CLI 执行后为 `true`,预检运行为 `false`。 - `consumptionConfirmed`:始终为 `false`;无论是入队还是验证都不能证明已被消费。 diff --git a/session_peer.py b/session_peer.py index 824c4ea3..ded65797 100644 --- a/session_peer.py +++ b/session_peer.py @@ -269,8 +269,6 @@ def _codex_lock_snapshot(path: Path) -> tuple[int, int, int, int] | None: def probe_codex_writer_lock(path: Path) -> tuple[str, str]: """Probe Codex's real advisory lock without changing the lock file.""" - if fcntl is None: - return "unknown", "lock_probe_unsupported" try: before = path.lstat() except (FileNotFoundError, NotADirectoryError): @@ -281,6 +279,8 @@ def probe_codex_writer_lock(path: Path) -> tuple[str, str]: return "unknown", "lock_symlink" if not stat.S_ISREG(before.st_mode): return "unknown", "lock_not_regular" + if fcntl is None: + return "unknown", "lock_probe_unsupported" descriptor = None try: @@ -426,6 +426,7 @@ def inspect_codex_writer(home: Path, thread_id: str) -> dict: return { "activity": "live_writer", "writerLock": "held", "ownerPid": first["pid"], "ownerStable": True, + "ownerStartTime": first["startTime"], "reason": "stable_live_writer", } @@ -444,27 +445,37 @@ def _home_resolution(status: str, selected: Path | None, reason: str, def resolve_codex_home(args: argparse.Namespace, selected: Path, thread_id: str) -> tuple[Path, dict]: """Select a destination home or fail closed with structured evidence.""" - if getattr(args, "codex_home", None): - candidate = {"codexHome": str(selected), "savedThread": None, - "writerLock": "not_checked", "reason": "explicit_selection"} - return selected, _home_resolution( - "explicit", selected, "explicit_codex_home", [candidate] + explicit = bool(getattr(args, "codex_home", None)) + inactive_opt_in = bool(getattr(args, "allow_inactive_codex_home", False)) + wake = bool(getattr(args, "wake", False)) + if inactive_opt_in and not explicit: + resolution = _home_resolution( + "unknown", None, "inactive_opt_in_requires_explicit_home", [] ) - - homes = known_codex_homes(selected) - if len(homes) == 1: - candidate = {"codexHome": str(selected), "savedThread": None, - "writerLock": "not_checked", "reason": "single_known_home"} - return selected, _home_resolution( - "selected", selected, "single_known_home", [candidate] + raise CcPeerError( + "--allow-inactive-codex-home requires --codex-home; nothing queued.", + {"codexHomeResolution": resolution}, ) - + homes = known_codex_homes(selected) candidates = [ {"codexHome": str(home), "savedThread": None, "writerLock": "not_checked", "reason": "not_inspected"} for home in homes ] for home, candidate in zip(homes, candidates): + db = home / "state_5.sqlite" + if not db.is_file(): + candidate["savedThread"] = False + candidate["reason"] = "state_db_absent" + if home == selected: + continue + resolution = _home_resolution( + "unknown", None, "home_inventory_unreadable", candidates + ) + raise CcPeerError( + f"Cannot check Codex home {home}: state_5.sqlite is absent; " + "nothing queued.", {"codexHomeResolution": resolution}, + ) try: candidate["savedThread"] = _codex_thread_is_saved(home, thread_id) except sqlite3.Error as exc: @@ -479,13 +490,12 @@ def resolve_codex_home(args: argparse.Namespace, selected: Path, ) from exc matches = [candidate for candidate in candidates if candidate["savedThread"]] if not matches: - return selected, _home_resolution( - "selected", selected, "no_competing_saved_copy", candidates + resolution = _home_resolution( + "unknown", None, "thread_not_saved_in_known_homes", candidates ) - if len(matches) == 1 and matches[0]["codexHome"] == str(selected): - matches[0]["reason"] = "single_saved_copy" - return selected, _home_resolution( - "selected", selected, "single_saved_copy", candidates + raise NoTargetError( + f"No saved Codex thread {thread_id} in known homes; nothing queued.", + {"codexHomeResolution": resolution}, ) by_home = {str(home): home for home in homes} @@ -493,37 +503,67 @@ def resolve_codex_home(args: argparse.Namespace, selected: Path, candidate.update(inspect_codex_writer(by_home[candidate["codexHome"]], thread_id)) live = [candidate for candidate in matches if candidate.get("activity") == "live_writer"] unknown = any(candidate.get("activity") == "unknown" for candidate in matches) - if len(live) == 1 and not unknown: - active = by_home[live[0]["codexHome"]] - return active, _home_resolution( - "selected", active, "single_stable_live_writer", candidates + paths = ", ".join(candidate["codexHome"] for candidate in matches) + if unknown: + resolution = _home_resolution( + "unknown", None, "active_writer_unverified", candidates + ) + raise CcPeerError( + f"Cannot verify every Codex writer for thread {thread_id} in homes: {paths}; " + "nothing queued.", {"codexHomeResolution": resolution}, ) + if len(live) > 1: + resolution = _home_resolution( + "ambiguous", None, "multiple_live_writers", candidates + ) + raise CcPeerError( + f"Multiple live Codex writers exist for thread {thread_id} in homes: {paths}; " + "nothing queued.", {"codexHomeResolution": resolution}, + ) + if len(live) == 1: + active = by_home[live[0]["codexHome"]] + if explicit and active != selected: + resolution = _home_resolution( + "ambiguous", None, "explicit_home_conflicts_with_live_writer", candidates + ) + raise CcPeerError( + f"Explicit Codex home {selected} is not the unique live writer for thread " + f"{thread_id}; the live writer is in {active}. Nothing queued and the home " + "was not changed automatically.", + {"codexHomeResolution": resolution}, + ) + status = "explicit" if explicit else "selected" + reason = "explicit_live_writer" if explicit else "single_stable_live_writer" + return active, _home_resolution(status, active, reason, candidates) - paths = ", ".join(candidate["codexHome"] for candidate in matches) - if len(matches) == 1: - reason = "saved_copy_outside_selected_home_not_active" - resolution = _home_resolution("unknown", None, reason, candidates) + if explicit and str(selected) not in {candidate["codexHome"] for candidate in matches}: + resolution = _home_resolution( + "unknown", None, "thread_not_saved_in_explicit_home", candidates + ) raise NoTargetError( - f"No saved Codex thread {thread_id} in selected home {selected}; found in " - f"{paths}, but no unique stable live writer selected it. Select --codex-home " - "explicitly; nothing queued.", + f"No saved Codex thread {thread_id} in explicit home {selected}; nothing queued.", {"codexHomeResolution": resolution}, ) + if explicit and (inactive_opt_in or wake): + reason = "explicit_inactive_wake" if wake else "explicit_inactive_opt_in" + return selected, _home_resolution("explicit", selected, reason, candidates) - reason = "multiple_live_writers" if len(live) > 1 else ( - "active_writer_unverified" if unknown else "no_live_writer" + resolution = _home_resolution( + "ambiguous", None, "inactive_queue_requires_opt_in", candidates ) - status = "unknown" if unknown and len(live) <= 1 else "ambiguous" - resolution = _home_resolution(status, None, reason, candidates) raise CcPeerError( - f"Ambiguous Codex thread {thread_id}; found in homes: {paths}. No unique " - "stable live writer could select one. Select --codex-home explicitly; nothing queued.", + f"All saved copies of Codex thread {thread_id} are inactive in homes: {paths}. " + "To queue for a future resume, select one with --codex-home and add " + "--allow-inactive-codex-home; nothing queued.", {"codexHomeResolution": resolution}, ) def revalidate_codex_home(root: Path, thread_id: str, resolution: dict) -> None: - if resolution.get("reason") != "single_stable_live_writer": + reason = resolution.get("reason") + live_selection = reason in ("single_stable_live_writer", "explicit_live_writer") + inactive_selection = reason in ("explicit_inactive_opt_in", "explicit_inactive_wake") + if not (live_selection or inactive_selection): return previous = next( candidate for candidate in resolution["candidates"] @@ -545,9 +585,17 @@ def revalidate_codex_home(root: Path, thread_id: str, resolution: dict) -> None: candidate for candidate in updated if candidate.get("savedThread") and candidate["codexHome"] != str(root) ] - if ( - (selected.get("activity"), selected.get("ownerPid")) - == ("live_writer", previous.get("ownerPid")) + if live_selection: + if ( + (selected.get("activity"), selected.get("writerLock"), + selected.get("ownerPid"), selected.get("ownerStartTime")) + == ("live_writer", "held", previous.get("ownerPid"), + previous.get("ownerStartTime")) + and all(candidate.get("activity") == "inactive" for candidate in competitors) + ): + return + elif ( + selected.get("activity") == "inactive" and all(candidate.get("activity") == "inactive" for candidate in competitors) ): return @@ -806,7 +854,15 @@ def queue_codex(args: argparse.Namespace, text: str) -> dict: return _queue_codex(args, text) thread_id = codex_thread(args.to) check_codex_message(text) - root, resolution = resolve_codex_home(args, codex_home(args), thread_id) + try: + root, resolution = resolve_codex_home(args, codex_home(args), thread_id) + except CcPeerError as exc: + resolution = exc.details.get("codexHomeResolution") + if resolution and resolution.get("reason") == "active_writer_unverified": + exc.details.setdefault( + "wake", {"status": "refused", "reason": "writer_unknown"} + ) + raise executable = codex_executable(args) preflight = codex_wake_preflight(root, thread_id, executable) selected = argparse.Namespace(**vars(args)) @@ -846,6 +902,8 @@ 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)] + if getattr(args, "allow_inactive_codex_home", False): + argv.append("--allow-inactive-codex-home") return argv @@ -3842,8 +3900,14 @@ def add_common(sub: argparse.ArgumentParser) -> None: help="session name, PID, codex:UUID, antigravity:UUID, or session-peer://v1/reply address", ) sending.add_argument("--codex-home", help=home_help) - sending.epilog = ("Without --codex-home, duplicate threads in known homes select only one " - "stable live writer or fail closed. Known homes: selected/default, macOS " + sending.add_argument( + "--allow-inactive-codex-home", action="store_true", + help="with --codex-home, intentionally queue an inactive thread for a future resume", + ) + sending.epilog = ("All matching known homes are checked even with --codex-home. A unique " + "stable live writer is required unless --codex-home and " + "--allow-inactive-codex-home explicitly select an all-inactive copy. " + "Known homes: selected/default, macOS " "Orca account homes, and destination SESSION_PEER_CODEX_HOMES (JSON array " "of paths). Queued/submitted never confirms consumption.") sending.add_argument("--codex-bin", help="Codex executable on the destination machine") diff --git a/tests/test_codex.py b/tests/test_codex.py index fef41d67..3538935b 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -31,7 +31,8 @@ def setUp(self): conn.commit() conn.close() self.args = argparse.Namespace(codex_home=str(self.root), codex_bin=None, all=False, - to="codex:" + THREAD, dry_run=False) + to="codex:" + THREAD, dry_run=False, + allow_inactive_codex_home=True, wake=False) def invoke(self, *argv): output = io.StringIO() @@ -165,7 +166,8 @@ def test_remote_send_preserves_status_and_destination_options(self): "consumptionConfirmed": False, "codexHomeResolution": resolution} with mock.patch.object(peer, "run_remote", return_value=response) as remote: code, result = self.invoke("send", "--host", "worker", "--to", "codex:" + THREAD, - "--codex-home", "/remote home", "--codex-bin", "/remote bin/codex", "--json", "--no-from", "--no-reply-to", "test") + "--codex-home", "/remote home", "--allow-inactive-codex-home", + "--codex-bin", "/remote bin/codex", "--json", "--no-from", "--no-reply-to", "test") self.assertEqual(code, 0) self.assertEqual(result["host"], "worker") self.assertEqual(result["queueId"], "queue-id") @@ -177,6 +179,7 @@ def test_remote_send_preserves_status_and_destination_options(self): argv = remote.call_args.args[1] self.assertEqual(argv[argv.index("--codex-home") + 1], "/remote home") self.assertEqual(argv[argv.index("--codex-bin") + 1], "/remote bin/codex") + self.assertIn("--allow-inactive-codex-home", argv) def test_remote_send_preserves_structured_home_failure(self): resolution = {"schemaVersion": 1, "status": "ambiguous", "selected": None, @@ -260,6 +263,16 @@ def test_parses_nul_delimited_lsof_processes(self): {"pid": 42, "command": "codex", "uid": 501}, ]) + def test_missing_lock_is_inactive_even_when_flock_is_unavailable(self): + with tempfile.TemporaryDirectory() as directory, \ + mock.patch.object(peer, "fcntl", None): + lock = Path(directory) / "missing.lock" + self.assertEqual(peer.probe_codex_writer_lock(lock), + ("absent", "lock_absent")) + lock.write_text("fixture", encoding="utf-8") + self.assertEqual(peer.probe_codex_writer_lock(lock), + ("unknown", "lock_probe_unsupported")) + @unittest.skipIf(peer.fcntl is None, "POSIX flock is unavailable") def test_probes_a_real_advisory_lock_without_changing_the_file(self): with tempfile.TemporaryDirectory() as directory: @@ -329,6 +342,7 @@ def test_stable_same_user_codex_owner_is_live(self): result = peer.inspect_codex_writer(Path("/home"), THREAD) self.assertEqual(result["activity"], "live_writer") self.assertEqual(result["ownerPid"], 42) + self.assertEqual(result["ownerStartTime"], "stable") self.assertNotIn("command", result) self.assertNotIn("startTime", result) @@ -394,8 +408,10 @@ def test_orca_duplicate_rejects_implicit_send_and_dry_run(self): code, result = self.send(*flags) self.assertEqual(code, 1) self.assertFalse(result["ok"]) - self.assertIn("ambiguous", result["error"].lower()) - for expected in (str(self.default), str(self.account), "--codex-home"): + self.assertEqual(result["codexHomeResolution"]["reason"], + "inactive_queue_requires_opt_in") + for expected in (str(self.default), str(self.account), + "--allow-inactive-codex-home"): self.assertIn(expected, result["error"]) self.queue.assert_not_called() for path, original in before.items(): @@ -405,7 +421,7 @@ def test_one_stable_live_writer_selects_its_home_and_revalidates(self): inactive = {"activity": "inactive", "writerLock": "free", "reason": "kernel_lock_free"} active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, - "ownerStable": True, "reason": "stable_live_writer"} + "ownerStable": True, "ownerStartTime": "stable", "reason": "stable_live_writer"} with mock.patch.object(peer, "inspect_codex_writer", side_effect=[inactive, active, inactive, active]) as inspect: code, result = self.send() @@ -421,7 +437,7 @@ def test_dry_run_selects_live_writer_without_queue_or_revalidation(self): inactive = {"activity": "inactive", "writerLock": "absent", "reason": "lock_absent"} active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, - "ownerStable": True, "reason": "stable_live_writer"} + "ownerStable": True, "ownerStartTime": "stable", "reason": "stable_live_writer"} with mock.patch.object(peer, "inspect_codex_writer", side_effect=[inactive, active]) as inspect: code, result = self.send("--dry-run") @@ -436,7 +452,7 @@ def test_live_writer_can_select_the_only_saved_copy_outside_default(self): conn.execute("DELETE FROM threads") conn.commit() active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, - "ownerStable": True, "reason": "stable_live_writer"} + "ownerStable": True, "ownerStartTime": "stable", "reason": "stable_live_writer"} with mock.patch.object(peer, "inspect_codex_writer", side_effect=[active, active]): code, result = self.send() self.assertEqual(code, 0) @@ -446,9 +462,9 @@ def test_live_writer_can_select_the_only_saved_copy_outside_default(self): def test_multiple_live_writers_are_structured_ambiguity(self): active_a = {"activity": "live_writer", "writerLock": "held", "ownerPid": 41, - "ownerStable": True, "reason": "stable_live_writer"} + "ownerStable": True, "ownerStartTime": "stable", "reason": "stable_live_writer"} active_b = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, - "ownerStable": True, "reason": "stable_live_writer"} + "ownerStable": True, "ownerStartTime": "stable", "reason": "stable_live_writer"} with mock.patch.object(peer, "inspect_codex_writer", side_effect=[active_a, active_b]): code, result = self.send() self.assertEqual(code, 1) @@ -460,7 +476,7 @@ def test_multiple_live_writers_are_structured_ambiguity(self): def test_unknown_competing_writer_prevents_selection(self): active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 41, - "ownerStable": True, "reason": "stable_live_writer"} + "ownerStable": True, "ownerStartTime": "stable", "reason": "stable_live_writer"} unknown = {"activity": "unknown", "writerLock": "held", "reason": "lsof_unavailable"} with mock.patch.object(peer, "inspect_codex_writer", side_effect=[active, unknown]): @@ -471,11 +487,26 @@ def test_unknown_competing_writer_prevents_selection(self): "active_writer_unverified") self.queue.assert_not_called() + def test_unknown_probe_prevents_explicit_selection(self): + unknown = {"activity": "unknown", "writerLock": "held", + "reason": "lsof_unavailable"} + inactive = {"activity": "inactive", "writerLock": "free", + "reason": "kernel_lock_free"} + with mock.patch.object(peer, "inspect_codex_writer", + side_effect=[unknown, inactive]): + code, result = self.send("--codex-home", str(self.account), + "--allow-inactive-codex-home") + self.assertEqual(code, 1) + self.assertEqual(result["codexHomeResolution"]["status"], "unknown") + self.assertEqual(result["codexHomeResolution"]["reason"], + "active_writer_unverified") + self.queue.assert_not_called() + def test_writer_pid_change_before_queue_fails_closed(self): inactive = {"activity": "inactive", "writerLock": "free", "reason": "kernel_lock_free"} active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, - "ownerStable": True, "reason": "stable_live_writer"} + "ownerStable": True, "ownerStartTime": "stable", "reason": "stable_live_writer"} changed = {**active, "ownerPid": 43} with mock.patch.object(peer, "inspect_codex_writer", side_effect=[inactive, active, inactive, changed]): @@ -486,11 +517,26 @@ def test_writer_pid_change_before_queue_fails_closed(self): "writer_evidence_changed_before_queue") self.queue.assert_not_called() + def test_writer_start_time_change_before_queue_fails_closed(self): + inactive = {"activity": "inactive", "writerLock": "free", + "reason": "kernel_lock_free"} + active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, + "ownerStable": True, "ownerStartTime": "first", + "reason": "stable_live_writer"} + changed = {**active, "ownerStartTime": "reused-pid"} + with mock.patch.object(peer, "inspect_codex_writer", + side_effect=[inactive, active, inactive, changed]): + code, result = self.send() + self.assertEqual(code, 1) + self.assertEqual(result["codexHomeResolution"]["reason"], + "writer_evidence_changed_before_queue") + self.queue.assert_not_called() + def test_competing_writer_appearing_before_queue_fails_closed(self): inactive = {"activity": "inactive", "writerLock": "free", "reason": "kernel_lock_free"} selected = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, - "ownerStable": True, "reason": "stable_live_writer"} + "ownerStable": True, "ownerStartTime": "stable", "reason": "stable_live_writer"} competitor = {**selected, "ownerPid": 41} with mock.patch.object(peer, "inspect_codex_writer", side_effect=[inactive, selected, competitor, selected]): @@ -509,22 +555,72 @@ def test_configured_duplicate_rejected_without_macos_discovery(self): self.assertIn("--codex-home", result["error"]) self.queue.assert_not_called() - def test_explicit_home_queues_only_selected_copy_and_exposes_semantics(self): - for home in (self.account, self.default): - with self.subTest(home=home): - code, result = self.send("--codex-home", str(home)) - self.assertEqual(code, 0) - self.assertEqual(result["codexHome"], str(home)) - self.assertEqual(result["status"], "queued") - self.assertTrue(result["submitted"]) - self.assertIs(result["consumptionConfirmed"], False) - self.assertEqual(result["codexHomeResolution"]["status"], "explicit") - self.assertEqual(result["codexHomeResolution"]["reason"], - "explicit_codex_home") - self.assertEqual(self.queue.call_args.kwargs["env"]["CODEX_HOME"], str(home)) - - def test_explicit_dry_run_selects_one_copy_without_submission(self): - code, result = self.send("--codex-home", str(self.account), "--dry-run") + def test_explicit_live_writer_is_validated_and_revalidated(self): + inactive = {"activity": "inactive", "writerLock": "free", + "reason": "kernel_lock_free"} + active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, + "ownerStable": True, "ownerStartTime": "stable", + "reason": "stable_live_writer"} + with mock.patch.object(peer, "inspect_codex_writer", + side_effect=[active, inactive, active, inactive]) as inspect: + code, result = self.send("--codex-home", str(self.account)) + self.assertEqual(code, 0) + self.assertEqual(result["codexHome"], str(self.account)) + self.assertEqual(result["codexHomeResolution"]["status"], "explicit") + self.assertEqual(result["codexHomeResolution"]["reason"], + "explicit_live_writer") + self.assertEqual(inspect.call_count, 4) + self.assertEqual(self.queue.call_args.kwargs["env"]["CODEX_HOME"], str(self.account)) + + def test_explicit_inactive_home_conflicting_with_live_writer_is_rejected(self): + inactive = {"activity": "inactive", "writerLock": "free", + "reason": "kernel_lock_free"} + active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, + "ownerStable": True, "ownerStartTime": "stable", + "reason": "stable_live_writer"} + with mock.patch.object(peer, "inspect_codex_writer", + side_effect=[inactive, active]): + code, result = self.send("--codex-home", str(self.default)) + self.assertEqual(code, 1) + resolution = result["codexHomeResolution"] + self.assertEqual(resolution["reason"], + "explicit_home_conflicts_with_live_writer") + self.assertIsNone(resolution["selected"]) + self.assertIn(str(self.account), result["error"]) + self.queue.assert_not_called() + + def test_explicit_inactive_home_requires_opt_in(self): + code, result = self.send("--codex-home", str(self.account)) + self.assertEqual(code, 1) + self.assertEqual(result["codexHomeResolution"]["reason"], + "inactive_queue_requires_opt_in") + self.assertIn("--allow-inactive-codex-home", result["error"]) + self.queue.assert_not_called() + + def test_inactive_opt_in_requires_explicit_home(self): + code, result = self.send("--allow-inactive-codex-home") + self.assertEqual(code, 1) + self.assertEqual(result["codexHomeResolution"]["reason"], + "inactive_opt_in_requires_explicit_home") + self.queue.assert_not_called() + + def test_explicit_inactive_opt_in_queues_selected_copy_and_revalidates(self): + with mock.patch.object(peer, "inspect_codex_writer", wraps=peer.inspect_codex_writer) as inspect: + code, result = self.send( + "--codex-home", str(self.account), "--allow-inactive-codex-home" + ) + self.assertEqual(code, 0) + self.assertEqual(result["codexHome"], str(self.account)) + self.assertEqual(result["codexHomeResolution"]["status"], "explicit") + self.assertEqual(result["codexHomeResolution"]["reason"], + "explicit_inactive_opt_in") + self.assertEqual(inspect.call_count, 4) + self.assertEqual(self.queue.call_args.kwargs["env"]["CODEX_HOME"], str(self.account)) + + def test_explicit_inactive_dry_run_requires_and_accepts_opt_in(self): + code, result = self.send( + "--codex-home", str(self.account), "--allow-inactive-codex-home", "--dry-run" + ) self.assertEqual(code, 0) self.assertEqual(result["codexHome"], str(self.account)) self.assertEqual(result["status"], "validated") @@ -541,46 +637,127 @@ def test_environment_selection_still_checks_default_copy(self): self.assertIn(str(self.account), result["error"]) self.queue.assert_not_called() + @unittest.skipIf(os.name == "nt", "hard links require platform-specific privileges") + def test_hard_linked_rollouts_still_use_unique_live_writer(self): + rollouts = self.user / "rollouts" + rollouts.mkdir() + default_rollout = rollouts / "default.jsonl" + account_rollout = rollouts / "account.jsonl" + default_rollout.write_text("fixture", encoding="utf-8") + os.link(default_rollout, account_rollout) + self.assertEqual(default_rollout.stat().st_ino, account_rollout.stat().st_ino) + for home, rollout in ((self.default, default_rollout), + (self.account, account_rollout)): + with contextlib.closing(sqlite3.connect(home / "state_5.sqlite")) as conn: + conn.execute("UPDATE threads SET rollout_path=? WHERE id=?", + (str(rollout), THREAD)) + conn.commit() + inactive = {"activity": "inactive", "writerLock": "free", + "reason": "kernel_lock_free"} + active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, + "ownerStable": True, "ownerStartTime": "stable", + "reason": "stable_live_writer"} + with mock.patch.object(peer, "inspect_codex_writer", + side_effect=[inactive, active, inactive, active]): + code, result = self.send() + self.assertEqual(code, 0) + self.assertEqual(result["codexHome"], str(self.account)) + self.assertEqual(result["codexHomeResolution"]["reason"], + "single_stable_live_writer") + + @unittest.skipIf(peer.fcntl is None, "POSIX flock is unavailable") + @unittest.skipUnless(hasattr(os, "getuid"), "POSIX UID is unavailable") + def test_resolver_uses_real_advisory_lock_and_revalidates_it(self): + with contextlib.closing(sqlite3.connect(self.account / "state_5.sqlite")) as conn: + conn.execute("UPDATE threads SET id=?", (OTHER,)) + conn.commit() + directory = self.default / "thread-writer-locks" + directory.mkdir() + lock = directory / f"{THREAD}.lock" + lock.write_text("fixture", encoding="utf-8") + child = subprocess.Popen([ + peer.sys.executable, "-c", + "import fcntl,sys,time; f=open(sys.argv[1],'r+'); " + "fcntl.flock(f,fcntl.LOCK_EX); print('ready',flush=True); time.sleep(60)", + str(lock), + ], stdout=subprocess.PIPE, text=True) + + def cleanup(): + if child.poll() is None: + child.kill() + child.wait(timeout=5) + if child.stdout and not child.stdout.closed: + child.stdout.close() + + self.addCleanup(cleanup) + self.assertEqual(child.stdout.readline().strip(), "ready") + opener = [{"pid": child.pid, "uid": os.getuid(), "command": "codex", + "startTime": "stable"}] + with mock.patch.object(peer, "_codex_lock_openers", + return_value=(opener, None)), \ + mock.patch.object(peer.time, "sleep"): + code, result = self.send() + self.assertEqual(code, 0) + self.assertEqual(result["codexHome"], str(self.default)) + self.assertEqual(result["codexHomeResolution"]["reason"], + "single_stable_live_writer") + self.assertEqual(peer.probe_codex_writer_lock(lock), + ("held", "kernel_lock_held")) + def test_archived_duplicate_is_not_assumed_inactive_or_safe(self): with contextlib.closing(sqlite3.connect(self.account / "state_5.sqlite")) as conn: conn.execute("UPDATE threads SET archived=1") conn.commit() code, result = self.send() self.assertEqual(code, 1) - self.assertIn("Ambiguous", result["error"]) + self.assertEqual(result["codexHomeResolution"]["reason"], + "inactive_queue_requires_opt_in") self.queue.assert_not_called() def test_unrelated_known_home_keeps_selected_destination(self): with contextlib.closing(sqlite3.connect(self.account / "state_5.sqlite")) as conn: conn.execute("UPDATE threads SET id=?", (OTHER,)) conn.commit() - code, result = self.send() + active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, + "ownerStable": True, "ownerStartTime": "stable", + "reason": "stable_live_writer"} + with mock.patch.object(peer, "inspect_codex_writer", side_effect=[active, active]): + code, result = self.send() self.assertEqual(code, 0) self.assertEqual(result["codexHome"], str(self.default)) self.assertEqual(self.queue.call_args.kwargs["env"]["CODEX_HOME"], str(self.default)) self.queue.assert_called_once() - def test_single_default_home_preserves_native_queue_behavior(self): - with mock.patch.object(peer.sys, "platform", "linux"): + def test_single_default_live_writer_is_validated(self): + active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, + "ownerStable": True, "ownerStartTime": "stable", + "reason": "stable_live_writer"} + with mock.patch.object(peer.sys, "platform", "linux"), \ + mock.patch.object(peer, "inspect_codex_writer", side_effect=[active, active]): code, result = self.send() self.assertEqual(code, 0) self.assertEqual(result["codexHome"], str(self.default)) self.assertEqual(result["status"], "queued") self.queue.assert_called_once() - def test_single_default_without_discovery_db_still_delegates_to_native_queue(self): + def test_single_default_without_discovery_db_fails_without_queue(self): empty_user = self.user / "empty-user" with mock.patch.object(peer.Path, "home", return_value=empty_user): code, result = self.send() - self.assertEqual(code, 0) - self.assertEqual(result["codexHome"], str(empty_user / ".codex")) + self.assertEqual(code, 2) + self.assertEqual(result["codexHomeResolution"]["reason"], + "thread_not_saved_in_known_homes") self.assertFalse(empty_user.exists()) - self.queue.assert_called_once() + self.queue.assert_not_called() def test_macos_default_without_orca_installation_remains_compatible(self): user = self.user / "single-home-user" self.make_home(user / ".codex") - with mock.patch.object(peer.Path, "home", return_value=user): + active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, + "ownerStable": True, "ownerStartTime": "stable", + "reason": "stable_live_writer"} + with mock.patch.object(peer.Path, "home", return_value=user), \ + mock.patch.object(peer, "inspect_codex_writer", side_effect=[active, active]): code, result = self.send() self.assertEqual(code, 0) self.assertEqual(result["codexHome"], str(user / ".codex")) @@ -591,7 +768,11 @@ def test_orca_account_without_db_is_not_a_competing_saved_copy(self): self.make_home(user / ".codex") empty = user / "Library/Application Support/orca/codex-accounts/empty/home" empty.mkdir(parents=True) - with mock.patch.object(peer.Path, "home", return_value=user): + active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, + "ownerStable": True, "ownerStartTime": "stable", + "reason": "stable_live_writer"} + with mock.patch.object(peer.Path, "home", return_value=user), \ + mock.patch.object(peer, "inspect_codex_writer", side_effect=[active, active]): code, result = self.send() self.assertEqual(code, 0) self.assertEqual(result["codexHome"], str(user / ".codex")) @@ -603,9 +784,9 @@ def test_never_automatically_switches_to_another_home(self): conn.execute("DELETE FROM threads") conn.commit() code, result = self.send() - self.assertEqual(code, 2) + self.assertEqual(code, 1) self.assertIn(str(self.account), result["error"]) - self.assertIn("--codex-home", result["error"]) + self.assertIn("--allow-inactive-codex-home", result["error"]) self.queue.assert_not_called() def test_invalid_config_fails_closed_without_echoing_raw_value(self): @@ -624,7 +805,7 @@ def test_missing_configured_home_is_not_created_or_silently_skipped(self): code, result = self.send() self.assertEqual(code, 1) self.assertIn(str(missing), result["error"]) - self.assertIn("--codex-home", result["error"]) + self.assertIn("nothing queued", result["error"]) self.assertFalse(missing.exists()) self.queue.assert_not_called() @@ -658,20 +839,24 @@ def test_orca_scan_permission_error_is_not_silently_skipped(self): self.assertIn("Cannot inspect Codex homes", result["error"]) self.queue.assert_not_called() - def test_explicit_selection_bypasses_unrelated_broken_inventory(self): + def test_explicit_selection_does_not_bypass_broken_inventory(self): os.environ["SESSION_PEER_CODEX_HOMES"] = 'invalid' with mock.patch.object(peer.Path, "iterdir", side_effect=PermissionError("denied")): code, result = self.send("--codex-home", str(self.account)) - self.assertEqual(code, 0) - self.assertEqual(result["codexHome"], str(self.account)) - self.queue.assert_called_once() + self.assertEqual(code, 1) + self.assertIn("Cannot inspect Codex homes", result["error"]) + self.queue.assert_not_called() @unittest.skipIf(os.name == "nt", "creating symlinks may require Windows privileges") def test_symlink_and_repeated_paths_are_one_home(self): alias = self.user / "codex-alias" alias.symlink_to(self.default, target_is_directory=True) os.environ["SESSION_PEER_CODEX_HOMES"] = json.dumps([str(alias), str(self.default), str(alias)]) - with mock.patch.object(peer.sys, "platform", "linux"): + active = {"activity": "live_writer", "writerLock": "held", "ownerPid": 42, + "ownerStable": True, "ownerStartTime": "stable", + "reason": "stable_live_writer"} + with mock.patch.object(peer.sys, "platform", "linux"), \ + mock.patch.object(peer, "inspect_codex_writer", side_effect=[active, active]): code, result = self.send() self.assertEqual(code, 0) self.assertEqual(result["codexHome"], str(self.default)) @@ -711,14 +896,15 @@ def test_human_results_identify_home_and_unconfirmed_consumption(self): output = io.StringIO() with contextlib.redirect_stdout(output): code = peer.main(["send", "--to", "codex:" + THREAD, "--codex-home", str(self.account), - "--no-from", "--no-reply-to", "test"]) + "--allow-inactive-codex-home", "--no-from", "--no-reply-to", "test"]) self.assertEqual(code, 0) self.assertIn(str(self.account), output.getvalue()) self.assertIn("consumption not confirmed", output.getvalue()) def test_timeout_does_not_return_false_submission_or_consumption_confirmation(self): self.queue.side_effect = subprocess.TimeoutExpired("codex", 30) - code, result = self.send("--codex-home", str(self.account)) + code, result = self.send("--codex-home", str(self.account), + "--allow-inactive-codex-home") self.assertEqual(code, 1) self.assertFalse(result["ok"]) self.assertIn("outcome unknown", result["error"]) @@ -743,9 +929,10 @@ def destination(host, argv, ssh_opts): code, result = self.send("--host", "worker") self.assertEqual(code, 1) self.assertEqual(result["host"], "worker") - self.assertIn("Ambiguous", result["error"]) + self.assertIn("inactive", result["error"]) self.queue.assert_not_called() - code, result = self.send("--host", "worker", "--codex-home", str(self.account)) + code, result = self.send("--host", "worker", "--codex-home", str(self.account), + "--allow-inactive-codex-home") self.assertEqual(code, 0) self.assertEqual(result["host"], "worker") self.assertEqual(result["codexHome"], str(self.account)) @@ -753,7 +940,8 @@ def destination(host, argv, ssh_opts): self.assertIs(result["consumptionConfirmed"], False) self.assertEqual(self.queue.call_args.kwargs["env"]["CODEX_HOME"], str(self.account)) self.queue.reset_mock() - code, result = self.send("--host", "worker", "--codex-home", str(self.account), "--dry-run") + code, result = self.send("--host", "worker", "--codex-home", str(self.account), + "--allow-inactive-codex-home", "--dry-run") self.assertEqual(code, 0) self.assertIs(result["submitted"], False) self.assertEqual(result["codexHome"], str(self.account)) diff --git a/tests/test_wake.py b/tests/test_wake.py index e24aba3a..538d3f1d 100644 --- a/tests/test_wake.py +++ b/tests/test_wake.py @@ -93,8 +93,11 @@ def test_queue_id_survives_wake_failure_without_retry(self): wake.assert_called_once_with('codex', self.root, THREAD, str(self.root), 1) def test_active_writer_only_queues(self): + active = {'activity':'live_writer', 'writerLock':'held', 'ownerPid':42, + 'ownerStartTime':'stable', 'ownerStable':True, + 'reason':'stable_live_writer'} with mock.patch.object(peer, 'codex_wake_preflight', return_value={'cwd':str(self.root)}), \ - mock.patch.object(peer, 'inspect_codex_writer', return_value={'activity':'live_writer'}), \ + mock.patch.object(peer, 'inspect_codex_writer', return_value=active), \ mock.patch.object(peer, '_queue_codex', return_value=dict(self.submitted)) as queue, \ mock.patch.object(peer, 'run_codex_wake') as wake: result = peer.queue_codex(self.args, 'test')