From 5ac18465a0550a6c8fa1fb979c888886c55f6803 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Mon, 21 Sep 2026 09:50:06 +0900 Subject: [PATCH] chore: organize relay docs and deployment artifacts --- .github/workflows/ci.yml | 4 +- README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + README.zh-CN.md | 1 + deploy/README.md | 25 ++ .../examples/authenticated-control/README.md | 15 + .../session-peer-control.service | 0 deploy/examples/static-account/README.md | 12 + .../session-peer-relay.service | 0 deploy/{kr => ops/abruption-kr}/README.md | 0 .../abruption-kr}/admin-session-peer.caddy | 0 .../abruption-kr}/check-control-ready.py | 0 .../abruption-kr}/session-peer-admin.html | 0 .../session-peer-backup-snapshot.py | 0 .../session-peer-control-ready.service | 0 .../session-peer-control.service | 0 .../abruption-kr}/session-peer-relay.service | 0 .../abruption-kr}/session-peer-stack.target | 0 docs/README.md | 29 ++ docs/i18n-manifest.json | 13 +- docs/ja/README.md | 28 ++ docs/ja/paired-devices.md | 2 +- docs/ja/relay-auth.md | 2 +- docs/ja/relay-lifecycle.md | 2 +- docs/ja/validation/69-rc.md | 2 +- docs/ko/README.md | 28 ++ docs/ko/paired-devices.md | 2 +- docs/ko/relay-auth.md | 2 +- docs/ko/relay-lifecycle.md | 2 +- docs/ko/validation/69-rc.md | 2 +- docs/paired-devices.md | 2 +- docs/relay-auth.md | 2 +- docs/relay-lifecycle.md | 2 +- docs/validation/69-rc.md | 2 +- docs/zh-CN/README.md | 27 ++ docs/zh-CN/paired-devices.md | 2 +- docs/zh-CN/relay-auth.md | 2 +- docs/zh-CN/relay-lifecycle.md | 2 +- docs/zh-CN/validation/69-rc.md | 2 +- experiments/relay69/README.ja.md | 59 ---- experiments/relay69/README.ko.md | 57 ---- experiments/relay69/README.md | 122 --------- experiments/relay69/README.zh-CN.md | 57 ---- experiments/relay69/REPORT.ja.md | 64 ----- experiments/relay69/REPORT.ko.md | 64 ----- experiments/relay69/REPORT.md | 135 --------- experiments/relay69/REPORT.zh-CN.md | 116 -------- experiments/relay69/__init__.py | 1 - experiments/relay69/__main__.py | 123 --------- experiments/relay69/app.py | 259 ------------------ experiments/relay69/bench.py | 92 ------- experiments/relay69/identity.py | 72 ----- experiments/relay69/relay.py | 137 --------- experiments/relay69/requirements.txt | 5 - experiments/relay69/results.json | 157 ----------- experiments/relay69/sandbox_check.py | 14 - experiments/relay69/store.py | 148 ---------- experiments/relay69/wire.py | 175 ------------ pyproject.toml | 19 +- tests/test_docs_i18n.py | 8 +- ...test_relay69.py => test_relay_protocol.py} | 101 ++++--- 62 files changed, 280 insertions(+), 1922 deletions(-) create mode 100644 deploy/README.md create mode 100644 deploy/examples/authenticated-control/README.md rename deploy/{ => examples/authenticated-control}/session-peer-control.service (100%) create mode 100644 deploy/examples/static-account/README.md rename deploy/{ => examples/static-account}/session-peer-relay.service (100%) rename deploy/{kr => ops/abruption-kr}/README.md (100%) rename deploy/{kr => ops/abruption-kr}/admin-session-peer.caddy (100%) rename deploy/{kr => ops/abruption-kr}/check-control-ready.py (100%) rename deploy/{kr => ops/abruption-kr}/session-peer-admin.html (100%) rename deploy/{kr => ops/abruption-kr}/session-peer-backup-snapshot.py (100%) rename deploy/{kr => ops/abruption-kr}/session-peer-control-ready.service (100%) rename deploy/{kr => ops/abruption-kr}/session-peer-control.service (100%) rename deploy/{kr => ops/abruption-kr}/session-peer-relay.service (100%) rename deploy/{kr => ops/abruption-kr}/session-peer-stack.target (100%) create mode 100644 docs/README.md create mode 100644 docs/ja/README.md create mode 100644 docs/ko/README.md create mode 100644 docs/zh-CN/README.md delete mode 100644 experiments/relay69/README.ja.md delete mode 100644 experiments/relay69/README.ko.md delete mode 100644 experiments/relay69/README.md delete mode 100644 experiments/relay69/README.zh-CN.md delete mode 100644 experiments/relay69/REPORT.ja.md delete mode 100644 experiments/relay69/REPORT.ko.md delete mode 100644 experiments/relay69/REPORT.md delete mode 100644 experiments/relay69/REPORT.zh-CN.md delete mode 100644 experiments/relay69/__init__.py delete mode 100644 experiments/relay69/__main__.py delete mode 100644 experiments/relay69/app.py delete mode 100644 experiments/relay69/bench.py delete mode 100644 experiments/relay69/identity.py delete mode 100644 experiments/relay69/relay.py delete mode 100644 experiments/relay69/requirements.txt delete mode 100644 experiments/relay69/results.json delete mode 100644 experiments/relay69/sandbox_check.py delete mode 100644 experiments/relay69/store.py delete mode 100644 experiments/relay69/wire.py rename tests/{test_relay69.py => test_relay_protocol.py} (80%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0aee55b3..274fd886 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,12 +157,12 @@ jobs: with: python-version: ${{ matrix.python }} - run: python -m pip install '.[relay,mcp]' build - - run: python -m unittest tests.test_native_relay tests.test_relay69 tests.test_relay_lifecycle tests.test_relay_auth tests.test_relay_control -v + - run: python -m unittest tests.test_native_relay tests.test_relay_protocol tests.test_relay_lifecycle tests.test_relay_auth tests.test_relay_control -v - run: python -m build - name: Verify optional transport packaging run: | python -c 'import glob,zipfile; z=zipfile.ZipFile(glob.glob("dist/*.whl")[0]); assert "session_peer_relay/cli.py" in z.namelist(); assert not any(n.startswith("experiments/") for n in z.namelist())' - python -c 'import glob,tarfile; t=tarfile.open(glob.glob("dist/*.tar.gz")[0]); assert not any("/experiments/" in n for n in t.getnames())' + python -c 'import glob,tarfile; t=tarfile.open(glob.glob("dist/*.tar.gz")[0]); n=t.getnames(); assert not any("/experiments/" in p for p in n); assert any(p.endswith("/docs/README.md") for p in n); assert any(p.endswith("/deploy/examples/static-account/session-peer-relay.service") for p in n); assert any(p.endswith("/deploy/examples/authenticated-control/session-peer-control.service") for p in n); assert not any("/deploy/ops/" in p for p in n)' - name: Optional management CLI smoke run: | session-peer device --help diff --git a/README.ja.md b/README.ja.md index d21f8c14..12b4c45a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -95,6 +95,7 @@ npx -y skills@latest add abruption/session-peer-skill \ ## ドキュメント - [プロジェクトガイドサイト](https://abruption.dev/projects/session-peer/): 概要、クイックスタート、ドキュメントへの入口 +- [リポジトリ文書マップ](https://github.com/abruption/session-peer/blob/main/docs/ja/README.md): ユーザー、統合、運用、開発、履歴資料 - [CLIリファレンス](https://github.com/abruption/session-peer/blob/main/docs/ja/cli-reference.md): コマンド、JSON、環境変数、更新、制約、検証履歴 - [診断と返信](https://github.com/abruption/session-peer/blob/main/docs/ja/diagnostics.md) · [複数のCodexホーム](https://github.com/abruption/session-peer/blob/main/docs/ja/multi-home-list.md) - [cc-peerからの移行](https://github.com/abruption/session-peer/blob/main/docs/ja/cli-reference.md#moving-from-cc-peer) · [リリース](https://github.com/abruption/session-peer/releases) diff --git a/README.ko.md b/README.ko.md index 96fbf1a7..430138f5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -95,6 +95,7 @@ npx -y skills@latest add abruption/session-peer-skill \ ## 문서 - [프로젝트 안내 사이트](https://abruption.dev/projects/session-peer/): 핵심 소개, 빠른 시작과 문서 진입점 +- [저장소 문서 지도](https://github.com/abruption/session-peer/blob/main/docs/ko/README.md): 사용자, 통합, 운영, 개발, 이력 문서 분류 - [CLI 상세](https://github.com/abruption/session-peer/blob/main/docs/ko/cli-reference.md): 명령, JSON, 환경 변수, 업데이트, 제약, 검증 이력 - [진단·회신](https://github.com/abruption/session-peer/blob/main/docs/ko/diagnostics.md) · [여러 Codex 홈](https://github.com/abruption/session-peer/blob/main/docs/ko/multi-home-list.md) - [cc-peer에서 전환](https://github.com/abruption/session-peer/blob/main/docs/ko/cli-reference.md#moving-from-cc-peer) · [릴리스](https://github.com/abruption/session-peer/releases) diff --git a/README.md b/README.md index c88735aa..074ac085 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Install the alpha explicitly with `pipx install 'session-peer[relay]==1.0.0a1'`; ## Documentation - [Project site](https://abruption.dev/projects/session-peer/): a curated overview, quick start, and documentation entry point. +- [Repository documentation map](https://github.com/abruption/session-peer/blob/main/docs/README.md): user, integration, operator, development, and historical references. - [CLI reference](https://github.com/abruption/session-peer/blob/main/docs/cli-reference.md): commands, JSON, environment variables, updates, limits, and validation history. - [Diagnostics and replies](https://github.com/abruption/session-peer/blob/main/docs/diagnostics.md) · [Multiple Codex homes](https://github.com/abruption/session-peer/blob/main/docs/multi-home-list.md) - [Moving from cc-peer](https://github.com/abruption/session-peer/blob/main/docs/cli-reference.md#moving-from-cc-peer) · [Releases](https://github.com/abruption/session-peer/releases) diff --git a/README.zh-CN.md b/README.zh-CN.md index d8f8499a..ed2b3d71 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -93,6 +93,7 @@ npx -y skills@latest add abruption/session-peer-skill \ ## 文档 - [项目指南网站](https://abruption.dev/projects/session-peer/):核心概览、快速开始和文档入口 +- [仓库文档导航](https://github.com/abruption/session-peer/blob/main/docs/zh-CN/README.md):用户、集成、运维、开发和历史资料 - [CLI参考](https://github.com/abruption/session-peer/blob/main/docs/zh-CN/cli-reference.md):命令、JSON、环境变量、更新、限制及验证记录 - [诊断与回复](https://github.com/abruption/session-peer/blob/main/docs/zh-CN/diagnostics.md) · [多个Codex主目录](https://github.com/abruption/session-peer/blob/main/docs/zh-CN/multi-home-list.md) - [从cc-peer迁移](https://github.com/abruption/session-peer/blob/main/docs/zh-CN/cli-reference.md#moving-from-cc-peer) · [发行版本](https://github.com/abruption/session-peer/releases) diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 00000000..96c747d2 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,25 @@ +# Deployment material + +Deployment files are separated by purpose so an example cannot be mistaken for +the live Abruption topology. + +## Reusable examples + +- [`examples/static-account`](examples/static-account/README.md) runs only the + blind relay with operator-provisioned hashed bearer accounts. It has no OAuth, + browser UI, control database, public-state publisher or managed revocation. +- [`examples/authenticated-control`](examples/authenticated-control/README.md) + is the control-service half of a managed deployment. It requires an explicit + migration, provider credentials, a compatible managed relay, readiness and + recovery policy. It must not be combined with the static-account relay unit. + +These files are templates. Copying a unit is not installation or validation. +Review paths, service identity, credentials, resource limits, network policy and +backup scope for the target host before enabling anything. + +## Instance-specific operations + +[`ops/abruption-kr`](ops/abruption-kr/README.md) preserves secret-free copies of +the deployed KR service contracts and recovery helper. Those files document one +reviewed installation; their paths, users, runtime and Authelia/Caddy topology +are not reusable defaults. diff --git a/deploy/examples/authenticated-control/README.md b/deploy/examples/authenticated-control/README.md new file mode 100644 index 00000000..e430c8fe --- /dev/null +++ b/deploy/examples/authenticated-control/README.md @@ -0,0 +1,15 @@ +# Authenticated control example + +This directory contains the control-service template for browser OAuth, native +device authorization, managed enrollment and publication of relay verification +state. It is not a complete deployment by itself. + +Run the compiled migration explicitly under the same service identity and writer +lock before starting the service. Configure only enabled provider credentials, +the canonical HTTPS origin, protected private state and a dedicated public-state +directory. A compatible relay must consume that directory read-only, retain its +replay/high-water state, fail closed on stale state and participate in an +explicit readiness/recovery topology. + +Do not combine this unit with the static-account relay example. For a complete, +instance-specific reference see [`../../ops/abruption-kr`](../../ops/abruption-kr/README.md). diff --git a/deploy/session-peer-control.service b/deploy/examples/authenticated-control/session-peer-control.service similarity index 100% rename from deploy/session-peer-control.service rename to deploy/examples/authenticated-control/session-peer-control.service diff --git a/deploy/examples/static-account/README.md b/deploy/examples/static-account/README.md new file mode 100644 index 00000000..3cc52dbc --- /dev/null +++ b/deploy/examples/static-account/README.md @@ -0,0 +1,12 @@ +# Static-account blind relay example + +This example runs `session-peer relay serve --accounts ...` with a private JSON +file containing only hashed bearer admission values, rooms and roles. It does +not run the browser control service and cannot provide OAuth login, managed +device enrollment, operation receipts or immediate managed revocation. + +Adapt the paths and supply `accounts.json` through systemd credentials. Keep the +listener on loopback behind a reviewed TLS reverse proxy. The service limits are +initial safety bounds rather than a capacity guarantee. Validate the final unit +with `systemd-analyze verify` and a loopback authentication test before enabling +it. diff --git a/deploy/session-peer-relay.service b/deploy/examples/static-account/session-peer-relay.service similarity index 100% rename from deploy/session-peer-relay.service rename to deploy/examples/static-account/session-peer-relay.service diff --git a/deploy/kr/README.md b/deploy/ops/abruption-kr/README.md similarity index 100% rename from deploy/kr/README.md rename to deploy/ops/abruption-kr/README.md diff --git a/deploy/kr/admin-session-peer.caddy b/deploy/ops/abruption-kr/admin-session-peer.caddy similarity index 100% rename from deploy/kr/admin-session-peer.caddy rename to deploy/ops/abruption-kr/admin-session-peer.caddy diff --git a/deploy/kr/check-control-ready.py b/deploy/ops/abruption-kr/check-control-ready.py similarity index 100% rename from deploy/kr/check-control-ready.py rename to deploy/ops/abruption-kr/check-control-ready.py diff --git a/deploy/kr/session-peer-admin.html b/deploy/ops/abruption-kr/session-peer-admin.html similarity index 100% rename from deploy/kr/session-peer-admin.html rename to deploy/ops/abruption-kr/session-peer-admin.html diff --git a/deploy/kr/session-peer-backup-snapshot.py b/deploy/ops/abruption-kr/session-peer-backup-snapshot.py similarity index 100% rename from deploy/kr/session-peer-backup-snapshot.py rename to deploy/ops/abruption-kr/session-peer-backup-snapshot.py diff --git a/deploy/kr/session-peer-control-ready.service b/deploy/ops/abruption-kr/session-peer-control-ready.service similarity index 100% rename from deploy/kr/session-peer-control-ready.service rename to deploy/ops/abruption-kr/session-peer-control-ready.service diff --git a/deploy/kr/session-peer-control.service b/deploy/ops/abruption-kr/session-peer-control.service similarity index 100% rename from deploy/kr/session-peer-control.service rename to deploy/ops/abruption-kr/session-peer-control.service diff --git a/deploy/kr/session-peer-relay.service b/deploy/ops/abruption-kr/session-peer-relay.service similarity index 100% rename from deploy/kr/session-peer-relay.service rename to deploy/ops/abruption-kr/session-peer-relay.service diff --git a/deploy/kr/session-peer-stack.target b/deploy/ops/abruption-kr/session-peer-stack.target similarity index 100% rename from deploy/kr/session-peer-stack.target rename to deploy/ops/abruption-kr/session-peer-stack.target diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..8ad5dac8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# session-peer documentation + +English is the canonical source. Korean, Japanese and Simplified Chinese editions +mirror the same navigation and normative behavior. Start with the shortest guide +for your goal; protocol and validation records are review material, not parallel +installation paths. + +## Users + +- [CLI reference](cli-reference.md) covers local and SSH discovery, sending, JSON, updates and limits. +- [Diagnostics and replies](diagnostics.md) explains truthful submission states and safe troubleshooting. +- [Multiple Codex homes](multi-home-list.md), [explicit wake](wake.md) and [Antigravity](antigravity.md) cover optional agent workflows. + +## AI-assisted setup and integrations + +- [AI assistant guide](ai-assistant-guide.md) is the bounded handoff for an agent performing setup or verification. +- [MCP](mcp.md) and [agent adapters](agent-adapters.md) describe optional integrations and policy boundaries. + +## Paired devices and operators + +- [Paired devices](paired-devices.md) is the user path for direct or hosted end-to-end encrypted relay use. +- [Relay authentication](relay-auth.md), [lifecycle and recovery](relay-lifecycle.md), [watchdog](relay-watchdog.md), and [edge policy](relay-edge-policy.md) are operator references. +- [Deployment material](../deploy/README.md) separates reusable examples from the Abruption KR operational reference. + +## Development and history + +- [Agent transport architecture](architecture/agent-transports.md) and the [relay development plan](relay-development-plan.md) explain implementation boundaries. +- [Release notes](releases/v1.0.0-alpha.1.md) describe published behavior; [validation records](validation/69-rc.md) preserve scoped evidence and limitations. +- The completed relay69 prototype source was removed after its unique regressions moved to the product relay suite. Git history remains the source for obsolete prototype code. diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index 1cdcddcf..b0001600 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -2,9 +2,10 @@ "canonicalLocale": "en", "schemaVersion": 1, "sources": { - "README.md": "735bd8e4dca1e41e0aa00651df8adf70aace46c4733da830232aa53edefc4deb", + "README.md": "551ae5d5c9403185ea13305d5b71cef3ce21f8761ad3d3039d73ab2638cf8daf", "RELEASING.md": "cfbe28076a6126b7691aeda1172e7315ee4eb61ab80312c7e63d5d577e316184", "SECURITY.md": "83551e78e3525cb7e2614b303f7c6758bbddf0442bfe1b3e4529635cf2fec918", + "docs/README.md": "3061ee59816751d50a3589b7d8bec8dcf069c95154e484de05b258b9ab034757", "docs/agent-adapters.md": "bf185d3121c94829bb412071a25c4d943302e10a6b2e713111e653b8f8c3ea91", "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "6937c0208464dc536ce810abd70e449b65cb72e1d937a3d818339f46a9ab11d4", @@ -14,12 +15,12 @@ "docs/download-metrics.md": "ded60e7b84d14ce779f8d639516b4e390358a06ef76b677dc9c1f997fb626a9f", "docs/mcp.md": "e99689b8e6a222babc9438be2b2aabee953c3e0b2f967c6582065569529c32da", "docs/multi-home-list.md": "f8d8e3adf727e4842ea7c4d45e8f6523b11d0513bf2509d4d1204645875e6489", - "docs/paired-devices.md": "ad02ac67398d22b9cd2a227d50763cc389b598431f9feb6ac86802eb30c83bce", - "docs/relay-auth.md": "6ef0d8017dc55c57c286eef058a40e0a6622e982d5fa0a4b0cc1775241f55be3", + "docs/paired-devices.md": "df6331349ba2c34a81d7dd9a4298665f18306af110086bad4bf1e1e2c1dd4a7d", + "docs/relay-auth.md": "b2ff176cb93ffa2a42c81111d8b152f66ec6d85e846c64ad33d62d6aa41faf8c", "docs/relay-development-plan.md": "f33ea62a59809ea708f679e5a0ec5fbfd7e40948c730adce3a7b214a1409cbad", "docs/relay-edge-policy.md": "c1c7f7a1c4303943c78ca334dab4034f66c0c272e40c007d07c3da2a496155eb", "docs/relay-google-bootstrap.md": "bf6ce95c1ba39a56b765542d90e49c24211de5dd7cecb453819dfc35388bed9d", - "docs/relay-lifecycle.md": "8a078ae197295088fb3b9c81dadb7b852c8cdea612e768db87d1fe64562295b5", + "docs/relay-lifecycle.md": "b9ce6d5b39548e9cdb555d81947f624941007e13e98fb8fef278ced2af666699", "docs/relay-public-pilot-2026-09-17.md": "e86c5528533178663f38662e15f1d7a12b6db1eab9a722e3bdbd29dd033bc1ac", "docs/relay-watchdog.md": "bccb789ed27e3ec5ae36343f91b2d2710d6bb3c4c8735353a07d98c8f258b5ae", "docs/releases/v0.6.1.md": "7fae8ab4498228d8498518c1de20de857271ae14089fe9e0bf6f7af828bd77fc", @@ -28,11 +29,9 @@ "docs/releases/v0.8.0.md": "317f238c008aa7679653c0cd74a8de3fb58108d3602d88e63bd5bf486744052c", "docs/releases/v0.9.0.md": "fa057fed64fdb1c454a25be2b28fcbeb926d4b0163cfb61b026d9409418c1052", "docs/releases/v1.0.0-alpha.1.md": "59004f0eb2db2dcc19a832eb260e48473ba388bf094aec38364a88db8c9d3c0b", - "docs/validation/69-rc.md": "7a746f0330a0b803697f4af5aa3d608c1db916e318f0ad81ac6edb9ece083906", + "docs/validation/69-rc.md": "4e9a2183e8ca98a6db36a7b58dfebe0f2c02b33ae6534ffcda9352ab7198337f", "docs/validation/85-antigravity.md": "468ba1702a60c57079730ab5b7545b0b1968f11cc6832f0ac88460664ba87723", "docs/wake.md": "e382df91b14f91b026280c4e12dfbdb047f2bf16e73186b959c62bb67d6b4d8c", - "experiments/relay69/README.md": "ca9e742cf58cf01e373d5621d078b47d052b599f0a7bbfafa94c7f4d0e7f7d38", - "experiments/relay69/REPORT.md": "965fd7add95112ec8327aaf5dc5bdc3654b81289b95dfb69f68d181bf7856661", "plugins/session-peer/README.md": "a4eeae54caac9ad824d740f7f319561f060bc9c2bbbbf7def2bdd8e36dbcd1c6" }, "translatedLocales": [ diff --git a/docs/ja/README.md b/docs/ja/README.md new file mode 100644 index 00000000..70b4413c --- /dev/null +++ b/docs/ja/README.md @@ -0,0 +1,28 @@ +# session-peer ドキュメント + +英語版が正本です。韓国語、日本語、簡体字中国語版は同じナビゲーションと規範的な +動作を維持します。目的に合う最短のガイドから始めてください。プロトコルと検証記録は +レビュー資料であり、別のインストール経路ではありません。 + +## ユーザー + +- [CLIリファレンス](cli-reference.md)はローカル・SSHでの検出、送信、JSON、更新、制限を説明します。 +- [診断と返信](diagnostics.md)は正確な送信状態と安全なトラブルシューティングを説明します。 +- [複数のCodexホーム](multi-home-list.md)、[明示的なwake](wake.md)、[Antigravity](antigravity.md)はオプションのエージェントワークフローを扱います。 + +## AI支援セットアップと統合 + +- [AIアシスタントガイド](ai-assistant-guide.md)は、エージェントにセットアップや検証を任せるための制限付き引き継ぎ文書です。 +- [MCP](mcp.md)と[エージェントアダプター](agent-adapters.md)はオプション統合とポリシー境界を説明します。 + +## ペアデバイスと運用者 + +- [ペアデバイス](paired-devices.md)は、直接またはホスト型のエンドツーエンド暗号化リレーを使うユーザー向け経路です。 +- [リレー認証](relay-auth.md)、[ライフサイクルと復旧](relay-lifecycle.md)、[ウォッチドッグ](relay-watchdog.md)、[エッジポリシー](relay-edge-policy.md)は運用者向け資料です。 +- [デプロイ資料](../../deploy/README.md)は再利用可能な例とAbruption KRの運用参照を分離します。 + +## 開発と履歴 + +- [エージェント転送アーキテクチャ](architecture/agent-transports.md)と[リレー開発計画](relay-development-plan.md)は実装境界を説明します。 +- [リリースノート](releases/v1.0.0-alpha.1.md)は公開済み動作を説明し、[検証記録](validation/69-rc.md)は範囲付きの証拠と制限を保存します。 +- 完了したrelay69プロトタイプのソースは、固有の回帰テストを製品リレースイートへ移した後に削除しました。旧プロトタイプコードはGit履歴に残ります。 diff --git a/docs/ja/paired-devices.md b/docs/ja/paired-devices.md index 9af39010..2f06ea0d 100644 --- a/docs/ja/paired-devices.md +++ b/docs/ja/paired-devices.md @@ -80,7 +80,7 @@ session-peer relay serve --accounts /private/accounts.json \ --bind 127.0.0.1 --port 3769 --seconds 3600 ``` -永続的な Linux ホスティングの場合、wheel を `/opt/session-peer-relay/venv` にインストールし、ハッシュを `/etc/session-peer-relay/accounts.json` に保存して、レビュー済みの [systemd テンプレート](../../deploy/session-peer-relay.service) を適合させてください。有効化する前に `systemd-analyze verify` で検証してください。テンプレートは、動的ユーザー、ケーパビリティなし、アクセス不可のホームディレクトリ、読み取り専用システム、プライベートな一時ディレクトリ、および明示的なリソース制限で動作します。これは**ブラインドリレー**用であり、ネイティブのレシーバー用ではありません。後者はエージェントが所有するランタイムパスとソケットが必要です。 +永続的な Linux ホスティングの場合、wheel を `/opt/session-peer-relay/venv` にインストールし、ハッシュを `/etc/session-peer-relay/accounts.json` に保存して、レビュー済みの [systemd テンプレート](../../deploy/examples/static-account/session-peer-relay.service) を適合させてください。有効化する前に `systemd-analyze verify` で検証してください。テンプレートは、動的ユーザー、ケーパビリティなし、アクセス不可のホームディレクトリ、読み取り専用システム、プライベートな一時ディレクトリ、および明示的なリソース制限で動作します。これは**ブラインドリレー**用であり、ネイティブのレシーバー用ではありません。後者はエージェントが所有するランタイムパスとソケットが必要です。 ループバックリレーを専用ホスト名の HTTPS/WSS リバースプロキシの背後に配置します: diff --git a/docs/ja/relay-auth.md b/docs/ja/relay-auth.md index cef68a4d..6271287f 100644 --- a/docs/ja/relay-auth.md +++ b/docs/ja/relay-auth.md @@ -328,7 +328,7 @@ iCloud の外部になければなりません。プライベート署名鍵は control SQLite の一貫したスナップショット、署名鍵、および OAuth ポリシーを個別の 保護されたリソースとして含める必要があります。これは中央オントロジー SQLite では**ありません**。 -`deploy/session-peer-control.service` はインストールテンプレートであり、有効化された +`deploy/examples/authenticated-control/session-peer-control.service` はインストールテンプレートであり、有効化された ユニットではありません: ループバック 3770、DynamicUser、排他的 flock ライターロック、プライベート永続 状態、保護された homes/system/kernel、読み取り専用の control ツリーのみを公開する隔離された `/opt` マウント、アクセス不能な他サービスの data/config/log パス、ケーパビリティ diff --git a/docs/ja/relay-lifecycle.md b/docs/ja/relay-lifecycle.md index 44df807f..ec2581db 100644 --- a/docs/ja/relay-lifecycle.md +++ b/docs/ja/relay-lifecycle.md @@ -79,6 +79,6 @@ session-peer relay init-replay --out /private/relay-state/spent-tickets.json ## KR サービスのバックアップと復元ステージング -デプロイされた KR サービスは、暗号化された Restic ジョブの前に `deploy/kr/session-peer-backup-snapshot.py` を使用します。このヘルパーは連動するスタックを一時的に停止し、トランザクションとして一貫した制御データベース、アドミッション署名鍵、プロバイダー設定、パブリック認証状態、およびリレーリプレイ/最高水位標ファイルを含む root 専用の回復セットを作成します。スナップショットを公開する前にデータベースの整合性と署名鍵の公開 JWK を検証し、その後に以前アクティブだったスタックを復元します。不完全なスナップショットは DR ジョブを中止させます。 +デプロイされた KR サービスは、暗号化された Restic ジョブの前に `deploy/ops/abruption-kr/session-peer-backup-snapshot.py` を使用します。このヘルパーは連動するスタックを一時的に停止し、トランザクションとして一貫した制御データベース、アドミッション署名鍵、プロバイダー設定、パブリック認証状態、およびリレーリプレイ/最高水位標ファイルを含む root 専用の回復セットを作成します。スナップショットを公開する前にデータベースの整合性と署名鍵の公開 JWK を検証し、その後に以前アクティブだったスタックを復元します。不完全なスナップショットは DR ジョブを中止させます。 Restic のスコープには、イミュータブルな制御およびリレーランタイム、準備完了ヘルパー、インストールされたサービスユニットも含まれます。復旧では、まず分離された root 専用ディレクトリに復元し、スナップショットマニフェスト、ファイルハッシュとモード、SQLite テーブル数、状態/リプレイリビジョン、使用済みエントリ、署名鍵、ランタイムリンクを検証する必要があります。稼働中のサービスに直接上書きしたり、古い制御データベース、署名鍵、またはリプレイ最高水位標を自動的に昇格させたりしないでください。本番環境の置き換えには、依然として停止されたスタックと明示的なロールバック/失効フェンスが必要です。 diff --git a/docs/ja/validation/69-rc.md b/docs/ja/validation/69-rc.md index f4406e65..52250b7d 100644 --- a/docs/ja/validation/69-rc.md +++ b/docs/ja/validation/69-rc.md @@ -213,7 +213,7 @@ macOS上で5.267秒で合格しました。意図的な復旧フェーズは、 分離されたままです。CFインフラの採用計画は破棄され、このKR実装ブランチおよびプッシュから除外されています。 レビュー済みのKR watchdog/readiness/relayユニットおよびヘルパーの機密情報を含まないコピーは -[deploy/kr](../../../deploy/kr/README.md)に保存されています。これらには認証情報、環境設定ファイル、 +[deploy/ops/abruption-kr](../../../deploy/ops/abruption-kr/README.md)に保存されています。これらには認証情報、環境設定ファイル、 プロバイダーサブジェクトID、データベース、またはリプレイ状態は含まれていません。 記録された実際のOAuth、トランスクリプトACK、およびサービス障害の証拠は、上記の範囲に限定されたままです。 ソークテスト(soak)を停止してもそれらの範囲が広がることはなく、このソースプッシュはRCリリース承認ではありません。 diff --git a/docs/ko/README.md b/docs/ko/README.md new file mode 100644 index 00000000..95c55eaa --- /dev/null +++ b/docs/ko/README.md @@ -0,0 +1,28 @@ +# session-peer 문서 + +영어 문서가 정본입니다. 한국어, 일본어, 중국어 간체 문서는 같은 목차와 규범적 +동작을 유지합니다. 목표에 맞는 가장 짧은 가이드부터 시작하세요. 프로토콜과 검증 +기록은 검토 자료이며 별도의 설치 경로가 아닙니다. + +## 사용자 + +- [CLI 레퍼런스](cli-reference.md)는 로컬·SSH 검색, 전송, JSON, 업데이트와 제한을 설명합니다. +- [진단과 회신](diagnostics.md)은 정확한 제출 상태와 안전한 문제 해결 방법을 설명합니다. +- [여러 Codex 홈](multi-home-list.md), [명시적 wake](wake.md), [Antigravity](antigravity.md)는 선택형 에이전트 흐름을 다룹니다. + +## AI 지원 설정과 통합 + +- [AI 지원 가이드](ai-assistant-guide.md)는 에이전트에게 설정이나 검증을 맡길 때 사용하는 제한된 인계 문서입니다. +- [MCP](mcp.md)와 [에이전트 어댑터](agent-adapters.md)는 선택형 통합과 정책 경계를 설명합니다. + +## 페어링 기기와 운영자 + +- [페어링 기기](paired-devices.md)는 직접 또는 호스팅된 종단 간 암호화 릴레이를 사용하는 사용자 경로입니다. +- [릴레이 인증](relay-auth.md), [수명주기와 복구](relay-lifecycle.md), [와치독](relay-watchdog.md), [엣지 정책](relay-edge-policy.md)은 운영자 참고 문서입니다. +- [배포 자료](../../deploy/README.md)는 재사용 예제와 Abruption KR 운영 참조를 구분합니다. + +## 개발과 이력 + +- [에이전트 전송 아키텍처](architecture/agent-transports.md)와 [릴레이 개발 계획](relay-development-plan.md)은 구현 경계를 설명합니다. +- [릴리스 노트](releases/v1.0.0-alpha.1.md)는 공개된 동작을 설명하고, [검증 기록](validation/69-rc.md)은 범위가 명시된 증거와 한계를 보존합니다. +- 완료된 relay69 프로토타입 소스는 고유 회귀 검사를 제품 릴레이 테스트로 옮긴 뒤 제거했습니다. 폐기된 프로토타입 코드는 Git 이력에 남아 있습니다. diff --git a/docs/ko/paired-devices.md b/docs/ko/paired-devices.md index 92525c67..52cd8612 100644 --- a/docs/ko/paired-devices.md +++ b/docs/ko/paired-devices.md @@ -80,7 +80,7 @@ session-peer relay serve --accounts /private/accounts.json \ --bind 127.0.0.1 --port 3769 --seconds 3600 ``` -영구적인 Linux 호스팅의 경우, wheel을 `/opt/session-peer-relay/venv`에 설치하고, 해시를 `/etc/session-peer-relay/accounts.json`에 저장하며, 검토된 [systemd 템플릿](../../deploy/session-peer-relay.service)을 맞게 수정하십시오. 활성화하기 전에 `systemd-analyze verify`로 검증하십시오. 이 템플릿은 동적 사용자, 권한 없음, 접근 불가능한 홈 디렉터리, 읽기 전용 시스템, 비공개 임시 디렉터리 및 명시적 리소스 제한으로 실행됩니다. 이는 네이티브 수신자가 아닌 **블라인드 릴레이**를 위한 것입니다. 후자는 에이전트 소유의 런타임 경로와 소켓이 필요합니다. +영구적인 Linux 호스팅의 경우, wheel을 `/opt/session-peer-relay/venv`에 설치하고, 해시를 `/etc/session-peer-relay/accounts.json`에 저장하며, 검토된 [systemd 템플릿](../../deploy/examples/static-account/session-peer-relay.service)을 맞게 수정하십시오. 활성화하기 전에 `systemd-analyze verify`로 검증하십시오. 이 템플릿은 동적 사용자, 권한 없음, 접근 불가능한 홈 디렉터리, 읽기 전용 시스템, 비공개 임시 디렉터리 및 명시적 리소스 제한으로 실행됩니다. 이는 네이티브 수신자가 아닌 **블라인드 릴레이**를 위한 것입니다. 후자는 에이전트 소유의 런타임 경로와 소켓이 필요합니다. 루프백 릴레이를 전용 호스트 이름의 HTTPS/WSS 역방향 프록시 뒤에 둡니다: diff --git a/docs/ko/relay-auth.md b/docs/ko/relay-auth.md index 5e16eb23..5d687025 100644 --- a/docs/ko/relay-auth.md +++ b/docs/ko/relay-auth.md @@ -327,7 +327,7 @@ iCloud 외부에 있어야 합니다. 비공개 서명 키는 0600 모드로 한 control SQLite 일관된 스냅샷, 서명 키 및 OAuth 정책이 별도의 보호된 리소스로 포함되어야 합니다. 이는 중앙 온톨로지 SQLite가 **아닙니다**. -`deploy/session-peer-control.service`는 활성화된 유닛이 아닌 설치 템플릿입니다: +`deploy/examples/authenticated-control/session-peer-control.service`는 활성화된 유닛이 아닌 설치 템플릿입니다: 루프백 3770, DynamicUser, 배타적 flock 작성자 락, 비공개 영속 상태, 보호된 homes/system/kernel, 읽기 전용 control 트리만 노출하는 격리된 `/opt` 마운트, 접근 불가능한 타 서비스 data/config/log 경로, capability diff --git a/docs/ko/relay-lifecycle.md b/docs/ko/relay-lifecycle.md index 37bdda36..bfac9440 100644 --- a/docs/ko/relay-lifecycle.md +++ b/docs/ko/relay-lifecycle.md @@ -79,6 +79,6 @@ session-peer relay init-replay --out /private/relay-state/spent-tickets.json ## KR 서비스 백업 및 복원 스테이징 -배포된 KR 서비스는 암호화된 Restic 작업 전에 `deploy/kr/session-peer-backup-snapshot.py`를 사용합니다. 이 헬퍼는 조정된 스택을 잠시 중지하고 트랜잭션 측면에서 일관된 제어 데이터베이스, 어드미션 서명 키, 제공자 구성, 공개 인증 상태 및 릴레이 재생/최고 수위 파일을 포함하는 루트 전용 복구 세트를 생성합니다. 스냅샷을 공개하기 전에 데이터베이스 무결성과 서명 키의 게시된 JWK를 검증한 다음, 이전에 활성화되어 있던 스택을 복원합니다. 부분 스냅샷은 DR 작업을 중단시킵니다. +배포된 KR 서비스는 암호화된 Restic 작업 전에 `deploy/ops/abruption-kr/session-peer-backup-snapshot.py`를 사용합니다. 이 헬퍼는 조정된 스택을 잠시 중지하고 트랜잭션 측면에서 일관된 제어 데이터베이스, 어드미션 서명 키, 제공자 구성, 공개 인증 상태 및 릴레이 재생/최고 수위 파일을 포함하는 루트 전용 복구 세트를 생성합니다. 스냅샷을 공개하기 전에 데이터베이스 무결성과 서명 키의 게시된 JWK를 검증한 다음, 이전에 활성화되어 있던 스택을 복원합니다. 부분 스냅샷은 DR 작업을 중단시킵니다. Restic 범위에는 변경 불가능한 제어 및 릴레이 런타임, 준비 상태 헬퍼 및 설치된 서비스 유닛도 포함됩니다. 복구는 먼저 격리된 루트 전용 디렉터리에 복원한 후 스냅샷 매니페스트, 파일 해시 및 모드, SQLite 테이블 수, 상태/재생 리비전, 사용된 항목, 서명 키 및 런타임 링크를 검증해야 합니다. 실행 중인 서비스에 직접 덮어쓰거나 이전 제어 데이터베이스, 서명 키 또는 재생 최고 수위를 자동으로 승격하지 마십시오. 프로덕션 교체에는 여전히 중지된 스택과 명시적인 롤백/취소 격리가 필요합니다. diff --git a/docs/ko/validation/69-rc.md b/docs/ko/validation/69-rc.md index f26040e1..788ef80c 100644 --- a/docs/ko/validation/69-rc.md +++ b/docs/ko/validation/69-rc.md @@ -216,7 +216,7 @@ WebSocket을 닫습니다. 작업 ID(operation ID)를 재사용하면 제어 영 도입 계획은 폐기되었으며 이번 KR 구현 브랜치 및 푸시에서 제외됩니다. 검토된 KR watchdog/readiness/relay 유닛 및 헬퍼의 비밀 정보가 제거된 사본은 -[deploy/kr](../../../deploy/kr/README.md)에 보존되어 있습니다. 여기에는 +[deploy/ops/abruption-kr](../../../deploy/ops/abruption-kr/README.md)에 보존되어 있습니다. 여기에는 자격 증명, 환경 파일, 제공자 주체(subject) ID, 데이터베이스 또는 재생 상태가 포함되지 않습니다. 기록된 실제 OAuth, 트랜스크립트 ACK 및 서비스 장애 증거는 위의 범위로 한정됩니다. 소크(soak) 테스트를 중단한다고 해서 해당 범위가 확장되는 것은 아니며, diff --git a/docs/paired-devices.md b/docs/paired-devices.md index 545c7fd3..b7519bb8 100644 --- a/docs/paired-devices.md +++ b/docs/paired-devices.md @@ -111,7 +111,7 @@ session-peer relay serve --accounts /private/accounts.json \ For persistent Linux hosting, install the wheel into `/opt/session-peer-relay/venv`, store hashes in `/etc/session-peer-relay/accounts.json`, and adapt the reviewed -[systemd template](../deploy/session-peer-relay.service). Validate it with +[systemd template](../deploy/examples/static-account/session-peer-relay.service). Validate it with `systemd-analyze verify` before enabling. The template runs with a dynamic user, no capabilities, inaccessible homes, read-only system, private temporary directory and explicit resource limits. It is for the **blind relay**, not the native receiver; diff --git a/docs/relay-auth.md b/docs/relay-auth.md index a3d7c94c..c2834f35 100644 --- a/docs/relay-auth.md +++ b/docs/relay-auth.md @@ -331,7 +331,7 @@ restart. All private DB/state parents are owner-only; backups must include the control SQLite consistent snapshot, signing key and OAuth policy as separate protected resources. This is **not** the central ontology SQLite. -`deploy/session-peer-control.service` is an installation template, not an enabled +`deploy/examples/authenticated-control/session-peer-control.service` is an installation template, not an enabled unit: loopback3770, DynamicUser, exclusive flock writer lock, private persistent state, protected homes/system/kernel, an isolated `/opt` mount exposing only its read-only control tree, inaccessible other-service data/config/log paths, no diff --git a/docs/relay-lifecycle.md b/docs/relay-lifecycle.md index 30ea14b5..af37af93 100644 --- a/docs/relay-lifecycle.md +++ b/docs/relay-lifecycle.md @@ -143,7 +143,7 @@ revision high-water mark and must be preserved for the service's lifetime. ## KR service backup and restore staging -The deployed KR service uses `deploy/kr/session-peer-backup-snapshot.py` before +The deployed KR service uses `deploy/ops/abruption-kr/session-peer-backup-snapshot.py` before its encrypted Restic job. The helper briefly stops the coordinated stack and creates a root-only recovery set containing a transactionally consistent control database, the admission signing key, provider configuration, public auth state diff --git a/docs/validation/69-rc.md b/docs/validation/69-rc.md index 027e592b..e4680681 100644 --- a/docs/validation/69-rc.md +++ b/docs/validation/69-rc.md @@ -485,7 +485,7 @@ The CF infrastructure adoption plan was discarded and is excluded from this KR implementation branch and push. Secret-free copies of the reviewed KR watchdog/readiness/relay units and helper -are preserved in [deploy/kr](../../deploy/kr/README.md). They do not include +are preserved in [deploy/ops/abruption-kr](../../deploy/ops/abruption-kr/README.md). They do not include credentials, environment files, provider subject IDs, databases or replay state. The recorded actual OAuth, transcript ACK and service fault evidence remains bounded by the scopes above. Stopping the soak does not broaden those scopes, diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md new file mode 100644 index 00000000..6fa62f42 --- /dev/null +++ b/docs/zh-CN/README.md @@ -0,0 +1,27 @@ +# session-peer 文档 + +英文版是规范源。韩文、日文和简体中文版保持相同的导航及规范行为。请从最符合目标的 +最短指南开始;协议和验证记录是审查材料,不是并行的安装路径。 + +## 用户 + +- [CLI参考](cli-reference.md)介绍本地及SSH发现、发送、JSON、更新和限制。 +- [诊断与回复](diagnostics.md)介绍准确的提交状态和安全的故障排查。 +- [多个Codex主目录](multi-home-list.md)、[显式wake](wake.md)和[Antigravity](antigravity.md)介绍可选的代理工作流。 + +## AI辅助设置与集成 + +- [AI助手指南](ai-assistant-guide.md)是让代理执行设置或验证时使用的受限交接文档。 +- [MCP](mcp.md)和[代理适配器](agent-adapters.md)介绍可选集成及策略边界。 + +## 配对设备与运维人员 + +- [配对设备](paired-devices.md)是使用直连或托管端到端加密中继的用户路径。 +- [中继认证](relay-auth.md)、[生命周期与恢复](relay-lifecycle.md)、[看门狗](relay-watchdog.md)和[边缘策略](relay-edge-policy.md)是运维参考。 +- [部署资料](../../deploy/README.md)区分可复用示例和Abruption KR运维参考。 + +## 开发与历史 + +- [代理传输架构](architecture/agent-transports.md)和[中继开发计划](relay-development-plan.md)介绍实现边界。 +- [发布说明](releases/v1.0.0-alpha.1.md)描述已发布行为;[验证记录](validation/69-rc.md)保留范围明确的证据及限制。 +- relay69原型完成后,其独有回归测试已迁移到产品中继测试套件,原型源代码已删除。废弃的原型代码仍保留在Git历史中。 diff --git a/docs/zh-CN/paired-devices.md b/docs/zh-CN/paired-devices.md index b3f5a8c2..a11c1626 100644 --- a/docs/zh-CN/paired-devices.md +++ b/docs/zh-CN/paired-devices.md @@ -106,7 +106,7 @@ session-peer relay serve --accounts /private/accounts.json \ 对于持久化 Linux 托管,请将 wheel 安装到 `/opt/session-peer-relay/venv` 中, 将哈希存储在 `/etc/session-peer-relay/accounts.json` 中,并调整经审查的 -[systemd 模板](../../deploy/session-peer-relay.service)。在启用前使用 +[systemd 模板](../../deploy/examples/static-account/session-peer-relay.service)。在启用前使用 `systemd-analyze verify` 进行验证。该模板以动态用户(dynamic user)、 无特权功能(capabilities)、不可访问的主目录、只读系统、私有临时目录 和显式资源限制运行。它适用于**盲中继**,而非原生接收端; diff --git a/docs/zh-CN/relay-auth.md b/docs/zh-CN/relay-auth.md index b36466dd..d5a4d265 100644 --- a/docs/zh-CN/relay-auth.md +++ b/docs/zh-CN/relay-auth.md @@ -323,7 +323,7 @@ iCloud 之外。私有签名密钥仅生成一次,权限为 0600 模式,并 control SQLite 一致快照、签名密钥和 OAuth 策略作为单独的 受保护资源包含在内。这**不是**中央本体 SQLite。 -`deploy/session-peer-control.service` 是安装模板,而非已启用的 +`deploy/examples/authenticated-control/session-peer-control.service` 是安装模板,而非已启用的 单元:回环 3770、DynamicUser、独占 flock 写入锁、私有持久 状态、受保护的 homes/system/kernel、仅暴露其只读 control 树的隔离 `/opt` 挂载、不可访问的其他服务 data/config/log 路径、无 diff --git a/docs/zh-CN/relay-lifecycle.md b/docs/zh-CN/relay-lifecycle.md index ddabd4e6..08ecebd8 100644 --- a/docs/zh-CN/relay-lifecycle.md +++ b/docs/zh-CN/relay-lifecycle.md @@ -79,6 +79,6 @@ session-peer relay init-replay --out /private/relay-state/spent-tickets.json ## KR 服务备份与恢复暂存 -已部署的 KR 服务在其加密的 Restic 作业之前使用 `deploy/kr/session-peer-backup-snapshot.py`。该辅助程序会短暂停止协同栈,并创建一个仅 root 权限的恢复集,其中包含事务一致的控制数据库、准入签名密钥、提供商配置、公有认证状态以及中继重放/高水位线文件。它在使快照可见之前验证数据库完整性和签名密钥已发布的 JWK,然后恢复先前处于活跃状态的栈。部分快照将中止 DR 作业。 +已部署的 KR 服务在其加密的 Restic 作业之前使用 `deploy/ops/abruption-kr/session-peer-backup-snapshot.py`。该辅助程序会短暂停止协同栈,并创建一个仅 root 权限的恢复集,其中包含事务一致的控制数据库、准入签名密钥、提供商配置、公有认证状态以及中继重放/高水位线文件。它在使快照可见之前验证数据库完整性和签名密钥已发布的 JWK,然后恢复先前处于活跃状态的栈。部分快照将中止 DR 作业。 Restic 范围还包含不可变的控制和中继运行时、就绪辅助程序以及已安装的服务单元。恢复必须首先还原到隔离的仅 root 权限目录中,并验证快照清单、文件哈希和模式、SQLite 表计数、状态/重放修订版本、已消耗条目、签名密钥以及运行时链接。切勿覆盖正在运行的活跃服务,也不要自动晋升旧的控制数据库、签名密钥或重放高水位线。生产环境替换仍需要停止的栈以及显式的回滚/吊销隔离。 diff --git a/docs/zh-CN/validation/69-rc.md b/docs/zh-CN/validation/69-rc.md index 54733dde..454bea5f 100644 --- a/docs/zh-CN/validation/69-rc.md +++ b/docs/zh-CN/validation/69-rc.md @@ -207,7 +207,7 @@ KR control/relay 服务与这些已终止的测试进程保持分离。CF 基础 已被废弃,并从本 KR 实现分支和推送中排除。 已审查的 KR watchdog/readiness/relay 单元及辅助脚本的无密钥副本保存在 -[deploy/kr](../../../deploy/kr/README.md) 中。它们不包含凭据、环境文件、 +[deploy/ops/abruption-kr](../../../deploy/ops/abruption-kr/README.md) 中。它们不包含凭据、环境文件、 提供方主体 ID、数据库或重放状态。记录的实际 OAuth、transcript ACK 和 服务故障证据仍受限于上述范围。停止耐受(soak)测试并不会扩大这些范围, 且本次源码推送并非 RC 发布批准。 diff --git a/experiments/relay69/README.ja.md b/experiments/relay69/README.ja.md deleted file mode 100644 index 27e2d0fd..00000000 --- a/experiments/relay69/README.ja.md +++ /dev/null @@ -1,59 +0,0 @@ -# #69 ペアリングデバイス中継ラボ - -ローカルの #47 コミット `993d04d` に基づく実験的かつフィクスチャ専用のコードです。**本番レシーバー、プラグイン、またはインストール済みCLIコマンドではありません。** 通常のパッケージ、ローカル/SSHの動作、およびインストール済みバージョンは変更されません。ネイティブのClaude/Codexセッション、受信箱、キュー、モデルの認証情報やクォータは使用されません。 - -## ローカルでの再現 - -Python 3.11以上およびTLS 1.3対応のOpenSSLが必要です。テストされた環境は、macOS Python 3.14/3.13およびUbuntu arm64 Python 3.12です。 - -```sh -python3 -m venv /tmp/session-peer-relay-lab -/tmp/session-peer-relay-lab/bin/pip install -r experiments/relay69/requirements.txt -/tmp/session-peer-relay-lab/bin/python -m unittest tests.test_relay69 -v -``` - -テストでは、ループバックTCP、実際のTLS、ローカルWebSocket中継、および破棄可能な状態を使用します。オプションの依存関係がない場合、通常のunittest探索はこのモジュールをスキップします。この実験はwheelやsdistには含まれません。インポートによってリスナーが起動することはありません。 - -## コンポーネントと境界 - -- `identity.py`: ローカルで生成されたP-256秘密鍵と有効期間の短い自己署名証明書。鍵/状態は0600/0700であり、招待状に含められたり中継にコピーされたりすることはありません。これはファイルシステムによる保護であり、本番OSのキーチェーンではありません。 -- `wire.py`: Python/OpenSSLのMemoryBIOを使用した、TCPまたはバイナリWebSocketフレーム上のTLS 1.3/ALPN。クライアントは招待された証明書を明示的に信頼し、ピン留めします。ペアリングされたレシーバーは、list/sendの前に認識されたクライアント証明書を要求します。未知の未認証クライアントは、接続ごとに1回のみの招待予約を試行できます。公開中継URLにはWSSが必要です。平文のWSはループバックテストに限定されます。HTTPおよびWSのリダイレクトは拒否されます。 -- `store.py`: 10分間の1回限り有効な招待予約、保留中/ペアリング済み/失効済みのデバイス状態、エージェントデータベースから独立した実行ジャーナル。ペアリングは、相互TLSによって提案された鍵が証明された後にのみコミットされます。保留中のイニシエーターは、同じ鍵を使用して失われたコミット応答を照合できます。失効はアクティブなチャネルを切断します。ローテーションは失効と新しいペアリングであり、シームレスな更新ではありません。 -- `relay.py`: ロール/ルームスコープの入場認証情報が120秒間有効なHttpOnlyセッションCookieを発行します。認証はアップグレードの前に行われます。中継は暗号文のみを転送し、エンドポイントの鍵を保持しません。ルーティング/ロール、IP、時刻、長さ、および暗号文を認識します。Cloudflare/Caddyは外部TLSメタデータ**および入場認証情報/Cookie**を認識しますが、内部TLSの平文や鍵は認識しません。外部認証はエンドツーエンドのアイデンティティではなく、ピアTLS検証の代わりにはなりません。 -- `app.py`: 受信したすべての操作は #47 の `LocalTransport` および1つの明示的にインスタンス化された決定論的フィクスチャアダプターを経由します。通信操作はprobe/list/send/statusです。任意のコマンド、パス、ネイティブアダプター、またはwakeは利用できません。リクエストはバージョン、両方のアイデンティティ、UUID、有効期限、操作、および本文をバインドします。TLSチャネルがレコードの整合性とリプレイ保護を提供します。 - -ジャーナルは、フィクスチャアダプターを呼び出す前に保留中の意図をコミットします。同じ本文を持つ同一のピア+メッセージIDは保存された結果を返します。異なる本文は拒否されます。アダプターの呼び出しと結果コミットの間のクラッシュは不明のままとなり、自動的に再実行されることはありません。これは活性よりも最大1回の試行を優先します。これはネイティブの厳密に1回の配信では**ありません**。`submitted` は `consumptionConfirmed` とは分離されており、後者はfalseのままです。`relayAttached` はキャリアへの接続を示します。認証されたエンドポイントプローブのみがレシーバーの準備完了を確立します。 - -自動ルートは認証済みの直接プローブと中継プローブを競合させ、両方が同じ選択サイクルで完了した場合は直接接続を優先します。メッセージは1つの勝者にのみ送信されます。送信後の失敗によってルートが切り替わったり、再送されたりすることはありません。結果が不明な場合は、元のメッセージIDでstatusを使用してください。 - -## 明示的なコマンド - -実験用venvを使用してワークツリールートから実行します: - -```sh -python -m experiments.relay69 init --state /private/test/receiver -python -m experiments.relay69 invite --state /private/test/receiver \ - --out /private/test/invitation.json --direct 127.0.0.1:3769 -python -m experiments.relay69 serve --state /private/test/receiver \ - --bind 127.0.0.1 --port 3769 --seconds 1800 -python -m experiments.relay69 pair --state /private/test/client \ - --invite /private/test/invitation.json --route direct -python -m experiments.relay69 request --state /private/test/client \ - --peer RECEIVER_FINGERPRINT --route direct --op send --message fixture-only -``` - -ペアリングの前に、招待状の証明書フィンガープリントとレシーバーの `init` 出力を、個別に信頼された管理経路を通じて比較してください。このラボでは、公開招待情報に既存のSSHを使用し、使い捨てシークレットにプライベートファイルを使用しました。招待状、入場シークレット、またはCookieをログやURLに貼り付けないでください。 -`request --op status --message ORIGINAL_MESSAGE_ID` は不確実な結果を照合します。 -`revoke --state RECEIVER_STATE --peer CLIENT_FINGERPRINT` はそのテストクライアントを失効させます。 - -中継モードの場合、git外でロールごとに1つずつ、2つのランダムな256ビット入場認証情報をプロビジョニングします。中継の `--accounts` ファイルは、`{hash: SHA256(credential), room: test-room, role: receiver|client}` のJSON配列です。エンドポイントは0600の `--admission-file` で自身の認証情報のみを受け取り、相手のプライベートTLS鍵を受け取ることはありません。inviteとserveの両方に `--relay wss://HOST/v1/connect` を追加し、`pair --route relay` を使用します。レシーバーとクライアントは両方ともアウトバウンド接続します。中継コマンドは `--bind`、`--port`、`--accounts`、および `--seconds` を受け付けます。 - -公開トライアルでは、専用のホスト名、既存のCF/KR TLS終端、およびKRからUSへのtailnetアップストリームを使用しました。公開設定はインフラストラクチャ固有であり、このリポジトリによって自動的にプロビジョニングされることはありません。デプロイされたトライアルは残っていません。 - -## 運用制限 - -中継は、10接続、毎秒20件のHTTP入場/アップグレードリクエスト、100個のアクティブなCookie、256 KiBのフレーム、4フレームの受信ウォーターマーク、32 KiBのトランスポート書き込みウォーターマーク、2秒の転送期限、および合計32 MiBの転送バイト数を許可します。TLS JSONメッセージは64 KiBに、メッセージ本文は32 KiBに制限されます。レシーバーは接続を8に、接続あたりのリクエストを100に制限します。そのジャーナルは、重複排除履歴を暗黙のうちに破棄するのではなく、10,000レコードで停止します。これらの制限はテスト用のバジェットであり、調整された製品の保証ではありません。 - -サービスのライフタイムは最大30分です。公開バックエンドでは、独立したsystemdランタイム制限、DynamicUser、ケーパビリティなし、読み取り専用コード、ProtectHome、明示的な禁止ディレクトリ、専用のtmp/devices、256 MiBのメモリ、50%のCPU、64個のタスクも使用されました。Cgroup IPルールにより、USバックエンドへのアクセスはKRのみに許可されました。サンドボックスの事前確認により、他のホームや製品ディレクトリが読み取り不可能であることがアサートされました。新しいファイアウォールルール、DNSレコード、永続アカウント、またはグローバルPythonパッケージはインストールされませんでした。Macのフィクスチャクライアントは個別のstate/venvを使用しました。これらがLinuxサンドボックスのOS分離を備えているとは主張されていません。 - -実際のアプリケーションでは、より堅牢なデバイスライフサイクル/UX、送信元に束縛された入場設定、デバイスごとのクォータ、オフライン回復、依存関係とプロトコルのレビュー、およびプラットフォームサービス/鍵ストレージの設計が必要になります。受領契約は、中継への接続、エンドポイントによる受領、およびネイティブの送信を引き続き区別しなければなりません。[測定レポート](REPORT.ja.md)を参照してください。 diff --git a/experiments/relay69/README.ko.md b/experiments/relay69/README.ko.md deleted file mode 100644 index c39e0716..00000000 --- a/experiments/relay69/README.ko.md +++ /dev/null @@ -1,57 +0,0 @@ -# #69 페어링된 기기 릴레이 실험실 - -로컬 #47 커밋 `993d04d`에 기반한 실험용 픽스처 전용 코드입니다. **프로덕션 수신기, 플러그인 또는 설치된 CLI 명령어가 아닙니다.** 일반 패키지, 로컬/SSH 동작 및 설치된 버전은 변경되지 않습니다. 네이티브 Claude/Codex 세션, 인박스, 큐, 모델 자격 증명 또는 쿼터는 사용되지 않습니다. - -## 로컬에서 재현 - -Python 3.11+ 및 TLS 1.3을 지원하는 OpenSSL이 필요합니다. 테스트된 환경은 macOS Python 3.14/3.13 및 Ubuntu arm64 Python 3.12였습니다. - -```sh -python3 -m venv /tmp/session-peer-relay-lab -/tmp/session-peer-relay-lab/bin/pip install -r experiments/relay69/requirements.txt -/tmp/session-peer-relay-lab/bin/python -m unittest tests.test_relay69 -v -``` - -테스트는 루프백 TCP, 실제 TLS, 로컬 WebSocket 릴레이 및 일회용 상태를 사용합니다. 선택적 의존성이 없으면 일반적인 unittest 검색은 이 모듈을 건너뜁니다. 이 실험은 wheel이나 sdist에 포함되지 않습니다. 어떤 import도 리스너를 시작하지 않습니다. - -## 컴포넌트 및 경계 - -- `identity.py`: 로컬에서 생성된 P-256 개인 키 및 수명이 짧은 자체 서명 인증서입니다. 키/상태는 0600/0700 권한이며 초대장에 포함되거나 릴레이로 복사되지 않습니다. 이는 파일 시스템 보호이며 프로덕션 OS 키체인이 아닙니다. -- `wire.py`: Python/OpenSSL MemoryBIO를 사용하는 TCP 또는 바이너리 WebSocket 프레임 기반의 TLS 1.3/ALPN입니다. 클라이언트는 초대된 인증서를 명시적으로 신뢰하고 고정(pin)합니다. 페어링된 수신기는 list/send 전에 인식된 클라이언트 인증서를 요구합니다. 인증되지 않은 알 수 없는 클라이언트는 연결당 한 번의 초대 예약만 시도할 수 있습니다. 공용 릴레이 URL에는 WSS가 필요하며 일반 텍스트 WS는 루프백 테스트로 제한됩니다. HTTP 및 WS 리디렉션은 거부됩니다. -- `store.py`: 10분 일회용 초대 예약, 보류/페어링/철회된 기기 상태, 에이전트 데이터베이스와 독립적인 실행 저널입니다. 페어링은 상호 TLS가 제안된 키를 증명한 후에만 커밋됩니다. 보류 중인 개시자는 동일한 키로 유실된 커밋 응답을 조정할 수 있습니다. 철회는 활성 채널을 닫으며, 로테이션은 매끄러운 갱신이 아닌 철회 후 새 페어링입니다. -- `relay.py`: 역할/룸 범위의 입장 자격 증명으로 120초 HttpOnly 세션 쿠키를 발급합니다. 인증은 업그레이드 전에 발생합니다. 릴레이는 암호문만 전달하고 엔드포인트 키를 보유하지 않습니다. 릴레이는 라우팅/역할, IP, 시간, 길이 및 암호문을 봅니다. Cloudflare/Caddy는 외부 TLS 메타데이터 **및 입장 자격 증명/쿠키**를 볼 수 있지만 내부 TLS 일반 텍스트나 키는 볼 수 없습니다. 외부 인증은 종단 간 신원이 아니며 피어 TLS 검증을 대체하지 않습니다. -- `app.py`: 수신된 모든 작업은 #47 `LocalTransport` 및 명시적으로 인스턴스화된 결정론적 픽스처 어댑터 하나를 거칩니다. 와이어 작업은 probe/list/send/status이며 임의의 명령어, 경로, 네이티브 어댑터 또는 wake는 사용할 수 없습니다. 요청은 버전, 양측 신원, UUID, 만료 시간, 작업 및 본문을 바인딩하며 TLS 채널은 레코드 무결성 및 재생 보호를 제공합니다. - -저널은 픽스처 어댑터를 호출하기 전에 보류 중인 의도를 커밋합니다. 동일한 본문을 가진 동일한 피어+메시지 ID는 저장된 결과를 반환하며, 다른 본문은 거부됩니다. 어댑터 호출과 결과 커밋 사이의 충돌은 알 수 없는 상태로 유지되며 절대 자동으로 재실행되지 않습니다. 이는 활성성보다 최대 한 번 시도를 우선시합니다. 이것은 네이티브의 정확히 한 번 전달이 **아닙니다**. `submitted`는 계속 false로 유지되는 `consumptionConfirmed`와 별개입니다. `relayAttached`는 통신망 연결을 설명하며, 인증된 엔드포인트 프로브만이 수신기의 준비 상태를 확인합니다. - -자동 경로는 인증된 직접 프로브와 릴레이 프로브를 경쟁시키며, 동일한 선택 주기에서 둘 다 완료되면 직접 연결을 우선합니다. 메시지는 한 승자에게만 전달됩니다. 제출 후 실패하더라도 경로를 바꾸거나 재전송하지 않습니다. 알 수 없는 결과를 확인할 때는 원래 메시지 ID로 status를 사용하세요. - -## 명시적 명령어 - -실험 venv를 사용하여 작업 트리 루트에서 실행합니다: - -```sh -python -m experiments.relay69 init --state /private/test/receiver -python -m experiments.relay69 invite --state /private/test/receiver \ - --out /private/test/invitation.json --direct 127.0.0.1:3769 -python -m experiments.relay69 serve --state /private/test/receiver \ - --bind 127.0.0.1 --port 3769 --seconds 1800 -python -m experiments.relay69 pair --state /private/test/client \ - --invite /private/test/invitation.json --route direct -python -m experiments.relay69 request --state /private/test/client \ - --peer RECEIVER_FINGERPRINT --route direct --op send --message fixture-only -``` - -페어링 전에 별도로 신뢰할 수 있는 관리 경로를 통해 초대장의 인증서 핑거프린트를 수신기의 `init` 출력과 비교하세요. 본 실험실에서는 공개 초대 자료에 기존 SSH를 사용하고 일회용 비밀에는 비공개 파일을 사용했습니다. 초대장, 입장 비밀 또는 쿠키를 로그나 URL에 절대 붙여넣지 마세요. `request --op status --message ORIGINAL_MESSAGE_ID`는 불확실한 결과를 조정합니다. `revoke --state RECEIVER_STATE --peer CLIENT_FINGERPRINT`는 해당 테스트 클라이언트를 철회합니다. - -릴레이 모드의 경우 git 외부에서 역할당 하나씩 무작위 256비트 입장 자격 증명 두 개를 프로비저닝하세요. 릴레이 `--accounts` 파일은 `{hash: SHA256(credential), room: test-room, role: receiver|client}` 형식의 JSON 배열입니다. 엔드포인트는 0600 권한의 `--admission-file`로 자신의 자격 증명만 받으며 상대방의 비공개 TLS 키는 절대 받지 않습니다. invite 및 serve 둘 다에 `--relay wss://HOST/v1/connect`를 추가하고 `pair --route relay`를 사용하세요. 수신기와 클라이언트 모두 아웃바운드로 연결합니다. relay 명령어는 `--bind`, `--port`, `--accounts` 및 `--seconds`를 허용합니다. - -공개 시험에서는 전용 호스트 이름, 기존 CF/KR TLS 종단 및 KR-to-US 테일넷 업스트림을 사용했습니다. 공개 구성은 인프라에 따라 다르며 이 저장소에 의해 자동으로 프로비저닝되지 않습니다. 현재 배포된 시험 환경은 남아 있지 않습니다. - -## 운영 한도 - -릴레이는 10개의 연결, 초당 20개의 HTTP 입장/업그레이드 요청, 100개의 활성 쿠키, 256 KiB 프레임, 4프레임 수신 워터마크, 32 KiB 트랜스포트 쓰기 워터마크, 2초 전달 마감 시간 및 총 32 MiB 전달 바이트를 허용합니다. TLS JSON 메시지는 64 KiB로, 메시지 본문은 32 KiB로 제한됩니다. 수신기는 연결을 8개로, 연결당 요청을 100개로 제한하며, 저널은 중복 제거 이력을 조용히 축출하는 대신 10,000개의 레코드에서 멈춥니다. 이러한 한도는 테스트 예산이며 조정된 제품 보증이 아닙니다. - -서비스 수명은 최대 30분입니다. 공용 백엔드는 독립적인 systemd 런타임 한도, DynamicUser, 기능(capability) 없음, 읽기 전용 코드, ProtectHome, 명시적 금지 디렉터리, 비공개 tmp/장치, 256 MiB 메모리, 50% CPU 및 64개 작업을 사용했습니다. Cgroup IP 규칙은 US 백엔드에 대해 KR만 허용했습니다. 샌드박스 사전 점검을 통해 다른 홈과 제품 디렉터리를 읽을 수 없음을 확인했습니다. 새로운 방화벽 규칙, DNS 레코드, 영구 계정 또는 전역 Python 패키지는 설치되지 않았습니다. Mac 픽스처 클라이언트는 별도의 상태/venv를 사용했으며 Linux 샌드박스의 OS 격리를 갖추었다고 주장하지 않았습니다. - -실제 애플리케이션에는 더 강력한 기기 수명 주기/UX, 출발지 바인딩 입장 구성, 기기별 쿼터, 오프라인 복구, 의존성 및 프로토콜 검토, 플랫폼 서비스/키 스토리지 설계가 필요합니다. 영수증 계약은 릴레이 연결, 엔드포인트 수락 및 네이티브 제출을 계속 구분해야 합니다. [측정 보고서](REPORT.ko.md)를 참조하세요. diff --git a/experiments/relay69/README.md b/experiments/relay69/README.md deleted file mode 100644 index 8affe9e1..00000000 --- a/experiments/relay69/README.md +++ /dev/null @@ -1,122 +0,0 @@ -# #69 paired-device relay laboratory - -Experimental, fixture-only code based on local #47 commit `993d04d`. **Not a -production receiver, plugin, or installed CLI command.** The ordinary package, -local/SSH behavior, and installed version are unchanged. No native Claude/Codex -sessions, inboxes, queues, model credentials or quota are used. - -## Reproduce locally - -Python 3.11+ and OpenSSL with TLS 1.3 are required. The exercised environments -were macOS Python 3.14/3.13 and Ubuntu arm64 Python 3.12. - -```sh -python3 -m venv /tmp/session-peer-relay-lab -/tmp/session-peer-relay-lab/bin/pip install -r experiments/relay69/requirements.txt -/tmp/session-peer-relay-lab/bin/python -m unittest tests.test_relay69 -v -``` - -Tests use loopback TCP, real TLS, a local WebSocket relay and disposable state. -Without the optional dependencies, ordinary unittest discovery skips this module. -The experiment isn't included in wheel or sdist. No import starts a listener. - -## Components and boundaries - -- `identity.py`: locally generated P-256 private keys and short-lived self-signed - certificates. Keys/state are 0600/0700, never placed in invitations or copied - to the relay. This is filesystem protection, not a production OS keychain. -- `wire.py`: TLS 1.3/ALPN over either TCP or binary WebSocket frames using - Python/OpenSSL MemoryBIO. The client explicitly trusts and pins the invited - certificate. Paired receivers require a recognized client certificate before - list/send. Unknown unauthenticated clients can only attempt one invitation - reservation per connection. Public relay URLs require WSS; plaintext WS is - restricted to loopback tests. HTTP and WS redirects are rejected. -- `store.py`: ten-minute, single-use invitation reservation; pending/paired/ - revoked device states; an execution journal independent of agent databases. - Pairing commits only after mutual TLS proves the proposed key. A pending - initiator can reconcile a lost commit response with the same key. Revocation - closes live channels; rotation is revoke plus a new pairing, not seamless renewal. -- `relay.py`: role/room-scoped admission credentials issue 120-second HttpOnly - session cookies. Authentication happens before upgrade. The relay only forwards - ciphertext and holds no endpoint key. It sees routing/role, IP, time, lengths - and ciphertext. Cloudflare/Caddy see outer TLS metadata **and admission - credentials/cookies**, but not inner-TLS plaintext or keys. Outer authentication - is not end-to-end identity and doesn't replace peer TLS verification. -- `app.py`: all received operations go through the #47 `LocalTransport` and one - explicitly instantiated deterministic fixture adapter. Wire operations are - probe/list/send/status; no arbitrary command, path, native adapter, or wake is - available. Requests bind version, both identities, UUID, expiry, operation and - body; the TLS channel supplies record integrity and replay protection. - -The journal commits pending intent before invoking the fixture adapter. The same -peer+message ID with the same body returns the stored result; another body is -rejected. A crash between adapter invocation and result commit remains unknown -and is never automatically reexecuted. This favors at-most-once attempts over -liveness. It is **not** exactly-once native delivery. `submitted` is separate from -`consumptionConfirmed`, which remains false. `relayAttached` describes carrier -attachment; only an authenticated endpoint probe establishes receiver readiness. - -An automatic route races authenticated direct and relay probes, prioritizing -direct if both complete in the same selection cycle. The message goes to one -winner only. Failure after submission does not switch routes or resend. When -checking an unknown outcome, use status with the original message ID. - -## Explicit commands - -Run from the worktree root with the experiment venv: - -```sh -python -m experiments.relay69 init --state /private/test/receiver -python -m experiments.relay69 invite --state /private/test/receiver \ - --out /private/test/invitation.json --direct 127.0.0.1:3769 -python -m experiments.relay69 serve --state /private/test/receiver \ - --bind 127.0.0.1 --port 3769 --seconds 1800 -python -m experiments.relay69 pair --state /private/test/client \ - --invite /private/test/invitation.json --route direct -python -m experiments.relay69 request --state /private/test/client \ - --peer RECEIVER_FINGERPRINT --route direct --op send --message fixture-only -``` - -Compare the invitation's certificate fingerprint with the receiver's `init` -output through a separately trusted management path before pairing. The lab used -existing SSH for public invitation material and a private file for the one-time -secret. Never paste invitations, admission secrets or cookies into logs or URLs. -`request --op status --message ORIGINAL_MESSAGE_ID` reconciles an uncertain result. -`revoke --state RECEIVER_STATE --peer CLIENT_FINGERPRINT` revokes that test client. - -For relay mode, provision two random 256-bit admission credentials outside git, -one per role. The relay `--accounts` file is a JSON array of -`{hash: SHA256(credential), room: test-room, role: receiver|client}`. Endpoints -receive only their own credential in a 0600 `--admission-file`; they never receive -the other's private TLS key. Add `--relay wss://HOST/v1/connect` to both invite and -serve, and use `pair --route relay`. Receiver and client both connect outbound. -The relay command accepts `--bind`, `--port`, `--accounts`, and `--seconds`. - -The public trial used a dedicated hostname, existing CF/KR TLS termination and a -KR-to-US tailnet upstream. Public configuration is infrastructure-specific and is -not automatically provisioned by this repository. No deployed trial remains. - -## Operational limits - -The relay allows 10 connections, 20 HTTP admission/upgrade requests per second, -100 live cookies, 256 KiB frames, a four-frame receive watermark, 32 KiB transport -write watermark, a two-second forwarding deadline and 32 MiB total forwarded -bytes. TLS JSON messages are bounded to 64 KiB and message bodies to 32 KiB. -The receiver limits connections to eight and requests per connection to 100; -its journal stops at 10,000 records rather than silently evicting deduplication -history. These limits are test budgets, not tuned product guarantees. - -The service lifetime is at most 30 minutes. The public backend also used an -independent systemd runtime limit, DynamicUser, no capabilities, read-only code, -ProtectHome, explicit forbidden directories, private tmp/devices, 256 MiB memory, -50% CPU and 64 tasks. Cgroup IP rules admitted only KR to the US backend. The -sandbox preflight asserted that other homes and product directories were -unreadable. No new firewall rule, DNS record, persistent account or global Python -package was installed. Mac fixture clients used separate state/venvs; they were -not claimed to have the Linux sandbox's OS isolation. - -A real application would need stronger device lifecycle/UX, origin-bound -admission configuration, per-device quotas, offline recovery, dependency and -protocol review, and platform service/key storage design. The receipt contract -must continue distinguishing relay attachment, endpoint acceptance and native -submission. See [the measured report](REPORT.md). diff --git a/experiments/relay69/README.zh-CN.md b/experiments/relay69/README.zh-CN.md deleted file mode 100644 index d2cf26e5..00000000 --- a/experiments/relay69/README.zh-CN.md +++ /dev/null @@ -1,57 +0,0 @@ -# #69 配对设备中继实验室 - -基于本地 #47 提交 `993d04d` 的实验性、仅用于测试固件的代码。**不是生产环境接收器、插件或已安装的 CLI 命令。** 普通软件包、本地/SSH 行为以及已安装版本均未改变。未使用任何原生 Claude/Codex 会话、收件箱、队列、模型凭据或配额。 - -## 本地复现 - -需要 Python 3.11+ 和支持 TLS 1.3 的 OpenSSL。测试过的环境为 macOS Python 3.14/3.13 和 Ubuntu arm64 Python 3.12。 - -```sh -python3 -m venv /tmp/session-peer-relay-lab -/tmp/session-peer-relay-lab/bin/pip install -r experiments/relay69/requirements.txt -/tmp/session-peer-relay-lab/bin/python -m unittest tests.test_relay69 -v -``` - -测试使用回环 TCP、真实 TLS、本地 WebSocket 中继和一次性状态。在没有可选依赖项的情况下,普通的 unittest 发现机制会跳过此模块。本实验不包含在 wheel 或 sdist 中。任何 import 操作都不会启动监听器。 - -## 组件与边界 - -- `identity.py`:本地生成的 P-256 私钥和短期自签名证书。密钥/状态权限为 0600/0700,绝不会放入邀请中或复制到中继。这是文件系统保护,而非生产级操作系统密钥链。 -- `wire.py`:使用 Python/OpenSSL MemoryBIO 通过 TCP 或二进制 WebSocket 帧实现 TLS 1.3/ALPN。客户端显式信任并固定被邀请的证书。已配对的接收器在 list/send 之前需要已识别的客户端证书。未知的未经身份验证客户端每次连接只能尝试一次邀请预留。公共中继 URL 需要 WSS;明文 WS 仅限于回环测试。拒绝 HTTP 和 WS 重定向。 -- `store.py`:十分钟有效、单次使用的邀请预留;待定/已配对/已吊销的设备状态;独立于 agent 数据库的执行日志。配对仅在双向 TLS 证明了提议的密钥之后才会提交。处于待定状态的发起方可以使用相同的密钥调和丢失的提交响应。吊销会关闭活动通道;轮换是吊销加上重新配对,而非无缝续期。 -- `relay.py`:限定角色/房间范围的准入凭据会签发 120 秒有效的 HttpOnly 会话 cookie。身份验证在协议升级前发生。中继仅转发密文,不持有端点密钥。它可以看到路由/角色、IP、时间、长度和密文。Cloudflare/Caddy 可以看到外部 TLS 元数据**以及准入凭据/cookie**,但看不到内部 TLS 明文或密钥。外部身份验证不是端到端身份,不能替代对端 TLS 验证。 -- `app.py`:所有接收到的操作都会通过 #47 `LocalTransport` 和一个显式实例化的确定性测试固件适配器。线上操作为 probe/list/send/status;不提供任意命令、路径、原生适配器或唤醒功能。请求绑定版本、双方身份、UUID、过期时间、操作和主体;TLS 通道提供记录完整性和重放保护。 - -日志在调用测试固件适配器之前提交待处理意图。具有相同主体的相同 peer+message ID 会返回已存储的结果;不同的主体将被拒绝。在适配器调用和结果提交之间发生的崩溃将保持未知状态,且绝不会自动重新执行。这更倾向于最多一次尝试而非活性。这**不是**恰好一次的原生投递。`submitted` 与 `consumptionConfirmed` 是分开的,后者保持为 false。`relayAttached` 描述载体连接;只有经过身份验证的端点探测才能确立接收器的就绪状态。 - -自动路由会在经过身份验证的直连探测和中继探测之间竞速,若两者在同一选择周期内完成,则优先选择直连。消息仅发送给唯一的胜出者。提交后的失败不会切换路由或重新发送。在检查未知结果时,请使用带有原始消息 ID 的 status。 - -## 显式命令 - -在工作树根目录下使用实验 venv 运行: - -```sh -python -m experiments.relay69 init --state /private/test/receiver -python -m experiments.relay69 invite --state /private/test/receiver \ - --out /private/test/invitation.json --direct 127.0.0.1:3769 -python -m experiments.relay69 serve --state /private/test/receiver \ - --bind 127.0.0.1 --port 3769 --seconds 1800 -python -m experiments.relay69 pair --state /private/test/client \ - --invite /private/test/invitation.json --route direct -python -m experiments.relay69 request --state /private/test/client \ - --peer RECEIVER_FINGERPRINT --route direct --op send --message fixture-only -``` - -在配对之前,通过单独受信任的管理途径,将邀请的证书指纹与接收器的 `init` 输出进行比对。实验室使用现有的 SSH 传输公开邀请材料,使用私密文件传输一次性密钥。切勿将邀请、准入密钥或 cookie 粘贴到日志或 URL 中。`request --op status --message ORIGINAL_MESSAGE_ID` 可调和不确定的结果。`revoke --state RECEIVER_STATE --peer CLIENT_FINGERPRINT` 可吊销该测试客户端。 - -对于中继模式,在 git 外部准备两个随机的 256 位准入凭据,每个角色一个。中继 `--accounts` 文件是 `{hash: SHA256(credential), room: test-room, role: receiver|client}` 的 JSON 数组。端点仅在权限为 0600 的 `--admission-file` 中接收其自身的凭据;它们绝不会接收对方的私有 TLS 密钥。在 invite 和 serve 中均添加 `--relay wss://HOST/v1/connect`,并使用 `pair --route relay`。接收器和客户端均向外出站连接。中继命令接受 `--bind`、`--port`、`--accounts` 和 `--seconds`。 - -公开试验使用了专用主机名、现有的 CF/KR TLS 终止以及 KR 到 US 的 tailnet 上游。公开配置特定于基础设施,不会由此代码库自动配置。目前未保留任何已部署的试验。 - -## 运行限制 - -中继允许 10 个连接、每秒 20 个 HTTP 准入/升级请求、100 个有效 cookie、256 KiB 帧、4 帧接收水位线、32 KiB 传输写入水位线、2 秒转发截止时间和 32 MiB 总转发字节数。TLS JSON 消息限制为 64 KiB,消息主体限制为 32 KiB。接收器将连接数限制为 8 个,每个连接的请求数限制为 100 个;其日志在达到 10,000 条记录时停止,而不是静默逐出重复数据删除历史。这些限制属于测试预算,并非经过调优的产品保证。 - -服务生命周期最长为 30 分钟。公共后端还使用了独立的 systemd 运行时间限制、DynamicUser、无 capabilities、只读代码、ProtectHome、显式禁止目录、私有 tmp/devices、256 MiB 内存、50% CPU 和 64 个任务。Cgroup IP 规则仅允许 KR 访问 US 后端。沙箱预检断言其他主目录和产品目录不可读。未安装任何新的防火墙规则、DNS 记录、持久账户或全局 Python 包。Mac 测试固件客户端使用独立的状态/venv;它们并未声称具备 Linux 沙箱的操作系统隔离性。 - -实际应用将需要更强大的设备生命周期/UX、绑定来源的准入配置、单设备配额、离线恢复、依赖项和协议审查,以及平台服务/密钥存储设计。接收契约必须继续区分中继连接、端点接受和原生提交。参见[实测报告](REPORT.zh-CN.md)。 diff --git a/experiments/relay69/REPORT.ja.md b/experiments/relay69/REPORT.ja.md deleted file mode 100644 index aaa297bf..00000000 --- a/experiments/relay69/REPORT.ja.md +++ /dev/null @@ -1,64 +0,0 @@ -# Issue #69 技術検証 — 2026-09-16 - -## 決定事項 - -デバイスのペアリング、認証済み直接トランスポート、および不透明なパブリックWebSocketリレーは、#47のアダプター/トランスポート分離によって実現可能です。この実験は孤立したプロトタイプであり、本番向けの実装でも現在のコードのデプロイを推奨するものでもありません。決定論的フィクスチャアダプターのみを使用しており、実際のClaude/Codexセッション、キュー、認証情報、モデルクォータは一切関与していません。 - -ワークツリーはローカルコミット `993d04d` に基づいています。製品のCLI、依存関係、パッケージング、およびバージョンは変更されていません。リモートへのプッシュ、PR、リリースはいずれもこの試行には含まれません。再現手順およびプロトコルの境界については [README](README.ja.md) を参照してください。 - -## 実際のトポロジと測定値 - -Direct: tailnet TCP経由の Mac mini → MacBook。Relay: 各Macが独立してアウトバウンドのWSS接続を確立 → Cloudflare → KR Caddy → US tailnetバックエンド。内部TLS 1.3は2台のMacでのみ終端されます。これはWebSocket上のアプリケーションE2EEであり、新しくデプロイされたWireGuardトンネルやNATトラバーサル実装ではありません。 - -一時的なパブリックホスト名は `relay-test.abruption.dev` でした。パブリックな試行は **2026-09-16の21:13:09から21:20:33 KST** に実施されました。Cloudflareへのルートには、Tailscaleではなく通常のネットワークインターフェース(mini: en1、MacBook: en7)が使用されました。Tailscaleは管理/直接トラフィック用に引き続き利用可能でした。VPNを無効化したモバイルデバイスでのテストは行われませんでした。 - -| 測定項目 | サンプル | 中央値 | 最大値 | -| --- | ---: | ---: | ---: | -| コールド直接フィクスチャ送信 | 10/10 成功 | 46.955 ms | 53.62 ms | -| コールドパブリックリレーフィクスチャ送信 | 10/10 成功 | 3363.5 ms | 3473.93 ms | -| 既存の直接TLS接続、list | 10 成功 | 9.495 ms | 18.6 ms | -| 既存のリレーTLS接続、list | 10 成功 | 439.42 ms | 460.17 ms | - -コールドリレーの測定値には、アドミッションHTTP、WebSocket接続、内部TLS、認証済みレディネスプローブ、および送信が含まれます。ウォーム測定ではsendではなく **list** を使用しています。これらは接続再利用の可能性を示すものであり、同等のsendベンチマークではありません。これら少数の連続サンプルは、スループットや負荷の調査ではありません。サニタイズされた観測結果は [results.json](results.json) にあります。 - -パブリックペアリング、直接接続の優先、直接ルート利用不可時のリレーフォールバック、応答消失ステータスの整合、およびルート間の重複抑止はすべて成功しました。フィクスチャは23のエフェクト、重複ゼロ、保留エントリゼロを記録しました。ペアリングされたパブリッククライアントを失効させることで、その後の使用が防止されました。バックエンドの再起動後、新たなアドミッションと10件のウォームリレーリクエストは成功しましたが、再起動中の即時リクエストは失敗しました。中断のない復旧は実証されませんでした。 - -## 検証 - -- macOS Python 3.14/OpenSSL 3.6 および Ubuntu arm64 Python 3.12/OpenSSL 3.0 で、20件のラボ統合テストに合格しました。実機のMacBookでは Python 3.13/OpenSSL 3.0 が動作していました。 -- リポジトリ全体の統合スイートは、オプションのMCPおよびラボ依存関係を含め、7.751秒で **335件のテスト** に合格しました。Ubuntuでのラボテストは4.701秒で合格しました。 -- テストでは、実際のループバックTLS/WebSocketトラフィック、証明書の差し替え、TLSの改ざん/リプレイ、ペアリングの期限切れ/再利用、コミット応答の喪失、失効、クラッシュの曖昧性、重複ID、リクエストの境界、リレー認証、リソース制限、遅延転送、リダイレクトの拒否、および鍵ファイルの権限を検証しています。 -- キャプチャされたローカルリレーフレームには、センチネルプレーンテキスト、招待シークレット、秘密鍵は含まれていませんでした。パブリックリレーのペイロードキャプチャは無効化されていたため、これはエッジ/プロキシの可観測性に関する完全な監査ではありません。 -- Wheelおよびsdistのビルドは正常に完了し、この実験は除外されています。オプションのラボインポートによって、インストールされた製品に依存関係やリスナーが追加されることはありません。 - -意図ジャーナルは最大1回の実行試行をサポートします。エフェクトの発生後、レシートのコミット前にクラッシュした場合、結果は不明のままとなり、自動的な再実行は防止されます。これは意図的に厳密に1回のネイティブ配信を主張するものではありません。`submitted` と `consumptionConfirmed` は分離されたままです。 - -## 試行中に判明した事項 - -1. 最初の直接ペアリングの試行はタイムアウトしましたが、その後のTCPプローブと再試行は成功しました。ピアはコミットされていませんでした。原因は決定的に特定されておらず、起動/レディネス診断の改善が必要です。 -2. MacBook PythonのデフォルトCAファイル/ディレクトリパスが存在しませんでした。テストプロセスに `SSL_CERT_FILE=/etc/ssl/cert.pem` を設定することで、外部HTTPS検証が有効になりました。検証が無効化されることはなく、グローバル設定も変更されていません。 -3. デフォルトのurllibユーザーエージェントはCloudflareからHTTP 403を受信しました。明示的な `session-peer-relay69-lab/1.0` ユーザーエージェントおよびcurlでの比較は成功しました。特定のCloudflareルールは特定されておらず、WAFポリシーも変更されていません。 -4. バックエンド再起動直後のクライアントリクエストは失敗しましたが、その後のリクエストは成功しました。本番向けの作業では、境界付きの再接続/バックオフ、レディネス、および認証情報を漏洩しない有用な型付けされたエラーが必要です。 -5. コールドパブリックリレーのセットアップがレイテンシの大部分を占めています。ワンショットのCLIワークフローを統合する前に、永続的な認証済みチャネルの測定を行う価値があります。 - -## 隔離、リソース、およびクリーンアップ - -USプロセスは、DynamicUser、ケーパビリティなし、読み取り専用コード、制限付きディレクトリ、プライベートtmp/デバイス、30分のランタイム制限、MemoryMax 256 MiB、CPUQuota 50%、TasksMax 64 を指定した一時的なsystemdユニットを使用しました。事前チェックにより、他のホームディレクトリおよび選択された製品ディレクトリが読み取り不可であることが確認されました。cgroup IPポリシーにより、KRからこのバックエンドへのアクセスが許可されました。Macフィクスチャは、LinuxサービスのOSサンドボックスではなく、プライベートな一時状態を使用しました。 - -観測されたUSのピークメモリは2つのバックエンドプロセス全体で 26,189,824 バイト(約25 MiB)、1タスク、約1.002 CPU秒でした。これらの低負荷の観測結果は、敵対的な持続負荷の下での制限を検証するものではありません。 - -この試行では、既存のCloudflareイングレス配下に完全一致ホストのCaddyブロックのみを追加しました。DNSレコード、グローバルファイアウォールルール、永続アカウント、グローバルPythonパッケージは追加されていません。変更前にはタイマー付きロールバックが準備されました。Caddyのリロードは既存のWebSocketクライアントに影響を与える可能性がありましたが、ユーザーセッションへの完全な影響は測定されませんでした。既存のチャットヘルスエンドポイントは、変更前、変更中、変更後を通じてHTTP 200を返しました。 - -クリーンアップでは、MacBookレシーバーとUSリレーを停止し、試行リスナーが存在しないことを確認し、リモートコード/状態/認証情報を削除し、該当のCaddyブロックを削除して、ロールバックタイマーを停止しました。Caddyはアクティブなままであり、元のSHA-256が復元されました: -`60ae35627b82d65724ceac89421e1f92ba475b675fd146b4036129f352197ebb`。 -テスト用ホスト名はその後、元のHTTP 301リダイレクトを返しました。ローカルの試行鍵および認証情報は、サニタイズされた測定値のみを保持した後に削除されました。 - -## 残された境界と次のステップ - -外部アドミッションには、プロビジョニングされたルーム/ロールのBearer認証情報と有効期間の短いCookieが使用され、デバイスにバインドされた証明ではありません。Cloudflare/Caddyはこれらの外部認証情報とメタデータを参照できます。内部のピン留めされた相互TLSがピアを認証し、アプリケーションペイロードを保護します。本番向けの作業には、オリジンにバインドされたアドミッション設定、認証情報のローテーション、デバイスごとのクォータ、およびプロトコルのレビューが必要です。 - -招待の転送には、信頼できる管理パスとプライベートファイルが使用されました。モバイル向けのQR/SAS UX、ハードウェア鍵ストレージ、シームレスな証明書更新、オフラインキュー、本番レシーバーのライフサイクル、ネイティブエージェントの認可統合はありません。ファイルシステムの権限だけではキーチェーンにはなりません。このラボの証明書は有効期間が短く、ローテーションには失効と再ペアリングが必要です。 - -直接接続の成功はtailnet TCPを意味し、パブリックなピアツーピアNATトラバーサルではありません。Windows、モバイルのVPN無効クライアント、IPv6オリジンポリシー、現在のOCI APIファイアウォールの状態、大規模なサービス拒否(DoS)動作、および独立したセキュリティレビューは未検証のままです。直接のパブリックオリジンプローブは、1つの観測点からのみタイムアウトしました。 - -推奨される次のスコープ: 安定したデバイス/レシートスキーマとライフサイクルの定義、接続再利用および再接続動作の測定、外部アドミッションと鍵ストレージの強化、その上で実際のエージェントアダプターを有効化する前のプロトコルレビューです。これらの決定が行われる間、このラボはオプションかつ孤立した状態を維持してください。 diff --git a/experiments/relay69/REPORT.ko.md b/experiments/relay69/REPORT.ko.md deleted file mode 100644 index a968e34d..00000000 --- a/experiments/relay69/REPORT.ko.md +++ /dev/null @@ -1,64 +0,0 @@ -# 이슈 #69 기술 검증 — 2026-09-16 - -## 결정 - -기기 페어링, 인증된 직접 트랜스포트 및 불투명한 공용 WebSocket 릴레이는 #47 어댑터/트랜스포트 분리를 통해 실현 가능합니다. 이 실험은 격리된 프로토타입이며, 프로덕션 구현이나 현재 코드를 배포하라는 권장 사항이 아닙니다. 결정론적 픽스처 어댑터만을 독점적으로 사용하며, 실제 Claude/Codex 세션, 큐, 자격 증명 또는 모델 쿼터는 관여되지 않았습니다. - -작업 트리는 로컬 커밋 `993d04d`를 기반으로 합니다. 제품 CLI, 의존성, 패키징 및 버전은 변경되지 않고 유지됩니다. 원격 푸시, PR 또는 릴리스는 이 시험의 일부가 아닙니다. 재현 및 프로토콜 경계에 대해서는 [README](README.ko.md)를 참조하십시오. - -## 실제 토폴로지 및 측정값 - -직접 연결: tailnet TCP 기반 Mac mini → MacBook. 릴레이: 각 Mac이 독립적으로 아웃바운드 WSS 연결 생성 → Cloudflare → KR Caddy → US tailnet 백엔드. 내부 TLS 1.3은 두 Mac에서만 종단됩니다. 이는 새로 배포된 WireGuard 터널이나 NAT 통과 구현이 아닌, WebSocket 기반 애플리케이션 E2EE입니다. - -임시 공용 호스트 이름은 `relay-test.abruption.dev`였습니다. 공용 시험은 **2026-09-16 21:13:09부터 21:20:33 KST까지** 진행되었습니다. Cloudflare로의 경로는 Tailscale 대신 일반 네트워크 인터페이스(mini: en1, MacBook: en7)를 사용했습니다. Tailscale은 관리/직접 트래픽용으로 계속 사용할 수 있었습니다. VPN이 비활성화된 모바일 기기는 테스트되지 않았습니다. - -| 측정 항목 | 샘플 | 중앙값 | 최댓값 | -| --- | ---: | ---: | ---: | -| 콜드 직접 픽스처 send | 10/10 성공 | 46.955 ms | 53.62 ms | -| 콜드 공용 릴레이 픽스처 send | 10/10 성공 | 3363.5 ms | 3473.93 ms | -| 기존 직접 TLS 연결, list | 10 성공 | 9.495 ms | 18.6 ms | -| 기존 릴레이 TLS 연결, list | 10 성공 | 439.42 ms | 460.17 ms | - -콜드 릴레이 측정값에는 입장 HTTP, WebSocket 연결, 내부 TLS, 인증된 준비 상태 프로브 및 제출이 포함됩니다. 웜 측정값은 send가 아닌 **list**를 사용하며, 이는 동등한 send 벤치마크가 아닌 연결 재사용 잠재력을 보여줍니다. 이러한 소규모 순차 샘플은 처리량이나 부하 연구가 아닙니다. 정제된 관측 데이터는 [results.json](results.json)에 있습니다. - -공용 페어링, 직접 우선, 사용할 수 없는 직접 경로 시 릴레이 폴백, 유실된 응답 상태 조정 및 경로 간 중복 억제가 모두 통과되었습니다. 픽스처는 23개의 효과, 0개의 중복 및 0개의 보류 항목을 기록했습니다. 페어링된 공용 클라이언트를 철회하자 이후 사용이 차단되었습니다. 백엔드 재시작 후 새로운 입장과 10개의 웜 릴레이 요청이 성공했으나, 재시작 중 발생한 즉각적인 요청은 실패했습니다. 중단 없는 복구는 입증되지 않았습니다. - -## 검증 - -- macOS Python 3.14/OpenSSL 3.6 및 Ubuntu arm64 Python 3.12/OpenSSL 3.0에서 20개의 실험실 통합 테스트를 통과했습니다. 실제 MacBook에서는 Python 3.13/OpenSSL 3.0이 실행되었습니다. -- 결합된 저장소 제품군은 선택적 MCP 및 실험실 의존성을 포함하여 7.751초 만에 **335개의 테스트**를 통과했습니다. Ubuntu 실험실 테스트는 4.701초 만에 통과했습니다. -- 테스트는 실제 루프백 TLS/WebSocket 트래픽, 인증서 대체, TLS 변조/재생, 페어링 만료/재사용, 유실된 커밋 응답, 철회, 충돌 모호성, 중복 ID, 요청 경계, 릴레이 인증, 리소스 제한, 느린 전달, 리디렉션 거부 및 키 파일 권한을 실행하고 검증합니다. -- 캡처된 로컬 릴레이 프레임에는 센티널 평문, 초대 시크릿 또는 개인 키가 포함되지 않았습니다. 공용 릴레이 페이로드 캡처는 비활성화되었으며, 이는 엣지/프록시 관측 가능성에 대한 완전한 감사가 아닙니다. -- Wheel과 sdist는 성공적으로 빌드되며 해당 실험을 제외합니다. 선택적 실험실 import는 설치된 제품에 의존성이나 리스너를 추가하지 않습니다. - -의도 저널은 최대 한 번(at-most-once) 실행 시도를 지원합니다. 효과 발생 후 영수증 커밋 전에 비정상 종료되면 알 수 없는 결과가 남으며 자동 재실행이 방지됩니다. 이는 의도적으로 네이티브의 정확히 한 번(exactly-once) 전달을 주장하지 않습니다. `submitted`와 `consumptionConfirmed`는 계속 분리되어 유지됩니다. - -## 시험 중 발견 사항 - -1. 첫 번째 직접 페어링 시도는 이후의 TCP 프로브 및 재시도가 성공하기 전에 시간 초과되었습니다. 커밋된 피어는 없었습니다. 원인은 명확하게 규명되지 않았으며, 시작/준비 상태 진단 기능을 개선해야 합니다. -2. MacBook Python의 기본 CA 파일/디렉터리 경로가 존재하지 않았습니다. 테스트 프로세스에 `SSL_CERT_FILE=/etc/ssl/cert.pem`을 설정하여 외부 HTTPS 검증을 활성화했습니다. 검증은 결코 비활성화되지 않았으며 전역 설정도 변경되지 않았습니다. -3. 기본 urllib user agent는 Cloudflare HTTP 403을 수신했습니다. 명시적인 `session-peer-relay69-lab/1.0` user agent는 성공했으며 curl 비교 테스트도 성공했습니다. 구체적인 Cloudflare 규칙은 확인되지 않았으며 WAF 정책도 변경되지 않았습니다. -4. 백엔드 재시작 직후의 클라이언트 요청은 실패했고 이후의 요청은 성공했습니다. 프로덕션 작업에는 제한된 재연결/백오프, 준비 상태 확인, 자격 증명을 노출하지 않는 유용한 타입화된 오류가 필요합니다. -5. 콜드 공용 릴레이 설정이 지연 시간의 대부분을 차지합니다. 단일 실행(one-shot) CLI 워크플로를 통합하기 전에 영구 인증 채널을 측정해 볼 가치가 있습니다. - -## 격리, 리소스 및 정리 - -US 프로세스는 DynamicUser, 권한(capabilities) 없음, 읽기 전용 코드, 제한된 디렉터리, 비공개 tmp/devices, 30분 실행 시간 제한, MemoryMax 256 MiB, CPUQuota 50%, TasksMax 64가 적용된 임시 systemd 유닛을 사용했습니다. 사전 점검을 통해 다른 홈 및 선택된 제품 디렉터리를 읽을 수 없음을 확인했습니다. Cgroup IP 정책은 이 백엔드에 대한 KR 접근을 허용했습니다. Mac 픽스처는 Linux 서비스의 OS 샌드박스가 아닌 비공개 임시 상태를 사용했습니다. - -관측된 US 최대 메모리는 26,189,824 바이트(약 25 MiB), 1개 태스크, 2개 백엔드 프로세스에 걸쳐 약 1.002 CPU 초였습니다. 이러한 소규모 관측 데이터는 적대적인 지속 부하 상태에서의 제한 한계를 검증하지 못합니다. - -이 시험은 기존 Cloudflare 인그레스 아래에 정확한 호스트(exact-host) Caddy 블록만을 추가했습니다. DNS 레코드, 전역 방화벽 규칙, 영구 계정 또는 전역 Python 패키지는 추가되지 않았습니다. 변경 전에 시간 제한 롤백이 준비되었습니다. Caddy 리로드는 기존 WebSocket 클라이언트에 영향을 미칠 수 있었으며, 전체 사용자 세션에 미친 영향은 측정되지 않았습니다. 기존 채팅 헬스 엔드포인트는 작업 전, 도중, 후에 모두 HTTP 200을 반환했습니다. - -정리 작업에서는 MacBook 수신기와 US 릴레이를 중지하고, 시험 리스너가 없음을 확인하고, 원격 코드/상태/자격 증명을 제거하고, 정확한 Caddy 블록을 제거하고, 롤백 타이머를 중지했습니다. Caddy는 활성 상태를 유지했으며 원래의 SHA-256이 복원되었습니다: -`60ae35627b82d65724ceac89421e1f92ba475b675fd146b4036129f352197ebb`. -테스트 호스트 이름은 이후 원래의 HTTP 301 리디렉션을 반환했습니다. 로컬 시험 키와 자격 증명은 정제된 측정값만을 보관한 후 삭제되었습니다. - -## 남은 경계 및 다음 단계 - -외부 입장은 프로비저닝된 룸/역할 베어러 자격 증명과 수명이 짧은 쿠키를 사용하며, 기기 바인딩 증명이 아닙니다. Cloudflare/Caddy는 이러한 외부 자격 증명과 메타데이터를 볼 수 있습니다. 내부 고정(pinned) 상호 TLS는 피어를 인증하고 애플리케이션 페이로드를 보호합니다. 프로덕션 작업에는 오리진 바인딩 입장 구성, 자격 증명 로테이션, 기기별 쿼터 및 프로토콜 검토가 필요합니다. - -초대장 전달에는 신뢰할 수 있는 관리 경로와 비공개 파일을 사용했습니다. 모바일 QR/SAS UX, 하드웨어 키 스토리지, 매끄러운 인증서 갱신, 오프라인 큐, 프로덕션 수신기 수명 주기 또는 네이티브 에이전트 승인 통합은 제공되지 않습니다. 파일 시스템 권한만으로는 키체인이 될 수 없습니다. 이 실험실의 인증서는 수명이 짧으며, 로테이션하려면 철회 후 다시 페어링해야 합니다. - -직접 연결 성공은 공용 피어 투 피어 NAT 통과가 아닌 tailnet TCP를 의미합니다. Windows, 모바일 VPN 미사용(VPN-off) 클라이언트, IPv6 오리진 정책, 현재 OCI API 방화벽 상태, 대규모 서비스 거부(DoS) 동작 및 독립적인 보안 검토는 여전히 검증되지 않았습니다. 직접 공용 오리진 프로브는 단 하나의 관측 지점에서만 시간 초과되었습니다. - -권장되는 다음 범위: 안정적인 기기/영수증 스키마 및 수명 주기 정의, 연결 재사용 및 재연결 동작 측정, 외부 입장 및 키 스토리지 강화, 실제 에이전트 어댑터가 활성화되기 전에 프로토콜 검토. 이러한 결정이 내려지는 동안 이 실험실을 선택 사항으로 격리된 상태로 유지하십시오. diff --git a/experiments/relay69/REPORT.md b/experiments/relay69/REPORT.md deleted file mode 100644 index 87009583..00000000 --- a/experiments/relay69/REPORT.md +++ /dev/null @@ -1,135 +0,0 @@ -# Issue #69 technical validation — 2026-09-16 - -## Decision - -Device pairing, authenticated direct transport, and an opaque public WebSocket -relay are feasible with the #47 adapter/transport separation. This experiment is -an isolated prototype, not a production implementation or a recommendation to -deploy the current code. It uses a deterministic fixture adapter exclusively; -no real Claude/Codex session, queue, credential, or model quota was involved. - -The worktree is based on local commit `993d04d`. Product CLI, dependencies, -packaging, and version remain unchanged. No remote push, PR, or release is part -of this trial. See [README](README.md) for reproduction and protocol boundaries. - -## Actual topology and measurements - -Direct: Mac mini → MacBook over tailnet TCP. Relay: each Mac independently makes -an outbound WSS connection → Cloudflare → KR Caddy → US tailnet backend. Inner -TLS 1.3 terminates only at the two Macs. This is application E2EE over WebSocket, -not a newly deployed WireGuard tunnel or a NAT traversal implementation. - -The temporary public hostname was `relay-test.abruption.dev`. The public trial -ran from **21:13:09 to 21:20:33 KST on 2026-09-16**. Routes to Cloudflare used -ordinary network interfaces (mini: en1; MacBook: en7), rather than Tailscale. -Tailscale remained available for management/direct traffic; a mobile device with -VPN disabled was not tested. - -| Measurement | Samples | Median | Maximum | -| --- | ---: | ---: | ---: | -| Cold direct fixture send | 10/10 successful | 46.955 ms | 53.62 ms | -| Cold public relay fixture send | 10/10 successful | 3363.5 ms | 3473.93 ms | -| Existing direct TLS connection, list | 10 successful | 9.495 ms | 18.6 ms | -| Existing relay TLS connection, list | 10 successful | 439.42 ms | 460.17 ms | - -Cold relay measurements include admission HTTP, WebSocket connection, inner TLS, -authenticated readiness probe, and submission. Warm measurements use **list**, -not send; they demonstrate connection reuse potential, not an equivalent send -benchmark. These small sequential samples are not a throughput or load study. -Sanitized observations are in [results.json](results.json). - -Public pairing, direct preference, relay fallback with an unavailable direct -route, lost-response status reconciliation, and cross-route duplicate suppression -all passed. The fixture recorded 23 effects, zero duplicates, and zero pending -entries. Revoking a paired public client prevented subsequent use. Backend -restart was followed by successful fresh admission and ten warm relay requests, -but an immediate request during restart failed: uninterrupted recovery was not -demonstrated. - -## Verification - -- 20 lab integration tests passed on macOS Python 3.14/OpenSSL 3.6 and Ubuntu - arm64 Python 3.12/OpenSSL 3.0. The real MacBook ran Python 3.13/OpenSSL 3.0. -- The combined repository suite passed **335 tests** in 7.751 seconds, including - the optional MCP and lab dependencies. Ubuntu lab tests passed in 4.701 seconds. -- Tests exercise real loopback TLS/WebSocket traffic, certificate substitution, - TLS tampering/replay, pairing expiry/reuse, lost commit responses, revocation, - crash ambiguity, duplicate IDs, request bounds, relay authentication, resource - limits, slow forwarding, redirect rejection, and key file permissions. -- Captured local relay frames did not contain sentinel plaintext, invitation - secrets, or private keys. Public relay payload capture was disabled; this is - not a full audit of edge/proxy observability. -- Wheel and sdist build successfully and exclude the experiment. Optional lab - imports do not add dependencies or listeners to the installed product. - -The intent journal supports at-most-once execution attempts. A crash after an -effect but before receipt commit leaves an unknown result and prevents automatic -reexecution. This deliberately does not claim exactly-once native delivery. -`submitted` and `consumptionConfirmed` remain separate. - -## Findings during the trial - -1. The first direct pairing attempt timed out before a later TCP probe and retry - succeeded. No peer had been committed. The cause was not conclusively - established; startup/readiness diagnostics need improvement. -2. MacBook Python's default CA file/directory paths did not exist. Setting - `SSL_CERT_FILE=/etc/ssl/cert.pem` for the test process enabled outer HTTPS - verification. Verification was never disabled; no global setting changed. -3. The default urllib user agent received Cloudflare HTTP 403. An explicit - `session-peer-relay69-lab/1.0` user agent succeeded, as did a curl comparison. - The specific Cloudflare rule was not identified and no WAF policy changed. -4. A client request immediately after backend restart failed; later requests - succeeded. Production work needs bounded reconnect/backoff, readiness, and - useful typed errors without exposing credentials. -5. Cold public relay setup dominates latency. Persistent authenticated channels - deserve measurement before integrating a one-shot CLI workflow. - -## Isolation, resources, and cleanup - -The US process used a temporary systemd unit with DynamicUser, no capabilities, -read-only code, restricted directories, private tmp/devices, a 30-minute runtime -limit, MemoryMax 256 MiB, CPUQuota 50%, and TasksMax 64. A preflight verified that -other homes and selected product directories were unreadable. Cgroup IP policy -allowed KR access to this backend. Mac fixtures used private temporary state, -not the Linux service's OS sandbox. - -Observed US peak memory was 26,189,824 bytes (about 25 MiB), one task, and about -1.002 CPU seconds across two backend processes. These low-volume observations do -not validate the limits under hostile sustained load. - -The trial added only an exact-host Caddy block under the existing Cloudflare -ingress. No DNS record, global firewall rule, persistent account, or global Python -package was added. A timed rollback was prepared before the change. Caddy reload -could affect existing WebSocket clients; full user-session impact was not measured. -The existing chat health endpoint returned HTTP 200 before, during, and after. - -Cleanup stopped the MacBook receiver and US relay, verified no trial listener, -removed remote code/state/credentials, removed the exact Caddy block, and stopped -the rollback timer. Caddy remained active and its original SHA-256 was restored: -`60ae35627b82d65724ceac89421e1f92ba475b675fd146b4036129f352197ebb`. -The test hostname returned its original HTTP 301 redirect afterward. Local trial -keys and credentials are deleted after retaining only sanitized measurements. - -## Remaining boundaries and next steps - -Outer admission uses provisioned room/role bearer credentials and short-lived -cookies; it is not a device-bound proof. Cloudflare/Caddy can see these outer -credentials and metadata. Inner pinned mutual TLS authenticates peers and -protects application payloads. Production work needs origin-bound admission -configuration, credential rotation, per-device quotas, and protocol review. - -Invitation transfer used trusted management paths and private files. There is no -mobile QR/SAS UX, hardware key storage, seamless certificate renewal, offline -queue, production receiver lifecycle, or native agent authorization integration. -Filesystem permissions alone are not a keychain. Certificates in this lab are -short-lived, and rotation requires revocation and pairing again. - -Direct success means tailnet TCP, not public peer-to-peer NAT traversal. Windows, -mobile VPN-off clients, IPv6 origin policy, current OCI API firewall state, -large-scale denial-of-service behavior, and independent security review remain -unverified. A direct public-origin probe timed out from one vantage point only. - -Recommended next scope: define stable device/receipt schemas and lifecycle; -measure connection reuse and reconnect behavior; strengthen outer admission and -key storage; then review the protocol before any real agent adapter is enabled. -Keep this lab optional and isolated while those decisions are made. diff --git a/experiments/relay69/REPORT.zh-CN.md b/experiments/relay69/REPORT.zh-CN.md deleted file mode 100644 index 47097be6..00000000 --- a/experiments/relay69/REPORT.zh-CN.md +++ /dev/null @@ -1,116 +0,0 @@ -# Issue #69 技术验证 — 2026-09-16 - -## 决策 - -设备配对、经过身份验证的直接传输以及不透明的公共 WebSocket 中继, -在 #47 的适配器/传输分离架构下是可行的。本实验是一个隔离的原型, -不是生产实现,也不建议部署当前代码。实验仅使用确定性的夹具适配器; -未涉及任何真实的 Claude/Codex 会话、队列、凭据或模型配额。 - -该工作树基于本地提交 `993d04d`。产品 CLI、依赖项、打包方式和版本均未更改。 -本次试验不包含远程推送、PR 或发布。复现步骤和协议边界请参阅 -[README](README.zh-CN.md)。 - -## 实际拓扑与测量结果 - -直接连接:Mac mini 通过 tailnet TCP → MacBook。中继连接:每台 Mac 分别建立 -出站 WSS 连接 → Cloudflare → 韩国 Caddy → 美国 tailnet 后端。内部 TLS 1.3 -只在两台 Mac 上终止。这是在 WebSocket 上运行的应用层 E2EE,并非新部署的 -WireGuard 隧道或 NAT 穿透实现。 - -临时公共主机名为 `relay-test.abruption.dev`。公共试验于 -**2026-09-16 KST 21:13:09 至 21:20:33** 运行。通往 Cloudflare 的路由使用 -普通网络接口(mini:en1;MacBook:en7),而不是 Tailscale。Tailscale 仍可用于 -管理和直接流量;未测试关闭 VPN 的移动设备。 - -| 测量项目 | 样本 | 中位数 | 最大值 | -| --- | ---: | ---: | ---: | -| 冷启动直接夹具发送 | 10/10 成功 | 46.955 ms | 53.62 ms | -| 冷启动公共中继夹具发送 | 10/10 成功 | 3363.5 ms | 3473.93 ms | -| 现有直接 TLS 连接,list | 10 次成功 | 9.495 ms | 18.6 ms | -| 现有中继 TLS 连接,list | 10 次成功 | 439.42 ms | 460.17 ms | - -冷启动中继测量包括准入 HTTP、WebSocket 连接、内部 TLS、经过身份验证的就绪探测 -以及提交。热连接测量使用 **list** 而非发送;它们表明连接有复用潜力,并非等价的 -发送基准。这些少量顺序样本不是吞吐量或负载研究。清理后的观测结果位于 -[results.json](results.json)。 - -公共配对、优先直接连接、直接路由不可用时回退到中继、响应丢失后的状态协调, -以及跨路由重复抑制均通过测试。夹具记录了 23 个效果、零个重复项和零个待处理项。 -撤销已配对的公共客户端后,后续使用被阻止。后端重启后,新的准入和十次热中继请求 -均成功,但重启期间立即发出的请求失败:尚未证明能够不间断恢复。 - -## 验证 - -- 在 macOS Python 3.14/OpenSSL 3.6 和 Ubuntu arm64 Python 3.12/OpenSSL 3.0 - 上,20 个实验室集成测试全部通过。真实 MacBook 运行 Python 3.13/OpenSSL 3.0。 -- 合并后的仓库测试套件在 7.751 秒内通过 **335 项测试**,其中包括可选的 MCP - 和实验室依赖项。Ubuntu 实验室测试在 4.701 秒内通过。 -- 测试覆盖真实的回环 TLS/WebSocket 流量、证书替换、TLS 篡改/重放、配对过期/复用、 - 提交响应丢失、撤销、崩溃歧义、重复 ID、请求边界、中继身份验证、资源限制、 - 慢速转发、重定向拒绝以及密钥文件权限。 -- 捕获的本地中继帧中不包含哨兵明文、邀请密钥或私钥。公共中继载荷捕获已禁用; - 这并非对边缘/代理可观测性的完整审计。 -- Wheel 和 sdist 构建成功且排除本实验。可选实验室导入不会向已安装产品添加依赖项 - 或监听器。 - -意图日志支持最多执行一次的尝试。在效果已经产生、但回执提交前发生崩溃时, -结果会保持未知,并阻止自动重新执行。这明确不宣称原生传递恰好执行一次。 -`submitted` 与 `consumptionConfirmed` 仍然彼此独立。 - -## 试验期间的发现 - -1. 第一次直接配对尝试超时,随后 TCP 探测和重试成功。当时没有任何对等方被提交。 - 原因尚未得到确证;启动/就绪诊断需要改进。 -2. MacBook Python 的默认 CA 文件/目录路径不存在。为测试进程设置 - `SSL_CERT_FILE=/etc/ssl/cert.pem` 后启用了外层 HTTPS 验证。验证从未被禁用, - 也没有更改任何全局设置。 -3. urllib 的默认用户代理收到 Cloudflare HTTP 403。显式使用 - `session-peer-relay69-lab/1.0` 用户代理后成功,curl 对照也成功。未识别具体的 - Cloudflare 规则,也未更改任何 WAF 策略。 -4. 后端重启后立即发出的客户端请求失败,稍后的请求成功。生产实现需要有界的重连/ - 退避、就绪检查以及不泄露凭据的实用类型化错误。 -5. 冷启动公共中继的建立过程占据了大部分延迟。在集成一次性 CLI 工作流之前, - 应测量持久身份验证通道的表现。 - -## 隔离、资源与清理 - -美国服务器进程使用临时 systemd 单元运行,配置包括 DynamicUser、无 capabilities、 -只读代码、受限目录、私有 tmp/设备、30 分钟运行时限、MemoryMax 256 MiB、 -CPUQuota 50% 和 TasksMax 64。预检确认其他 home 目录及选定的产品目录不可读。 -Cgroup IP 策略允许韩国服务器访问该后端。Mac 夹具使用私有临时状态, -而不是 Linux 服务的操作系统沙箱。 - -观测到的美国服务器峰值内存为 26,189,824 字节(约 25 MiB)、一个任务, -两个后端进程合计约 1.002 CPU 秒。这些低流量观测无法验证在持续恶意负载下的限制。 - -本次试验仅在现有 Cloudflare 入口下添加了精确主机的 Caddy 块。未添加 DNS 记录、 -全局防火墙规则、持久账户或全局 Python 软件包。更改前准备了定时回滚。 -Caddy 重载可能影响现有 WebSocket 客户端;未测量对所有用户会话的影响。 -现有聊天健康检查端点在试验前、试验中和试验后均返回 HTTP 200。 - -清理过程停止了 MacBook 接收器和美国中继,确认不存在试验监听器,删除了远程代码/ -状态/凭据和精确 Caddy 块,并停止回滚计时器。Caddy 保持活动,且恢复了其原始 -SHA-256:`60ae35627b82d65724ceac89421e1f92ba475b675fd146b4036129f352197ebb`。 -之后,测试主机名恢复返回原来的 HTTP 301 重定向。仅保留经过清理的测量结果后, -本地试验密钥和凭据均已删除。 - -## 剩余边界与后续步骤 - -外层准入使用预配的房间/角色 bearer 凭据和短期 cookie;它不是与设备绑定的证明。 -Cloudflare/Caddy 可以看到这些外层凭据和元数据。内部固定的双向 TLS 对对等方进行 -身份验证并保护应用载荷。生产实现需要绑定来源的准入配置、凭据轮换、每设备配额 -以及协议审查。 - -邀请传输使用可信的管理路径和私有文件。目前没有移动端 QR/SAS 用户体验、 -硬件密钥存储、无缝证书续期、离线队列、生产级接收器生命周期或原生代理授权集成。 -仅依靠文件系统权限并不等同于钥匙串。本实验中的证书有效期很短,轮换需要先撤销 -再重新配对。 - -直接连接成功是指 tailnet TCP,而不是公共点对点 NAT 穿透。Windows、关闭 VPN 的 -移动客户端、IPv6 源站策略、当前 OCI API 防火墙状态、大规模拒绝服务行为以及独立 -安全审查仍未验证。从一个观测点对公共源站进行的直接探测发生了超时。 - -建议的下一阶段范围:定义稳定的设备/回执模式和生命周期;测量连接复用与重连行为; -加强外层准入和密钥存储;然后在启用任何真实代理适配器之前审查协议。在完成这些 -决策之前,应让本实验保持可选且隔离。 diff --git a/experiments/relay69/__init__.py b/experiments/relay69/__init__.py deleted file mode 100644 index f8bbc501..00000000 --- a/experiments/relay69/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Opt-in #69 laboratory. Not part of the installed session-peer package.""" diff --git a/experiments/relay69/__main__.py b/experiments/relay69/__main__.py deleted file mode 100644 index 8c9acf67..00000000 --- a/experiments/relay69/__main__.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Explicit laboratory commands; never starts services from normal CLI usage.""" -import argparse -import asyncio -import json -import logging -from pathlib import Path -import signal -import time - -from .app import Receiver, pair, exchange -from .identity import private_write -from .relay import Relay -from .store import Store - - -async def run(args): - # Headers, cookies and request bodies must never enter websocket debug logs. - logging.getLogger('websockets').setLevel(logging.CRITICAL+1) - if args.command == 'relay': - accounts = json.loads(Path(args.accounts).read_text()) - relay = Relay(accounts) - server = await relay.start(args.bind, args.port) - print(json.dumps({'ready': True, 'port': server.sockets[0].getsockname()[1]}), flush=True) - try: - await lifetime(args.seconds) - finally: - server.close() - await server.wait_closed() - print(json.dumps({'bytes': relay.bytes, 'frames': relay.forwarded_frames, - 'rejected': relay.rejected}), flush=True) - return - store = Store(args.state) - try: - if args.command == 'init': - print(json.dumps({'device': store.device})) - elif args.command == 'invite': - routes = {} - if args.direct: - routes['direct'] = args.direct - if args.relay: - routes['relay'] = args.relay - private_write(args.out, json.dumps(store.invite(routes))) - print(json.dumps({'invitationSaved': True, 'device': store.device})) - elif args.command == 'revoke': - store.revoke(args.peer) - print(json.dumps({'revoked': True})) - elif args.command == 'serve': - receiver = Receiver(store) - server = await receiver.listen(args.bind, args.port) - task = None - if args.relay: - task = asyncio.create_task(receiver.relay_listener(args.relay, - Path(args.admission_file).read_text().strip())) - print(json.dumps({'ready': True, 'port': server.sockets[0].getsockname()[1], - 'device': store.device}), flush=True) - try: - await lifetime(args.seconds) - finally: - if task: - task.cancel() - await asyncio.gather(task, return_exceptions=True) - server.close() - await server.wait_closed() - await receiver.close() - elif args.command == 'pair': - print(json.dumps(await pair(store, json.loads(Path(args.invite).read_text()), args.route, - Path(args.admission_file).read_text().strip() if args.admission_file else None))) - elif args.command == 'request': - start = time.monotonic() - result = await exchange(store, args.peer, args.op, args.message, args.id, args.route, - Path(args.admission_file).read_text().strip() if args.admission_file else None) - print(json.dumps({**result, 'elapsedMs': round((time.monotonic()-start)*1000, 2)})) - if not result.get('ok'): - return 1 - finally: - store.close() - return 0 - - -async def lifetime(seconds): - event = asyncio.Event() - loop = asyncio.get_running_loop() - for sig in (signal.SIGTERM, signal.SIGINT): - loop.add_signal_handler(sig, event.set) - try: - await asyncio.wait_for(event.wait(), seconds) - except asyncio.TimeoutError: - pass - - -def main(): - p = argparse.ArgumentParser() - p.add_argument('command', choices=['init', 'invite', 'serve', 'pair', 'request', 'relay', 'revoke']) - p.add_argument('--state') - p.add_argument('--bind', default='127.0.0.1') - p.add_argument('--port', type=int, default=0) - p.add_argument('--seconds', type=int, default=1800) - p.add_argument('--out') - p.add_argument('--direct') - p.add_argument('--relay') - p.add_argument('--accounts') - p.add_argument('--admission-file') - p.add_argument('--invite') - p.add_argument('--route', choices=['auto', 'direct', 'relay'], default='auto') - p.add_argument('--peer') - p.add_argument('--op', choices=['list', 'send', 'status'], default='list') - p.add_argument('--message') - p.add_argument('--id') - args = p.parse_args() - if not 1 <= args.seconds <= 1800: - p.error('lifetime must be 1..1800 seconds') - if args.command == 'pair' and args.route == 'auto': - p.error('pair requires an explicit direct or relay route') - try: - return asyncio.run(run(args)) - except Exception as exc: - # Error class only: never render credential-bearing HTTP exceptions. - print(json.dumps({'ok': False, 'errorType': type(exc).__name__})) - return 1 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/experiments/relay69/app.py b/experiments/relay69/app.py deleted file mode 100644 index 4f405f1e..00000000 --- a/experiments/relay69/app.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Receiver/client harness. Only the deterministic fixture adapter is reachable.""" -import argparse -import asyncio -import contextlib -import hashlib -import json -from pathlib import Path -import time -import uuid - -import session_peer as core -from .identity import context, fingerprint -from .store import Store, Rejected -from .wire import Secure, Tcp, relay_stream - - -class FixtureAdapter(core.AgentAdapter): - name = 'fixture' - - def __init__(self, store): - self.store = store - - def list(self, ctx): - return {'sessions': [{'agent': 'fixture', 'id': 'one', 'status': 'idle'}], - 'discovery': {'status': 'ok'}} - - def submit(self, ctx, text): - if ctx.options.to != 'fixture:one': - raise Rejected('fixture_only') - digest = hashlib.sha256(text.encode()).hexdigest() - self.store.db.execute('INSERT INTO effects VALUES(?,?,?)', - (ctx.options.sender, ctx.options.request_id, digest)) - return {'ok': True, 'status': 'submitted', 'submitted': True, 'chars': len(text), - 'bodyHash': digest, 'target': {'agent': 'fixture', 'id': 'one'}, - 'consumptionConfirmed': False} - - -class Receiver: - def __init__(self, store): - self.store = store - self.adapter = FixtureAdapter(store) - self.tasks = set() - self.connections = set() - self.rejected = 0 - - async def watch_peer(self, channel): - while True: - await asyncio.sleep(.2) - row = self.store.peer(channel.peer) - if not row or row['status'] == 'revoked' or ( - row['status'] == 'pending' and row['expires'] < time.time()): - await channel.close() - return - - def dispatch(self, peer, value): - if value.get('op') == 'pair.prepare' and peer is None: - return self.store.prepare(value) - if value.get('op') == 'pair.commit' and peer: - return self.store.commit(peer) - self.store.authorize(peer, value.get('op')) - if (set(value) != {'v', 'sender', 'receiver', 'id', 'expires', 'op', 'body'} - or type(value['v']) is not int or value['v'] != 1 or value['sender'] != peer or value['receiver'] != self.store.device - or not isinstance(value['expires'], (int, float)) - or not time.time() < value['expires'] <= time.time()+65 - or not isinstance(value['id'], str)): - raise Rejected('invalid_request') - try: - uuid.UUID(value['id']) - except ValueError: - raise Rejected('invalid_request_id') - op = value['op'] - if op == 'probe': - return {'ok': True, 'device': self.store.device, 'receiverReady': True} - if op == 'status': - return self.store.status(peer, value['body']) - options = argparse.Namespace(all=False, to='fixture:one', dry_run=False, - wake=False, request_id=value['id'], sender=peer) - if op == 'list': - return {'ok': True, **core.LocalTransport().execute('list', self.adapter, options)} - if not isinstance(value['body'], str) or not value['body'] or len(value['body'].encode()) > 32768: - raise Rejected('invalid_body') - return self.store.submit(peer, value['id'], value['body'], - lambda: core.LocalTransport().execute('send', self.adapter, options, value['body'])) - - async def handle(self, raw): - if len(self.connections) >= 8: - await raw.close() - return - self.connections.add(raw) - channel = Secure(raw, context(self.store.root, True, self.store.trusted()), server=True) - watch = None - try: - await channel.handshake() - if channel.peer: - watch = asyncio.create_task(self.watch_peer(channel)) - # Bound unauthenticated pairing attempts per TLS connection. - budget = 100 if channel.peer else 1 - for _ in range(budget): - request = await channel.recv() - try: - result = self.dispatch(channel.peer, request) - except Rejected as exc: - self.rejected += 1 - result = {'ok': False, 'reason': str(exc)} - except Exception: - self.rejected += 1 - result = {'ok': False, 'reason': 'unknown', 'retryAllowed': False} - await channel.send(result) - except Exception: - self.rejected += 1 - finally: - if watch: - watch.cancel() - with contextlib.suppress(asyncio.CancelledError): - await watch - await channel.close() - self.connections.discard(raw) - - def spawn(self, raw): - task = asyncio.create_task(self.handle(raw)) - self.tasks.add(task) - task.add_done_callback(self.tasks.discard) - - async def listen(self, host, port): - return await asyncio.start_server(lambda r, w: self.spawn(Tcp(r, w)), host, port, limit=65536) - - async def relay_listener(self, url, credential): - while True: - try: - raw = await relay_stream(url, credential) - self.spawn(raw) - except asyncio.CancelledError: - raise - except Exception: - await asyncio.sleep(.5) - - async def close(self): - tasks = list(self.tasks) - for task in tasks: - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - - -def request(store, peer, op, body=None, ident=None): - return {'v': 1, 'sender': store.device, 'receiver': peer, 'id': ident or str(uuid.uuid4()), - 'expires': time.time()+60, 'op': op, 'body': body} - - -async def open_channel(store, certificate, routes, route, credential=None, bootstrap=False): - if route == 'direct': - host, port = routes['direct'].rsplit(':', 1) - raw = Tcp(*await asyncio.wait_for(asyncio.open_connection(host, int(port)), 5)) - else: - raw = await relay_stream(routes['relay'], credential) - channel = Secure(raw, context(store.root, False, [certificate], present=not bootstrap), - expected=fingerprint(certificate)) - try: - await channel.handshake() - return channel - except BaseException: - await raw.close() - raise - - -async def pair(store, invite, route, credential=None): - if (invite.get('v') != 1 or invite['expires'] < time.time() - or fingerprint(invite['certificate']) != invite['device']): - raise Rejected('invalid_invitation') - previous = store.peer(invite['device']) - if previous and previous['status'] == 'paired': - raise Rejected('invitation_consumed') - if previous and previous['status'] == 'pending': - # Resume by proving the same key, not by consuming the invitation again. - try: - return await finish_pair(store, invite, route, credential) - except Exception: - pass # prepare is itself idempotent for this invitation and public key - store.stage(invite) - channel = await open_channel(store, invite['certificate'], invite['routes'], route, credential, True) - try: - await channel.send({'op': 'pair.prepare', 'invitation': invite['id'], - 'secret': invite['secret'], 'certificate': store.cert}) - prepared = await channel.recv() - if not prepared.get('ok'): - raise Rejected(prepared.get('reason', 'pairing_failed')) - finally: - await channel.close() - return await finish_pair(store, invite, route, credential) - - -async def finish_pair(store, invite, route, credential): - # No send/list access before possession of the newly proposed key is proven. - channel = await open_channel(store, invite['certificate'], invite['routes'], route, credential) - try: - await channel.send({'op': 'pair.commit'}) - committed = await channel.recv() - if not committed.get('ok'): - raise Rejected('pairing_not_committed') - store.remember(invite) - return committed - finally: - await channel.close() - - -async def exchange(store, peer, op, body=None, ident=None, route='auto', credential=None): - record = store.peer(peer) - if not record or record['status'] != 'paired': - raise Rejected('unpaired_device') - routes = record['routes'] - - async def ready(kind): - channel = await open_channel(store, record['certificate'], routes, kind, credential) - try: - await channel.send(request(store, peer, 'probe')) - response = await channel.recv() - if not response.get('ok') or response.get('device') != peer: - raise Rejected('peer_not_ready') - return kind, channel - except BaseException: - await channel.close() - raise - - kinds = [kind for kind in ('direct', 'relay') if kind in routes] if route == 'auto' else [route] - tasks = {asyncio.create_task(ready(kind)): kind for kind in kinds} - winner = None - try: - pending = set(tasks) - while pending and winner is None: - done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) - for task in sorted(done, key=lambda t: tasks[t] != 'direct'): - if not task.cancelled() and task.exception() is None: - if winner is None: - winner = task.result() - else: - await task.result()[1].close() - if winner is None: - raise Rejected('no_authenticated_route') - finally: - for task in tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - for task in tasks: - 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 = winner - value = request(store, peer, op, body, ident) - try: - # Exactly one application submission, after path selection. No failover resend. - await channel.send(value) - result = await channel.recv() - return {**result, 'route': kind, 'receiverAccepted': result.get('ok', False), - 'relayAttached': kind == 'relay', 'requestId': value['id']} - except Exception: - return {'ok': False, 'status': 'unknown', 'requestId': value['id'], - 'route': kind, 'retryAllowed': False, 'consumptionConfirmed': False} - finally: - await channel.close() diff --git a/experiments/relay69/bench.py b/experiments/relay69/bench.py deleted file mode 100644 index f460351d..00000000 --- a/experiments/relay69/bench.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Explicit fixture-only real-device measurements; never invokes native agents.""" -import argparse -import asyncio -import json -from pathlib import Path -import statistics -import time -import uuid - -from .app import exchange, open_channel, request -from .identity import private_write -from .store import Store - - -async def measure(args): - store = Store(args.state) - credential = Path(args.admission_file).read_text().strip() - output = {'samples': {}, 'checks': {}} - try: - for route in ('direct', 'relay'): - samples = [] - for index in range(args.count): - start = time.monotonic() - result = await exchange(store, args.peer, 'send', 'fixture-benchmark-'+str(index), - route=route, credential=credential) - samples.append({'ms': round((time.monotonic()-start)*1000, 2), - 'ok': result.get('ok', False), 'route': result.get('route')}) - await asyncio.sleep(.1) - times = sorted(item['ms'] for item in samples if item['ok']) - output['samples'][route] = {'runs': samples, 'successes': len(times), - 'p50Ms': statistics.median(times) if times else None, - 'maxMs': max(times) if times else None} - print(json.dumps({'route': route, 'successes': len(times), 'count': args.count}), flush=True) - result = await exchange(store, args.peer, 'list', route='auto', credential=credential) - output['checks']['autoDirect'] = result.get('ok') and result.get('route') == 'direct' - record = store.peer(args.peer) - routes = record['routes'] - store.db.execute('UPDATE peers SET routes=? WHERE id=?', - (json.dumps({**routes, 'direct': '127.0.0.1:1'}), args.peer)) - try: - result = await exchange(store, args.peer, 'list', route='auto', credential=credential) - output['checks']['autoFallbackRelay'] = result.get('ok') and result.get('route') == 'relay' - finally: - store.db.execute('UPDATE peers SET routes=? WHERE id=?', (json.dumps(routes), args.peer)) - ident = str(uuid.uuid4()) - channel = await open_channel(store, record['certificate'], routes, 'relay', credential) - await channel.send(request(store, args.peer, 'send', 'lost-response-fixture', ident)) - await asyncio.sleep(.7) - await channel.close() # Deliberately discard the response; do not issue a new ID. - status = await exchange(store, args.peer, 'status', ident, route='relay', credential=credential) - duplicate = await exchange(store, args.peer, 'send', 'lost-response-fixture', ident, - route='direct', credential=credential) - output['checks']['lostResponseReconciled'] = status.get('submitted') is True - output['checks']['crossRouteDuplicateSuppressed'] = duplicate.get('duplicate') is True - output['lostResponseMessageId'] = ident - output['ok'] = (all(output['checks'].values()) and - all(item['successes'] == args.count for item in output['samples'].values())) - private_write(args.out, json.dumps(output, indent=2)) - print(json.dumps({'checks': output['checks'], 'ok': output['ok']}), flush=True) - return 0 if output['ok'] else 1 - finally: - store.close() - - -async def steady_measure(store, peer, route, credential, count=10): - record = store.peer(peer) - channel = await open_channel(store, record['certificate'], record['routes'], route, credential) - values = [] - try: - for _ in range(count): - start = time.monotonic() - await channel.send(request(store, peer, 'list')) - result = await channel.recv() - if not result.get('ok'): - raise RuntimeError('Steady request failed') - values.append(round((time.monotonic()-start)*1000, 2)) - return {'count': count, 'p50Ms': statistics.median(values), 'maxMs': max(values)} - finally: - await channel.close() - - -if __name__ == '__main__': - p = argparse.ArgumentParser() - p.add_argument('--state', required=True) - p.add_argument('--peer', required=True) - p.add_argument('--admission-file', required=True) - p.add_argument('--out', required=True) - p.add_argument('--count', type=int, default=10) - args = p.parse_args() - if not 1 <= args.count <= 20: - p.error('count must be 1..20') - raise SystemExit(asyncio.run(measure(args))) diff --git a/experiments/relay69/identity.py b/experiments/relay69/identity.py deleted file mode 100644 index 9891662d..00000000 --- a/experiments/relay69/identity.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Local TLS identities; only public certificates cross device boundaries.""" -import datetime as dt -import hashlib -import os -from pathlib import Path -import ssl - -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID - - -def fingerprint(pem): - cert = x509.load_pem_x509_certificate(pem.encode()) - return hashlib.sha256(cert.public_bytes(serialization.Encoding.DER)).hexdigest() - - -def private_write(path, text): - path = Path(path) - path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, 'w') as out: - out.write(text) - os.chmod(path, 0o600) - - -def initialize(root): - root = Path(root) - root.mkdir(parents=True, exist_ok=True, mode=0o700) - os.chmod(root, 0o700) - keypath, certpath = root / 'identity.key', root / 'identity.pem' - if keypath.exists() or certpath.exists(): - if not keypath.exists() or not certpath.exists(): - raise ValueError('Incomplete identity; do not replace a partially stored key') - return fingerprint(certpath.read_text()) - key = ec.generate_private_key(ec.SECP256R1()) - name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'session-peer-69-device')]) - now = dt.datetime.now(dt.timezone.utc) - cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name) - .public_key(key.public_key()).serial_number(x509.random_serial_number()) - .not_valid_before(now-dt.timedelta(minutes=1)).not_valid_after(now+dt.timedelta(days=2)) - .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) - .add_extension(x509.KeyUsage(digital_signature=True, content_commitment=False, - key_encipherment=False, data_encipherment=False, key_agreement=False, - key_cert_sign=True, crl_sign=True, encipher_only=False, decipher_only=False), critical=True) - .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) - .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False) - .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH, - ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False) - .sign(key, hashes.SHA256())) - private_write(keypath, key.private_bytes(serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode()) - private_write(certpath, cert.public_bytes(serialization.Encoding.PEM).decode()) - return fingerprint(certpath.read_text()) - - -def context(root, server, trusted, present=True): - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER if server else ssl.PROTOCOL_TLS_CLIENT) - ctx.minimum_version = ctx.maximum_version = ssl.TLSVersion.TLSv1_3 - ctx.set_alpn_protocols(['session-peer-69-v1']) - if not server: - # Trust is the explicitly invited device certificate, not public DNS. - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_OPTIONAL if server else ssl.CERT_REQUIRED - if trusted: - ctx.load_verify_locations(cadata='\n'.join(trusted)) - if server or present: - ctx.load_cert_chain(str(Path(root)/'identity.pem'), str(Path(root)/'identity.key')) - if server: - ctx.num_tickets = 0 - return ctx diff --git a/experiments/relay69/relay.py b/experiments/relay69/relay.py deleted file mode 100644 index 0d66b0c3..00000000 --- a/experiments/relay69/relay.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Authenticated online-only blind relay. Never imports endpoint identity keys.""" -import asyncio -import hashlib -from http import HTTPStatus -from http.cookies import SimpleCookie -import json -import secrets -import time - -from websockets.asyncio.server import serve - -from .wire import MAX_FRAME - - -class Relay: - def __init__(self, accounts, *, capture=None): - # Provisioned admission hashes are independent of end-to-end TLS identities. - self.accounts = accounts - self.sessions = {} - self.waiting = {} - self.connections = set() - self.capture = capture # in-memory adversarial test hook, never enabled by CLI - self.bytes = 0 - self.forwarded_frames = 0 - self.rejected = 0 - self.requests = [] - - def response(self, connection, status, text): - response = connection.respond(status, text) - response.headers['Cache-Control'] = 'no-store' - return response - - async def process_request(self, connection, request): - now = time.monotonic() - self.requests = [stamp for stamp in self.requests if stamp > now-1] - if len(self.requests) >= 20: - self.rejected += 1 - return self.response(connection, HTTPStatus.TOO_MANY_REQUESTS, 'rate_limited\n') - self.requests.append(now) - self.sessions = {key: value for key, value in self.sessions.items() if value[0] > now} - if request.path == '/v1/session': - auth = request.headers.get('Authorization', '') - digest = hashlib.sha256(auth.removeprefix('Bearer ').encode()).hexdigest() - account = next((a for a in self.accounts if auth.startswith('Bearer ') - and secrets.compare_digest(digest, a['hash'])), None) - if not account or len(self.sessions) >= 100: - self.rejected += 1 - return self.response(connection, HTTPStatus.UNAUTHORIZED, 'unauthorized\n') - token = secrets.token_urlsafe(32) - self.sessions[token] = (now+120, account) - response = self.response(connection, HTTPStatus.OK, '{"ok":true}\n') - response.headers['Set-Cookie'] = 'relay69='+token+'; Path=/v1/; Secure; HttpOnly; SameSite=Strict; Max-Age=120' - return response - if request.path != '/v1/connect': - return self.response(connection, HTTPStatus.NOT_FOUND, 'not_found\n') - cookie = SimpleCookie() - try: - cookie.load(request.headers.get('Cookie', '')) - ticket = self.sessions.get(cookie['relay69'].value) - except (KeyError, ValueError): - ticket = None - if not ticket: - self.rejected += 1 - return self.response(connection, HTTPStatus.UNAUTHORIZED, 'unauthorized\n') - if len(self.connections) >= 10: - return self.response(connection, HTTPStatus.SERVICE_UNAVAILABLE, 'capacity\n') - self.connections.add(connection) - async def release_slot(): - await connection.wait_closed() - self.connections.discard(connection) - asyncio.create_task(release_slot()) - connection.lab_account = ticket[1] - connection.lab_expiry = ticket[0] - return None - - async def handler(self, ws): - self.connections.add(ws) - account = ws.lab_account - key = account['room'] - other_role = 'client' if account['role'] == 'receiver' else 'receiver' - slot = self.waiting.get(key) - if slot is None: - slot = {'members': {}, 'ready': asyncio.Event()} - self.waiting[key] = slot - try: - if account['role'] in slot['members']: - await ws.close(1013, 'role_busy') - return - slot['members'][account['role']] = ws - if other_role in slot['members']: - # Both forwarding handlers exist before attach notification. - self.waiting.pop(key, None) - slot['ready'].set() - ready = asyncio.create_task(slot['ready'].wait()) - closed = asyncio.create_task(ws.wait_closed()) - try: - await asyncio.wait({ready, closed}, timeout=10, return_when=asyncio.FIRST_COMPLETED) - if not slot['ready'].is_set() or closed.done(): - return - finally: - ready.cancel() - closed.cancel() - await asyncio.gather(ready, closed, return_exceptions=True) - other = slot['members'][other_role] - await ws.send(json.dumps({'relayAttached': True})) - async with asyncio.timeout(max(0, ws.lab_expiry-time.monotonic())): - async for data in ws: - if not isinstance(data, bytes): - await ws.close(1008, 'binary_only') - break - self.bytes += len(data) - self.forwarded_frames += 1 - if self.bytes > 32*1024*1024: - await ws.close(1013, 'byte_budget') - break - if self.capture is not None: - self.capture.append(data) - await asyncio.wait_for(other.send(data), 2) - except Exception: - # No request headers, tokens, body or endpoint fingerprints in logs. - pass - finally: - self.connections.discard(ws) - if slot['members'].get(account['role']) is ws: - slot['members'].pop(account['role'], None) - if self.waiting.get(key) is slot: - self.waiting.pop(key, None) - other = slot['members'].get(other_role) - if other: - await other.close(1001, 'peer_closed') - await ws.close() - - async def start(self, host, port): - return await serve(self.handler, host, port, process_request=self.process_request, - compression=None, max_size=MAX_FRAME, max_queue=4, - write_limit=32768, ping_interval=10, ping_timeout=10, - open_timeout=5, close_timeout=2) diff --git a/experiments/relay69/requirements.txt b/experiments/relay69/requirements.txt deleted file mode 100644 index de23faab..00000000 --- a/experiments/relay69/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -websockets==17.1 -cryptography==50.0.1 -# Transitive crypto dependencies pinned for reproducible lab installs. -cffi==2.1.1 -pycparser==3.0 diff --git a/experiments/relay69/results.json b/experiments/relay69/results.json deleted file mode 100644 index dbbfb556..00000000 --- a/experiments/relay69/results.json +++ /dev/null @@ -1,157 +0,0 @@ -{ - "samples": { - "direct": { - "runs": [ - { - "ms": 44.7, - "ok": true, - "route": "direct" - }, - { - "ms": 49.46, - "ok": true, - "route": "direct" - }, - { - "ms": 50.64, - "ok": true, - "route": "direct" - }, - { - "ms": 46.68, - "ok": true, - "route": "direct" - }, - { - "ms": 40.14, - "ok": true, - "route": "direct" - }, - { - "ms": 53.62, - "ok": true, - "route": "direct" - }, - { - "ms": 47.78, - "ok": true, - "route": "direct" - }, - { - "ms": 47.23, - "ok": true, - "route": "direct" - }, - { - "ms": 42.89, - "ok": true, - "route": "direct" - }, - { - "ms": 40.41, - "ok": true, - "route": "direct" - } - ], - "successes": 10, - "p50Ms": 46.955, - "maxMs": 53.62 - }, - "relay": { - "runs": [ - { - "ms": 3425.17, - "ok": true, - "route": "relay" - }, - { - "ms": 3278.68, - "ok": true, - "route": "relay" - }, - { - "ms": 3344.99, - "ok": true, - "route": "relay" - }, - { - "ms": 3399.62, - "ok": true, - "route": "relay" - }, - { - "ms": 2878.93, - "ok": true, - "route": "relay" - }, - { - "ms": 2940.13, - "ok": true, - "route": "relay" - }, - { - "ms": 3473.93, - "ok": true, - "route": "relay" - }, - { - "ms": 3423.01, - "ok": true, - "route": "relay" - }, - { - "ms": 3382.01, - "ok": true, - "route": "relay" - }, - { - "ms": 3310.13, - "ok": true, - "route": "relay" - } - ], - "successes": 10, - "p50Ms": 3363.5, - "maxMs": 3473.93 - } - }, - "checks": { - "autoDirect": true, - "autoFallbackRelay": true, - "lostResponseReconciled": true, - "crossRouteDuplicateSuppressed": true - }, - "ok": true, - "steady": { - "direct": { - "count": 10, - "p50Ms": 9.495, - "maxMs": 18.6 - }, - "relay": { - "count": 10, - "p50Ms": 439.42, - "maxMs": 460.17 - } - }, - "publicWindowKST": { - "start": "2026-09-16T21:13:09+09:00", - "end": "2026-09-16T21:20:33+09:00" - }, - "effects": { - "count": 23, - "duplicates": 0, - "pending": 0 - }, - "relayResources": { - "memoryPeakBytes": 26189824, - "cpuSecondsAcrossTwoProcesses": 1.001907, - "maxTasksObserved": 1 - }, - "cleanup": { - "originalCaddyHashRestored": true, - "temporaryRemoteListenersRemoved": true, - "temporaryRemoteStateRemoved": true, - "dnsFirewallChanged": false - } -} diff --git a/experiments/relay69/sandbox_check.py b/experiments/relay69/sandbox_check.py deleted file mode 100644 index 974fdfa1..00000000 --- a/experiments/relay69/sandbox_check.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Run inside the same transient unit before opening a public listener.""" -import json -import os - -assert os.geteuid() != 0 -checked = [] -for path in ('/home/ubuntu', '/root', '/opt', '/srv', '/var/lib', '/etc/ssh'): - try: - os.listdir(path) - except (PermissionError, FileNotFoundError): - checked.append(path) - else: - raise RuntimeError('Sandbox permitted a forbidden directory') -print(json.dumps({'sandbox': 'passed', 'forbiddenDirectories': checked}), flush=True) diff --git a/experiments/relay69/store.py b/experiments/relay69/store.py deleted file mode 100644 index bcdc6936..00000000 --- a/experiments/relay69/store.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Test-only state, pairing and conservative durable submission journal.""" -import hashlib -import json -from pathlib import Path -import secrets -import sqlite3 -import time -import uuid - -from .identity import fingerprint, initialize - - -class Rejected(ValueError): - pass - - -class Store: - def __init__(self, root): - self.root = Path(root) - self.device = initialize(self.root) - self.cert = (self.root/'identity.pem').read_text() - self.db = sqlite3.connect(self.root/'lab.sqlite', isolation_level=None) - (self.root/'lab.sqlite').chmod(0o600) - self.db.execute('PRAGMA journal_mode=WAL') - self.db.execute('PRAGMA synchronous=FULL') - self.db.executescript(''' - CREATE TABLE IF NOT EXISTS invites(id TEXT PRIMARY KEY, hash TEXT, expires REAL, peer TEXT); - CREATE TABLE IF NOT EXISTS peers(id TEXT PRIMARY KEY, cert TEXT, status TEXT, expires REAL, - routes TEXT, capabilities TEXT); - CREATE TABLE IF NOT EXISTS requests(peer TEXT,id TEXT,hash TEXT,status TEXT,result TEXT, - PRIMARY KEY(peer,id)); - CREATE TABLE IF NOT EXISTS effects(peer TEXT,id TEXT,hash TEXT); - ''') - - def close(self): - self.db.close() - - def invite(self, routes): - secret = secrets.token_urlsafe(32) - ident = str(uuid.uuid4()) - expires = time.time()+600 - self.db.execute('INSERT INTO invites VALUES(?,?,?,NULL)', - (ident, hashlib.sha256(secret.encode()).hexdigest(), expires)) - return {'v': 1, 'id': ident, 'secret': secret, 'expires': expires, - 'device': self.device, 'certificate': self.cert, 'routes': routes} - - def prepare(self, request): - now = time.time() - row = self.db.execute('SELECT hash,expires,peer FROM invites WHERE id=?', - (request.get('invitation'),)).fetchone() - secret = request.get('secret') - if (not row or row[1] < now or not isinstance(secret, str) - or not secrets.compare_digest(row[0], hashlib.sha256(secret.encode()).hexdigest())): - raise Rejected('invalid_invitation') - pem = request.get('certificate', '') - if not isinstance(pem, str) or len(pem) > 8192: - raise Rejected('invalid_certificate') - peer = fingerprint(pem) - if row[2] and row[2] != peer: - raise Rejected('invitation_already_reserved') - existing = self.peer(peer) - if row[2] and existing and existing['status'] == 'paired': - raise Rejected('invitation_consumed') - self.db.execute('BEGIN IMMEDIATE') - try: - self.db.execute('UPDATE invites SET peer=? WHERE id=?', (peer, request['invitation'])) - self.db.execute('INSERT INTO peers VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET ' - 'status=excluded.status,expires=excluded.expires', - (peer, pem, 'pending', row[1], '{}', '["list","send"]')) - self.db.execute('COMMIT') - except BaseException: - self.db.execute('ROLLBACK') - raise - return {'ok': True, 'pairing': 'pending', 'device': self.device} - - def peer(self, ident): - row = self.db.execute('SELECT cert,status,expires,routes,capabilities FROM peers WHERE id=?', - (ident,)).fetchone() - if not row: - return None - return {'certificate': row[0], 'status': row[1], 'expires': row[2], - 'routes': json.loads(row[3]), 'capabilities': json.loads(row[4])} - - def trusted(self): - return [row[0] for row in self.db.execute( - 'SELECT cert FROM peers WHERE status="paired" OR (status="pending" AND expires>?)', - (time.time(),))] - - def commit(self, peer): - row = self.peer(peer) - if not row or row['status'] not in ('pending', 'paired') or ( - row['status'] == 'pending' and row['expires'] < time.time()): - raise Rejected('pairing_not_pending') - self.db.execute('UPDATE peers SET status="paired",expires=0 WHERE id=?', (peer,)) - self.db.execute('UPDATE invites SET expires=0 WHERE peer=?', (peer,)) - return {'ok': True, 'pairing': 'paired', 'device': self.device} - - def stage(self, invite): - self.db.execute('INSERT OR REPLACE INTO peers VALUES(?,?,?,?,?,?)', - (invite['device'], invite['certificate'], 'pending', invite['expires'], - json.dumps(invite['routes']), '["list","send"]')) - - def remember(self, invite): - if fingerprint(invite['certificate']) != invite['device']: - raise Rejected('pin_mismatch') - self.db.execute('INSERT OR REPLACE INTO peers VALUES(?,?,?,?,?,?)', - (invite['device'], invite['certificate'], 'paired', 0, - json.dumps(invite['routes']), '["list","send"]')) - - def revoke(self, peer): - self.db.execute('UPDATE peers SET status="revoked" WHERE id=?', (peer,)) - - def authorize(self, peer, op): - row = self.peer(peer) - if not row or row['status'] != 'paired': - raise Rejected('unpaired_device') - if op in ('list', 'send') and op not in row['capabilities']: - raise Rejected('operation_denied') - if op not in ('list', 'send', 'probe', 'status'): - raise Rejected('operation_denied') - - def status(self, peer, ident): - row = self.db.execute('SELECT status,result FROM requests WHERE peer=? AND id=?', - (peer, ident)).fetchone() - if not row: - return {'ok': True, 'status': 'not_found'} - if row[0] == 'done': - return json.loads(row[1]) - return {'ok': False, 'status': 'unknown', 'messageId': ident, - 'consumptionConfirmed': False, 'retryAllowed': False} - - def submit(self, peer, ident, text, action): - digest = hashlib.sha256(text.encode()).hexdigest() - row = self.db.execute('SELECT hash FROM requests WHERE peer=? AND id=?', (peer, ident)).fetchone() - if row: - if row[0] != digest: - raise Rejected('message_id_conflict') - return {**self.status(peer, ident), 'duplicate': True} - if self.db.execute('SELECT COUNT(*) FROM requests').fetchone()[0] >= 10000: - raise Rejected('journal_full') - # Synchronous commit precedes the adapter side effect. Pending records are - # never retried, including after restart: there is no atomic native inbox transaction. - self.db.execute('INSERT INTO requests VALUES(?,?,?,"pending",NULL)', (peer, ident, digest)) - result = action() - result = {**result, 'messageId': ident, 'consumptionConfirmed': False} - self.db.execute('UPDATE requests SET status="done",result=? WHERE peer=? AND id=?', - (json.dumps(result), peer, ident)) - return result diff --git a/experiments/relay69/wire.py b/experiments/relay69/wire.py deleted file mode 100644 index 546c563b..00000000 --- a/experiments/relay69/wire.py +++ /dev/null @@ -1,175 +0,0 @@ -"""TLS 1.3 over TCP or opaque websocket frames, with bounded JSON messages.""" -import asyncio -import hashlib -import json -import ssl -import struct -import urllib.request -import urllib.error -import urllib.parse - -from websockets.asyncio.client import connect - -MAX_FRAME = 256 * 1024 -MAX_MESSAGE = 64 * 1024 -TIMEOUT = 8 - - -class Tcp: - def __init__(self, reader, writer): - self.reader, self.writer = reader, writer - - async def recv(self): - return await self.reader.read(MAX_FRAME) - - async def send(self, data): - self.writer.write(data) - await asyncio.wait_for(self.writer.drain(), TIMEOUT) - - async def close(self): - self.writer.close() - try: - await asyncio.wait_for(self.writer.wait_closed(), 2) - except (OSError, asyncio.TimeoutError): - pass - - -class Ws: - def __init__(self, ws): - self.ws = ws - - async def recv(self): - data = await self.ws.recv() - if not isinstance(data, bytes): - raise ValueError('Nonbinary relay frame') - return data - - async def send(self, data): - for offset in range(0, len(data), MAX_FRAME): - await asyncio.wait_for(self.ws.send(data[offset:offset+MAX_FRAME]), TIMEOUT) - - async def close(self): - await self.ws.close() - - -class PinnedConnect(connect): - def process_redirect(self, exc): - # Relay placement/migration is outside this PoC. Never change origins. - return exc - - -def validate_relay_url(url): - parsed = urllib.parse.urlsplit(url) - if (parsed.scheme not in ('ws', 'wss') or not parsed.hostname - or parsed.username or parsed.password or parsed.query or parsed.fragment - or parsed.path != '/v1/connect' - or (parsed.scheme == 'ws' and parsed.hostname not in ('127.0.0.1', '::1', 'localhost'))): - raise ValueError('Relay must be WSS; plaintext WS is loopback-test-only') - - -def admission(url, credential): - validate_relay_url(url) - target = url.replace('wss://', 'https://', 1).replace('ws://', 'http://', 1) - target = target.removesuffix('/v1/connect') + '/v1/session' - req = urllib.request.Request(target, headers={'Authorization': 'Bearer '+credential, 'User-Agent': 'session-peer-relay69-lab/1.0'}) - class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, *args, **kwargs): - return None - try: - with urllib.request.build_opener(NoRedirect).open(req, timeout=TIMEOUT) as response: - return response.headers['Set-Cookie'].split(';', 1)[0] - except urllib.error.HTTPError as exc: - exc.close() - raise ValueError('Relay admission rejected') from None - - -async def relay_stream(url, credential): - cookie = await asyncio.to_thread(admission, url, credential) - ws = await PinnedConnect(url, additional_headers={'Cookie': cookie}, compression=None, - user_agent_header='session-peer-relay69-lab/1.0', - max_size=MAX_FRAME, max_queue=4, write_limit=32768, - open_timeout=TIMEOUT, close_timeout=2) - try: - ready = json.loads(await asyncio.wait_for(ws.recv(), 12)) - if ready != {'relayAttached': True}: - raise ValueError('Relay attach rejected') - return Ws(ws) - except BaseException: - await ws.close() - raise - - -class Secure: - def __init__(self, raw, ctx, server=False, expected=None): - self.raw, self.expected = raw, expected - self.incoming, self.outgoing = ssl.MemoryBIO(), ssl.MemoryBIO() - self.ssl = ctx.wrap_bio(self.incoming, self.outgoing, server_side=server) - self.tx = asyncio.Lock() - self.peer = None - self.buffer = bytearray() - - async def flush(self): - async with self.tx: - while self.outgoing.pending: - await self.raw.send(self.outgoing.read(MAX_FRAME)) - - async def feed(self): - data = await self.raw.recv() - if not data: - raise EOFError('Connection ended') - self.incoming.write(data) - - async def handshake(self): - async with asyncio.timeout(TIMEOUT): - while True: - try: - self.ssl.do_handshake() - await self.flush() - break - except ssl.SSLWantReadError: - await self.flush() - await self.feed() - if self.ssl.selected_alpn_protocol() != 'session-peer-69-v1': - raise ValueError('Protocol mismatch') - cert = self.ssl.getpeercert(binary_form=True) - self.peer = hashlib.sha256(cert).hexdigest() if cert else None - if self.expected and self.peer != self.expected: - raise ValueError('Peer pin mismatch') - return self - - async def send(self, value): - data = json.dumps(value, separators=(',', ':'), ensure_ascii=False).encode() - if len(data) > MAX_MESSAGE: - raise ValueError('Message too large') - pending = memoryview(struct.pack('!I', len(data)) + data) - while pending: - size = self.ssl.write(pending) - pending = pending[size:] - await self.flush() - - async def read_bytes(self, size): - while len(self.buffer) < size: - try: - data = self.ssl.read(min(MAX_MESSAGE+4-len(self.buffer), MAX_FRAME)) - if not data: - raise EOFError('TLS connection closed') - self.buffer.extend(data) - except ssl.SSLWantReadError: - await self.flush() - await self.feed() - out = bytes(self.buffer[:size]) - del self.buffer[:size] - return out - - async def recv(self): - async with asyncio.timeout(30): - size = struct.unpack('!I', await self.read_bytes(4))[0] - if not 0 < size <= MAX_MESSAGE: - raise ValueError('Invalid message size') - data = json.loads(await self.read_bytes(size)) - if not isinstance(data, dict): - raise ValueError('Expected request object') - return data - - async def close(self): - await self.raw.close() diff --git a/pyproject.toml b/pyproject.toml index 2e9a20c6..bfc6fdfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,8 +49,23 @@ path = "session_peer.py" pattern = '__version__ = "(?P[^"]+)"' [tool.hatch.build.targets.sdist] -exclude = ["/experiments"] -include = ["session_peer_relay", "session_peer.py", "session_peer_mcp.py", "LICENSE", "SECURITY.md", "README.md", "README.ko.md", "README.ja.md", "README.zh-CN.md", "docs/cli-reference.md", "docs/download-metrics.md", "docs/releases/v0.9.0.md", "docs/releases/v1.0.0-alpha.1.md", "docs/mcp.md", "docs/wake.md", "docs/multi-home-list.md", "docs/agent-adapters.md", "docs/paired-devices.md", "docs/relay-lifecycle.md", "docs/relay-auth.md", "docs/relay-google-bootstrap.md", "docs/relay-development-plan.md", "docs/relay-public-pilot-2026-09-17.md", "deploy/session-peer-relay.service", "docs/antigravity.md", "docs/validation/85-antigravity.md", "docs/architecture/agent-transports.md", "plugins", ".agents/plugins/marketplace.json"] +exclude = ["/experiments", "/deploy/ops", "/control/node_modules", "/control/dist"] +include = [ + "/session_peer_relay", + "/session_peer.py", + "/session_peer_mcp.py", + "/LICENSE", + "/SECURITY.md", + "/README.md", + "/README.ko.md", + "/README.ja.md", + "/README.zh-CN.md", + "/docs", + "/deploy/README.md", + "/deploy/examples", + "/plugins", + "/.agents/plugins/marketplace.json", +] [tool.hatch.build.targets.wheel] only-include = ["session_peer_relay", "session_peer.py", "session_peer_mcp.py"] diff --git a/tests/test_docs_i18n.py b/tests/test_docs_i18n.py index b293568d..299bc4f0 100644 --- a/tests/test_docs_i18n.py +++ b/tests/test_docs_i18n.py @@ -29,11 +29,7 @@ def canonical_documents(): if not any(part in LANGUAGES for part in path.relative_to(ROOT).parts) ) ) - documents.extend( - (Path("experiments/relay69/README.md"), - Path("experiments/relay69/REPORT.md"), - Path("plugins/session-peer/README.md")) - ) + documents.append(Path("plugins/session-peer/README.md")) return documents @@ -68,7 +64,7 @@ class DocumentationTranslationsTest(unittest.TestCase): def test_every_canonical_document_has_each_translation(self): sources = canonical_documents() - self.assertEqual(32, len(sources), "Update the translation manifest for new canonical docs") + self.assertEqual(31, len(sources), "Update the translation manifest for new canonical docs") canonical_docs = { path.relative_to(ROOT / "docs") for path in (ROOT / "docs").rglob("*.md") diff --git a/tests/test_relay69.py b/tests/test_relay_protocol.py similarity index 80% rename from tests/test_relay69.py rename to tests/test_relay_protocol.py index b475701c..fa4fc013 100644 --- a/tests/test_relay69.py +++ b/tests/test_relay_protocol.py @@ -1,4 +1,4 @@ -"""Opt-in relay lab integration tests; no native agent or public network used.""" +"""Product relay protocol regressions retained from the completed relay69 lab.""" import asyncio import hashlib import json @@ -11,6 +11,7 @@ import time import unittest import uuid +from unittest import mock if sys.version_info < (3, 11): raise unittest.SkipTest('relay experiment requires Python 3.11+') @@ -20,11 +21,11 @@ except ImportError: raise unittest.SkipTest('optional relay experiment dependencies not installed') -from experiments.relay69.app import Receiver, exchange, open_channel, pair, request -from experiments.relay69.identity import context, fingerprint -from experiments.relay69.relay import Relay -from experiments.relay69.store import Store, Rejected -from experiments.relay69.wire import Secure, Tcp, MAX_MESSAGE, relay_stream +from session_peer_relay.app import Receiver, exchange, open_channel, pair, request +from session_peer_relay.identity import context, fingerprint +from session_peer_relay.relay import Relay +from session_peer_relay.store import Store, Rejected +from session_peer_relay.wire import Secure, Tcp, MAX_MESSAGE, relay_stream class RelayLab(unittest.IsolatedAsyncioTestCase): @@ -33,7 +34,30 @@ async def asyncSetUp(self): self.root = Path(self.directory.name) self.host = Store(self.root/'host') self.client = Store(self.root/'client') - self.receiver = Receiver(self.host) + self.effects = [] + async def inbox(reader, writer): + raw = await reader.readline() + if raw: + self.effects.append(json.loads(raw)) + writer.close() + await writer.wait_closed() + self.socket = self.root/'inbox.sock' + self.inbox = await asyncio.start_unix_server(inbox, path=str(self.socket)) + config = self.root/'claude' + (config/'sessions').mkdir(parents=True) + (config/'sessions'/f'{os.getpid()}.json').write_text(json.dumps({ + 'pid': os.getpid(), 'name': 'worker', 'cwd': '/fixture', + 'messagingSocketPath': str(self.socket), + })) + self.env = mock.patch.dict(os.environ, {'CLAUDE_CONFIG_DIR': str(config)}) + self.env.start() + self.policy = { + 'targets': {'review': {'agent': 'claude', 'target': 'worker'}}, + 'peers': {self.client.device: { + 'capabilities': ['list', 'send'], 'targets': ['review'], + }}, + } + self.receiver = Receiver(self.host, self.policy) self.listener = await self.receiver.listen('127.0.0.1', 0) self.direct = '127.0.0.1:'+str(self.listener.sockets[0].getsockname()[1]) self.host_token, self.client_token = secrets.token_urlsafe(32), secrets.token_urlsafe(32) @@ -54,8 +78,11 @@ async def asyncTearDown(self): await self.receiver.close() self.relay_server.close() await self.relay_server.wait_closed() + self.inbox.close() + await self.inbox.wait_closed() self.client.close() self.host.close() + self.env.stop() self.directory.cleanup() async def pair(self, route='direct'): @@ -65,18 +92,21 @@ async def test_pair_direct_send_and_duplicate(self): await self.pair() ident = str(uuid.uuid4()) for duplicate in (False, True): - result = await exchange(self.client, self.host.device, 'send', 'hello', ident, 'direct') + result = await exchange(self.client, self.host.device, 'send', + {'target': 'review', 'message': 'hello'}, ident, 'direct') self.assertTrue(result['ok'], result) self.assertEqual(result.get('duplicate', False), duplicate) self.assertFalse(result['consumptionConfirmed']) - self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM effects').fetchone()[0], 1) - result = await exchange(self.client, self.host.device, 'send', 'different', ident, 'direct') + self.assertEqual(len(self.effects), 1) + result = await exchange(self.client, self.host.device, 'send', + {'target': 'review', 'message': 'different'}, ident, 'direct') self.assertEqual(result['reason'], 'message_id_conflict') async def test_pair_over_relay_payload_is_opaque(self): await self.pair('relay') secret = 'sentinel-plaintext-'+secrets.token_hex(20) - result = await exchange(self.client, self.host.device, 'send', secret, + result = await exchange(self.client, self.host.device, 'send', + {'target': 'review', 'message': secret}, route='relay', credential=self.client_token) self.assertTrue(result['ok'], result) self.assertEqual(result['route'], 'relay') @@ -122,9 +152,12 @@ async def test_revocation_closes_existing_connection_and_rotation_repairs(self): await channel.close() rotated = Store(self.root/'rotated') try: + self.receiver.policy.peers[rotated.device] = { + 'capabilities': ['list', 'send'], 'targets': ['review'], + } invitation = self.host.invite({'direct': self.direct}) await pair(rotated, invitation, 'direct') - result = await exchange(rotated, self.host.device, 'list', route='direct') + result = await exchange(rotated, self.host.device, 'probe', route='direct') self.assertTrue(result['ok']) finally: rotated.close() @@ -158,29 +191,31 @@ async def test_wrong_sender_receiver_expiry_and_unauthorized_operation(self): await self.pair() for field, value in [('sender', 'other'), ('receiver', 'other'), ('expires', time.time()-1), ('op', 'shell')]: - msg = request(self.client, self.host.device, 'send', 'no effect') + msg = request(self.client, self.host.device, 'send', + {'target': 'review', 'message': 'no effect'}) msg[field] = value with self.subTest(field=field), self.assertRaises(Rejected): - self.receiver.dispatch(self.client.device, msg) + await self.receiver.dispatch(self.client.device, msg) self.host.db.execute('UPDATE peers SET capabilities=? WHERE id=?', ('["list"]', self.client.device)) with self.assertRaises(Rejected): - self.receiver.dispatch(self.client.device, request(self.client, self.host.device, 'send', 'x')) - self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM effects').fetchone()[0], 0) + await self.receiver.dispatch(self.client.device, request( + self.client, self.host.device, 'send', {'target': 'review', 'message': 'x'})) + self.assertEqual(self.effects, []) async def test_crash_after_effect_is_unknown_after_restart_and_not_reexecuted(self): await self.pair() ident = str(uuid.uuid4()) - def crash(): - self.host.db.execute('INSERT INTO effects VALUES(?,?,?)', (self.client.device, ident, 'hash')) - raise RuntimeError('simulated crash after effect') - with self.assertRaises(RuntimeError): - self.host.submit(self.client.device, ident, 'message', crash) + binding = self.policy['targets']['review'] + canonical = json.dumps({'binding': binding, 'message': 'message'}, + sort_keys=True, separators=(',', ':')) + self.assertIsNone(self.host.begin(self.client.device, ident, canonical)) + self.effects.append({'simulated': 'effect'}) reopened = Store(self.host.root) try: - result = reopened.submit(self.client.device, ident, 'message', lambda: self.fail('reexecuted')) + result = reopened.begin(self.client.device, ident, canonical) self.assertEqual(result['status'], 'unknown') self.assertFalse(result['retryAllowed']) - self.assertEqual(reopened.db.execute('SELECT COUNT(*) FROM effects').fetchone()[0], 1) + self.assertEqual(len(self.effects), 1) finally: reopened.close() @@ -196,12 +231,13 @@ async def tamper(data): await original(changed) channel.raw.send = tamper try: - await channel.send(request(self.client, self.host.device, 'send', 'not delivered')) + await channel.send(request(self.client, self.host.device, 'send', + {'target': 'review', 'message': 'not delivered'})) with self.assertRaises(Exception): await channel.recv() finally: await channel.close() - self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM effects').fetchone()[0], 0) + self.assertEqual(self.effects, []) async def test_unauthenticated_relay_rejected(self): with self.assertRaises(Exception): @@ -244,7 +280,7 @@ def respond(self, status, text): self.relay.sessions['ticket'] = (time.monotonic()+60, {'role': 'client', 'room': 'test'}) self.relay.connections.update(object() for _ in range(10)) result = await self.relay.process_request(connection, SimpleNamespace( - path='/v1/connect', headers=Headers({'Cookie': 'relay69=ticket'}))) + path='/v1/connect', headers=Headers({'Cookie': 'session_peer=ticket'}))) self.assertEqual(result.status_code, 503) self.relay.connections.clear() @@ -284,18 +320,19 @@ async def record(data): await original(data) channel.raw.send = record try: - await channel.send(request(self.client, self.host.device, 'send', 'one effect')) + await channel.send(request(self.client, self.host.device, 'send', + {'target': 'review', 'message': 'one effect'})) self.assertTrue((await channel.recv())['ok']) await original(captured[0]) with self.assertRaises(Exception): await channel.recv() finally: await channel.close() - self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM effects').fetchone()[0], 1) + self.assertEqual(len(self.effects), 1) async def test_malicious_inbound_length_is_rejected_before_body(self): import struct - from experiments.relay69.wire import MAX_MESSAGE + from session_peer_relay.wire import MAX_MESSAGE await self.pair() channel = await open_channel(self.client, self.host.cert, self.invite['routes'], 'direct') try: @@ -305,10 +342,10 @@ async def test_malicious_inbound_length_is_rejected_before_body(self): await channel.recv() finally: await channel.close() - self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM effects').fetchone()[0], 0) + self.assertEqual(self.effects, []) async def test_relay_origin_credentials_and_expired_cookies_fail_closed(self): - from experiments.relay69.wire import validate_relay_url, PinnedConnect + from session_peer_relay.wire import validate_relay_url, PinnedConnect from types import SimpleNamespace from websockets.datastructures import Headers for url in ('ws://public.example/v1/connect', 'wss://user:pass@example/v1/connect', @@ -323,7 +360,7 @@ def respond(self, status, text): return SimpleNamespace(status_code=status, headers=Headers()) self.relay.sessions['expired'] = (time.monotonic()-1, {'role': 'client', 'room': 'test'}) result = await self.relay.process_request(Connection(), SimpleNamespace( - path='/v1/connect', headers=Headers({'Cookie': 'relay69=expired'}))) + path='/v1/connect', headers=Headers({'Cookie': 'session_peer=expired'}))) self.assertEqual(result.status_code, 401) async def test_device_keys_are_distinct_and_private(self):