From 523b04e55d981d24347b9c70d2f1b14b50ed4149 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 22 Oct 2026 15:52:08 +0900 Subject: [PATCH 1/6] feat(cli): explain ordinary dry-run delivery limits --- docs/cli-reference.md | 16 ++ docs/i18n-manifest.json | 2 +- docs/ja/cli-reference.md | 16 ++ docs/ko/cli-reference.md | 16 ++ docs/zh-CN/cli-reference.md | 16 ++ session_peer.py | 85 +++++++++ session_peer_core/cli.py | 15 ++ session_peer_core/output.py | 61 +++++++ session_peer_core/sending.py | 9 + tests/core/test_delivery_explanation.py | 218 ++++++++++++++++++++++++ 10 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 tests/core/test_delivery_explanation.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 46c79a3f..037cd10b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -60,6 +60,22 @@ not a `--agent` flag. running. Plain send does not activate a session; [explicit `--wake`](wake.md) is opt-in and does not confirm consumption or a reply. + + +### Delivery explanation (development) + +Development for #323; unavailable in published v1.2.1. Add `--explain` to `send --dry-run` for a human-readable summary of the selected local or SSH destination, existing validation result, delivery method and observation limits. Multiple SSH destinations receive separate summaries. + +```bash +session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain +``` + +The explanation reuses the current dry-run result and adds no diagnostic, native-version or SSH probes. The dry-run itself may already run version checks. A later send repeats validation; a successful preview does not reserve the destination or guarantee submission, consumption or completion. Without `--wake`, wake eligibility is not checked. With it, only the existing preflight result is described. Ordinary-send receipt ACKs remain unavailable; the separate experimental handoff workflow has different capabilities. + +This first scope is text-only and requires `--dry-run`. Combining `--explain` with `--json` or `--output-format json`, paired `--device`, or handoff `--correlation-id`, `--request-ack`, `--observe-delivery` or `--wait-for` is a usage error before message input or destination/ledger work. Existing commands without the new option keep their output, JSON and exit contracts. + +The normal message requirement and limits still apply: supply `--message`, `--message-file` or stdin as usual; no placeholder body is generated. Explanation fields do not include the body or receipt secrets. Unknown evidence stays unknown, and an uncertain actual send must never trigger automatic resend. + ### Optional paired devices and Antigravity (v0.9) These features are available starting with PyPI v0.9.0. Antigravity remains diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index a5c9f04a..c9efe420 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "43e9e3232d8975d3e6b17065f40f43ff590184cac503d2c8fdae05142668334b", + "docs/cli-reference.md": "4dc4ae4f9bfe0039b1375f7a765c4932dae92d007764be6c70d2f14125a8d4f5", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 590eff67..82ec8915 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -93,6 +93,22 @@ session-peer send --host worker --to 'codex:' --dry-run -m "me 限りません。通常の送信ではセッションはアクティブ化されません。[明示的な `--wake`](wake.md) はオプトインであり、消費または返信を確認するものではありません。 + + +### 配信説明(開発中) + +#323 の開発機能であり、公開済み v1.2.1 には含まれません。`send --dry-run` に `--explain` を追加すると、選択したローカルまたは SSH の宛先、既存の検証結果、配信方法、観測上の制限を読みやすく表示します。SSH の宛先が複数ある場合は個別に説明します。 + +```bash +session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain +``` + +説明は現在の dry-run の結果を再利用し、診断、native バージョン、SSH の追加照会は行いません。既存の dry-run 自体にはバージョン確認が含まれる場合があります。実際の送信時には再検証するため、プレビューの成功は宛先の予約や投入・消費・完了を保証しません。`--wake` を指定しない場合、起動の適格性は確認しません。指定した場合も既存の事前検証結果だけを説明します。通常送信の receipt ACK は未対応で、別の実験的 handoff とは機能範囲が異なります。 + +最初の対応範囲はテキスト出力のみで、`--dry-run` が必須です。`--explain` と `--json` または `--output-format json`、ペアリングの `--device`、handoff の `--correlation-id`、`--request-ack`、`--observe-delivery`、`--wait-for` を組み合わせると、メッセージ入力や宛先・台帳処理の前に使用法エラーになります。新しいオプションを使わない既存コマンドの出力・JSON・終了契約は維持されます。 + +通常のメッセージ入力要件と上限は変わりません。従来どおり `--message`、`--message-file` または stdin を使い、仮の本文は自動生成しません。説明フィールドに本文や receipt の秘密値は含めません。不明な証拠は不明のまま表示し、実際の送信結果が不確実な場合は自動再送しません。 + ### オプションのペアリング済みデバイスと Antigravity (v0.9) これらの機能は PyPI v0.9.0 以降で利用可能です。Antigravity は引き続き diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index bc9e92e8..9a6a97e9 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -94,6 +94,22 @@ session-peer send --host worker --to 'codex:' --dry-run -m "me 실행 중인 것은 아닙니다. 일반 send는 세션을 활성화하지 않으며, [명시적 `--wake`](wake.md)는 옵트인 방식이며 소비나 응답을 확인해주지 않습니다. + + +### 전달 설명 (개발 중) + +#323의 개발 기능이며 공개 v1.2.1에는 없습니다. `send --dry-run`에 `--explain`을 추가하면 선택한 로컬 또는 SSH 대상, 기존 검증 결과, 전달 방식과 관찰 한계를 사람이 읽기 쉬운 형태로 표시합니다. SSH 대상이 여러 개이면 각각 설명합니다. + +```bash +session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain +``` + +설명은 현재 dry-run 결과를 재사용하며 진단·native 버전·SSH 조회를 추가하지 않습니다. 기존 dry-run 자체에는 버전 검사가 포함될 수 있습니다. 실제 전송 시에는 다시 검증하며, 미리보기 성공이 대상을 예약하거나 제출·소비·완료를 보장하지는 않습니다. `--wake`를 지정하지 않으면 깨우기 가능 여부를 검사하지 않습니다. 지정한 경우에도 기존 사전 검사 결과만 설명합니다. 일반 전송의 receipt ACK는 지원하지 않으며, 별도의 실험적 handoff는 지원 범위가 다릅니다. + +첫 지원 범위는 텍스트 출력 전용이며 `--dry-run`이 필수입니다. `--explain`을 `--json` 또는 `--output-format json`, 페어링 `--device`, handoff `--correlation-id`, `--request-ack`, `--observe-delivery`, `--wait-for`와 함께 지정하면 메시지 입력이나 대상·저널 작업 전에 사용법 오류로 거부합니다. 새 옵션을 사용하지 않은 기존 명령의 출력·JSON·종료 계약은 유지됩니다. + +메시지 입력 요구사항과 한도는 동일합니다. 평소처럼 `--message`, `--message-file` 또는 stdin을 사용하며 임시 본문을 자동 생성하지 않습니다. 설명 필드에는 본문이나 receipt 비밀값을 넣지 않습니다. 확인할 수 없는 증거는 그대로 알 수 없음으로 표시하고, 실제 전송 결과가 불확실하면 자동 재전송하지 않습니다. + ### 페어링된 기기 옵션 및 Antigravity (v0.9) 이 기능들은 PyPI v0.9.0부터 제공됩니다. Antigravity는 여전히 실험적이며 페어링된 전송은 diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index e042fcfa..f3a97023 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -72,6 +72,22 @@ session-peer send --host worker --to 'codex:' --dry-run -m "me **已投递/已入队并不代表已确认。** 已保存的 Codex 线程不一定正在运行。普通的发送不会激活会话;[显式 `--wake`](wake.md) 是可选功能,且不确认消息已被消费或回复。 + + +### 传递说明(开发中) + +这是 #323 的开发功能,尚未包含在已发布的 v1.2.1 中。在 `send --dry-run` 中添加 `--explain`,即可查看所选本地或 SSH 目标、现有验证结果、传递方式及观察限制。多个 SSH 目标会分别显示说明。 + +```bash +session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain +``` + +说明复用当前 dry-run 的结果,不增加诊断、native 版本或 SSH 探测。原有 dry-run 本身可能包含版本检查。实际发送时会重新验证,因此预览成功不代表预留目标,也不保证提交、消费或完成。未指定 `--wake` 时,不检查唤醒资格;指定后也只说明原有预检结果。普通发送不支持 receipt ACK,独立的实验性 handoff 具有不同的能力范围。 + +首期范围仅支持文本输出,且必须指定 `--dry-run`。将 `--explain` 与 `--json` 或 `--output-format json`、配对 `--device`,或 handoff 的 `--correlation-id`、`--request-ack`、`--observe-delivery`、`--wait-for` 组合使用时,会在读取消息或执行目标、账本操作前报告用法错误。不使用新选项的现有命令保留原有输出、JSON 和退出契约。 + +消息输入要求和限制保持不变。按原有方式使用 `--message`、`--message-file` 或 stdin,不自动生成占位正文。说明字段不包含正文或 receipt 秘密值。未知证据仍显示为未知,实际发送结果不确定时禁止自动重发。 + ### 可选配对设备与 Antigravity (v0.9) 这些功能自 PyPI v0.9.0 起提供。Antigravity 仍处于实验阶段,配对传输仍处于 Beta 阶段;仍需进行操作验证。使用 `pipx install 'session-peer[relay]'` 安装额外组件。`[relay]` 额外组件(Unix、Python 3.11+)支持通过直接连接或自建 WSS 中继进行经过身份验证的配对设备传递。配对会固定设备身份;单独的操作员策略允许特定的代理目标和操作。盲中继无法解密应用消息。在公开端点之前,请遵循[配对设备设置与操作指南](paired-devices.md)。该测试版不提供 NAT 穿透或托管公共服务。 diff --git a/session_peer.py b/session_peer.py index 7bb8797e..52ef54ff 100644 --- a/session_peer.py +++ b/session_peer.py @@ -4321,6 +4321,67 @@ def render_sessions(sessions: list[dict], where: str) -> str: return f"Sessions on {where}:\n" + "\n".join(lines) +def render_delivery_explanation(args: argparse.Namespace, result: dict, *, + host: str | None = None, failed: bool = False) -> str: + """Describe only existing dry-run facts; never inspect or execute a target. + + This display-only allowlist deliberately excludes message bodies, native + errors, credentials, routing URIs and receipt authority. Missing remote + facts stay unknown rather than being filled from the sender's environment. + """ + requested = str(getattr(args, "to", "")) + unresolved_uri = requested.startswith("session-peer:") + if unresolved_uri: + requested = "Reply-To URI (resolution unavailable)" + agent = (None if unresolved_uri else + "codex" if requested.startswith("codex:") else + "antigravity" if requested.startswith("antigravity:") else "claude") + where = host or (", ".join(getattr(args, "host", None) or []) or "this machine") + lines = ["Delivery preview — nothing submitted", + "Requested destination: " + human_text(requested) + " on " + human_text(where)] + target = result.get("target") + if isinstance(target, dict): + if agent == "claude" and type(target.get("pid")) is int: + resolved = "Claude PID " + str(target["pid"]) + if isinstance(target.get("name"), str): + resolved += " (" + human_text(target["name"]) + ")" + lines.append("Resolved target: " + resolved) + elif agent == "codex" and isinstance(target.get("id"), str): + lines.append("Resolved target: codex:" + human_text(target["id"])) + home_key = "codexHome" if agent == "codex" else "antigravityHome" + if agent in ("codex", "antigravity"): + home = result.get(home_key) + lines.append(("Codex home: " if agent == "codex" else "Antigravity home: ") + + (human_text(home) if isinstance(home, str) else "unknown (not reported)")) + lines.append("Validation: " + ("not passed" if failed else + "passed for this dry run")) + method = {"claude": "Claude Code inbox", "codex": "native Codex queue", + "antigravity": "registered Antigravity bridge"}.get(agent) + lines.append("Delivery method: " + (method + " (planned)" if method else "unknown (not resolved)")) + if not getattr(args, "wake", False): + wake = "not requested; eligibility not checked" + else: + fact = result.get("wake") + wake = ("preflight passed for this dry run; no turn started" + if isinstance(fact, dict) and fact.get("status") == "validated" else + "requested; preflight not confirmed") + lines.extend(["Wake: " + wake, + "Receipt ACK: unavailable for ordinary sends", + "Consumption / completion: not observed", + "A later send repeats validation.", + "Do not automatically resend after an unknown outcome."]) + if failed: + lines.append("Next step: resolve the validation failure before attempting a send.") + return "\n".join(lines) + + +def emit_delivery_explanation(args: argparse.Namespace, result: dict, *, + host: str | None = None, failed: bool = False, + stream=None) -> None: + print(render_delivery_explanation(args, result, host=host, failed=failed), + file=stream or (sys.stderr if failed else sys.stdout)) + + JSON_RESPONSE_SCHEMA_VERSION = 1 @@ -9362,6 +9423,8 @@ def cmd_send(args: argparse.Namespace) -> int: result = LocalTransport().execute("send", adapter, args, text) result.update(routing_metadata) emit(args.json, result, adapter.submission_text(result, "this machine"), command="send") + if getattr(args, "explain", False): + emit_delivery_explanation(args, result, failed=result.get("ok") is False) if advertised_route and advertised_route["status"] == "unverified" and not args.json: print( "session-peer: reverse SSH reply route was not checked; " @@ -9404,6 +9467,9 @@ def cmd_send(args: argparse.Namespace) -> int: exit_code = EXIT_ERROR if not args.json: print(adapter.submission_text(payload, shown_host)) + if getattr(args, "explain", False): + emit_delivery_explanation(args, payload, host=shown_host, + failed=payload.get("ok") is False) except CcPeerError as exc: exit_code = EXIT_ERROR all_results.append(json_result( @@ -9413,6 +9479,10 @@ def cmd_send(args: argparse.Namespace) -> int: )) if not args.json: print(human_text(f"session-peer: {requested_host}: {exc}"), file=sys.stderr) + if getattr(args, "explain", False): + emit_delivery_explanation(args, exc.details, + host=display_host(requested_host, host), + failed=True, stream=sys.stderr) if args.json: emit_json_results(all_results) @@ -9634,6 +9704,7 @@ def add_common(sub: argparse.ArgumentParser) -> None: 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") + sending.add_argument("--explain", action="store_true", help="explain an ordinary local/SSH dry-run in text") sending.set_defaults(func=cmd_send) handoff = subparsers.add_parser("handoff", help="manage private correlated intents without submitting native messages") @@ -9772,6 +9843,16 @@ def main(argv: list[str] | None = None) -> int: if args.json and output_format == "text": parser.error("--json conflicts with --output-format text") args.json = args.json or output_format == "json" + if getattr(args, "explain", False): + if not args.dry_run: + parser.error("--explain requires --dry-run") + if args.json: + parser.error("--explain requires text output") + if getattr(args, "device", None): + parser.error("--explain does not support paired-device sends") + if handoff_requested(args) or any(getattr(args, key, None) is not None for key in ( + "_handoff_native_context", "_handoff_native_cutoff_ms", "_handoff_native_total_ms")): + parser.error("--explain does not support experimental handoff sends") try: _CLIENT_UPDATE_NOTICE = prepare_client_update(args) if cli_invocation else None except Exception: @@ -9793,6 +9874,8 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(with_client_update(payload), ensure_ascii=False)) else: print(human_text(f"session-peer: {message}"), file=sys.stderr) + if getattr(args, "explain", False): + emit_delivery_explanation(args, exc.details, failed=True, stream=sys.stderr) exit_code = ( EXIT_NO_TARGET if isinstance(exc, NoTargetError) or "no reachable session" in message @@ -9808,6 +9891,8 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(with_client_update(payload), ensure_ascii=False)) else: print(human_text(f"session-peer: {message}"), file=sys.stderr) + if getattr(args, "explain", False): + emit_delivery_explanation(args, {}, failed=True, stream=sys.stderr) exit_code = EXIT_ERROR if show_human_notice and not args.json: emit_human_update_notice() diff --git a/session_peer_core/cli.py b/session_peer_core/cli.py index 7b5faa92..e0d9e42c 100644 --- a/session_peer_core/cli.py +++ b/session_peer_core/cli.py @@ -132,6 +132,7 @@ def add_common(sub: argparse.ArgumentParser) -> None: 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") + sending.add_argument("--explain", action="store_true", help="explain an ordinary local/SSH dry-run in text") sending.set_defaults(func=cmd_send) handoff = subparsers.add_parser("handoff", help="manage private correlated intents without submitting native messages") @@ -270,6 +271,16 @@ def main(argv: list[str] | None = None) -> int: if args.json and output_format == "text": parser.error("--json conflicts with --output-format text") args.json = args.json or output_format == "json" + if getattr(args, "explain", False): + if not args.dry_run: + parser.error("--explain requires --dry-run") + if args.json: + parser.error("--explain requires text output") + if getattr(args, "device", None): + parser.error("--explain does not support paired-device sends") + if handoff_requested(args) or any(getattr(args, key, None) is not None for key in ( + "_handoff_native_context", "_handoff_native_cutoff_ms", "_handoff_native_total_ms")): + parser.error("--explain does not support experimental handoff sends") try: _CLIENT_UPDATE_NOTICE = prepare_client_update(args) if cli_invocation else None except Exception: @@ -291,6 +302,8 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(with_client_update(payload), ensure_ascii=False)) else: print(human_text(f"session-peer: {message}"), file=sys.stderr) + if getattr(args, "explain", False): + emit_delivery_explanation(args, exc.details, failed=True, stream=sys.stderr) exit_code = ( EXIT_NO_TARGET if isinstance(exc, NoTargetError) or "no reachable session" in message @@ -306,6 +319,8 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(with_client_update(payload), ensure_ascii=False)) else: print(human_text(f"session-peer: {message}"), file=sys.stderr) + if getattr(args, "explain", False): + emit_delivery_explanation(args, {}, failed=True, stream=sys.stderr) exit_code = EXIT_ERROR if show_human_notice and not args.json: emit_human_update_notice() diff --git a/session_peer_core/output.py b/session_peer_core/output.py index 1bf651c2..9b4ef6d8 100644 --- a/session_peer_core/output.py +++ b/session_peer_core/output.py @@ -43,6 +43,67 @@ def render_sessions(sessions: list[dict], where: str) -> str: return f"Sessions on {where}:\n" + "\n".join(lines) +def render_delivery_explanation(args: argparse.Namespace, result: dict, *, + host: str | None = None, failed: bool = False) -> str: + """Describe only existing dry-run facts; never inspect or execute a target. + + This display-only allowlist deliberately excludes message bodies, native + errors, credentials, routing URIs and receipt authority. Missing remote + facts stay unknown rather than being filled from the sender's environment. + """ + requested = str(getattr(args, "to", "")) + unresolved_uri = requested.startswith("session-peer:") + if unresolved_uri: + requested = "Reply-To URI (resolution unavailable)" + agent = (None if unresolved_uri else + "codex" if requested.startswith("codex:") else + "antigravity" if requested.startswith("antigravity:") else "claude") + where = host or (", ".join(getattr(args, "host", None) or []) or "this machine") + lines = ["Delivery preview — nothing submitted", + "Requested destination: " + human_text(requested) + " on " + human_text(where)] + target = result.get("target") + if isinstance(target, dict): + if agent == "claude" and type(target.get("pid")) is int: + resolved = "Claude PID " + str(target["pid"]) + if isinstance(target.get("name"), str): + resolved += " (" + human_text(target["name"]) + ")" + lines.append("Resolved target: " + resolved) + elif agent == "codex" and isinstance(target.get("id"), str): + lines.append("Resolved target: codex:" + human_text(target["id"])) + home_key = "codexHome" if agent == "codex" else "antigravityHome" + if agent in ("codex", "antigravity"): + home = result.get(home_key) + lines.append(("Codex home: " if agent == "codex" else "Antigravity home: ") + + (human_text(home) if isinstance(home, str) else "unknown (not reported)")) + lines.append("Validation: " + ("not passed" if failed else + "passed for this dry run")) + method = {"claude": "Claude Code inbox", "codex": "native Codex queue", + "antigravity": "registered Antigravity bridge"}.get(agent) + lines.append("Delivery method: " + (method + " (planned)" if method else "unknown (not resolved)")) + if not getattr(args, "wake", False): + wake = "not requested; eligibility not checked" + else: + fact = result.get("wake") + wake = ("preflight passed for this dry run; no turn started" + if isinstance(fact, dict) and fact.get("status") == "validated" else + "requested; preflight not confirmed") + lines.extend(["Wake: " + wake, + "Receipt ACK: unavailable for ordinary sends", + "Consumption / completion: not observed", + "A later send repeats validation.", + "Do not automatically resend after an unknown outcome."]) + if failed: + lines.append("Next step: resolve the validation failure before attempting a send.") + return "\n".join(lines) + + +def emit_delivery_explanation(args: argparse.Namespace, result: dict, *, + host: str | None = None, failed: bool = False, + stream=None) -> None: + print(render_delivery_explanation(args, result, host=host, failed=failed), + file=stream or (sys.stderr if failed else sys.stdout)) + + JSON_RESPONSE_SCHEMA_VERSION = 1 diff --git a/session_peer_core/sending.py b/session_peer_core/sending.py index 2a34917d..1e384c1a 100644 --- a/session_peer_core/sending.py +++ b/session_peer_core/sending.py @@ -81,6 +81,8 @@ def cmd_send(args: argparse.Namespace) -> int: result = LocalTransport().execute("send", adapter, args, text) result.update(routing_metadata) emit(args.json, result, adapter.submission_text(result, "this machine"), command="send") + if getattr(args, "explain", False): + emit_delivery_explanation(args, result, failed=result.get("ok") is False) if advertised_route and advertised_route["status"] == "unverified" and not args.json: print( "session-peer: reverse SSH reply route was not checked; " @@ -123,6 +125,9 @@ def cmd_send(args: argparse.Namespace) -> int: exit_code = EXIT_ERROR if not args.json: print(adapter.submission_text(payload, shown_host)) + if getattr(args, "explain", False): + emit_delivery_explanation(args, payload, host=shown_host, + failed=payload.get("ok") is False) except CcPeerError as exc: exit_code = EXIT_ERROR all_results.append(json_result( @@ -132,6 +137,10 @@ def cmd_send(args: argparse.Namespace) -> int: )) if not args.json: print(human_text(f"session-peer: {requested_host}: {exc}"), file=sys.stderr) + if getattr(args, "explain", False): + emit_delivery_explanation(args, exc.details, + host=display_host(requested_host, host), + failed=True, stream=sys.stderr) if args.json: emit_json_results(all_results) diff --git a/tests/core/test_delivery_explanation.py b/tests/core/test_delivery_explanation.py new file mode 100644 index 00000000..d10c3a5f --- /dev/null +++ b/tests/core/test_delivery_explanation.py @@ -0,0 +1,218 @@ +"""Offline display-only previews preserve ordinary submission contracts.""" +import argparse +import contextlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import session_peer as peer + + +THREAD = "01900000-0000-7000-8000-000000000001" +BODY = "PRIVATE-BODY-MUST-NOT-BE-DISPLAYED" + + +class DeliveryExplanation(unittest.TestCase): + def invoke(self, *flags, target="worker"): + out, err = io.StringIO(), io.StringIO() + argv = ["send", "--to", target, "--no-from", "--no-reply-to", + "--message", BODY, *flags] + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = peer.main(argv) + return code, out.getvalue(), err.getvalue() + + def test_invalid_combinations_fail_before_updates_input_or_dispatch(self): + variants = [[], ["--dry-run", "--json"], + ["--dry-run", "--output-format", "json"], + ["--dry-run", "--device", "fixture"], + ["--dry-run", "--correlation-id", THREAD], + ["--dry-run", "--request-ack"], + ["--dry-run", "--observe-delivery"], + ["--dry-run", "--wait-for", "acknowledged"], + ["--dry-run", "--_handoff-native-context", "private"], + ["--dry-run", "--_handoff-native-cutoff-ms", "1"], + ["--dry-run", "--_handoff-native-total-ms", "1"]] + for flags in variants: + with self.subTest(flags=flags), \ + mock.patch.object(peer, "prepare_client_update") as updates, \ + mock.patch.object(peer, "prepare_skill_updates") as skills, \ + mock.patch.object(peer, "read_message") as read, \ + mock.patch.object(peer, "cmd_send") as send, \ + mock.patch.object(peer.subprocess, "Popen") as child, \ + mock.patch.object(sys, "argv", ["session-peer", "send", "--to", "worker", + "--explain", *flags]), \ + contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as exc: + peer.main() + self.assertEqual(exc.exception.code, 2) + for action in (updates, skills, read, send, child): + action.assert_not_called() + + def test_claude_preview_reuses_dry_run_without_native_post_or_extra_discovery(self): + session = {"pid": 42, "name": "worker", "alive": True, + "reachable": True, "socket": "/fixture.sock"} + outputs = [] + for extra in ([], ["--explain"]): + with mock.patch.object(peer, "discover", return_value=[session]) as discover, \ + mock.patch.object(peer, "post_to_socket") as post, \ + mock.patch.object(peer.subprocess, "run") as run: + code, out, err = self.invoke("--dry-run", *extra) + self.assertEqual(code, 0) + self.assertEqual(err, "") + discover.assert_called_once_with(include_unreachable=True) + post.assert_not_called() + run.assert_not_called() + outputs.append(out) + self.assertTrue(outputs[1].startswith(outputs[0])) + self.assertIn("Resolved target: Claude PID 42 (worker)", outputs[1]) + self.assertIn("Wake: not requested; eligibility not checked", outputs[1]) + self.assertNotIn(BODY, outputs[1]) + + def test_existing_json_and_absent_claude_fields_are_unchanged(self): + payload = {"ok": True, "target": {"pid": 42, "name": None}, + "chars": 99, "dryRun": True} + before = json.dumps(payload, sort_keys=True) + with mock.patch.object(peer.LocalTransport, "execute", return_value=payload) as execute: + code, out, _ = self.invoke("--dry-run", "--json") + self.assertEqual(code, 0) + result = json.loads(out) + self.assertEqual(result, {"schemaVersion": 1, "ok": True, "host": peer.local_host(), + "command": "send", "target": {"pid": 42, "name": None}, + "chars": 99, "dryRun": True}) + self.assertEqual(json.dumps(payload, sort_keys=True), before) + self.assertNotIn("submitted", result) + self.assertNotIn("status", result) + execute.assert_called_once() + + def test_codex_reported_home_and_wake_use_only_native_result(self): + for wake, fact, expected in ( + (False, None, "not requested; eligibility not checked"), + (True, {"status": "validated", "cwd": "/hidden-cwd"}, + "preflight passed for this dry run; no turn started"), + (True, None, "requested; preflight not confirmed")): + with self.subTest(wake=wake, fact=fact): + payload = {"ok": True, "target": {"agent": "codex", "id": THREAD}, + "chars": 12, "dryRun": True, "status": "validated", "submitted": False, + "consumptionConfirmed": False, "codexHome": "/receiver/codex"} + if fact is not None: + payload["wake"] = fact + with mock.patch.object(peer, "queue_codex", return_value=payload) as queue: + code, out, _ = self.invoke("--dry-run", "--explain", + *(["--wake"] if wake else []), target="codex:" + THREAD) + self.assertEqual(code, 0) + queue.assert_called_once() + self.assertIn("Codex home: /receiver/codex", out) + self.assertIn("Wake: " + expected, out) + self.assertNotIn("/hidden-cwd", out) + + def test_antigravity_dry_run_needs_no_target_or_dry_run_fields(self): + payload = {"ok": True, "status": "dry_run", "submitted": False, + "consumptionConfirmed": False, "generation": "not-for-display", + "antigravityHome": "/receiver/agy"} + with mock.patch.object(peer.AntigravityAdapter, "submit", return_value=payload) as submit: + code, out, _ = self.invoke("--dry-run", "--explain", target="antigravity:" + THREAD) + self.assertEqual(code, 0) + submit.assert_called_once() + self.assertIn("registered Antigravity bridge", out) + self.assertIn("Antigravity home: /receiver/agy", out) + self.assertNotIn("not-for-display", out) + + def test_remote_preview_is_sender_only_and_does_not_guess_remote_home(self): + payload = {"ok": True, "target": {"agent": "codex", "id": THREAD}, + "chars": 12, "dryRun": True, "status": "validated", "submitted": False, + "consumptionConfirmed": False} + with mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport") as transport: + transport.return_value.host = "receiver" + transport.return_value.ssh_opts = [] + transport.return_value.execute.return_value = payload + code, out, _ = self.invoke("--host", "receiver", "--dry-run", "--explain", + "--codex-home", "/requested/home", target="codex:" + THREAD) + self.assertEqual(code, 0) + transport.assert_called_once() + transport.return_value.execute.assert_called_once() + argv = transport.return_value.execute.call_args.args[0] + self.assertNotIn("--explain", argv) + self.assertIn("--dry-run", argv) + self.assertIn("/requested/home", argv) + self.assertIn("Codex home: unknown (not reported)", out) + self.assertNotIn("Codex home: /requested/home", out) + + def test_multiple_hosts_keep_partial_failure_attribution_and_streams(self): + good = mock.Mock(host="first", ssh_opts=[]) + good.execute.return_value = {"target": {"pid": 42, "name": "worker"}, + "chars": 12, "dryRun": True} + bad = mock.Mock(host="second", ssh_opts=[]) + bad.execute.side_effect = peer.CcPeerError("fixture diagnostic", { + "status": "unknown", "submitted": None, "retryAllowed": False, + "error": BODY, "receipt": BODY}) + with mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport", side_effect=[good, bad]): + code, out, err = self.invoke("--host", "first", "--host", "second", + "--dry-run", "--explain") + self.assertEqual(code, peer.EXIT_ERROR) + self.assertIn("on first", out) + self.assertNotIn("on second", out) + self.assertIn("on second", err) + self.assertIn("session-peer: second: fixture diagnostic", err) + self.assertIn("Do not automatically resend after an unknown outcome.", err) + self.assertNotIn(BODY, out + err) + good.execute.assert_called_once() + bad.execute.assert_called_once() + + def test_local_failure_keeps_exit_and_sanitized_diagnostic(self): + error = peer.NoTargetError("no reachable session: fixture\x1b[31m") + with mock.patch.object(peer.LocalTransport, "execute", side_effect=error) as execute: + code, out, err = self.invoke("--dry-run", "--explain") + self.assertEqual(code, peer.EXIT_NO_TARGET) + self.assertEqual(out, "") + self.assertIn("Validation: not passed", err) + self.assertIn("fixture\\x1b[31m", err) + self.assertNotIn("\x1b", err) + execute.assert_called_once() + + def test_display_allowlist_does_not_mutate_or_reflect_payload_secrets(self): + args = argparse.Namespace(to="codex:" + THREAD, host=[], wake=False, + codex_home="/sender/home") + payload = {"codexHome": "/receiver/\n\x1b", "target": {"id": THREAD + "\r"}, + "error": BODY, "message": BODY, "receipt": BODY, + "handoff": {"capability": BODY}, "reason": BODY, + "replyRoute": {"uri": BODY}} + original = json.dumps(payload) + rendered = peer.render_delivery_explanation(args, payload, host="host\x00") + self.assertNotIn(BODY, rendered) + for char in ("\x1b", "\r", "\x00"): + self.assertNotIn(char, rendered) + self.assertIn("\\x0a\\x1b", rendered) + self.assertEqual(json.dumps(payload), original) + + def test_unresolved_reply_uri_is_not_reflected(self): + args = argparse.Namespace(to="session-peer://v1/reply?secret=" + BODY, + host=[], wake=False) + rendered = peer.render_delivery_explanation(args, {}, failed=True) + self.assertNotIn(BODY, rendered) + self.assertIn("Delivery method: unknown (not resolved)", rendered) + self.assertNotIn("Claude Code inbox", rendered) + + def test_private_message_file_remains_an_ordinary_supported_input(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "private-message" + path.write_text(BODY, encoding="utf-8") + path.chmod(0o600) + out = io.StringIO() + payload = {"ok": True, "target": {"pid": 42, "name": "worker"}, + "chars": 12, "dryRun": True} + with mock.patch.object(peer.LocalTransport, "execute", return_value=payload) as execute, \ + contextlib.redirect_stdout(out): + code = peer.main(["send", "--to", "worker", "--no-from", "--no-reply-to", + "--message-file", str(path), "--dry-run", "--explain"]) + self.assertEqual(code, 0) + self.assertEqual(execute.call_args.args[-1], BODY) + self.assertNotIn(BODY, out.getvalue()) + + +if __name__ == "__main__": + unittest.main() From 1a43baef6650f81d908c28a1c8e1c2a8c8eaa982 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 22 Oct 2026 16:01:16 +0900 Subject: [PATCH 2/6] fix(cli): preserve uncertainty in delivery explanations --- session_peer.py | 59 +++++++-- session_peer_core/cli.py | 8 +- session_peer_core/output.py | 38 ++++-- session_peer_core/sending.py | 13 +- tests/core/test_delivery_explanation.py | 159 ++++++++++++++++++++++-- 5 files changed, 244 insertions(+), 33 deletions(-) diff --git a/session_peer.py b/session_peer.py index 52ef54ff..6b53eb28 100644 --- a/session_peer.py +++ b/session_peer.py @@ -4321,6 +4321,12 @@ def render_sessions(sessions: list[dict], where: str) -> str: return f"Sessions on {where}:\n" + "\n".join(lines) +def delivery_explanation_has_submission(result: dict) -> bool: + """Recognize reported submission evidence without changing native results.""" + return (result.get("submitted") is True or result.get("dryRun") is False + or result.get("status") in ("queued", "posted", "submitted")) + + def render_delivery_explanation(args: argparse.Namespace, result: dict, *, host: str | None = None, failed: bool = False) -> str: """Describe only existing dry-run facts; never inspect or execute a target. @@ -4330,14 +4336,14 @@ def render_delivery_explanation(args: argparse.Namespace, result: dict, *, facts stay unknown rather than being filled from the sender's environment. """ requested = str(getattr(args, "to", "")) - unresolved_uri = requested.startswith("session-peer:") + unresolved_uri = requested.lower().startswith("session-peer:") if unresolved_uri: requested = "Reply-To URI (resolution unavailable)" agent = (None if unresolved_uri else "codex" if requested.startswith("codex:") else "antigravity" if requested.startswith("antigravity:") else "claude") where = host or (", ".join(getattr(args, "host", None) or []) or "this machine") - lines = ["Delivery preview — nothing submitted", + lines = ["Delivery preview", "Requested destination: " + human_text(requested) + " on " + human_text(where)] target = result.get("target") if isinstance(target, dict): @@ -4353,8 +4359,23 @@ def render_delivery_explanation(args: argparse.Namespace, result: dict, *, home = result.get(home_key) lines.append(("Codex home: " if agent == "codex" else "Antigravity home: ") + (human_text(home) if isinstance(home, str) else "unknown (not reported)")) - lines.append("Validation: " + ("not passed" if failed else - "passed for this dry run")) + started = getattr(args, "_delivery_explanation_started", True) + unknown = result.get("status") == "unknown" or ( + "submitted" in result and result["submitted"] is None) + unexpected = delivery_explanation_has_submission(result) + if not started: + lines.append("Destination checks: not run (command failed before destination checks)") + lines.append("Validation: not confirmed") + else: + lines.append("Validation: " + ("unexpected submission evidence; dry-run not confirmed" if unexpected else + "result unknown" if unknown else + "not confirmed" if failed else "passed for this dry run")) + if unexpected: + for key, label in (("dryRun", "dry-run"), ("submitted", "submitted")): + if type(result.get(key)) is bool: + lines.append("Reported " + label + ": " + str(result[key]).lower()) + if result.get("status") in ("queued", "posted", "submitted", "unknown"): + lines.append("Reported native status: " + result["status"]) method = {"claude": "Claude Code inbox", "codex": "native Codex queue", "antigravity": "registered Antigravity bridge"}.get(agent) lines.append("Delivery method: " + (method + " (planned)" if method else "unknown (not resolved)")) @@ -4363,15 +4384,16 @@ def render_delivery_explanation(args: argparse.Namespace, result: dict, *, else: fact = result.get("wake") wake = ("preflight passed for this dry run; no turn started" - if isinstance(fact, dict) and fact.get("status") == "validated" else + if not unknown and not unexpected and isinstance(fact, dict) and fact.get("status") == "validated" else "requested; preflight not confirmed") lines.extend(["Wake: " + wake, "Receipt ACK: unavailable for ordinary sends", "Consumption / completion: not observed", "A later send repeats validation.", "Do not automatically resend after an unknown outcome."]) - if failed: - lines.append("Next step: resolve the validation failure before attempting a send.") + if failed or unexpected: + lines.append("Next step: inspect the target and existing evidence before deciding what to do." + if unknown or unexpected else "Next step: resolve the validation failure before attempting a send.") return "\n".join(lines) @@ -4379,7 +4401,7 @@ def emit_delivery_explanation(args: argparse.Namespace, result: dict, *, host: str | None = None, failed: bool = False, stream=None) -> None: print(render_delivery_explanation(args, result, host=host, failed=failed), - file=stream or (sys.stderr if failed else sys.stdout)) + file=stream or (sys.stderr if failed or delivery_explanation_has_submission(result) else sys.stdout)) JSON_RESPONSE_SCHEMA_VERSION = 1 @@ -9420,6 +9442,8 @@ def cmd_send(args: argparse.Namespace) -> int: validate_agent_send(adapter, args, text) if not args.host: + if getattr(args, "explain", False): + args._delivery_explanation_started = True result = LocalTransport().execute("send", adapter, args, text) result.update(routing_metadata) emit(args.json, result, adapter.submission_text(result, "this machine"), command="send") @@ -9437,9 +9461,14 @@ def cmd_send(args: argparse.Namespace) -> int: for requested_host in args.host: host = requested_host try: + if getattr(args, "explain", False): + args._delivery_explanation_started = True + args._delivery_explanation_host = requested_host transport = SshTransport(requested_host, args, tailnet_status) host, ssh_opts = transport.host, transport.ssh_opts shown_host = display_host(requested_host, host) + if getattr(args, "explain", False): + args._delivery_explanation_host = shown_host encoded = base64.b64encode(text.encode("utf-8")).decode("ascii") remote_argv = ["send", "--no-update-notice", "--to", args.to, "--b64", encoded] remote_argv += adapter.remote_options(args) @@ -9468,8 +9497,10 @@ def cmd_send(args: argparse.Namespace) -> int: if not args.json: print(adapter.submission_text(payload, shown_host)) if getattr(args, "explain", False): - emit_delivery_explanation(args, payload, host=shown_host, - failed=payload.get("ok") is False) + # Explain the received facts, before legacy adapter shaping + # replaces fields such as Claude's dryRun with request data. + emit_delivery_explanation(args, result, host=shown_host, + failed=payload.get("ok") is False or result.get("ok") is False) except CcPeerError as exc: exit_code = EXIT_ERROR all_results.append(json_result( @@ -9853,6 +9884,8 @@ def main(argv: list[str] | None = None) -> int: if handoff_requested(args) or any(getattr(args, key, None) is not None for key in ( "_handoff_native_context", "_handoff_native_cutoff_ms", "_handoff_native_total_ms")): parser.error("--explain does not support experimental handoff sends") + args._delivery_explanation_started = False + args._delivery_explanation_host = None try: _CLIENT_UPDATE_NOTICE = prepare_client_update(args) if cli_invocation else None except Exception: @@ -9875,7 +9908,8 @@ def main(argv: list[str] | None = None) -> int: else: print(human_text(f"session-peer: {message}"), file=sys.stderr) if getattr(args, "explain", False): - emit_delivery_explanation(args, exc.details, failed=True, stream=sys.stderr) + emit_delivery_explanation(args, exc.details, failed=True, stream=sys.stderr, + host=getattr(args, "_delivery_explanation_host", None)) exit_code = ( EXIT_NO_TARGET if isinstance(exc, NoTargetError) or "no reachable session" in message @@ -9892,7 +9926,8 @@ def main(argv: list[str] | None = None) -> int: else: print(human_text(f"session-peer: {message}"), file=sys.stderr) if getattr(args, "explain", False): - emit_delivery_explanation(args, {}, failed=True, stream=sys.stderr) + emit_delivery_explanation(args, {}, failed=True, stream=sys.stderr, + host=getattr(args, "_delivery_explanation_host", None)) exit_code = EXIT_ERROR if show_human_notice and not args.json: emit_human_update_notice() diff --git a/session_peer_core/cli.py b/session_peer_core/cli.py index e0d9e42c..075ab574 100644 --- a/session_peer_core/cli.py +++ b/session_peer_core/cli.py @@ -281,6 +281,8 @@ def main(argv: list[str] | None = None) -> int: if handoff_requested(args) or any(getattr(args, key, None) is not None for key in ( "_handoff_native_context", "_handoff_native_cutoff_ms", "_handoff_native_total_ms")): parser.error("--explain does not support experimental handoff sends") + args._delivery_explanation_started = False + args._delivery_explanation_host = None try: _CLIENT_UPDATE_NOTICE = prepare_client_update(args) if cli_invocation else None except Exception: @@ -303,7 +305,8 @@ def main(argv: list[str] | None = None) -> int: else: print(human_text(f"session-peer: {message}"), file=sys.stderr) if getattr(args, "explain", False): - emit_delivery_explanation(args, exc.details, failed=True, stream=sys.stderr) + emit_delivery_explanation(args, exc.details, failed=True, stream=sys.stderr, + host=getattr(args, "_delivery_explanation_host", None)) exit_code = ( EXIT_NO_TARGET if isinstance(exc, NoTargetError) or "no reachable session" in message @@ -320,7 +323,8 @@ def main(argv: list[str] | None = None) -> int: else: print(human_text(f"session-peer: {message}"), file=sys.stderr) if getattr(args, "explain", False): - emit_delivery_explanation(args, {}, failed=True, stream=sys.stderr) + emit_delivery_explanation(args, {}, failed=True, stream=sys.stderr, + host=getattr(args, "_delivery_explanation_host", None)) exit_code = EXIT_ERROR if show_human_notice and not args.json: emit_human_update_notice() diff --git a/session_peer_core/output.py b/session_peer_core/output.py index 9b4ef6d8..d629ee1b 100644 --- a/session_peer_core/output.py +++ b/session_peer_core/output.py @@ -43,6 +43,12 @@ def render_sessions(sessions: list[dict], where: str) -> str: return f"Sessions on {where}:\n" + "\n".join(lines) +def delivery_explanation_has_submission(result: dict) -> bool: + """Recognize reported submission evidence without changing native results.""" + return (result.get("submitted") is True or result.get("dryRun") is False + or result.get("status") in ("queued", "posted", "submitted")) + + def render_delivery_explanation(args: argparse.Namespace, result: dict, *, host: str | None = None, failed: bool = False) -> str: """Describe only existing dry-run facts; never inspect or execute a target. @@ -52,14 +58,14 @@ def render_delivery_explanation(args: argparse.Namespace, result: dict, *, facts stay unknown rather than being filled from the sender's environment. """ requested = str(getattr(args, "to", "")) - unresolved_uri = requested.startswith("session-peer:") + unresolved_uri = requested.lower().startswith("session-peer:") if unresolved_uri: requested = "Reply-To URI (resolution unavailable)" agent = (None if unresolved_uri else "codex" if requested.startswith("codex:") else "antigravity" if requested.startswith("antigravity:") else "claude") where = host or (", ".join(getattr(args, "host", None) or []) or "this machine") - lines = ["Delivery preview — nothing submitted", + lines = ["Delivery preview", "Requested destination: " + human_text(requested) + " on " + human_text(where)] target = result.get("target") if isinstance(target, dict): @@ -75,8 +81,23 @@ def render_delivery_explanation(args: argparse.Namespace, result: dict, *, home = result.get(home_key) lines.append(("Codex home: " if agent == "codex" else "Antigravity home: ") + (human_text(home) if isinstance(home, str) else "unknown (not reported)")) - lines.append("Validation: " + ("not passed" if failed else - "passed for this dry run")) + started = getattr(args, "_delivery_explanation_started", True) + unknown = result.get("status") == "unknown" or ( + "submitted" in result and result["submitted"] is None) + unexpected = delivery_explanation_has_submission(result) + if not started: + lines.append("Destination checks: not run (command failed before destination checks)") + lines.append("Validation: not confirmed") + else: + lines.append("Validation: " + ("unexpected submission evidence; dry-run not confirmed" if unexpected else + "result unknown" if unknown else + "not confirmed" if failed else "passed for this dry run")) + if unexpected: + for key, label in (("dryRun", "dry-run"), ("submitted", "submitted")): + if type(result.get(key)) is bool: + lines.append("Reported " + label + ": " + str(result[key]).lower()) + if result.get("status") in ("queued", "posted", "submitted", "unknown"): + lines.append("Reported native status: " + result["status"]) method = {"claude": "Claude Code inbox", "codex": "native Codex queue", "antigravity": "registered Antigravity bridge"}.get(agent) lines.append("Delivery method: " + (method + " (planned)" if method else "unknown (not resolved)")) @@ -85,15 +106,16 @@ def render_delivery_explanation(args: argparse.Namespace, result: dict, *, else: fact = result.get("wake") wake = ("preflight passed for this dry run; no turn started" - if isinstance(fact, dict) and fact.get("status") == "validated" else + if not unknown and not unexpected and isinstance(fact, dict) and fact.get("status") == "validated" else "requested; preflight not confirmed") lines.extend(["Wake: " + wake, "Receipt ACK: unavailable for ordinary sends", "Consumption / completion: not observed", "A later send repeats validation.", "Do not automatically resend after an unknown outcome."]) - if failed: - lines.append("Next step: resolve the validation failure before attempting a send.") + if failed or unexpected: + lines.append("Next step: inspect the target and existing evidence before deciding what to do." + if unknown or unexpected else "Next step: resolve the validation failure before attempting a send.") return "\n".join(lines) @@ -101,7 +123,7 @@ def emit_delivery_explanation(args: argparse.Namespace, result: dict, *, host: str | None = None, failed: bool = False, stream=None) -> None: print(render_delivery_explanation(args, result, host=host, failed=failed), - file=stream or (sys.stderr if failed else sys.stdout)) + file=stream or (sys.stderr if failed or delivery_explanation_has_submission(result) else sys.stdout)) JSON_RESPONSE_SCHEMA_VERSION = 1 diff --git a/session_peer_core/sending.py b/session_peer_core/sending.py index 1e384c1a..7f9d3fa5 100644 --- a/session_peer_core/sending.py +++ b/session_peer_core/sending.py @@ -78,6 +78,8 @@ def cmd_send(args: argparse.Namespace) -> int: validate_agent_send(adapter, args, text) if not args.host: + if getattr(args, "explain", False): + args._delivery_explanation_started = True result = LocalTransport().execute("send", adapter, args, text) result.update(routing_metadata) emit(args.json, result, adapter.submission_text(result, "this machine"), command="send") @@ -95,9 +97,14 @@ def cmd_send(args: argparse.Namespace) -> int: for requested_host in args.host: host = requested_host try: + if getattr(args, "explain", False): + args._delivery_explanation_started = True + args._delivery_explanation_host = requested_host transport = SshTransport(requested_host, args, tailnet_status) host, ssh_opts = transport.host, transport.ssh_opts shown_host = display_host(requested_host, host) + if getattr(args, "explain", False): + args._delivery_explanation_host = shown_host encoded = base64.b64encode(text.encode("utf-8")).decode("ascii") remote_argv = ["send", "--no-update-notice", "--to", args.to, "--b64", encoded] remote_argv += adapter.remote_options(args) @@ -126,8 +133,10 @@ def cmd_send(args: argparse.Namespace) -> int: if not args.json: print(adapter.submission_text(payload, shown_host)) if getattr(args, "explain", False): - emit_delivery_explanation(args, payload, host=shown_host, - failed=payload.get("ok") is False) + # Explain the received facts, before legacy adapter shaping + # replaces fields such as Claude's dryRun with request data. + emit_delivery_explanation(args, result, host=shown_host, + failed=payload.get("ok") is False or result.get("ok") is False) except CcPeerError as exc: exit_code = EXIT_ERROR all_results.append(json_result( diff --git a/tests/core/test_delivery_explanation.py b/tests/core/test_delivery_explanation.py index d10c3a5f..12876280 100644 --- a/tests/core/test_delivery_explanation.py +++ b/tests/core/test_delivery_explanation.py @@ -29,7 +29,7 @@ def test_invalid_combinations_fail_before_updates_input_or_dispatch(self): variants = [[], ["--dry-run", "--json"], ["--dry-run", "--output-format", "json"], ["--dry-run", "--device", "fixture"], - ["--dry-run", "--correlation-id", THREAD], + ["--dry-run", "--correlation-id", "00000000-0000-4000-8000-000000000001"], ["--dry-run", "--request-ack"], ["--dry-run", "--observe-delivery"], ["--dry-run", "--wait-for", "acknowledged"], @@ -37,6 +37,7 @@ def test_invalid_combinations_fail_before_updates_input_or_dispatch(self): ["--dry-run", "--_handoff-native-cutoff-ms", "1"], ["--dry-run", "--_handoff-native-total-ms", "1"]] for flags in variants: + diagnostic = io.StringIO() with self.subTest(flags=flags), \ mock.patch.object(peer, "prepare_client_update") as updates, \ mock.patch.object(peer, "prepare_skill_updates") as skills, \ @@ -45,9 +46,14 @@ def test_invalid_combinations_fail_before_updates_input_or_dispatch(self): mock.patch.object(peer.subprocess, "Popen") as child, \ mock.patch.object(sys, "argv", ["session-peer", "send", "--to", "worker", "--explain", *flags]), \ - contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as exc: + contextlib.redirect_stderr(diagnostic), self.assertRaises(SystemExit) as exc: peer.main() self.assertEqual(exc.exception.code, 2) + expected = ("--explain requires --dry-run" if not flags else + "--explain requires text output" if "--json" in flags or "--output-format" in flags else + "--explain does not support paired-device sends" if "--device" in flags else + "--explain does not support experimental handoff sends") + self.assertIn("session-peer: error: " + expected, diagnostic.getvalue()) for action in (updates, skills, read, send, child): action.assert_not_called() @@ -169,11 +175,129 @@ def test_local_failure_keeps_exit_and_sanitized_diagnostic(self): code, out, err = self.invoke("--dry-run", "--explain") self.assertEqual(code, peer.EXIT_NO_TARGET) self.assertEqual(out, "") - self.assertIn("Validation: not passed", err) + self.assertIn("Validation: not confirmed", err) self.assertIn("fixture\\x1b[31m", err) self.assertNotIn("\x1b", err) execute.assert_called_once() + def test_unknown_local_and_ssh_exceptions_preserve_uncertainty(self): + for remote in (False, True): + with self.subTest(remote=remote), contextlib.ExitStack() as stack: + error = peer.CcPeerError("fixture unknown outcome", { + "status": "unknown", "submitted": None, "retryAllowed": False}) + stack.enter_context(mock.patch.object(peer, "tailscale_status", return_value={})) + if remote: + transport = stack.enter_context(mock.patch.object(peer, "SshTransport")) + transport.return_value.host = "receiver" + transport.return_value.ssh_opts = [] + execute = transport.return_value.execute + execute.side_effect = error + else: + execute = stack.enter_context(mock.patch.object( + peer.LocalTransport, "execute", side_effect=error)) + code, out, err = self.invoke("--dry-run", "--explain", + *(["--host", "receiver"] if remote else [])) + self.assertEqual(code, peer.EXIT_ERROR) + self.assertEqual(out, "") + self.assertIn("Validation: result unknown", err) + self.assertIn("inspect the target and existing evidence", err) + self.assertIn("Do not automatically resend", err) + self.assertNotIn("nothing submitted", err) + self.assertNotIn("Destination checks: not run", err) + execute.assert_called_once() + + def test_returned_failed_payload_keeps_old_stdout_and_explanation_on_stderr(self): + for remote in (False, True): + for submitted in (None, False): + outputs = [] + for explain in (False, True): + with self.subTest(remote=remote, submitted=submitted, explain=explain), \ + contextlib.ExitStack() as stack: + payload = {"ok": False, "target": {"agent": "codex", "id": THREAD}, + "dryRun": True, "chars": 12, "status": "unknown", + "submitted": submitted, "consumptionConfirmed": False} + stack.enter_context(mock.patch.object(peer, "tailscale_status", return_value={})) + if remote: + transport = stack.enter_context(mock.patch.object(peer, "SshTransport")) + transport.return_value.host = "receiver" + transport.return_value.ssh_opts = [] + execute = transport.return_value.execute + execute.return_value = payload + else: + execute = stack.enter_context(mock.patch.object( + peer.LocalTransport, "execute", return_value=payload)) + code, out, err = self.invoke("--dry-run", *(["--explain"] if explain else []), + *(["--host", "receiver"] if remote else []), + target="codex:" + THREAD) + self.assertEqual(code, peer.EXIT_ERROR) + execute.assert_called_once() + outputs.append(out) + if explain: + self.assertIn("Delivery preview\n", err) + self.assertIn("Validation: result unknown", err) + self.assertNotIn("nothing submitted", err) + else: + self.assertEqual(err, "") + self.assertEqual(outputs[0], outputs[1]) + + def test_common_multihost_failures_do_not_claim_destination_checks(self): + for stage in ("read_message", "validate_ssh_identity_options"): + with self.subTest(stage=stage), \ + mock.patch.object(peer, stage, side_effect=peer.CcPeerError("common command failure")), \ + mock.patch.object(peer, "SshTransport") as transport, \ + mock.patch.object(peer.LocalTransport, "execute") as local: + code, out, err = self.invoke("--host", "first", "--host", "second", + "--dry-run", "--explain") + self.assertEqual(code, peer.EXIT_ERROR) + self.assertEqual(out, "") + self.assertEqual(err.count("Delivery preview\n"), 1) + self.assertIn("Destination checks: not run", err) + self.assertIn("Validation: not confirmed", err) + self.assertNotIn("passed for this dry run", err) + transport.assert_not_called() + local.assert_not_called() + + def test_second_host_unexpected_error_names_only_current_destination(self): + good = mock.Mock(host="first", ssh_opts=[]) + good.execute.return_value = {"target": {"pid": 42, "name": "worker"}, + "chars": 12, "dryRun": True} + bad = mock.Mock(host="second", ssh_opts=[]) + bad.execute.side_effect = OSError("fixture transport error") + with mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport", side_effect=[good, bad]): + code, out, err = self.invoke("--host", "first", "--host", "second", + "--dry-run", "--explain") + self.assertEqual(code, peer.EXIT_ERROR) + self.assertIn("on first", out) + self.assertIn("on second", err) + self.assertNotIn("first", err) + self.assertNotIn("Destination checks: not run", err) + good.execute.assert_called_once() + bad.execute.assert_called_once() + + def test_remote_submission_evidence_is_not_presented_as_validated_dry_run(self): + for agent in ("claude", "codex"): + for evidence in ({"submitted": True}, {"dryRun": False}, + {"status": "queued"}, {"status": "posted"}): + with self.subTest(agent=agent, evidence=evidence): + payload = {"ok": True, "target": {"pid": 42, "name": "worker"} + if agent == "claude" else {"agent": "codex", "id": THREAD}, + "chars": 12, "dryRun": True, **evidence} + with mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport") as transport: + transport.return_value.host = "receiver" + transport.return_value.ssh_opts = [] + transport.return_value.execute.return_value = payload + code, out, err = self.invoke("--host", "receiver", "--dry-run", "--explain", + target="worker" if agent == "claude" else "codex:" + THREAD) + self.assertEqual(code, 0) + self.assertNotIn("passed for this dry run", out + err) + self.assertIn("unexpected submission evidence; dry-run not confirmed", err) + self.assertIn("Reported ", err) + self.assertIn("Do not automatically resend", err) + self.assertNotIn("nothing submitted", out + err) + transport.return_value.execute.assert_called_once() + def test_display_allowlist_does_not_mutate_or_reflect_payload_secrets(self): args = argparse.Namespace(to="codex:" + THREAD, host=[], wake=False, codex_home="/sender/home") @@ -189,13 +313,30 @@ def test_display_allowlist_does_not_mutate_or_reflect_payload_secrets(self): self.assertIn("\\x0a\\x1b", rendered) self.assertEqual(json.dumps(payload), original) + def test_ok_true_does_not_promote_unknown_evidence_to_validation(self): + args = argparse.Namespace(to="codex:" + THREAD, host=[], wake=False) + for evidence in ({"status": "unknown"}, {"submitted": None}): + rendered = peer.render_delivery_explanation(args, {"ok": True, **evidence}) + self.assertIn("Validation: result unknown", rendered) + self.assertNotIn("passed for this dry run", rendered) + + def test_ambiguous_or_submitted_response_does_not_claim_no_turn_started(self): + args = argparse.Namespace(to="codex:" + THREAD, host=[], wake=True) + for evidence in ({"status": "unknown"}, {"submitted": None}, + {"submitted": True}, {"dryRun": False}, {"status": "queued"}): + rendered = peer.render_delivery_explanation(args, { + "ok": True, "wake": {"status": "validated"}, **evidence}) + self.assertIn("Wake: requested; preflight not confirmed", rendered) + self.assertNotIn("no turn started", rendered) + def test_unresolved_reply_uri_is_not_reflected(self): - args = argparse.Namespace(to="session-peer://v1/reply?secret=" + BODY, - host=[], wake=False) - rendered = peer.render_delivery_explanation(args, {}, failed=True) - self.assertNotIn(BODY, rendered) - self.assertIn("Delivery method: unknown (not resolved)", rendered) - self.assertNotIn("Claude Code inbox", rendered) + for scheme in ("session-peer", "SeSsIoN-PeEr"): + args = argparse.Namespace(to=scheme + "://v1/reply?secret=" + BODY, + host=[], wake=False) + rendered = peer.render_delivery_explanation(args, {}, failed=True) + self.assertNotIn(BODY, rendered) + self.assertIn("Delivery method: unknown (not resolved)", rendered) + self.assertNotIn("Claude Code inbox", rendered) def test_private_message_file_remains_an_ordinary_supported_input(self): with tempfile.TemporaryDirectory() as temporary: From 03e9891092e1727e638d352d8952151969baa1b2 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 22 Oct 2026 16:07:55 +0900 Subject: [PATCH 3/6] fix(cli): recognize unknown adapter outcomes in previews --- docs/cli-reference.md | 4 +-- docs/i18n-manifest.json | 2 +- docs/ja/cli-reference.md | 4 +-- docs/ko/cli-reference.md | 4 +-- docs/zh-CN/cli-reference.md | 4 +-- session_peer.py | 12 ++++++-- session_peer_core/cli.py | 4 ++- session_peer_core/output.py | 8 ++++-- tests/core/test_delivery_explanation.py | 38 +++++++++++++++++++++++++ 9 files changed, 65 insertions(+), 15 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 037cd10b..3edef353 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -64,7 +64,7 @@ is opt-in and does not confirm consumption or a reply. ### Delivery explanation (development) -Development for #323; unavailable in published v1.2.1. Add `--explain` to `send --dry-run` for a human-readable summary of the selected local or SSH destination, existing validation result, delivery method and observation limits. Multiple SSH destinations receive separate summaries. +Development for #323; unavailable in published v1.2.1. Add `--explain` to `send --dry-run` for a human-readable summary of the selected local or SSH destination, existing validation result, delivery method and observation limits. Each processed SSH destination receives its own summary; an unexpected error can still stop the existing host loop before later destinations are processed. ```bash session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain @@ -74,7 +74,7 @@ The explanation reuses the current dry-run result and adds no diagnostic, native This first scope is text-only and requires `--dry-run`. Combining `--explain` with `--json` or `--output-format json`, paired `--device`, or handoff `--correlation-id`, `--request-ack`, `--observe-delivery` or `--wait-for` is a usage error before message input or destination/ledger work. Existing commands without the new option keep their output, JSON and exit contracts. -The normal message requirement and limits still apply: supply `--message`, `--message-file` or stdin as usual; no placeholder body is generated. Explanation fields do not include the body or receipt secrets. Unknown evidence stays unknown, and an uncertain actual send must never trigger automatic resend. +The normal message requirement and limits still apply: supply `--message`, `--message-file` or stdin as usual; no placeholder body is generated. Explanation fields do not include the body or receipt secrets. Normal explanations use stdout; failed results or unexpected submission evidence use stderr, without moving existing summary/error output. Unknown evidence stays unknown, and an uncertain actual send must never trigger automatic resend. ### Optional paired devices and Antigravity (v0.9) diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index c9efe420..152daa73 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "4dc4ae4f9bfe0039b1375f7a765c4932dae92d007764be6c70d2f14125a8d4f5", + "docs/cli-reference.md": "8a3001e70aea9a811a6910af11957c6d0ff8a62b31930be3e3bf0f6ea411936a", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 82ec8915..53747068 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -97,7 +97,7 @@ session-peer send --host worker --to 'codex:' --dry-run -m "me ### 配信説明(開発中) -#323 の開発機能であり、公開済み v1.2.1 には含まれません。`send --dry-run` に `--explain` を追加すると、選択したローカルまたは SSH の宛先、既存の検証結果、配信方法、観測上の制限を読みやすく表示します。SSH の宛先が複数ある場合は個別に説明します。 +#323 の開発機能であり、公開済み v1.2.1 には含まれません。`send --dry-run` に `--explain` を追加すると、選択したローカルまたは SSH の宛先、既存の検証結果、配信方法、観測上の制限を読みやすく表示します。処理した SSH の宛先ごとに説明を表示しますが、予期しないエラーが発生すると既存のホスト処理が中断され、後続の宛先が処理されない場合があります。 ```bash session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain @@ -107,7 +107,7 @@ session-peer send --host worker --to 'codex:' --message "Revie 最初の対応範囲はテキスト出力のみで、`--dry-run` が必須です。`--explain` と `--json` または `--output-format json`、ペアリングの `--device`、handoff の `--correlation-id`、`--request-ack`、`--observe-delivery`、`--wait-for` を組み合わせると、メッセージ入力や宛先・台帳処理の前に使用法エラーになります。新しいオプションを使わない既存コマンドの出力・JSON・終了契約は維持されます。 -通常のメッセージ入力要件と上限は変わりません。従来どおり `--message`、`--message-file` または stdin を使い、仮の本文は自動生成しません。説明フィールドに本文や receipt の秘密値は含めません。不明な証拠は不明のまま表示し、実際の送信結果が不確実な場合は自動再送しません。 +通常のメッセージ入力要件と上限は変わりません。従来どおり `--message`、`--message-file` または stdin を使い、仮の本文は自動生成しません。説明フィールドに本文や receipt の秘密値は含めません。通常の説明は stdout に、失敗した結果や予期しない投入の証拠に関する説明は stderr に出力し、既存の要約・エラーの出力先は変えません。不明な証拠は不明のまま表示し、実際の送信結果が不確実な場合は自動再送しません。 ### オプションのペアリング済みデバイスと Antigravity (v0.9) diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index 9a6a97e9..a2bf3562 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -98,7 +98,7 @@ session-peer send --host worker --to 'codex:' --dry-run -m "me ### 전달 설명 (개발 중) -#323의 개발 기능이며 공개 v1.2.1에는 없습니다. `send --dry-run`에 `--explain`을 추가하면 선택한 로컬 또는 SSH 대상, 기존 검증 결과, 전달 방식과 관찰 한계를 사람이 읽기 쉬운 형태로 표시합니다. SSH 대상이 여러 개이면 각각 설명합니다. +#323의 개발 기능이며 공개 v1.2.1에는 없습니다. `send --dry-run`에 `--explain`을 추가하면 선택한 로컬 또는 SSH 대상, 기존 검증 결과, 전달 방식과 관찰 한계를 사람이 읽기 쉬운 형태로 표시합니다. 처리한 SSH 대상마다 개별 설명을 표시하지만, 예상치 못한 오류가 나면 기존 호스트 반복 처리가 중단되어 이후 대상은 처리하지 않을 수 있습니다. ```bash session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain @@ -108,7 +108,7 @@ session-peer send --host worker --to 'codex:' --message "Revie 첫 지원 범위는 텍스트 출력 전용이며 `--dry-run`이 필수입니다. `--explain`을 `--json` 또는 `--output-format json`, 페어링 `--device`, handoff `--correlation-id`, `--request-ack`, `--observe-delivery`, `--wait-for`와 함께 지정하면 메시지 입력이나 대상·저널 작업 전에 사용법 오류로 거부합니다. 새 옵션을 사용하지 않은 기존 명령의 출력·JSON·종료 계약은 유지됩니다. -메시지 입력 요구사항과 한도는 동일합니다. 평소처럼 `--message`, `--message-file` 또는 stdin을 사용하며 임시 본문을 자동 생성하지 않습니다. 설명 필드에는 본문이나 receipt 비밀값을 넣지 않습니다. 확인할 수 없는 증거는 그대로 알 수 없음으로 표시하고, 실제 전송 결과가 불확실하면 자동 재전송하지 않습니다. +메시지 입력 요구사항과 한도는 동일합니다. 평소처럼 `--message`, `--message-file` 또는 stdin을 사용하며 임시 본문을 자동 생성하지 않습니다. 설명 필드에는 본문이나 receipt 비밀값을 넣지 않습니다. 일반 설명은 stdout에, 실패 결과나 예상 밖 제출 증거의 설명은 stderr에 표시하며 기존 요약·오류 출력의 위치는 바꾸지 않습니다. 확인할 수 없는 증거는 그대로 알 수 없음으로 표시하고, 실제 전송 결과가 불확실하면 자동 재전송하지 않습니다. ### 페어링된 기기 옵션 및 Antigravity (v0.9) diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index f3a97023..cebda5b0 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -76,7 +76,7 @@ session-peer send --host worker --to 'codex:' --dry-run -m "me ### 传递说明(开发中) -这是 #323 的开发功能,尚未包含在已发布的 v1.2.1 中。在 `send --dry-run` 中添加 `--explain`,即可查看所选本地或 SSH 目标、现有验证结果、传递方式及观察限制。多个 SSH 目标会分别显示说明。 +这是 #323 的开发功能,尚未包含在已发布的 v1.2.1 中。在 `send --dry-run` 中添加 `--explain`,即可查看所选本地或 SSH 目标、现有验证结果、传递方式及观察限制。每个已处理的 SSH 目标都会单独显示说明;意外错误仍可能中断现有的主机循环,导致后续目标未被处理。 ```bash session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain @@ -86,7 +86,7 @@ session-peer send --host worker --to 'codex:' --message "Revie 首期范围仅支持文本输出,且必须指定 `--dry-run`。将 `--explain` 与 `--json` 或 `--output-format json`、配对 `--device`,或 handoff 的 `--correlation-id`、`--request-ack`、`--observe-delivery`、`--wait-for` 组合使用时,会在读取消息或执行目标、账本操作前报告用法错误。不使用新选项的现有命令保留原有输出、JSON 和退出契约。 -消息输入要求和限制保持不变。按原有方式使用 `--message`、`--message-file` 或 stdin,不自动生成占位正文。说明字段不包含正文或 receipt 秘密值。未知证据仍显示为未知,实际发送结果不确定时禁止自动重发。 +消息输入要求和限制保持不变。按原有方式使用 `--message`、`--message-file` 或 stdin,不自动生成占位正文。说明字段不包含正文或 receipt 秘密值。常规说明输出到 stdout,失败结果或意外提交证据的说明输出到 stderr,不改变现有摘要和错误的输出位置。未知证据仍显示为未知,实际发送结果不确定时禁止自动重发。 ### 可选配对设备与 Antigravity (v0.9) diff --git a/session_peer.py b/session_peer.py index 6b53eb28..5bdc36a6 100644 --- a/session_peer.py +++ b/session_peer.py @@ -4360,16 +4360,20 @@ def render_delivery_explanation(args: argparse.Namespace, result: dict, *, lines.append(("Codex home: " if agent == "codex" else "Antigravity home: ") + (human_text(home) if isinstance(home, str) else "unknown (not reported)")) started = getattr(args, "_delivery_explanation_started", True) - unknown = result.get("status") == "unknown" or ( + unknown = result.get("status") == "unknown" or result.get("reason") in ( + "outcome_unknown", "ssh_identity_evidence_unavailable") or ( "submitted" in result and result["submitted"] is None) unexpected = delivery_explanation_has_submission(result) + dry_run_reported = result.get("dryRun") is True or result.get("status") in ("dry_run", "validated") if not started: lines.append("Destination checks: not run (command failed before destination checks)") lines.append("Validation: not confirmed") else: lines.append("Validation: " + ("unexpected submission evidence; dry-run not confirmed" if unexpected else "result unknown" if unknown else - "not confirmed" if failed else "passed for this dry run")) + "not confirmed" if failed else + "passed for this dry run" if dry_run_reported else + "not confirmed (dry-run evidence not reported)")) if unexpected: for key, label in (("dryRun", "dry-run"), ("submitted", "submitted")): if type(result.get(key)) is bool: @@ -9926,7 +9930,9 @@ def main(argv: list[str] | None = None) -> int: else: print(human_text(f"session-peer: {message}"), file=sys.stderr) if getattr(args, "explain", False): - emit_delivery_explanation(args, {}, failed=True, stream=sys.stderr, + explanation = ({"reason": "outcome_unknown"} + if getattr(args, "_delivery_explanation_started", False) else {}) + emit_delivery_explanation(args, explanation, failed=True, stream=sys.stderr, host=getattr(args, "_delivery_explanation_host", None)) exit_code = EXIT_ERROR if show_human_notice and not args.json: diff --git a/session_peer_core/cli.py b/session_peer_core/cli.py index 075ab574..1e5f43f0 100644 --- a/session_peer_core/cli.py +++ b/session_peer_core/cli.py @@ -323,7 +323,9 @@ def main(argv: list[str] | None = None) -> int: else: print(human_text(f"session-peer: {message}"), file=sys.stderr) if getattr(args, "explain", False): - emit_delivery_explanation(args, {}, failed=True, stream=sys.stderr, + explanation = ({"reason": "outcome_unknown"} + if getattr(args, "_delivery_explanation_started", False) else {}) + emit_delivery_explanation(args, explanation, failed=True, stream=sys.stderr, host=getattr(args, "_delivery_explanation_host", None)) exit_code = EXIT_ERROR if show_human_notice and not args.json: diff --git a/session_peer_core/output.py b/session_peer_core/output.py index d629ee1b..ec54e123 100644 --- a/session_peer_core/output.py +++ b/session_peer_core/output.py @@ -82,16 +82,20 @@ def render_delivery_explanation(args: argparse.Namespace, result: dict, *, lines.append(("Codex home: " if agent == "codex" else "Antigravity home: ") + (human_text(home) if isinstance(home, str) else "unknown (not reported)")) started = getattr(args, "_delivery_explanation_started", True) - unknown = result.get("status") == "unknown" or ( + unknown = result.get("status") == "unknown" or result.get("reason") in ( + "outcome_unknown", "ssh_identity_evidence_unavailable") or ( "submitted" in result and result["submitted"] is None) unexpected = delivery_explanation_has_submission(result) + dry_run_reported = result.get("dryRun") is True or result.get("status") in ("dry_run", "validated") if not started: lines.append("Destination checks: not run (command failed before destination checks)") lines.append("Validation: not confirmed") else: lines.append("Validation: " + ("unexpected submission evidence; dry-run not confirmed" if unexpected else "result unknown" if unknown else - "not confirmed" if failed else "passed for this dry run")) + "not confirmed" if failed else + "passed for this dry run" if dry_run_reported else + "not confirmed (dry-run evidence not reported)")) if unexpected: for key, label in (("dryRun", "dry-run"), ("submitted", "submitted")): if type(result.get(key)) is bool: diff --git a/tests/core/test_delivery_explanation.py b/tests/core/test_delivery_explanation.py index 12876280..11879a89 100644 --- a/tests/core/test_delivery_explanation.py +++ b/tests/core/test_delivery_explanation.py @@ -206,6 +206,20 @@ def test_unknown_local_and_ssh_exceptions_preserve_uncertainty(self): self.assertNotIn("Destination checks: not run", err) execute.assert_called_once() + def test_local_transport_invalid_result_and_adapter_exception_stay_unknown(self): + for kwargs in ({"return_value": None}, {"side_effect": RuntimeError(BODY)}): + with self.subTest(case=tuple(kwargs)), \ + mock.patch.object(peer.ClaudeAdapter, "submit", **kwargs) as submit: + code, out, err = self.invoke("--dry-run", "--explain") + self.assertEqual(code, peer.EXIT_ERROR) + self.assertEqual(out, "") + self.assertIn("Validation: result unknown", err) + self.assertIn("inspect the target and existing evidence", err) + self.assertIn("Do not automatically resend", err) + self.assertNotIn(BODY, err) + self.assertNotIn("nothing submitted", err) + submit.assert_called_once() + def test_returned_failed_payload_keeps_old_stdout_and_explanation_on_stderr(self): for remote in (False, True): for submitted in (None, False): @@ -272,6 +286,8 @@ def test_second_host_unexpected_error_names_only_current_destination(self): self.assertIn("on second", err) self.assertNotIn("first", err) self.assertNotIn("Destination checks: not run", err) + self.assertIn("Validation: result unknown", err) + self.assertIn("inspect the target and existing evidence", err) good.execute.assert_called_once() bad.execute.assert_called_once() @@ -320,6 +336,28 @@ def test_ok_true_does_not_promote_unknown_evidence_to_validation(self): self.assertIn("Validation: result unknown", rendered) self.assertNotIn("passed for this dry run", rendered) + def test_connection_identity_failure_reason_preserves_uncertainty(self): + args = argparse.Namespace(to="worker", host=["receiver"], wake=False) + rendered = peer.render_delivery_explanation(args, { + "reason": "ssh_identity_evidence_unavailable", "dryRun": True}, failed=True) + self.assertIn("Validation: result unknown", rendered) + self.assertIn("inspect the target and existing evidence", rendered) + self.assertNotIn("nothing submitted", rendered) + + def test_passed_validation_requires_adapter_dry_run_evidence(self): + args = argparse.Namespace(to="worker", host=[], wake=False) + for result, passed in (({"ok": True}, False), ({"ok": True, "submitted": False}, False), + ({"ok": True, "dryRun": True}, True), + ({"ok": True, "status": "validated"}, True), + ({"ok": True, "status": "dry_run"}, True)): + with self.subTest(result=result): + rendered = peer.render_delivery_explanation(args, result) + if passed: + self.assertIn("Validation: passed for this dry run", rendered) + else: + self.assertIn("Validation: not confirmed (dry-run evidence not reported)", rendered) + self.assertNotIn("passed for this dry run", rendered) + def test_ambiguous_or_submitted_response_does_not_claim_no_turn_started(self): args = argparse.Namespace(to="codex:" + THREAD, host=[], wake=True) for evidence in ({"status": "unknown"}, {"submitted": None}, From 8762df7f61195c59e184356fb8a71c9f97c7600b Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 22 Oct 2026 16:14:03 +0900 Subject: [PATCH 4/6] test(cli): preserve Windows private-file refusal in previews --- tests/core/test_delivery_explanation.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/core/test_delivery_explanation.py b/tests/core/test_delivery_explanation.py index 11879a89..eebccb9b 100644 --- a/tests/core/test_delivery_explanation.py +++ b/tests/core/test_delivery_explanation.py @@ -376,6 +376,7 @@ def test_unresolved_reply_uri_is_not_reflected(self): self.assertIn("Delivery method: unknown (not resolved)", rendered) self.assertNotIn("Claude Code inbox", rendered) + @unittest.skipIf(peer.IS_WINDOWS, "private POSIX message-file input only") def test_private_message_file_remains_an_ordinary_supported_input(self): with tempfile.TemporaryDirectory() as temporary: path = Path(temporary) / "private-message" @@ -392,6 +393,27 @@ def test_private_message_file_remains_an_ordinary_supported_input(self): self.assertEqual(execute.call_args.args[-1], BODY) self.assertNotIn(BODY, out.getvalue()) + @unittest.skipUnless(peer.IS_WINDOWS, "Windows private message-file refusal") + def test_windows_private_message_file_refusal_remains_before_destination_checks(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "private-message" + path.write_text(BODY, encoding="utf-8") + out, err = io.StringIO(), io.StringIO() + with mock.patch.object(peer.LocalTransport, "execute") as execute, \ + mock.patch.object(peer, "SshTransport") as ssh, \ + mock.patch.object(peer.subprocess, "Popen") as child, \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = peer.main(["send", "--to", "worker", "--no-from", "--no-reply-to", + "--message-file", str(path), "--dry-run", "--explain"]) + self.assertEqual(code, peer.EXIT_ERROR) + self.assertEqual(out.getvalue(), "") + self.assertIn("handoff_platform_unsupported", err.getvalue()) + self.assertIn("Destination checks: not run", err.getvalue()) + self.assertNotIn(BODY, err.getvalue()) + execute.assert_not_called() + ssh.assert_not_called() + child.assert_not_called() + if __name__ == "__main__": unittest.main() From 3302f66e185352e68e4c22a0519e28b0e6713f4c Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 22 Oct 2026 16:15:43 +0900 Subject: [PATCH 5/6] test(cli): assert Windows refusal without changing text output --- tests/core/test_delivery_explanation.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/core/test_delivery_explanation.py b/tests/core/test_delivery_explanation.py index eebccb9b..1bcc8976 100644 --- a/tests/core/test_delivery_explanation.py +++ b/tests/core/test_delivery_explanation.py @@ -393,21 +393,25 @@ def test_private_message_file_remains_an_ordinary_supported_input(self): self.assertEqual(execute.call_args.args[-1], BODY) self.assertNotIn(BODY, out.getvalue()) - @unittest.skipUnless(peer.IS_WINDOWS, "Windows private message-file refusal") def test_windows_private_message_file_refusal_remains_before_destination_checks(self): with tempfile.TemporaryDirectory() as temporary: path = Path(temporary) / "private-message" path.write_text(BODY, encoding="utf-8") out, err = io.StringIO(), io.StringIO() - with mock.patch.object(peer.LocalTransport, "execute") as execute, \ + with mock.patch.object(peer, "IS_WINDOWS", True), \ + mock.patch.object(peer.LocalTransport, "execute") as execute, \ mock.patch.object(peer, "SshTransport") as ssh, \ mock.patch.object(peer.subprocess, "Popen") as child, \ contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + with self.assertRaises(peer.CcPeerError) as refusal: + peer.handoff_private_stat(path) + self.assertEqual(refusal.exception.details["reason"], "handoff_platform_unsupported") code = peer.main(["send", "--to", "worker", "--no-from", "--no-reply-to", "--message-file", str(path), "--dry-run", "--explain"]) self.assertEqual(code, peer.EXIT_ERROR) self.assertEqual(out.getvalue(), "") - self.assertIn("handoff_platform_unsupported", err.getvalue()) + self.assertIn("session-peer: Handoff operation refused", err.getvalue()) + self.assertNotIn("handoff_platform_unsupported", err.getvalue()) self.assertIn("Destination checks: not run", err.getvalue()) self.assertNotIn(BODY, err.getvalue()) execute.assert_not_called() From 5507298a699cc9462500b830e28120e121de7cbb Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 23 Oct 2026 11:16:30 +0900 Subject: [PATCH 6/6] feat(cli): explain paired readiness, diagnostics and stored handoff evidence --- docs/cli-reference.md | 15 +- docs/i18n-manifest.json | 2 +- docs/ja/cli-reference.md | 15 +- docs/ko/cli-reference.md | 15 +- docs/zh-CN/cli-reference.md | 15 +- session_peer.py | 347 ++++++++++++++++++++--- session_peer_core/cli.py | 22 +- session_peer_core/commands.py | 15 + session_peer_core/explanation.py | 171 +++++++++++ session_peer_core/handoff.py | 81 +++++- session_peer_core/output.py | 56 +++- session_peer_relay/app.py | 23 +- session_peer_relay/cli.py | 16 +- session_peer_relay/framing.py | 47 ++- session_peer_relay/native.py | 5 +- session_peer_relay/worker.py | 36 ++- tests/core/test_delivery_explanation.py | 2 - tests/core/test_explanation_context.py | 167 +++++++++++ tests/core/test_explanation_modes.py | 137 +++++++++ tests/relay/test_delivery_explanation.py | 210 ++++++++++++++ tools/generate_session_peer.py | 1 + 21 files changed, 1303 insertions(+), 95 deletions(-) create mode 100644 session_peer_core/explanation.py create mode 100644 tests/core/test_explanation_context.py create mode 100644 tests/core/test_explanation_modes.py create mode 100644 tests/relay/test_delivery_explanation.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3edef353..75c4ba8b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -64,17 +64,24 @@ is opt-in and does not confirm consumption or a reply. ### Delivery explanation (development) -Development for #323; unavailable in published v1.2.1. Add `--explain` to `send --dry-run` for a human-readable summary of the selected local or SSH destination, existing validation result, delivery method and observation limits. Each processed SSH destination receives its own summary; an unexpected error can still stop the existing host loop before later destinations are processed. +Development for #323, a v1.3 candidate; unavailable in published v1.2.1. Add `--explain` to `send --dry-run` for a human-readable summary of the selected local, SSH or paired-device destination, existing validation result, delivery method and observation limits. `doctor --explain` describes the existing diagnostic snapshot and declared capabilities, not per-target readiness. `handoff status --explain` describes an existing intent's stored evidence. Each processed SSH destination receives its own summary; an unexpected error can still stop the existing host loop before later destinations are processed. ```bash session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain +session-peer send --device RECEIVER_FINGERPRINT --to review --message "Review the patch" --dry-run --explain +session-peer doctor --explain +session-peer handoff status --correlation-id PREPARED_UUID --explain ``` -The explanation reuses the current dry-run result and adds no diagnostic, native-version or SSH probes. The dry-run itself may already run version checks. A later send repeats validation; a successful preview does not reserve the destination or guarantee submission, consumption or completion. Without `--wake`, wake eligibility is not checked. With it, only the existing preflight result is described. Ordinary-send receipt ACKs remain unavailable; the separate experimental handoff workflow has different capabilities. +The explanation reuses the selected command's existing results and adds no diagnostic, native-version or SSH probes, target activation or standalone `list` work. The dry-run itself may already run version checks. A later send repeats validation; a successful preview does not reserve the destination or guarantee submission, consumption or completion. Declared support, observed checks and unknown facts are shown separately. Without `--wake`, wake eligibility is not checked. With it, only the existing preflight result is described. -This first scope is text-only and requires `--dry-run`. Combining `--explain` with `--json` or `--output-format json`, paired `--device`, or handoff `--correlation-id`, `--request-ack`, `--observe-delivery` or `--wait-for` is a usage error before message input or destination/ledger work. Existing commands without the new option keep their output, JSON and exit contracts. +Paired dry-run keeps the existing receiver-policy `resolve` operation. The existing connection probe negotiates optional explanation context only with a receiver advertising support. This internal context is versioned, allowlisted and limited to 8 KiB; it adds no general JSON output or handoff schema. The selected receiver agent, target and home are shown only when reported and validated, never guessed from the sender's profile. Older receivers retain their independently known native results while unreported context stays unknown; no fallback, extra probe or resend is attempted. -The normal message requirement and limits still apply: supply `--message`, `--message-file` or stdin as usual; no placeholder body is generated. Explanation fields do not include the body or receipt secrets. Normal explanations use stdout; failed results or unexpected submission evidence use stderr, without moving existing summary/error output. Unknown evidence stays unknown, and an uncertain actual send must never trigger automatic resend. +Handoff status reuses its read-only snapshot without creating an intent, receipt capability, wait or collector, or writing the ledger. Submission, injection observation, explicit receipt and latest stored wait remain separate; even an acknowledged receipt does not prove task completion. Stored observations may be stale. Only a reported receipt observation time is described as such; a description's generation time or diagnostic snapshot is not a delivery observation time. Ordinary sends do not gain receipt ACK support. + +This scope is text-only: combining `--explain` with `--json` or `--output-format json` is a usage error. Only `send --explain` requires `--dry-run`; `doctor --explain` and `handoff status --explain` do not. Send still refuses experimental handoff `--correlation-id`, `--request-ack`, `--observe-delivery` and `--wait-for` with `--explain`, before message input or destination/ledger work. Existing commands without the new option keep their output, JSON and exit contracts; explanation does not turn a successful status query into proof of ACK or change the underlying command's exit meaning. + +For send, the normal message requirement and limits still apply: supply `--message`, `--message-file` or stdin as usual; no placeholder body is generated. Explanation fields do not include the body or receipt secrets. Normal explanations use stdout; failed results or unexpected submission evidence use stderr, without moving existing summary/error output. Unknown evidence stays unknown, and an uncertain actual send must never trigger automatic resend. ### Optional paired devices and Antigravity (v0.9) diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index 152daa73..7354936b 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "8a3001e70aea9a811a6910af11957c6d0ff8a62b31930be3e3bf0f6ea411936a", + "docs/cli-reference.md": "73616e06cc221b9c701327f1872c713ba12d7813042cf07fd644994b493d9a1a", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 53747068..b9d35e58 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -97,17 +97,24 @@ session-peer send --host worker --to 'codex:' --dry-run -m "me ### 配信説明(開発中) -#323 の開発機能であり、公開済み v1.2.1 には含まれません。`send --dry-run` に `--explain` を追加すると、選択したローカルまたは SSH の宛先、既存の検証結果、配信方法、観測上の制限を読みやすく表示します。処理した SSH の宛先ごとに説明を表示しますが、予期しないエラーが発生すると既存のホスト処理が中断され、後続の宛先が処理されない場合があります。 +#323 の開発機能で、v1.3 の候補です。公開済み v1.2.1 には含まれません。`send --dry-run` に `--explain` を追加すると、選択したローカル・SSH・ペアリング済みデバイスの宛先、既存の検証結果、配信方法、観測上の制限を読みやすく表示します。`doctor --explain` は既存の診断スナップショットと宣言された対応機能を説明するもので、個々の宛先の送信準備状態を確認するものではありません。`handoff status --explain` は既存の intent に保存された証拠を説明します。処理した SSH の宛先ごとに説明を表示しますが、予期しないエラーが発生すると既存のホスト処理が中断され、後続の宛先が処理されない場合があります。 ```bash session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain +session-peer send --device RECEIVER_FINGERPRINT --to review --message "Review the patch" --dry-run --explain +session-peer doctor --explain +session-peer handoff status --correlation-id PREPARED_UUID --explain ``` -説明は現在の dry-run の結果を再利用し、診断、native バージョン、SSH の追加照会は行いません。既存の dry-run 自体にはバージョン確認が含まれる場合があります。実際の送信時には再検証するため、プレビューの成功は宛先の予約や投入・消費・完了を保証しません。`--wake` を指定しない場合、起動の適格性は確認しません。指定した場合も既存の事前検証結果だけを説明します。通常送信の receipt ACK は未対応で、別の実験的 handoff とは機能範囲が異なります。 +説明は選択したコマンドの既存の結果を再利用し、診断、native バージョン、SSH の追加照会、宛先の起動、別途の `list` 処理は行いません。既存の dry-run 自体にはバージョン確認が含まれる場合があります。実際の送信時には再検証するため、プレビューの成功は宛先の予約や投入・消費・完了を保証しません。宣言された対応機能、実際の検証結果、不明な事実を区別して表示します。`--wake` を指定しない場合、起動の適格性は確認しません。指定した場合も既存の事前検証結果だけを説明します。 -最初の対応範囲はテキスト出力のみで、`--dry-run` が必須です。`--explain` と `--json` または `--output-format json`、ペアリングの `--device`、handoff の `--correlation-id`、`--request-ack`、`--observe-delivery`、`--wait-for` を組み合わせると、メッセージ入力や宛先・台帳処理の前に使用法エラーになります。新しいオプションを使わない既存コマンドの出力・JSON・終了契約は維持されます。 +ペアリングの dry-run は、受信側ポリシーに従う既存の `resolve` 処理を維持します。既存の接続確認で対応を通知した受信側とのみ、任意の説明 context をネゴシエートします。この内部 context にはバージョンが付けられ、許可されたフィールドのみが認められます。サイズの上限は 8 KiB です。一般の JSON 出力や handoff スキーマは追加しません。選択された受信側のエージェント・宛先・home は、報告され検証された場合だけ表示し、送信側のプロファイルから推測しません。旧バージョンの受信側でも、独立に確認された native の結果は維持し、報告されない context は不明のまま扱います。代替経路への切り替え、追加照会、再送は行いません。 -通常のメッセージ入力要件と上限は変わりません。従来どおり `--message`、`--message-file` または stdin を使い、仮の本文は自動生成しません。説明フィールドに本文や receipt の秘密値は含めません。通常の説明は stdout に、失敗した結果や予期しない投入の証拠に関する説明は stderr に出力し、既存の要約・エラーの出力先は変えません。不明な証拠は不明のまま表示し、実際の送信結果が不確実な場合は自動再送しません。 +Handoff status は読み取り専用スナップショットを再利用し、intent・receipt capability・wait・collector の作成や台帳への書き込みは行いません。投入、入力注入の観測、明示的な receipt、最後に保存された wait は別々に扱います。Receipt が acknowledged でも、タスクの完了を証明するものではありません。保存された観測は古い場合があります。報告された receipt の観測時刻だけを観測時刻として説明し、説明の生成時刻や診断スナップショットの時刻を配信の観測時刻として扱いません。通常送信に receipt ACK の対応が追加されるわけではありません。 + +この範囲はテキスト出力のみで、`--explain` と `--json` または `--output-format json` の併用は使用法エラーになります。`--dry-run` が必要なのは `send --explain` だけで、`doctor --explain` と `handoff status --explain` には不要です。Send では引き続き、実験的 handoff の `--correlation-id`、`--request-ack`、`--observe-delivery`、`--wait-for` と `--explain` の組み合わせを、メッセージ入力や宛先・台帳処理の前に拒否します。新しいオプションを使わない既存コマンドの出力・JSON・終了契約は維持されます。説明によって、状態照会の成功が ACK の証拠に変わったり、元のコマンドの終了結果の意味が変わったりすることはありません。 + +Send の通常のメッセージ入力要件と上限は変わりません。従来どおり `--message`、`--message-file` または stdin を使い、仮の本文は自動生成しません。説明フィールドに本文や receipt の秘密値は含めません。通常の説明は stdout に、失敗した結果や予期しない投入の証拠に関する説明は stderr に出力し、既存の要約・エラーの出力先は変えません。不明な証拠は不明のまま表示し、実際の送信結果が不確実な場合は自動再送しません。 ### オプションのペアリング済みデバイスと Antigravity (v0.9) diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index a2bf3562..28a702d2 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -98,17 +98,24 @@ session-peer send --host worker --to 'codex:' --dry-run -m "me ### 전달 설명 (개발 중) -#323의 개발 기능이며 공개 v1.2.1에는 없습니다. `send --dry-run`에 `--explain`을 추가하면 선택한 로컬 또는 SSH 대상, 기존 검증 결과, 전달 방식과 관찰 한계를 사람이 읽기 쉬운 형태로 표시합니다. 처리한 SSH 대상마다 개별 설명을 표시하지만, 예상치 못한 오류가 나면 기존 호스트 반복 처리가 중단되어 이후 대상은 처리하지 않을 수 있습니다. +#323의 개발 기능으로 v1.3 후보이며, 공개 v1.2.1에는 없습니다. `send --dry-run`에 `--explain`을 추가하면 선택한 로컬·SSH·페어링 기기 대상, 기존 검증 결과, 전달 방식과 관찰 한계를 사람이 읽기 쉬운 형태로 표시합니다. `doctor --explain`은 기존 진단 스냅샷과 선언된 지원 기능을 설명하며, 특정 대상의 전송 준비 상태를 확인하지는 않습니다. `handoff status --explain`은 기존 intent에 저장된 증거를 설명합니다. 처리한 SSH 대상마다 개별 설명을 표시하지만, 예상치 못한 오류가 나면 기존 호스트 반복 처리가 중단되어 이후 대상은 처리하지 않을 수 있습니다. ```bash session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain +session-peer send --device RECEIVER_FINGERPRINT --to review --message "Review the patch" --dry-run --explain +session-peer doctor --explain +session-peer handoff status --correlation-id PREPARED_UUID --explain ``` -설명은 현재 dry-run 결과를 재사용하며 진단·native 버전·SSH 조회를 추가하지 않습니다. 기존 dry-run 자체에는 버전 검사가 포함될 수 있습니다. 실제 전송 시에는 다시 검증하며, 미리보기 성공이 대상을 예약하거나 제출·소비·완료를 보장하지는 않습니다. `--wake`를 지정하지 않으면 깨우기 가능 여부를 검사하지 않습니다. 지정한 경우에도 기존 사전 검사 결과만 설명합니다. 일반 전송의 receipt ACK는 지원하지 않으며, 별도의 실험적 handoff는 지원 범위가 다릅니다. +설명은 선택한 명령의 기존 결과를 재사용하며 진단·native 버전·SSH 조회, 대상 활성화나 별도의 `list` 작업을 추가하지 않습니다. 기존 dry-run 자체에는 버전 검사가 포함될 수 있습니다. 실제 전송 시에는 다시 검증하며, 미리보기 성공이 대상을 예약하거나 제출·소비·완료를 보장하지는 않습니다. 선언된 지원 기능, 실제 검사 결과와 확인할 수 없는 사실을 구분해 표시합니다. `--wake`를 지정하지 않으면 깨우기 가능 여부를 검사하지 않습니다. 지정한 경우에도 기존 사전 검사 결과만 설명합니다. -첫 지원 범위는 텍스트 출력 전용이며 `--dry-run`이 필수입니다. `--explain`을 `--json` 또는 `--output-format json`, 페어링 `--device`, handoff `--correlation-id`, `--request-ack`, `--observe-delivery`, `--wait-for`와 함께 지정하면 메시지 입력이나 대상·저널 작업 전에 사용법 오류로 거부합니다. 새 옵션을 사용하지 않은 기존 명령의 출력·JSON·종료 계약은 유지됩니다. +페어링 dry-run은 기존 수신 기기 정책에 따른 `resolve` 작업을 유지합니다. 기존 연결 확인 과정에서 지원을 알리는 수신 기기와만 선택적 설명 context를 협상합니다. 이 내부 context에는 버전과 허용 필드 검증을 적용하며 크기를 8 KiB로 제한합니다. 일반 JSON 출력이나 handoff 스키마는 추가하지 않습니다. 선택한 수신 측 에이전트·대상·home은 보고되고 검증된 경우에만 표시하며 송신 측 프로필에서 추측하지 않습니다. 구형 수신 기기의 독립적으로 확인된 native 결과는 유지하고, 보고되지 않은 context는 알 수 없음으로 표시합니다. 대체 경로 시도, 추가 조회나 재전송은 하지 않습니다. -메시지 입력 요구사항과 한도는 동일합니다. 평소처럼 `--message`, `--message-file` 또는 stdin을 사용하며 임시 본문을 자동 생성하지 않습니다. 설명 필드에는 본문이나 receipt 비밀값을 넣지 않습니다. 일반 설명은 stdout에, 실패 결과나 예상 밖 제출 증거의 설명은 stderr에 표시하며 기존 요약·오류 출력의 위치는 바꾸지 않습니다. 확인할 수 없는 증거는 그대로 알 수 없음으로 표시하고, 실제 전송 결과가 불확실하면 자동 재전송하지 않습니다. +Handoff status는 읽기 전용 스냅샷을 재사용하며 intent·receipt capability·wait·collector를 만들거나 ledger에 기록하지 않습니다. 제출, 입력 주입 관찰, 명시적 receipt와 최근 저장된 wait는 구분합니다. Receipt가 acknowledged여도 작업 완료를 증명하지는 않습니다. 저장된 관찰은 오래된 정보일 수 있습니다. 보고된 receipt 관찰 시각만 관찰 시각으로 설명하며, 설명 생성 시각이나 진단 스냅샷 시각을 전달 관찰 시각으로 해석하지 않습니다. 일반 전송에 receipt ACK 지원이 추가되지는 않습니다. + +이 범위는 텍스트 출력 전용이므로 `--explain`을 `--json` 또는 `--output-format json`과 함께 지정하면 사용법 오류가 발생합니다. `--dry-run`은 `send --explain`에만 필수이며 `doctor --explain`과 `handoff status --explain`에는 필요하지 않습니다. Send에서는 실험적 handoff 옵션인 `--correlation-id`, `--request-ack`, `--observe-delivery`, `--wait-for`와 `--explain`의 조합을 메시지 입력이나 대상·ledger 작업 전에 계속 거부합니다. 새 옵션을 사용하지 않은 기존 명령의 출력·JSON·종료 계약은 유지됩니다. 설명은 성공한 상태 조회를 ACK 증거로 바꾸거나 원래 명령의 종료 의미를 바꾸지 않습니다. + +Send의 메시지 입력 요구사항과 한도는 동일합니다. 평소처럼 `--message`, `--message-file` 또는 stdin을 사용하며 임시 본문을 자동 생성하지 않습니다. 설명 필드에는 본문이나 receipt 비밀값을 넣지 않습니다. 일반 설명은 stdout에, 실패 결과나 예상 밖 제출 증거의 설명은 stderr에 표시하며 기존 요약·오류 출력의 위치는 바꾸지 않습니다. 확인할 수 없는 증거는 그대로 알 수 없음으로 표시하고, 실제 전송 결과가 불확실하면 자동 재전송하지 않습니다. ### 페어링된 기기 옵션 및 Antigravity (v0.9) diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index cebda5b0..22977102 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -76,17 +76,24 @@ session-peer send --host worker --to 'codex:' --dry-run -m "me ### 传递说明(开发中) -这是 #323 的开发功能,尚未包含在已发布的 v1.2.1 中。在 `send --dry-run` 中添加 `--explain`,即可查看所选本地或 SSH 目标、现有验证结果、传递方式及观察限制。每个已处理的 SSH 目标都会单独显示说明;意外错误仍可能中断现有的主机循环,导致后续目标未被处理。 +这是 #323 的开发功能,拟纳入 v1.3,尚未包含在已发布的 v1.2.1 中。在 `send --dry-run` 中添加 `--explain`,即可查看所选本地、SSH 或配对设备目标、现有验证结果、传递方式及观察限制。`doctor --explain` 说明现有诊断快照和声明的支持能力,不验证特定目标是否已准备好接收提交。`handoff status --explain` 说明现有 intent 中保存的证据。每个已处理的 SSH 目标都会单独显示说明;意外错误仍可能中断现有的主机循环,导致后续目标未被处理。 ```bash session-peer send --host worker --to 'codex:' --message "Review the patch" --dry-run --explain +session-peer send --device RECEIVER_FINGERPRINT --to review --message "Review the patch" --dry-run --explain +session-peer doctor --explain +session-peer handoff status --correlation-id PREPARED_UUID --explain ``` -说明复用当前 dry-run 的结果,不增加诊断、native 版本或 SSH 探测。原有 dry-run 本身可能包含版本检查。实际发送时会重新验证,因此预览成功不代表预留目标,也不保证提交、消费或完成。未指定 `--wake` 时,不检查唤醒资格;指定后也只说明原有预检结果。普通发送不支持 receipt ACK,独立的实验性 handoff 具有不同的能力范围。 +说明复用所选命令的现有结果,不增加诊断、native 版本或 SSH 探测,也不激活目标或另行执行 `list`。原有 dry-run 本身可能包含版本检查。实际发送时会重新验证,因此预览成功不代表预留目标,也不保证提交、消费或完成。声明的支持能力、实际检查结果和未知事实会分别显示。未指定 `--wake` 时,不检查唤醒资格;指定后也只说明原有预检结果。 -首期范围仅支持文本输出,且必须指定 `--dry-run`。将 `--explain` 与 `--json` 或 `--output-format json`、配对 `--device`,或 handoff 的 `--correlation-id`、`--request-ack`、`--observe-delivery`、`--wait-for` 组合使用时,会在读取消息或执行目标、账本操作前报告用法错误。不使用新选项的现有命令保留原有输出、JSON 和退出契约。 +配对 dry-run 保留现有的、遵循接收端策略的 `resolve` 操作。现有连接探测只会与声明支持该功能的接收端协商可选的说明 context。此内部 context 带有版本,严格校验允许的字段,大小限制为 8 KiB;不会新增通用 JSON 输出,也不引入新的 handoff schema。只有接收端实际报告且通过验证的代理、目标和 home 才会显示,绝不根据发送端配置猜测。旧接收端仍保留独立确认的 native 结果,未报告的 context 保持未知,不尝试回退、额外探测或重发。 -消息输入要求和限制保持不变。按原有方式使用 `--message`、`--message-file` 或 stdin,不自动生成占位正文。说明字段不包含正文或 receipt 秘密值。常规说明输出到 stdout,失败结果或意外提交证据的说明输出到 stderr,不改变现有摘要和错误的输出位置。未知证据仍显示为未知,实际发送结果不确定时禁止自动重发。 +Handoff status 复用只读快照,不创建 intent、receipt capability、wait 或 collector,也不写入账本。提交、输入注入观察、显式 receipt 和最近保存的 wait 分别显示;即使 receipt 已 acknowledged,也不证明任务完成。保存的观察可能已经过时。只有已报告的 receipt 观察时间才会作为观察时间说明;说明生成时间或诊断快照时间不代表传递观察时间。普通发送不会因此获得 receipt ACK 支持。 + +此范围仅支持文本输出:将 `--explain` 与 `--json` 或 `--output-format json` 组合使用会报告用法错误。只有 `send --explain` 必须指定 `--dry-run`,`doctor --explain` 和 `handoff status --explain` 不需要。Send 仍会在读取消息或执行目标、账本操作前,拒绝将实验性 handoff 的 `--correlation-id`、`--request-ack`、`--observe-delivery`、`--wait-for` 与 `--explain` 组合使用。不使用新选项的现有命令保留原有输出、JSON 和退出契约;说明不会将状态查询成功变成 ACK 证据,也不会改变原命令退出结果的含义。 + +Send 的消息输入要求和限制保持不变。按原有方式使用 `--message`、`--message-file` 或 stdin,不自动生成占位正文。说明字段不包含正文或 receipt 秘密值。常规说明输出到 stdout,失败结果或意外提交证据的说明输出到 stderr,不改变现有摘要和错误的输出位置。未知证据仍显示为未知,实际发送结果不确定时禁止自动重发。 ### 可选配对设备与 Antigravity (v0.9) diff --git a/session_peer.py b/session_peer.py index 5bdc36a6..b4698936 100644 --- a/session_peer.py +++ b/session_peer.py @@ -4276,6 +4276,179 @@ def run_remote(host: str, argv: list[str], ssh_opts: list[str], *, handoff_budge return _run_remote_dispatch(host, argv, ssh_opts) +# Display-only explanation facts. No discovery, transport, ledger or clock I/O. + + +def validated_delivery_context(value): + """Accept the complete private receiver context, never guessed fragments.""" + if not isinstance(value, dict) or set(value) != { + "schemaVersion", "agent", "target", "home", "capabilities"}: + return None + if type(value["schemaVersion"]) is not int or value["schemaVersion"] != 1: + return None + if value["agent"] not in ("claude", "codex", "antigravity"): + return None + for key in ("target", "home"): + item = value[key] + if item is not None: + if (not isinstance(item, str) or not item or item.lower().startswith("session-peer:") + or any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in item)): + return None + try: + if len(item.encode("utf-8")) > 4096: + return None + except UnicodeError: + return None + if value["target"] is not None: + pattern = (r"claude:[1-9][0-9]*" if value["agent"] == "claude" else + value["agent"] + r":[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}") + if re.fullmatch(pattern, value["target"]) is None: + return None + if value["home"] is not None and not (PurePosixPath(value["home"]).is_absolute() + or PureWindowsPath(value["home"]).is_absolute()): + return None + caps = value["capabilities"] + if not isinstance(caps, dict) or set(caps) != {"send", "wake", "wait", "ack"}: + return None + if any(type(item) is not bool for item in caps.values()): + return None + if len(json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) > 8192: + return None + return {**value, "capabilities": dict(caps)} + + +def explanation_text(value): + """Sanitize only opt-in explanations, preserving ordinary output behavior.""" + text = human_text(value) + return "".join("\\u%04x" % ord(c) if ord(c) in ( + 0x200b, 0x200c, 0x200d, 0x200e, 0x200f, 0x202a, 0x202b, + 0x202c, 0x202d, 0x202e, 0x2060, 0x2066, 0x2067, 0x2068, 0x2069, 0xfeff) + else c for c in text) + + +def build_delivery_explanation(args, result, *, mode="readiness", failed=False): + """Normalize only allowlisted facts already supplied by the caller. + + Declaration never proves eligibility, and a snapshot never proves delivery. + The returned object is internal and is not a new JSON/wire contract. + """ + if mode not in ("readiness", "doctor", "existing_intent"): + raise ValueError("unsupported delivery explanation mode") + paired = bool(getattr(args, "device", None)) + context = validated_delivery_context(getattr(args, "_delivery_explanation_context", None)) if paired else None + requested = str(getattr(args, "to", "") or "") + uri = requested.lower().startswith("session-peer:") + agent = (context["agent"] if context else None) if paired else ( + None if uri or not requested else "codex" if requested.startswith("codex:") else + "antigravity" if requested.startswith("antigravity:") else "claude") + caps = context["capabilities"] if context else None + if not paired and agent is not None: + caps = {key: getattr(AGENTS.get(agent).capabilities, key) + for key in ("send", "wake", "wait", "ack")} + home = context["home"] if context else None + if not paired and agent in ("codex", "antigravity"): + value = result.get("codexHome" if agent == "codex" else "antigravityHome") + home = value if isinstance(value, str) else None + facts = {"mode": mode, "paired": paired, "agent": agent, + "target": context["target"] if context else None, "home": home, + "capabilities": caps, "agents": [], "handoff": None, + "selectedRoute": result.get("route") if paired and result.get("route") in ("direct", "relay") else None, + "available": not failed, "historyContext": None} + if mode == "doctor": + declared = result.get("capabilities", {}) + declared = declared.get("agents", {}) if isinstance(declared, dict) else {} + for name in ("claude", "codex", "antigravity"): + diagnostic = result.get(name, {}) + status = diagnostic.get("status") if isinstance(diagnostic, dict) else None + allowed_status = {"available", "unavailable", "missing_home", "permission_denied", + "wrong_home", "missing_tool", "unknown", "disabled", "unsupported", "inbox_unavailable"} + declaration = declared.get(name, {}) if isinstance(declared, dict) else {} + home = diagnostic.get("selectedHome") if isinstance(diagnostic, dict) else None + facts["agents"].append({"agent": name, "status": status if isinstance(status, str) and status in allowed_status else "unknown", + "home": home if isinstance(home, str) else None, + "capabilities": {key: declaration[key] if isinstance(declaration, dict) + and type(declaration.get(key)) is bool else None + for key in ("send", "wake", "wait", "ack")}}) + elif mode == "existing_intent": + query = result.get("handoffQuery") + if isinstance(query, dict) and query.get("context") in ( + "ledger_missing", "ledger_corrupt", "id_unknown", "history_unavailable"): + facts["historyContext"] = query["context"] + public = result.get("handoff") + if isinstance(public, dict): + snapshot = {} + allowed = {"submission": {"not_attempted", "submitted", "refused", "unknown"}, + "observation": {"unsupported", "not_requested", "pending", "observed", "failed"}, + "ack": {"pending", "unsupported", "not_requested", "acknowledged"}, + "wait": {"not_requested", "pending", "satisfied", "timed_out_unknown", "unsupported", "stopped", "failed"}} + for key, statuses in allowed.items(): + value = public.get(key) + status = value.get("status") if isinstance(value, dict) else None + snapshot[key] = status if isinstance(status, str) and status in statuses else "unknown" + ack = public.get("ack", {}) + observed = ack.get("receivedAtUtcMs") if isinstance(ack, dict) else None + snapshot["receivedAtUtcMs"] = observed if type(observed) is int and 0 <= observed <= 9007199254740991 else None + snapshot["late"] = ack.get("late") if isinstance(ack, dict) and type(ack.get("late")) is bool else None + assurance = ack.get("assurance") if isinstance(ack, dict) else None + snapshot["assurance"] = assurance if assurance in ("token_possession", "operator_confirmed") else None + if snapshot["ack"] != "acknowledged": + snapshot.update(receivedAtUtcMs=None, late=None, assurance=None) + wait = public.get("wait", {}) + goal = wait.get("for") if isinstance(wait, dict) else None + snapshot["waitFor"] = goal if goal in ("none", "delivered", "acknowledged") else None + reason = wait.get("reason") if isinstance(wait, dict) else None + snapshot["waitReason"] = reason if reason in ("insufficient_budget", "deadline_before_effect", "evidence_unsupported", "evidence_failed", "history_unavailable", "stopped_by_operator", "invalid_handoff") else None + facts["handoff"] = snapshot + return facts + + +def render_delivery_fact_lines(facts): + def capability(value): + return "supported (declared; eligibility unverified)" if value is True else "unsupported (declared)" if value is False else "unknown (not reported)" + if facts["mode"] == "doctor": + lines = ["Delivery capabilities (existing diagnostic snapshot)" if facts["available"] else + "Delivery capabilities unavailable (diagnostic snapshot not received)"] + for item in facts["agents"]: + basis = "diagnostic report" if item["status"] != "unknown" else "not reported" + lines.append(item["agent"] + " availability: " + item["status"] + " (" + basis + "; not per-target readiness)") + if item["agent"] in ("codex", "antigravity"): + lines.append(" Selected home: " + (explanation_text(item["home"]) if item["home"] else "unknown (not reported)")) + for key, value in item["capabilities"].items(): + lines.append(" " + key.capitalize() + ": " + capability(value)) + return lines + ["Injection observation: unsupported", "Task completion: unknown", + "Last delivery observation: unknown (not reported)", + "A diagnostic snapshot does not guarantee later delivery."] + if facts["mode"] == "existing_intent": + value = facts["handoff"] or {} + lines = ["Delivery evidence (existing intent snapshot)" if facts["available"] else + "Delivery evidence unavailable (history not read)"] + if facts["historyContext"]: + lines.append("History query: " + facts["historyContext"] + "; no history initialized or repaired.") + for key, label in (("submission", "Submission"), ("observation", "Injection observation"), + ("ack", "Explicit receipt"), ("wait", "Latest stored wait")): + lines.append(label + ": " + value.get(key, "unknown") + (" (stored observation)" if facts["available"] else " (not available)")) + observed = value.get("receivedAtUtcMs") + lines.append("Receipt recorded at (stored UTC milliseconds): " + (str(observed) if observed is not None else "unknown (not reported)")) + if value.get("assurance"): + lines.append("Receipt assurance: " + value["assurance"] + "; does not prove task completion") + if value["assurance"] == "token_possession": + lines.append("Receipt proves token possession, not original agent/model identity.") + if value.get("waitFor"): + lines.append("Latest stored wait goal: " + value["waitFor"]) + if value.get("waitReason"): + lines.append("Latest stored wait reason: " + value["waitReason"]) + if value.get("late") is not None: + lines.append("Receipt late relative to first wait: " + str(value["late"]).lower()) + return lines + ["Task completion: unknown", "Stored evidence may be stale; this view does not refresh delivery.", + "Do not automatically resend after an unknown outcome."] + caps = facts["capabilities"] or {} + return ["Submission capability: " + capability(caps.get("send")), + "Wake capability: " + capability(caps.get("wake")), + "Injection observation: unsupported" if not facts["paired"] else "Injection observation: unknown (not reported)", + "Last delivery observation: unknown (not reported)", + "Task completion: unknown"] + + # -------------------------------------------------------------------------- # Output # -------------------------------------------------------------------------- @@ -4328,37 +4501,46 @@ def delivery_explanation_has_submission(result: dict) -> bool: def render_delivery_explanation(args: argparse.Namespace, result: dict, *, - host: str | None = None, failed: bool = False) -> str: + host: str | None = None, failed: bool = False, + mode: str = "readiness") -> str: """Describe only existing dry-run facts; never inspect or execute a target. This display-only allowlist deliberately excludes message bodies, native errors, credentials, routing URIs and receipt authority. Missing remote facts stay unknown rather than being filled from the sender's environment. """ + facts = build_delivery_explanation(args, result, mode=mode, failed=failed) + if mode != "readiness": + lines = render_delivery_fact_lines(facts) + if host: + lines.insert(1, "Destination host: " + explanation_text(host)) + return "\n".join(lines) requested = str(getattr(args, "to", "")) unresolved_uri = requested.lower().startswith("session-peer:") if unresolved_uri: requested = "Reply-To URI (resolution unavailable)" - agent = (None if unresolved_uri else - "codex" if requested.startswith("codex:") else - "antigravity" if requested.startswith("antigravity:") else "claude") - where = host or (", ".join(getattr(args, "host", None) or []) or "this machine") + agent = facts["agent"] + if facts["paired"]: + requested = facts["target"] or "paired target (not reported by receiver)" + where = host or (("device:" + str(args.device)) if facts["paired"] else + (", ".join(getattr(args, "host", None) or []) or "this machine")) lines = ["Delivery preview", - "Requested destination: " + human_text(requested) + " on " + human_text(where)] - target = result.get("target") + "Requested destination: " + explanation_text(requested) + " on " + explanation_text(where)] + if facts["paired"]: + lines.append("Selected paired route: " + (facts["selectedRoute"] + " (reported)" if facts["selectedRoute"] else "unknown (not reported)")) + target = None if facts["paired"] else result.get("target") if isinstance(target, dict): if agent == "claude" and type(target.get("pid")) is int: resolved = "Claude PID " + str(target["pid"]) if isinstance(target.get("name"), str): - resolved += " (" + human_text(target["name"]) + ")" + resolved += " (" + explanation_text(target["name"]) + ")" lines.append("Resolved target: " + resolved) elif agent == "codex" and isinstance(target.get("id"), str): - lines.append("Resolved target: codex:" + human_text(target["id"])) - home_key = "codexHome" if agent == "codex" else "antigravityHome" + lines.append("Resolved target: codex:" + explanation_text(target["id"])) if agent in ("codex", "antigravity"): - home = result.get(home_key) + home = facts["home"] lines.append(("Codex home: " if agent == "codex" else "Antigravity home: ") - + (human_text(home) if isinstance(home, str) else "unknown (not reported)")) + + (explanation_text(home) if isinstance(home, str) else "unknown (not reported)")) started = getattr(args, "_delivery_explanation_started", True) unknown = result.get("status") == "unknown" or result.get("reason") in ( "outcome_unknown", "ssh_identity_evidence_unavailable") or ( @@ -4391,20 +4573,37 @@ def render_delivery_explanation(args: argparse.Namespace, result: dict, *, if not unknown and not unexpected and isinstance(fact, dict) and fact.get("status") == "validated" else "requested; preflight not confirmed") lines.extend(["Wake: " + wake, - "Receipt ACK: unavailable for ordinary sends", + ("Receipt ACK: unknown (receiver support not reported)" if facts["paired"] + and facts["capabilities"] is None else "Receipt ACK: unavailable for ordinary sends"), "Consumption / completion: not observed", "A later send repeats validation.", "Do not automatically resend after an unknown outcome."]) if failed or unexpected: lines.append("Next step: inspect the target and existing evidence before deciding what to do." if unknown or unexpected else "Next step: resolve the validation failure before attempting a send.") + lines.extend(render_delivery_fact_lines(facts)) + if facts["paired"] and agent is None: + lines.append("Selected agent / home: unknown (receiver context not reported)") + generation = getattr(args, "target_generation", None) + if generation is not None: + lines.append("Target generation: requested; existing validation result applies") + else: + lines.append("Target generation: not requested" if agent == "claude" and not facts["paired"] + else "Target generation: unsupported for this route") return "\n".join(lines) def emit_delivery_explanation(args: argparse.Namespace, result: dict, *, host: str | None = None, failed: bool = False, - stream=None) -> None: - print(render_delivery_explanation(args, result, host=host, failed=failed), + stream=None, mode: str = "readiness") -> None: + explanation = render_delivery_explanation(args, result, host=host, failed=failed, mode=mode) + try: + generated = int(time.time() * 1000) + if 0 <= generated <= 9007199254740991: + explanation += "\nExplanation generated at (UTC milliseconds): " + str(generated) + "; not a delivery observation." + except Exception: + pass + print(explanation, file=stream or (sys.stderr if failed or delivery_explanation_has_submission(result) else sys.stdout)) @@ -5771,6 +5970,9 @@ def cmd_doctor(args: argparse.Namespace) -> int: emit( args.json, payload, render_doctor(payload, "this machine"), command="doctor", ) + if getattr(args, "explain", False): + args._delivery_explanation_started = True + emit_delivery_explanation(args, payload, mode="doctor") return 0 exit_code = 0 @@ -5779,9 +5981,15 @@ def cmd_doctor(args: argparse.Namespace) -> int: return_host = _doctor_return_host(args) if args.check_return_route else None for requested_host in args.host: host = requested_host + if getattr(args, "explain", False): + args._delivery_explanation_started = False + args._delivery_explanation_host = requested_host try: transport = SshTransport(requested_host, args, tailnet_status) host, ssh_opts = transport.host, transport.ssh_opts + if getattr(args, "explain", False): + args._delivery_explanation_started = True + args._delivery_explanation_host = display_host(requested_host, host) remote_argv = ["doctor", "--no-update-notice"] + agent_remote_options(args) if return_host: remote_argv.extend(["--_return-host", return_host]) @@ -5803,6 +6011,9 @@ def cmd_doctor(args: argparse.Namespace) -> int: if len(all_results) > 1: print() print(render_doctor(payload, display_host(requested_host, host))) + if getattr(args, "explain", False): + args._delivery_explanation_started = True + emit_delivery_explanation(args, payload, mode="doctor", host=display_host(requested_host, host)) except CcPeerError as exc: exit_code = EXIT_ERROR all_results.append(json_result( @@ -5812,6 +6023,9 @@ def cmd_doctor(args: argparse.Namespace) -> int: )) if not args.json: print(human_text(f"session-peer: {requested_host}: {exc}"), file=sys.stderr) + if getattr(args, "explain", False): + emit_delivery_explanation(args, {}, mode="doctor", failed=True, + host=display_host(requested_host, host), stream=sys.stderr) if args.json: emit_json_results(all_results) return exit_code @@ -7446,6 +7660,10 @@ def handoff_private_stat(path, directory=False): return info +class HandoffReadDeadlineExceeded(Exception): + """The caller's read budget elapsed; not evidence of corrupt history.""" + + class HandoffLedger: """Serialized, fsynced snapshots with retained effect fences, not dedup. @@ -7506,12 +7724,19 @@ def _save(self, state): os.unlink(temporary) @contextlib.contextmanager - def transaction(self, deadline=None): + def transaction(self, deadline=None, *, read_only=False): + """Validate one locked snapshot; read-only projections never persist retention.""" + def check_read_deadline(): + if read_only and deadline is not None and handoff_now() >= deadline: + raise HandoffReadDeadlineExceeded() + + check_read_deadline() if IS_WINDOWS or fcntl is None: raise handoff_error("handoff_platform_unsupported") handoff_private_stat(self.root, True) lock = self.root / "ledger.lock" before = handoff_private_stat(lock) + check_read_deadline() fd = os.open(str(lock), os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)) try: after = os.fstat(fd) @@ -7519,38 +7744,52 @@ def transaction(self, deadline=None): raise handoff_error("unsafe_handoff_storage") limit = deadline if deadline is not None else handoff_now() + 5 while True: + check_read_deadline() try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fd, (fcntl.LOCK_SH if read_only else fcntl.LOCK_EX) | fcntl.LOCK_NB) + check_read_deadline() break except BlockingIOError: + check_read_deadline() if handoff_now() >= limit: raise handoff_error("handoff_storage_busy") time.sleep(min(0.01, max(0, limit - handoff_now()))) path = self.root / "ledger.json" + check_read_deadline() initial = handoff_private_stat(path) if initial.st_size > HANDOFF_LEDGER_BYTES: raise handoff_error("handoff_history_unavailable") + check_read_deadline() read_fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(read_fd, "rb") as stream: current = os.fstat(stream.fileno()) if (initial.st_dev, initial.st_ino) != (current.st_dev, current.st_ino): raise handoff_error("unsafe_handoff_storage") + check_read_deadline() raw = stream.read(HANDOFF_LEDGER_BYTES + 1) + if read_only: + # Parse/validation use only this immutable local byte snapshot; + # release the shared lock so writers need not wait for them. + fcntl.flock(fd, fcntl.LOCK_UN) + check_read_deadline() try: state = handoff_json(raw, HANDOFF_LEDGER_BYTES) if set(state) != {"schemaVersion", "epoch", "records"} or type(state["schemaVersion"]) is not int or state["schemaVersion"] != 1 or type(state["records"]) is not dict: raise ValueError() handoff_uuid(state["epoch"]) for key, record in state["records"].items(): + check_read_deadline() handoff_uuid(key) if type(record) is not dict or record.get("id") != key: raise ValueError() self._validate_record(record) except (CcPeerError, ValueError, KeyError, TypeError) as exc: raise handoff_error("handoff_ledger_corrupt") from exc + check_read_deadline() boot, now = handoff_boot_clock(), handoff_now_ns() if boot is not None: for record in state["records"].values(): + check_read_deadline() if (record["phase"] != "tombstone" and record["historyBoot"] == boot and now >= record["createdNs"] + 30 * 86400 * 1000000000): # Detail expires only under proven boot/monotonic @@ -7558,8 +7797,10 @@ def transaction(self, deadline=None): # survives and still counts toward the intent quota. record.update(phase="tombstone", submission="unknown", native=None, waits=[], ack=None, capability=None, pendingReceipt=None, observe=False, ackRequested=False) + check_read_deadline() yield state - self._save(state) + if not read_only: + self._save(state) except OSError as exc: raise handoff_error("handoff_history_unavailable") from exc finally: @@ -7591,10 +7832,10 @@ def prepare(self, binding, generation, correlation=None, deadline=None, native_c state["records"][correlation] = record return state["epoch"], copy.deepcopy(record) - def record(self, correlation, deadline=None): + def record(self, correlation, deadline=None, *, read_only=False): import copy handoff_uuid(correlation) - with self.transaction(deadline) as state: + with self.transaction(deadline, **({"read_only": True} if read_only else {})) as state: record = state["records"].get(correlation) if record is None: raise handoff_error("handoff_id_unknown") @@ -7838,8 +8079,9 @@ def handoff_cap_hash(epoch, correlation, generation, cap): def handoff_collect(ledger, epoch_id, frame): """Receipt-only operations. No native command, transcript or model access.""" import hmac - with ledger.transaction() as state: - if frame == {"op": "clock"}: + read_only_clock = frame == {"op": "clock", "readOnly": True} and type(frame.get("readOnly")) is bool + with ledger.transaction(**({"read_only": True} if read_only_clock else {})) as state: + if frame == {"op": "clock"} or read_only_clock: return {"ok": True, "clockEpoch": epoch_id} if frame.get("op") == "mint": if set(frame) != {"op", "ledgerEpoch", "correlationId", "targetGeneration"}: @@ -7962,9 +8204,16 @@ def handoff_ipc(root, frame, deadline=None): raise handoff_error("receipt_channel_unavailable") from exc -def handoff_channel_epoch(ledger, deadline=None): +def handoff_channel_epoch(ledger, deadline=None, *, read_only=False): try: - result = handoff_ipc(ledger.root, {"op": "clock"}, deadline) + frame = {"op": "clock"} + if read_only: + # Older collectors reject this extra field before committing a + # transaction. Never retry their write-through legacy clock query. + frame["readOnly"] = True + if deadline is None: + deadline = handoff_now() + 2 + result = handoff_ipc(ledger.root, frame, deadline) return handoff_uuid(result.get("clockEpoch")) except CcPeerError: return None @@ -8217,7 +8466,35 @@ def handoff_receipt_stdin(timeout=5): raise handoff_error("invalid_receipt") from exc +def cmd_handoff_explanation(args): + """Project existing history without saving retention or minting authority. + + Reuses the same read-only ledger/clock primitives as #296 status/watch; + receipt schemas, wait history and ordinary output remain unchanged. + """ + ledger = HandoffLedger() + correlation = handoff_uuid(args.correlation_id) + args._delivery_explanation_started = True + try: + epoch, record = ledger.record(correlation, read_only=True) + except CcPeerError as exc: + context = ("id_unknown" if exc.details.get("reason") == "handoff_id_unknown" else + "ledger_corrupt" if exc.details.get("reason") == "handoff_ledger_corrupt" else + "ledger_missing" if isinstance(exc.__cause__, FileNotFoundError) else "history_unavailable") + emit_delivery_explanation(args, {"handoffQuery": {"context": context}}, + mode="existing_intent", failed=True, stream=sys.stderr) + return 1 + active_clock = None + if record["capability"] and not record["capability"]["revoked"] and not record["ack"]: + active_clock = handoff_channel_epoch(ledger, read_only=True) + public = handoff_public(epoch, record, active_clock) + emit_delivery_explanation(args, {"handoff": public}, mode="existing_intent") + return 0 + + def cmd_handoff(args): + if args.handoff_action == "status" and getattr(args, "explain", False): + return cmd_handoff_explanation(args) ledger = HandoffLedger() action = args.handoff_action if action == "init": @@ -9739,7 +10016,8 @@ def add_common(sub: argparse.ArgumentParser) -> None: 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") - sending.add_argument("--explain", action="store_true", help="explain an ordinary local/SSH dry-run in text") + sending.add_argument("--explain", action="store_true", help="explain existing dry-run evidence in text") + doctor.add_argument("--explain", action="store_true", help="explain existing diagnostic capabilities in text") sending.set_defaults(func=cmd_send) handoff = subparsers.add_parser("handoff", help="manage private correlated intents without submitting native messages") @@ -9750,6 +10028,8 @@ def add_common(sub: argparse.ArgumentParser) -> None: sub.set_defaults(func=cmd_handoff, no_update_notice=True) if action in ("status", "wait"): sub.add_argument("--correlation-id", required=True, type=handoff_cli_uuid) + if action == "status": + sub.add_argument("--explain", action="store_true", help="explain the existing read-only intent snapshot") if action == "wait": sub.add_argument("--wait-for", choices=("delivered", "acknowledged"), required=True) sub.add_argument("--wait-timeout", type=handoff_timeout, default=30) @@ -9879,15 +10159,16 @@ def main(argv: list[str] | None = None) -> int: parser.error("--json conflicts with --output-format text") args.json = args.json or output_format == "json" if getattr(args, "explain", False): - if not args.dry_run: - parser.error("--explain requires --dry-run") if args.json: parser.error("--explain requires text output") - if getattr(args, "device", None): - parser.error("--explain does not support paired-device sends") - if handoff_requested(args) or any(getattr(args, key, None) is not None for key in ( - "_handoff_native_context", "_handoff_native_cutoff_ms", "_handoff_native_total_ms")): - parser.error("--explain does not support experimental handoff sends") + if args.command == "send": + if not args.dry_run: + parser.error("--explain requires --dry-run") + if handoff_requested(args) or any(getattr(args, key, None) is not None for key in ( + "_handoff_native_context", "_handoff_native_cutoff_ms", "_handoff_native_total_ms")): + parser.error("--explain does not support experimental handoff sends") + elif args.command == "handoff" and args.handoff_action != "status": + parser.error("--explain requires handoff status") args._delivery_explanation_started = False args._delivery_explanation_host = None try: @@ -9913,6 +10194,7 @@ def main(argv: list[str] | None = None) -> int: print(human_text(f"session-peer: {message}"), file=sys.stderr) if getattr(args, "explain", False): emit_delivery_explanation(args, exc.details, failed=True, stream=sys.stderr, + mode="doctor" if args.command == "doctor" else "existing_intent" if args.command == "handoff" else "readiness", host=getattr(args, "_delivery_explanation_host", None)) exit_code = ( EXIT_NO_TARGET @@ -9933,6 +10215,7 @@ def main(argv: list[str] | None = None) -> int: explanation = ({"reason": "outcome_unknown"} if getattr(args, "_delivery_explanation_started", False) else {}) emit_delivery_explanation(args, explanation, failed=True, stream=sys.stderr, + mode="doctor" if args.command == "doctor" else "existing_intent" if args.command == "handoff" else "readiness", host=getattr(args, "_delivery_explanation_host", None)) exit_code = EXIT_ERROR if show_human_notice and not args.json: diff --git a/session_peer_core/cli.py b/session_peer_core/cli.py index 1e5f43f0..30a6414f 100644 --- a/session_peer_core/cli.py +++ b/session_peer_core/cli.py @@ -132,7 +132,8 @@ def add_common(sub: argparse.ArgumentParser) -> None: 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") - sending.add_argument("--explain", action="store_true", help="explain an ordinary local/SSH dry-run in text") + sending.add_argument("--explain", action="store_true", help="explain existing dry-run evidence in text") + doctor.add_argument("--explain", action="store_true", help="explain existing diagnostic capabilities in text") sending.set_defaults(func=cmd_send) handoff = subparsers.add_parser("handoff", help="manage private correlated intents without submitting native messages") @@ -143,6 +144,8 @@ def add_common(sub: argparse.ArgumentParser) -> None: sub.set_defaults(func=cmd_handoff, no_update_notice=True) if action in ("status", "wait"): sub.add_argument("--correlation-id", required=True, type=handoff_cli_uuid) + if action == "status": + sub.add_argument("--explain", action="store_true", help="explain the existing read-only intent snapshot") if action == "wait": sub.add_argument("--wait-for", choices=("delivered", "acknowledged"), required=True) sub.add_argument("--wait-timeout", type=handoff_timeout, default=30) @@ -272,15 +275,16 @@ def main(argv: list[str] | None = None) -> int: parser.error("--json conflicts with --output-format text") args.json = args.json or output_format == "json" if getattr(args, "explain", False): - if not args.dry_run: - parser.error("--explain requires --dry-run") if args.json: parser.error("--explain requires text output") - if getattr(args, "device", None): - parser.error("--explain does not support paired-device sends") - if handoff_requested(args) or any(getattr(args, key, None) is not None for key in ( - "_handoff_native_context", "_handoff_native_cutoff_ms", "_handoff_native_total_ms")): - parser.error("--explain does not support experimental handoff sends") + if args.command == "send": + if not args.dry_run: + parser.error("--explain requires --dry-run") + if handoff_requested(args) or any(getattr(args, key, None) is not None for key in ( + "_handoff_native_context", "_handoff_native_cutoff_ms", "_handoff_native_total_ms")): + parser.error("--explain does not support experimental handoff sends") + elif args.command == "handoff" and args.handoff_action != "status": + parser.error("--explain requires handoff status") args._delivery_explanation_started = False args._delivery_explanation_host = None try: @@ -306,6 +310,7 @@ def main(argv: list[str] | None = None) -> int: print(human_text(f"session-peer: {message}"), file=sys.stderr) if getattr(args, "explain", False): emit_delivery_explanation(args, exc.details, failed=True, stream=sys.stderr, + mode="doctor" if args.command == "doctor" else "existing_intent" if args.command == "handoff" else "readiness", host=getattr(args, "_delivery_explanation_host", None)) exit_code = ( EXIT_NO_TARGET @@ -326,6 +331,7 @@ def main(argv: list[str] | None = None) -> int: explanation = ({"reason": "outcome_unknown"} if getattr(args, "_delivery_explanation_started", False) else {}) emit_delivery_explanation(args, explanation, failed=True, stream=sys.stderr, + mode="doctor" if args.command == "doctor" else "existing_intent" if args.command == "handoff" else "readiness", host=getattr(args, "_delivery_explanation_host", None)) exit_code = EXIT_ERROR if show_human_notice and not args.json: diff --git a/session_peer_core/commands.py b/session_peer_core/commands.py index 5bdc069d..a2252e58 100644 --- a/session_peer_core/commands.py +++ b/session_peer_core/commands.py @@ -106,6 +106,9 @@ def cmd_doctor(args: argparse.Namespace) -> int: emit( args.json, payload, render_doctor(payload, "this machine"), command="doctor", ) + if getattr(args, "explain", False): + args._delivery_explanation_started = True + emit_delivery_explanation(args, payload, mode="doctor") return 0 exit_code = 0 @@ -114,9 +117,15 @@ def cmd_doctor(args: argparse.Namespace) -> int: return_host = _doctor_return_host(args) if args.check_return_route else None for requested_host in args.host: host = requested_host + if getattr(args, "explain", False): + args._delivery_explanation_started = False + args._delivery_explanation_host = requested_host try: transport = SshTransport(requested_host, args, tailnet_status) host, ssh_opts = transport.host, transport.ssh_opts + if getattr(args, "explain", False): + args._delivery_explanation_started = True + args._delivery_explanation_host = display_host(requested_host, host) remote_argv = ["doctor", "--no-update-notice"] + agent_remote_options(args) if return_host: remote_argv.extend(["--_return-host", return_host]) @@ -138,6 +147,9 @@ def cmd_doctor(args: argparse.Namespace) -> int: if len(all_results) > 1: print() print(render_doctor(payload, display_host(requested_host, host))) + if getattr(args, "explain", False): + args._delivery_explanation_started = True + emit_delivery_explanation(args, payload, mode="doctor", host=display_host(requested_host, host)) except CcPeerError as exc: exit_code = EXIT_ERROR all_results.append(json_result( @@ -147,6 +159,9 @@ def cmd_doctor(args: argparse.Namespace) -> int: )) if not args.json: print(human_text(f"session-peer: {requested_host}: {exc}"), file=sys.stderr) + if getattr(args, "explain", False): + emit_delivery_explanation(args, {}, mode="doctor", failed=True, + host=display_host(requested_host, host), stream=sys.stderr) if args.json: emit_json_results(all_results) return exit_code diff --git a/session_peer_core/explanation.py b/session_peer_core/explanation.py new file mode 100644 index 00000000..01940bf5 --- /dev/null +++ b/session_peer_core/explanation.py @@ -0,0 +1,171 @@ +# Display-only explanation facts. No discovery, transport, ledger or clock I/O. + + +def validated_delivery_context(value): + """Accept the complete private receiver context, never guessed fragments.""" + if not isinstance(value, dict) or set(value) != { + "schemaVersion", "agent", "target", "home", "capabilities"}: + return None + if type(value["schemaVersion"]) is not int or value["schemaVersion"] != 1: + return None + if value["agent"] not in ("claude", "codex", "antigravity"): + return None + for key in ("target", "home"): + item = value[key] + if item is not None: + if (not isinstance(item, str) or not item or item.lower().startswith("session-peer:") + or any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in item)): + return None + try: + if len(item.encode("utf-8")) > 4096: + return None + except UnicodeError: + return None + if value["target"] is not None: + pattern = (r"claude:[1-9][0-9]*" if value["agent"] == "claude" else + value["agent"] + r":[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}") + if re.fullmatch(pattern, value["target"]) is None: + return None + if value["home"] is not None and not (PurePosixPath(value["home"]).is_absolute() + or PureWindowsPath(value["home"]).is_absolute()): + return None + caps = value["capabilities"] + if not isinstance(caps, dict) or set(caps) != {"send", "wake", "wait", "ack"}: + return None + if any(type(item) is not bool for item in caps.values()): + return None + if len(json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) > 8192: + return None + return {**value, "capabilities": dict(caps)} + + +def explanation_text(value): + """Sanitize only opt-in explanations, preserving ordinary output behavior.""" + text = human_text(value) + return "".join("\\u%04x" % ord(c) if ord(c) in ( + 0x200b, 0x200c, 0x200d, 0x200e, 0x200f, 0x202a, 0x202b, + 0x202c, 0x202d, 0x202e, 0x2060, 0x2066, 0x2067, 0x2068, 0x2069, 0xfeff) + else c for c in text) + + +def build_delivery_explanation(args, result, *, mode="readiness", failed=False): + """Normalize only allowlisted facts already supplied by the caller. + + Declaration never proves eligibility, and a snapshot never proves delivery. + The returned object is internal and is not a new JSON/wire contract. + """ + if mode not in ("readiness", "doctor", "existing_intent"): + raise ValueError("unsupported delivery explanation mode") + paired = bool(getattr(args, "device", None)) + context = validated_delivery_context(getattr(args, "_delivery_explanation_context", None)) if paired else None + requested = str(getattr(args, "to", "") or "") + uri = requested.lower().startswith("session-peer:") + agent = (context["agent"] if context else None) if paired else ( + None if uri or not requested else "codex" if requested.startswith("codex:") else + "antigravity" if requested.startswith("antigravity:") else "claude") + caps = context["capabilities"] if context else None + if not paired and agent is not None: + caps = {key: getattr(AGENTS.get(agent).capabilities, key) + for key in ("send", "wake", "wait", "ack")} + home = context["home"] if context else None + if not paired and agent in ("codex", "antigravity"): + value = result.get("codexHome" if agent == "codex" else "antigravityHome") + home = value if isinstance(value, str) else None + facts = {"mode": mode, "paired": paired, "agent": agent, + "target": context["target"] if context else None, "home": home, + "capabilities": caps, "agents": [], "handoff": None, + "selectedRoute": result.get("route") if paired and result.get("route") in ("direct", "relay") else None, + "available": not failed, "historyContext": None} + if mode == "doctor": + declared = result.get("capabilities", {}) + declared = declared.get("agents", {}) if isinstance(declared, dict) else {} + for name in ("claude", "codex", "antigravity"): + diagnostic = result.get(name, {}) + status = diagnostic.get("status") if isinstance(diagnostic, dict) else None + allowed_status = {"available", "unavailable", "missing_home", "permission_denied", + "wrong_home", "missing_tool", "unknown", "disabled", "unsupported", "inbox_unavailable"} + declaration = declared.get(name, {}) if isinstance(declared, dict) else {} + home = diagnostic.get("selectedHome") if isinstance(diagnostic, dict) else None + facts["agents"].append({"agent": name, "status": status if isinstance(status, str) and status in allowed_status else "unknown", + "home": home if isinstance(home, str) else None, + "capabilities": {key: declaration[key] if isinstance(declaration, dict) + and type(declaration.get(key)) is bool else None + for key in ("send", "wake", "wait", "ack")}}) + elif mode == "existing_intent": + query = result.get("handoffQuery") + if isinstance(query, dict) and query.get("context") in ( + "ledger_missing", "ledger_corrupt", "id_unknown", "history_unavailable"): + facts["historyContext"] = query["context"] + public = result.get("handoff") + if isinstance(public, dict): + snapshot = {} + allowed = {"submission": {"not_attempted", "submitted", "refused", "unknown"}, + "observation": {"unsupported", "not_requested", "pending", "observed", "failed"}, + "ack": {"pending", "unsupported", "not_requested", "acknowledged"}, + "wait": {"not_requested", "pending", "satisfied", "timed_out_unknown", "unsupported", "stopped", "failed"}} + for key, statuses in allowed.items(): + value = public.get(key) + status = value.get("status") if isinstance(value, dict) else None + snapshot[key] = status if isinstance(status, str) and status in statuses else "unknown" + ack = public.get("ack", {}) + observed = ack.get("receivedAtUtcMs") if isinstance(ack, dict) else None + snapshot["receivedAtUtcMs"] = observed if type(observed) is int and 0 <= observed <= 9007199254740991 else None + snapshot["late"] = ack.get("late") if isinstance(ack, dict) and type(ack.get("late")) is bool else None + assurance = ack.get("assurance") if isinstance(ack, dict) else None + snapshot["assurance"] = assurance if assurance in ("token_possession", "operator_confirmed") else None + if snapshot["ack"] != "acknowledged": + snapshot.update(receivedAtUtcMs=None, late=None, assurance=None) + wait = public.get("wait", {}) + goal = wait.get("for") if isinstance(wait, dict) else None + snapshot["waitFor"] = goal if goal in ("none", "delivered", "acknowledged") else None + reason = wait.get("reason") if isinstance(wait, dict) else None + snapshot["waitReason"] = reason if reason in ("insufficient_budget", "deadline_before_effect", "evidence_unsupported", "evidence_failed", "history_unavailable", "stopped_by_operator", "invalid_handoff") else None + facts["handoff"] = snapshot + return facts + + +def render_delivery_fact_lines(facts): + def capability(value): + return "supported (declared; eligibility unverified)" if value is True else "unsupported (declared)" if value is False else "unknown (not reported)" + if facts["mode"] == "doctor": + lines = ["Delivery capabilities (existing diagnostic snapshot)" if facts["available"] else + "Delivery capabilities unavailable (diagnostic snapshot not received)"] + for item in facts["agents"]: + basis = "diagnostic report" if item["status"] != "unknown" else "not reported" + lines.append(item["agent"] + " availability: " + item["status"] + " (" + basis + "; not per-target readiness)") + if item["agent"] in ("codex", "antigravity"): + lines.append(" Selected home: " + (explanation_text(item["home"]) if item["home"] else "unknown (not reported)")) + for key, value in item["capabilities"].items(): + lines.append(" " + key.capitalize() + ": " + capability(value)) + return lines + ["Injection observation: unsupported", "Task completion: unknown", + "Last delivery observation: unknown (not reported)", + "A diagnostic snapshot does not guarantee later delivery."] + if facts["mode"] == "existing_intent": + value = facts["handoff"] or {} + lines = ["Delivery evidence (existing intent snapshot)" if facts["available"] else + "Delivery evidence unavailable (history not read)"] + if facts["historyContext"]: + lines.append("History query: " + facts["historyContext"] + "; no history initialized or repaired.") + for key, label in (("submission", "Submission"), ("observation", "Injection observation"), + ("ack", "Explicit receipt"), ("wait", "Latest stored wait")): + lines.append(label + ": " + value.get(key, "unknown") + (" (stored observation)" if facts["available"] else " (not available)")) + observed = value.get("receivedAtUtcMs") + lines.append("Receipt recorded at (stored UTC milliseconds): " + (str(observed) if observed is not None else "unknown (not reported)")) + if value.get("assurance"): + lines.append("Receipt assurance: " + value["assurance"] + "; does not prove task completion") + if value["assurance"] == "token_possession": + lines.append("Receipt proves token possession, not original agent/model identity.") + if value.get("waitFor"): + lines.append("Latest stored wait goal: " + value["waitFor"]) + if value.get("waitReason"): + lines.append("Latest stored wait reason: " + value["waitReason"]) + if value.get("late") is not None: + lines.append("Receipt late relative to first wait: " + str(value["late"]).lower()) + return lines + ["Task completion: unknown", "Stored evidence may be stale; this view does not refresh delivery.", + "Do not automatically resend after an unknown outcome."] + caps = facts["capabilities"] or {} + return ["Submission capability: " + capability(caps.get("send")), + "Wake capability: " + capability(caps.get("wake")), + "Injection observation: unsupported" if not facts["paired"] else "Injection observation: unknown (not reported)", + "Last delivery observation: unknown (not reported)", + "Task completion: unknown"] diff --git a/session_peer_core/handoff.py b/session_peer_core/handoff.py index 7847a1dd..f62f1b61 100644 --- a/session_peer_core/handoff.py +++ b/session_peer_core/handoff.py @@ -252,6 +252,10 @@ def handoff_private_stat(path, directory=False): return info +class HandoffReadDeadlineExceeded(Exception): + """The caller's read budget elapsed; not evidence of corrupt history.""" + + class HandoffLedger: """Serialized, fsynced snapshots with retained effect fences, not dedup. @@ -312,12 +316,19 @@ def _save(self, state): os.unlink(temporary) @contextlib.contextmanager - def transaction(self, deadline=None): + def transaction(self, deadline=None, *, read_only=False): + """Validate one locked snapshot; read-only projections never persist retention.""" + def check_read_deadline(): + if read_only and deadline is not None and handoff_now() >= deadline: + raise HandoffReadDeadlineExceeded() + + check_read_deadline() if IS_WINDOWS or fcntl is None: raise handoff_error("handoff_platform_unsupported") handoff_private_stat(self.root, True) lock = self.root / "ledger.lock" before = handoff_private_stat(lock) + check_read_deadline() fd = os.open(str(lock), os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)) try: after = os.fstat(fd) @@ -325,38 +336,52 @@ def transaction(self, deadline=None): raise handoff_error("unsafe_handoff_storage") limit = deadline if deadline is not None else handoff_now() + 5 while True: + check_read_deadline() try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fd, (fcntl.LOCK_SH if read_only else fcntl.LOCK_EX) | fcntl.LOCK_NB) + check_read_deadline() break except BlockingIOError: + check_read_deadline() if handoff_now() >= limit: raise handoff_error("handoff_storage_busy") time.sleep(min(0.01, max(0, limit - handoff_now()))) path = self.root / "ledger.json" + check_read_deadline() initial = handoff_private_stat(path) if initial.st_size > HANDOFF_LEDGER_BYTES: raise handoff_error("handoff_history_unavailable") + check_read_deadline() read_fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(read_fd, "rb") as stream: current = os.fstat(stream.fileno()) if (initial.st_dev, initial.st_ino) != (current.st_dev, current.st_ino): raise handoff_error("unsafe_handoff_storage") + check_read_deadline() raw = stream.read(HANDOFF_LEDGER_BYTES + 1) + if read_only: + # Parse/validation use only this immutable local byte snapshot; + # release the shared lock so writers need not wait for them. + fcntl.flock(fd, fcntl.LOCK_UN) + check_read_deadline() try: state = handoff_json(raw, HANDOFF_LEDGER_BYTES) if set(state) != {"schemaVersion", "epoch", "records"} or type(state["schemaVersion"]) is not int or state["schemaVersion"] != 1 or type(state["records"]) is not dict: raise ValueError() handoff_uuid(state["epoch"]) for key, record in state["records"].items(): + check_read_deadline() handoff_uuid(key) if type(record) is not dict or record.get("id") != key: raise ValueError() self._validate_record(record) except (CcPeerError, ValueError, KeyError, TypeError) as exc: raise handoff_error("handoff_ledger_corrupt") from exc + check_read_deadline() boot, now = handoff_boot_clock(), handoff_now_ns() if boot is not None: for record in state["records"].values(): + check_read_deadline() if (record["phase"] != "tombstone" and record["historyBoot"] == boot and now >= record["createdNs"] + 30 * 86400 * 1000000000): # Detail expires only under proven boot/monotonic @@ -364,8 +389,10 @@ def transaction(self, deadline=None): # survives and still counts toward the intent quota. record.update(phase="tombstone", submission="unknown", native=None, waits=[], ack=None, capability=None, pendingReceipt=None, observe=False, ackRequested=False) + check_read_deadline() yield state - self._save(state) + if not read_only: + self._save(state) except OSError as exc: raise handoff_error("handoff_history_unavailable") from exc finally: @@ -397,10 +424,10 @@ def prepare(self, binding, generation, correlation=None, deadline=None, native_c state["records"][correlation] = record return state["epoch"], copy.deepcopy(record) - def record(self, correlation, deadline=None): + def record(self, correlation, deadline=None, *, read_only=False): import copy handoff_uuid(correlation) - with self.transaction(deadline) as state: + with self.transaction(deadline, **({"read_only": True} if read_only else {})) as state: record = state["records"].get(correlation) if record is None: raise handoff_error("handoff_id_unknown") @@ -644,8 +671,9 @@ def handoff_cap_hash(epoch, correlation, generation, cap): def handoff_collect(ledger, epoch_id, frame): """Receipt-only operations. No native command, transcript or model access.""" import hmac - with ledger.transaction() as state: - if frame == {"op": "clock"}: + read_only_clock = frame == {"op": "clock", "readOnly": True} and type(frame.get("readOnly")) is bool + with ledger.transaction(**({"read_only": True} if read_only_clock else {})) as state: + if frame == {"op": "clock"} or read_only_clock: return {"ok": True, "clockEpoch": epoch_id} if frame.get("op") == "mint": if set(frame) != {"op", "ledgerEpoch", "correlationId", "targetGeneration"}: @@ -768,9 +796,16 @@ def handoff_ipc(root, frame, deadline=None): raise handoff_error("receipt_channel_unavailable") from exc -def handoff_channel_epoch(ledger, deadline=None): +def handoff_channel_epoch(ledger, deadline=None, *, read_only=False): try: - result = handoff_ipc(ledger.root, {"op": "clock"}, deadline) + frame = {"op": "clock"} + if read_only: + # Older collectors reject this extra field before committing a + # transaction. Never retry their write-through legacy clock query. + frame["readOnly"] = True + if deadline is None: + deadline = handoff_now() + 2 + result = handoff_ipc(ledger.root, frame, deadline) return handoff_uuid(result.get("clockEpoch")) except CcPeerError: return None @@ -1023,7 +1058,35 @@ def handoff_receipt_stdin(timeout=5): raise handoff_error("invalid_receipt") from exc +def cmd_handoff_explanation(args): + """Project existing history without saving retention or minting authority. + + Reuses the same read-only ledger/clock primitives as #296 status/watch; + receipt schemas, wait history and ordinary output remain unchanged. + """ + ledger = HandoffLedger() + correlation = handoff_uuid(args.correlation_id) + args._delivery_explanation_started = True + try: + epoch, record = ledger.record(correlation, read_only=True) + except CcPeerError as exc: + context = ("id_unknown" if exc.details.get("reason") == "handoff_id_unknown" else + "ledger_corrupt" if exc.details.get("reason") == "handoff_ledger_corrupt" else + "ledger_missing" if isinstance(exc.__cause__, FileNotFoundError) else "history_unavailable") + emit_delivery_explanation(args, {"handoffQuery": {"context": context}}, + mode="existing_intent", failed=True, stream=sys.stderr) + return 1 + active_clock = None + if record["capability"] and not record["capability"]["revoked"] and not record["ack"]: + active_clock = handoff_channel_epoch(ledger, read_only=True) + public = handoff_public(epoch, record, active_clock) + emit_delivery_explanation(args, {"handoff": public}, mode="existing_intent") + return 0 + + def cmd_handoff(args): + if args.handoff_action == "status" and getattr(args, "explain", False): + return cmd_handoff_explanation(args) ledger = HandoffLedger() action = args.handoff_action if action == "init": diff --git a/session_peer_core/output.py b/session_peer_core/output.py index ec54e123..8aaad388 100644 --- a/session_peer_core/output.py +++ b/session_peer_core/output.py @@ -50,37 +50,46 @@ def delivery_explanation_has_submission(result: dict) -> bool: def render_delivery_explanation(args: argparse.Namespace, result: dict, *, - host: str | None = None, failed: bool = False) -> str: + host: str | None = None, failed: bool = False, + mode: str = "readiness") -> str: """Describe only existing dry-run facts; never inspect or execute a target. This display-only allowlist deliberately excludes message bodies, native errors, credentials, routing URIs and receipt authority. Missing remote facts stay unknown rather than being filled from the sender's environment. """ + facts = build_delivery_explanation(args, result, mode=mode, failed=failed) + if mode != "readiness": + lines = render_delivery_fact_lines(facts) + if host: + lines.insert(1, "Destination host: " + explanation_text(host)) + return "\n".join(lines) requested = str(getattr(args, "to", "")) unresolved_uri = requested.lower().startswith("session-peer:") if unresolved_uri: requested = "Reply-To URI (resolution unavailable)" - agent = (None if unresolved_uri else - "codex" if requested.startswith("codex:") else - "antigravity" if requested.startswith("antigravity:") else "claude") - where = host or (", ".join(getattr(args, "host", None) or []) or "this machine") + agent = facts["agent"] + if facts["paired"]: + requested = facts["target"] or "paired target (not reported by receiver)" + where = host or (("device:" + str(args.device)) if facts["paired"] else + (", ".join(getattr(args, "host", None) or []) or "this machine")) lines = ["Delivery preview", - "Requested destination: " + human_text(requested) + " on " + human_text(where)] - target = result.get("target") + "Requested destination: " + explanation_text(requested) + " on " + explanation_text(where)] + if facts["paired"]: + lines.append("Selected paired route: " + (facts["selectedRoute"] + " (reported)" if facts["selectedRoute"] else "unknown (not reported)")) + target = None if facts["paired"] else result.get("target") if isinstance(target, dict): if agent == "claude" and type(target.get("pid")) is int: resolved = "Claude PID " + str(target["pid"]) if isinstance(target.get("name"), str): - resolved += " (" + human_text(target["name"]) + ")" + resolved += " (" + explanation_text(target["name"]) + ")" lines.append("Resolved target: " + resolved) elif agent == "codex" and isinstance(target.get("id"), str): - lines.append("Resolved target: codex:" + human_text(target["id"])) - home_key = "codexHome" if agent == "codex" else "antigravityHome" + lines.append("Resolved target: codex:" + explanation_text(target["id"])) if agent in ("codex", "antigravity"): - home = result.get(home_key) + home = facts["home"] lines.append(("Codex home: " if agent == "codex" else "Antigravity home: ") - + (human_text(home) if isinstance(home, str) else "unknown (not reported)")) + + (explanation_text(home) if isinstance(home, str) else "unknown (not reported)")) started = getattr(args, "_delivery_explanation_started", True) unknown = result.get("status") == "unknown" or result.get("reason") in ( "outcome_unknown", "ssh_identity_evidence_unavailable") or ( @@ -113,20 +122,37 @@ def render_delivery_explanation(args: argparse.Namespace, result: dict, *, if not unknown and not unexpected and isinstance(fact, dict) and fact.get("status") == "validated" else "requested; preflight not confirmed") lines.extend(["Wake: " + wake, - "Receipt ACK: unavailable for ordinary sends", + ("Receipt ACK: unknown (receiver support not reported)" if facts["paired"] + and facts["capabilities"] is None else "Receipt ACK: unavailable for ordinary sends"), "Consumption / completion: not observed", "A later send repeats validation.", "Do not automatically resend after an unknown outcome."]) if failed or unexpected: lines.append("Next step: inspect the target and existing evidence before deciding what to do." if unknown or unexpected else "Next step: resolve the validation failure before attempting a send.") + lines.extend(render_delivery_fact_lines(facts)) + if facts["paired"] and agent is None: + lines.append("Selected agent / home: unknown (receiver context not reported)") + generation = getattr(args, "target_generation", None) + if generation is not None: + lines.append("Target generation: requested; existing validation result applies") + else: + lines.append("Target generation: not requested" if agent == "claude" and not facts["paired"] + else "Target generation: unsupported for this route") return "\n".join(lines) def emit_delivery_explanation(args: argparse.Namespace, result: dict, *, host: str | None = None, failed: bool = False, - stream=None) -> None: - print(render_delivery_explanation(args, result, host=host, failed=failed), + stream=None, mode: str = "readiness") -> None: + explanation = render_delivery_explanation(args, result, host=host, failed=failed, mode=mode) + try: + generated = int(time.time() * 1000) + if 0 <= generated <= 9007199254740991: + explanation += "\nExplanation generated at (UTC milliseconds): " + str(generated) + "; not a delivery observation." + except Exception: + pass + print(explanation, file=stream or (sys.stderr if failed or delivery_explanation_has_submission(result) else sys.stdout)) diff --git a/session_peer_relay/app.py b/session_peer_relay/app.py index 2d255a1f..6bdf05d1 100644 --- a/session_peer_relay/app.py +++ b/session_peer_relay/app.py @@ -112,7 +112,7 @@ async def dispatch(self, peer, value): raise Rejected('key_rotation_restricted') if op == 'probe': return {'ok': True, 'device': self.store.device, 'receiverReady': True, - 'features': ['identity-rotation-v1']} + 'features': ['identity-rotation-v1', 'delivery-explanation-v1']} if op == 'status': return self.store.status(peer, value['body']) if op == 'rotation.prepare': @@ -138,8 +138,11 @@ async def dispatch(self, peer, value): for row in part.get('sessions', [])) return {'ok': not errors, 'sessions': sessions, 'errors': errors} body = value['body'] - if not isinstance(body, dict) or set(body) != {'target', 'message'}: + if (not isinstance(body, dict) or set(body) not in ( + {'target', 'message'}, {'target', 'message', 'explain'}) + or ('explain' in body and (op != 'resolve' or body['explain'] is not True))): raise Rejected('invalid_body') + explanation = {'explain': True} if body.get('explain') is True else {} alias, text = body['target'], body['message'] if not isinstance(alias, str): raise Rejected('invalid_target') @@ -154,11 +157,11 @@ async def dispatch(self, peer, value): raise Rejected('framed_message_too_large') # Binding overhead must also fit, before resolve or journal begin. try: - worker_frame(binding, 'resolve' if op == 'resolve' else 'send', text, key) + worker_frame(binding, 'resolve' if op == 'resolve' else 'send', text, key, **explanation) except FrameRejected as exc: raise Rejected(str(exc)) from None if op == 'resolve': - result = await self.native.invoke(binding, 'resolve', text, peer_fingerprint=key) + result = await self.native.invoke(binding, 'resolve', text, peer_fingerprint=key, **explanation) if result.get('reason') == 'codex_executable_not_found': self.lifecycle_event('native_preflight_refused', operation='resolve', reason='codex_executable_not_found') @@ -412,7 +415,7 @@ async def finish_pair(store, invite, route, credential): await channel.close() -async def exchange(store, peer, op, body=None, ident=None, route='auto', credential=None): +async def exchange(store, peer, op, body=None, ident=None, route='auto', credential=None, *, explain=False): if store.recovery_required() and op not in ('probe', 'status', 'rotation.status'): raise Rejected('recovery_required') record = store.peer(peer) @@ -441,7 +444,9 @@ async def ready_once(kind, attempt_id): raise Rejected('peer_not_ready') if op.startswith('rotation.') and 'identity-rotation-v1' not in response.get('features', []): raise Rejected('rotation_unsupported') - return kind, channel, attempt_id + features = response.get('features') + supported = isinstance(features, list) and 'delivery-explanation-v1' in features + return kind, channel, attempt_id, supported except TimeoutError: with contextlib.suppress(Exception): await channel.close() @@ -508,7 +513,7 @@ async def ready(kind): if (not task.cancelled() and task.exception() is None and (winner is None or task.result()[1] is not winner[1])): await task.result()[1].close() - kind, channel, selected_attempt_id = winner + kind, channel, selected_attempt_id, explanation_supported = winner setup = ({'setupAttempts': setup_attempts[kind], 'setupDegraded': True, 'setupFailureHistory': history[kind]} if setup_attempts[kind] > 1 else {}) submitted = False @@ -517,10 +522,14 @@ async def ready(kind): # Preserve the preflighted request ID/body; Secure.send rechecks framing # after refreshing the expiry, before any application bytes are written. value['expires'] = time.time() + 60 + if op == 'resolve' and explain is True and explanation_supported: + value['body'] = {**body, 'explain': True} # Exactly one application submission, after path selection. No failover resend. await channel.send(value) submitted = True result = await channel.recv() + if not (op == 'resolve' and explain is True and explanation_supported): + result = {key: item for key, item in result.items() if key != 'deliveryContext'} return {**result, 'route': kind, 'receiverAccepted': result.get('ok', False), 'relayAttached': kind == 'relay', 'requestId': value['id'], 'attemptId': selected_attempt_id, **setup} diff --git a/session_peer_relay/cli.py b/session_peer_relay/cli.py index ec347564..eb0447de 100644 --- a/session_peer_relay/cli.py +++ b/session_peer_relay/cli.py @@ -21,6 +21,7 @@ from .wire import validate_relay_url, direct_address from .transport_errors import NoAuthenticatedRoute, TransportFailure from .guidance import with_guidance +from .framing import valid_delivery_context def connection_diagnostics(exc): @@ -429,9 +430,13 @@ async def core_exchange(args): text = core.wrap_message(text, with_from=True, with_reply=False, explicit_host=None) body = {'target': args.to, 'message': text} operation = 'resolve' if args.command == 'send' and args.dry_run else args.command + credential = device_credential(args, store, args.device) + if operation == 'resolve' and getattr(args, 'explain', False): + args._delivery_explanation_started = True result = await exchange(store, args.device, operation, body, getattr(args, 'request_id', None), args.device_route, - device_credential(args, store, args.device)) + credential, + **({'explain': True} if operation == 'resolve' and getattr(args, 'explain', False) else {})) if args.command == 'list' and args.agent: result['sessions'] = [row for row in result.get('sessions', []) if row.get('agent') == args.agent] return result @@ -446,6 +451,13 @@ def invoke_core(args): except Exception as exc: result = {'ok': False, 'reason': str(exc) if isinstance(exc, Rejected) else type(exc).__name__, 'retryAllowed': False, 'consumptionConfirmed': False, **connection_diagnostics(exc)} + delivery_context = result.pop('deliveryContext', None) + explain = (args.command == 'send' and getattr(args, 'dry_run', False) + and getattr(args, 'explain', False)) + if hasattr(args, '_delivery_explanation_context'): + del args._delivery_explanation_context + if explain and valid_delivery_context(delivery_context): + args._delivery_explanation_context = delivery_context result = with_guidance(result) result.update(device=args.device, host='device:'+args.device, transport='paired_device') display = core.human_text(result) @@ -457,4 +469,6 @@ def invoke_core(args): str(row.get('target', ''))+' '+str(row.get('agent', ''))+' '+str(row.get('id', row.get('pid', '')))+' '+str(row.get('status', 'unknown')) for row in display.get('sessions', [])) core.emit(args.json, result, human, command=args.command) + if explain: + core.emit_delivery_explanation(args, result, failed=not result.get('ok')) return 0 if result.get('ok') else 1 diff --git a/session_peer_relay/framing.py b/session_peer_relay/framing.py index 5e4025c3..9e03d30f 100644 --- a/session_peer_relay/framing.py +++ b/session_peer_relay/framing.py @@ -1,10 +1,51 @@ """Byte budgets checked before application or native-worker effects.""" import json +import re +from pathlib import PurePosixPath, PureWindowsPath MAX_TEXT_BYTES = 32 * 1024 MAX_APPLICATION_BYTES = 64 * 1024 # Includes binding, operation and JSON escaping, not just the raw message. MAX_WORKER_BYTES = 256 * 1024 +MAX_DELIVERY_CONTEXT_BYTES = 8 * 1024 + + +def valid_delivery_context(value): + """Accept only bounded receiver facts; malformed context never spoils a result.""" + if (type(value) is not dict + or set(value) != {'schemaVersion', 'agent', 'target', 'home', 'capabilities'} + or type(value['schemaVersion']) is not int or value['schemaVersion'] != 1 + or value['agent'] not in ('claude', 'codex', 'antigravity')): + return False + for key in ('target', 'home'): + item = value[key] + if item is not None: + if not isinstance(item, str) or not item or any(ord(char) < 32 or 127 <= ord(char) <= 159 for char in item): + return False + try: + if len(item.encode('utf-8')) > 4096: + return False + except UnicodeError: + return False + if value['target'] is not None and not value['target'].startswith(value['agent'] + ':'): + return False + if value['target'] is not None: + identifier = value['target'].split(':', 1)[1] + pattern = r'[1-9][0-9]*' if value['agent'] == 'claude' else r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}' + if re.fullmatch(pattern, identifier) is None: + return False + if value['home'] is not None and not ( + PurePosixPath(value['home']).is_absolute() or PureWindowsPath(value['home']).is_absolute()): + return False + capabilities = value['capabilities'] + if (type(capabilities) is not dict or set(capabilities) != {'send', 'wake', 'wait', 'ack'} + or any(type(item) is not bool for item in capabilities.values())): + return False + try: + encoded_frame(value, MAX_DELIVERY_CONTEXT_BYTES, 'delivery_context_too_large') + except FrameRejected: + return False + return True class FrameRejected(ValueError): @@ -25,10 +66,14 @@ def application_frame(value): return encoded_frame(value, MAX_APPLICATION_BYTES, 'application_frame_too_large') -def worker_frame(binding, operation, text, peer_fingerprint=None): +def worker_frame(binding, operation, text, peer_fingerprint=None, *, explain=False): value = {'binding': binding, 'operation': operation, 'text': text} if peer_fingerprint is not None: value['peerFingerprint'] = peer_fingerprint + if explain: + if operation != 'resolve' or explain is not True: + raise FrameRejected('invalid_explain') + value['explain'] = True return encoded_frame(value, MAX_WORKER_BYTES, 'native_worker_frame_too_large') diff --git a/session_peer_relay/native.py b/session_peer_relay/native.py index a59206fb..b76f7709 100644 --- a/session_peer_relay/native.py +++ b/session_peer_relay/native.py @@ -229,9 +229,10 @@ class Native: def __init__(self): self.slots = asyncio.Semaphore(2) - async def invoke(self, binding, operation, text=None, *, peer_fingerprint=None): + async def invoke(self, binding, operation, text=None, *, peer_fingerprint=None, explain=False): try: - payload = worker_frame(binding, operation, text, peer_fingerprint) + payload = worker_frame(binding, operation, text, peer_fingerprint, + **({'explain': True} if explain else {})) except FrameRejected as exc: return {'ok': False, 'status': 'refused', 'submitted': False, 'reason': str(exc), 'retryAllowed': False, 'consumptionConfirmed': False} diff --git a/session_peer_relay/worker.py b/session_peer_relay/worker.py index 61001259..390e0959 100644 --- a/session_peer_relay/worker.py +++ b/session_peer_relay/worker.py @@ -5,11 +5,33 @@ import session_peer as core from .native import Policy, options, invoke_windows_codex -from .framing import MAX_WORKER_BYTES, valid_text +from .framing import MAX_WORKER_BYTES, valid_text, valid_delivery_context + + +def delivery_context(binding, adapter, result): + """Use the executing registry and actual native resolution, never guessed homes.""" + agent = binding['agent'] + target = None + native_target = result.get('target') + if result.get('ok') is True: + if isinstance(native_target, dict): + identifier = native_target.get('pid' if agent == 'claude' else 'id') + if (agent == 'claude' and type(identifier) is int and identifier > 0 + or agent != 'claude' and isinstance(identifier, str) and identifier): + # Claude's public CLI adapter uses an unprefixed PID, whereas + # this private cross-agent context always carries an agent tag. + target = agent + ':' + str(identifier).lower() + home = result.get('codexHome' if agent == 'codex' else 'antigravityHome') if agent != 'claude' else None + value = {'schemaVersion': 1, 'agent': agent, 'target': target, 'home': home, + 'capabilities': {key: getattr(adapter.capabilities, key) + for key in ('send', 'wake', 'wait', 'ack')}} + return value if valid_delivery_context(value) else None def main(): attempted = False + explain = False + binding = adapter = None try: raw = sys.stdin.buffer.read(MAX_WORKER_BYTES + 1) if len(raw) > MAX_WORKER_BYTES: @@ -20,6 +42,9 @@ def main(): Policy({'targets': {'endpoint': binding}, 'peers': {}}) if op not in ('list', 'send', 'resolve'): raise ValueError('invalid_operation') + if 'explain' in value and (op != 'resolve' or value['explain'] is not True): + raise ValueError('invalid_explain') + explain = value.get('explain') is True adapter = core.AGENTS.get(binding['agent']) args = options(binding) args._peer_fingerprint = peer_fingerprint @@ -72,7 +97,16 @@ def main(): result = {'ok': False, 'reason': 'native_outcome_unknown' if attempted else 'native_refused', 'status': 'unknown' if attempted else 'refused', 'consumptionConfirmed': False, 'retryAllowed': False} + if explain and adapter is not None: + context = delivery_context(binding, adapter, result) + if context is not None: + result['deliveryContext'] = context encoded = json.dumps(result, ensure_ascii=False) + if len(encoded.encode()) > 60*1024 and 'deliveryContext' in result: + # Optional explanation metadata cannot erase independently usable + # native facts merely by taking the result over the legacy byte cap. + result.pop('deliveryContext', None) + encoded = json.dumps(result, ensure_ascii=False) if len(encoded.encode()) > 60*1024: encoded = json.dumps({'ok': False, 'reason': 'native_result_too_large', 'status': 'unknown', 'retryAllowed': False, 'consumptionConfirmed': False}) diff --git a/tests/core/test_delivery_explanation.py b/tests/core/test_delivery_explanation.py index 1bcc8976..2d38ab20 100644 --- a/tests/core/test_delivery_explanation.py +++ b/tests/core/test_delivery_explanation.py @@ -28,7 +28,6 @@ def invoke(self, *flags, target="worker"): def test_invalid_combinations_fail_before_updates_input_or_dispatch(self): variants = [[], ["--dry-run", "--json"], ["--dry-run", "--output-format", "json"], - ["--dry-run", "--device", "fixture"], ["--dry-run", "--correlation-id", "00000000-0000-4000-8000-000000000001"], ["--dry-run", "--request-ack"], ["--dry-run", "--observe-delivery"], @@ -51,7 +50,6 @@ def test_invalid_combinations_fail_before_updates_input_or_dispatch(self): self.assertEqual(exc.exception.code, 2) expected = ("--explain requires --dry-run" if not flags else "--explain requires text output" if "--json" in flags or "--output-format" in flags else - "--explain does not support paired-device sends" if "--device" in flags else "--explain does not support experimental handoff sends") self.assertIn("session-peer: error: " + expected, diagnostic.getvalue()) for action in (updates, skills, read, send, child): diff --git a/tests/core/test_explanation_context.py b/tests/core/test_explanation_context.py new file mode 100644 index 00000000..1e9703ef --- /dev/null +++ b/tests/core/test_explanation_context.py @@ -0,0 +1,167 @@ +"""Offline facts-only explanation context and privacy boundaries.""" +import argparse +import copy +import io +import unittest +from unittest import mock + +import session_peer as peer + + +class ExplanationContextTests(unittest.TestCase): + def args(self, **kwargs): + return argparse.Namespace(**{**dict(to="worker", host=[], device=None, wake=False), **kwargs}) + + def context(self, **kwargs): + return {**dict(schemaVersion=1, agent="codex", target="codex:00000000-0000-4000-8000-000000000001", home="/receiver/home", + capabilities=dict(send=True, wake=True, wait=False, ack=False)), **kwargs} + + def test_context_closed_versioned_and_boolean_contract(self): + valid = self.context() + self.assertEqual(peer.validated_delivery_context(valid), valid) + bad = [self.context(schemaVersion=True), self.context(schemaVersion=2), self.context(agent="other"), + self.context(target="session-peer://secret"), self.context(home=1), + self.context(capabilities={"send": 1, "wake": True, "wait": False, "ack": False}), + {**valid, "capability": "SECRET"}] + for value in bad: + with self.subTest(value=value): + self.assertIsNone(peer.validated_delivery_context(value)) + normalized = peer.validated_delivery_context(valid) + normalized["capabilities"]["send"] = False + self.assertTrue(valid["capabilities"]["send"]) + + def test_paired_missing_context_never_guesses_from_alias_or_sender(self): + args = self.args(device="fingerprint", to="codex:sender-alias", codex_home="/sender/home") + result = {"dryRun": True, "codexHome": "/unvalidated/home", "target": {"id": "unvalidated"}} + facts = peer.build_delivery_explanation(args, result) + self.assertIsNone(facts["agent"]) + self.assertIsNone(facts["capabilities"]) + rendered = peer.render_delivery_explanation(args, result) + for secret in ("/sender/home", "/unvalidated/home", "codex:sender-alias"): + self.assertNotIn(secret, rendered) + self.assertIn("Selected agent / home: unknown", rendered) + self.assertIn("Receipt ACK: unknown", rendered) + + def test_paired_uses_only_receiver_declarations(self): + args = self.args(device="fingerprint", to="opaque-alias", _delivery_explanation_context=self.context()) + rendered = peer.render_delivery_explanation(args, {"dryRun": True, "codexHome": "/wrong/home"}) + self.assertIn("codex:00000000-0000-4000-8000-000000000001", rendered) + self.assertIn("/receiver/home", rendered) + self.assertNotIn("/wrong/home", rendered) + self.assertIn("Wake capability: supported (declared; eligibility unverified)", rendered) + self.assertIn("Wake: not requested; eligibility not checked", rendered) + + def test_declarations_never_become_observed_eligibility(self): + rendered = peer.render_delivery_explanation(self.args(to="codex:id"), {"ok": True}) + self.assertIn("Submission capability: supported (declared; eligibility unverified)", rendered) + self.assertIn("Validation: not confirmed", rendered) + self.assertIn("Task completion: unknown", rendered) + self.assertIn("Last delivery observation: unknown", rendered) + + def test_paired_route_uses_only_existing_report_not_requested_auto(self): + args = self.args(device="fingerprint", to="opaque-alias", device_route="auto") + for route in ("direct", "relay", None, "auto"): + output = peer.render_delivery_explanation(args, {"dryRun": True, "route": route}) + self.assertIn("Selected paired route: " + (route + " (reported)" if route in ("direct", "relay") else "unknown (not reported)"), output) + + def test_doctor_reuses_only_supplied_diagnostics(self): + result = {"codex": {"status": "available", "selectedHome": "/receiver/\x1bhome"}, + "claude": {"status": "PRIVATE-ERROR"}, + "capabilities": {"agents": {"codex": {"send": True, "wake": True, "wait": False, "ack": False}}}} + original = copy.deepcopy(result) + with mock.patch.object(peer, "doctor_payload", side_effect=AssertionError("new diagnostic")), \ + mock.patch.object(peer, "collect_listing", side_effect=AssertionError("new discovery")), \ + mock.patch.object(peer.subprocess, "run", side_effect=AssertionError("new process")): + rendered = peer.render_delivery_explanation(self.args(), result, mode="doctor", host="worker\n") + self.assertIn("codex availability: available", rendered) + self.assertIn("not per-target readiness", rendered) + self.assertIn("Selected home: /receiver/\\x1bhome", rendered) + self.assertNotIn("PRIVATE-ERROR", rendered) + self.assertNotIn("\x1b", rendered) + self.assertIn("Destination host: worker\\x0a", rendered) + self.assertEqual(result, original) + + def test_doctor_unknown_capabilities_not_filled_from_local_registry(self): + rendered = peer.render_delivery_explanation(self.args(), {"codex": {"status": "available"}}, mode="doctor") + self.assertIn("Wake: unknown (not reported)", rendered) + self.assertNotIn(": supported (declared", rendered) + + def test_existing_intent_keeps_receipt_latest_wait_and_completion_separate(self): + result = {"handoff": {"submission": {"status": "submitted"}, + "observation": {"status": "unsupported", "injectionObserved": False}, + "ack": {"status": "acknowledged", "assurance": "token_possession", "receivedAtUtcMs": 12345, "late": True}, + "wait": {"status": "satisfied", "for": "acknowledged"}}} + rendered = peer.render_delivery_explanation(self.args(), result, mode="existing_intent") + self.assertIn("Explicit receipt: acknowledged", rendered) + self.assertIn("Latest stored wait: satisfied", rendered) + self.assertIn("Receipt assurance: token_possession", rendered) + self.assertIn("Receipt late relative to first wait: true", rendered) + self.assertIn("UTC milliseconds): 12345", rendered) + self.assertIn("Task completion: unknown", rendered) + self.assertIn("Stored evidence may be stale", rendered) + + def test_non_acknowledged_timestamp_cannot_become_receipt_observation(self): + result = {"handoff": {"ack": {"status": "unsupported", "receivedAtUtcMs": 12345, "late": True}}} + rendered = peer.render_delivery_explanation(self.args(), result, mode="existing_intent") + self.assertIn("Explicit receipt: unsupported", rendered) + self.assertNotIn("12345", rendered) + self.assertNotIn("late relative", rendered) + + def test_existing_intent_history_failure_is_unknown_not_new_observation(self): + result = {"error": "SECRET", "handoffQuery": {"status": "unknown", "context": "history_unavailable"}} + with mock.patch.object(peer, "HandoffLedger", side_effect=AssertionError("new ledger read")), \ + mock.patch.object(peer, "handoff_ensure_collector", side_effect=AssertionError("new collector")): + rendered = peer.render_delivery_explanation(self.args(), result, mode="existing_intent", failed=True) + self.assertIn("Submission: unknown", rendered) + self.assertNotIn("SECRET", rendered) + self.assertIn("Do not automatically resend", rendered) + + def test_private_fields_and_control_characters_never_escape_allowlist(self): + result = {"handoff": {"capability": "SECRET", "targetGeneration": "SECRET", "native": "SECRET", + "ack": {"status": "pending", "receiptId": "SECRET", "assurance": "SECRET"}}, + "message": "SECRET", "error": "SECRET", "reason": "SECRET"} + rendered = peer.render_delivery_explanation(self.args(), result, mode="existing_intent") + self.assertNotIn("SECRET", rendered) + + def test_emitter_mode_and_stream_are_backward_compatible(self): + output = io.StringIO() + peer.emit_delivery_explanation(self.args(), {}, stream=output, mode="doctor") + self.assertIn("Delivery capabilities", output.getvalue()) + + def test_malformed_status_values_are_unknown(self): + rendered = peer.render_delivery_explanation(self.args(), {"codex": {"status": []}}, mode="doctor") + self.assertIn("codex availability: unknown", rendered) + rendered = peer.render_delivery_explanation(self.args(), {"handoff": {"ack": {"status": []}}}, mode="existing_intent") + self.assertIn("Explicit receipt: unknown", rendered) + + + def test_bidi_is_escaped_only_in_explanations(self): + home = "/receiver/\u202eprofile" + args = self.args(device="fingerprint", to="alias", _delivery_explanation_context=self.context(home=home)) + output = peer.render_delivery_explanation(args, {"dryRun": True}) + self.assertNotIn("\u202e", output) + self.assertIn("\\u202e", output) + self.assertEqual(args._delivery_explanation_context["home"], home) + + def test_unavailable_history_is_not_presented_as_read_observation(self): + output = peer.render_delivery_explanation(self.args(), {"handoffQuery": {"context": "ledger_missing"}}, + mode="existing_intent", failed=True) + self.assertIn("history not read", output) + self.assertIn("History query: ledger_missing", output) + self.assertNotIn("stored observation", output) + + def test_receipt_time_is_recorded_not_assumed_physical_arrival(self): + public = {"ack": {"status": "acknowledged", "assurance": "operator_confirmed", + "receivedAtUtcMs": 123, "late": True}} + output = peer.render_delivery_explanation(self.args(), {"handoff": public}, mode="existing_intent") + self.assertIn("Receipt recorded at (stored UTC milliseconds): 123", output) + self.assertNotIn("Receipt observed at", output) + stream = io.StringIO() + with mock.patch.object(peer.time, "time", side_effect=OSError("private clock diagnostic")): + peer.emit_delivery_explanation(self.args(), {"handoff": public}, mode="existing_intent", stream=stream) + self.assertIn("Explicit receipt: acknowledged", stream.getvalue()) + self.assertNotIn("private clock", stream.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_explanation_modes.py b/tests/core/test_explanation_modes.py new file mode 100644 index 00000000..1770ad1a --- /dev/null +++ b/tests/core/test_explanation_modes.py @@ -0,0 +1,137 @@ +"""New modes reuse existing facts; no extra probes or native submission.""" +import contextlib +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import session_peer as peer + + +class ExplanationModes(unittest.TestCase): + def invoke(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = peer.main(argv) + return code, out.getvalue(), err.getvalue() + + def test_doctor_reuses_exactly_one_existing_payload_and_keeps_default_json(self): + payload = {"status": "partial", "claude": {"status": "available"}, + "capabilities": {"agents": {"claude": {"send": True, "wake": False, "wait": False, "ack": False}}}} + with mock.patch.object(peer, "doctor_payload", return_value=payload) as doctor, \ + mock.patch.object(peer, "render_doctor", return_value="Existing diagnostics"), \ + mock.patch.object(peer.subprocess, "Popen", side_effect=AssertionError("extra process")): + code, plain, _ = self.invoke(["doctor", "--no-update-notice"]) + self.assertEqual(code, 0) + self.assertNotIn("Delivery capabilities", plain) + code, explained, _ = self.invoke(["doctor", "--explain", "--no-update-notice"]) + self.assertEqual(code, 0) + self.assertIn("Delivery capabilities", explained) + self.assertIn("not per-target readiness", explained) + self.assertIn("not a delivery observation", explained) + self.assertEqual(doctor.call_count, 2) + code, encoded, _ = self.invoke(["doctor", "--json", "--no-update-notice"]) + self.assertEqual(code, 0) + self.assertNotIn("deliveryExplanation", json.loads(encoded)) + self.assertNotIn("Delivery capabilities", encoded) + + def test_remote_doctor_explains_each_existing_response_without_extra_ssh(self): + payload = {"status": "partial", "capabilities": {"agents": {}}, "host": "remote"} + with mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport") as transport, \ + mock.patch.object(peer, "render_doctor", return_value="Existing remote diagnostics"): + transport.return_value.host = "resolved" + transport.return_value.ssh_opts = [] + transport.return_value.execute.return_value = payload + code, output, _ = self.invoke(["doctor", "--host", "one", "--host", "two", "--explain", "--no-update-notice"]) + self.assertEqual(code, 0) + self.assertEqual(transport.call_count, 2) + self.assertEqual(transport.return_value.execute.call_count, 2) + self.assertTrue(all("--explain" not in c.args[0] for c in transport.return_value.execute.call_args_list)) + self.assertEqual(output.count("Delivery capabilities"), 2) + + def test_new_json_combinations_fail_before_advisory_or_destination_work(self): + for argv in (["doctor", "--explain", "--json"], + ["handoff", "status", "--correlation-id", "00000000-0000-4000-8000-000000000001", "--explain", "--json"]): + with mock.patch.object(peer, "prepare_client_update") as updates, \ + mock.patch.object(peer, "prepare_skill_updates") as skills, \ + mock.patch.object(peer, "HandoffLedger") as ledger, \ + mock.patch.object(peer, "doctor_payload") as doctor, \ + mock.patch.object(peer.sys, "argv", ["session-peer", *argv]), \ + contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error: + peer.main() + self.assertEqual(error.exception.code, 2) + for operation in (updates, skills, ledger, doctor): + operation.assert_not_called() + + def test_remote_doctor_partial_failure_still_explains_each_host(self): + payload = {"status": "partial", "capabilities": {"agents": {}}} + with mock.patch.object(peer, "tailscale_status", return_value={}), \ + mock.patch.object(peer, "SshTransport") as transport, \ + mock.patch.object(peer, "render_doctor", return_value="Existing diagnostics"): + transport.return_value.host = "resolved" + transport.return_value.ssh_opts = [] + transport.return_value.execute.side_effect = [payload, peer.CcPeerError("fixture transport failed")] + code, output, err = self.invoke(["doctor", "--host", "one", "--host", "two", "--explain", "--no-update-notice"]) + self.assertEqual(code, 1) + self.assertEqual(transport.return_value.execute.call_count, 2) + self.assertEqual(output.count("Delivery capabilities"), 1) + self.assertEqual(err.count("Delivery capabilities"), 1) + self.assertIn("unknown (not reported", err) + + @unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "local POSIX ledger") + def test_status_explain_clock_is_readonly_and_legacy_failure_never_retries(self): + epoch = "00000000-0000-4000-8000-000000000001" + correlation = "00000000-0000-4000-8000-000000000002" + public = {"submission": {"status": "submitted"}, "observation": {"status": "unsupported"}, + "ack": {"status": "pending"}, "wait": {"status": "not_requested"}} + record = {"capability": {"revoked": False}, "ack": None} + for response in ({"ok": True, "clockEpoch": epoch}, peer.handoff_error("receipt_operation_refused")): + with mock.patch.object(peer.HandoffLedger, "record", return_value=(epoch, record)) as read, \ + mock.patch.object(peer.HandoffLedger, "_save", side_effect=AssertionError("history write")), \ + mock.patch.object(peer, "handoff_public", return_value=public) as projection, \ + mock.patch.object(peer, "handoff_ipc", side_effect=response if isinstance(response, Exception) else None, + return_value=response) as ipc, \ + mock.patch.object(peer, "handoff_collector", side_effect=AssertionError("collector start")): + code, _, _ = self.invoke(["handoff", "status", "--correlation-id", correlation, "--explain"]) + self.assertEqual(code, 0) + read.assert_called_once_with(correlation, read_only=True) + self.assertEqual(ipc.call_count, 1) + self.assertEqual(ipc.call_args.args[1], {"op": "clock", "readOnly": True}) + self.assertEqual(projection.call_args.args[2], None if isinstance(response, Exception) else epoch) + + @unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "local POSIX ledger") + def test_handoff_explanation_never_persists_retention_or_creates_authority(self): + with tempfile.TemporaryDirectory(prefix="codex-explain-", dir=os.environ.get("SESSION_PEER_TEST_TMP")) as directory: + root = Path(directory).resolve() / "ledger" + ledger = peer.HandoffLedger(root) + ledger.initialize() + _, record = ledger.prepare({"agent": "claude", "destination": ["local"], + "target": str(os.getpid()), "home": None, "payloadDigest": "a" * 64}, "tg1:" + "b" * 64) + before = {p.name: (p.read_bytes(), p.stat().st_ino, p.stat().st_mtime_ns) for p in root.iterdir()} + with mock.patch.object(peer, "handoff_root", return_value=root), \ + mock.patch.object(peer.HandoffLedger, "_save", side_effect=AssertionError("history write")), \ + mock.patch.object(peer, "handoff_collector", side_effect=AssertionError("collector launch")), \ + mock.patch.object(peer, "handoff_ipc", side_effect=AssertionError("unneeded IPC")), \ + mock.patch.object(peer, "post_to_socket", side_effect=AssertionError("native post")), \ + mock.patch.object(peer.subprocess, "Popen", side_effect=AssertionError("child process")): + code, output, _ = self.invoke(["handoff", "status", "--correlation-id", record["id"], "--explain"]) + self.assertEqual(code, 0) + self.assertIn("existing intent snapshot", output) + self.assertIn("Task completion: unknown", output) + self.assertIn("Submission: not_attempted", output) + after = {p.name: (p.read_bytes(), p.stat().st_ino, p.stat().st_mtime_ns) for p in root.iterdir()} + self.assertTrue(before == after, "explanation mutated history") + + @unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "local POSIX ledger") + def test_missing_handoff_explanation_does_not_initialize_history(self): + with tempfile.TemporaryDirectory(prefix="codex-explain-", dir=os.environ.get("SESSION_PEER_TEST_TMP")) as directory: + root = Path(directory).resolve() / "missing" + with mock.patch.object(peer, "handoff_root", return_value=root): + code, _, err = self.invoke(["handoff", "status", "--correlation-id", "00000000-0000-4000-8000-000000000001", "--explain"]) + self.assertEqual(code, 1) + self.assertIn("unknown", err) + self.assertFalse(root.exists()) diff --git a/tests/relay/test_delivery_explanation.py b/tests/relay/test_delivery_explanation.py new file mode 100644 index 00000000..bdf3ebd7 --- /dev/null +++ b/tests/relay/test_delivery_explanation.py @@ -0,0 +1,210 @@ +"""Offline paired explanation negotiation; no native or model submission.""" +import contextlib +import copy +import io +import json +import sys +import unittest +from types import SimpleNamespace +from unittest import mock + +from tests.relay import test_native_relay as platform_guard +from session_peer_relay import app, cli, worker +from session_peer_relay.framing import valid_delivery_context, worker_frame + +UUID = '01900000-0000-7000-8000-000000000001' +CONTEXT = {'schemaVersion': 1, 'agent': 'codex', 'target': 'codex:' + UUID, + 'home': '/actual', 'capabilities': {'send': True, 'wake': True, 'wait': False, 'ack': False}} + + +class ContextValidation(unittest.TestCase): + def test_strict_bounded_optional_context(self): + self.assertTrue(valid_delivery_context(CONTEXT)) + self.assertTrue(valid_delivery_context({**CONTEXT, 'target': None, 'home': None})) + for change in ({'schemaVersion': True}, {'schemaVersion': 2}, {'agent': 'other'}, + {'home': 'relative'}, {'home': '/' + 'x' * 4096}, {'home': '/\ud800'}, + {'target': 'claude:1'}, {'target': 'codex:alias'}, + {'capabilities': {**CONTEXT['capabilities'], 'wake': 1}}, {'extra': True}): + self.assertFalse(valid_delivery_context({**CONTEXT, **change}), change) + + def test_context_only_explicit_resolve_and_actual_returned_facts(self): + binding = {'agent': 'codex', 'target': 'codex:' + UUID, 'codexHome': '/guessed'} + for explain in (False, True): + output = io.StringIO() + native = {'ok': True, 'target': {'agent': 'codex', 'id': UUID}, 'codexHome': '/actual'} + with mock.patch.object(sys, 'stdin', SimpleNamespace(buffer=io.BytesIO( + worker_frame(binding, 'resolve', 'fixture', explain=explain)))), \ + mock.patch.object(worker.core, 'codex_executable', return_value='fixture'), \ + mock.patch.object(worker.core.LocalTransport, 'execute', return_value=native) as execute, \ + contextlib.redirect_stdout(output): + worker.main() + result = json.loads(output.getvalue()) + self.assertEqual(execute.call_count, 1) + if explain: + self.assertEqual(result['deliveryContext'], CONTEXT) + else: + self.assertNotIn('deliveryContext', result) + no_facts = worker.delivery_context(binding, worker.core.AGENTS.get('codex'), {'ok': False}) + self.assertIsNone(no_facts['target']) + self.assertIsNone(no_facts['home']) + agy = worker.delivery_context({'agent': 'antigravity', 'target': 'antigravity:' + UUID}, + worker.core.AGENTS.get('antigravity'), + {'ok': True, 'antigravityHome': '/actual'}) + self.assertIsNone(agy['target']) + self.assertEqual(agy['home'], '/actual') + claude = worker.delivery_context({'agent': 'claude', 'target': 'worker'}, + worker.core.AGENTS.get('claude'), + {'ok': True, 'target': {'pid': 42, 'name': 'worker'}}) + self.assertEqual(claude['target'], 'claude:42') + self.assertIsNone(claude['home']) + + def test_invoke_strips_context_and_preserves_native_facts(self): + for explain in (False, True): + for context in (CONTEXT, {**CONTEXT, 'schemaVersion': 99}, None): + args = SimpleNamespace(device='a' * 64, command='send', dry_run=True, json=True, explain=explain) + native = {'ok': True, 'target': {'id': UUID}, 'codexHome': '/actual', + 'route': 'direct', 'deliveryContext': copy.deepcopy(context)} + with mock.patch.object(cli, 'core_exchange', mock.AsyncMock(return_value=native)), \ + mock.patch.object(cli.core, 'emit') as emit, \ + mock.patch.object(cli.core, 'emit_delivery_explanation') as explanation: + self.assertEqual(cli.invoke_core(args), 0) + emitted = emit.call_args.args[1] + self.assertNotIn('deliveryContext', emitted) + self.assertEqual(emitted['target'], {'id': UUID}) + self.assertEqual(emitted['codexHome'], '/actual') + self.assertEqual(hasattr(args, '_delivery_explanation_context'), explain and context == CONTEXT) + if explain: + explanation.assert_called_once_with(args, emitted, failed=False) + else: + explanation.assert_not_called() + + def test_optional_worker_context_cannot_displace_valid_native_result_at_cap(self): + binding = {'agent': 'codex', 'target': 'codex:' + UUID, 'codexHome': '/configured'} + native = {'ok': True, 'target': {'agent': 'codex', 'id': UUID}, 'codexHome': '/actual', + 'padding': 'x' * (60 * 1024 - 280)} + output = io.StringIO() + with mock.patch.object(sys, 'stdin', SimpleNamespace(buffer=io.BytesIO( + worker_frame(binding, 'resolve', 'fixture', explain=True)))), \ + mock.patch.object(worker.core, 'codex_executable', return_value='fixture'), \ + mock.patch.object(worker.core.LocalTransport, 'execute', return_value=native), \ + contextlib.redirect_stdout(output): + worker.main() + result = json.loads(output.getvalue()) + self.assertTrue(result['ok']) + self.assertEqual(result['target']['id'], UUID) + self.assertEqual(result['padding'], native['padding']) + self.assertNotIn('deliveryContext', result) + + def test_default_json_has_no_context_or_extra_output(self): + args = SimpleNamespace(device='a' * 64, command='send', dry_run=True, json=True) + native = {'ok': True, 'target': {'id': UUID}, 'codexHome': '/actual', + 'deliveryContext': CONTEXT} + output = io.StringIO() + with mock.patch.object(cli, 'core_exchange', mock.AsyncMock(return_value=native)), \ + mock.patch.object(cli.core, 'emit_delivery_explanation') as explanation, \ + contextlib.redirect_stdout(output): + self.assertEqual(cli.invoke_core(args), 0) + result = json.loads(output.getvalue()) + self.assertNotIn('deliveryContext', result) + self.assertEqual(result['target'], {'id': UUID}) + self.assertEqual(result['codexHome'], '/actual') + self.assertFalse(hasattr(args, '_delivery_explanation_context')) + explanation.assert_not_called() + + def test_real_cli_reports_device_validation_stage(self): + # Exercise main -> cmd_send -> relay -> actual explanation renderer. + argv = ['send', '--device', 'a' * 64, '--to', 'alias', '--message', 'fixture', + '--dry-run', '--explain', '--no-from', '--no-update-notice'] + for result, expected in (({'ok': True, 'status': 'validated', 'dryRun': True, + 'deliveryContext': CONTEXT}, 'Validation: passed for this dry run'), + ({'ok': False, 'status': 'unknown', 'retryAllowed': False}, + 'Validation: result unknown')): + out, err = io.StringIO(), io.StringIO() + with mock.patch.object(cli, 'Store'), \ + mock.patch.object(cli, 'device_credential', return_value=None), \ + mock.patch.object(cli, 'exchange', mock.AsyncMock(return_value=result)) as exchange, \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.core.main(argv) + self.assertEqual(code, 0 if result['ok'] else 1) + self.assertIn(expected, out.getvalue() + err.getvalue()) + self.assertNotIn('Destination checks: not run', out.getvalue() + err.getvalue()) + exchange.assert_awaited_once() + for rejected in ('message', 'credential', 'options'): + out, err = io.StringIO(), io.StringIO() + options = [*argv, '--codex-home', '/sender'] if rejected == 'options' else argv + with mock.patch.object(cli, 'Store'), \ + mock.patch.object(cli.core, 'check_message', + side_effect=cli.Rejected('invalid_message') if rejected == 'message' else None), \ + mock.patch.object(cli, 'device_credential', + side_effect=cli.Rejected('login_missing') if rejected == 'credential' else None), \ + mock.patch.object(cli, 'exchange', mock.AsyncMock()) as exchange, \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + self.assertEqual(cli.core.main(options), 1) + self.assertIn('Destination checks: not run', out.getvalue() + err.getvalue()) + exchange.assert_not_awaited() + + +class Negotiation(unittest.IsolatedAsyncioTestCase): + async def test_core_exchange_requests_context_only_for_explicit_dry_run(self): + for command, dry_run, explain in (('send', True, True), ('send', True, False), + ('send', False, True), ('list', False, True)): + args = SimpleNamespace(command=command, dry_run=dry_run, explain=explain, + device='a' * 64, host=None, wake=False, reply_to=None, + b64=False, no_from=True, to='alias', device_state=None, + device_route='auto', agent=None) + store = mock.Mock() + with mock.patch.object(cli, 'Store', return_value=store), \ + mock.patch.object(cli.core, 'read_message', return_value='fixture'), \ + mock.patch.object(cli.core, 'check_message'), \ + mock.patch.object(cli, 'device_credential', return_value=None), \ + mock.patch.object(cli, 'exchange', mock.AsyncMock(return_value={'ok': True})) as exchange: + await cli.core_exchange(args) + self.assertEqual(exchange.call_args.kwargs, + {'explain': True} if command == 'send' and dry_run and explain else {}) + store.close.assert_called_once() + + async def test_only_explicit_resolve_on_advertising_receiver_gets_flag(self): + store = mock.Mock(device='a' * 64) + store.recovery_required.return_value = False + store.peer.return_value = {'status': 'paired', 'certificate': 'fixture', + 'routes': {'direct': '127.0.0.1:1'}} + peer = 'b' * 64 + for op in ('resolve', 'send', 'list'): + for explain in (False, True, 1): + for feature in (False, True): + probe = {'ok': True, 'device': peer} + if feature: + probe['features'] = ['delivery-explanation-v1'] + native = {'ok': True, 'target': {'id': UUID}, 'codexHome': '/actual', + 'deliveryContext': CONTEXT} + channel = SimpleNamespace(send=mock.AsyncMock(), close=mock.AsyncMock(), + recv=mock.AsyncMock(side_effect=[probe, native])) + body = None if op == 'list' else {'target': 'alias', 'message': 'fixture'} + with mock.patch.object(app, 'open_channel', mock.AsyncMock(return_value=channel)) as connect: + result = await app.exchange(store, peer, op, body, route='direct', explain=explain) + connect.assert_awaited_once() + sent = [call.args[0] for call in channel.send.await_args_list] + self.assertEqual([item['op'] for item in sent], ['probe', op]) + expected = {**body, 'explain': True} if op == 'resolve' and explain is True and feature else body + self.assertEqual(sent[1]['body'], expected) + self.assertEqual(result['target'], {'id': UUID}) + self.assertEqual(result['codexHome'], '/actual') + negotiated = op == 'resolve' and explain is True and feature + self.assertEqual('deliveryContext' in result, negotiated) + if negotiated: + self.assertEqual(result['deliveryContext'], CONTEXT) + + async def test_receiver_resolve_only_optional_flag(self): + from tests.relay.test_message_framing import EffectBoundaries, SENDER, PEER, IDENT + fixture = EffectBoundaries() + for op, flag, accepted in (('resolve', True, True), ('resolve', False, False), ('send', True, False)): + receiver = fixture.receiver() + body = {'target': 'fixture', 'message': 'fixture', 'explain': flag} + value = app.request(fixture.store(), PEER, op, body, IDENT) + if accepted: + await receiver.dispatch(SENDER, value) + self.assertTrue(receiver.native.invoke.call_args.kwargs['explain']) + else: + with self.assertRaisesRegex(app.Rejected, 'invalid_body'): + await receiver.dispatch(SENDER, value) + receiver.native.invoke.assert_not_called() diff --git a/tools/generate_session_peer.py b/tools/generate_session_peer.py index 1cfdc81c..bb10defd 100644 --- a/tools/generate_session_peer.py +++ b/tools/generate_session_peer.py @@ -22,6 +22,7 @@ "replies.py", "ssh.py", "ssh_identity.py", + "explanation.py", "output.py", "diagnostics.py", "adapters.py",