Skip to content

✨ feat: send encrypted files from agents and scripts with the SendSelf CLI - #35

Merged
martinezharo merged 7 commits into
mainfrom
t3code/api-sharing
Sep 30, 2026
Merged

martinezharo merged 7 commits into
mainfrom
t3code/api-sharing

Conversation

@martinezharo

@martinezharo martinezharo commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

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.

  • Extract the shared client crypto/API code and add the sendself CLI, including messages, file albums, view-once delivery, key rotation, and local credentials.
  • Add send-only devices and authenticated invitation pairing, with idempotent slot creation, rate-limit backoff, and invitation state preserved across pairing tabs and expiry starting on first use. The invitation secret stays off the server; its seal binds the joining device's identity and keys.
  • Add the Agents section and /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.sql before deploying the Worker. Its default of 0 preserves existing devices' receiving behavior. Existing QR pairing remains available. This PR prepares the npm package; publishing it is a separate release step.

Concise setup prompt

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.

@oli-review oli-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 binary
  • apps/web/public/agents/codex.svg — generated or binary
  • apps/web/public/agents/cursor.svg — generated or binary
  • pnpm-lock.yaml — generated or binary

Automated review — opencode-go/deepseek-v4.1-flash · 3 comment(s)

Comment thread apps/cli/src/link.ts
Comment thread apps/cli/src/link.ts Outdated
Comment thread apps/web/src/ui/DeviceManager.tsx
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.

@oli-review oli-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 binary
  • apps/web/public/agents/codex.svg — generated or binary
  • apps/web/public/agents/cursor.svg — generated or binary
  • pnpm-lock.yaml — generated or binary

Automated review — opencode-go/deepseek-v4.1-flash · 2 comment(s) · incremental

Comment thread apps/cli/src/link.ts
Comment thread apps/web/src/ui/DeviceManager.tsx Outdated
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.

@oli-review oli-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 binary
  • apps/web/public/agents/codex.svg — generated or binary
  • apps/web/public/agents/cursor.svg — generated or binary
  • pnpm-lock.yaml — generated or binary

Automated review — opencode-go/deepseek-v4.1-flash · 1 comment(s) · incremental

Comment thread apps/cli/src/link.ts
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.

@oli-review oli-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 binary
  • apps/web/public/agents/codex.svg — generated or binary
  • apps/web/public/agents/cursor.svg — generated or binary
  • pnpm-lock.yaml — generated or binary

Automated review — opencode-go/deepseek-v4.1-flash · 0 comment(s) · incremental

@martinezharo
martinezharo merged commit ac9a1c9 into main Sep 30, 2026
1 check passed
@martinezharo
martinezharo deleted the t3code/api-sharing branch September 30, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant