feat(agent): add opt-in push notifications
This commit is contained in:
+73
-3
@@ -625,14 +625,17 @@ Invalid receipts return 502; delivery remains uncertain, so retry using the orig
|
||||
### `GET /v1/agent/read-state`
|
||||
### `PUT /v1/agent/read-state`
|
||||
|
||||
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z" }`.
|
||||
Initially: `{ "cursor": "0", "updatedAt": null }`. PUT takes
|
||||
`{ "cursor": "43" }` and returns the same shape. The cursor is shared across this
|
||||
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z", "sourceId": "00000000-0000-4000-8000-000000000001" }`.
|
||||
Initially: `{ "cursor": "0", "updatedAt": null, "sourceId": null }` (after source discovery the zero row also has its source UUID).
|
||||
PUT takes `{ "cursor": "43", "sourceId": "00000000-0000-4000-8000-000000000001" }`; `sourceId` is optional for old clients.
|
||||
A supplied source must match the bound runtime or the server returns 409. Both operations return the same additive response shape. The cursor is shared across this
|
||||
personal installation and persisted in SQLite. Atomic, monotonic updates prevent
|
||||
a stale browser from lowering a cursor saved by another browser. Advance it only
|
||||
after showing the corresponding history; poll it to reconcile unread indicators
|
||||
across clients. Agent REST responses use `Cache-Control: no-store`.
|
||||
|
||||
Source-aware clients bind both history and queued acknowledgments to the first accepted read source. A missing/null source is a legacy binding once read state or history has been accepted, not permission to adopt a later UUID. A source mismatch, HTTP 409 or invalid/non-advancing PUT acknowledgment stops old read writers and requires an explicit fresh history load; old cursors must never be relabeled. Known-source writes include `sourceId`; legacy writes omit it. Client reload does not reset server read state. See the source-binding rules in the streaming contract.
|
||||
|
||||
See the persistent Agent WebSocket section in `streaming-chat.md` for live updates.
|
||||
|
||||
### Agent attachments (v1)
|
||||
@@ -686,3 +689,70 @@ Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT
|
||||
The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. The deployed open auth mode must be changed separately; no deployed config or mount is modified by this implementation. Backend and web must be upgraded with runtime capability/auth configured before clients advertise uploads.
|
||||
|
||||
Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission.
|
||||
|
||||
|
||||
## Agent push notifications (v1)
|
||||
|
||||
All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer authentication; open development mode returns 503. Missing/wrong bearer returns 401/403. Bodies are limited to 16 KiB, responses use `Cache-Control: no-store`, invalid bodies return 400, and unavailable configuration returns 503. Do not put credentials, device tokens or subscription endpoints into URLs. Ownership is one **shared authenticated personal principal**, derived on the server from the configured credential generation. Anyone possessing that shared token has authority over all its installations; UUIDs identify installations and do not create per-human isolation. No `ownerId` is accepted. Changing `ADMIN_TOKEN` invalidates all prior push opt-ins. Protect the SQLite database/backups because endpoint credentials are stored there.
|
||||
|
||||
### `GET /v1/agent/push/config`
|
||||
|
||||
```json
|
||||
{"version":1,"enabled":false,"apnsAvailable":false,"webPushAvailable":false,"sourceId":null,"quarantined":false,"policy":{"promptGraceMs":5000,"debounceMs":2000,"interactionGraceMs":30000,"presenceHeartbeatMs":15000,"presenceLeaseMs":60000}}
|
||||
```
|
||||
|
||||
`vapidPublicKey` is present only when Web Push is enabled and its credentials are valid. No private keys, tokens, paths or configured account identifiers are returned. Source discovery and browser presence run independently of transport enablement/installation consent. Clients bind local consent to a known source UUID as well as the authenticated backend; null/unknown source cannot opt in. A source change retires prior local consent/installation and requires a new UUID plus explicit user opt-in, rechecking the source after asynchronous permission/subscription/registration work. `sourceId` is the persisted bound runtime UUID or null before discovery. A changed runtime source or regressed durable cursor quarantines delivery and cancels pending alerts without resetting the canonical read cursor. There is deliberately no HTTP read-reset/rebaseline endpoint; an operator must reconcile replacement runtime data with source-aware clients before enabling a new baseline.
|
||||
|
||||
### `PUT /v1/agent/push/installations/:installationId`
|
||||
|
||||
Use a stable random UUID per local installation, authenticated with the current shared token. Registration is explicit user opt-in and requires enabled/configured transport. Native body:
|
||||
|
||||
```json
|
||||
{"transport":"apns","enabled":true,"token":"hex-device-token","environment":"production","topic":"net.buzzert.sybil2"}
|
||||
```
|
||||
|
||||
Device tokens must be even-length hexadecimal, 32–512 characters; use the exact token returned by the OS. The server pins permitted topic and environment. TestFlight/App Store normally use production; local development must match the signed `aps-environment` entitlement. Tokens rotate: upsert the current token after registration callbacks and each opted-in launch. App Store upload passwords cannot authenticate APNs.
|
||||
|
||||
Web body (the keys and endpoint come from the browser's actual `PushSubscription`):
|
||||
|
||||
```json
|
||||
{"transport":"web","enabled":true,"subscription":{"endpoint":"https://web.push.apple.com/provider-path","expirationTime":null,"keys":{"p256dh":"base64url-P256-public-key","auth":"base64url-16-byte-auth-secret"}}}
|
||||
```
|
||||
|
||||
Subscription keys are curve/size validated. Endpoints must use HTTPS, port 443, no userinfo/fragment, and the standard allowlisted Apple (`web.push.apple.com` or one-label `*.push.apple.com`), Google (`fcm.googleapis.com`) or Mozilla (`updates.push.services.mozilla.com`) provider hosts. HTTP redirects are never followed; DNS addresses are checked at connection time to reject private/reserved addresses. Other providers require a reviewed server allowlist change. There is no arbitrary push URL fetch.
|
||||
|
||||
Response: `{ "installationId":"uuid", "transport":"apns|web", "enabled":true, "version":1 }`. No endpoint/token/key is reflected. Identical registrations retain the same version. Rotation increments the version and cancels old pending deliveries; a provider rejection of an old version cannot disable a newer one. A single endpoint is active under at most one installation ID; rebinding it irreversibly retires every prior non-retired UUID for that endpoint, including provider-disabled and prior credential-generation registrations. A provider-invalid UUID may refresh only until a newer installation claims its endpoint. A PUT to an explicitly retired UUID returns 409. At most 64 opted-in installations are allowed (429 when full).
|
||||
|
||||
### `DELETE /v1/agent/push/installations/:installationId`
|
||||
|
||||
Idempotent authenticated 204. Irreversibly retires the UUID, including an unknown UUID (durable tombstone), removes stored transport payload, increments its generation once and cancels pending deliveries. Late/delayed PUTs cannot re-enable a retired UUID, including after restart. A new explicit user opt-in must mint a **new UUID**; rotations may continue using the still-active UUID. Provider-invalid disabled endpoints are separate from explicit retirement and may re-register their active UUID. Clients unregister before discarding their bearer on logout and unsubscribe locally. Offline logout must persist a local revocation tombstone for a later authenticated retry and must never silently re-enable consent. Retry that tombstone only against its original backend and credential authority; a 204 from a different backend does not acknowledge the old revocation. Persist authority digests and installation IDs, never raw credentials. Changing source under the same authority can still revoke the old UUID. A transport request already crossing the provider boundary cannot be recalled reliably.
|
||||
|
||||
### `PUT /v1/agent/presence/:sessionId`
|
||||
|
||||
A UUID per browser document/tab, independent of notification permission and selected workspace tab:
|
||||
|
||||
```json
|
||||
{"sequence":12,"visibility":"visible","focused":true,"interacted":false}
|
||||
```
|
||||
|
||||
Response `{ "expiresAt":"2026-10-15T03:00:00.000Z" }`. Sequence is a nonnegative signed 32-bit integer; stale/out-of-order heartbeats neither overwrite state nor extend the lease. Heartbeat every 15 seconds and on focus/visibility changes and recent pointer/key/touch input. Times are assigned by the server. Visible+focused is active even without recent interaction; any recent interaction blocks alerts for another 30 seconds. Leases expire after 60 seconds. All authenticated workspace sessions must report, even without notification opt-in; at most 512 unexpired sessions are accepted. Associate the UUID with WS auth as described in the streaming contract. Unknown authenticated web sockets block dispatch until classified or their heartbeat lease expires.
|
||||
|
||||
`DELETE /v1/agent/presence/:sessionId` returns 204 and retires focus/visibility best effort; recent-interaction grace is retained until expiry. It does not revoke push consent. Backend restarts impose a full 60-second attention warm-up, with leases preserved across restart.
|
||||
|
||||
### Scheduling, read reconciliation and delivery identities
|
||||
|
||||
Only complete user-visible root assistant messages enter the durable outbox. Tool output, model reasoning, worker envelopes and raw events do not. A runtime external event or timer may result in a later qualifying assistant message. First source discovery bootstraps at the current high-water cursor without historical alerts. Checkpoint advancement and enqueue are atomic. Latest accepted human input, including queued input and runtime-native channels, delays alerts for at least five seconds; backend prompt admission also records a durable five-second guard before proxying, covering a concurrent source snapshot (a failed submission only delays briefly). Bursts debounce for two seconds, with a 15-second burst cap subject to the prompt grace. Timer/external-event results and replies arriving at least 30 seconds after a prompt receive higher queue priority; coalescing keeps the latest message identity and preserves the burst's higher priority.
|
||||
|
||||
All web sessions must be inactive at dispatch, including the runtime-native browser. Before each provider request, the backend refreshes runtime read/prompt/presence, merges native read into the canonical shared read row, and transactionally rechecks opt-in/version, owner generation, source freshness, read cursor and all attention leases. Unknown/malformed/unavailable source data suppresses sends. Messages expire after five minutes; provider TTL is at most 60 seconds. The active outbox coalesces per installation; completed receipts are capped at 10,000 and expire after seven days, while revocation tombstones remain durable. There are at most four attempts, only after definite retryable provider rejections, with bounded backoff. A persisted send intent protects crash recovery: interrupted/ambiguous network sends are never automatically replayed. Exactly-once provider/device delivery is not promised; a final focus/read race after dispatch remains possible.
|
||||
|
||||
Alert payload identity is top-level `{version:1,sourceId,kind:"message",messageId,cursor}`. APNs adds `aps.alert` with title **Sybil Agent**, body **You have a new message.**, default sound and `thread-id:sybil-agent-root`. Web Push adds those same generic title/body strings, fixed `url:"/?agent=root"` and `tag:"sybil-agent:"+sourceId`. No transcript preview, filename, event source, token or key appears in a notification. Normal APNs alerts do not request critical/time-sensitive entitlements.
|
||||
|
||||
Monotonic canonical read advances cancel pending alerts and broadcast `read_state` on authenticated WS. New read-aware clients pass the response's exact source/cursor to their notification manager; receiving/tapping a push does not itself mark history read. Native APNs clear payload is `{version:1,sourceId,kind:"read_state",readCursor,aps:{"content-available":1}}`, push type background/priority 5, coalesced to at most one request per minute per installation that has an earlier delivered/ambiguous alert. App foreground/background handlers reconcile delivered OS notifications by source/cursor. Silent pushes are best effort and cannot guarantee removal when an app is terminated or force-quit; foreground read sync remains required.
|
||||
|
||||
**No silent Web Push read-clear is sent.** Safari requires visible notifications for Web Push. Authenticated foreground pages forward source-bound read state to their service worker; it closes only owned notifications at/below that read cursor. Closing while every browser client is stopped is best effort only. The service worker must never receive/store the general bearer token. Notification click uses the fixed same-origin Agent link and waits for actual rendered history before acknowledging read.
|
||||
|
||||
### Server configuration and runtime source contract
|
||||
|
||||
Shipping is disabled by default: `AGENT_PUSH_ENABLED=false`. Native transport requires `AGENT_PUSH_APNS_KEY_FILE` (APNs-enabled ES256 `.p8`, regular secret file 0400/0600), `AGENT_PUSH_APNS_KEY_ID`, `AGENT_PUSH_APNS_TEAM_ID`, optional `AGENT_PUSH_APNS_TOPIC` (default `net.buzzert.sybil2`) and `AGENT_PUSH_APNS_ENVIRONMENT` (`production` by default, or `sandbox`; exactly one credential environment per deployment). Web Push requires `AGENT_PUSH_VAPID_FILE`, a private regular JSON file 0400/0600 containing `publicKey`, `privateKey` (base64url P-256) and `subject` (mailto/HTTPS contact). Invalid/missing/unsafe files leave only that adapter unavailable; files and parse errors are never exposed. This implementation neither creates production keys nor changes Apple portal capabilities. Correct native team/topic is `DQQH5H6GBD` / `net.buzzert.sybil2`; parent portal work, push entitlements/provisioning and real device registration are separate prerequisites. An environment-scoped production key cannot send sandbox notifications: sandbox registrations are rejected when production is configured. Concurrent sandbox/production credentials require a future explicit per-environment credential map; do not reuse one scoped key across environments.
|
||||
|
||||
Backend independently polls token-authenticated runtime `GET /api/notification-context?after=<cursor>&limit=200` and sends `X-Sybil-Client: backend-proxy-v1` on private proxy REST/WS traffic. The marker classifies the proxy for the native attention census and never replaces runtime authentication. The runtime source is version 1 with `{sourceId,conversationId,cursor,hasMore,latestUserMessageAt,nativeReadCursor,uncertain,webPresence:{active,unknown,observedAt},candidates:[{messageId,cursor,createdAt,trigger}]}`; trigger is `user|external_event|timer|worker|unknown`. No transcript text is included. `uncertain` and `webPresence.unknown` fail closed. Candidate pages preserve all candidates belonging to each consumed cursor (max 200 candidates/page), advancing across non-candidates; omitted `after` is high-water bootstrap with no old candidates. The source identity survives normal runtime restarts. If an incremental context request returns 409 because its cursor exceeds a replaced/restored runtime high-water mark, the backend makes exactly one authenticated no-after probe under the same 10-second deadline. Only a candidate-free, nonpaginated snapshot proving a different source or lower cursor is accepted, solely to quarantine and cancel old pending alerts. It never resets reads/checkpoints, rebaselines, or enumerates historical candidates. Failed/invalid probes and same-source nonregressions remain unavailable and suppress delivery. Runtime native presence/read is an acknowledgment input to the existing canonical backend read state, not a second independently authoritative read store.
|
||||
|
||||
Reference in New Issue
Block a user