@richhardry/codor 0.10.0
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/LICENSE +21 -0
- package/README.md +293 -0
- package/bin/codor.mjs +7 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.d.ts +41 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.js +275 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.d.ts +2 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.js +2 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-antigravity/package.json +18 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.d.ts +94 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.js +785 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.d.ts +8 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.js +5 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.d.ts +18 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.js +67 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.d.ts +3 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.js +128 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.d.ts +9 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.js +7 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.d.ts +40 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.js +505 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/package.json +18 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.d.ts +105 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.js +625 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.d.ts +39 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.js +186 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/index.d.ts +8 -0
- package/node_modules/@codor/adapter-codex/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/index.js +5 -0
- package/node_modules/@codor/adapter-codex/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.d.ts +18 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.js +87 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/peek.d.ts +3 -0
- package/node_modules/@codor/adapter-codex/dist/peek.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/peek.js +98 -0
- package/node_modules/@codor/adapter-codex/dist/peek.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/translate.d.ts +17 -0
- package/node_modules/@codor/adapter-codex/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/translate.js +286 -0
- package/node_modules/@codor/adapter-codex/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-codex/package.json +17 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.d.ts +40 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.js +190 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/index.d.ts +4 -0
- package/node_modules/@codor/adapter-copilot/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/index.js +3 -0
- package/node_modules/@codor/adapter-copilot/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.d.ts +11 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.js +184 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-copilot/package.json +18 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.d.ts +32 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.js +179 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/index.d.ts +9 -0
- package/node_modules/@codor/adapter-cursor/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/index.js +8 -0
- package/node_modules/@codor/adapter-cursor/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.d.ts +15 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.js +164 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-cursor/package.json +17 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.d.ts +36 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.js +245 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/index.d.ts +4 -0
- package/node_modules/@codor/adapter-gemini/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/index.js +3 -0
- package/node_modules/@codor/adapter-gemini/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.d.ts +12 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.js +123 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-gemini/package.json +18 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.d.ts +42 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.js +240 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/index.d.ts +4 -0
- package/node_modules/@codor/adapter-opencode/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/index.js +3 -0
- package/node_modules/@codor/adapter-opencode/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.d.ts +12 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.js +144 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-opencode/package.json +18 -0
- package/node_modules/@codor/cli/dist/artifact.d.ts +33 -0
- package/node_modules/@codor/cli/dist/artifact.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/artifact.js +183 -0
- package/node_modules/@codor/cli/dist/artifact.js.map +1 -0
- package/node_modules/@codor/cli/dist/attach.d.ts +25 -0
- package/node_modules/@codor/cli/dist/attach.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/attach.js +133 -0
- package/node_modules/@codor/cli/dist/attach.js.map +1 -0
- package/node_modules/@codor/cli/dist/connection.d.ts +21 -0
- package/node_modules/@codor/cli/dist/connection.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/connection.js +130 -0
- package/node_modules/@codor/cli/dist/connection.js.map +1 -0
- package/node_modules/@codor/cli/dist/detect.d.ts +14 -0
- package/node_modules/@codor/cli/dist/detect.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/detect.js +99 -0
- package/node_modules/@codor/cli/dist/detect.js.map +1 -0
- package/node_modules/@codor/cli/dist/index.d.ts +17 -0
- package/node_modules/@codor/cli/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/index.js +24 -0
- package/node_modules/@codor/cli/dist/index.js.map +1 -0
- package/node_modules/@codor/cli/dist/mirror.d.ts +4 -0
- package/node_modules/@codor/cli/dist/mirror.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/mirror.js +93 -0
- package/node_modules/@codor/cli/dist/mirror.js.map +1 -0
- package/node_modules/@codor/cli/dist/program.d.ts +17 -0
- package/node_modules/@codor/cli/dist/program.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/program.js +1068 -0
- package/node_modules/@codor/cli/dist/program.js.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-install.d.ts +61 -0
- package/node_modules/@codor/cli/dist/runtime-install.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-install.js +122 -0
- package/node_modules/@codor/cli/dist/runtime-install.js.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-paths.d.ts +24 -0
- package/node_modules/@codor/cli/dist/runtime-paths.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-paths.js +69 -0
- package/node_modules/@codor/cli/dist/runtime-paths.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup-flow.d.ts +69 -0
- package/node_modules/@codor/cli/dist/setup-flow.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup-flow.js +110 -0
- package/node_modules/@codor/cli/dist/setup-flow.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup-session.d.ts +105 -0
- package/node_modules/@codor/cli/dist/setup-session.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup-session.js +346 -0
- package/node_modules/@codor/cli/dist/setup-session.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup-ui.d.ts +93 -0
- package/node_modules/@codor/cli/dist/setup-ui.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup-ui.js +249 -0
- package/node_modules/@codor/cli/dist/setup-ui.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup.d.ts +108 -0
- package/node_modules/@codor/cli/dist/setup.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup.js +878 -0
- package/node_modules/@codor/cli/dist/setup.js.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-keys.d.ts +26 -0
- package/node_modules/@codor/cli/dist/terminal-keys.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-keys.js +70 -0
- package/node_modules/@codor/cli/dist/terminal-keys.js.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-qr.d.ts +2 -0
- package/node_modules/@codor/cli/dist/terminal-qr.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-qr.js +30 -0
- package/node_modules/@codor/cli/dist/terminal-qr.js.map +1 -0
- package/node_modules/@codor/cli/dist/up.d.ts +52 -0
- package/node_modules/@codor/cli/dist/up.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/up.js +178 -0
- package/node_modules/@codor/cli/dist/up.js.map +1 -0
- package/node_modules/@codor/cli/package.json +24 -0
- package/node_modules/@codor/cli/packaging/systemd/codor.service +21 -0
- package/node_modules/@codor/cli/runtime/web/assets/LandingPage-BOmG5kSw.js +31 -0
- package/node_modules/@codor/cli/runtime/web/assets/LedgerPage-CecuTS8-.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/NoChannels-CYiBXGQd.js +11 -0
- package/node_modules/@codor/cli/runtime/web/assets/PairingCodeInput-Cr6XDWx-.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/PairingPage-CQBWNA2L.js +6 -0
- package/node_modules/@codor/cli/runtime/web/assets/SettingsPage-DGLbSXE7.js +16 -0
- package/node_modules/@codor/cli/runtime/web/assets/StartupUnavailable-DdQBHip_.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/arrow-left-BOpjMuRi.js +6 -0
- package/node_modules/@codor/cli/runtime/web/assets/browser-BWQYNOsD.js +6 -0
- package/node_modules/@codor/cli/runtime/web/assets/graph-toBZHlC0.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/index-Cxi54Xim.js +408 -0
- package/node_modules/@codor/cli/runtime/web/assets/index-DjwJakJC.css +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/qr-DpynCwP9.js +8 -0
- package/node_modules/@codor/cli/runtime/web/assets/react-C8w-UNLI.js +32 -0
- package/node_modules/@codor/cli/runtime/web/assets/sodium-DtsvMzX9.js +1 -0
- package/node_modules/@codor/cli/runtime/web/codor-192.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-512.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-apple-touch-180.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-favicon.svg +86 -0
- package/node_modules/@codor/cli/runtime/web/codor-mark.svg +86 -0
- package/node_modules/@codor/cli/runtime/web/codor-maskable-512.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-og.png +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/BitcountPropSingle-latin.woff2 +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/Geist-Variable.woff2 +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/GeistMono-Variable.woff2 +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/OFL.txt +93 -0
- package/node_modules/@codor/cli/runtime/web/index.html +53 -0
- package/node_modules/@codor/cli/runtime/web/manifest.webmanifest +1 -0
- package/node_modules/@codor/cli/runtime/web/sw.js +2 -0
- package/node_modules/@codor/protocol/dist/adapter.d.ts +118 -0
- package/node_modules/@codor/protocol/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/adapter.js +15 -0
- package/node_modules/@codor/protocol/dist/adapter.js.map +1 -0
- package/node_modules/@codor/protocol/dist/body.d.ts +14 -0
- package/node_modules/@codor/protocol/dist/body.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/body.js +50 -0
- package/node_modules/@codor/protocol/dist/body.js.map +1 -0
- package/node_modules/@codor/protocol/dist/changelog.d.ts +30 -0
- package/node_modules/@codor/protocol/dist/changelog.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/changelog.js +19 -0
- package/node_modules/@codor/protocol/dist/changelog.js.map +1 -0
- package/node_modules/@codor/protocol/dist/default-recipient.d.ts +8 -0
- package/node_modules/@codor/protocol/dist/default-recipient.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/default-recipient.js +19 -0
- package/node_modules/@codor/protocol/dist/default-recipient.js.map +1 -0
- package/node_modules/@codor/protocol/dist/delivery.d.ts +42 -0
- package/node_modules/@codor/protocol/dist/delivery.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/delivery.js +44 -0
- package/node_modules/@codor/protocol/dist/delivery.js.map +1 -0
- package/node_modules/@codor/protocol/dist/events.d.ts +284 -0
- package/node_modules/@codor/protocol/dist/events.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/events.js +150 -0
- package/node_modules/@codor/protocol/dist/events.js.map +1 -0
- package/node_modules/@codor/protocol/dist/ids.d.ts +19 -0
- package/node_modules/@codor/protocol/dist/ids.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/ids.js +27 -0
- package/node_modules/@codor/protocol/dist/ids.js.map +1 -0
- package/node_modules/@codor/protocol/dist/index.d.ts +14 -0
- package/node_modules/@codor/protocol/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/index.js +14 -0
- package/node_modules/@codor/protocol/dist/index.js.map +1 -0
- package/node_modules/@codor/protocol/dist/interaction.d.ts +45 -0
- package/node_modules/@codor/protocol/dist/interaction.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/interaction.js +33 -0
- package/node_modules/@codor/protocol/dist/interaction.js.map +1 -0
- package/node_modules/@codor/protocol/dist/member.d.ts +146 -0
- package/node_modules/@codor/protocol/dist/member.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/member.js +169 -0
- package/node_modules/@codor/protocol/dist/member.js.map +1 -0
- package/node_modules/@codor/protocol/dist/message.d.ts +166 -0
- package/node_modules/@codor/protocol/dist/message.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/message.js +95 -0
- package/node_modules/@codor/protocol/dist/message.js.map +1 -0
- package/node_modules/@codor/protocol/dist/observability.d.ts +80 -0
- package/node_modules/@codor/protocol/dist/observability.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/observability.js +41 -0
- package/node_modules/@codor/protocol/dist/observability.js.map +1 -0
- package/node_modules/@codor/protocol/dist/room.d.ts +430 -0
- package/node_modules/@codor/protocol/dist/room.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/room.js +142 -0
- package/node_modules/@codor/protocol/dist/room.js.map +1 -0
- package/node_modules/@codor/protocol/dist/ws.d.ts +1348 -0
- package/node_modules/@codor/protocol/dist/ws.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/ws.js +282 -0
- package/node_modules/@codor/protocol/dist/ws.js.map +1 -0
- package/node_modules/@codor/protocol/package.json +17 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.d.ts +14 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.js +175 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/authorization.d.ts +13 -0
- package/node_modules/@codor/switchboard/dist/authorization.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/authorization.js +96 -0
- package/node_modules/@codor/switchboard/dist/authorization.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/blobs.d.ts +17 -0
- package/node_modules/@codor/switchboard/dist/blobs.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/blobs.js +54 -0
- package/node_modules/@codor/switchboard/dist/blobs.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/collaboration.d.ts +115 -0
- package/node_modules/@codor/switchboard/dist/collaboration.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/collaboration.js +123 -0
- package/node_modules/@codor/switchboard/dist/collaboration.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/continuation.d.ts +33 -0
- package/node_modules/@codor/switchboard/dist/continuation.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/continuation.js +177 -0
- package/node_modules/@codor/switchboard/dist/continuation.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.d.ts +22 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.js +92 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.d.ts +37 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.js +132 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.d.ts +41 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.js +145 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.d.ts +53 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.js +182 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.d.ts +30 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.js +101 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/daemon.d.ts +545 -0
- package/node_modules/@codor/switchboard/dist/daemon.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/daemon.js +3907 -0
- package/node_modules/@codor/switchboard/dist/daemon.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.d.ts +103 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.js +228 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/index.d.ts +44 -0
- package/node_modules/@codor/switchboard/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/index.js +24 -0
- package/node_modules/@codor/switchboard/dist/index.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.d.ts +12 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.js +17 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.d.ts +47 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.js +175 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.d.ts +73 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.js +232 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.d.ts +14 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.js +40 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-socket.d.ts +3 -0
- package/node_modules/@codor/switchboard/dist/local-socket.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-socket.js +16 -0
- package/node_modules/@codor/switchboard/dist/local-socket.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/pricing.d.ts +32 -0
- package/node_modules/@codor/switchboard/dist/pricing.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/pricing.js +64 -0
- package/node_modules/@codor/switchboard/dist/pricing.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.d.ts +2 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.js +9 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/producer.d.ts +54 -0
- package/node_modules/@codor/switchboard/dist/push/producer.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/producer.js +168 -0
- package/node_modules/@codor/switchboard/dist/push/producer.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.d.ts +29 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.js +83 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/redact.d.ts +11 -0
- package/node_modules/@codor/switchboard/dist/redact.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/redact.js +65 -0
- package/node_modules/@codor/switchboard/dist/redact.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/residency.d.ts +110 -0
- package/node_modules/@codor/switchboard/dist/residency.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/residency.js +587 -0
- package/node_modules/@codor/switchboard/dist/residency.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/router.d.ts +120 -0
- package/node_modules/@codor/switchboard/dist/router.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/router.js +198 -0
- package/node_modules/@codor/switchboard/dist/router.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/server.d.ts +50 -0
- package/node_modules/@codor/switchboard/dist/server.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/server.js +1425 -0
- package/node_modules/@codor/switchboard/dist/server.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/store.d.ts +534 -0
- package/node_modules/@codor/switchboard/dist/store.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/store.js +2625 -0
- package/node_modules/@codor/switchboard/dist/store.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.d.ts +59 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.js +183 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.d.ts +87 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.js +316 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/working-directory.d.ts +2 -0
- package/node_modules/@codor/switchboard/dist/working-directory.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/working-directory.js +26 -0
- package/node_modules/@codor/switchboard/dist/working-directory.js.map +1 -0
- package/node_modules/@codor/switchboard/package.json +33 -0
- package/package.json +58 -0
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
import { chmodSync, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
3
|
+
import { connect as connectSocket } from 'node:net';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { Transform } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import fastifyStatic from '@fastify/static';
|
|
8
|
+
import Fastify from 'fastify';
|
|
9
|
+
import { WebSocketServer } from 'ws';
|
|
10
|
+
import { BROWSER_PROTOCOL_EPOCH, ClientFrameSchema, CreateRoomRequestSchema, } from '@codor/protocol';
|
|
11
|
+
import { assertAgentCapability, assertHumanCapability, roleAllows, } from './authorization.js';
|
|
12
|
+
import { constantTimeEqual, hashTranscript } from './crypto/challenge.js';
|
|
13
|
+
import { MAX_ATTACHMENT_BYTES } from './daemon.js';
|
|
14
|
+
import { listLocalDirectories, LocalDirectoryError } from './local-dirs.js';
|
|
15
|
+
import { isPipePath } from './local-socket.js';
|
|
16
|
+
const PAIRING_CODE_ATTEMPTS = 5;
|
|
17
|
+
const PAIRING_CODE_WINDOW_MS = 60_000;
|
|
18
|
+
function pairingCodeAttemptLimiter(now) {
|
|
19
|
+
const attempts = new WeakMap();
|
|
20
|
+
return (connection) => {
|
|
21
|
+
const cutoff = now() - PAIRING_CODE_WINDOW_MS;
|
|
22
|
+
const recent = (attempts.get(connection) ?? []).filter((timestamp) => timestamp > cutoff);
|
|
23
|
+
if (recent.length >= PAIRING_CODE_ATTEMPTS) {
|
|
24
|
+
attempts.set(connection, recent);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
recent.push(now());
|
|
28
|
+
attempts.set(connection, recent);
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function prepareSocketPath(socketPath) {
|
|
33
|
+
// harn:assume unix-socket-parent-private-before-listen ref=unix-socket-parent-precondition
|
|
34
|
+
const parent = dirname(socketPath);
|
|
35
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
36
|
+
const parentStat = lstatSync(parent);
|
|
37
|
+
if (!parentStat.isDirectory() || (parentStat.mode & 0o077) !== 0) {
|
|
38
|
+
throw new Error(`unix socket parent must be a private directory (mode 0700): ${parent}`);
|
|
39
|
+
}
|
|
40
|
+
// harn:end unix-socket-parent-private-before-listen
|
|
41
|
+
if (!existsSync(socketPath))
|
|
42
|
+
return;
|
|
43
|
+
if (!lstatSync(socketPath).isSocket()) {
|
|
44
|
+
throw new Error(`refusing to replace non-socket path ${socketPath}`);
|
|
45
|
+
}
|
|
46
|
+
await new Promise((resolve, reject) => {
|
|
47
|
+
const probe = connectSocket(socketPath);
|
|
48
|
+
probe.once('connect', () => {
|
|
49
|
+
probe.destroy();
|
|
50
|
+
reject(new Error(`unix socket already in use: ${socketPath}`));
|
|
51
|
+
});
|
|
52
|
+
probe.once('error', (error) => {
|
|
53
|
+
if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT') {
|
|
54
|
+
rmSync(socketPath, { force: true });
|
|
55
|
+
resolve();
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
reject(error);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async function listenUnix(server, socketPath) {
|
|
64
|
+
// harn:assume windows-named-pipe-shares-local-websocket-protocol ref=windows-pipe-server-listener
|
|
65
|
+
// A named pipe has no filesystem entry to prepare or chmod. Windows applies the
|
|
66
|
+
// creating user's default pipe security descriptor; tokenless local admission
|
|
67
|
+
// therefore still depends on the OS endpoint being unwritable by other profiles.
|
|
68
|
+
const pipe = isPipePath(socketPath);
|
|
69
|
+
if (!pipe)
|
|
70
|
+
await prepareSocketPath(socketPath);
|
|
71
|
+
await new Promise((resolve, reject) => {
|
|
72
|
+
server.once('error', reject);
|
|
73
|
+
server.listen(socketPath, () => {
|
|
74
|
+
server.off('error', reject);
|
|
75
|
+
if (!pipe)
|
|
76
|
+
chmodSync(socketPath, 0o600);
|
|
77
|
+
resolve();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
// harn:end windows-named-pipe-shares-local-websocket-protocol
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The API surface: one WebSocket (subscribe/post/act) + small REST (sync,
|
|
84
|
+
* blob fetch, room + member management). Everything served here has already
|
|
85
|
+
* passed the daemon's redaction projection.
|
|
86
|
+
*/
|
|
87
|
+
export async function startServer(options) {
|
|
88
|
+
const { daemon, token } = options;
|
|
89
|
+
const minimumBrowserProtocol = options.minimumBrowserProtocol ?? 0;
|
|
90
|
+
if (!Number.isInteger(minimumBrowserProtocol) || minimumBrowserProtocol < 0) {
|
|
91
|
+
throw new Error('minimumBrowserProtocol must be a non-negative integer');
|
|
92
|
+
}
|
|
93
|
+
// harn:assume server-token-required ref=token-validation
|
|
94
|
+
if (typeof token !== 'string' || token.trim() === '') {
|
|
95
|
+
throw new Error('startServer requires a non-empty authentication token');
|
|
96
|
+
}
|
|
97
|
+
const configuredPrincipals = options.principals ?? [];
|
|
98
|
+
const principalTokens = new Set();
|
|
99
|
+
for (const principal of configuredPrincipals) {
|
|
100
|
+
if (principal.token.trim() === '')
|
|
101
|
+
throw new Error('principal tokens must be non-empty');
|
|
102
|
+
if (constantTimeEqual(principal.token, token) || principalTokens.has(principal.token)) {
|
|
103
|
+
throw new Error('principal tokens must be unique');
|
|
104
|
+
}
|
|
105
|
+
principalTokens.add(principal.token);
|
|
106
|
+
}
|
|
107
|
+
// harn:end server-token-required
|
|
108
|
+
const app = Fastify();
|
|
109
|
+
// Attachment uploads arrive as raw binary bodies (any content-type). This
|
|
110
|
+
// catch-all parser hands the route the untouched request stream to pipe to
|
|
111
|
+
// disk; it only fires for non-JSON bodies, which only the upload sends.
|
|
112
|
+
app.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
|
|
113
|
+
const allowPairingCodeAttempt = pairingCodeAttemptLimiter(Date.now);
|
|
114
|
+
const browserAuthTranscript = options.crypto
|
|
115
|
+
? hashTranscript(Buffer.from(`codor-browser-session-v1\0${options.crypto.keys.identity.device_id}`, 'utf8'))
|
|
116
|
+
: undefined;
|
|
117
|
+
const principalForToken = (candidate) => {
|
|
118
|
+
if (candidate === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
if (constantTimeEqual(candidate, token))
|
|
121
|
+
return { kind: 'owner' };
|
|
122
|
+
const configured = configuredPrincipals.find((principal) => constantTimeEqual(candidate, principal.token));
|
|
123
|
+
if (configured)
|
|
124
|
+
return { kind: 'human', memberId: configured.member_id };
|
|
125
|
+
const deviceId = options.crypto?.browserSessions.authenticate(candidate);
|
|
126
|
+
if (deviceId)
|
|
127
|
+
return { kind: 'browser', deviceId };
|
|
128
|
+
// harn:assume agent-member-credentials-stay-secret ref=agent-principal-resolution
|
|
129
|
+
const agent = daemon.authenticateAgentToken(candidate);
|
|
130
|
+
return agent
|
|
131
|
+
? { kind: 'agent', memberId: agent.member.id, room: agent.room }
|
|
132
|
+
: undefined;
|
|
133
|
+
// harn:end agent-member-credentials-stay-secret
|
|
134
|
+
};
|
|
135
|
+
const authed = (req, reply) => {
|
|
136
|
+
const header = req.headers.authorization;
|
|
137
|
+
const query = req.query.token;
|
|
138
|
+
const bearer = typeof header === 'string' && header.startsWith('Bearer ')
|
|
139
|
+
? header.slice('Bearer '.length)
|
|
140
|
+
: undefined;
|
|
141
|
+
const principal = principalForToken(bearer) ?? principalForToken(query);
|
|
142
|
+
if (principal)
|
|
143
|
+
return principal;
|
|
144
|
+
void reply.code(401).send({ error: 'unauthorized' });
|
|
145
|
+
return undefined;
|
|
146
|
+
};
|
|
147
|
+
const memberForRoom = (principal, room) => {
|
|
148
|
+
if (principal.kind === 'owner' || principal.kind === 'browser')
|
|
149
|
+
return daemon.ownerOf(room);
|
|
150
|
+
if (principal.kind === 'agent' && principal.room !== room) {
|
|
151
|
+
throw new Error(`forbidden: agent credential belongs to room ${principal.room}`);
|
|
152
|
+
}
|
|
153
|
+
const member = daemon.store.getMember(room, principal.memberId);
|
|
154
|
+
if (member?.kind !== principal.kind) {
|
|
155
|
+
throw new Error(`principal is not a ${principal.kind} member of this room`);
|
|
156
|
+
}
|
|
157
|
+
return member;
|
|
158
|
+
};
|
|
159
|
+
const memberForGlobal = (principal) => {
|
|
160
|
+
if (principal.kind === 'owner' || principal.kind === 'browser')
|
|
161
|
+
return undefined;
|
|
162
|
+
if (principal.kind === 'agent')
|
|
163
|
+
throw new Error('forbidden: agent principal is room-scoped');
|
|
164
|
+
for (const room of daemon.store.listRooms()) {
|
|
165
|
+
const member = daemon.store.getMember(room.id, principal.memberId);
|
|
166
|
+
if (member?.kind === 'human')
|
|
167
|
+
return member;
|
|
168
|
+
}
|
|
169
|
+
throw new Error('principal is not a human member');
|
|
170
|
+
};
|
|
171
|
+
// harn:assume agent-network-authority-is-narrow ref=agent-room-authorization
|
|
172
|
+
const assertRoomCapability = (principal, room, capability) => {
|
|
173
|
+
if (!daemon.store.getRoom(room))
|
|
174
|
+
throw new Error(`no such room ${room}`);
|
|
175
|
+
const member = memberForRoom(principal, room);
|
|
176
|
+
if (principal.kind === 'agent')
|
|
177
|
+
assertAgentCapability(member, capability);
|
|
178
|
+
else
|
|
179
|
+
assertHumanCapability(member, capability);
|
|
180
|
+
return member;
|
|
181
|
+
};
|
|
182
|
+
const authorizeRoom = (principal, room, capability, reply) => {
|
|
183
|
+
if (!daemon.store.getRoom(room)) {
|
|
184
|
+
if (reply)
|
|
185
|
+
void reply.code(404).send({ error: `no such room ${room}` });
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
return assertRoomCapability(principal, room, capability);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (reply)
|
|
193
|
+
void reply.code(403).send({ error: String(error) });
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const authorizeGlobal = (principal, capability, reply) => {
|
|
198
|
+
try {
|
|
199
|
+
if (principal.kind === 'agent') {
|
|
200
|
+
throw new Error(`forbidden: agent cannot use global ${capability}`);
|
|
201
|
+
}
|
|
202
|
+
const member = memberForGlobal(principal);
|
|
203
|
+
if (member)
|
|
204
|
+
assertHumanCapability(member, capability);
|
|
205
|
+
else if (!roleAllows('owner', capability))
|
|
206
|
+
throw new Error(`forbidden: owner cannot ${capability}`);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (reply)
|
|
211
|
+
void reply.code(403).send({ error: String(error) });
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const roomsFor = (principal) => daemon.store.listRooms().filter((room) => {
|
|
216
|
+
if (principal.kind === 'owner' || principal.kind === 'browser')
|
|
217
|
+
return true;
|
|
218
|
+
if (principal.kind === 'agent')
|
|
219
|
+
return room.id === principal.room;
|
|
220
|
+
return daemon.store.getMember(room.id, principal.memberId)?.kind === 'human';
|
|
221
|
+
});
|
|
222
|
+
// harn:end agent-network-authority-is-narrow
|
|
223
|
+
// harn:assume browser-protocol-epoch-blocks-only-stale-browser-ui ref=browser-protocol-compatibility-rest
|
|
224
|
+
const observedBrowserProtocols = new Set();
|
|
225
|
+
const observeBrowserProtocol = (protocol) => {
|
|
226
|
+
if (protocol === undefined || !Number.isInteger(protocol) || protocol < 1)
|
|
227
|
+
return;
|
|
228
|
+
options.onBrowserProtocolObserved?.(protocol);
|
|
229
|
+
if (observedBrowserProtocols.has(protocol))
|
|
230
|
+
return;
|
|
231
|
+
observedBrowserProtocols.add(protocol);
|
|
232
|
+
// This is the deploy precondition's live evidence. It contains no token,
|
|
233
|
+
// device id, room, or other browser identity.
|
|
234
|
+
if (options.onBrowserProtocolObserved === undefined) {
|
|
235
|
+
console.info(`[codor] observed browser protocol ${String(protocol)}`);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const browserCompatibility = (protocol) => ({
|
|
239
|
+
browser_protocol: BROWSER_PROTOCOL_EPOCH,
|
|
240
|
+
minimum_browser_protocol: minimumBrowserProtocol,
|
|
241
|
+
compatible: minimumBrowserProtocol === 0
|
|
242
|
+
|| (protocol !== undefined && protocol >= minimumBrowserProtocol),
|
|
243
|
+
});
|
|
244
|
+
app.get('/api/client-compatibility', (req, reply) => {
|
|
245
|
+
const principal = authed(req, reply);
|
|
246
|
+
if (!principal)
|
|
247
|
+
return;
|
|
248
|
+
const query = req.query;
|
|
249
|
+
const parsed = query.browser_protocol === undefined
|
|
250
|
+
? undefined
|
|
251
|
+
: Number(query.browser_protocol);
|
|
252
|
+
const protocol = Number.isInteger(parsed) && (parsed ?? 0) > 0 ? parsed : undefined;
|
|
253
|
+
const browserClient = principal.kind === 'browser'
|
|
254
|
+
|| (principal.kind !== 'agent' && query.client_kind === 'browser');
|
|
255
|
+
if (browserClient)
|
|
256
|
+
observeBrowserProtocol(protocol);
|
|
257
|
+
const compatibility = browserClient
|
|
258
|
+
? browserCompatibility(protocol)
|
|
259
|
+
: { ...browserCompatibility(protocol), compatible: true };
|
|
260
|
+
return reply
|
|
261
|
+
.header('cache-control', 'no-store')
|
|
262
|
+
.code(browserClient && !compatibility.compatible ? 426 : 200)
|
|
263
|
+
.send(compatibility);
|
|
264
|
+
});
|
|
265
|
+
// harn:end browser-protocol-epoch-blocks-only-stale-browser-ui
|
|
266
|
+
// harn:assume paired-browser-challenge-session ref=browser-device-session-rest
|
|
267
|
+
app.post('/api/auth/challenge', (req, reply) => {
|
|
268
|
+
if (!options.crypto || !browserAuthTranscript) {
|
|
269
|
+
return reply.code(404).send({ error: 'device authentication is not configured' });
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const body = req.body;
|
|
273
|
+
if (typeof body.device_id !== 'string' || body.device_id === '') {
|
|
274
|
+
throw new Error('device id is required');
|
|
275
|
+
}
|
|
276
|
+
const challenge = options.crypto.browserChallenges.issue(body.device_id, browserAuthTranscript);
|
|
277
|
+
return reply.header('cache-control', 'no-store').send({
|
|
278
|
+
challenge,
|
|
279
|
+
switchboard_device_id: options.crypto.keys.identity.device_id,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return reply.code(401).send({ error: 'device authentication failed' });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
app.post('/api/auth/session', (req, reply) => {
|
|
287
|
+
if (!options.crypto) {
|
|
288
|
+
return reply.code(404).send({ error: 'device authentication is not configured' });
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const body = req.body;
|
|
292
|
+
if (typeof body.challenge_id !== 'string' || typeof body.signature !== 'string') {
|
|
293
|
+
throw new Error('challenge response is required');
|
|
294
|
+
}
|
|
295
|
+
const peer = options.crypto.browserChallenges.verify(body.challenge_id, body.signature);
|
|
296
|
+
if (peer.kind !== 'device')
|
|
297
|
+
throw new Error('only paired devices may open browser sessions');
|
|
298
|
+
return reply.header('cache-control', 'no-store').send(options.crypto.browserSessions.issue(peer.device_id));
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return reply.code(401).send({ error: 'device authentication failed' });
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
// harn:end paired-browser-challenge-session
|
|
305
|
+
// harn:assume unpaired-browser-always-has-enrollment-path ref=trusted-pairing-status-rest
|
|
306
|
+
app.get('/api/pairing/status', (req, reply) => {
|
|
307
|
+
const tailnetLogin = req.headers['tailscale-user-login'];
|
|
308
|
+
return reply.header('cache-control', 'no-store').send({
|
|
309
|
+
trusted_enrollment: options.trustTailscaleServe === true &&
|
|
310
|
+
typeof tailnetLogin === 'string' &&
|
|
311
|
+
tailnetLogin.trim() !== '',
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
// harn:end unpaired-browser-always-has-enrollment-path
|
|
315
|
+
// harn:assume pairing-code-exchange-uniform-and-rate-limited ref=pairing-code-exchange-rest
|
|
316
|
+
app.post('/api/pairing/exchange', (req, reply) => {
|
|
317
|
+
const notFound = () => reply.header('cache-control', 'no-store')
|
|
318
|
+
.code(404).send({ error: 'pairing code not found' });
|
|
319
|
+
if (!options.crypto || !allowPairingCodeAttempt(req.raw.socket))
|
|
320
|
+
return notFound();
|
|
321
|
+
try {
|
|
322
|
+
const body = req.body;
|
|
323
|
+
if (typeof body.code !== 'string')
|
|
324
|
+
return notFound();
|
|
325
|
+
return reply.header('cache-control', 'no-store').send(options.crypto.pairing.exchange(body.code));
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return notFound();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
// harn:end pairing-code-exchange-uniform-and-rate-limited
|
|
332
|
+
app.post('/api/pairing/complete', (req, reply) => {
|
|
333
|
+
if (!options.crypto)
|
|
334
|
+
return reply.code(404).send({ error: 'pairing is not configured' });
|
|
335
|
+
const authorization = req.headers.authorization;
|
|
336
|
+
const pairingToken = authorization?.startsWith('Pairing ')
|
|
337
|
+
? authorization.slice('Pairing '.length)
|
|
338
|
+
: undefined;
|
|
339
|
+
try {
|
|
340
|
+
// harn:assume tailnet-auto-pairing-explicit-trust ref=trusted-tailnet-pairing-rest
|
|
341
|
+
const tailnetLogin = req.headers['tailscale-user-login'];
|
|
342
|
+
if (!pairingToken) {
|
|
343
|
+
if (options.trustTailscaleServe !== true ||
|
|
344
|
+
typeof tailnetLogin !== 'string' ||
|
|
345
|
+
tailnetLogin.trim() === '') {
|
|
346
|
+
return reply.code(401).send({ error: 'pairing token required' });
|
|
347
|
+
}
|
|
348
|
+
return reply.header('cache-control', 'no-store').send(options.crypto.pairing.completeTrusted(req.body, tailnetLogin.trim()));
|
|
349
|
+
}
|
|
350
|
+
// harn:end tailnet-auto-pairing-explicit-trust
|
|
351
|
+
return reply.header('cache-control', 'no-store').send(options.crypto.pairing.complete(pairingToken, req.body));
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
return reply.code(401).send({ error: String(error) });
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
app.post('/api/pairing/offers', (req, reply) => {
|
|
358
|
+
const principal = authed(req, reply);
|
|
359
|
+
if (!principal || !authorizeGlobal(principal, 'manage_devices', reply))
|
|
360
|
+
return;
|
|
361
|
+
if (!options.crypto)
|
|
362
|
+
return reply.code(404).send({ error: 'pairing is not configured' });
|
|
363
|
+
try {
|
|
364
|
+
const { endpoint } = req.body;
|
|
365
|
+
return reply.header('cache-control', 'no-store').send(options.crypto.pairing.issue(endpoint));
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
return reply.code(400).send({ error: String(error) });
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
app.post('/api/devices/:deviceId/push-subscription', (req, reply) => {
|
|
372
|
+
const principal = authed(req, reply);
|
|
373
|
+
if (!principal || !authorizeGlobal(principal, 'manage_devices', reply))
|
|
374
|
+
return;
|
|
375
|
+
if (!options.pushSubscriptions) {
|
|
376
|
+
return reply.code(404).send({ error: 'push subscriptions are not configured' });
|
|
377
|
+
}
|
|
378
|
+
const { deviceId } = req.params;
|
|
379
|
+
try {
|
|
380
|
+
const body = req.body;
|
|
381
|
+
return reply.code(201).send({
|
|
382
|
+
subscription: options.pushSubscriptions.register(deviceId, body.subscription),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
return reply.code(400).send({ error: String(error) });
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
app.delete('/api/devices/:deviceId/push-subscription', (req, reply) => {
|
|
390
|
+
const principal = authed(req, reply);
|
|
391
|
+
if (!principal || !authorizeGlobal(principal, 'manage_devices', reply))
|
|
392
|
+
return;
|
|
393
|
+
if (!options.pushSubscriptions) {
|
|
394
|
+
return reply.code(404).send({ error: 'push subscriptions are not configured' });
|
|
395
|
+
}
|
|
396
|
+
const { deviceId } = req.params;
|
|
397
|
+
options.pushSubscriptions.remove(deviceId);
|
|
398
|
+
return reply.code(204).send();
|
|
399
|
+
});
|
|
400
|
+
app.get('/api/push/config', (req, reply) => {
|
|
401
|
+
const principal = authed(req, reply);
|
|
402
|
+
if (!principal || !authorizeGlobal(principal, 'read', reply))
|
|
403
|
+
return;
|
|
404
|
+
return reply.send({
|
|
405
|
+
enabled: Boolean(options.pushSubscriptions && options.pushVapidPublicKey && options.pushRelayEnabled),
|
|
406
|
+
...(options.pushVapidPublicKey && { vapid_public_key: options.pushVapidPublicKey }),
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
// harn:assume unpair-purges-all-browser-state ref=device-revoke-rest
|
|
410
|
+
app.get('/api/devices', (req, reply) => {
|
|
411
|
+
const principal = authed(req, reply);
|
|
412
|
+
if (!principal || !authorizeGlobal(principal, 'manage_devices', reply))
|
|
413
|
+
return;
|
|
414
|
+
if (!options.crypto)
|
|
415
|
+
return reply.code(404).send({ error: 'pairing is not configured' });
|
|
416
|
+
return reply.send({
|
|
417
|
+
devices: options.crypto.keys.listPeers()
|
|
418
|
+
.filter((peer) => peer.kind === 'device')
|
|
419
|
+
.map((peer) => ({
|
|
420
|
+
device_id: peer.device_id,
|
|
421
|
+
label: peer.label,
|
|
422
|
+
paired_at: peer.paired_at,
|
|
423
|
+
push_enabled: options.pushSubscriptions?.get(peer.device_id) !== undefined,
|
|
424
|
+
})),
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
app.delete('/api/devices/:deviceId', (req, reply) => {
|
|
428
|
+
const principal = authed(req, reply);
|
|
429
|
+
if (!principal || !authorizeGlobal(principal, 'manage_devices', reply))
|
|
430
|
+
return;
|
|
431
|
+
if (!options.crypto)
|
|
432
|
+
return reply.code(404).send({ error: 'pairing is not configured' });
|
|
433
|
+
const { deviceId } = req.params;
|
|
434
|
+
const peer = options.crypto.keys.getPeer(deviceId);
|
|
435
|
+
if (!peer || peer.kind !== 'device')
|
|
436
|
+
return reply.code(404).send({ error: 'no such device' });
|
|
437
|
+
options.pushSubscriptions?.remove(deviceId);
|
|
438
|
+
const revoked = options.crypto.revokePeer(deviceId);
|
|
439
|
+
return reply.send({ revoked });
|
|
440
|
+
});
|
|
441
|
+
// harn:end unpair-purges-all-browser-state
|
|
442
|
+
app.get('/api/rooms', (req, reply) => {
|
|
443
|
+
const principal = authed(req, reply);
|
|
444
|
+
if (!principal)
|
|
445
|
+
return;
|
|
446
|
+
if (principal.kind === 'agent') {
|
|
447
|
+
if (!authorizeRoom(principal, principal.room, 'read', reply))
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
else if (!authorizeGlobal(principal, 'read', reply))
|
|
451
|
+
return;
|
|
452
|
+
void reply.send({ rooms: roomsFor(principal) });
|
|
453
|
+
});
|
|
454
|
+
// harn:assume durable-room-summaries-stream-and-fallback ref=durable-room-summary
|
|
455
|
+
// Durable summaries share the exact RoomSupport builder used by streaming
|
|
456
|
+
// subscriptions. The caller-cursor mode stays byte-compatible for clients
|
|
457
|
+
// that have not opted into room-addressed support yet.
|
|
458
|
+
app.get('/api/rooms/summary', (req, reply) => {
|
|
459
|
+
const principal = authed(req, reply);
|
|
460
|
+
if (!principal)
|
|
461
|
+
return;
|
|
462
|
+
if (principal.kind === 'agent') {
|
|
463
|
+
if (!authorizeRoom(principal, principal.room, 'read', reply))
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
else if (!authorizeGlobal(principal, 'read', reply))
|
|
467
|
+
return;
|
|
468
|
+
const query = req.query;
|
|
469
|
+
if (query.read_state !== undefined && query.read_state !== 'durable') {
|
|
470
|
+
return reply.code(400).send({ error: `unsupported read_state: ${query.read_state}` });
|
|
471
|
+
}
|
|
472
|
+
if (query.read_state === 'durable') {
|
|
473
|
+
if (principal.kind === 'agent') {
|
|
474
|
+
return reply.code(400).send({ error: 'durable room summaries require a human principal' });
|
|
475
|
+
}
|
|
476
|
+
return reply.send({
|
|
477
|
+
rooms: roomsFor(principal).map((room) => {
|
|
478
|
+
const actor = memberForRoom(principal, room.id);
|
|
479
|
+
return daemon.roomSupport(room.id, actor.id).summary;
|
|
480
|
+
}),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
const cursors = new Map();
|
|
484
|
+
const rawCursors = query.cursors;
|
|
485
|
+
if (rawCursors !== undefined && rawCursors !== '') {
|
|
486
|
+
for (const entry of rawCursors.split(',')) {
|
|
487
|
+
const split = entry.lastIndexOf(':');
|
|
488
|
+
const id = split === -1 ? Number.NaN : Number(entry.slice(split + 1));
|
|
489
|
+
if (split <= 0 || !Number.isInteger(id) || id < 0) {
|
|
490
|
+
return reply.code(400).send({ error: `malformed cursor: ${entry}` });
|
|
491
|
+
}
|
|
492
|
+
cursors.set(entry.slice(0, split), id);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
void reply.send({
|
|
496
|
+
rooms: roomsFor(principal).map((room) => {
|
|
497
|
+
const members = daemon.store.listMembers(room.id);
|
|
498
|
+
const latest = daemon.store.latestMessage(room.id, { ignoreAcks: true });
|
|
499
|
+
const latestRun = daemon.store.listRunMessages(room.id, { limit: 1 })[0];
|
|
500
|
+
const author = latest ? daemon.store.getMember(room.id, latest.author) : undefined;
|
|
501
|
+
const runAuthor = latestRun
|
|
502
|
+
? daemon.store.getMember(room.id, latestRun.author)
|
|
503
|
+
: undefined;
|
|
504
|
+
const cursor = cursors.get(room.id);
|
|
505
|
+
const working = members.some((m) => m.kind === 'agent' && (m.state === 'running' || m.state === 'queued'));
|
|
506
|
+
return {
|
|
507
|
+
id: room.id,
|
|
508
|
+
name: room.name,
|
|
509
|
+
created_ts: room.created_ts,
|
|
510
|
+
color: room.config.color,
|
|
511
|
+
working,
|
|
512
|
+
attention: !working
|
|
513
|
+
&& latestRun?.run?.status === 'failed'
|
|
514
|
+
&& runAuthor?.kind === 'agent'
|
|
515
|
+
&& runAuthor.state === 'dead',
|
|
516
|
+
...(latest !== undefined && {
|
|
517
|
+
latest: {
|
|
518
|
+
id: latest.id,
|
|
519
|
+
ts: latest.ts,
|
|
520
|
+
kind: latest.kind,
|
|
521
|
+
author_handle: author?.handle ?? '',
|
|
522
|
+
author_kind: author?.kind ?? 'human',
|
|
523
|
+
preview: (latest.body.split('\n', 1)[0] ?? '').slice(0, 140),
|
|
524
|
+
},
|
|
525
|
+
}),
|
|
526
|
+
unread: cursor === undefined ? 0 : daemon.store.countMessagesAfter(room.id, cursor),
|
|
527
|
+
};
|
|
528
|
+
}),
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
// harn:end durable-room-summaries-stream-and-fallback
|
|
532
|
+
app.get('/api/adapters', (req, reply) => {
|
|
533
|
+
const principal = authed(req, reply);
|
|
534
|
+
if (!principal || !authorizeGlobal(principal, 'read', reply))
|
|
535
|
+
return;
|
|
536
|
+
// harn:assume model-catalogs-reach-a-browser-that-arrives-early ref=adapter-discovery-pending-rest
|
|
537
|
+
void reply.send({
|
|
538
|
+
adapters: daemon.registeredAdapters(),
|
|
539
|
+
discovering: daemon.modelDiscoveryPending(),
|
|
540
|
+
});
|
|
541
|
+
// harn:end model-catalogs-reach-a-browser-that-arrives-early
|
|
542
|
+
});
|
|
543
|
+
// harn:assume local-directory-listing-home-contained ref=local-dirs-rest-boundary
|
|
544
|
+
app.get('/api/local/dirs', (req, reply) => {
|
|
545
|
+
const principal = authed(req, reply);
|
|
546
|
+
if (!principal || !authorizeGlobal(principal, 'manage_agents', reply))
|
|
547
|
+
return;
|
|
548
|
+
const query = req.query;
|
|
549
|
+
try {
|
|
550
|
+
return reply.send(listLocalDirectories(query.path, query.hidden === '1', options.homeDir));
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
if (error instanceof LocalDirectoryError) {
|
|
554
|
+
return reply.code(error.status).send({ error: error.message });
|
|
555
|
+
}
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
// harn:end local-directory-listing-home-contained
|
|
560
|
+
// harn:assume channel-creation-derived-and-seeded ref=create-room-rest-contract
|
|
561
|
+
app.post('/api/rooms', (req, reply) => {
|
|
562
|
+
const principal = authed(req, reply);
|
|
563
|
+
if (!principal || !authorizeGlobal(principal, 'manage_rooms', reply))
|
|
564
|
+
return;
|
|
565
|
+
try {
|
|
566
|
+
const body = CreateRoomRequestSchema.parse(req.body);
|
|
567
|
+
const created = daemon.createRoom(body);
|
|
568
|
+
options.crypto?.roomKeys.ensureRoom(created.room.id);
|
|
569
|
+
// harn:assume browser-created-channel-delivers-its-room-key ref=browser-room-key-response
|
|
570
|
+
// Pairing can only seal rooms that already exist. A device that creates a
|
|
571
|
+
// later room therefore needs that one additive envelope before it opens
|
|
572
|
+
// the room; operator tokens have no device key and keep the old response.
|
|
573
|
+
const roomKey = principal.kind === 'browser'
|
|
574
|
+
? options.crypto?.roomKeys.sealedFor(principal.deviceId)
|
|
575
|
+
.find((candidate) => candidate.room === created.room.id)
|
|
576
|
+
: undefined;
|
|
577
|
+
return reply.send({ ...created, ...(roomKey !== undefined && { room_key: roomKey }) });
|
|
578
|
+
// harn:end browser-created-channel-delivers-its-room-key
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
return reply.code(400).send({ error: String(error) });
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
// harn:end channel-creation-derived-and-seeded
|
|
585
|
+
app.get('/api/rooms/:room/sync', (req, reply) => {
|
|
586
|
+
const principal = authed(req, reply);
|
|
587
|
+
if (!principal)
|
|
588
|
+
return;
|
|
589
|
+
const { room } = req.params;
|
|
590
|
+
if (!authorizeRoom(principal, room, 'read', reply))
|
|
591
|
+
return;
|
|
592
|
+
const sinceSeq = Number(req.query.since_seq ?? 0);
|
|
593
|
+
try {
|
|
594
|
+
void reply.send(daemon.sync(room, sinceSeq));
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
void reply.code(404).send({ error: `no such room ${room}` });
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
// harn:assume member-status-is-bounded-and-identity-safe ref=status-rest-boundary
|
|
601
|
+
app.get('/api/rooms/:room/members/:memberId/status', (req, reply) => {
|
|
602
|
+
const principal = authed(req, reply);
|
|
603
|
+
if (!principal)
|
|
604
|
+
return;
|
|
605
|
+
const { room, memberId } = req.params;
|
|
606
|
+
if (!authorizeRoom(principal, room, principal.kind === 'agent' ? 'member_status' : 'read', reply))
|
|
607
|
+
return;
|
|
608
|
+
try {
|
|
609
|
+
return reply.send(daemon.memberStatus(room, memberId));
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
return reply.code(404).send({ error: String(error) });
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
// harn:end member-status-is-bounded-and-identity-safe
|
|
616
|
+
// harn:assume permalink-ids-stable ref=message-history-rest
|
|
617
|
+
const positiveInteger = (value, fallback, maximum, label) => {
|
|
618
|
+
if (value === undefined)
|
|
619
|
+
return fallback;
|
|
620
|
+
const parsed = Number(value);
|
|
621
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) {
|
|
622
|
+
throw new Error(`${label} must be an integer from 1 to ${String(maximum)}`);
|
|
623
|
+
}
|
|
624
|
+
return parsed;
|
|
625
|
+
};
|
|
626
|
+
app.get('/api/rooms/:room/messages', (req, reply) => {
|
|
627
|
+
const principal = authed(req, reply);
|
|
628
|
+
if (!principal)
|
|
629
|
+
return;
|
|
630
|
+
const { room } = req.params;
|
|
631
|
+
if (!authorizeRoom(principal, room, 'read', reply))
|
|
632
|
+
return;
|
|
633
|
+
if (!daemon.store.getRoom(room))
|
|
634
|
+
return reply.code(404).send({ error: `no such room ${room}` });
|
|
635
|
+
try {
|
|
636
|
+
const query = req.query;
|
|
637
|
+
// Pins are few; the strip hydrates the whole set at once, ignoring paging.
|
|
638
|
+
if (query.pinned === '1') {
|
|
639
|
+
const pinned = daemon.store.listPinnedMessages(room);
|
|
640
|
+
return reply.send({ messages: daemon.project(room, pinned), has_more: false });
|
|
641
|
+
}
|
|
642
|
+
const limit = positiveInteger(query.limit, 50, 100, 'limit');
|
|
643
|
+
const before = positiveInteger(query.before, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 'before');
|
|
644
|
+
const page = daemon.store.listMessages(room, { before, limit: limit + 1 });
|
|
645
|
+
const hasMore = page.length > limit;
|
|
646
|
+
const messages = hasMore ? page.slice(-limit) : page;
|
|
647
|
+
return reply.send({ messages: daemon.project(room, messages), has_more: hasMore });
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
return reply.code(400).send({ error: String(error) });
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
app.get('/api/rooms/:room/search', (req, reply) => {
|
|
654
|
+
const principal = authed(req, reply);
|
|
655
|
+
if (!principal)
|
|
656
|
+
return;
|
|
657
|
+
const { room } = req.params;
|
|
658
|
+
if (!authorizeRoom(principal, room, principal.kind === 'agent' ? 'search' : 'read', reply))
|
|
659
|
+
return;
|
|
660
|
+
if (!daemon.store.getRoom(room))
|
|
661
|
+
return reply.code(404).send({ error: `no such room ${room}` });
|
|
662
|
+
try {
|
|
663
|
+
const query = req.query;
|
|
664
|
+
const needle = query.q?.trim();
|
|
665
|
+
if (!needle || needle.length > 200)
|
|
666
|
+
throw new Error('q must contain 1 to 200 characters');
|
|
667
|
+
if (query.include !== undefined && query.include !== 'runs') {
|
|
668
|
+
throw new Error('include must be runs when provided');
|
|
669
|
+
}
|
|
670
|
+
const includeRuns = query.include === 'runs';
|
|
671
|
+
const limit = positiveInteger(query.limit, 50, includeRuns ? 200 : 100, 'limit');
|
|
672
|
+
const messages = daemon.store.searchMessages(room, needle, { limit });
|
|
673
|
+
// harn:assume run-evidence-search-is-bounded-and-redacted ref=run-search-rest-boundary
|
|
674
|
+
return reply.send({
|
|
675
|
+
messages: daemon.project(room, messages),
|
|
676
|
+
...(includeRuns && { runs: daemon.searchRunEvidence(room, needle, limit) }),
|
|
677
|
+
});
|
|
678
|
+
// harn:end run-evidence-search-is-bounded-and-redacted
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
return reply.code(400).send({ error: String(error) });
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
// harn:end permalink-ids-stable
|
|
685
|
+
app.get('/api/rooms/:room/runs/:msgId', (req, reply) => {
|
|
686
|
+
const principal = authed(req, reply);
|
|
687
|
+
if (!principal)
|
|
688
|
+
return;
|
|
689
|
+
const { room, msgId } = req.params;
|
|
690
|
+
if (!authorizeRoom(principal, room, 'read', reply))
|
|
691
|
+
return;
|
|
692
|
+
void reply.send({ events: daemon.readRunBlob(room, Number(msgId)) });
|
|
693
|
+
});
|
|
694
|
+
// harn:assume room-git-state-read-only-from-known-cwds ref=room-git-state-contract
|
|
695
|
+
// The diff explorer's live git working-state. `cwd` is a SELECTOR into the
|
|
696
|
+
// room's known directories, never a free path — the daemon refuses anything
|
|
697
|
+
// outside that set before running any (read-only) git command.
|
|
698
|
+
app.get('/api/rooms/:room/git-diff', async (req, reply) => {
|
|
699
|
+
const principal = authed(req, reply);
|
|
700
|
+
if (!principal)
|
|
701
|
+
return;
|
|
702
|
+
const { room } = req.params;
|
|
703
|
+
if (!authorizeRoom(principal, room, 'read', reply))
|
|
704
|
+
return;
|
|
705
|
+
if (!daemon.store.getRoom(room))
|
|
706
|
+
return reply.code(404).send({ error: `no such room ${room}` });
|
|
707
|
+
try {
|
|
708
|
+
const { cwd } = req.query;
|
|
709
|
+
return reply.send(await daemon.gitWorkingState(room, cwd));
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
return reply.code(400).send({ error: String(error) });
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
// harn:end room-git-state-read-only-from-known-cwds
|
|
716
|
+
// harn:assume attachments-are-capped-files-served-inert ref=attachment-contract
|
|
717
|
+
// Upload streams one file to disk under the room's attachment dir keyed by a
|
|
718
|
+
// server-issued id (the client filename is metadata only), refusing >25 MB.
|
|
719
|
+
app.post('/api/rooms/:room/attachments', { bodyLimit: MAX_ATTACHMENT_BYTES + 4096 }, async (req, reply) => {
|
|
720
|
+
const principal = authed(req, reply);
|
|
721
|
+
if (!principal)
|
|
722
|
+
return;
|
|
723
|
+
const { room } = req.params;
|
|
724
|
+
if (!authorizeRoom(principal, room, 'post', reply))
|
|
725
|
+
return;
|
|
726
|
+
if (!daemon.store.getRoom(room))
|
|
727
|
+
return reply.code(404).send({ error: `no such room ${room}` });
|
|
728
|
+
const declared = Number(req.headers['content-length'] ?? 0);
|
|
729
|
+
if (Number.isFinite(declared) && declared > MAX_ATTACHMENT_BYTES) {
|
|
730
|
+
return reply.code(413).send({ error: 'attachment exceeds 25 MB' });
|
|
731
|
+
}
|
|
732
|
+
const { name } = req.query;
|
|
733
|
+
const filename = (name ?? 'file').slice(0, 255) || 'file';
|
|
734
|
+
const mime = (req.headers['content-type'] ?? 'application/octet-stream').split(';')[0]?.trim()
|
|
735
|
+
|| 'application/octet-stream';
|
|
736
|
+
const id = daemon.newAttachmentId();
|
|
737
|
+
daemon.ensureAttachmentDir(room);
|
|
738
|
+
const path = daemon.attachmentPath(room, id);
|
|
739
|
+
let size = 0;
|
|
740
|
+
let tooBig = false;
|
|
741
|
+
const counter = new Transform({
|
|
742
|
+
transform(chunk, _enc, callback) {
|
|
743
|
+
size += chunk.length;
|
|
744
|
+
if (size > MAX_ATTACHMENT_BYTES) {
|
|
745
|
+
tooBig = true;
|
|
746
|
+
callback(new Error('attachment exceeds 25 MB'));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
callback(null, chunk);
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
try {
|
|
753
|
+
await pipeline(req.body, counter, createWriteStream(path));
|
|
754
|
+
}
|
|
755
|
+
catch (error) {
|
|
756
|
+
rmSync(path, { force: true });
|
|
757
|
+
return reply.code(tooBig ? 413 : 400).send({ error: String(error) });
|
|
758
|
+
}
|
|
759
|
+
const meta = { id, name: filename, mime, size };
|
|
760
|
+
daemon.recordAttachment(room, meta);
|
|
761
|
+
return reply.send(meta);
|
|
762
|
+
});
|
|
763
|
+
// Serve a stored attachment with its recorded mime and name. The id is
|
|
764
|
+
// validated (hex handle) before it ever touches a path — traversal is dead.
|
|
765
|
+
app.get('/api/rooms/:room/attachments/:id', (req, reply) => {
|
|
766
|
+
const principal = authed(req, reply);
|
|
767
|
+
if (!principal)
|
|
768
|
+
return;
|
|
769
|
+
const { room, id } = req.params;
|
|
770
|
+
if (!authorizeRoom(principal, room, 'read', reply))
|
|
771
|
+
return;
|
|
772
|
+
const meta = daemon.getAttachmentMeta(room, id);
|
|
773
|
+
if (!meta)
|
|
774
|
+
return reply.code(404).send({ error: 'no such attachment' });
|
|
775
|
+
const path = daemon.attachmentPath(room, id);
|
|
776
|
+
if (!existsSync(path))
|
|
777
|
+
return reply.code(404).send({ error: 'no such attachment' });
|
|
778
|
+
// Inertness: the mime came from the uploader, so only raster images (not
|
|
779
|
+
// svg, which scripts) render inline with it; everything else downloads as
|
|
780
|
+
// an opaque octet-stream. Uploaded content can never execute same-origin.
|
|
781
|
+
const inline = /^image\/(png|jpe?g|gif|webp|avif)$/.test(meta.mime);
|
|
782
|
+
void reply
|
|
783
|
+
.header('x-content-type-options', 'nosniff')
|
|
784
|
+
.header('content-type', inline ? meta.mime : 'application/octet-stream')
|
|
785
|
+
.header('content-disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(meta.name)}`)
|
|
786
|
+
.send(createReadStream(path));
|
|
787
|
+
});
|
|
788
|
+
// harn:end attachments-are-capped-files-served-inert
|
|
789
|
+
// harn:assume graph-derived-from-vault-links-readonly-v5 ref=ledger-graph-rest
|
|
790
|
+
app.get('/api/rooms/:room/ledger', (req, reply) => {
|
|
791
|
+
const principal = authed(req, reply);
|
|
792
|
+
if (!principal)
|
|
793
|
+
return;
|
|
794
|
+
const { room } = req.params;
|
|
795
|
+
if (!authorizeRoom(principal, room, 'read', reply))
|
|
796
|
+
return;
|
|
797
|
+
try {
|
|
798
|
+
return reply.send({ graph: daemon.project(room, daemon.ledgerGraph(room)) });
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
return reply.code(500).send({ error: 'ledger graph unavailable' });
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
// harn:end graph-derived-from-vault-links-readonly-v5
|
|
805
|
+
app.get('/api/rooms/:room/ledger/:name', (req, reply) => {
|
|
806
|
+
const principal = authed(req, reply);
|
|
807
|
+
if (!principal)
|
|
808
|
+
return;
|
|
809
|
+
const { room, name } = req.params;
|
|
810
|
+
if (!authorizeRoom(principal, room, 'read', reply))
|
|
811
|
+
return;
|
|
812
|
+
try {
|
|
813
|
+
const note = daemon.getLedgerNote(room, name);
|
|
814
|
+
if (!note)
|
|
815
|
+
return reply.code(404).send({ error: `no such ledger note ${name}` });
|
|
816
|
+
return reply.send({ note: daemon.project(room, note) });
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
return reply.code(404).send({ error: String(error) });
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
// harn:assume bridge-enable-admin-or-owner ref=bridge-rest-boundary
|
|
823
|
+
app.post('/api/rooms/:room/bridges', (req, reply) => {
|
|
824
|
+
const principal = authed(req, reply);
|
|
825
|
+
if (!principal)
|
|
826
|
+
return;
|
|
827
|
+
const { room } = req.params;
|
|
828
|
+
if (!authorizeRoom(principal, room, 'enable_bridge', reply))
|
|
829
|
+
return;
|
|
830
|
+
try {
|
|
831
|
+
const body = req.body;
|
|
832
|
+
if (body.platform !== 'slack' && body.platform !== 'telegram') {
|
|
833
|
+
throw new Error('platform must be slack or telegram');
|
|
834
|
+
}
|
|
835
|
+
if (typeof body.channel !== 'string' || body.channel.trim() === '' || body.channel.length > 200) {
|
|
836
|
+
throw new Error('channel must contain 1 to 200 characters');
|
|
837
|
+
}
|
|
838
|
+
return reply.code(201).send(daemon.enableBridge(room, body.platform, body.channel));
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
return reply.code(400).send({ error: String(error) });
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
app.post('/api/rooms/:room/bridges/:memberId/messages', (req, reply) => {
|
|
845
|
+
const principal = authed(req, reply);
|
|
846
|
+
if (!principal)
|
|
847
|
+
return;
|
|
848
|
+
const { room, memberId } = req.params;
|
|
849
|
+
if (!authorizeRoom(principal, room, 'enable_bridge', reply))
|
|
850
|
+
return;
|
|
851
|
+
try {
|
|
852
|
+
const body = req.body;
|
|
853
|
+
if (typeof body.body !== 'string' || body.body.trim() === '' || body.body.length > 100_000) {
|
|
854
|
+
throw new Error('body must contain 1 to 100000 characters');
|
|
855
|
+
}
|
|
856
|
+
return reply.send(daemon.postBridgeMessage(room, memberId, body.body, body.origin));
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
return reply.code(400).send({ error: String(error) });
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
// harn:assume bridge-runtime-persists-delivery-progress ref=bridge-outbound-ready-window
|
|
863
|
+
app.get('/api/rooms/:room/bridges/:memberId/outbound', (req, reply) => {
|
|
864
|
+
const principal = authed(req, reply);
|
|
865
|
+
if (!principal)
|
|
866
|
+
return;
|
|
867
|
+
const { room, memberId } = req.params;
|
|
868
|
+
if (!authorizeRoom(principal, room, 'enable_bridge', reply))
|
|
869
|
+
return;
|
|
870
|
+
try {
|
|
871
|
+
const bridge = daemon.store.getMember(room, memberId);
|
|
872
|
+
if (bridge?.kind !== 'bridge')
|
|
873
|
+
return reply.code(404).send({ error: 'no such bridge' });
|
|
874
|
+
const query = req.query;
|
|
875
|
+
const after = query.after === undefined ? 0 : Number(query.after);
|
|
876
|
+
const limit = query.limit === undefined ? 100 : Number(query.limit);
|
|
877
|
+
if (!Number.isSafeInteger(after) || after < 0)
|
|
878
|
+
throw new Error('after must be a non-negative integer');
|
|
879
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
880
|
+
throw new Error('limit must be an integer from 1 to 100');
|
|
881
|
+
}
|
|
882
|
+
const scanned = daemon.bridgeMessagesAfter(room, after, limit);
|
|
883
|
+
const platform = bridge.handle.slice(0, -'-bridge'.length);
|
|
884
|
+
const messages = [];
|
|
885
|
+
let nextAfter = after;
|
|
886
|
+
for (const message of scanned) {
|
|
887
|
+
if (message.kind === 'run' && message.run?.status === 'running')
|
|
888
|
+
break;
|
|
889
|
+
nextAfter = message.id;
|
|
890
|
+
if (message.kind === 'run' && message.body.trim() === '')
|
|
891
|
+
continue;
|
|
892
|
+
if (message.author === bridge.id && message.origin?.platform === platform)
|
|
893
|
+
continue;
|
|
894
|
+
messages.push(message);
|
|
895
|
+
}
|
|
896
|
+
return reply.send({
|
|
897
|
+
messages: daemon.project(room, messages),
|
|
898
|
+
next_after: nextAfter,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
catch (error) {
|
|
902
|
+
return reply.code(400).send({ error: String(error) });
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
// harn:end bridge-runtime-persists-delivery-progress
|
|
906
|
+
// harn:end bridge-enable-admin-or-owner
|
|
907
|
+
app.post('/api/rooms/:room/members', (req, reply) => {
|
|
908
|
+
const principal = authed(req, reply);
|
|
909
|
+
if (!principal)
|
|
910
|
+
return;
|
|
911
|
+
const { room } = req.params;
|
|
912
|
+
if (!authorizeRoom(principal, room, 'spawn', reply))
|
|
913
|
+
return;
|
|
914
|
+
const body = req.body;
|
|
915
|
+
try {
|
|
916
|
+
return reply.send(daemon.spawnMember(room, body));
|
|
917
|
+
}
|
|
918
|
+
catch (error) {
|
|
919
|
+
return reply.code(400).send({ error: String(error) });
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
app.get('/api/rooms/:room/members', (req, reply) => {
|
|
923
|
+
const principal = authed(req, reply);
|
|
924
|
+
if (!principal)
|
|
925
|
+
return;
|
|
926
|
+
const { room } = req.params;
|
|
927
|
+
if (!authorizeRoom(principal, room, 'read', reply))
|
|
928
|
+
return;
|
|
929
|
+
void reply.send({ members: daemon.project(room, daemon.memberDetails(room)) });
|
|
930
|
+
});
|
|
931
|
+
app.patch('/api/rooms/:room/members/:memberId', (req, reply) => {
|
|
932
|
+
const principal = authed(req, reply);
|
|
933
|
+
if (!principal)
|
|
934
|
+
return;
|
|
935
|
+
const { room, memberId } = req.params;
|
|
936
|
+
if (!authorizeRoom(principal, room, 'rename', reply))
|
|
937
|
+
return;
|
|
938
|
+
const body = req.body;
|
|
939
|
+
void reply.send(daemon.renameMember(room, memberId, body.handle, body.display_name));
|
|
940
|
+
});
|
|
941
|
+
// harn:assume member-config-is-changed-not-respawned ref=configure-act-contract
|
|
942
|
+
app.post('/api/rooms/:room/members/:memberId/configure', (req, reply) => {
|
|
943
|
+
const principal = authed(req, reply);
|
|
944
|
+
if (!principal)
|
|
945
|
+
return;
|
|
946
|
+
const { room, memberId } = req.params;
|
|
947
|
+
if (!authorizeRoom(principal, room, 'configure', reply))
|
|
948
|
+
return;
|
|
949
|
+
const body = req.body;
|
|
950
|
+
const actor = memberForRoom(principal, room);
|
|
951
|
+
void reply.send(daemon.configureMember(room, memberId, body, { actor: actor.id }));
|
|
952
|
+
});
|
|
953
|
+
// harn:end member-config-is-changed-not-respawned
|
|
954
|
+
for (const action of ['revive', 'kill', 'pause', 'unpause']) {
|
|
955
|
+
app.post(`/api/rooms/:room/members/:memberId/${action}`, (req, reply) => {
|
|
956
|
+
const principal = authed(req, reply);
|
|
957
|
+
if (!principal)
|
|
958
|
+
return;
|
|
959
|
+
const { room, memberId } = req.params;
|
|
960
|
+
if (!authorizeRoom(principal, room, action, reply))
|
|
961
|
+
return;
|
|
962
|
+
const member = action === 'revive'
|
|
963
|
+
? daemon.reviveMember(room, memberId)
|
|
964
|
+
: action === 'kill'
|
|
965
|
+
? daemon.killMember(room, memberId)
|
|
966
|
+
: action === 'pause'
|
|
967
|
+
? daemon.pauseMember(room, memberId)
|
|
968
|
+
: daemon.unpauseMember(room, memberId);
|
|
969
|
+
void reply.send(member);
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
if (options.staticRoot !== undefined) {
|
|
973
|
+
await app.register(fastifyStatic, { root: options.staticRoot });
|
|
974
|
+
app.setNotFoundHandler((req, reply) => {
|
|
975
|
+
if (req.url.startsWith('/api/'))
|
|
976
|
+
return reply.code(404).send({ error: 'not found' });
|
|
977
|
+
return reply.sendFile('index.html'); // SPA fallback
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
await app.listen({ host: options.host ?? '127.0.0.1', port: options.port ?? 0 });
|
|
981
|
+
const address = app.server.address();
|
|
982
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
983
|
+
// ── WebSocket: subscribe / post / act ─────────────────────────────────
|
|
984
|
+
const wss = new WebSocketServer({ server: app.server, path: '/ws' });
|
|
985
|
+
let ipcServer;
|
|
986
|
+
let ipcWss;
|
|
987
|
+
const subscriptions = new Map();
|
|
988
|
+
const projectLiveFrame = (room, frame, subscription) => {
|
|
989
|
+
if (!subscription.roomAddressed || frame.type !== 'member')
|
|
990
|
+
return frame;
|
|
991
|
+
return { ...frame, room };
|
|
992
|
+
};
|
|
993
|
+
const pendingSupportRooms = new Set();
|
|
994
|
+
const sendRoomSupport = (socket, room, subscription, seq = daemon.store.currentSeq(room)) => {
|
|
995
|
+
if (subscription.memberId === undefined || socket.readyState !== socket.OPEN)
|
|
996
|
+
return;
|
|
997
|
+
const support = daemon.roomSupport(room, subscription.memberId);
|
|
998
|
+
const serialized = JSON.stringify(support);
|
|
999
|
+
if (serialized === subscription.lastSupport)
|
|
1000
|
+
return;
|
|
1001
|
+
subscription.lastSupport = serialized;
|
|
1002
|
+
socket.send(JSON.stringify({ type: 'room_support', seq, support }));
|
|
1003
|
+
};
|
|
1004
|
+
const scheduleRoomSupport = (room) => {
|
|
1005
|
+
if (pendingSupportRooms.has(room))
|
|
1006
|
+
return;
|
|
1007
|
+
pendingSupportRooms.add(room);
|
|
1008
|
+
queueMicrotask(() => {
|
|
1009
|
+
pendingSupportRooms.delete(room);
|
|
1010
|
+
for (const [socket, rooms] of subscriptions) {
|
|
1011
|
+
const subscription = rooms.get(room);
|
|
1012
|
+
if (subscription)
|
|
1013
|
+
sendRoomSupport(socket, room, subscription);
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
};
|
|
1017
|
+
// harn:end room-support-is-bounded-recipient-scoped-state
|
|
1018
|
+
// harn:end multiplexed-subscriptions-identify-their-room
|
|
1019
|
+
const deviceSockets = new Map();
|
|
1020
|
+
// harn:assume paired-browser-challenge-session ref=browser-device-session-socket
|
|
1021
|
+
const stopDeviceRevocations = options.crypto?.keys.onPeerRevoked((deviceId) => {
|
|
1022
|
+
const sockets = deviceSockets.get(deviceId);
|
|
1023
|
+
if (!sockets)
|
|
1024
|
+
return;
|
|
1025
|
+
deviceSockets.delete(deviceId);
|
|
1026
|
+
for (const socket of sockets)
|
|
1027
|
+
socket.close(4403, 'device revoked');
|
|
1028
|
+
});
|
|
1029
|
+
// harn:end paired-browser-challenge-session
|
|
1030
|
+
// harn:assume multiplexed-subscriptions-identify-their-room ref=room-addressed-live-fanout
|
|
1031
|
+
const unsubscribeFrames = daemon.onFrame((room, frame) => {
|
|
1032
|
+
for (const [socket, rooms] of subscriptions) {
|
|
1033
|
+
const subscription = rooms.get(room);
|
|
1034
|
+
if (subscription && socket.readyState === socket.OPEN) {
|
|
1035
|
+
socket.send(JSON.stringify(projectLiveFrame(room, frame, subscription)));
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
// harn:assume room-support-is-bounded-recipient-scoped-state ref=room-support-fanout
|
|
1039
|
+
if (frame.type === 'message'
|
|
1040
|
+
|| frame.type === 'member'
|
|
1041
|
+
|| frame.type === 'inbox'
|
|
1042
|
+
|| frame.type === 'room') {
|
|
1043
|
+
scheduleRoomSupport(room);
|
|
1044
|
+
}
|
|
1045
|
+
// harn:end room-support-is-bounded-recipient-scoped-state
|
|
1046
|
+
});
|
|
1047
|
+
// harn:end multiplexed-subscriptions-identify-their-room
|
|
1048
|
+
// harn:assume unix-socket-same-protocol ref=unix-websocket-listener
|
|
1049
|
+
const bindProtocol = (server, authenticate) => {
|
|
1050
|
+
server.on('connection', (socket, req) => {
|
|
1051
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1052
|
+
const principal = authenticate(url);
|
|
1053
|
+
if (!principal) {
|
|
1054
|
+
socket.close(4401, 'unauthorized');
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
// harn:assume multiplexed-subscriptions-identify-their-room ref=room-addressed-live-fanout
|
|
1058
|
+
subscriptions.set(socket, new Map());
|
|
1059
|
+
// harn:end multiplexed-subscriptions-identify-their-room
|
|
1060
|
+
if (principal.kind === 'browser') {
|
|
1061
|
+
const sockets = deviceSockets.get(principal.deviceId) ?? new Set();
|
|
1062
|
+
sockets.add(socket);
|
|
1063
|
+
deviceSockets.set(principal.deviceId, sockets);
|
|
1064
|
+
}
|
|
1065
|
+
socket.on('close', () => {
|
|
1066
|
+
subscriptions.delete(socket);
|
|
1067
|
+
if (principal.kind !== 'browser')
|
|
1068
|
+
return;
|
|
1069
|
+
const sockets = deviceSockets.get(principal.deviceId);
|
|
1070
|
+
sockets?.delete(socket);
|
|
1071
|
+
if (sockets?.size === 0)
|
|
1072
|
+
deviceSockets.delete(principal.deviceId);
|
|
1073
|
+
});
|
|
1074
|
+
const send = (frame) => {
|
|
1075
|
+
socket.send(JSON.stringify(frame));
|
|
1076
|
+
};
|
|
1077
|
+
socket.on('message', (raw) => {
|
|
1078
|
+
let frame;
|
|
1079
|
+
try {
|
|
1080
|
+
frame = ClientFrameSchema.parse(JSON.parse(raw.toString()));
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
return send({ type: 'error', message: `invalid frame: ${String(error)}` });
|
|
1084
|
+
}
|
|
1085
|
+
try {
|
|
1086
|
+
if (frame.type === 'mirror_turn') {
|
|
1087
|
+
const joined = daemon.store.findMemberBySessionRef(frame.harness, frame.session_ref);
|
|
1088
|
+
if (!joined)
|
|
1089
|
+
throw new Error(`no mirrored member for ${frame.harness} session ${frame.session_ref}`);
|
|
1090
|
+
assertRoomCapability(principal, joined.room, 'mirror_turn');
|
|
1091
|
+
const mirrored = daemon.mirrorTurn(frame);
|
|
1092
|
+
send({
|
|
1093
|
+
type: 'mirror_ack',
|
|
1094
|
+
native_turn_id: frame.native_turn_id,
|
|
1095
|
+
message_id: mirrored.message.id,
|
|
1096
|
+
deduped: mirrored.deduped,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
else if (frame.type === 'mirror_session_end') {
|
|
1100
|
+
const joined = daemon.store.findMemberBySessionRef(frame.harness, frame.session_ref);
|
|
1101
|
+
if (!joined)
|
|
1102
|
+
throw new Error(`no mirrored member for ${frame.harness} session ${frame.session_ref}`);
|
|
1103
|
+
assertRoomCapability(principal, joined.room, 'mirror_session_end');
|
|
1104
|
+
send({
|
|
1105
|
+
type: 'mirror_ack',
|
|
1106
|
+
adopted: daemon.mirrorSessionEnd(frame.harness, frame.session_ref),
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
else if (frame.type === 'list_rooms') {
|
|
1110
|
+
if (principal.kind === 'agent') {
|
|
1111
|
+
assertRoomCapability(principal, principal.room, 'read');
|
|
1112
|
+
}
|
|
1113
|
+
else if (!authorizeGlobal(principal, 'read')) {
|
|
1114
|
+
throw new Error('forbidden: principal cannot list rooms');
|
|
1115
|
+
}
|
|
1116
|
+
send({
|
|
1117
|
+
type: 'rooms',
|
|
1118
|
+
rooms: roomsFor(principal).map((room) => daemon.project(room.id, room)),
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
else if (frame.type === 'subscribe') {
|
|
1122
|
+
// harn:assume browser-protocol-epoch-blocks-only-stale-browser-ui ref=browser-protocol-admission
|
|
1123
|
+
const browserClient = principal.kind === 'browser'
|
|
1124
|
+
|| (principal.kind !== 'agent' && frame.client_kind === 'browser');
|
|
1125
|
+
if (browserClient)
|
|
1126
|
+
observeBrowserProtocol(frame.browser_protocol);
|
|
1127
|
+
if (browserClient
|
|
1128
|
+
&& minimumBrowserProtocol > 0
|
|
1129
|
+
&& (frame.browser_protocol ?? 0) < minimumBrowserProtocol) {
|
|
1130
|
+
send({
|
|
1131
|
+
type: 'upgrade_required',
|
|
1132
|
+
minimum_browser_protocol: minimumBrowserProtocol,
|
|
1133
|
+
current_browser_protocol: BROWSER_PROTOCOL_EPOCH,
|
|
1134
|
+
});
|
|
1135
|
+
socket.close(4406, 'browser upgrade required');
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
// harn:end browser-protocol-epoch-blocks-only-stale-browser-ui
|
|
1139
|
+
const actor = assertRoomCapability(principal, frame.room, 'read');
|
|
1140
|
+
// harn:assume multiplexed-subscriptions-identify-their-room ref=room-addressed-hydration
|
|
1141
|
+
const roomAddressed = frame.room_addressed === true;
|
|
1142
|
+
// harn:assume room-support-is-bounded-recipient-scoped-state ref=room-support-fanout
|
|
1143
|
+
const supportMemberId = roomAddressed && actor.kind === 'human'
|
|
1144
|
+
? actor.id
|
|
1145
|
+
: undefined;
|
|
1146
|
+
const subscription = {
|
|
1147
|
+
roomAddressed,
|
|
1148
|
+
...(supportMemberId !== undefined && { memberId: supportMemberId }),
|
|
1149
|
+
};
|
|
1150
|
+
subscriptions.get(socket).set(frame.room, subscription);
|
|
1151
|
+
// harn:end room-support-is-bounded-recipient-scoped-state
|
|
1152
|
+
const address = roomAddressed ? { room: frame.room } : {};
|
|
1153
|
+
// harn:end multiplexed-subscriptions-identify-their-room
|
|
1154
|
+
// The bound is the subscriber's own: passed straight through, honoured
|
|
1155
|
+
// only on a cold subscribe, and scoped to this actor so their unread
|
|
1156
|
+
// deliveries' messages ride along. Omit it and the replay is today's.
|
|
1157
|
+
const sync = daemon.sync(frame.room, frame.since_seq, {
|
|
1158
|
+
hydrateLimit: frame.hydrate_limit,
|
|
1159
|
+
subscriber: actor.id,
|
|
1160
|
+
strictTail: roomAddressed,
|
|
1161
|
+
supportFor: supportMemberId,
|
|
1162
|
+
});
|
|
1163
|
+
const hydrationCursor = frame.since_seq;
|
|
1164
|
+
// harn:assume multiplexed-subscriptions-identify-their-room ref=room-addressed-hydration
|
|
1165
|
+
send({ type: 'self', member_id: actor.id, ...address });
|
|
1166
|
+
send({ type: 'room', seq: hydrationCursor, room: sync.room });
|
|
1167
|
+
for (const member of sync.members) {
|
|
1168
|
+
send({ type: 'member', seq: hydrationCursor, member, ...address });
|
|
1169
|
+
}
|
|
1170
|
+
// harn:end multiplexed-subscriptions-identify-their-room
|
|
1171
|
+
for (const message of sync.messages)
|
|
1172
|
+
send({ type: 'message', seq: hydrationCursor, message });
|
|
1173
|
+
// harn:assume agent-sync-hydrates-only-own-queued-inbox ref=agent-own-queued-sync-overlay
|
|
1174
|
+
const inbox = principal.kind === 'agent'
|
|
1175
|
+
? new Map([
|
|
1176
|
+
...sync.inbox
|
|
1177
|
+
.filter((delivery) => delivery.recipient === actor.id)
|
|
1178
|
+
.map((delivery) => [delivery.id, delivery]),
|
|
1179
|
+
...daemon.store.listDeliveries(frame.room, {
|
|
1180
|
+
recipient: actor.id,
|
|
1181
|
+
state: 'queued',
|
|
1182
|
+
}).map((delivery) => [delivery.id, delivery]),
|
|
1183
|
+
]).values()
|
|
1184
|
+
: sync.inbox;
|
|
1185
|
+
for (const delivery of inbox)
|
|
1186
|
+
send({ type: 'inbox', seq: hydrationCursor, delivery });
|
|
1187
|
+
// harn:end agent-sync-hydrates-only-own-queued-inbox
|
|
1188
|
+
for (const meter of sync.meters)
|
|
1189
|
+
send({ type: 'meter', seq: hydrationCursor, meter });
|
|
1190
|
+
// harn:assume room-support-is-bounded-recipient-scoped-state ref=room-support-fanout
|
|
1191
|
+
if (sync.support !== undefined) {
|
|
1192
|
+
subscription.lastSupport = JSON.stringify(sync.support);
|
|
1193
|
+
send({ type: 'room_support', seq: hydrationCursor, support: sync.support });
|
|
1194
|
+
}
|
|
1195
|
+
// harn:end room-support-is-bounded-recipient-scoped-state
|
|
1196
|
+
send({
|
|
1197
|
+
type: 'sync_complete',
|
|
1198
|
+
seq: sync.seq,
|
|
1199
|
+
// harn:assume multiplexed-subscriptions-identify-their-room ref=room-addressed-hydration
|
|
1200
|
+
...address,
|
|
1201
|
+
// harn:end multiplexed-subscriptions-identify-their-room
|
|
1202
|
+
// The floor is the server's, not a guess from what happened to arrive.
|
|
1203
|
+
...(sync.history_floor !== undefined && { history_floor: sync.history_floor }),
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
else if (frame.type === 'post') {
|
|
1207
|
+
const actor = assertRoomCapability(principal, frame.room, 'post');
|
|
1208
|
+
if (principal.kind === 'agent') {
|
|
1209
|
+
daemon.postAgentMessage(frame.room, actor.id, frame.body, frame.reply_to, frame.awaiting_reply);
|
|
1210
|
+
}
|
|
1211
|
+
else {
|
|
1212
|
+
// Resolve prior uploads to metadata (refuses unknown/cross-room ids
|
|
1213
|
+
// and over-count); refuse a post with neither body nor attachments.
|
|
1214
|
+
const attachments = daemon.resolveAttachmentsForPost(frame.room, frame.attachments);
|
|
1215
|
+
if (frame.body.trim().length === 0 && attachments.length === 0) {
|
|
1216
|
+
throw new Error('a post needs body text or at least one attachment');
|
|
1217
|
+
}
|
|
1218
|
+
daemon.postHumanMessage(frame.room, frame.body, {
|
|
1219
|
+
author: actor.id,
|
|
1220
|
+
reply_to: frame.reply_to,
|
|
1221
|
+
attachments,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
else if (frame.type === 'act') {
|
|
1226
|
+
const act = frame.act;
|
|
1227
|
+
const actor = assertRoomCapability(principal, frame.room, act.act);
|
|
1228
|
+
if (act.act === 'answer_interaction') {
|
|
1229
|
+
void daemon
|
|
1230
|
+
.answerInteraction(frame.room, act.interaction_id, act.answer, actor.id)
|
|
1231
|
+
.catch((error) => send({ type: 'error', message: String(error), ref: 'answer_interaction' }));
|
|
1232
|
+
}
|
|
1233
|
+
else if (act.act === 'join') {
|
|
1234
|
+
daemon.joinMember(frame.room, {
|
|
1235
|
+
harness: act.harness,
|
|
1236
|
+
handle: act.handle,
|
|
1237
|
+
session_ref: act.session_ref,
|
|
1238
|
+
cwd: act.cwd,
|
|
1239
|
+
policy: act.policy,
|
|
1240
|
+
purpose: act.purpose,
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
else if (act.act === 'adopt')
|
|
1244
|
+
daemon.adoptMember(frame.room, act.member_id);
|
|
1245
|
+
else if (act.act === 'attach_acquire') {
|
|
1246
|
+
void daemon.acquireAttachLease(frame.room, act.member_id, act.cli_pid)
|
|
1247
|
+
.then(({ lease, member }) => send({
|
|
1248
|
+
type: 'attach_lease',
|
|
1249
|
+
status: 'acquired',
|
|
1250
|
+
lease,
|
|
1251
|
+
member,
|
|
1252
|
+
}))
|
|
1253
|
+
.catch((error) => send({
|
|
1254
|
+
type: 'error',
|
|
1255
|
+
message: String(error),
|
|
1256
|
+
ref: 'attach_acquire',
|
|
1257
|
+
}));
|
|
1258
|
+
}
|
|
1259
|
+
else if (act.act === 'attach_child') {
|
|
1260
|
+
// harn:assume attach-lease-actions-room-bound ref=attach-lease-room-authorization
|
|
1261
|
+
const attachLease = daemon.store.getAttachLease(act.lease_id);
|
|
1262
|
+
if (!attachLease || attachLease.room !== frame.room) {
|
|
1263
|
+
throw new Error(`no such attach lease ${act.lease_id}`);
|
|
1264
|
+
}
|
|
1265
|
+
const { lease, member } = daemon.reportAttachChild(act.lease_id, act.child_pid, act.process_group_id);
|
|
1266
|
+
send({ type: 'attach_lease', status: 'child_recorded', lease, member });
|
|
1267
|
+
}
|
|
1268
|
+
else if (act.act === 'attach_heartbeat') {
|
|
1269
|
+
const attachLease = daemon.store.getAttachLease(act.lease_id);
|
|
1270
|
+
if (!attachLease || attachLease.room !== frame.room) {
|
|
1271
|
+
throw new Error(`no such attach lease ${act.lease_id}`);
|
|
1272
|
+
}
|
|
1273
|
+
daemon.heartbeatAttachLease(act.lease_id);
|
|
1274
|
+
}
|
|
1275
|
+
else if (act.act === 'attach_complete') {
|
|
1276
|
+
const attachLease = daemon.store.getAttachLease(act.lease_id);
|
|
1277
|
+
if (!attachLease || attachLease.room !== frame.room) {
|
|
1278
|
+
throw new Error(`no such attach lease ${act.lease_id}`);
|
|
1279
|
+
}
|
|
1280
|
+
const completed = daemon.completeAttachLease(act.lease_id);
|
|
1281
|
+
send({
|
|
1282
|
+
type: 'attach_lease',
|
|
1283
|
+
status: completed.status,
|
|
1284
|
+
lease: completed.lease,
|
|
1285
|
+
member: completed.member,
|
|
1286
|
+
});
|
|
1287
|
+
// harn:end attach-lease-actions-room-bound
|
|
1288
|
+
}
|
|
1289
|
+
else if (act.act === 'configure_room') {
|
|
1290
|
+
daemon.configureRoom(frame.room, {
|
|
1291
|
+
...(act.turn_brake !== undefined && { turn_brake: act.turn_brake }),
|
|
1292
|
+
...(act.spend_brake_usd !== undefined && {
|
|
1293
|
+
spend_brake_usd: act.spend_brake_usd,
|
|
1294
|
+
}),
|
|
1295
|
+
...(act.stall_minutes !== undefined && { stall_minutes: act.stall_minutes }),
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
else if (act.act === 'redeliver')
|
|
1299
|
+
daemon.redeliver(frame.room, act.delivery_id);
|
|
1300
|
+
else if (act.act === 'release_hold')
|
|
1301
|
+
daemon.releaseHold(frame.room, act.delivery_id);
|
|
1302
|
+
else if (act.act === 'mark_read')
|
|
1303
|
+
daemon.markRead(frame.room, act.delivery_id, actor.id);
|
|
1304
|
+
// harn:assume human-room-read-cursors-are-durable-and-monotonic ref=durable-room-read-storage
|
|
1305
|
+
else if (act.act === 'mark_room_read') {
|
|
1306
|
+
daemon.markRoomRead(frame.room, act.through_seq, actor.id);
|
|
1307
|
+
scheduleRoomSupport(frame.room);
|
|
1308
|
+
}
|
|
1309
|
+
// harn:end human-room-read-cursors-are-durable-and-monotonic
|
|
1310
|
+
// harn:assume live-delivery-consumption-is-idempotent ref=consume-act-dispatch
|
|
1311
|
+
else if (act.act === 'consume_delivery') {
|
|
1312
|
+
const consumed = daemon.consumeDelivery(frame.room, act.delivery_id, actor.id);
|
|
1313
|
+
send({ type: 'consume_result', ...consumed });
|
|
1314
|
+
}
|
|
1315
|
+
// harn:end live-delivery-consumption-is-idempotent
|
|
1316
|
+
// harn:assume live-agent-waits-are-transient ref=wait-act-dispatch
|
|
1317
|
+
else if (act.act === 'wait_begin') {
|
|
1318
|
+
if (principal.kind !== 'agent')
|
|
1319
|
+
throw new Error('forbidden: waits require an agent credential');
|
|
1320
|
+
daemon.beginWait(frame.room, actor.id, {
|
|
1321
|
+
reason: act.reason,
|
|
1322
|
+
peers: act.peers,
|
|
1323
|
+
until_ts: act.until_ts,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
else if (act.act === 'wait_end') {
|
|
1327
|
+
if (principal.kind !== 'agent')
|
|
1328
|
+
throw new Error('forbidden: waits require an agent credential');
|
|
1329
|
+
daemon.endWait(frame.room, actor.id);
|
|
1330
|
+
}
|
|
1331
|
+
// harn:end live-agent-waits-are-transient
|
|
1332
|
+
else if (act.act === 'spawn') {
|
|
1333
|
+
daemon.spawnMember(frame.room, {
|
|
1334
|
+
harness: act.harness,
|
|
1335
|
+
handle: act.handle,
|
|
1336
|
+
cwd: act.cwd,
|
|
1337
|
+
policy: act.policy,
|
|
1338
|
+
model: act.model,
|
|
1339
|
+
thinking: act.thinking,
|
|
1340
|
+
purpose: act.purpose,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
else if (act.act === 'configure') {
|
|
1344
|
+
daemon.configureMember(frame.room, act.member_id, { model: act.model, thinking: act.thinking, policy: act.policy }, { actor: actor.id });
|
|
1345
|
+
}
|
|
1346
|
+
else if (act.act === 'rename')
|
|
1347
|
+
daemon.renameMember(frame.room, act.member_id, act.handle, act.display_name);
|
|
1348
|
+
else if (act.act === 'revive')
|
|
1349
|
+
daemon.reviveMember(frame.room, act.member_id);
|
|
1350
|
+
else if (act.act === 'kill')
|
|
1351
|
+
daemon.killMember(frame.room, act.member_id);
|
|
1352
|
+
else if (act.act === 'remove')
|
|
1353
|
+
daemon.removeMember(frame.room, act.member_id);
|
|
1354
|
+
else if (act.act === 'pause')
|
|
1355
|
+
daemon.pauseMember(frame.room, act.member_id);
|
|
1356
|
+
else if (act.act === 'unpause')
|
|
1357
|
+
daemon.unpauseMember(frame.room, act.member_id);
|
|
1358
|
+
else if (act.act === 'interrupt')
|
|
1359
|
+
daemon.interruptMember(frame.room, act.member_id);
|
|
1360
|
+
// Compaction is a round trip to the engine: report its refusal or
|
|
1361
|
+
// failure back on this connection like any other act.
|
|
1362
|
+
else if (act.act === 'compact_member') {
|
|
1363
|
+
void daemon
|
|
1364
|
+
.compactMember(frame.room, act.member_id, actor.id)
|
|
1365
|
+
.catch((error) => send({ type: 'error', message: String(error), ref: 'compact_member' }));
|
|
1366
|
+
}
|
|
1367
|
+
else if (act.act === 'set_role')
|
|
1368
|
+
daemon.setHumanRole(frame.room, act.member_id, act.role);
|
|
1369
|
+
else if (act.act === 'pin_message')
|
|
1370
|
+
daemon.pinMessage(frame.room, act.message_id, act.pinned, actor.id);
|
|
1371
|
+
else if (act.act === 'delete_message')
|
|
1372
|
+
daemon.deleteMessage(frame.room, act.message_id, actor.id);
|
|
1373
|
+
else if (act.act === 'retry_run')
|
|
1374
|
+
daemon.retryRun(frame.room, act.message_id, actor.id);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
catch (error) {
|
|
1378
|
+
send({ type: 'error', message: String(error), ref: frame.type });
|
|
1379
|
+
}
|
|
1380
|
+
});
|
|
1381
|
+
});
|
|
1382
|
+
};
|
|
1383
|
+
bindProtocol(wss, (url) => principalForToken(url.searchParams.get('token') ?? undefined));
|
|
1384
|
+
if (options.socketPath !== undefined) {
|
|
1385
|
+
ipcServer = createHttpServer((_req, res) => res.writeHead(404).end());
|
|
1386
|
+
ipcWss = new WebSocketServer({ server: ipcServer, path: '/ws' });
|
|
1387
|
+
bindProtocol(ipcWss, (url) => {
|
|
1388
|
+
const candidate = url.searchParams.get('token') ?? undefined;
|
|
1389
|
+
return candidate === undefined ? { kind: 'owner' } : principalForToken(candidate);
|
|
1390
|
+
});
|
|
1391
|
+
try {
|
|
1392
|
+
await listenUnix(ipcServer, options.socketPath);
|
|
1393
|
+
}
|
|
1394
|
+
catch (error) {
|
|
1395
|
+
unsubscribeFrames();
|
|
1396
|
+
stopDeviceRevocations?.();
|
|
1397
|
+
ipcWss.close();
|
|
1398
|
+
wss.close();
|
|
1399
|
+
await app.close();
|
|
1400
|
+
throw error;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
// harn:end unix-socket-same-protocol
|
|
1404
|
+
return {
|
|
1405
|
+
app,
|
|
1406
|
+
port,
|
|
1407
|
+
socketPath: options.socketPath,
|
|
1408
|
+
observedBrowserProtocols: () => [...observedBrowserProtocols].sort((left, right) => left - right),
|
|
1409
|
+
close: async () => {
|
|
1410
|
+
unsubscribeFrames();
|
|
1411
|
+
stopDeviceRevocations?.();
|
|
1412
|
+
for (const socket of subscriptions.keys())
|
|
1413
|
+
socket.terminate();
|
|
1414
|
+
wss.close();
|
|
1415
|
+
ipcWss?.close();
|
|
1416
|
+
if (ipcServer?.listening) {
|
|
1417
|
+
await new Promise((resolve, reject) => ipcServer.close((error) => (error ? reject(error) : resolve())));
|
|
1418
|
+
}
|
|
1419
|
+
await app.close();
|
|
1420
|
+
if (options.socketPath !== undefined)
|
|
1421
|
+
rmSync(options.socketPath, { force: true });
|
|
1422
|
+
},
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
//# sourceMappingURL=server.js.map
|