elanous 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -7
- package/README.md +9 -4
- package/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/chunks/1105-c473ed46272fba79.js +1 -0
- package/apps/pwa/out/_next/static/chunks/1201-09613bce624467c0.js +1 -0
- package/apps/pwa/out/_next/static/chunks/1377-1efeffbbac0963ff.js +1 -0
- package/apps/pwa/out/_next/static/chunks/1470.98a6ab61ccb4131c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/216-5acd01cbde4acda7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2330-c2f07246c8788af1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{5391-8793f88b03b30161.js → 2375-cb6debea56cce4fb.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{1751.50b96ea9c37b49b6.js → 2415.ed6428ff334c7ce2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/2929-617f1e66a55be184.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2977-e38442e86289f1d3.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2982-2a2df543be3d2e5a.js +25 -0
- package/apps/pwa/out/_next/static/chunks/{4daa2798-1363d725c79f3593.js → 2ea96958-e8c7e112de50b067.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/3403.4bc97b4066945a94.js +5 -0
- package/apps/pwa/out/_next/static/chunks/3441-a1dd4acfeda14488.js +1 -0
- package/apps/pwa/out/_next/static/chunks/371-4ad6af55dad02ced.js +1 -0
- package/apps/pwa/out/_next/static/chunks/4114-3e63117452378807.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{8617.465ca22a319c95b3.js → 4323.c5dfb6f503304775.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/4836-2eba6079f31b88d4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{8918-482b30ce2a8ebf4c.js → 5080-1613752891fcadb4.js} +3 -3
- package/apps/pwa/out/_next/static/chunks/5609-012bb3557211f07e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{5348-bbf838e119661eee.js → 5791-bb82743fa6299562.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{9704-3221c102623c8d73.js → 6062-73e68343a9814f6a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{4440-18bb160d95819f4e.js → 6090-85a3f05f954e5697.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/6284-69db415193d5f8bd.js +1 -0
- package/apps/pwa/out/_next/static/chunks/6320-baac91d68843a9a6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/6416-505ba707cacafbba.js +1 -0
- package/apps/pwa/out/_next/static/chunks/6725-68ab7cc0b1faf00d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/6998-a654fe5c58271600.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7647.2fcd30d894d5ee30.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7673-2eda1507d38dadb8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7990-e12efba616f46e03.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{f6f3850d-7f324c6566002113.js → 8046cadf-211ca3c0e00f2b72.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/8067-f74bf14d67ad98f1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8142-744998d65d7bb17e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8519-f6b5d8a5833a7603.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8619-4365e03a2862830f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8894-e92491a164192cc1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/920-f7dceb78f1c585ef.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9454-a3e0953307216c58.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9651-3cd291a6fa1370d2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9740-67051141115ce775.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9804-64c02c3c127d5014.js +1 -0
- package/apps/pwa/out/_next/static/chunks/ae787e4c-f2998de7f1ba72a4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/_not-found/page-859cea1570b6227a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-d5ebde04844689e6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-f89e9a303515a033.js → page-10ed24f413ed66e9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/{page-4f403cd060442c95.js → page-211810b6c93ab66a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-0ac5d053f0444d21.js → page-624f7a2f89b60c8f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/page-5a5fb385a4df0ffe.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/chat/page-80497e93a878ddc5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/consult/page-7457f52683562161.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/control/page-cee86394de407aa6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-4ff8edab44196b8c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/editor/page-92e4f44428d5e290.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/exec/page-b7dcf5d48a854b76.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/field/page-51480757e7d68b92.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/healthz/route-859cea1570b6227a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/intake/page-2a05dfe80c8bdf16.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/layout-d9979633aa82f8e6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live/page-30533117beb53625.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/page-f7857826c2f97ad7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-07267f1391b96aba.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-bccce0babceae30f.js → page-55d50e9b0006a91d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-9699b1786be6171d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-43dd772aee8af75f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/page-a7e37af7d0399cb8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-973ab7af59c718b7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a9f8d3865b91e436.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-15e5a15db84c56aa.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-a2e572e4511f2a16.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/page-dd107bf1c7d52a90.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d42b99cdfb9f27c6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/layout-859cea1570b6227a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/page-2764ff40dad5a995.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/share/page-c5f1152db116ff96.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-f933bca81ea38a6f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-c1c95739c0bff3b8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/term/page-a7aaffcf771662d8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/trace/page-71d18eec33b9c667.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/vault/page-56adbe1a1f1379bd.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-2c4e59079ee357ac.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-a81f113f3ef89d72.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/page-5d422f38c052967e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-fc27b00e20733146.js +1 -0
- package/apps/pwa/out/_next/static/chunks/framework-ea806bb57f2b8923.js +1 -0
- package/apps/pwa/out/_next/static/chunks/main-8d2302399c2c8f87.js +1 -0
- package/apps/pwa/out/_next/static/chunks/main-app-6870f30ea99f8346.js +1 -0
- package/apps/pwa/out/_next/static/chunks/pages/_app-b10928c60f0b8488.js +1 -0
- package/apps/pwa/out/_next/static/chunks/pages/_error-689039227d443fab.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{webpack-2bf59c720fe9542c.js → webpack-e5bea36f183b25e6.js} +1 -1
- package/apps/pwa/out/_next/static/css/f537bdb19c69306a.css +1 -0
- package/apps/pwa/out/_next/static/keajW7DDyghEs1y8oJe6T/_buildManifest.js +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +22 -22
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +22 -22
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +22 -22
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +21 -21
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +21 -21
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +21 -21
- package/apps/pwa/out/consult/index.html +1 -0
- package/apps/pwa/out/consult/index.txt +32 -0
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +22 -22
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +22 -22
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +22 -22
- package/apps/pwa/out/exec/index.html +1 -0
- package/apps/pwa/out/exec/index.txt +32 -0
- package/apps/pwa/out/field/index.html +1 -0
- package/apps/pwa/out/field/index.txt +32 -0
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/icons/elanous-16.png +0 -0
- package/apps/pwa/out/icons/elanous-180.png +0 -0
- package/apps/pwa/out/icons/elanous-192.png +0 -0
- package/apps/pwa/out/icons/elanous-32.png +0 -0
- package/apps/pwa/out/icons/elanous-512.png +0 -0
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +21 -21
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +22 -22
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +22 -22
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +21 -21
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +21 -21
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +22 -22
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +22 -22
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +22 -22
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +22 -22
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +21 -21
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +22 -22
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +22 -22
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +22 -22
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +22 -22
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +22 -22
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +21 -21
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +22 -22
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +22 -22
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +22 -22
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +22 -22
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +22 -22
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +22 -22
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +22 -22
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +22 -22
- package/bin/elanous.cjs +107 -0
- package/graphs/field/field-feed.yaml +24 -0
- package/graphs/field/recipes.yaml +12 -0
- package/graphs/landing/landing-heal.yaml +3 -1
- package/graphs/landing/recipes.yaml +3 -0
- package/graphs/release/recipes.yaml +13 -0
- package/graphs/release/release-loop.yaml +13 -4
- package/package.json +5 -5
- package/plugins/card-followup/README.md +42 -0
- package/plugins/card-followup/examples/card-sample.json +7 -0
- package/plugins/card-followup/graphs/card-followup.yaml +18 -0
- package/plugins/card-followup/graphs/recipes.yaml +16 -0
- package/plugins/card-followup/graphs/run-step.ts +276 -0
- package/plugins/card-followup/graphs/sales-strategy.ts +173 -0
- package/plugins/card-followup/plugin.json +25 -0
- package/plugins/doc-draft/README.md +13 -0
- package/plugins/doc-draft/examples/brief-sample.json +8 -0
- package/plugins/doc-draft/graphs/doc-draft.yaml +14 -0
- package/plugins/doc-draft/graphs/recipes.yaml +9 -0
- package/plugins/doc-draft/graphs/run-step.ts +113 -0
- package/plugins/doc-draft/plugin.json +24 -0
- package/plugins/geo-check/README.md +15 -0
- package/plugins/geo-check/examples/brand-sample.json +8 -0
- package/plugins/geo-check/graphs/geo-check.yaml +20 -0
- package/plugins/geo-check/graphs/recipes.yaml +18 -0
- package/plugins/geo-check/graphs/run-step.ts +198 -0
- package/plugins/geo-check/plugin.json +24 -0
- package/plugins/job-coach/.codex-plugin/plugin.json +1 -1
- package/plugins/job-coach/README.md +31 -1
- package/plugins/job-coach/graphs/run-step.ts +2 -2
- package/plugins/job-coach/plugin.json +1 -1
- package/plugins/job-coach/plugin.ts +1 -1
- package/scripts/affinity/endcard.sh +3 -2
- package/scripts/affinity/reframe.sh +3 -1
- package/scripts/ci-public-export-leak-gate.ts +48 -0
- package/scripts/codex-quota-alert.ts +122 -258
- package/scripts/coord-tracks.json +4 -4
- package/scripts/daemon-port-baseline.txt +1 -2
- package/scripts/field-feed/deliver-node.ts +52 -0
- package/scripts/field-feed/feed-node.ts +67 -0
- package/scripts/field-feed/lib.ts +162 -0
- package/scripts/field-feed/reel-node.ts +24 -0
- package/scripts/field-feed/start.ts +62 -0
- package/scripts/fixtures/vflow-sample.ndjson +6 -0
- package/scripts/install.sh +92 -13
- package/scripts/landing-heal/clean-checkout.ts +114 -0
- package/scripts/landing-heal/run-mission.ts +38 -3
- package/scripts/lib/android-test-device.ts +32 -0
- package/scripts/lib/pwa-scenarios/scenarios.ts +2 -1
- package/scripts/public-export.ts +8 -4
- package/scripts/release-loop/baseline-from-logs.ts +92 -0
- package/scripts/release-loop/checklist-gate-node.ts +35 -0
- package/scripts/release-loop/env-known-failures.ts +31 -0
- package/scripts/release-loop/gate-diff.ts +29 -0
- package/scripts/release-loop/gate-node.ts +124 -29
- package/scripts/release-loop/known-issues-node.ts +159 -0
- package/scripts/release-loop/npm-publish-node.ts +118 -0
- package/scripts/release-loop/ops-upgrade-node.ts +87 -0
- package/scripts/release-loop/release-readiness.ts +58 -0
- package/scripts/release-loop/shard-plan.ts +47 -0
- package/scripts/release-loop/unattended-release.ts +93 -0
- package/scripts/ux/measure-first-text.ts +102 -0
- package/scripts/ux/record-page.ts +4 -1
- package/scripts/vflow-collect.ts +20 -7
- package/scripts/vflow-search.ts +73 -0
- package/scripts/video-free-line-test-walker.ts +36 -0
- package/scripts/video-pipeline.ts +2 -2
- package/skills/explainer-video/SKILL.md +24 -3
- package/skills/explainer-video/engine/brand/elanous-mark-on-dark.svg +1 -1
- package/skills/explainer-video/engine/build.mjs +2 -2
- package/skills/explainer-video/engine/reel.mjs +235 -0
- package/skills/explainer-video/engine/reel.sh +9 -0
- package/skills/explainer-video/engine/vo.ts +58 -16
- package/skills/explainer-video/examples/elanous-0.2.5/script.json +1 -1
- package/skills/grill-me/SKILL.md +1 -1
- package/skills/omni-digest/docs/diagrams/noncode-terminal-promotion-path-2026-08-13.mmd +1 -1
- package/src/a2a/server.ts +2 -0
- package/src/acp/elanous-aware-peers.ts +30 -0
- package/src/acp/server.ts +91 -15
- package/src/agent/monad-agent-turn.ts +25 -6
- package/src/agent/shared-app-tools.ts +5 -3
- package/src/agent-substrate/review-intent.ts +6 -2
- package/src/agent-substrate/shard-boundary-intent.ts +70 -0
- package/src/auth/github-app-token.ts +4 -1
- package/src/autopilot/mission-origin.ts +20 -4
- package/src/autopilot/mission-phase-track.ts +11 -1
- package/src/autopilot/track-agent-shadow.ts +151 -0
- package/src/autopilot/track-agent.ts +26 -6
- package/src/autopilot/track-courier.ts +2 -2
- package/src/boot/acp-server-help.ts +16 -0
- package/src/boot/daemon-prompt-turn.ts +31 -10
- package/src/boot/daemon-tools/index.ts +7 -1
- package/src/budget/codex-credit-pace.ts +84 -0
- package/src/budget/codex-credit-plan.ts +154 -0
- package/src/budget/codex-reset-credit-state.ts +2 -0
- package/src/chat/chat-cli.ts +4 -1
- package/src/chat/short-question.ts +40 -0
- package/src/cli/agent-cli.ts +24 -15
- package/src/cli/ask-bare.ts +42 -0
- package/src/cli/cli-user-error.ts +25 -0
- package/src/cli/codex-credit-plan-view.ts +25 -0
- package/src/cli/dependence-gauge.ts +237 -0
- package/src/cli/doctor-cli.ts +5 -0
- package/src/cli/doctor-modes.ts +48 -0
- package/src/cli/intake-cli.ts +12 -2
- package/src/cli/intake-source-cli.ts +58 -0
- package/src/cli/machine-cli.ts +23 -0
- package/src/cli/msg-cli.ts +137 -0
- package/src/cli/nexus-ios-bind.ts +25 -8
- package/src/cli/phone-link.ts +117 -0
- package/src/cli/plugin-cli.ts +52 -0
- package/src/cli/pr-cli.ts +36 -0
- package/src/cli/private-ledger-commands.ts +31 -0
- package/src/cli/provider-cli.ts +5 -0
- package/src/cli/release-cli.ts +56 -12
- package/src/cli/release-run-if-ready.ts +48 -0
- package/src/cli/setup-cli.ts +27 -6
- package/src/cli/setup-recommend.ts +8 -0
- package/src/cli/usage-cli.ts +11 -1
- package/src/codex/setup.ts +27 -8
- package/src/dashboard/index.ts +1 -1
- package/src/discord.ts +56 -6
- package/src/domains/core-tools.ts +37 -14
- package/src/domains/outbound-alert.ts +55 -16
- package/src/exec-requests/planner.ts +140 -0
- package/src/exec-requests/runner.ts +338 -0
- package/src/exec-requests/store.ts +76 -0
- package/src/field/field-media.ts +250 -0
- package/src/field/field-reel.ts +196 -0
- package/src/grok/credential.ts +7 -5
- package/src/harness/aside-run-status.ts +33 -0
- package/src/harness/browser-verify.ts +48 -17
- package/src/harness/harness-cli-command.ts +25 -3
- package/src/harness/image-load-state.ts +21 -0
- package/src/harness/process-garbage.ts +45 -0
- package/src/index.ts +104 -38
- package/src/instance/leader.ts +13 -1
- package/src/intake-plane/collect-github.ts +5 -3
- package/src/intake-plane/collect-telegram-saved.ts +15 -3
- package/src/intake-plane/discord-seat-work.ts +56 -0
- package/src/intake-plane/intake-sources.ts +202 -0
- package/src/intake-plane/items.ts +28 -12
- package/src/intake-plane/seat-doc-route.ts +207 -0
- package/src/intake-plane/submit-intake-work.ts +9 -1
- package/src/intake-plane/telegram-seat-work.ts +72 -0
- package/src/llm/model-defaults.ts +24 -1
- package/src/llm.ts +5 -4
- package/src/machines/machine-ledger.ts +127 -0
- package/src/market/pack-internal-refs.ts +28 -0
- package/src/market/publish.ts +31 -4
- package/src/maturity/feature-maturity.ts +65 -0
- package/src/model-tier/turn-tier.ts +24 -0
- package/src/msg/msg-store.ts +149 -0
- package/src/native-tool-catalog.ts +2 -1
- package/src/nexus/api/chat-fast-path-config.ts +28 -0
- package/src/nexus/api/consult-requests.ts +117 -0
- package/src/nexus/api/discord-trigger-bot.ts +3 -0
- package/src/nexus/api/exec-requests.ts +51 -0
- package/src/nexus/api/field-uploads.ts +180 -0
- package/src/nexus/api/graph-approvals-feed.ts +140 -0
- package/src/nexus/api/graph-approvals.ts +120 -4
- package/src/nexus/api/harness-api.ts +219 -5
- package/src/nexus/api/http-server.ts +68 -5
- package/src/nexus/api/seat-requests.ts +254 -0
- package/src/nexus/api/trace-run-scan.ts +37 -0
- package/src/nexus/api/trace.ts +19 -3
- package/src/nexus/index.ts +3 -0
- package/src/nexus/setup-status.ts +4 -5
- package/src/notify/llm-budget-alert.ts +125 -0
- package/src/oauth/codex-account-rotation.ts +26 -4
- package/src/oauth/codex-account-store.ts +20 -2
- package/src/oauth/codex-browser-login.ts +173 -0
- package/src/oauth/codex.ts +4 -1
- package/src/oauth/fallback-chain.ts +5 -5
- package/src/oauth/store.ts +42 -5
- package/src/onboarding/continue-here.ts +29 -0
- package/src/onboarding/entry-hints.ts +8 -0
- package/src/onboarding/full-screen-io.ts +35 -3
- package/src/onboarding/help/skills.md +1 -1
- package/src/onboarding/non-interactive.ts +1 -0
- package/src/onboarding/subscription-hint.ts +27 -0
- package/src/onboarding/web-first.ts +83 -0
- package/src/onboarding.ts +230 -23
- package/src/plugins/maker/node-install.ts +92 -0
- package/src/plugins/maker/node-maker.ts +139 -0
- package/src/plugins/maker/plugin-maker.ts +220 -0
- package/src/policy/llm-policy.ts +268 -0
- package/src/prompt-library/session-guidance.ts +7 -1
- package/src/prompt-library/universal-preamble.ts +2 -2
- package/src/relay/channel.ts +184 -0
- package/src/relay/mux.ts +182 -0
- package/src/relay/relay-cli.ts +20 -0
- package/src/relay/relay-server.ts +220 -0
- package/src/relay/test-vectors.json +29 -0
- package/src/release-loop/checklist.ts +36 -9
- package/src/seat-address/seat-address.ts +34 -0
- package/src/self-dev/decompose.ts +1 -0
- package/src/self-dev/dev-pipeline.ts +6 -3
- package/src/self-dev/run-record-mirror.ts +44 -0
- package/src/self-dev/run-store.ts +12 -3
- package/src/self-implement/harness-policy-gates.ts +4 -1
- package/src/self-implement/report-origin.ts +12 -1
- package/src/self-implement/run-ledger-compact.ts +1 -1
- package/src/session/chat.ts +17 -5
- package/src/session/index.ts +23 -10
- package/src/skills/shared-agent-skill-roots.ts +25 -0
- package/src/steward/failure-streak.ts +78 -0
- package/src/steward/steward-cards.ts +24 -0
- package/src/steward/triage-budget.ts +127 -0
- package/src/steward/triage-plan.ts +75 -0
- package/src/steward/triage.ts +244 -42
- package/src/task-cards/card-store.ts +1 -1
- package/src/task-orchestrator/surfaces/pod-bun-cache.ts +18 -0
- package/src/task-orchestrator/surfaces/pod-command-job.ts +12 -3
- package/src/task-orchestrator/surfaces/pod-ledger-collect.ts +11 -5
- package/src/task-orchestrator/surfaces/pod-ledger-events.ts +68 -0
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +78 -4
- package/src/telegram.ts +145 -2
- package/src/tool-runtime/elanous-skills-list-runtime.ts +3 -3
- package/src/tool-runtime/self-cognition-runtimes.ts +160 -2
- package/src/user-config.ts +84 -4
- package/src/version/packed-revision.json +1 -1
- package/src/video-pipeline/recipes/character.ts +42 -5
- package/src/video-pipeline/recipes/explainer.ts +213 -0
- package/src/video-pipeline/recipes/ffmpeg.ts +1 -1
- package/src/video-pipeline/recipes/free-line.ts +16 -3
- package/src/video-pipeline/recipes/remote.ts +7 -1
- package/src/video-pipeline/vflow-reference.ts +109 -0
- package/src/video-pipeline/walk-line.ts +3 -2
- package/apps/pwa/out/_next/static/chunks/06ef2eba-d1b99ac6d2ea663c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/1413-7179a49332db5a80.js +0 -1
- package/apps/pwa/out/_next/static/chunks/150-25c277137a9638ab.js +0 -1
- package/apps/pwa/out/_next/static/chunks/1640-bc99ec23bbe29d12.js +0 -1
- package/apps/pwa/out/_next/static/chunks/2300-de059d0ab2a16630.js +0 -1
- package/apps/pwa/out/_next/static/chunks/2684-05b0a81e75298c16.js +0 -1
- package/apps/pwa/out/_next/static/chunks/3026-2ed7e317d9b02102.js +0 -25
- package/apps/pwa/out/_next/static/chunks/3611-77496ada38cd6358.js +0 -1
- package/apps/pwa/out/_next/static/chunks/3717-68957c4dc9ceec90.js +0 -1
- package/apps/pwa/out/_next/static/chunks/3929-46ba9af6f9d895cc.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4015-62c5a3744698a8da.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4217-3c4317a7905f9eeb.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4240-3230f230fae2d7d5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4271-e53e075ebd48593b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4377.3b125fef79576dfd.js +0 -5
- package/apps/pwa/out/_next/static/chunks/4853-35fdbe6bdbcdc0fc.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5007-6a3b30e23ff68a6e.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5244-12b424c42070f5ff.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5611-df00af3e4e4a61bb.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5890-dca2321773be829a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/6123-37c9dc57ddaa7ab0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/6588-b7975304a302ab91.js +0 -1
- package/apps/pwa/out/_next/static/chunks/6604-c03961962b6273b6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7109-cf8c87e968e83a65.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7178-30f77877525a2efb.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7262-871563fc9663730b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7294-f0eeebb92aa93459.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7429-881af44deed6958f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7527.943753b6ed829f09.js +0 -1
- package/apps/pwa/out/_next/static/chunks/764-a01e858525a190c0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7868-e35d5f7985ecd3f1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8320-51484af7b01fdfa5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9131-52f5fad51cceca04.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9238.2cd5f47f4bf7a2c3.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/_not-found/page-70c404c7bb6d7f0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-3ffd80166b1050aa.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/page-4bcc63e272ae8205.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/page-880734bd07d62882.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-72bd384898d88955.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-1018bbc9586ecae5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-de86f8c38076dd7b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/healthz/route-70c404c7bb6d7f0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/page-cd6447196d8bafe6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-e733630c47fa102b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-d1c1a8357b161875.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-60c514128e81d787.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-8b637b7e325e13c1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-63301d11dbe73836.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-a8cf0dc472060db2.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/page-02fba54881ccfa49.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-d5e8e90f0038f8de.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-b1ef6824f494466a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-d589bb0f233b1009.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-c9bbaebef4cda059.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-3639ce7916b92fed.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-7450ae7478955653.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/layout-70c404c7bb6d7f0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-b41ca4abfd618452.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-b333dd79ebee3fa9.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-8be1227492793655.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-6e03e9371b552e0d.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-911411e9fca18d03.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-792170e649b37088.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-c40463466937bc91.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-509c798b80384536.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-1cac2aa21d77c417.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workspace/page-9626799d9b4a2cd1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-84cd419494e16b0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/framework-361487d2da603917.js +0 -1
- package/apps/pwa/out/_next/static/chunks/main-app-607f5beb94cb2e07.js +0 -1
- package/apps/pwa/out/_next/static/chunks/main-d0222d219e29dddf.js +0 -1
- package/apps/pwa/out/_next/static/chunks/pages/_app-a26980a31126decc.js +0 -1
- package/apps/pwa/out/_next/static/chunks/pages/_error-edb2b37decf23e4d.js +0 -1
- package/apps/pwa/out/_next/static/css/eaa74e8bb2f62269.css +0 -1
- package/apps/pwa/out/_next/static/ktMqfrBbf8BMGac0jqLrb/_buildManifest.js +0 -1
- /package/apps/pwa/out/_next/static/{ktMqfrBbf8BMGac0jqLrb → keajW7DDyghEs1y8oJe6T}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import sodium from 'libsodium-wrappers';
|
|
3
|
+
|
|
4
|
+
/** Maximum size of a complete encrypted frame, including sequence and MAC. */
|
|
5
|
+
export const MAX_FRAME_BYTES = 1024 * 1024;
|
|
6
|
+
const SEQUENCE_BYTES = 8;
|
|
7
|
+
const MAC_BYTES = 16;
|
|
8
|
+
const NONCE_BYTES = 24;
|
|
9
|
+
const MAX_SEQUENCE = (1n << 64n) - 1n;
|
|
10
|
+
|
|
11
|
+
export type ChannelResult<T> = { ok: true; value: T } | { ok: false; reason: string };
|
|
12
|
+
export interface DaemonKeyPair {
|
|
13
|
+
publicKey: Uint8Array;
|
|
14
|
+
secretKey: Uint8Array;
|
|
15
|
+
}
|
|
16
|
+
export interface SerializedDaemonKeyPair {
|
|
17
|
+
publicKey: string;
|
|
18
|
+
secretKey: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function rejected<T>(reason: string): ChannelResult<T> {
|
|
22
|
+
return { ok: false, reason };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validKey(key: Uint8Array): boolean {
|
|
26
|
+
return key instanceof Uint8Array && key.length === 32;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function equalKeys(left: Uint8Array, right: Uint8Array): boolean {
|
|
30
|
+
return validKey(left) && validKey(right) && timingSafeEqual(left, right);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The daemon persists this pair; the phone creates a fresh pair per connection. */
|
|
34
|
+
export async function generateDaemonKeyPair(seed?: Uint8Array): Promise<DaemonKeyPair> {
|
|
35
|
+
await sodium.ready;
|
|
36
|
+
if (seed !== undefined && !validKey(seed)) throw new RangeError('X25519 seed must be 32 bytes');
|
|
37
|
+
const pair = seed === undefined ? sodium.crypto_kx_keypair() : sodium.crypto_kx_seed_keypair(seed);
|
|
38
|
+
return { publicKey: pair.publicKey, secretKey: pair.privateKey };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function serverIdFromPublicKey(publicKey: Uint8Array): string {
|
|
42
|
+
if (!validKey(publicKey)) throw new RangeError('X25519 public key must be 32 bytes');
|
|
43
|
+
return createHash('sha256').update(publicKey).digest('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function serializeDaemonKeyPair(pair: DaemonKeyPair): SerializedDaemonKeyPair {
|
|
47
|
+
if (!validKey(pair.publicKey) || !validKey(pair.secretKey)) throw new RangeError('invalid X25519 key pair');
|
|
48
|
+
return { publicKey: Buffer.from(pair.publicKey).toString('base64'), secretKey: Buffer.from(pair.secretKey).toString('base64') };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function deserializeDaemonKeyPair(serialized: SerializedDaemonKeyPair): Promise<ChannelResult<DaemonKeyPair>> {
|
|
52
|
+
await sodium.ready;
|
|
53
|
+
if (typeof serialized?.publicKey !== 'string' || typeof serialized?.secretKey !== 'string' ||
|
|
54
|
+
!/^[A-Za-z0-9+/]{43}=$/.test(serialized.publicKey) || !/^[A-Za-z0-9+/]{43}=$/.test(serialized.secretKey)) {
|
|
55
|
+
return rejected('invalid daemon key serialization');
|
|
56
|
+
}
|
|
57
|
+
const publicKey = new Uint8Array(Buffer.from(serialized.publicKey, 'base64'));
|
|
58
|
+
const secretKey = new Uint8Array(Buffer.from(serialized.secretKey, 'base64'));
|
|
59
|
+
try {
|
|
60
|
+
if (!validKey(publicKey) || !validKey(secretKey) ||
|
|
61
|
+
!equalKeys(sodium.crypto_scalarmult_base(secretKey), publicKey)) {
|
|
62
|
+
return rejected('invalid daemon key pair');
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
return rejected('invalid daemon key pair');
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, value: { publicKey, secretKey } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sequenceBytes(sequence: bigint): Uint8Array {
|
|
71
|
+
const bytes = new Uint8Array(SEQUENCE_BYTES);
|
|
72
|
+
new DataView(bytes.buffer).setBigUint64(0, sequence, false);
|
|
73
|
+
return bytes;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nonceFor(sequence: bigint): Uint8Array {
|
|
77
|
+
const nonce = new Uint8Array(NONCE_BYTES);
|
|
78
|
+
nonce.set(sequenceBytes(sequence), NONCE_BYTES - SEQUENCE_BYTES);
|
|
79
|
+
return nonce;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Each direction uses its own crypto_kx key and monotonically increasing sequence. */
|
|
83
|
+
export class EncryptedChannel {
|
|
84
|
+
private sendSequence = 0n;
|
|
85
|
+
private receiveSequence = 0n;
|
|
86
|
+
private readonly sendKey: Uint8Array;
|
|
87
|
+
private readonly receiveKey: Uint8Array;
|
|
88
|
+
|
|
89
|
+
constructor(sendKey: Uint8Array, receiveKey: Uint8Array) {
|
|
90
|
+
if (!validKey(sendKey) || !validKey(receiveKey)) throw new RangeError('invalid session keys');
|
|
91
|
+
this.sendKey = new Uint8Array(sendKey);
|
|
92
|
+
this.receiveKey = new Uint8Array(receiveKey);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
seal(plaintext: Uint8Array): ChannelResult<Uint8Array> {
|
|
96
|
+
if (!(plaintext instanceof Uint8Array)) return rejected('invalid plaintext');
|
|
97
|
+
if (plaintext.length + SEQUENCE_BYTES + MAC_BYTES > MAX_FRAME_BYTES) return rejected('frame exceeds 1MB');
|
|
98
|
+
if (this.sendSequence > MAX_SEQUENCE) return rejected('send sequence exhausted');
|
|
99
|
+
const header = sequenceBytes(this.sendSequence);
|
|
100
|
+
try {
|
|
101
|
+
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
|
102
|
+
plaintext, header, null, nonceFor(this.sendSequence), this.sendKey,
|
|
103
|
+
);
|
|
104
|
+
const frame = new Uint8Array(header.length + ciphertext.length);
|
|
105
|
+
frame.set(header);
|
|
106
|
+
frame.set(ciphertext, header.length);
|
|
107
|
+
this.sendSequence++;
|
|
108
|
+
return { ok: true, value: frame };
|
|
109
|
+
} catch {
|
|
110
|
+
return rejected('frame encryption failed');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
open(frame: Uint8Array): ChannelResult<Uint8Array> {
|
|
115
|
+
if (!(frame instanceof Uint8Array) || frame.length < SEQUENCE_BYTES + MAC_BYTES) return rejected('invalid frame');
|
|
116
|
+
if (frame.length > MAX_FRAME_BYTES) return rejected('frame exceeds 1MB');
|
|
117
|
+
if (this.receiveSequence > MAX_SEQUENCE) return rejected('receive sequence exhausted');
|
|
118
|
+
const sequence = new DataView(frame.buffer, frame.byteOffset, SEQUENCE_BYTES).getBigUint64(0, false);
|
|
119
|
+
if (sequence < this.receiveSequence) return rejected('replayed frame');
|
|
120
|
+
if (sequence > this.receiveSequence) return rejected('out-of-order frame');
|
|
121
|
+
const header = frame.subarray(0, SEQUENCE_BYTES);
|
|
122
|
+
try {
|
|
123
|
+
const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
|
124
|
+
null, frame.subarray(SEQUENCE_BYTES), header, nonceFor(sequence), this.receiveKey,
|
|
125
|
+
);
|
|
126
|
+
this.receiveSequence++;
|
|
127
|
+
return { ok: true, value: plaintext };
|
|
128
|
+
} catch {
|
|
129
|
+
return rejected('frame authentication failed');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface PhoneChannel {
|
|
135
|
+
/** Sent to the daemon during handshake; the private key stays on the phone. */
|
|
136
|
+
phonePublicKey: Uint8Array;
|
|
137
|
+
serverId: string;
|
|
138
|
+
channel: EncryptedChannel;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Pin the daemon's public key before deriving any traffic keys. */
|
|
142
|
+
export async function createPhoneChannel(
|
|
143
|
+
daemonPublicKey: Uint8Array,
|
|
144
|
+
expectedDaemonPublicKey: Uint8Array,
|
|
145
|
+
phoneSeed?: Uint8Array,
|
|
146
|
+
): Promise<ChannelResult<PhoneChannel>> {
|
|
147
|
+
await sodium.ready;
|
|
148
|
+
if (!validKey(daemonPublicKey) || !equalKeys(daemonPublicKey, expectedDaemonPublicKey)) {
|
|
149
|
+
return rejected('wrong daemon public key');
|
|
150
|
+
}
|
|
151
|
+
if (phoneSeed !== undefined && !validKey(phoneSeed)) return rejected('invalid phone seed');
|
|
152
|
+
const phoneKeys = phoneSeed === undefined ? sodium.crypto_kx_keypair() : sodium.crypto_kx_seed_keypair(phoneSeed);
|
|
153
|
+
try {
|
|
154
|
+
const keys = sodium.crypto_kx_client_session_keys(phoneKeys.publicKey, phoneKeys.privateKey, daemonPublicKey);
|
|
155
|
+
return { ok: true, value: {
|
|
156
|
+
phonePublicKey: phoneKeys.publicKey,
|
|
157
|
+
serverId: serverIdFromPublicKey(daemonPublicKey),
|
|
158
|
+
channel: new EncryptedChannel(keys.sharedTx, keys.sharedRx),
|
|
159
|
+
} };
|
|
160
|
+
} catch {
|
|
161
|
+
return rejected('invalid daemon public key');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Called with the phone's ephemeral public key; crypto_kx supplies opposite Tx/Rx keys. */
|
|
166
|
+
export async function acceptDaemonChannel(
|
|
167
|
+
daemonKeys: DaemonKeyPair,
|
|
168
|
+
phonePublicKey: Uint8Array,
|
|
169
|
+
): Promise<ChannelResult<EncryptedChannel>> {
|
|
170
|
+
await sodium.ready;
|
|
171
|
+
if (!validKey(daemonKeys?.publicKey) || !validKey(daemonKeys?.secretKey)) {
|
|
172
|
+
return rejected('invalid daemon key pair');
|
|
173
|
+
}
|
|
174
|
+
if (!validKey(phonePublicKey)) return rejected('invalid phone public key');
|
|
175
|
+
try {
|
|
176
|
+
if (!equalKeys(sodium.crypto_scalarmult_base(daemonKeys.secretKey), daemonKeys.publicKey)) {
|
|
177
|
+
return rejected('invalid daemon key pair');
|
|
178
|
+
}
|
|
179
|
+
const keys = sodium.crypto_kx_server_session_keys(daemonKeys.publicKey, daemonKeys.secretKey, phonePublicKey);
|
|
180
|
+
return { ok: true, value: new EncryptedChannel(keys.sharedTx, keys.sharedRx) };
|
|
181
|
+
} catch {
|
|
182
|
+
return rejected('invalid handshake key');
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/relay/mux.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { MAX_FRAME_BYTES } from './channel.js';
|
|
2
|
+
import type { ChannelResult } from './channel.js';
|
|
3
|
+
|
|
4
|
+
export type MuxFrame =
|
|
5
|
+
| { streamId: number; kind: 'open'; protocol: 'http'; method: string; path: string; headers: Record<string, string> }
|
|
6
|
+
| { streamId: number; kind: 'open'; protocol: 'ws'; path: string; headers: Record<string, string> }
|
|
7
|
+
| { streamId: number; kind: 'data'; data: Uint8Array }
|
|
8
|
+
| { streamId: number; kind: 'close'; reason: string };
|
|
9
|
+
|
|
10
|
+
// u32 BE length (remaining bytes), u32 BE stream ID, u8 kind (1=open, 2=data, 3=close).
|
|
11
|
+
// Open: u8 protocol (1=http, 2=ws), [http: u16 method bytes + UTF-8 method],
|
|
12
|
+
// u32 path bytes + UTF-8 path, u16 header count, then u16 name bytes + UTF-8 name,
|
|
13
|
+
// u16 value bytes + UTF-8 value per header. Data: remaining raw bytes.
|
|
14
|
+
// Close: u16 reason bytes + UTF-8 reason. One frame per encrypted plaintext.
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
17
|
+
const HEADER_BYTES = 9;
|
|
18
|
+
// EncryptedChannel appends an 8-byte sequence and 16-byte MAC to each plaintext.
|
|
19
|
+
export const MAX_MUX_FRAME_BYTES = MAX_FRAME_BYTES - 24;
|
|
20
|
+
|
|
21
|
+
type Result<T> = ChannelResult<T>;
|
|
22
|
+
function reject<T>(reason: string): Result<T> { return { ok: false, reason }; }
|
|
23
|
+
|
|
24
|
+
class Writer {
|
|
25
|
+
private parts: Uint8Array[] = [];
|
|
26
|
+
length = 0;
|
|
27
|
+
bytes(value: Uint8Array) { this.parts.push(value); this.length += value.length; }
|
|
28
|
+
number(value: number, size: 1 | 2 | 4) {
|
|
29
|
+
const bytes = new Uint8Array(size);
|
|
30
|
+
const view = new DataView(bytes.buffer);
|
|
31
|
+
if (size === 1) view.setUint8(0, value);
|
|
32
|
+
else if (size === 2) view.setUint16(0, value, false);
|
|
33
|
+
else view.setUint32(0, value, false);
|
|
34
|
+
this.bytes(bytes);
|
|
35
|
+
}
|
|
36
|
+
text(value: string, size: 2 | 4): boolean {
|
|
37
|
+
if (typeof value !== 'string') return false;
|
|
38
|
+
const bytes = encoder.encode(value);
|
|
39
|
+
if (bytes.length > (size === 2 ? 0xffff : 0xffffffff)) return false;
|
|
40
|
+
this.number(bytes.length, size);
|
|
41
|
+
this.bytes(bytes);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
finish(): Uint8Array {
|
|
45
|
+
const out = new Uint8Array(this.length);
|
|
46
|
+
let offset = 0;
|
|
47
|
+
for (const part of this.parts) { out.set(part, offset); offset += part.length; }
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class Reader {
|
|
53
|
+
offset = 0;
|
|
54
|
+
constructor(readonly bytes: Uint8Array) {}
|
|
55
|
+
number(size: 1 | 2 | 4): number | undefined {
|
|
56
|
+
if (this.offset + size > this.bytes.length) return undefined;
|
|
57
|
+
const view = new DataView(this.bytes.buffer, this.bytes.byteOffset + this.offset, size);
|
|
58
|
+
this.offset += size;
|
|
59
|
+
return size === 1 ? view.getUint8(0) : size === 2 ? view.getUint16(0, false) : view.getUint32(0, false);
|
|
60
|
+
}
|
|
61
|
+
text(size: 2 | 4): string | undefined {
|
|
62
|
+
const length = this.number(size);
|
|
63
|
+
if (length === undefined || this.offset + length > this.bytes.length) return undefined;
|
|
64
|
+
const bytes = this.bytes.subarray(this.offset, this.offset + length);
|
|
65
|
+
this.offset += length;
|
|
66
|
+
return decoder.decode(bytes);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validId(id: number): boolean { return Number.isInteger(id) && id >= 0 && id <= 0xffffffff; }
|
|
71
|
+
|
|
72
|
+
/** Share one instance between the encoder and decoder of a single endpoint. */
|
|
73
|
+
export class MuxStreamState {
|
|
74
|
+
private readonly active = new Set<number>();
|
|
75
|
+
|
|
76
|
+
isOpen(id: number): boolean { return this.active.has(id); }
|
|
77
|
+
opened(id: number): void { this.active.add(id); }
|
|
78
|
+
closed(id: number): void { this.active.delete(id); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** No network I/O; shares stream IDs with the receiving decoder when supplied a state. */
|
|
82
|
+
export class MuxEncoder {
|
|
83
|
+
constructor(private readonly streams: MuxStreamState = new MuxStreamState()) {}
|
|
84
|
+
|
|
85
|
+
encode(frame: MuxFrame): Result<Uint8Array> {
|
|
86
|
+
if (!frame || !validId(frame.streamId)) return reject('invalid stream ID');
|
|
87
|
+
if (frame.kind !== 'open' && frame.kind !== 'data' && frame.kind !== 'close') return reject('unknown frame kind');
|
|
88
|
+
if (frame.kind === 'open' ? this.streams.isOpen(frame.streamId) : !this.streams.isOpen(frame.streamId)) {
|
|
89
|
+
return reject(frame.kind === 'open' ? 'stream already open' : 'stream not open');
|
|
90
|
+
}
|
|
91
|
+
const writer = new Writer();
|
|
92
|
+
writer.number(0, 4);
|
|
93
|
+
writer.number(frame.streamId, 4);
|
|
94
|
+
if (frame.kind === 'open') {
|
|
95
|
+
writer.number(1, 1);
|
|
96
|
+
if (frame.protocol !== 'http' && frame.protocol !== 'ws') return reject('unknown protocol');
|
|
97
|
+
writer.number(frame.protocol === 'http' ? 1 : 2, 1);
|
|
98
|
+
if (frame.protocol === 'http' && (!frame.method || !writer.text(frame.method, 2))) return reject('invalid method');
|
|
99
|
+
if (!frame.path || !writer.text(frame.path, 4)) return reject('invalid path');
|
|
100
|
+
if (!frame.headers || typeof frame.headers !== 'object' || Array.isArray(frame.headers)) return reject('invalid headers');
|
|
101
|
+
const entries = Object.entries(frame.headers);
|
|
102
|
+
if (entries.length > 0xffff) return reject('invalid headers');
|
|
103
|
+
writer.number(entries.length, 2);
|
|
104
|
+
for (const [name, value] of entries) {
|
|
105
|
+
if (!name || !writer.text(name, 2) || !writer.text(value, 2)) return reject('invalid headers');
|
|
106
|
+
}
|
|
107
|
+
} else if (frame.kind === 'data') {
|
|
108
|
+
if (!(frame.data instanceof Uint8Array)) return reject('invalid data');
|
|
109
|
+
writer.number(2, 1);
|
|
110
|
+
writer.bytes(frame.data);
|
|
111
|
+
} else if (frame.kind === 'close') {
|
|
112
|
+
writer.number(3, 1);
|
|
113
|
+
if (!writer.text(frame.reason, 2)) return reject('invalid reason');
|
|
114
|
+
}
|
|
115
|
+
if (writer.length > MAX_MUX_FRAME_BYTES) return reject('frame exceeds 1MB');
|
|
116
|
+
const out = writer.finish();
|
|
117
|
+
new DataView(out.buffer).setUint32(0, out.length - 4, false);
|
|
118
|
+
if (frame.kind === 'open') this.streams.opened(frame.streamId);
|
|
119
|
+
if (frame.kind === 'close') this.streams.closed(frame.streamId);
|
|
120
|
+
return { ok: true, value: out };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Rejected frames never change stream state. Share state with the sending encoder per endpoint. */
|
|
125
|
+
export class MuxDecoder {
|
|
126
|
+
constructor(private readonly streams: MuxStreamState = new MuxStreamState()) {}
|
|
127
|
+
|
|
128
|
+
decode(bytes: Uint8Array): Result<MuxFrame> {
|
|
129
|
+
if (!(bytes instanceof Uint8Array)) return reject('invalid frame');
|
|
130
|
+
if (bytes.length > MAX_MUX_FRAME_BYTES) return reject('frame exceeds 1MB');
|
|
131
|
+
if (bytes.length < HEADER_BYTES) return reject('truncated frame');
|
|
132
|
+
const reader = new Reader(bytes);
|
|
133
|
+
const length = reader.number(4)!;
|
|
134
|
+
if (length !== bytes.length - 4) return reject(length > bytes.length - 4 ? 'truncated frame' : 'trailing frame bytes');
|
|
135
|
+
const streamId = reader.number(4)!;
|
|
136
|
+
const kind = reader.number(1)!;
|
|
137
|
+
if (kind !== 1 && kind !== 2 && kind !== 3) return reject('unknown frame kind');
|
|
138
|
+
if (kind === 1 ? this.streams.isOpen(streamId) : !this.streams.isOpen(streamId)) {
|
|
139
|
+
return reject(kind === 1 ? 'stream already open' : 'stream not open');
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
let frame: MuxFrame;
|
|
143
|
+
if (kind === 1) {
|
|
144
|
+
const protocolByte = reader.number(1);
|
|
145
|
+
if (protocolByte !== 1 && protocolByte !== 2) return reject(protocolByte === undefined ? 'truncated frame' : 'unknown protocol');
|
|
146
|
+
const method = protocolByte === 1 ? reader.text(2) : undefined;
|
|
147
|
+
if (protocolByte === 1 && method === undefined) return reject('truncated frame');
|
|
148
|
+
const path = reader.text(4);
|
|
149
|
+
const count = reader.number(2);
|
|
150
|
+
if (path === undefined || count === undefined) return reject('truncated frame');
|
|
151
|
+
if (!path || (protocolByte === 1 && !method)) return reject('invalid open frame');
|
|
152
|
+
const entries: [string, string][] = [];
|
|
153
|
+
const names = new Set<string>();
|
|
154
|
+
for (let i = 0; i < count; i++) {
|
|
155
|
+
const name = reader.text(2);
|
|
156
|
+
const value = reader.text(2);
|
|
157
|
+
if (name === undefined || value === undefined) return reject('truncated frame');
|
|
158
|
+
if (!name || names.has(name)) return reject('invalid headers');
|
|
159
|
+
names.add(name);
|
|
160
|
+
entries.push([name, value]);
|
|
161
|
+
}
|
|
162
|
+
const headers = Object.fromEntries(entries);
|
|
163
|
+
frame = protocolByte === 1
|
|
164
|
+
? { streamId, kind: 'open', protocol: 'http', method: method!, path, headers }
|
|
165
|
+
: { streamId, kind: 'open', protocol: 'ws', path, headers };
|
|
166
|
+
} else if (kind === 2) {
|
|
167
|
+
frame = { streamId, kind: 'data', data: bytes.slice(reader.offset) };
|
|
168
|
+
reader.offset = bytes.length;
|
|
169
|
+
} else {
|
|
170
|
+
const reason = reader.text(2);
|
|
171
|
+
if (reason === undefined) return reject('truncated frame');
|
|
172
|
+
frame = { streamId, kind: 'close', reason };
|
|
173
|
+
}
|
|
174
|
+
if (reader.offset !== bytes.length) return reject('trailing frame bytes');
|
|
175
|
+
if (kind === 1) this.streams.opened(streamId);
|
|
176
|
+
if (kind === 3) this.streams.closed(streamId);
|
|
177
|
+
return { ok: true, value: frame };
|
|
178
|
+
} catch {
|
|
179
|
+
return reject('invalid UTF-8 frame');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
|
|
3
|
+
/** `elanous relay serve` — 릴레이 서버(R2 · 내부 문서 `RFC-mobile-relay-without-tailscale-2026-09-30` A5).
|
|
4
|
+
* index.ts 원문을 잘라 보는 시험(첫 `.command('serve')` = mcp)을 흔들지 않게 등록을 여기 둔다. */
|
|
5
|
+
export function registerRelayCommand(program: Command): void {
|
|
6
|
+
const relayCmd = program.command('relay').description('Encrypted WebSocket relay');
|
|
7
|
+
relayCmd.command('serve')
|
|
8
|
+
.description('Serve paired relay connections using operator-provisioned host signing keys')
|
|
9
|
+
.requiredOption('--trusted-host-keys <file>', 'JSON object of X25519-derived serverId to Ed25519 public key (base64url)')
|
|
10
|
+
.option('--port <port>', 'Listening port', '8765')
|
|
11
|
+
.action(async (opts: { trustedHostKeys: string; port: string }) => {
|
|
12
|
+
const port = Number(opts.port);
|
|
13
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('invalid relay port');
|
|
14
|
+
const { loadTrustedHostKeys, startRelayServer } = await import('./relay-server.js');
|
|
15
|
+
const relay = startRelayServer({ port, trustedHostKeys: loadTrustedHostKeys(opts.trustedHostKeys) });
|
|
16
|
+
console.log(`relay listening on 127.0.0.1:${relay.port}`);
|
|
17
|
+
process.once('SIGINT', () => { relay.stop(); });
|
|
18
|
+
process.once('SIGTERM', () => { relay.stop(); });
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createPublicKey, randomBytes, verify } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import type { ServerWebSocket } from 'bun';
|
|
4
|
+
import { MAX_FRAME_BYTES } from './channel.js';
|
|
5
|
+
|
|
6
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
7
|
+
const MAX_CLIENTS_PER_SERVER = 8;
|
|
8
|
+
const BYTES_PER_SECOND = 1024 * 1024;
|
|
9
|
+
const BURST_BYTES = 2 * BYTES_PER_SECOND;
|
|
10
|
+
const PING_INTERVAL_MS = 30_000;
|
|
11
|
+
const DEAD_PEER_MS = 90_000;
|
|
12
|
+
const AUTH_TIMEOUT_MS = 10_000;
|
|
13
|
+
|
|
14
|
+
type SocketData = { role: 'host' | 'client'; serverId: string; clientId?: number; challenge?: string; authenticated: boolean; lastSeen: number; tokens: number; refillAt: number; authTimer?: ReturnType<typeof setTimeout> };
|
|
15
|
+
type Socket = ServerWebSocket<SocketData>;
|
|
16
|
+
type Room = { host?: Socket; clients: Map<number, Socket>; nextId: number; bytes: number; failures: Record<string, number> };
|
|
17
|
+
|
|
18
|
+
export interface RelayServerOptions {
|
|
19
|
+
port?: number;
|
|
20
|
+
hostname?: string;
|
|
21
|
+
/** Ed25519 signing public keys (base64url raw 32 bytes), pinned by X25519-derived serverId. */
|
|
22
|
+
trustedHostKeys?: ReadonlyMap<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Load operator-provisioned pins, never keys claimed by connecting hosts. */
|
|
26
|
+
export function loadTrustedHostKeys(file: string): ReadonlyMap<string, string> {
|
|
27
|
+
const raw: unknown = JSON.parse(readFileSync(file, 'utf8'));
|
|
28
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('invalid relay trusted host keys');
|
|
29
|
+
const result = new Map<string, string>();
|
|
30
|
+
for (const [id, key] of Object.entries(raw)) {
|
|
31
|
+
if (!/^[a-f0-9]{64}$/.test(id) || typeof key !== 'string' || !decodeKey(key)) {
|
|
32
|
+
throw new Error('invalid relay trusted host key entry');
|
|
33
|
+
}
|
|
34
|
+
result.set(id, key);
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function challengeMessage(serverId: string, nonce: string): Uint8Array {
|
|
40
|
+
return Buffer.from(`elanous-relay-v1:${serverId}:${nonce}`, 'utf8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decodeKey(value: string): Buffer | undefined {
|
|
44
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(value)) return undefined;
|
|
45
|
+
const decoded = Buffer.from(value, 'base64url');
|
|
46
|
+
return decoded.length === 32 && decoded.toString('base64url') === value ? decoded : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function encodeClientId(id: number, payload: Uint8Array): Uint8Array {
|
|
50
|
+
const result = new Uint8Array(payload.length + 4);
|
|
51
|
+
new DataView(result.buffer).setUint32(0, id, false);
|
|
52
|
+
result.set(payload, 4);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The relay never parses or logs encrypted channel payloads. Host binary frames carry a
|
|
57
|
+
* four-byte big-endian clientId prefix; phone binary frames are raw ciphertext. */
|
|
58
|
+
export function startRelayServer(options: RelayServerOptions = {}) {
|
|
59
|
+
if (!options.trustedHostKeys?.size) throw new Error('relay trusted host keys required');
|
|
60
|
+
const trustedHostKeys = new Map(options.trustedHostKeys);
|
|
61
|
+
for (const [id, key] of trustedHostKeys) {
|
|
62
|
+
if (!/^[a-f0-9]{64}$/.test(id) || !decodeKey(key)) throw new Error('invalid relay trusted host key entry');
|
|
63
|
+
}
|
|
64
|
+
const rooms = new Map<string, Room>();
|
|
65
|
+
const sockets = new Set<Socket>();
|
|
66
|
+
const roomFor = (id: string): Room => {
|
|
67
|
+
let room = rooms.get(id);
|
|
68
|
+
if (!room) { room = { clients: new Map(), nextId: 1, bytes: 0, failures: {} }; rooms.set(id, room); }
|
|
69
|
+
return room;
|
|
70
|
+
};
|
|
71
|
+
const fail = (room: Room, reason: string) => { room.failures[reason] = (room.failures[reason] ?? 0) + 1; };
|
|
72
|
+
const sendControl = (ws: Socket, value: object) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value)); };
|
|
73
|
+
const drop = (ws: Socket) => {
|
|
74
|
+
const data = ws.data;
|
|
75
|
+
if (data.authTimer) clearTimeout(data.authTimer);
|
|
76
|
+
sockets.delete(ws);
|
|
77
|
+
const room = rooms.get(data.serverId);
|
|
78
|
+
if (!room) return;
|
|
79
|
+
if (!data.authenticated) {
|
|
80
|
+
if (!room.host && room.clients.size === 0 &&
|
|
81
|
+
!Array.from(sockets).some(peer => peer.data.serverId === data.serverId)) rooms.delete(data.serverId);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (data.role === 'host' && room.host === ws) {
|
|
85
|
+
room.host = undefined;
|
|
86
|
+
const clients = [...room.clients.values()];
|
|
87
|
+
room.clients.clear();
|
|
88
|
+
for (const client of clients) client.close(1012, 'host disconnected');
|
|
89
|
+
} else if (data.role === 'client' && data.clientId !== undefined && room.clients.get(data.clientId) === ws) {
|
|
90
|
+
room.clients.delete(data.clientId);
|
|
91
|
+
if (room.host) sendControl(room.host, { type: 'client-disconnected', clientId: data.clientId });
|
|
92
|
+
}
|
|
93
|
+
if (!room.host && room.clients.size === 0 &&
|
|
94
|
+
!Array.from(sockets).some(peer => peer.data.serverId === data.serverId)) rooms.delete(data.serverId);
|
|
95
|
+
};
|
|
96
|
+
const capacity = (ws: Socket, length: number): boolean => {
|
|
97
|
+
const data = ws.data;
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
data.tokens = Math.min(BURST_BYTES, data.tokens + (now - data.refillAt) * BYTES_PER_SECOND / 1000);
|
|
100
|
+
data.refillAt = now;
|
|
101
|
+
if (length > data.tokens) return false;
|
|
102
|
+
data.tokens -= length;
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
105
|
+
const server = Bun.serve<SocketData>({
|
|
106
|
+
hostname: options.hostname ?? '127.0.0.1', port: options.port ?? 0,
|
|
107
|
+
fetch(req, server) {
|
|
108
|
+
const url = new URL(req.url);
|
|
109
|
+
if (url.pathname !== '/host' && url.pathname !== '/client') return new Response('not found', { status: 404 });
|
|
110
|
+
const serverId = url.searchParams.get('serverId');
|
|
111
|
+
if (!serverId || !/^[a-f0-9]{64}$/.test(serverId)) return new Response('invalid serverId', { status: 400 });
|
|
112
|
+
if (!trustedHostKeys.has(serverId)) return new Response('unknown serverId', { status: 403 });
|
|
113
|
+
if (req.headers.get('upgrade')?.toLowerCase() !== 'websocket') return new Response('upgrade required', { status: 426 });
|
|
114
|
+
const role = url.pathname === '/host' ? 'host' : 'client';
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
if (!server.upgrade(req, { data: { role, serverId, authenticated: false, lastSeen: now, tokens: BURST_BYTES, refillAt: now } })) {
|
|
117
|
+
return new Response('upgrade required', { status: 426 });
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
},
|
|
121
|
+
websocket: {
|
|
122
|
+
maxPayloadLength: MAX_FRAME_BYTES + 4,
|
|
123
|
+
open(ws) {
|
|
124
|
+
sockets.add(ws);
|
|
125
|
+
const data = ws.data;
|
|
126
|
+
const room = roomFor(data.serverId);
|
|
127
|
+
if (data.role === 'host') {
|
|
128
|
+
if (room.host) { fail(room, 'duplicate-host'); ws.close(1008, 'host already connected'); return; }
|
|
129
|
+
data.challenge = randomBytes(32).toString('base64url');
|
|
130
|
+
sendControl(ws, { type: 'challenge', nonce: data.challenge });
|
|
131
|
+
} else {
|
|
132
|
+
if (!room.host || room.clients.size >= MAX_CLIENTS_PER_SERVER) {
|
|
133
|
+
fail(room, !room.host ? 'host-offline' : 'client-limit');
|
|
134
|
+
ws.close(1008, !room.host ? 'host offline' : 'client limit reached');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const id = room.nextId++;
|
|
138
|
+
data.clientId = id;
|
|
139
|
+
data.authenticated = true;
|
|
140
|
+
room.clients.set(id, ws);
|
|
141
|
+
sendControl(ws, { type: 'connected', clientId: id });
|
|
142
|
+
sendControl(room.host, { type: 'client-connected', clientId: id });
|
|
143
|
+
}
|
|
144
|
+
if (!data.authenticated) data.authTimer = setTimeout(() => ws.close(1008, 'authentication timeout'), AUTH_TIMEOUT_MS);
|
|
145
|
+
},
|
|
146
|
+
message(ws, message) {
|
|
147
|
+
const data = ws.data;
|
|
148
|
+
const room = rooms.get(data.serverId);
|
|
149
|
+
if (!room) { ws.close(1008, 'room missing'); return; }
|
|
150
|
+
data.lastSeen = Date.now();
|
|
151
|
+
if (!data.authenticated) {
|
|
152
|
+
if (data.role !== 'host' || typeof message !== 'string') { fail(room, 'invalid-auth'); ws.close(1008, 'authentication required'); return; }
|
|
153
|
+
let auth: unknown;
|
|
154
|
+
try { auth = JSON.parse(message); } catch { /* reject below */ }
|
|
155
|
+
const value = auth as { type?: unknown; publicKey?: unknown; signature?: unknown } | undefined;
|
|
156
|
+
const key = typeof value?.publicKey === 'string' ? decodeKey(value.publicKey) : undefined;
|
|
157
|
+
const signature = typeof value?.signature === 'string' && /^[A-Za-z0-9_-]{86}$/.test(value.signature)
|
|
158
|
+
? Buffer.from(value.signature, 'base64url') : undefined;
|
|
159
|
+
const expected = trustedHostKeys.get(data.serverId);
|
|
160
|
+
let valid = false;
|
|
161
|
+
if (value?.type === 'auth' && key && signature?.length === 64 && data.challenge &&
|
|
162
|
+
value.publicKey === expected) {
|
|
163
|
+
try {
|
|
164
|
+
valid = verify(null, challengeMessage(data.serverId, data.challenge),
|
|
165
|
+
createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, key]), format: 'der', type: 'spki' }), signature);
|
|
166
|
+
} catch { /* invalid public key */ }
|
|
167
|
+
}
|
|
168
|
+
if (!valid || (room.host && room.host !== ws)) { fail(room, 'invalid-auth'); ws.close(1008, 'invalid host signature'); return; }
|
|
169
|
+
data.authenticated = true;
|
|
170
|
+
data.challenge = undefined;
|
|
171
|
+
if (data.authTimer) clearTimeout(data.authTimer);
|
|
172
|
+
room.host = ws;
|
|
173
|
+
sendControl(ws, { type: 'authenticated' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (typeof message === 'string') { fail(room, 'plaintext-data'); ws.close(1003, 'binary ciphertext required'); return; }
|
|
177
|
+
const bytes = new Uint8Array(message);
|
|
178
|
+
if (bytes.length > MAX_FRAME_BYTES + (data.role === 'host' ? 4 : 0) || bytes.length < (data.role === 'host' ? 5 : 1)) {
|
|
179
|
+
fail(room, 'invalid-frame'); ws.close(1009, 'invalid frame size'); return;
|
|
180
|
+
}
|
|
181
|
+
if (!capacity(ws, bytes.length)) { fail(room, 'rate-limit'); ws.close(1008, 'rate limit exceeded'); return; }
|
|
182
|
+
if (data.role === 'client') {
|
|
183
|
+
if (!room.host || data.clientId === undefined) { ws.close(1012, 'host offline'); return; }
|
|
184
|
+
room.bytes += bytes.length;
|
|
185
|
+
room.host.send(encodeClientId(data.clientId, bytes));
|
|
186
|
+
} else {
|
|
187
|
+
if (room.host !== ws) { ws.close(1008, 'host replaced'); return; }
|
|
188
|
+
const clientId = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0, false);
|
|
189
|
+
const client = room.clients.get(clientId);
|
|
190
|
+
if (!client) { fail(room, 'unknown-client'); return; }
|
|
191
|
+
room.bytes += bytes.length - 4;
|
|
192
|
+
client.send(bytes.subarray(4));
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
pong(ws) { ws.data.lastSeen = Date.now(); },
|
|
196
|
+
close(ws) { drop(ws); },
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
const heartbeat = setInterval(() => {
|
|
200
|
+
for (const ws of sockets) {
|
|
201
|
+
if (Date.now() - ws.data.lastSeen > DEAD_PEER_MS) ws.close(1001, 'heartbeat timeout');
|
|
202
|
+
else ws.ping();
|
|
203
|
+
}
|
|
204
|
+
}, PING_INTERVAL_MS);
|
|
205
|
+
heartbeat.unref();
|
|
206
|
+
return {
|
|
207
|
+
server,
|
|
208
|
+
get port() { return server.port; },
|
|
209
|
+
stats(serverId: string) {
|
|
210
|
+
const room = rooms.get(serverId);
|
|
211
|
+
return room ? { host: !!room.host, clients: room.clients.size, bytes: room.bytes, failures: { ...room.failures } } :
|
|
212
|
+
{ host: false, clients: 0, bytes: 0, failures: {} as Record<string, number> };
|
|
213
|
+
},
|
|
214
|
+
stop() {
|
|
215
|
+
clearInterval(heartbeat);
|
|
216
|
+
for (const ws of sockets) ws.close(1001, 'relay stopping');
|
|
217
|
+
server.stop(true);
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"daemonSeedHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
|
3
|
+
"phoneSeedHex": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
|
|
4
|
+
"daemonPublicKeyHex": "0e0216223f147143d32615a91189c288c1728cba3cc5f9f621b1026e03d83129",
|
|
5
|
+
"phonePublicKeyHex": "99f4674ecc87c0b8e712f192b8f49e7442a9376b4875967ababa28471019a93e",
|
|
6
|
+
"serverId": "4bc447d530c4044729c44372319cebfcbe079462826a6d39ea4828b149f9c685",
|
|
7
|
+
"phoneToDaemonKeySha256": "1fd0e0a3bd4e5afff9efb91653c4e67f549b2aaf26e8fea9a5fb263cbd987ea0",
|
|
8
|
+
"daemonToPhoneKeySha256": "534f8e2462d2bfe07a31cc43a2fd7a46d6109c31289c971c17626324b9806ee8",
|
|
9
|
+
"nonceHex": "000000000000000000000000000000000000000000000000",
|
|
10
|
+
"request": {
|
|
11
|
+
"streamId": 16909060,
|
|
12
|
+
"kind": "open",
|
|
13
|
+
"protocol": "http",
|
|
14
|
+
"method": "POST",
|
|
15
|
+
"path": "/v1/health",
|
|
16
|
+
"headers": {
|
|
17
|
+
"authorization": "Bearer vector-token"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"requestMuxHex": "000000400102030401010004504f53540000000a2f76312f6865616c74680001000d617574686f72697a6174696f6e001342656172657220766563746f722d746f6b656e",
|
|
21
|
+
"requestEncryptedHex": "0000000000000000d793343f86630442fcf3812bc1375c4b102aa1ffd546ac4ecbd5f5f4e9e4b3c2de47616a01df19b25b317cd0c5f3c2f0aa7a8b12f307c9872c5f03765a0b1c0e6b6f46dcbda1758ae286b475870eb7ad957f409e",
|
|
22
|
+
"response": {
|
|
23
|
+
"streamId": 16909060,
|
|
24
|
+
"kind": "data",
|
|
25
|
+
"dataHex": "00ff1020"
|
|
26
|
+
},
|
|
27
|
+
"responseMuxHex": "00000009010203040200ff1020",
|
|
28
|
+
"responseEncryptedHex": "0000000000000000bf589589e25e990a1c7da5fb7a773f0a798e84d1f444d51bd8e3e0bdb4"
|
|
29
|
+
}
|