Repository navigation
✨ feat: send encrypted files from agents and scripts with the SendSelf CLI - #35
Conversation
Move what every SendSelf client needs and the server never runs out of the PWA into its own package: the Web Crypto core, the API client, the pure keyring, the message ciphertext contexts and the joining side of pairing. The PWA keeps only what is specific to it (IndexedDB persistence, the same-origin API instance and its auth-failure hook). The AAD contexts of a message (text:/file:/meta:<id>) are protocol, so they now live in one place with a test pinning them, instead of being spelled out separately in the outbox and in sync. No behaviour change. This prepares a second client, the sendself CLI, which has to speak exactly the same wire format.
A device can now ask to be send-only when it reserves its pairing slot: a script or an agent on a server that sends into the space but never reads it. It pairs, holds the GroupKey, signs and receives every rotated key exactly like any other member; the Worker only leaves it out of message recipients. Otherwise each message would wait on the server for a device that never collects it until the 24-hour cleanup, instead of being deleted as soon as the devices that read the space have it. A send-only device cannot be made an administrator: it never reads the space, so it is in no position to vet the devices an admin adds. The device list says which devices are send-only, and the app shows it. Migration 0009 adds `devices.send_only`, defaulting to 0, so every existing device keeps receiving exactly as before; it only adds a column, so the Worker still serving during a deploy is unaffected.
A command-line client for scripts and AI agents running on a server: sendself link --name "VPS" # once: shows a QR code / pasteable code sendself send report.pdf # files, with -m for text or a caption echo "Deploy done" | sendself send It links through the normal pairing flow as a send-only device, and encrypts and signs on the machine it runs on with the same code as the PWA, so the server still only ever sees ciphertext. Before each send it adopts any rotated GroupKey; it never performs a rotation itself, since that means wrapping the key for a roster it does not verify. The link is one owner-only file (device.json in $SENDSELF_HOME, default ~/.config/sendself) with no passphrase, because the CLI runs unattended. The security doc and the public security and privacy pages say so. `pnpm build` now also bundles the CLI into a single self-contained apps/cli/dist/sendself.mjs that needs only Node 20+.
Add encrypted invitation pairing with explicit approval, npm packaging for the sendself CLI, and shared setup instructions across the app and public site. The landing prompt starts QR pairing directly without requiring an invitation code.
There was a problem hiding this comment.
Review
This PR extracts the browser crypto/API/message/keyring code into a new shared packages/client workspace, then builds a sendself CLI on top of it: send/link/status/unlink commands, a send-only device flag (migration 0009), and authenticated invitation pairing where the secret never reaches the server and its AES-GCM seal binds the joiner's keys and send-only flag. It also adds the Agents landing section, /cli/ page, npm package metadata, and tests across the shared package, CLI, and Worker.
| File | Note | |
|---|---|---|
packages/client/src/message.ts |
✅ | Context strings pinned identically to the old web code; covered by a test. |
packages/client/src/pairing.ts |
✅ | Invite seal binds pairing id, device id, both keys and send-only flag; mismatch paths tested. |
apps/cli/src/send.ts |
✅ | Faithful port of the outbox; rotation retry and rate-limit handling are tested. |
apps/cli/src/session.ts |
✅ | Persist-before-ack ordering is correct and documented. |
apps/cli/src/link.ts |
🟡 | New poll/request flow has two robustness gaps around transient failures. |
apps/web/src/ui/DeviceManager.tsx |
🟡 | Invitation panel state is lost when the Agent tab is left. |
apps/worker/src/routes/pairing.ts |
✅ | send_only is applied from the sealed slot; QR slots are not exposed by /joiner. |
apps/worker/migrations/0009_send_only_devices.sql |
✅ | NOT NULL DEFAULT 0 preserves existing receiving behavior. |
Risk: low
Notes
Add tests for the new link flow: assert the invitation and QR paths poll, unwrap, and persist device.json, and that expiry/cancellation surface the friendly errors. A test that the invitation panel keeps the same pairing id across a tab switch would pin down the UX issue above. Consider making pairingRequest idempotent (or tolerating conflict for an invitation's own slot) and treating rate_limited as transient in the link poll.
4 file(s) not reviewed
apps/web/public/agents/claude-code.svg— generated or binaryapps/web/public/agents/codex.svg— generated or binaryapps/web/public/agents/cursor.svg— generated or binarypnpm-lock.yaml— generated or binary
Automated review — opencode-go/deepseek-v4.1-flash · 3 comment(s)
Make slot creation idempotent for an identical descriptor, back off after poll rate limits, and preserve invitation state while switching pairing tabs. Cover linking, key persistence, expiry, cancellation, and invitation continuity with regression tests. Give the private workspace a distinct name so CLI package filters do not also select the root.
There was a problem hiding this comment.
Review
Follow-up hardening for the CLI link flow: keeps the agent invitation panel mounted so a copied invite survives pairing-tab switches, treats poll rate limits as transient with a 15s backoff, fixes the abortable sleep so an already-aborted signal settles, makes the worker's pairing request idempotent (same descriptor keeps its slot and deadline, a different one still conflicts), renames the root workspace so it no longer clashes with the published sendself CLI, and adds CLI, worker and browser tests.
| File | Note | |
|---|---|---|
apps/cli/src/link.ts |
🟡 | Rate-limit backoff and abort fix are correct; only NetworkError/rate_limited are treated as transient. |
apps/cli/src/link.test.ts |
✅ | Covers invitation and QR persistence, both transient failures, expiry and cancel cleanup. |
apps/worker/src/routes/pairing.ts |
✅ | Idempotent ON CONFLICT insert compares the full serialized descriptor before accepting a retry. |
apps/worker/src/routes/pairing.test.ts |
✅ | Asserts age/identity preserved on retry and 409 for changed invite/sendOnly. |
apps/web/src/ui/DeviceManager.tsx |
🟡 | State now survives tab switches, but the invitation is created at modal open, not at first Agent visit. |
e2e/agent-invitation.spec.ts |
✅ | Pins the tab-switch regression the panel fix addresses. |
package.json |
✅ | Renaming the private root resolves the duplicate name with the published CLI package. |
Risk: low
Notes
The idempotency and abort-handling changes look sound and well tested. Worth adding: a test asserting the invitation is not created until the Agent tab is first opened, and one that a transient 500 keeps the link poll going. No blocking issues.
4 file(s) not reviewed
apps/web/public/agents/claude-code.svg— generated or binaryapps/web/public/agents/codex.svg— generated or binaryapps/web/public/agents/cursor.svg— generated or binarypnpm-lock.yaml— generated or binary
Automated review — opencode-go/deepseek-v4.1-flash · 2 comment(s) · incremental
Create the agent invitation only when its tab is first opened, preserve it on later visits, and refresh the deadline immediately on return. Keep CLI polling after transient 5xx responses. Cover delayed first use with the browser clock and add a 500-recovery regression.
There was a problem hiding this comment.
Review
Follow-up hardening for the CLI link flow: the CLI's pairing poll now treats 5xx responses as transient (with a matching test case), the web Add-device modal mounts the agent invitation panel only on the first Agent-tab visit so the invite is created at first use, and the invite panel refreshes its countdown immediately when returning to the tab. The browser E2E test is updated to fast-forward time before first visit and assert the full 10-minute expiry, plus tab preservation.
| File | Note | |
|---|---|---|
apps/cli/src/link.ts |
🟡 | 5xx treated as transient; correct but a persistent failure now surfaces as a misleading expiry error. |
apps/cli/src/link.test.ts |
✅ | The 'server' case does exercise the new branch since pairingPoll uses retries:0. |
apps/web/src/ui/DeviceManager.tsx |
✅ | Lazy mount and immediate tick are sound and the effect cleanup guards stale polls. |
e2e/agent-invitation.spec.ts |
✅ | Fast-forwarding before first visit genuinely distinguishes lazy creation from modal-open creation. |
Risk: low
Notes
Tests are good: the 5xx path is actually exercised (pairingPoll uses retries:0) and the E2E fast-forward genuinely proves lazy invite creation. Optional follow-up: a unit test for a 5xx that persists past the deadline, asserting the user-facing error mentions the server failure rather than expiry.
4 file(s) not reviewed
apps/web/public/agents/claude-code.svg— generated or binaryapps/web/public/agents/codex.svg— generated or binaryapps/web/public/agents/cursor.svg— generated or binarypnpm-lock.yaml— generated or binary
Automated review — opencode-go/deepseek-v4.1-flash · 1 comment(s) · incremental
Report the most recent transient poll failure when the pairing deadline is reached. A successful poll clears that cause, so an unanswered invitation still reports ordinary expiry.
There was a problem hiding this comment.
Review
Follow-up to the CLI pairing-poll handling: when polls fail transiently (network, rate limit, or 5xx) and the failures persist until the invitation/pairing deadline, the CLI now surfaces the last underlying failure message instead of the generic expiry message. A persistent option and a new unit test were added to cover a server failure that never recovers.
| File | Note | |
|---|---|---|
apps/cli/src/link.ts |
✅ | lastPollError is reset on a successful poll and reported at deadline; correct and reachable only when the deadline genuinely passed. |
apps/cli/src/link.test.ts |
✅ | persistent server test exercises the new branch end-to-end and asserts cleanup and no saved device. |
Risk: low
Notes
The prior follow-up (surface the cause of a persistent failure rather than reporting mere expiry) is implemented and tested. No further issues found; the new error path is well covered.
4 file(s) not reviewed
apps/web/public/agents/claude-code.svg— generated or binaryapps/web/public/agents/codex.svg— generated or binaryapps/web/public/agents/cursor.svg— generated or binarypnpm-lock.yaml— generated or binary
Automated review — opencode-go/deepseek-v4.1-flash · 0 comment(s) · incremental
Agents and scripts can now link a machine to a SendSelf space and send encrypted files and messages from the terminal. The app offers an invitation command with explicit device approval; the landing-page setup prompt starts QR/text pairing directly, without asking the user for an invitation first.
sendselfCLI, including messages, file albums, view-once delivery, key rotation, and local credentials./cli/guide, npm package metadata, and concise shared setup prompts that remember how to send without extra status or test commands.Validation: lint, formatting, workspace typecheck, 539 unit/integration tests, 12 existing browser E2E tests plus the new invitation-tab regression, and production web/CLI build passed. The npm package dry run contains only the bundled CLI, README, license, and package metadata; the bundle runs successfully. The browser screenshot shows the concise setup prompt in the public CLI guide.
Deployment: apply migration
0009_send_only_devices.sqlbefore deploying the Worker. Its default of0preserves existing devices' receiving behavior. Existing QR pairing remains available. This PR prepares the npm package; publishing it is a separate release step.