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.
|
||||
|
||||
@@ -373,3 +373,20 @@ Merge queued and committed messages by stable message ID. A matching ID alone do
|
||||
Retrieve bytes using authenticated `GET /v1/agent/attachments/:id/content`; fetch with an Authorization header and use/revoke browser blob URLs for previews. Never add credentials to URLs or use a public file route. Persist completed descriptors and pending upload IDs with the composer/outbox; navigation cannot silently turn an attached draft into a text-only send. Incomplete reloaded uploads require verified file reselection or explicit removal, per the REST contract.
|
||||
|
||||
Attached input admission and its request binding are atomic. Replay verifies the durable accepted payload, including the ordered attachment IDs, before acknowledging it. Queued attachment metadata survives restart even if placement did not run before the crash; normal queue recovery places the original input without duplicate charge or delivery. Worker preparation saturation retries through durable Reporter checkpoints; permanent admission failures appear through the existing worker-report path.
|
||||
|
||||
|
||||
### Agent attention and shared read events (push v1)
|
||||
|
||||
Web auth frames add `{ "clientKind":"web", "presenceSessionId":"per-document-uuid" }` to the existing `type`, `token`, and optional `after`. Report the same UUID to `PUT /v1/agent/presence/:sessionId` across the entire authenticated workspace. Native iOS clients add `clientKind:"native"`; no browser-attention lease is required for a native app. These declarations classify a client under the existing shared principal and do not grant new authority. Old/unclassified authenticated sockets conservatively veto push delivery until closed/expired. A web-classified socket without a live matching presence receipt is also unknown. Transport ping/pong renews connection liveness, never user activity. The proxy classifies itself upstream using the authenticated `X-Sybil-Client: backend-proxy-v1` upgrade header.
|
||||
|
||||
The backend adds this optional server-to-client frame after a successful monotonic read advance, including a runtime-native browser acknowledgment merged into the shared row:
|
||||
|
||||
```json
|
||||
{"type":"read_state","cursor":"44","updatedAt":"2026-10-15T03:00:00.000Z","sourceId":"00000000-0000-4000-8000-000000000001"}
|
||||
```
|
||||
|
||||
`sourceId` is nullable before source discovery and is never silently rebound after runtime replacement. Source replacement quarantines push delivery; the backend does not reset old read cursors. Apply read cursors monotonically within their source, and only clear notifications whose payload source matches this frame and whose cursor is at/below it. Frames without a source remain usable by legacy unread indicators but cannot safely authorize notification clearing. Clients still poll `GET /v1/agent/read-state` on foreground/reconnect to recover missed frames; receiving history, activity, a push, or a notification tap does not acknowledge that history was viewed.
|
||||
|
||||
Distinguish an unbound room from one already bound to a legacy absent/null source or a source UUID. Bind on the first accepted read state; if history arrives first, pin the legacy state before publishing it. A different source in a later GET, stream frame or PUT acknowledgment, including null-to-UUID or UUID-to-null, must stop stale read writes and require an explicit fresh room load. Preserve the old snapshot and cursors until that reload, then fetch read state before fresh history; never attach a new UUID to an old cursor. Ordinary foreground/network recovery must not reset this stopped room. HTTP 409, an invalid acknowledgment cursor or an acknowledgment below the requested cursor also stops the writer. A transient transport failure can retry only within the same pinned source. None of these client reloads resets server read state.
|
||||
|
||||
Older clients may ignore unknown server frame types. `read_state` and presence are not client subscribe messages and are never forwarded to runtime as generic frames. Push consent, revoke, presence, retry/expiry and notification payload contracts live in [rest.md](./rest.md#agent-push-notifications-v1). The runtime built-in browser has its own authenticated presence/read inputs; its unknown sessions participate in the same conservative veto. No Web Push is used for invisible read synchronization.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SybilAPNSEnvironment</key>
|
||||
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array><string>remote-notification</string></array>
|
||||
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Sybil
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
struct SybilApp: App
|
||||
@@ -18,11 +19,13 @@ struct SybilApp: App
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
final class SybilAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
SybilPushNotifications.launchFixtureIfRequested()
|
||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.start()
|
||||
@@ -30,6 +33,31 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
SybilPushNotifications.didRegister(deviceToken: deviceToken)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
SybilPushNotifications.didFailToRegister()
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
SybilPushNotifications.receiveBackground(userInfo: userInfo) { result in completionHandler(result) }
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
||||
let event = SybilPushNotifications.Event(userInfo: notification.request.content.userInfo)
|
||||
return await SybilPushNotifications.presentationOptions(event: event)
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
|
||||
if response.actionIdentifier == UNNotificationDefaultActionIdentifier {
|
||||
let event = SybilPushNotifications.Event(userInfo: response.notification.request.content.userInfo)
|
||||
await SybilPushNotifications.didRespond(event: event, identifier: response.notification.request.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
SybilMacQuickQuestionController.shared.stop()
|
||||
@@ -73,6 +101,9 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
if let shortcutItem = connectionOptions.shortcutItem {
|
||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||
}
|
||||
if let response = connectionOptions.notificationResponse, response.actionIdentifier == UNNotificationDefaultActionIdentifier {
|
||||
SybilPushNotifications.didRespond(userInfo: response.notification.request.content.userInfo, identifier: response.notification.request.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,6 +5,12 @@ targets:
|
||||
- iOS
|
||||
- macCatalyst
|
||||
deploymentTarget: "18.0"
|
||||
attributes:
|
||||
SystemCapabilities:
|
||||
com.apple.Push:
|
||||
enabled: 1
|
||||
com.apple.BackgroundModes:
|
||||
enabled: 1
|
||||
sources:
|
||||
- Sources
|
||||
- Resources
|
||||
@@ -25,6 +31,9 @@ targets:
|
||||
PRODUCT_MODULE_NAME: SybilApp
|
||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]": Apps/Sybil/Sybil.entitlements
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]": Apps/Sybil/Sybil.entitlements
|
||||
INFOPLIST_KEY_SybilAPNSEnvironment: "$(SYBIL_APNS_ENVIRONMENT)"
|
||||
SWIFT_VERSION: 6.0
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||
@@ -41,7 +50,10 @@ targets:
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||
configs:
|
||||
Debug:
|
||||
SYBIL_APNS_ENVIRONMENT: development
|
||||
Release:
|
||||
SYBIL_APNS_ENVIRONMENT: production
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGN_IDENTITY: Apple Distribution
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
|
||||
|
||||
@@ -11,6 +11,7 @@ public struct SplitView: View {
|
||||
@State private var hasPendingQuickQuestionPresentation = false
|
||||
@State private var isQuickQuestionPresented = false
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
||||
@State private var notificationSceneID = UUID()
|
||||
|
||||
private var keyboardActions: SybilKeyboardActions? {
|
||||
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
||||
@@ -92,8 +93,14 @@ public struct SplitView: View {
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
await viewModel.bootstrap()
|
||||
SybilPushNotifications.foregroundFixtureIfRequested()
|
||||
viewModel.notifications.setScene(notificationSceneID, active: scenePhase == .active)
|
||||
routePendingNotification()
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onChange(of: viewModel.notifications.pendingRouteID) { _, _ in routePendingNotification() }
|
||||
.onChange(of: viewModel.notifications.scope) { _, _ in routePendingNotification() }
|
||||
.onDisappear { viewModel.notifications.setScene(notificationSceneID, active: false) }
|
||||
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
|
||||
guard request > 0 else {
|
||||
return
|
||||
@@ -101,6 +108,7 @@ public struct SplitView: View {
|
||||
queueQuickQuestionPresentation()
|
||||
}
|
||||
.onChange(of: viewModel.isCheckingSession) { _, _ in
|
||||
routePendingNotification()
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
||||
@@ -110,11 +118,14 @@ public struct SplitView: View {
|
||||
viewModel.agent.isModalPresented = presented
|
||||
}
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
viewModel.notifications.setScene(notificationSceneID, active: nextPhase == .active)
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
SybilPushNotifications.foregroundFixtureIfRequested()
|
||||
routePendingNotification()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
@@ -138,6 +149,13 @@ public struct SplitView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func routePendingNotification() {
|
||||
guard scenePhase == .active, viewModel.consumeAgentNotificationRoute() else { return }
|
||||
hasPendingQuickQuestionPresentation = false
|
||||
isQuickQuestionPresented = false
|
||||
columnVisibility = .detailOnly
|
||||
}
|
||||
|
||||
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
|
||||
return size.width < size.height ? .showSidebar : .hidden
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ struct AnyEncodable: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting, AgentPushAPIClienting {
|
||||
private let configuration: APIConfiguration
|
||||
private let session: URLSession
|
||||
|
||||
@@ -64,8 +64,51 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self)
|
||||
}
|
||||
|
||||
func agentPushConfig() async throws -> AgentPushConfig {
|
||||
let data = try await pushRequest(path: "/v1/agent/push/config", method: "GET")
|
||||
guard let config = try? JSONDecoder().decode(AgentPushConfig.self, from: data) else { throw APIError.invalidResponse }
|
||||
return config
|
||||
}
|
||||
|
||||
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt {
|
||||
let data = try await pushRequest(path: "/v1/agent/push/installations/\(id.uuidString.lowercased())", method: "PUT", body: AnyEncodable(registration))
|
||||
guard let receipt = try? JSONDecoder().decode(AgentPushReceipt.self, from: data) else { throw APIError.invalidResponse }
|
||||
return receipt
|
||||
}
|
||||
|
||||
func agentRevokePush(id: UUID) async throws {
|
||||
_ = try await pushRequest(path: "/v1/agent/push/installations/\(id.uuidString.lowercased())", method: "DELETE")
|
||||
}
|
||||
|
||||
private func pushRequest(path: String, method: String, body: AnyEncodable? = nil) async throws -> Data {
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
let allowLoopback = true // Local URLProtocol/UI fixtures; no device enrollment.
|
||||
#else
|
||||
let allowLoopback = false
|
||||
#endif
|
||||
guard AgentPushScope.canonicalBackend(configuration.baseURL, allowLoopback: allowLoopback) != nil,
|
||||
configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw APIError.invalidBaseURL }
|
||||
var request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
|
||||
request.timeoutInterval = 8
|
||||
request.setValue("no-store", forHTTPHeaderField: "Cache-Control")
|
||||
// Same bounded, redirect-rejecting authenticated Agent transport as attachments.
|
||||
// Never decode/log a server error body that might reflect an address or token.
|
||||
let (data, status, _) = try await attachmentBytes(request, maximum: 64 * 1024)
|
||||
guard method == "DELETE" ? status == 204 : (200...299).contains(status) else { throw AgentPushRequestError(status: status) }
|
||||
return data
|
||||
}
|
||||
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState {
|
||||
try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(["cursor": try AgentCursor.normalize(cursor)]), responseType: AgentReadState.self)
|
||||
try await agentMarkRead(cursor: cursor, sourceID: nil)
|
||||
}
|
||||
|
||||
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
|
||||
var body = ["cursor": try AgentCursor.normalize(cursor)]
|
||||
if let sourceID {
|
||||
guard AgentPushPayload.validSource(sourceID) else { throw APIError.invalidResponse }
|
||||
body["sourceId"] = sourceID
|
||||
}
|
||||
return try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(body), responseType: AgentReadState.self)
|
||||
}
|
||||
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
|
||||
@@ -73,6 +73,7 @@ struct AgentPage: Codable, Sendable {
|
||||
struct AgentReadState: Codable, Sendable {
|
||||
var cursor: String
|
||||
var updatedAt: String?
|
||||
var sourceId: String? = nil
|
||||
}
|
||||
struct AgentReceipt: Codable, Sendable {
|
||||
var id: Int64
|
||||
@@ -95,6 +96,7 @@ enum AgentEvent: Sendable {
|
||||
case ready(String?)
|
||||
case messages(AgentPage)
|
||||
case activity(String, AgentActivity)
|
||||
case readState(AgentReadState)
|
||||
case heartbeat
|
||||
case error(String)
|
||||
|
||||
@@ -118,6 +120,13 @@ enum AgentEvent: Sendable {
|
||||
}
|
||||
return .activity(id, activity)
|
||||
case "pong", "heartbeat": return .heartbeat
|
||||
case "read_state":
|
||||
// Additive optional frame: malformed new metadata must not tear down
|
||||
// an otherwise compatible Agent connection.
|
||||
guard let read = try? AgentCoding.decoder().decode(AgentReadState.self, from: data),
|
||||
AgentPushPayload.validCursor(read.cursor) != nil,
|
||||
read.sourceId.map(AgentPushPayload.validSource) ?? true else { return nil }
|
||||
return .readState(read)
|
||||
case "error": return .error(envelope.error ?? "Agent connection error.")
|
||||
default: return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import CoreFoundation
|
||||
import CryptoKit
|
||||
|
||||
struct AgentPushConfig: Codable, Sendable {
|
||||
var version: Int
|
||||
var enabled: Bool
|
||||
var apnsAvailable: Bool
|
||||
var webPushAvailable: Bool
|
||||
var sourceId: String?
|
||||
var quarantined: Bool
|
||||
var available: Bool { version == 1 && enabled && apnsAvailable && !quarantined && sourceId.map(AgentPushPayload.validSource) == true }
|
||||
}
|
||||
|
||||
enum AgentAPNSEnvironment: String, Codable, Sendable {
|
||||
case sandbox, production
|
||||
// The app's Info.plist and entitlement expand the SAME XcodeGen build setting.
|
||||
// Never guess from DEBUG, token length, simulator, or marketing version.
|
||||
init?(entitlementSetting: String?) {
|
||||
switch entitlementSetting {
|
||||
case "development": self = .sandbox
|
||||
case "production": self = .production
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentPushRegistration: Encodable, Equatable, Sendable {
|
||||
let transport = "apns"
|
||||
let enabled = true
|
||||
var token: String
|
||||
var environment: AgentAPNSEnvironment
|
||||
let topic = "net.buzzert.sybil2"
|
||||
}
|
||||
|
||||
struct AgentPushReceipt: Codable, Sendable {
|
||||
var installationId: String
|
||||
var transport: String
|
||||
var enabled: Bool
|
||||
var version: Int
|
||||
}
|
||||
|
||||
protocol AgentPushAPIClienting: Sendable {
|
||||
func agentPushConfig() async throws -> AgentPushConfig
|
||||
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt
|
||||
func agentRevokePush(id: UUID) async throws
|
||||
func agentReadState() async throws -> AgentReadState
|
||||
}
|
||||
|
||||
struct AgentPushRequestError: LocalizedError, Sendable {
|
||||
var status: Int
|
||||
var retryable: Bool { status == 408 || status == 429 || status >= 500 }
|
||||
var errorDescription: String? { "Agent notification request failed (\(status))." }
|
||||
}
|
||||
|
||||
struct AgentPushScope: Codable, Equatable, Hashable, Sendable {
|
||||
var backend: String
|
||||
// ADMIN_TOKEN identifies one shared personal principal, not individual people.
|
||||
var owner = "shared-personal-admin"
|
||||
var credentialScope: String
|
||||
var sourceID: String
|
||||
|
||||
static func credentialScope(backend: String, token: String) -> String {
|
||||
// Nonsecret consent epoch, never logged. A credential replacement requires
|
||||
// new consent even when the server still represents one personal principal.
|
||||
SHA256.hash(data: Data((backend + "\n" + token.trimmingCharacters(in: .whitespacesAndNewlines)).utf8))
|
||||
.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
static func canonicalBackend(_ url: URL, allowLoopback: Bool = false) -> String? {
|
||||
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let host = parts.host?.lowercased(), parts.user == nil, parts.password == nil,
|
||||
parts.query == nil, parts.fragment == nil,
|
||||
parts.scheme?.lowercased() == "https" || (allowLoopback && parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)) else { return nil }
|
||||
parts.scheme = parts.scheme?.lowercased(); parts.host = host
|
||||
if (parts.scheme == "https" && parts.port == 443) || (parts.scheme == "http" && parts.port == 80) { parts.port = nil }
|
||||
let path = parts.path
|
||||
guard !path.contains("//"), !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) else { return nil }
|
||||
parts.path = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty ? "" : "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
return parts.url?.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentPushPayload: Equatable, Sendable {
|
||||
enum Kind: String { case message, readState = "read_state" }
|
||||
var sourceID: String
|
||||
var kind: Kind
|
||||
var cursor: String
|
||||
var messageID: String?
|
||||
|
||||
init?(_ info: [AnyHashable: Any]) {
|
||||
guard let version = info["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(), version == 1,
|
||||
let source = info["sourceId"] as? String, Self.validSource(source),
|
||||
let name = info["kind"] as? String, let kind = Kind(rawValue: name),
|
||||
let raw = info[kind == .message ? "cursor" : "readCursor"] as? String,
|
||||
let cursor = Self.validCursor(raw) else { return nil }
|
||||
let message = info["messageId"] as? String
|
||||
if kind == .message && (message?.isEmpty != false || (message?.utf8.count ?? 0) > 256) { return nil }
|
||||
self.sourceID = source.lowercased(); self.kind = kind; self.cursor = cursor; self.messageID = message
|
||||
}
|
||||
|
||||
static func validSource(_ value: String) -> Bool {
|
||||
value.utf8.count == 36 && UUID(uuidString: value) != nil
|
||||
}
|
||||
static func validCursor(_ value: String) -> String? {
|
||||
guard value.utf8.count <= 16, let normalized = try? AgentCursor.normalize(value),
|
||||
!AgentCursor.less("9007199254740991", normalized) else { return nil }
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentDeliveredNotification: Sendable {
|
||||
var identifier: String // UNNotification.request.identifier, never messageId.
|
||||
var payload: AgentPushPayload?
|
||||
}
|
||||
|
||||
enum AgentNotificationPermission: Sendable { case notDetermined, denied, authorized, provisional }
|
||||
|
||||
@MainActor
|
||||
protocol AgentNotificationPlatform: AnyObject {
|
||||
var environment: AgentAPNSEnvironment? { get }
|
||||
var allowsLoopback: Bool { get }
|
||||
func permission() async -> AgentNotificationPermission
|
||||
func requestPermission() async throws -> Bool
|
||||
func register(generation: UUID)
|
||||
func unregister()
|
||||
func delivered() async -> [AgentDeliveredNotification]
|
||||
func removeDelivered(identifiers: [String])
|
||||
}
|
||||
|
||||
struct AgentPushRecord: Codable, Equatable {
|
||||
var scope: AgentPushScope
|
||||
var installationID = UUID()
|
||||
var optedIn = false
|
||||
var mayBeRegistered = false
|
||||
var version: Int?
|
||||
}
|
||||
|
||||
struct AgentPushPersistence: Codable {
|
||||
var records: [AgentPushRecord] = []
|
||||
var tombstones: [AgentPushRecord] = []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct AgentPushStorage {
|
||||
var read: () throws -> AgentPushPersistence
|
||||
var write: (AgentPushPersistence) throws -> Void
|
||||
|
||||
static func defaults(_ defaults: UserDefaults = .standard) -> Self {
|
||||
let key = "sybil.ios.agentPush.v1"
|
||||
return Self(read: {
|
||||
guard let data = defaults.data(forKey: key) else { return .init() }
|
||||
return try JSONDecoder().decode(AgentPushPersistence.self, from: data)
|
||||
}, write: { value in
|
||||
// No authentication credential or device token is ever persisted here.
|
||||
defaults.set(try JSONEncoder().encode(value), forKey: key)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// One app-lifetime owner of push enrollment. OS addresses stay in memory; retired
|
||||
/// installation IDs are never reused. Every suspension checks its session generation.
|
||||
@MainActor @Observable
|
||||
final class SybilAgentNotifications {
|
||||
static let shared = SybilAgentNotifications(platform: SybilNotificationPlatform.shared, storage: .defaults())
|
||||
|
||||
private(set) var optedIn = false
|
||||
private(set) var available = false
|
||||
private(set) var permission: AgentNotificationPermission = .notDetermined
|
||||
private(set) var status = "Connect to a server to configure notifications."
|
||||
private(set) var pendingRouteID: UUID?
|
||||
private(set) var generation = UUID()
|
||||
private(set) var scope: AgentPushScope?
|
||||
private(set) var pendingRevocations = 0
|
||||
@ObservationIgnored private let platform: any AgentNotificationPlatform
|
||||
@ObservationIgnored private let storage: AgentPushStorage
|
||||
@ObservationIgnored private var saved = AgentPushPersistence()
|
||||
@ObservationIgnored private var storageHealthy = true
|
||||
@ObservationIgnored private var client: (any AgentPushAPIClienting)?
|
||||
@ObservationIgnored private var backend: String?
|
||||
@ObservationIgnored private var credentialScope: String?
|
||||
@ObservationIgnored private var token: String?
|
||||
@ObservationIgnored private var uploaded: AgentPushRegistration?
|
||||
@ObservationIgnored private var enrollment: Task<Void, Never>?
|
||||
@ObservationIgnored private var refreshing: UUID?
|
||||
@ObservationIgnored private var pendingPayload: AgentPushPayload?
|
||||
@ObservationIgnored private var consumedResponses: [String] = []
|
||||
@ObservationIgnored private var readCursor = "0"
|
||||
@ObservationIgnored private var foregroundScenes: Set<UUID> = []
|
||||
@ObservationIgnored private var sessionBackend: String?
|
||||
@ObservationIgnored private var sessionIdentity: String?
|
||||
|
||||
func transition(baseURL: URL?, token: String?) async {
|
||||
// Multiple windows and ordinary reconnects share one app enrollment. Compare
|
||||
// credentials only in memory; never derive/persist an owner from a token.
|
||||
let canonical = baseURL.flatMap { AgentPushScope.canonicalBackend($0, allowLoopback: platform.allowsLoopback) }
|
||||
let identity = canonical.flatMap { backend in token.map { AgentPushScope.credentialScope(backend: backend, token: $0) } }
|
||||
guard sessionBackend != canonical || sessionIdentity != identity else { return }
|
||||
let hadSession = sessionBackend != nil || sessionIdentity != nil
|
||||
sessionBackend = canonical; sessionIdentity = identity
|
||||
if hadSession { await deactivate() }
|
||||
}
|
||||
|
||||
init(platform: any AgentNotificationPlatform, storage: AgentPushStorage) {
|
||||
self.platform = platform; self.storage = storage
|
||||
do { saved = try storage.read(); pendingRevocations = saved.tombstones.count }
|
||||
catch { storageHealthy = false; status = "Notification settings could not be restored." }
|
||||
}
|
||||
|
||||
private func persist() -> Bool {
|
||||
do { try storage.write(saved); pendingRevocations = saved.tombstones.count; return true }
|
||||
catch { storageHealthy = false; available = false; status = "Notification settings could not be saved."; return false }
|
||||
}
|
||||
private var recordIndex: Int? { scope.flatMap { scope in saved.records.firstIndex { $0.scope == scope } } }
|
||||
|
||||
/// Call BEFORE overwriting the old session settings/client. This invalidates OS,
|
||||
/// config, read, permission and upload callbacks before attempting revocation.
|
||||
@discardableResult func deactivate() async -> UUID {
|
||||
let oldClient = client
|
||||
let oldScope = scope
|
||||
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
|
||||
let retiringGeneration = generation
|
||||
refreshing = nil; available = false; optedIn = false; readCursor = "0"
|
||||
scope = nil; client = nil; backend = nil; credentialScope = nil
|
||||
if oldScope != nil { pendingPayload = nil; pendingRouteID = nil }
|
||||
platform.unregister()
|
||||
if let oldScope { retire(oldScope, clearOptIn: true) }
|
||||
// Server DELETE is a terminal UUID tombstone, even if PUT is in flight.
|
||||
// Attempt it before settings are replaced. A stale PUT also retries revoke.
|
||||
if let oldClient, let oldScope {
|
||||
await revokePending(client: oldClient, scope: oldScope)
|
||||
}
|
||||
if generation == retiringGeneration {
|
||||
status = pendingRevocations > 0 ? "Revocation pending. Reconnect to the original server to retry." : "Notifications are off."
|
||||
}
|
||||
return retiringGeneration
|
||||
}
|
||||
|
||||
func bind(client: any AgentPushAPIClienting, baseURL: URL, authenticated: Bool, mode: String, token: String?) async {
|
||||
await transition(baseURL: baseURL, token: token)
|
||||
let requestedBackend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback)
|
||||
let requestedIdentity = requestedBackend.flatMap { backend in token.map { AgentPushScope.credentialScope(backend: backend, token: $0) } }
|
||||
guard sessionBackend == requestedBackend, sessionIdentity == requestedIdentity else { return }
|
||||
guard authenticated, mode == "token", let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, storageHealthy,
|
||||
let backend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback) else {
|
||||
if self.client != nil { await deactivate() }
|
||||
available = false; status = "Notifications require a token-authenticated HTTPS server."; return
|
||||
}
|
||||
self.client = client; self.backend = backend
|
||||
self.credentialScope = AgentPushScope.credentialScope(backend: backend, token: token)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard refreshing == nil, let client, let backend, let credentialScope else { return }
|
||||
let refreshID = UUID()
|
||||
refreshing = refreshID
|
||||
let run = generation
|
||||
defer { if refreshing == refreshID { refreshing = nil } }
|
||||
do {
|
||||
let config = try await client.agentPushConfig()
|
||||
guard generation == run else { return }
|
||||
guard config.version == 1, let source = config.sourceId, AgentPushPayload.validSource(source) else {
|
||||
available = false; status = "This server does not support Agent notifications."; return
|
||||
}
|
||||
let nextScope = AgentPushScope(backend: backend, credentialScope: credentialScope, sourceID: source.lowercased())
|
||||
if let scope, scope != nextScope {
|
||||
let retired = await deactivate()
|
||||
guard generation == retired else { return }
|
||||
self.client = client; self.backend = backend; self.credentialScope = credentialScope
|
||||
await refresh()
|
||||
return
|
||||
}
|
||||
await revokePending(client: client, scope: nextScope)
|
||||
guard generation == run else { return }
|
||||
scope = nextScope
|
||||
if recordIndex == nil { saved.records.append(AgentPushRecord(scope: nextScope)); guard persist() else { return } }
|
||||
optedIn = recordIndex.map { saved.records[$0].optedIn } ?? false
|
||||
available = config.available && platform.environment != nil
|
||||
permission = await platform.permission()
|
||||
guard generation == run else { return }
|
||||
if permission == .denied {
|
||||
await disable()
|
||||
guard self.scope == nextScope else { return }
|
||||
status = "Notifications are denied in iOS Settings."
|
||||
return
|
||||
}
|
||||
if !available {
|
||||
if let index = recordIndex, saved.records[index].mayBeRegistered {
|
||||
// Invalidate both the cached success and any outstanding PUT
|
||||
// before replacing the UUID. A fresh callback must enroll the
|
||||
// replacement even when APNs returns the same token.
|
||||
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
|
||||
refreshing = nil; platform.unregister()
|
||||
let retirementRun = generation
|
||||
retire(nextScope); await revokePending(client: client, scope: nextScope)
|
||||
guard generation == retirementRun else { return }
|
||||
}
|
||||
status = "Agent notifications are unavailable on this server or build."
|
||||
} else if optedIn && (permission == .authorized || permission == .provisional) {
|
||||
status = "Registering notifications…"
|
||||
// A fresh OS callback is required on each launch/foreground refresh.
|
||||
platform.register(generation: run)
|
||||
} else { status = optedIn ? "Enable notifications to allow alerts." : "Notifications are off." }
|
||||
await syncRead()
|
||||
} catch {
|
||||
guard generation == run else { return }
|
||||
available = false
|
||||
status = "Agent notifications are unavailable. Retry after reconnecting."
|
||||
}
|
||||
}
|
||||
|
||||
/// The only code path that can display the system permission prompt.
|
||||
func enable() async {
|
||||
guard available, let index = recordIndex else { return }
|
||||
let run = generation
|
||||
do {
|
||||
let current = await platform.permission()
|
||||
guard generation == run else { return }
|
||||
let allowed: Bool
|
||||
if current == .notDetermined { allowed = try await platform.requestPermission() }
|
||||
else { allowed = current == .authorized || current == .provisional }
|
||||
guard generation == run else { return }
|
||||
permission = await platform.permission()
|
||||
guard generation == run else { return }
|
||||
guard allowed else { status = "Notifications are denied in iOS Settings."; return }
|
||||
saved.records[index].optedIn = true
|
||||
guard persist() else { return }
|
||||
optedIn = true; status = "Registering notifications…"
|
||||
platform.register(generation: run)
|
||||
} catch { if generation == run { status = "Could not request notification permission. Try again." } }
|
||||
}
|
||||
|
||||
func disable() async {
|
||||
guard let scope else { return }
|
||||
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
|
||||
let disablingGeneration = generation
|
||||
refreshing = nil
|
||||
optedIn = false; platform.unregister()
|
||||
retire(scope, clearOptIn: true)
|
||||
if let client { await revokePending(client: client, scope: scope) }
|
||||
if generation == disablingGeneration {
|
||||
status = pendingRevocations > 0 ? "Notifications off; revocation pending until this server reconnects." : "Notifications are off."
|
||||
}
|
||||
}
|
||||
|
||||
private func retire(_ scope: AgentPushScope, clearOptIn: Bool = false) {
|
||||
guard let index = saved.records.firstIndex(where: { $0.scope == scope }) else { return }
|
||||
let old = saved.records[index]
|
||||
if self.scope == scope { uploaded = nil; token = nil }
|
||||
if old.mayBeRegistered && !saved.tombstones.contains(where: { $0.installationID == old.installationID }) {
|
||||
saved.tombstones.append(old)
|
||||
}
|
||||
saved.records[index] = AgentPushRecord(scope: scope, optedIn: clearOptIn ? false : old.optedIn)
|
||||
_ = persist()
|
||||
}
|
||||
|
||||
private func revokePending(client: any AgentPushAPIClienting, scope: AgentPushScope) async {
|
||||
for record in saved.tombstones.filter({ $0.scope == scope }) {
|
||||
do {
|
||||
try await client.agentRevokePush(id: record.installationID)
|
||||
saved.tombstones.removeAll { $0.installationID == record.installationID }
|
||||
_ = persist()
|
||||
} catch { /* Durable nonsecret tombstone survives offline/logout. */ }
|
||||
}
|
||||
}
|
||||
|
||||
func receivedDeviceToken(_ data: Data, generation run: UUID) {
|
||||
guard generation == run, available, optedIn, !data.isEmpty, data.count <= 256 else { return }
|
||||
token = data.map { String(format: "%02x", $0) }.joined()
|
||||
startEnrollment()
|
||||
}
|
||||
func registrationFailed(generation run: UUID) {
|
||||
guard generation == run else { return }
|
||||
status = "iOS registration failed. Retry when connected."
|
||||
}
|
||||
|
||||
private func startEnrollment() {
|
||||
guard enrollment == nil, optedIn, available, let client, let token,
|
||||
let environment = platform.environment, let index = recordIndex else { return }
|
||||
let registration = AgentPushRegistration(token: token, environment: environment)
|
||||
guard uploaded != registration else { status = "Notifications are enabled."; return }
|
||||
let record = saved.records[index], run = generation
|
||||
// Persist revocation intent BEFORE the first request, even if its response is lost.
|
||||
saved.records[index].mayBeRegistered = true
|
||||
guard persist() else { return }
|
||||
enrollment = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer {
|
||||
if self.generation == run {
|
||||
self.enrollment = nil
|
||||
if self.token != token { self.startEnrollment() }
|
||||
}
|
||||
}
|
||||
for attempt in 0..<3 {
|
||||
do {
|
||||
let receipt = try await client.agentRegisterPush(id: record.installationID, registration: registration)
|
||||
guard self.generation == run else {
|
||||
await self.revokePending(client: client, scope: record.scope); return
|
||||
}
|
||||
guard UUID(uuidString: receipt.installationId) == record.installationID,
|
||||
receipt.transport == "apns", receipt.enabled, receipt.version > 0 else { throw APIError.invalidResponse }
|
||||
guard let index = self.recordIndex, self.saved.records[index].installationID == record.installationID else { return }
|
||||
self.saved.records[index].version = receipt.version
|
||||
guard self.persist() else { return }
|
||||
self.uploaded = registration; self.status = "Notifications are enabled."
|
||||
return
|
||||
} catch {
|
||||
guard self.generation == run else {
|
||||
await self.revokePending(client: client, scope: record.scope); return
|
||||
}
|
||||
if let failure = error as? AgentPushRequestError, !failure.retryable {
|
||||
self.status = "The server rejected this notification registration. Disable it and check the server configuration before enabling again."
|
||||
return
|
||||
}
|
||||
self.status = "Registration pending. Retry when connected."
|
||||
if attempt < 2 { try? await Task.sleep(for: .seconds(attempt + 1)) }
|
||||
guard self.generation == run else { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setScene(_ id: UUID, active: Bool) {
|
||||
if active { foregroundScenes.insert(id) } else { foregroundScenes.remove(id) }
|
||||
}
|
||||
|
||||
func shouldPresent(_ payload: AgentPushPayload) -> Bool {
|
||||
// Foreground updates already flow through the Agent transcript. Never mark
|
||||
// them read here; the visible, bottom-rendered, settled guard owns that.
|
||||
matches(payload) && payload.kind == .message && foregroundScenes.isEmpty && AgentCursor.less(readCursor, payload.cursor)
|
||||
}
|
||||
|
||||
private func matches(_ payload: AgentPushPayload) -> Bool {
|
||||
guard let scope, scope.sourceID == payload.sourceID else { return false }
|
||||
// The wire payload has no backend origin. If one source is known at two
|
||||
// origins, refuse to route/clear rather than guess which server sent it.
|
||||
// Credentials may rotate for the same shared personal owner and runtime.
|
||||
// Old revoke tombstones must not veto a newly consented installation there.
|
||||
return !(saved.records + saved.tombstones).contains {
|
||||
$0.scope.sourceID == payload.sourceID &&
|
||||
($0.scope.backend != scope.backend || $0.scope.owner != scope.owner)
|
||||
}
|
||||
}
|
||||
|
||||
func syncRead() async {
|
||||
guard let client else { return }
|
||||
let run = generation
|
||||
if let read = try? await client.agentReadState(), generation == run { await acknowledged(read, generation: run) }
|
||||
}
|
||||
|
||||
func acknowledged(_ read: AgentReadState, generation run: UUID) async {
|
||||
guard generation == run, let source = read.sourceId, source.lowercased() == scope?.sourceID,
|
||||
let cursor = AgentPushPayload.validCursor(read.cursor) else { return }
|
||||
readCursor = (try? AgentCursor.advance(readCursor, cursor)) ?? readCursor
|
||||
await clearDelivered(generation: run)
|
||||
}
|
||||
|
||||
private func clearDelivered(generation run: UUID) async {
|
||||
let delivered = await platform.delivered()
|
||||
guard generation == run else { return }
|
||||
let ids = delivered.compactMap { notification -> String? in
|
||||
guard let payload = notification.payload, matches(payload), payload.kind == .message,
|
||||
!AgentCursor.less(readCursor, payload.cursor) else { return nil }
|
||||
return notification.identifier
|
||||
}
|
||||
if !ids.isEmpty { platform.removeDelivered(identifiers: ids) }
|
||||
}
|
||||
|
||||
func receiveBackground(_ payload: AgentPushPayload) async -> Bool {
|
||||
guard payload.kind == .readState, matches(payload) else { return false }
|
||||
await acknowledged(AgentReadState(cursor: payload.cursor, sourceId: payload.sourceID), generation: generation)
|
||||
// No foreground socket, no PUT read receipt, no badge reset. APNs is the
|
||||
// source of this scoped read hint; foreground GET converges missed hints.
|
||||
return true
|
||||
}
|
||||
|
||||
/// Cold background delivery may run before SwiftUI/auth bootstrap. Only restore
|
||||
/// the opted-in record at the currently saved backend; never enroll from here.
|
||||
func restoreBackgroundScope(baseURL: URL, token: String?, payload: AgentPushPayload) {
|
||||
guard client == nil, let token, !token.isEmpty, let backend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback),
|
||||
let record = saved.records.first(where: { $0.scope.backend == backend && $0.scope.credentialScope == AgentPushScope.credentialScope(backend: backend, token: token) && $0.scope.sourceID == payload.sourceID && $0.optedIn && $0.mayBeRegistered }) else { return }
|
||||
scope = record.scope
|
||||
}
|
||||
|
||||
func notificationResponse(_ payload: AgentPushPayload, identifier: String) {
|
||||
guard payload.kind == .message else { return }
|
||||
let identity = payload.sourceID + "|" + (payload.messageID ?? "") + "|" + payload.cursor
|
||||
guard !consumedResponses.contains(identity) else { return }
|
||||
consumedResponses.append(identity)
|
||||
if consumedResponses.count > 64 { consumedResponses.removeFirst() }
|
||||
pendingPayload = payload; pendingRouteID = UUID()
|
||||
}
|
||||
|
||||
func consumeRoute() -> Bool {
|
||||
guard let payload = pendingPayload, client != nil, matches(payload) else { return false }
|
||||
pendingPayload = nil; pendingRouteID = nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ final class SybilAgentRoom {
|
||||
private(set) var connection = "Offline"
|
||||
private(set) var error: String?
|
||||
private(set) var isLoading = false
|
||||
private(set) var requiresReload = false
|
||||
private(set) var outbox: [AgentOutboxRow] = []
|
||||
let attachmentDraft = AgentAttachmentDraft()
|
||||
private(set) var attachmentSessionID = UUID()
|
||||
@@ -23,6 +24,11 @@ final class SybilAgentRoom {
|
||||
func setAttachmentModal(_ id: UUID, presented: Bool) {
|
||||
if presented { attachmentModals.insert(id) } else { attachmentModals.remove(id) }
|
||||
}
|
||||
func dismissAttachmentPresentations() {
|
||||
// Existing picker/share views observe this identity and dismiss themselves.
|
||||
// Keep draft contents and transcript/view identity intact during routing.
|
||||
attachmentSessionID = UUID()
|
||||
}
|
||||
var composer: String {
|
||||
get { attachmentDraft.caption }
|
||||
set { attachmentDraft.caption = newValue }
|
||||
@@ -31,6 +37,15 @@ final class SybilAgentRoom {
|
||||
var isModalPresented = false
|
||||
var isSelected = false
|
||||
private(set) var isActive = true
|
||||
@ObservationIgnored var onAcknowledgedRead: ((AgentReadState) -> Void)?
|
||||
private enum ReadSourceBinding: Equatable {
|
||||
case unbound, legacy, source(String)
|
||||
}
|
||||
@ObservationIgnored private var readBinding: ReadSourceBinding = .unbound
|
||||
private var readSourceID: String? {
|
||||
if case let .source(id) = readBinding { return id }
|
||||
return nil
|
||||
}
|
||||
|
||||
@ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads()
|
||||
@ObservationIgnored private var client: (any AgentAPIClienting)?
|
||||
@@ -89,8 +104,7 @@ final class SybilAgentRoom {
|
||||
Task { await oldDownloads.invalidate() }
|
||||
attachmentDownloads = AgentAttachmentDownloads()
|
||||
attachmentDraft.configure(client: client, storage: draftStorage)
|
||||
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
|
||||
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
|
||||
resetHistory()
|
||||
error = nil; storageReadable = true
|
||||
do {
|
||||
outbox = try storage.read().map { row in
|
||||
@@ -154,10 +168,32 @@ final class SybilAgentRoom {
|
||||
}
|
||||
}
|
||||
|
||||
func retryConnection() { flush(); stop(); if isActive { start() } }
|
||||
func retryConnection() {
|
||||
flush(); stop()
|
||||
// Explicit Reconnect starts a new history/source boundary. Foreground and
|
||||
// network recovery must not relabel the preserved old room automatically.
|
||||
if requiresReload { resetHistory(); error = nil }
|
||||
if isActive { start() }
|
||||
}
|
||||
|
||||
private func resetHistory() {
|
||||
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
|
||||
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
|
||||
readBinding = .unbound; requiresReload = false
|
||||
}
|
||||
|
||||
private func requireReload(_ reason: String) -> AgentFailure {
|
||||
let failure = AgentFailure(message: reason + " Reconnect to load fresh Agent history.")
|
||||
requiresReload = true
|
||||
// Preserve already accepted old cursors/history, cancel every old writer,
|
||||
// and invalidate callbacks before allowing any further source observation.
|
||||
flush(); stop()
|
||||
isLoading = false; connection = "Reload required"; error = failure.localizedDescription
|
||||
return failure
|
||||
}
|
||||
|
||||
private func start() {
|
||||
guard isActive, let client, connectionTask == nil else { return }
|
||||
guard !requiresReload, isActive, let client, connectionTask == nil else { return }
|
||||
let run = generation
|
||||
isLoading = !hasLoaded
|
||||
connection = "Connecting"
|
||||
@@ -168,7 +204,7 @@ final class SybilAgentRoom {
|
||||
guard let self, self.generation == run else { return }
|
||||
let recovered = self.wasNetworkOffline && satisfied
|
||||
self.wasNetworkOffline = !satisfied
|
||||
if recovered { self.retryConnection() }
|
||||
if recovered && !self.requiresReload { self.retryConnection() }
|
||||
}
|
||||
}
|
||||
monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network"))
|
||||
@@ -245,7 +281,7 @@ final class SybilAgentRoom {
|
||||
|
||||
func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws {
|
||||
let run = run ?? generation
|
||||
guard generation == run, !Task.isCancelled, !syncing else { return }
|
||||
guard !requiresReload, generation == run, !Task.isCancelled, !syncing else { return }
|
||||
syncing = true
|
||||
defer { if generation == run { syncing = false } }
|
||||
var cursor = pending.cursor
|
||||
@@ -264,15 +300,21 @@ final class SybilAgentRoom {
|
||||
}
|
||||
|
||||
func receive(_ event: AgentEvent, replay: Bool = false) throws {
|
||||
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
|
||||
let before = pending
|
||||
var next = pending
|
||||
switch event {
|
||||
case let .messages(page): try next.apply(page)
|
||||
case let .messages(page):
|
||||
try next.apply(page)
|
||||
// History has no source field. Pin even an empty first page before
|
||||
// publishing it so a later UUID cannot adopt legacy cursor space.
|
||||
if readBinding == .unbound { readBinding = .legacy }
|
||||
case let .activity(id, activity):
|
||||
try next.checkConversation(id)
|
||||
next.activity = activity
|
||||
case let .ready(id): if let id { try next.checkConversation(id) }
|
||||
case let .error(message): throw AgentFailure(message: message)
|
||||
case let .readState(read): try applyRead(read); return
|
||||
case .heartbeat: return
|
||||
}
|
||||
presenter.receive(event, before: before, after: next, replay: replay, now: Date())
|
||||
@@ -315,32 +357,53 @@ final class SybilAgentRoom {
|
||||
try applyRead(read)
|
||||
}
|
||||
func applyRead(_ read: AgentReadState) throws {
|
||||
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, read.cursor)
|
||||
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
|
||||
let cursor = try AgentCursor.normalize(read.cursor)
|
||||
let incoming: ReadSourceBinding
|
||||
if let source = read.sourceId {
|
||||
guard AgentPushPayload.validSource(source) else { throw requireReload("Agent read source is invalid.") }
|
||||
incoming = .source(source.lowercased())
|
||||
} else { incoming = .legacy }
|
||||
guard readBinding == .unbound || readBinding == incoming else {
|
||||
throw requireReload("Agent read source changed.")
|
||||
}
|
||||
readBinding = incoming
|
||||
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, cursor)
|
||||
pending.readCursor = acknowledgedRead
|
||||
schedulePublish()
|
||||
onAcknowledgedRead?(read)
|
||||
}
|
||||
func markRead(renderedCursor: String) {
|
||||
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
|
||||
guard !requiresReload, hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
|
||||
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
private func flushRead() {
|
||||
guard let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
|
||||
guard !requiresReload, readBinding != .unbound, let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
|
||||
let run = generation
|
||||
readTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.readTask = nil } }
|
||||
do {
|
||||
while self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
|
||||
while !self.requiresReload, self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
|
||||
let captured = self.desiredRead
|
||||
let read = try await client.agentMarkRead(cursor: captured)
|
||||
let read = try await client.agentMarkRead(cursor: captured, sourceID: self.readSourceID)
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
guard !AgentCursor.less(try AgentCursor.normalize(read.cursor), captured) else {
|
||||
throw AgentFailure(message: "Agent read state was not acknowledged.")
|
||||
guard let cursor = try? AgentCursor.normalize(read.cursor), !AgentCursor.less(cursor, captured) else {
|
||||
throw self.requireReload("Agent read state was not acknowledged.")
|
||||
}
|
||||
try self.applyRead(read)
|
||||
}
|
||||
} catch { if self.generation == run { self.error = error.localizedDescription } }
|
||||
} catch {
|
||||
if self.generation == run {
|
||||
let isHTTPConflict: Bool
|
||||
if case APIError.httpError(statusCode: 409, message: _) = error { isHTTPConflict = true }
|
||||
else { isHTTPConflict = (error as? AgentAttachmentIssue)?.statusCode == 409 }
|
||||
if isHTTPConflict {
|
||||
_ = self.requireReload("Agent read source changed.")
|
||||
} else { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +427,7 @@ final class SybilAgentRoom {
|
||||
|
||||
@discardableResult
|
||||
func send(_ content: String, attachments: [AgentAttachment] = [], draftID: String? = nil) -> Bool {
|
||||
guard !requiresReload else { return false }
|
||||
do {
|
||||
_ = try AgentSubmission(content: content, requestID: UUID().uuidString, attachments: attachments)
|
||||
if !attachments.isEmpty {
|
||||
@@ -398,6 +462,7 @@ final class SybilAgentRoom {
|
||||
try storage.write(outbox.filter { !$0.confirmed })
|
||||
}
|
||||
private func dispatch(_ id: String) {
|
||||
guard !requiresReload else { return }
|
||||
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
|
||||
if outbox[index].draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { return }
|
||||
outbox[index].status = "sending"; outbox[index].error = nil
|
||||
|
||||
@@ -12,6 +12,7 @@ protocol AgentAPIClienting: Sendable {
|
||||
func agentMessages(after: String?, before: String?) async throws -> AgentPage
|
||||
func agentReadState() async throws -> AgentReadState
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState
|
||||
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
|
||||
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment
|
||||
@@ -51,6 +52,7 @@ extension APIConfiguration {
|
||||
let token = authToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value: JSONValue = .object([
|
||||
"type": .string("auth"),
|
||||
"clientKind": .string("native"),
|
||||
"token": token.flatMap { $0.isEmpty ? nil : JSONValue.string($0) } ?? .null,
|
||||
"after": .string(try AgentCursor.normalize(after))
|
||||
])
|
||||
@@ -60,6 +62,9 @@ extension APIConfiguration {
|
||||
|
||||
// Text-only test/legacy clients fail closed for the new wire path.
|
||||
extension AgentAPIClienting {
|
||||
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
|
||||
try await agentMarkRead(cursor: cursor)
|
||||
}
|
||||
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
|
||||
guard submission.attachmentIDs.isEmpty else { throw AgentAttachmentIssue.unavailable }
|
||||
return try await agentSubmit(content: submission.content, requestID: submission.requestId)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class SybilNotificationPlatform: AgentNotificationPlatform {
|
||||
static let shared = SybilNotificationPlatform()
|
||||
private var center: UNUserNotificationCenter { .current() }
|
||||
// UIKit's callback has no request ID. Keep an outstanding request tied to its
|
||||
// original generation until it drains; queue the next session behind it.
|
||||
private var outstanding: UUID?
|
||||
private var queued: UUID?
|
||||
private var registeredGeneration: UUID?
|
||||
|
||||
var environment: AgentAPNSEnvironment? {
|
||||
#if targetEnvironment(simulator)
|
||||
return isFixture ? .sandbox : nil
|
||||
#elseif targetEnvironment(macCatalyst)
|
||||
return nil
|
||||
#else
|
||||
return AgentAPNSEnvironment(entitlementSetting: Bundle.main.object(forInfoDictionaryKey: "SybilAPNSEnvironment") as? String)
|
||||
#endif
|
||||
}
|
||||
var allowsLoopback: Bool { isFixture }
|
||||
var isFixture: Bool {
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
return ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1"
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
private var fixturePermission: AgentNotificationPermission = .notDetermined
|
||||
func permission() async -> AgentNotificationPermission {
|
||||
if isFixture {
|
||||
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "authorized" { return .authorized }
|
||||
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "denied" { return .denied }
|
||||
return fixturePermission
|
||||
}
|
||||
guard environment != nil else { return .notDetermined }
|
||||
switch await center.notificationSettings().authorizationStatus {
|
||||
case .authorized, .ephemeral: return .authorized
|
||||
case .provisional: return .provisional
|
||||
case .denied: return .denied
|
||||
default: return .notDetermined
|
||||
}
|
||||
}
|
||||
func requestPermission() async throws -> Bool {
|
||||
if isFixture {
|
||||
fixturePermission = ProcessInfo.processInfo.environment["SYBIL_PUSH_DENIED"] == "1" ? .denied : .authorized
|
||||
return fixturePermission == .authorized
|
||||
}
|
||||
guard environment != nil else { return false }
|
||||
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
}
|
||||
func register(generation: UUID) {
|
||||
guard environment != nil else { return }
|
||||
if let outstanding {
|
||||
if outstanding != generation { queued = generation }
|
||||
return
|
||||
}
|
||||
outstanding = generation
|
||||
if isFixture {
|
||||
// Deliberately synthetic bytes; never call Apple's registrar in a simulator.
|
||||
Task { didRegister(Data([0x00, 0x01, 0x0a, 0xff] + Array(repeating: 0x42, count: 28))) }
|
||||
} else {
|
||||
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
func unregister() {
|
||||
queued = nil; registeredGeneration = nil
|
||||
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
|
||||
UIApplication.shared.unregisterForRemoteNotifications()
|
||||
#endif
|
||||
// Do not reassign an old callback to a new session.
|
||||
}
|
||||
func didRegister(_ data: Data) {
|
||||
let run = outstanding ?? registeredGeneration
|
||||
outstanding = nil
|
||||
if let run {
|
||||
registeredGeneration = run
|
||||
SybilAgentNotifications.shared.receivedDeviceToken(data, generation: run)
|
||||
}
|
||||
drainQueued()
|
||||
}
|
||||
func didFail() {
|
||||
let run = outstanding ?? registeredGeneration
|
||||
outstanding = nil
|
||||
if let run { SybilAgentNotifications.shared.registrationFailed(generation: run) }
|
||||
drainQueued()
|
||||
}
|
||||
private func drainQueued() {
|
||||
if let next = queued { queued = nil; register(generation: next) }
|
||||
}
|
||||
func delivered() async -> [AgentDeliveredNotification] {
|
||||
if isFixture || environment == nil { return [] }
|
||||
return await center.deliveredNotifications().map {
|
||||
AgentDeliveredNotification(identifier: $0.request.identifier, payload: AgentPushPayload($0.request.content.userInfo))
|
||||
}
|
||||
}
|
||||
func removeDelivered(identifiers: [String]) {
|
||||
guard !isFixture, environment != nil else { return }
|
||||
center.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
}
|
||||
|
||||
/// Public app-delegate bridge. The package coordinator remains injectable in tests.
|
||||
@MainActor
|
||||
public enum SybilPushNotifications {
|
||||
public struct Event: Sendable {
|
||||
let payload: AgentPushPayload?
|
||||
public init(userInfo: [AnyHashable: Any]) { payload = AgentPushPayload(userInfo) }
|
||||
}
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
private static var fixtureWasActive = false
|
||||
private static var fixtureForegroundSequence = 0
|
||||
#endif
|
||||
public static func didRegister(deviceToken: Data) { SybilNotificationPlatform.shared.didRegister(deviceToken) }
|
||||
public static func didFailToRegister() { SybilNotificationPlatform.shared.didFail() }
|
||||
|
||||
public static func presentationOptions(event: Event) -> UNNotificationPresentationOptions {
|
||||
guard let payload = event.payload else { return [] }
|
||||
let notifications = SybilAgentNotifications.shared
|
||||
Task { await notifications.syncRead() }
|
||||
// Covers foreground bootstrap before a SwiftUI scene has attached too.
|
||||
guard UIApplication.shared.applicationState != .active, notifications.shouldPresent(payload) else { return [] }
|
||||
return [.banner, .list, .sound]
|
||||
}
|
||||
public static func didRespond(userInfo: [AnyHashable: Any], identifier: String) {
|
||||
didRespond(event: Event(userInfo: userInfo), identifier: identifier)
|
||||
}
|
||||
public static func didRespond(event: Event, identifier: String) {
|
||||
guard let payload = event.payload else { return }
|
||||
SybilAgentNotifications.shared.notificationResponse(payload, identifier: identifier)
|
||||
}
|
||||
public static func receiveBackground(userInfo: [AnyHashable: Any], completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
|
||||
guard let payload = AgentPushPayload(userInfo), payload.kind == .readState else { completion(.noData); return }
|
||||
let notifications = SybilAgentNotifications.shared
|
||||
let settings = SybilSettingsStore()
|
||||
if let base = settings.normalizedAPIBaseURL {
|
||||
notifications.restoreBackgroundScope(baseURL: base, token: settings.trimmedTokenOrNil, payload: payload)
|
||||
}
|
||||
AgentBackgroundCompletion.run(timeout: .seconds(5), operation: {
|
||||
await notifications.receiveBackground(payload) ? .newData : .noData
|
||||
}, completion: completion)
|
||||
}
|
||||
|
||||
public static func launchFixtureIfRequested() {
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
guard SybilNotificationPlatform.shared.isFixture,
|
||||
ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP"] == "1" else { return }
|
||||
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "outbound:3", "cursor": "3"], identifier: "fixture-system-request")
|
||||
#endif
|
||||
}
|
||||
|
||||
static func foregroundFixtureIfRequested() {
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
guard SybilNotificationPlatform.shared.isFixture else { return }
|
||||
let wasActive = fixtureWasActive
|
||||
fixtureWasActive = true
|
||||
guard wasActive, ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP_ON_FOREGROUND"] == "1" else { return }
|
||||
fixtureForegroundSequence += 1
|
||||
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "fixture-warm-tap-\(fixtureForegroundSequence)", "cursor": "3"], identifier: "fixture-warm-system-request")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded callback gate: even an uncooperative notification-center fixture (or
|
||||
/// OS suspension) cannot delay the fetch completion waiting for a network/socket.
|
||||
@MainActor
|
||||
enum AgentBackgroundCompletion {
|
||||
static func run(timeout: Duration, operation: @escaping @MainActor () async -> UIBackgroundFetchResult,
|
||||
completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
|
||||
let gate = Gate(completion)
|
||||
let work = Task { gate.finish(await operation()) }
|
||||
Task {
|
||||
try? await Task.sleep(for: timeout)
|
||||
if !gate.finished { work.cancel(); gate.finish(.noData) }
|
||||
}
|
||||
}
|
||||
@MainActor private final class Gate {
|
||||
var finished = false
|
||||
let completion: @MainActor (UIBackgroundFetchResult) -> Void
|
||||
init(_ completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) { self.completion = completion }
|
||||
func finish(_ result: UIBackgroundFetchResult) {
|
||||
guard !finished else { return }
|
||||
finished = true; completion(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ struct SybilPhoneShellView: View {
|
||||
@State private var sidebarHighlightClearTask: Task<Void, Never>?
|
||||
@State private var openingSelectionRequestID: UUID?
|
||||
|
||||
init(viewModel: SybilViewModel) {
|
||||
self.viewModel = viewModel
|
||||
_route = State(initialValue: viewModel.selectedItem == .agent ? .agent : .draftChat)
|
||||
}
|
||||
|
||||
private var canRecognizeSidebarSwipe: Bool {
|
||||
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
|
||||
}
|
||||
@@ -94,6 +99,10 @@ struct SybilPhoneShellView: View {
|
||||
viewModel.agent.isObscured = covered
|
||||
}
|
||||
.onDisappear { viewModel.agent.isObscured = false }
|
||||
.onChange(of: viewModel.agentNotificationRouteRequest) { _, _ in
|
||||
openingSelectionRequestID = nil
|
||||
showRouteAndClearSidebarHighlight(.agent)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilSettingsView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@@ -86,6 +87,35 @@ struct SybilSettingsView: View {
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Agent Notifications").font(.sybil(.title3, weight: .semibold))
|
||||
Text("Get an alert when the Agent has a new message. Previews say only “You have a new message.” Reading elsewhere clears older alerts when iOS allows the app to sync.")
|
||||
.font(.sybil(.footnote)).foregroundStyle(SybilTheme.textMuted)
|
||||
Text(viewModel.notifications.status)
|
||||
.font(.sybil(.footnote)).accessibilityIdentifier("agent-push-status")
|
||||
if viewModel.notifications.pendingRevocations > 0 {
|
||||
Text("A previous registration is awaiting revocation. It will retry when you reconnect with its original server credentials.")
|
||||
.font(.sybil(.footnote)).foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
if viewModel.notifications.optedIn {
|
||||
Button("Disable Notifications") { Task { await viewModel.notifications.disable() } }
|
||||
.accessibilityIdentifier("agent-push-disable")
|
||||
} else {
|
||||
Button("Enable Notifications") { Task { await viewModel.notifications.enable() } }
|
||||
.disabled(!viewModel.notifications.available)
|
||||
.accessibilityIdentifier("agent-push-enable")
|
||||
}
|
||||
if viewModel.notifications.permission == .denied {
|
||||
Button("Open iOS Notification Settings") {
|
||||
if let url = URL(string: UIApplication.openNotificationSettingsURLString) { UIApplication.shared.open(url) }
|
||||
}
|
||||
}
|
||||
Button("Refresh Notification Status") { Task { await viewModel.notifications.refresh() } }
|
||||
.accessibilityIdentifier("agent-push-refresh")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(SybilTheme.primarySoft)
|
||||
|
||||
if let authError = viewModel.authError {
|
||||
Text(authError)
|
||||
.font(.sybil(.footnote))
|
||||
|
||||
@@ -157,6 +157,9 @@ private actor SearchStreamStatus {
|
||||
final class SybilViewModel {
|
||||
let settings: SybilSettingsStore
|
||||
let agent = SybilAgentRoom()
|
||||
let notifications: SybilAgentNotifications
|
||||
private(set) var agentNotificationRouteRequest = 0
|
||||
@ObservationIgnored private var sessionGeneration = UUID()
|
||||
|
||||
var isCheckingSession = true
|
||||
var isAuthenticated = false
|
||||
@@ -245,11 +248,13 @@ final class SybilViewModel {
|
||||
|
||||
init(
|
||||
settings: SybilSettingsStore = SybilSettingsStore(),
|
||||
notifications: SybilAgentNotifications? = nil,
|
||||
clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
|
||||
SybilAPIClient(configuration: configuration)
|
||||
}
|
||||
) {
|
||||
self.settings = settings
|
||||
self.notifications = notifications ?? .shared
|
||||
self.clientFactory = clientFactory
|
||||
let initialProvider = settings.preferredProvider
|
||||
let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
|
||||
@@ -640,9 +645,13 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func reconnect() async {
|
||||
let run = UUID()
|
||||
sessionGeneration = run
|
||||
agent.disconnect()
|
||||
agent.onAcknowledgedRead = nil
|
||||
setSidebarSearchQuery("")
|
||||
isCheckingSession = true
|
||||
isAuthenticated = false
|
||||
authError = nil
|
||||
errorMessage = nil
|
||||
resetSelectionLoading()
|
||||
@@ -659,7 +668,13 @@ final class SybilViewModel {
|
||||
resetQuickQuestion()
|
||||
draftIdentity = UUID()
|
||||
composerAttachments = []
|
||||
// Keep the old authenticated push client alive until revoke has been
|
||||
// attempted; never use the edited URL/token to revoke an old registration.
|
||||
await notifications.transition(baseURL: settings.normalizedAPIBaseURL, token: settings.trimmedTokenOrNil)
|
||||
guard sessionGeneration == run else { return }
|
||||
settings.persist()
|
||||
let baseURL = settings.normalizedAPIBaseURL
|
||||
let pushToken = settings.trimmedTokenOrNil
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.app,
|
||||
@@ -669,6 +684,7 @@ final class SybilViewModel {
|
||||
do {
|
||||
let client = try client()
|
||||
let session = try await client.verifySession()
|
||||
guard sessionGeneration == run else { return }
|
||||
isAuthenticated = session.authenticated
|
||||
authMode = session.mode
|
||||
authError = nil
|
||||
@@ -679,12 +695,23 @@ final class SybilViewModel {
|
||||
)
|
||||
|
||||
await loadInitialData(using: client)
|
||||
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL = settings.normalizedAPIBaseURL {
|
||||
guard sessionGeneration == run else { return }
|
||||
if let pushClient = client as? any AgentPushAPIClienting, let baseURL {
|
||||
await notifications.bind(client: pushClient, baseURL: baseURL, authenticated: session.authenticated, mode: session.mode, token: pushToken)
|
||||
}
|
||||
guard sessionGeneration == run else { return }
|
||||
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL {
|
||||
agent.onAcknowledgedRead = { [weak self] read in
|
||||
guard let self, self.sessionGeneration == run else { return }
|
||||
let pushRun = self.notifications.generation
|
||||
Task { await self.notifications.acknowledged(read, generation: pushRun) }
|
||||
}
|
||||
agent.configure(client: agentClient, storage: .file(baseURL: baseURL), draftStorage: .file(baseURL: baseURL))
|
||||
agent.setActive(isAppActive)
|
||||
}
|
||||
startActiveRunPolling()
|
||||
} catch {
|
||||
guard sessionGeneration == run else { return }
|
||||
stopActiveRunPolling()
|
||||
isAuthenticated = false
|
||||
authMode = nil
|
||||
@@ -1143,7 +1170,6 @@ final class SybilViewModel {
|
||||
|
||||
func refreshAfterSettingsChange() async {
|
||||
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
||||
settings.persist()
|
||||
await reconnect()
|
||||
}
|
||||
|
||||
@@ -1159,8 +1185,19 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func markAppActiveForNetwork() {
|
||||
let wasInactive = !isAppActive
|
||||
isAppActive = true
|
||||
agent.setActive(true)
|
||||
if wasInactive { Task { await notifications.refresh() } }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func consumeAgentNotificationRoute() -> Bool {
|
||||
guard isAuthenticated, !isCheckingSession, notifications.consumeRoute() else { return false }
|
||||
agent.dismissAttachmentPresentations()
|
||||
select(.agent)
|
||||
agentNotificationRouteRequest += 1
|
||||
return true
|
||||
}
|
||||
|
||||
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import Sybil
|
||||
|
||||
@MainActor private final class PushMemory {
|
||||
var value = AgentPushPersistence()
|
||||
var failWrite = false
|
||||
var storage: AgentPushStorage { .init(read: { self.value }, write: { value in
|
||||
if self.failWrite { throw URLError(.cannotWriteToFile) }
|
||||
self.value = value
|
||||
}) }
|
||||
}
|
||||
@MainActor private final class PushPlatform: AgentNotificationPlatform {
|
||||
var environment: AgentAPNSEnvironment? = .sandbox
|
||||
var allowsLoopback = true
|
||||
var authorization: AgentNotificationPermission = .notDetermined
|
||||
var allow = true
|
||||
var prompts = 0
|
||||
var registrations: [UUID] = []
|
||||
var unregisters = 0
|
||||
var notifications: [AgentDeliveredNotification] = []
|
||||
var removed: [String] = []
|
||||
var holdPermission = false
|
||||
var holdPermissionRead = false
|
||||
var permissionReadWaiter: CheckedContinuation<AgentNotificationPermission, Never>?
|
||||
var permissionWaiter: CheckedContinuation<Bool, Never>?
|
||||
func permission() async -> AgentNotificationPermission {
|
||||
if holdPermissionRead { return await withCheckedContinuation { permissionReadWaiter = $0 } }
|
||||
return authorization
|
||||
}
|
||||
func requestPermission() async -> Bool {
|
||||
prompts += 1
|
||||
if holdPermission { return await withCheckedContinuation { permissionWaiter = $0 } }
|
||||
authorization = allow ? .authorized : .denied
|
||||
return allow
|
||||
}
|
||||
func register(generation: UUID) { registrations.append(generation) }
|
||||
func unregister() { unregisters += 1 }
|
||||
func delivered() async -> [AgentDeliveredNotification] { notifications }
|
||||
func removeDelivered(identifiers: [String]) { removed += identifiers; notifications.removeAll { identifiers.contains($0.identifier) } }
|
||||
}
|
||||
private actor PushAPI: AgentPushAPIClienting {
|
||||
var config = AgentPushConfig(version: 1, enabled: true, apnsAvailable: true, webPushAvailable: false, sourceId: "11111111-1111-4111-8111-111111111111", quarantined: false)
|
||||
var read = AgentReadState(cursor: "0", sourceId: "11111111-1111-4111-8111-111111111111")
|
||||
var puts: [(UUID, AgentPushRegistration)] = []
|
||||
var deletes: [UUID] = []
|
||||
var failures = 0
|
||||
var offline = false
|
||||
var oldServer = false
|
||||
var revokeStatus: Int?
|
||||
var holdPut = false
|
||||
var putWaiter: CheckedContinuation<Void, Never>?
|
||||
var holdConfig = false
|
||||
var configWaiter: CheckedContinuation<Void, Never>?
|
||||
func agentPushConfig() async throws -> AgentPushConfig {
|
||||
if holdConfig { await withCheckedContinuation { configWaiter = $0 } }
|
||||
if oldServer { throw URLError(.badServerResponse) }
|
||||
return config
|
||||
}
|
||||
func agentReadState() -> AgentReadState { read }
|
||||
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt {
|
||||
puts.append((id, registration))
|
||||
if holdPut { await withCheckedContinuation { putWaiter = $0 } }
|
||||
if failures > 0 { failures -= 1; throw URLError(.networkConnectionLost) }
|
||||
return .init(installationId: id.uuidString, transport: "apns", enabled: true, version: puts.count)
|
||||
}
|
||||
func agentRevokePush(id: UUID) throws {
|
||||
deletes.append(id)
|
||||
if let revokeStatus { throw AgentPushRequestError(status: revokeStatus) }
|
||||
if offline { throw URLError(.notConnectedToInternet) }
|
||||
}
|
||||
func failRevoke(status: Int) { revokeStatus = status }
|
||||
func set(failures: Int = 0, offline: Bool = false, holdPut: Bool = false, oldServer: Bool = false, holdConfig: Bool = false) {
|
||||
self.failures = failures; self.offline = offline; self.holdPut = holdPut; self.oldServer = oldServer; self.holdConfig = holdConfig
|
||||
}
|
||||
func source(_ id: String) { config.sourceId = id; read.sourceId = id }
|
||||
func setAvailable(_ available: Bool) { config.enabled = available }
|
||||
func releasePut() { holdPut = false; putWaiter?.resume(); putWaiter = nil }
|
||||
func releaseConfig() { holdConfig = false; configWaiter?.resume(); configWaiter = nil }
|
||||
func requests() -> [(UUID, AgentPushRegistration)] { puts }
|
||||
func revokes() -> [UUID] { deletes }
|
||||
func waitingPut() -> Bool { putWaiter != nil }
|
||||
func waitingConfig() -> Bool { configWaiter != nil }
|
||||
}
|
||||
@MainActor private func pushEventually(_ predicate: () async -> Bool) async -> Bool {
|
||||
for _ in 0..<300 { if await predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
|
||||
return false
|
||||
}
|
||||
private func pushPayload(_ cursor: String = "4", source: String = "11111111-1111-4111-8111-111111111111", kind: String = "message") -> AgentPushPayload {
|
||||
AgentPushPayload(["version": 1, "sourceId": source, "kind": kind, "cursor": cursor, "readCursor": cursor, "messageId": "outbound:\(cursor)"])!
|
||||
}
|
||||
@MainActor private func bindPush(_ notifications: SybilAgentNotifications, _ api: PushAPI, url: String = "https://example.test/api", mode: String = "token") async {
|
||||
await notifications.bind(client: api, baseURL: URL(string: url)!, authenticated: true, mode: mode, token: mode == "token" ? "fixture-token" : nil)
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct AgentNotificationTests {
|
||||
@Test func payloadValidationAndCanonicalScope() throws {
|
||||
#expect(AgentPushPayload(["version": true, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil)
|
||||
#expect(AgentPushPayload(["version": 2, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil)
|
||||
#expect(AgentPushPayload(["version": 1, "sourceId": "s", "kind": "message", "cursor": 1, "messageId": "m"]) == nil)
|
||||
for cursor in ["-1", "1e3", "9007199254740992", "1.0", ""] { #expect(AgentPushPayload.validCursor(cursor) == nil) }
|
||||
#expect(AgentPushPayload.validCursor("0004") == "4")
|
||||
#expect(AgentPushScope.canonicalBackend(URL(string: "https://EXAMPLE.test:443/api/")!) == "https://example.test/api")
|
||||
for url in ["https://user:secret@example.test/api", "https://example.test/api?token=bad", "https://example.test/#fragment", "http://example.test/api", "https://example.test/api/../other"] {
|
||||
#expect(AgentPushScope.canonicalBackend(URL(string: url)!) == nil)
|
||||
}
|
||||
#expect(AgentAPNSEnvironment(entitlementSetting: "production") == .production)
|
||||
#expect(AgentAPNSEnvironment(entitlementSetting: "development") == .sandbox)
|
||||
#expect(AgentAPNSEnvironment(entitlementSetting: "DEBUG") == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func explicitAllowFreshTokenRotationAndIdempotence() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
#expect(platform.prompts == 0 && platform.registrations.isEmpty)
|
||||
await push.enable()
|
||||
#expect(platform.prompts == 1 && platform.registrations.count == 1)
|
||||
#expect(await api.requests().isEmpty)
|
||||
let run = push.generation
|
||||
push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run)
|
||||
#expect(await pushEventually { await api.requests().count == 1 && memory.value.records.first?.version != nil })
|
||||
let first = try #require(await api.requests().first)
|
||||
#expect(first.1.token == "00010aff" && first.1.environment == .sandbox)
|
||||
push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run)
|
||||
#expect(await api.requests().count == 1)
|
||||
push.receivedDeviceToken(Data([0x42, 0xab]), generation: run)
|
||||
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version == 2 })
|
||||
let next = try #require(await api.requests().last)
|
||||
#expect(first.0 == next.0 && next.1.token == "42ab")
|
||||
let encoded = String(decoding: try JSONEncoder().encode(memory.value), as: UTF8.self)
|
||||
#expect(!encoded.contains("00010aff") && !encoded.contains("42ab"))
|
||||
await push.refresh()
|
||||
#expect(platform.prompts == 1 && platform.registrations.count == 2)
|
||||
let relaunchedPlatform = PushPlatform(); relaunchedPlatform.authorization = .authorized
|
||||
let relaunched = SybilAgentNotifications(platform: relaunchedPlatform, storage: memory.storage)
|
||||
await bindPush(relaunched, api)
|
||||
#expect(relaunchedPlatform.registrations.count == 1 && relaunchedPlatform.prompts == 0)
|
||||
#expect(await api.requests().count == 2) // persisted token is never used.
|
||||
}
|
||||
|
||||
@Test @MainActor func deniedOpenOldServerAndPersistenceFailureNeverEnroll() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api, mode: "open"); await push.enable()
|
||||
#expect(platform.prompts == 0 && platform.registrations.isEmpty)
|
||||
await api.set(oldServer: true); await bindPush(push, api)
|
||||
#expect(!push.available && platform.prompts == 0)
|
||||
await api.set(); await bindPush(push, api)
|
||||
platform.allow = false; await push.enable()
|
||||
#expect(platform.prompts == 1 && !push.optedIn && platform.registrations.isEmpty)
|
||||
platform.authorization = .authorized; memory.failWrite = true
|
||||
await push.enable()
|
||||
#expect(platform.registrations.isEmpty)
|
||||
#expect(await api.requests().isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func retryThenRevokeUsesRetiredUUID() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable(); await api.set(failures: 1)
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil })
|
||||
let ids = await api.requests().map(\.0)
|
||||
#expect(Set(ids).count == 1)
|
||||
await push.disable()
|
||||
#expect(await api.revokes().contains(ids[0]))
|
||||
#expect(!push.optedIn && push.pendingRevocations == 0)
|
||||
await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { await api.requests().count == 3 })
|
||||
#expect(await api.requests().last?.0 != ids[0])
|
||||
}
|
||||
|
||||
@Test @MainActor func logoutAndServerSwitchRejectLateOSAndUploadCallbacks() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, old); await push.enable(); await old.set(holdPut: true)
|
||||
let oldRun = push.generation
|
||||
push.receivedDeviceToken(Data([1]), generation: oldRun)
|
||||
#expect(await pushEventually { await old.waitingPut() })
|
||||
let oldID = try #require(await old.requests().first?.0)
|
||||
await push.deactivate()
|
||||
await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test/api")
|
||||
await push.enable()
|
||||
push.receivedDeviceToken(Data([0xff]), generation: oldRun)
|
||||
#expect(await next.requests().isEmpty)
|
||||
await old.releasePut()
|
||||
#expect(await pushEventually { await old.revokes().contains(oldID) })
|
||||
#expect(await next.revokes().isEmpty)
|
||||
#expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222" && push.optedIn)
|
||||
push.receivedDeviceToken(Data([2]), generation: push.generation)
|
||||
#expect(await pushEventually { await next.requests().count == 1 })
|
||||
}
|
||||
|
||||
@Test @MainActor func oldConfigAndPermissionCannotBindNewSession() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await old.set(holdConfig: true)
|
||||
let binding = Task { await bindPush(push, old) }
|
||||
#expect(await pushEventually { await old.waitingConfig() })
|
||||
await push.deactivate(); await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test")
|
||||
await old.releaseConfig(); await binding.value
|
||||
#expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222")
|
||||
platform.holdPermission = true
|
||||
let enabling = Task { await push.enable() }
|
||||
#expect(await pushEventually { platform.permissionWaiter != nil })
|
||||
await push.deactivate()
|
||||
platform.permissionWaiter?.resume(returning: true); await enabling.value
|
||||
#expect(platform.registrations.isEmpty && !push.optedIn)
|
||||
}
|
||||
|
||||
@Test @MainActor func offlineTombstoneSurvivesRelaunchAndOnlyExactScopeRetries() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable()
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
await api.set(offline: true); await push.deactivate()
|
||||
#expect(memory.value.tombstones.count == 1)
|
||||
let id = try #require(memory.value.tombstones.first?.installationID)
|
||||
let next = PushAPI(), relaunched = SybilAgentNotifications(platform: PushPlatform(), storage: memory.storage)
|
||||
await bindPush(relaunched, next, url: "https://other.test/api")
|
||||
#expect(await next.revokes().isEmpty)
|
||||
await relaunched.deactivate(); await next.source("33333333-3333-4333-8333-333333333333")
|
||||
await bindPush(relaunched, next)
|
||||
#expect(await next.revokes().isEmpty)
|
||||
await relaunched.deactivate(); await next.source("11111111-1111-4111-8111-111111111111"); await bindPush(relaunched, next)
|
||||
#expect(await next.revokes() == [id] && memory.value.tombstones.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func deliveredClearUsesSystemIdentifiersAndRetainsNewerOrUnrelated() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
platform.notifications = [
|
||||
.init(identifier: "apns-system-a", payload: pushPayload("3")),
|
||||
.init(identifier: "apns-system-b", payload: pushPayload("4")),
|
||||
.init(identifier: "newer", payload: pushPayload("5")),
|
||||
.init(identifier: "foreign", payload: pushPayload("1", source: "44444444-4444-4444-8444-444444444444")),
|
||||
.init(identifier: "unrelated", payload: nil)]
|
||||
await push.acknowledged(.init(cursor: "99"), generation: push.generation)
|
||||
#expect(platform.removed.isEmpty) // legacy reads have no source authority.
|
||||
await push.acknowledged(.init(cursor: "4", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation)
|
||||
#expect(platform.removed == ["apns-system-a", "apns-system-b"])
|
||||
#expect(platform.notifications.map(\.identifier) == ["newer", "foreign", "unrelated"])
|
||||
await push.acknowledged(.init(cursor: "1", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation)
|
||||
#expect(!push.shouldPresent(pushPayload("4")))
|
||||
#expect(push.shouldPresent(pushPayload("5")))
|
||||
let scene = UUID(); push.setScene(scene, active: true)
|
||||
#expect(!push.shouldPresent(pushPayload("5")))
|
||||
push.setScene(scene, active: false)
|
||||
#expect(await push.receiveBackground(pushPayload("5", kind: "read_state")))
|
||||
#expect(platform.removed.last == "newer")
|
||||
#expect(await api.requests().isEmpty) // no read/enrollment side effect.
|
||||
}
|
||||
|
||||
@Test @MainActor func coldTapWaitsForAuthenticationDeduplicatesAndDoesNotRead() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
let payload = pushPayload()
|
||||
push.notificationResponse(payload, identifier: "system-1")
|
||||
#expect(!push.consumeRoute())
|
||||
let request = push.pendingRouteID
|
||||
push.notificationResponse(payload, identifier: "system-2")
|
||||
#expect(push.pendingRouteID == request)
|
||||
await bindPush(push, api, mode: "open"); #expect(!push.consumeRoute())
|
||||
await bindPush(push, api); #expect(push.consumeRoute()); #expect(!push.consumeRoute())
|
||||
#expect(platform.removed.isEmpty)
|
||||
#expect(await api.requests().isEmpty)
|
||||
#expect(push.shouldPresent(payload)) // tap alone did not advance read.
|
||||
push.notificationResponse(pushPayload(source: "44444444-4444-4444-8444-444444444444"), identifier: "wrong")
|
||||
#expect(!push.consumeRoute())
|
||||
}
|
||||
|
||||
@Test @MainActor func backgroundCompletionExactlyOnceIncludingTimeout() async {
|
||||
var outcomes: [UIBackgroundFetchResult] = []
|
||||
AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { .newData }, completion: { outcomes.append($0) })
|
||||
#expect(await pushEventually { outcomes.count == 1 })
|
||||
try? await Task.sleep(for: .milliseconds(40)); #expect(outcomes == [.newData])
|
||||
var continuation: CheckedContinuation<UIBackgroundFetchResult, Never>?
|
||||
AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { await withCheckedContinuation { continuation = $0 } }, completion: { outcomes.append($0) })
|
||||
#expect(await pushEventually { outcomes.count == 2 })
|
||||
continuation?.resume(returning: .newData)
|
||||
try? await Task.sleep(for: .milliseconds(20)); #expect(outcomes == [.newData, .noData])
|
||||
}
|
||||
|
||||
@Test @MainActor func changedCredentialEpochRequiresConsentAndRejectsColdReadHints() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let original = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(original, api); await original.enable()
|
||||
original.receivedDeviceToken(Data([1]), generation: original.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
let replacementPlatform = PushPlatform(); replacementPlatform.authorization = .authorized
|
||||
let replacement = SybilAgentNotifications(platform: replacementPlatform, storage: memory.storage)
|
||||
replacement.restoreBackgroundScope(baseURL: URL(string: "https://example.test/api")!, token: "replacement-fixture-token", payload: pushPayload(kind: "read_state"))
|
||||
#expect(replacement.scope == nil)
|
||||
await replacement.bind(client: api, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token")
|
||||
#expect(!replacement.optedIn && replacementPlatform.registrations.isEmpty)
|
||||
#expect(memory.value.records.count == 2)
|
||||
#expect(memory.value.records[0].scope.credentialScope != memory.value.records[1].scope.credentialScope)
|
||||
#expect(await api.revokes().isEmpty)
|
||||
#expect(replacement.scope?.credentialScope == memory.value.records.last?.scope.credentialScope)
|
||||
}
|
||||
|
||||
@Test @MainActor func deniedPermissionThenAuthorizedRefreshRecovers() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
platform.authorization = .denied
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
#expect(push.permission == .denied)
|
||||
platform.authorization = .authorized
|
||||
await push.refresh()
|
||||
#expect(push.permission == .authorized && push.available)
|
||||
await push.enable()
|
||||
#expect(platform.registrations.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func equivalentCanonicalBackendDoesNotRetireConsent() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable()
|
||||
let run = push.generation
|
||||
await bindPush(push, api, url: "https://EXAMPLE.test:443/api/")
|
||||
#expect(push.generation == run && push.optedIn)
|
||||
#expect(memory.value.records.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func sameBackendCredentialRotationWith403TombstoneAllowsNewConsentRoutingAndClear() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, old); await push.enable()
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
await old.failRevoke(status: 403)
|
||||
await push.bind(client: next, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token")
|
||||
#expect(!push.optedIn && memory.value.tombstones.count == 1)
|
||||
await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { await next.requests().count == 1 && push.status == "Notifications are enabled." })
|
||||
push.notificationResponse(pushPayload(), identifier: "system-after-rotation")
|
||||
#expect(push.consumeRoute())
|
||||
platform.notifications = [.init(identifier: "actual-system-id", payload: pushPayload())]
|
||||
#expect(await push.receiveBackground(pushPayload(kind: "read_state")))
|
||||
#expect(platform.removed == ["actual-system-id"])
|
||||
#expect(memory.value.tombstones.count == 1)
|
||||
#expect(await next.revokes().isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func distinctBackendWithSameRuntimeSourceStillRefusesPayloads() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, old); await push.enable()
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
await old.failRevoke(status: 403)
|
||||
await bindPush(push, next, url: "https://other.test/api"); await push.enable()
|
||||
push.receivedDeviceToken(Data([2]), generation: push.generation)
|
||||
#expect(await pushEventually { await next.requests().count == 1 })
|
||||
push.notificationResponse(pushPayload(), identifier: "ambiguous-server")
|
||||
#expect(!push.consumeRoute())
|
||||
#expect(!(await push.receiveBackground(pushPayload(kind: "read_state"))))
|
||||
}
|
||||
|
||||
@Test @MainActor func disableDuringConfigOrPermissionRefreshDoesNotBlockNewRefresh() async {
|
||||
for holdConfig in [true, false] {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
await api.set(holdConfig: holdConfig); platform.holdPermissionRead = !holdConfig
|
||||
let oldRefresh = Task { await push.refresh() }
|
||||
#expect(await pushEventually { holdConfig ? await api.waitingConfig() : platform.permissionReadWaiter != nil })
|
||||
await push.disable()
|
||||
if holdConfig { await api.releaseConfig() }
|
||||
else { platform.holdPermissionRead = false; platform.permissionReadWaiter?.resume(returning: .denied) }
|
||||
await oldRefresh.value
|
||||
platform.authorization = .authorized
|
||||
await push.refresh(); await push.enable()
|
||||
#expect(push.permission == .authorized && platform.registrations.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func capabilityRecoveryReenrollsSameTokenWithNewInstallation() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable()
|
||||
let bytes = Data([1, 2, 3])
|
||||
push.receivedDeviceToken(bytes, generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
let oldID = try #require(await api.requests().first?.0)
|
||||
await api.setAvailable(false); await push.refresh()
|
||||
#expect(await api.revokes().contains(oldID))
|
||||
#expect(memory.value.records.first?.installationID != oldID)
|
||||
await api.setAvailable(true); await push.refresh()
|
||||
push.receivedDeviceToken(bytes, generation: push.generation)
|
||||
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil })
|
||||
#expect(await api.requests().last?.0 != oldID)
|
||||
#expect(push.status == "Notifications are enabled.")
|
||||
}
|
||||
|
||||
@Test @MainActor func optionalReadStateAndNativeAuthRemainCompatible() throws {
|
||||
let old = try JSONDecoder().decode(AgentReadState.self, from: Data(#"{"cursor":"4","updatedAt":null}"#.utf8))
|
||||
#expect(old.sourceId == nil)
|
||||
let data = Data(#"{"type":"read_state","sourceId":"11111111-1111-4111-8111-111111111111","cursor":"5","updatedAt":null}"#.utf8)
|
||||
guard case let .readState(read) = try AgentEvent.decode(data) else { Issue.record("Missing read frame"); return }
|
||||
let room = SybilAgentRoom(); try room.applyRead(read); room.flush()
|
||||
#expect(room.snapshot.room.readCursor == "5")
|
||||
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "99", sourceId: "foreign")) }
|
||||
room.flush()
|
||||
#expect(room.snapshot.room.readCursor == "5")
|
||||
#expect(room.requiresReload)
|
||||
#expect(try AgentEvent.decode(Data(#"{"type":"read_state","cursor":"-1"}"#.utf8)) == nil)
|
||||
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(APIConfiguration(baseURL: URL(string: "https://example.test")!, authToken: "fixture").agentAuthFrame(after: "0").utf8))
|
||||
#expect(frame.objectValue?["clientKind"] == .string("native"))
|
||||
#expect(frame.objectValue?["presenceSessionId"] == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private final class PushURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
static let lock = NSLock()
|
||||
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, Data))?
|
||||
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, Data)) { lock.withLock { handler = value } }
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
do {
|
||||
let handler = Self.lock.withLock { Self.handler! }
|
||||
let (status, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: ["Location": "https://untrusted.test/"])!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data); client?.urlProtocolDidFinishLoading(self)
|
||||
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
private func pushClient(_ url: String = "https://example.test/api", token: String? = "fixture-token") -> SybilAPIClient {
|
||||
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [PushURLProtocol.self]
|
||||
return SybilAPIClient(configuration: .init(baseURL: URL(string: url)!, authToken: token), session: URLSession(configuration: config))
|
||||
}
|
||||
private func pushRequestBody(_ request: URLRequest) -> Data {
|
||||
if let body = request.httpBody { return body }
|
||||
guard let stream = request.httpBodyStream else { return Data() }
|
||||
stream.open(); defer { stream.close() }
|
||||
var bytes = Data(), buffer = [UInt8](repeating: 0, count: 1024)
|
||||
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) }
|
||||
return bytes
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct AgentPushAPITests {
|
||||
@Test func authenticatedBasePathRegistrationAndRevoke() async throws {
|
||||
let id = UUID()
|
||||
PushURLProtocol.install { request in
|
||||
#expect(request.url?.host == "example.test")
|
||||
#expect(request.url?.query == nil)
|
||||
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-token")
|
||||
#expect(request.value(forHTTPHeaderField: "Cache-Control") == "no-store")
|
||||
#expect(request.timeoutInterval == 8)
|
||||
if request.httpMethod == "GET" {
|
||||
#expect(request.url?.path == "/api/v1/agent/push/config")
|
||||
return (200, Data(#"{"version":1,"enabled":true,"apnsAvailable":true,"webPushAvailable":false,"sourceId":"11111111-1111-4111-8111-111111111111","quarantined":false,"policy":{"future":1}}"#.utf8))
|
||||
}
|
||||
#expect(request.url?.path == "/api/v1/agent/push/installations/" + id.uuidString.lowercased())
|
||||
if request.httpMethod == "DELETE" { return (204, Data()) }
|
||||
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: Any])
|
||||
#expect(Set(object.keys) == Set(["transport", "enabled", "token", "environment", "topic"]))
|
||||
#expect(object["token"] as? String == "00010aff")
|
||||
#expect(object["environment"] as? String == "production")
|
||||
#expect(object["topic"] as? String == "net.buzzert.sybil2")
|
||||
return (200, try JSONEncoder().encode(AgentPushReceipt(installationId: id.uuidString, transport: "apns", enabled: true, version: 7)))
|
||||
}
|
||||
let client = pushClient()
|
||||
#expect(try await client.agentPushConfig().available)
|
||||
#expect(try await client.agentRegisterPush(id: id, registration: .init(token: "00010aff", environment: .production)).version == 7)
|
||||
try await client.agentRevokePush(id: id)
|
||||
}
|
||||
|
||||
@Test func unsupportedRedirectAndReflectedSecretsAreNotExposed() async {
|
||||
for status in [302, 401, 404, 409, 503] {
|
||||
PushURLProtocol.install { _ in (status, Data(#"{"token":"do-not-reflect-this-token","message":"do-not-reflect-this-token"}"#.utf8)) }
|
||||
do { _ = try await pushClient().agentPushConfig(); Issue.record("Unexpected success") }
|
||||
catch { #expect(!error.localizedDescription.contains("do-not-reflect")) }
|
||||
}
|
||||
PushURLProtocol.install { _ in Issue.record("Unsafe request reached transport"); return (200, Data()) }
|
||||
for client in [pushClient(token: nil), pushClient("https://example.test/api?token=bad"), pushClient("https://user:password@example.test")] {
|
||||
do { _ = try await client.agentPushConfig(); Issue.record("Unsafe configuration accepted") }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sourceAwareReadWritesAndLegacyFallback() async throws {
|
||||
let source = "11111111-1111-4111-8111-111111111111"
|
||||
for expectedSource: String? in [nil, source] {
|
||||
PushURLProtocol.install { request in
|
||||
#expect(request.url?.path == "/api/v1/agent/read-state")
|
||||
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: String])
|
||||
#expect(object["cursor"] == "4" && object["sourceId"] == expectedSource)
|
||||
return (200, try JSONEncoder().encode(AgentReadState(cursor: "4", sourceId: expectedSource)))
|
||||
}
|
||||
#expect(try await pushClient().agentMarkRead(cursor: "4", sourceID: expectedSource).sourceId == expectedSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@ private actor TestAgentClient: AgentAPIClienting {
|
||||
var requests: [String] = []
|
||||
var submissions: [(String, String)] = []
|
||||
var read = "0"
|
||||
var readSource: String?
|
||||
var readWrites: [(cursor: String, source: String?)] = []
|
||||
var writeReply: AgentReadState?
|
||||
var writeConflict: Bool?
|
||||
var holdWrite = false
|
||||
var heldWrite: CheckedContinuation<Void, Never>?
|
||||
var failRead = false
|
||||
var failSubmit = false
|
||||
var malformedReceipt = false
|
||||
@@ -56,16 +62,34 @@ private actor TestAgentClient: AgentAPIClienting {
|
||||
}
|
||||
func agentReadState() async -> AgentReadState {
|
||||
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
|
||||
return AgentReadState(cursor: read)
|
||||
return AgentReadState(cursor: read, sourceId: readSource)
|
||||
}
|
||||
func pauseRead() { holdRead = true }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read, sourceId: readSource)); heldRead = nil }
|
||||
func hasHeldRead() -> Bool { heldRead != nil }
|
||||
func agentMarkRead(cursor: String) throws -> AgentReadState {
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read)
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState {
|
||||
try await agentMarkRead(cursor: cursor, sourceID: nil)
|
||||
}
|
||||
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
|
||||
readWrites.append((cursor, sourceID))
|
||||
if holdWrite { await withCheckedContinuation { heldWrite = $0 } }
|
||||
if let coded = writeConflict {
|
||||
if coded { throw AgentAttachmentIssue(message: "Source mismatch", code: "source_mismatch", statusCode: 409) }
|
||||
throw APIError.httpError(statusCode: 409, message: "Source mismatch")
|
||||
}
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
if let writeReply { return writeReply }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read, sourceId: readSource)
|
||||
}
|
||||
func setReadState(_ state: AgentReadState) { read = state.cursor; readSource = state.sourceId }
|
||||
func setWriteReply(_ state: AgentReadState?) { writeReply = state }
|
||||
func setWriteConflict(coded: Bool) { writeConflict = coded }
|
||||
func pauseWrite() { holdWrite = true }
|
||||
func releaseWrite() { holdWrite = false; heldWrite?.resume(); heldWrite = nil }
|
||||
func hasHeldWrite() -> Bool { heldWrite != nil }
|
||||
func writtenReads() -> [(cursor: String, source: String?)] { readWrites }
|
||||
func setHistory(_ pages: [String: AgentPage]) { self.pages = pages }
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
#expect(beforeSubmit?() ?? true)
|
||||
submissions.append((content, requestID))
|
||||
@@ -420,6 +444,89 @@ struct AgentTests {
|
||||
#expect(await eventually { room.connection == "Connecting" })
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor func legacyReadPromotionCannotRelabelQueuedHistoryAndRequiresFreshReload() async throws {
|
||||
let sourceB = "22222222-2222-4222-8222-222222222222"
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90"))
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; await client.pauseWrite(); room.markRead(renderedCursor: "100")
|
||||
for _ in 0..<100 {
|
||||
if await client.hasHeldWrite() { break }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
#expect(await client.hasHeldWrite())
|
||||
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "0", sourceId: sourceB)) }
|
||||
#expect(room.requiresReload && room.connection == "Reload required")
|
||||
#expect(room.snapshot.room.readCursor == "90" && room.snapshot.room.cursor == "100")
|
||||
await client.setWriteReply(.init(cursor: "100", sourceId: sourceB)); await client.releaseWrite()
|
||||
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
#expect(room.requiresReload && room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
#expect(await client.writtenReads().first?.source == nil)
|
||||
#expect(room.messages.map(\.id) == ["old"])
|
||||
|
||||
// Explicit reconnect resets cursor space before discovering B or loading
|
||||
// its history. The old queued100 can never be sent under sourceB.
|
||||
await client.setWriteReply(nil); await client.setReadState(.init(cursor: "0", sourceId: sourceB))
|
||||
await client.setHistory(["0": agentPage([agentMessage("new", entry: "1")], cursor: "1")])
|
||||
room.retryConnection()
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
#expect(!room.requiresReload && room.snapshot.room.readCursor == "0")
|
||||
#expect(room.messages.map(\.id) == ["new"])
|
||||
room.markRead(renderedCursor: "1")
|
||||
#expect(await eventually { room.snapshot.room.readCursor == "1" })
|
||||
let writes = await client.writtenReads()
|
||||
#expect(writes.count == 2 && writes.last?.cursor == "1" && writes.last?.source == sourceB)
|
||||
#expect(!writes.contains { $0.cursor == "100" && $0.source == sourceB })
|
||||
}
|
||||
|
||||
@Test @MainActor func historyBeforeReadPinsLegacySourceBeforePublishing() throws {
|
||||
let room = SybilAgentRoom(); defer { room.stop() }
|
||||
try room.receive(.messages(agentPage([agentMessage("legacy", entry: "100")], cursor: "100")))
|
||||
#expect(throws: AgentFailure.self) {
|
||||
try room.applyRead(.init(cursor: "0", sourceId: "22222222-2222-4222-8222-222222222222"))
|
||||
}
|
||||
#expect(room.requiresReload && room.snapshot.room.cursor == "100" && room.snapshot.room.readCursor == "0")
|
||||
}
|
||||
|
||||
@Test @MainActor func mismatchedAndNonAdvancingReadAcknowledgmentsStopWriter() async throws {
|
||||
let sourceA = "11111111-1111-4111-8111-111111111111"
|
||||
let sourceB = "22222222-2222-4222-8222-222222222222"
|
||||
for reply in [AgentReadState(cursor: "100", sourceId: sourceB), .init(cursor: "90", sourceId: sourceA), .init(cursor: "100"), .init(cursor: "invalid", sourceId: sourceA)] {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90", sourceId: sourceA)); await client.setWriteReply(reply)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; room.markRead(renderedCursor: "100")
|
||||
#expect(await eventually { room.requiresReload })
|
||||
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
#expect(room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
#expect(await client.writtenReads().first?.source == sourceA)
|
||||
room.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func readSourceConflict409StopsWriterUntilReload() async {
|
||||
for coded in [false, true] {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90", sourceId: "11111111-1111-4111-8111-111111111111"))
|
||||
await client.setWriteConflict(coded: coded)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; room.markRead(renderedCursor: "100")
|
||||
#expect(await eventually { room.requiresReload })
|
||||
room.markRead(renderedCursor: "100")
|
||||
#expect(room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
room.stop()
|
||||
}
|
||||
}
|
||||
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
|
||||
let client = TestAgentClient(), memory = MemoryOutbox()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
|
||||
@@ -26,6 +26,12 @@ attachment_capability = True
|
||||
next_upload_error = None
|
||||
blocked_proxy = False
|
||||
transcript_comparison = False
|
||||
PUSH_SOURCE = '11111111-1111-4111-8111-111111111111'
|
||||
push_installations = {}
|
||||
push_retired = set()
|
||||
push_requests = []
|
||||
push_available = True
|
||||
auth_required = False
|
||||
CAPABILITY = dict(version=1, maxCount=8, maxTotalBytes=33554432, maxImageBytes=6291456,
|
||||
maxFileBytes=8388608, imageMimeTypes=['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
maxImagePixels=50000000, maxImageDimension=16384, quotaBytes=1073741824, usedBytes=0,
|
||||
@@ -88,7 +94,7 @@ async def reply(content):
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
|
||||
async def route(request):
|
||||
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison
|
||||
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison, push_available, auth_required
|
||||
path = request.path
|
||||
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
||||
if path == '/control':
|
||||
@@ -100,6 +106,8 @@ async def route(request):
|
||||
reply_tasks.clear()
|
||||
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
|
||||
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
|
||||
push_installations.clear(); push_retired.clear(); push_requests.clear(); log.clear()
|
||||
push_available = True; auth_required = False
|
||||
for i in range(body.get('count', 3)):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
elif action == 'transcript-comparison':
|
||||
@@ -126,13 +134,21 @@ async def route(request):
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
elif action == 'read':
|
||||
read_cursor = cursor
|
||||
await broadcast(dict(type='read_state', sourceId=PUSH_SOURCE, cursor=str(read_cursor), updatedAt=stamp()))
|
||||
elif action == 'push-available':
|
||||
push_available = body.get('enabled', True)
|
||||
elif action == 'auth-required':
|
||||
auth_required = body.get('enabled', True)
|
||||
elif action == 'disconnect':
|
||||
for socket in list(sockets):
|
||||
await socket.close(code=1012)
|
||||
elif action == 'malformed':
|
||||
await broadcast(dict(type='messages', conversationId='fixture-root', messages=[{'id': 'broken'}], cursor='999999', beforeCursor=None, hasMore=False))
|
||||
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log))
|
||||
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log,
|
||||
pushRequests=push_requests, pushActive=len(push_installations), pushRetired=len(push_retired)))
|
||||
if path == '/api/v1/auth/session':
|
||||
if auth_required and request.headers.get('Authorization') != 'Bearer fixture-token':
|
||||
return web.json_response(dict(message='Fixture authentication required'), status=401)
|
||||
return web.json_response(dict(authenticated=True, mode='token'))
|
||||
comparison = dict(id='fade-comparison', title='Transcript fade comparison', createdAt=stamp(), updatedAt=stamp())
|
||||
if path == '/api/v1/workspace-items':
|
||||
@@ -157,6 +173,29 @@ async def route(request):
|
||||
if attachment_capability:
|
||||
descriptor['capabilities'] = {'attachments': CAPABILITY}
|
||||
return web.json_response(descriptor)
|
||||
if path == '/api/v1/agent/push/config':
|
||||
if not push_available:
|
||||
return web.json_response(dict(message='Old server fixture'), status=404)
|
||||
return web.json_response(dict(version=1, enabled=True, apnsAvailable=True, webPushAvailable=False,
|
||||
sourceId=PUSH_SOURCE, quarantined=False, policy={}))
|
||||
if path.startswith('/api/v1/agent/push/installations/'):
|
||||
installation = str(uuid.UUID(path.rsplit('/', 1)[1]))
|
||||
if request.method == 'DELETE':
|
||||
push_installations.pop(installation, None); push_retired.add(installation)
|
||||
push_requests.append(dict(method='DELETE', installationId=installation))
|
||||
return web.Response(status=204)
|
||||
if request.method == 'PUT':
|
||||
body = await request.json()
|
||||
assert set(body) == {'transport', 'enabled', 'token', 'environment', 'topic'}
|
||||
assert body['transport'] == 'apns' and body['enabled'] is True
|
||||
assert body['environment'] == 'sandbox' and body['topic'] == 'net.buzzert.sybil2'
|
||||
assert body['token'] == '00010aff' + '42' * 28 # explicitly injected synthetic token
|
||||
if installation in push_retired:
|
||||
return web.json_response(dict(message='Retired installation'), status=409)
|
||||
version = push_installations.get(installation, 0) + 1
|
||||
push_installations[installation] = version
|
||||
push_requests.append(dict(method='PUT', installationId=installation, environment='sandbox'))
|
||||
return web.json_response(dict(installationId=installation, transport='apns', enabled=True, version=version))
|
||||
if path == '/api/v1/agent/attachments' and request.method == 'POST':
|
||||
if not attachment_capability:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
|
||||
@@ -217,7 +256,7 @@ async def route(request):
|
||||
if path == '/api/v1/agent/read-state':
|
||||
if request.method == 'PUT':
|
||||
read_cursor = max(read_cursor, int((await request.json())['cursor']))
|
||||
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp()))
|
||||
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp(), sourceId=PUSH_SOURCE))
|
||||
if path == '/api/v1/agent/messages':
|
||||
if request.method == 'POST':
|
||||
body = await request.json()
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import XCTest
|
||||
|
||||
/// Local fixture + fake notification center/registrar only. No APNs provider or
|
||||
/// system permission dialog is involved in these deterministic UI scenarios.
|
||||
@MainActor final class AgentPushUITests: XCTestCase {
|
||||
let app = XCUIApplication()
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
try await control(["action": "reset", "count": 3])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
app.launchEnvironment = ["SYBIL_PUSH_FIXTURE": "1", "SYBIL_PUSH_PERMISSION": "authorized"]
|
||||
}
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
@discardableResult private func control(_ body: [String: Any]) async throws -> [String: Any] {
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
|
||||
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
private func settings() {
|
||||
if !app.buttons["Settings"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 20))
|
||||
app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
XCTAssertTrue(app.buttons["Settings"].waitForExistence(timeout: 5)); app.buttons["Settings"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-push-refresh"].waitForExistence(timeout: 5))
|
||||
if !app.buttons["agent-push-refresh"].isHittable { app.scrollViews.firstMatch.swipeUp() }
|
||||
}
|
||||
private func capture(_ name: String) {
|
||||
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
||||
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||
}
|
||||
private func openAgent() {
|
||||
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 20)); app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5))
|
||||
}
|
||||
private func ensureDisabled() {
|
||||
if app.buttons["agent-push-disable"].exists { app.buttons["agent-push-disable"].tap() }
|
||||
XCTAssertTrue(app.buttons["agent-push-enable"].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
func testExplicitEnableRefreshAndDisable() async throws {
|
||||
app.launch(); settings(); ensureDisabled()
|
||||
let before = try await control(["action": "state"])
|
||||
let count = (before["pushRequests"] as? [[String: Any]])?.count ?? 0
|
||||
app.buttons["agent-push-enable"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-push-disable"].waitForExistence(timeout: 5))
|
||||
let status = app.staticTexts["agent-push-status"]
|
||||
let enabled = NSPredicate(format: "label == %@", "Notifications are enabled.")
|
||||
let enabledExpectation = expectation(for: enabled, evaluatedWith: status)
|
||||
await fulfillment(of: [enabledExpectation], timeout: 8)
|
||||
capture("push-enabled-generic-preview")
|
||||
app.buttons["agent-push-refresh"].tap()
|
||||
let state = try await control(["action": "state"])
|
||||
let requests = try XCTUnwrap(state["pushRequests"] as? [[String: Any]])
|
||||
XCTAssertEqual(requests.count, count + 1)
|
||||
XCTAssertEqual(requests.last?["environment"] as? String, "sandbox")
|
||||
app.buttons["agent-push-disable"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-push-enable"].waitForExistence(timeout: 5))
|
||||
let revoked = try await control(["action": "state"])
|
||||
XCTAssertEqual(revoked["pushActive"] as? Int, 0)
|
||||
capture("push-disabled-revoked")
|
||||
}
|
||||
|
||||
func testColdTapRoutesAgentAndPreservesAttachmentCaption() async throws {
|
||||
app.launch()
|
||||
openAgent()
|
||||
let composer = app.textFields["agent-composer"]
|
||||
XCTAssertTrue(composer.waitForExistence(timeout: 5))
|
||||
composer.tap(); composer.typeText("Push fixture saved caption")
|
||||
app.terminate()
|
||||
app.launchEnvironment["SYBIL_PUSH_TAP"] = "1"
|
||||
app.launch()
|
||||
XCTAssertTrue(composer.waitForExistence(timeout: 20))
|
||||
XCTAssertTrue((composer.value as? String ?? "").contains("Push fixture saved caption"))
|
||||
XCTAssertTrue(app.buttons["agent-attach"].exists)
|
||||
capture("push-cold-tap-agent-draft-preserved")
|
||||
}
|
||||
|
||||
func testTapBeforeLoginWaitsThenRoutesAgent() async throws {
|
||||
try await control(["action": "auth-required", "enabled": true])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", ""]
|
||||
app.launchEnvironment["SYBIL_PUSH_TAP"] = "1"
|
||||
app.launch()
|
||||
XCTAssertTrue(app.buttons["Connect"].waitForExistence(timeout: 15))
|
||||
XCTAssertFalse(app.textFields["agent-composer"].exists)
|
||||
let state = try await control(["action": "state"])
|
||||
XCTAssertEqual(state["readCursor"] as? Int, 0)
|
||||
app.secureTextFields.firstMatch.tap(); app.secureTextFields.firstMatch.typeText("fixture-token")
|
||||
app.buttons["Connect"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 20))
|
||||
capture("push-pending-auth-routed-after-login")
|
||||
}
|
||||
|
||||
func testDeniedAndOldServerLeaveAgentUsable() async throws {
|
||||
app.launchEnvironment["SYBIL_PUSH_PERMISSION"] = "denied"
|
||||
app.launch(); settings()
|
||||
XCTAssertTrue(app.buttons["Open iOS Notification Settings"].waitForExistence(timeout: 5))
|
||||
capture("push-denied-settings")
|
||||
try await control(["action": "push-available", "enabled": false])
|
||||
app.buttons["agent-push-refresh"].tap()
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].exists); app.buttons["Show sidebar"].tap()
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(app.staticTexts["History message 3: local simulator fixture."].waitForExistence(timeout: 5))
|
||||
capture("push-old-server-agent-compatible")
|
||||
}
|
||||
|
||||
func testWarmTapDismissesAttachmentPickerPreservingDraft() async throws {
|
||||
app.launchEnvironment["SYBIL_PUSH_TAP_ON_FOREGROUND"] = "1"
|
||||
app.launch(); openAgent()
|
||||
let composer = app.textFields["agent-composer"]
|
||||
composer.tap(); composer.typeText(" Warm push preserved draft")
|
||||
app.buttons["agent-attach"].tap(); app.buttons["Files"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
|
||||
XCUIDevice.shared.press(.home); app.activate()
|
||||
XCTAssertTrue(composer.waitForExistence(timeout: 10))
|
||||
let visible = expectation(for: NSPredicate(format: "hittable == true"), evaluatedWith: composer)
|
||||
await fulfillment(of: [visible], timeout: 5)
|
||||
XCTAssertFalse(app.buttons["Cancel"].firstMatch.exists)
|
||||
XCTAssertTrue((composer.value as? String ?? "").contains("Warm push preserved draft"))
|
||||
capture("push-warm-tap-dismissed-picker-draft-preserved")
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -10,13 +10,13 @@ generate:
|
||||
|
||||
build: generate
|
||||
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}' | xcbeautify; \
|
||||
else \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'; \
|
||||
fi
|
||||
|
||||
test:
|
||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}' -parallel-testing-enabled NO
|
||||
|
||||
run: generate
|
||||
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
||||
|
||||
Generated
+148
@@ -22,11 +22,13 @@
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
@@ -878,6 +880,16 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -894,6 +906,15 @@
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
@@ -927,6 +948,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -946,6 +979,18 @@
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
@@ -1116,6 +1161,15 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -1490,6 +1544,15 @@
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -1510,6 +1573,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -1589,6 +1665,27 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
@@ -1690,6 +1787,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
@@ -2006,6 +2109,26 @@
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex2": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
|
||||
@@ -2034,6 +2157,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
@@ -2231,6 +2360,25 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
||||
@@ -31,11 +31,13 @@
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE "AgentReadState" ADD COLUMN "sourceId" TEXT;
|
||||
CREATE TABLE "AgentPushInstallation" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "transport" TEXT NOT NULL, "payload" TEXT NOT NULL, "fingerprint" TEXT NOT NULL, "enabled" BOOLEAN NOT NULL DEFAULT false, "version" INTEGER NOT NULL DEFAULT 1, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL, "lastClearAt" DATETIME, "revokedAt" DATETIME);
|
||||
CREATE INDEX "AgentPushInstallation_ownerEpoch_enabled_idx" ON "AgentPushInstallation"("ownerEpoch", "enabled");
|
||||
CREATE INDEX "AgentPushInstallation_fingerprint_idx" ON "AgentPushInstallation"("fingerprint");
|
||||
CREATE TABLE "AgentPushPresence" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "sequence" INTEGER NOT NULL, "visible" BOOLEAN NOT NULL, "focused" BOOLEAN NOT NULL, "lastInteractionAt" DATETIME, "expiresAt" DATETIME NOT NULL);
|
||||
CREATE INDEX "AgentPushPresence_ownerEpoch_expiresAt_idx" ON "AgentPushPresence"("ownerEpoch", "expiresAt");
|
||||
CREATE TABLE "AgentPushConnection" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "kind" TEXT NOT NULL, "sessionId" TEXT, "expiresAt" DATETIME NOT NULL);
|
||||
CREATE INDEX "AgentPushConnection_ownerEpoch_expiresAt_idx" ON "AgentPushConnection"("ownerEpoch", "expiresAt");
|
||||
CREATE TABLE "AgentPushCoordinator" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "sourceId" TEXT, "cursor" TEXT NOT NULL DEFAULT '0', "quarantined" BOOLEAN NOT NULL DEFAULT false, "contextObservedAt" DATETIME, "latestUserMessageAt" DATETIME, "localPromptAt" DATETIME, "nativeActive" BOOLEAN NOT NULL DEFAULT false, "nativeUnknown" BOOLEAN NOT NULL DEFAULT true, "uncertain" BOOLEAN NOT NULL DEFAULT true, "lockOwner" TEXT, "lockUntil" DATETIME);
|
||||
CREATE TABLE "AgentPushDelivery" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "installationId" TEXT NOT NULL, "installationVersion" INTEGER NOT NULL, "sourceId" TEXT NOT NULL, "messageId" TEXT NOT NULL, "cursor" TEXT NOT NULL, "kind" TEXT NOT NULL, "trigger" TEXT NOT NULL, "priority" INTEGER NOT NULL DEFAULT 0, "status" TEXT NOT NULL DEFAULT 'pending', "attempts" INTEGER NOT NULL DEFAULT 0, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "dueAt" DATETIME NOT NULL, "expiresAt" DATETIME NOT NULL, "updatedAt" DATETIME NOT NULL, "outcome" TEXT);
|
||||
CREATE INDEX "AgentPushDelivery_status_dueAt_idx" ON "AgentPushDelivery"("status", "dueAt");
|
||||
CREATE INDEX "AgentPushDelivery_installationId_status_idx" ON "AgentPushDelivery"("installationId", "status");
|
||||
CREATE INDEX "AgentPushDelivery_ownerEpoch_sourceId_cursor_idx" ON "AgentPushDelivery"("ownerEpoch", "sourceId", "cursor");
|
||||
@@ -219,7 +219,84 @@ model ProjectItem {
|
||||
// Shared personal read position for the external persistent Sybil Agent room.
|
||||
// Messages remain in the agent's durable transcript, not duplicated in Sybil Chat.
|
||||
model AgentReadState {
|
||||
sourceId String?
|
||||
id String @id
|
||||
cursor String @default("0")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// One server-derived shared principal. Transport payloads are secret DB data.
|
||||
model AgentPushInstallation {
|
||||
id String @id
|
||||
ownerEpoch String
|
||||
transport String
|
||||
payload String
|
||||
fingerprint String
|
||||
enabled Boolean @default(false)
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastClearAt DateTime?
|
||||
revokedAt DateTime?
|
||||
@@index([ownerEpoch, enabled])
|
||||
@@index([fingerprint])
|
||||
}
|
||||
|
||||
model AgentPushPresence {
|
||||
id String @id
|
||||
ownerEpoch String
|
||||
sequence Int
|
||||
visible Boolean
|
||||
focused Boolean
|
||||
lastInteractionAt DateTime?
|
||||
expiresAt DateTime
|
||||
@@index([ownerEpoch, expiresAt])
|
||||
}
|
||||
|
||||
model AgentPushConnection {
|
||||
id String @id
|
||||
ownerEpoch String
|
||||
kind String
|
||||
sessionId String?
|
||||
expiresAt DateTime
|
||||
@@index([ownerEpoch, expiresAt])
|
||||
}
|
||||
|
||||
model AgentPushCoordinator {
|
||||
id String @id
|
||||
ownerEpoch String
|
||||
sourceId String?
|
||||
cursor String @default("0")
|
||||
quarantined Boolean @default(false)
|
||||
contextObservedAt DateTime?
|
||||
latestUserMessageAt DateTime?
|
||||
localPromptAt DateTime?
|
||||
nativeActive Boolean @default(false)
|
||||
nativeUnknown Boolean @default(true)
|
||||
uncertain Boolean @default(true)
|
||||
lockOwner String?
|
||||
lockUntil DateTime?
|
||||
}
|
||||
|
||||
model AgentPushDelivery {
|
||||
id String @id
|
||||
ownerEpoch String
|
||||
installationId String
|
||||
installationVersion Int
|
||||
sourceId String
|
||||
messageId String
|
||||
cursor String
|
||||
kind String
|
||||
trigger String
|
||||
priority Int @default(0)
|
||||
status String @default("pending")
|
||||
attempts Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
dueAt DateTime
|
||||
expiresAt DateTime
|
||||
updatedAt DateTime @updatedAt
|
||||
outcome String?
|
||||
@@index([status, dueAt])
|
||||
@@index([installationId, status])
|
||||
@@index([ownerEpoch, sourceId, cursor])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { createECDH } from 'node:crypto';
|
||||
import { env } from './env.js';
|
||||
import { validateAPNsKey } from './agent-push-contract.js';
|
||||
|
||||
export type PushConfig = { enabled: boolean; adminToken?: string; apns?: { key: string; keyId: string; teamId: string; topic: string; environment: 'sandbox' | 'production' };
|
||||
web?: { publicKey: string; privateKey: string; subject: string } };
|
||||
function secretFile(path: string): string {
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error('Unsafe push secret file');
|
||||
return readFileSync(path, 'utf8').trim();
|
||||
}
|
||||
/** Never log config objects or secret-file read/crypto errors. Invalid credentials disable their adapter. */
|
||||
export function loadPushConfig(): PushConfig {
|
||||
const result: PushConfig = { enabled: env.AGENT_PUSH_ENABLED && Boolean(env.ADMIN_TOKEN), adminToken: env.ADMIN_TOKEN };
|
||||
if (!result.enabled) return result;
|
||||
try {
|
||||
if (env.AGENT_PUSH_APNS_KEY_FILE && env.AGENT_PUSH_APNS_KEY_ID && env.AGENT_PUSH_APNS_TEAM_ID) {
|
||||
const key = secretFile(env.AGENT_PUSH_APNS_KEY_FILE); validateAPNsKey(key);
|
||||
result.apns = { key, keyId: env.AGENT_PUSH_APNS_KEY_ID, teamId: env.AGENT_PUSH_APNS_TEAM_ID,
|
||||
topic: env.AGENT_PUSH_APNS_TOPIC, environment: env.AGENT_PUSH_APNS_ENVIRONMENT };
|
||||
}
|
||||
} catch { /* Unavailable, fail closed. Public config exposes only the availability bit. */ }
|
||||
try {
|
||||
if (env.AGENT_PUSH_VAPID_FILE) {
|
||||
const value = JSON.parse(secretFile(env.AGENT_PUSH_VAPID_FILE));
|
||||
const curve = createECDH('prime256v1'); curve.setPrivateKey(Buffer.from(value.privateKey, 'base64url'));
|
||||
if (curve.getPublicKey().toString('base64url') !== value.publicKey || typeof value.subject !== 'string'
|
||||
|| !/^(mailto:[^\s@]+@[^\s@]+|https:\/\/[^\s]+)$/.test(value.subject)) throw new Error();
|
||||
result.web = { publicKey: value.publicKey, privateKey: value.privateKey, subject: value.subject };
|
||||
}
|
||||
} catch { /* Unavailable; never emit credential parse errors. */ }
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createHash, createPublicKey, ECDH } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 30_000,
|
||||
presenceHeartbeatMs: 15_000, presenceLeaseMs: 60_000 });
|
||||
export const MAX_AGE_MS = 5 * 60_000;
|
||||
export const MAX_ATTEMPTS = 4;
|
||||
export const ROOT_OWNER = 'root';
|
||||
export const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)))
|
||||
.transform(value => String(Number(value)));
|
||||
export const InstallationId = z.string().uuid();
|
||||
export const PresenceBody = z.object({ sequence: z.number().int().min(0).max(2_147_483_647),
|
||||
visibility: z.enum(['visible', 'hidden']), focused: z.boolean(), interacted: z.boolean() }).strict();
|
||||
export type Presence = z.infer<typeof PresenceBody>;
|
||||
const Base64url = z.string().regex(/^[A-Za-z0-9_-]+={0,2}$/);
|
||||
export const WebSubscription = z.object({ endpoint: z.string().max(2048), expirationTime: z.number().nonnegative().nullable().optional(),
|
||||
keys: z.object({ p256dh: Base64url.max(90), auth: Base64url.max(24) }).strict() }).strict();
|
||||
export const InstallationBody = z.discriminatedUnion('transport', [
|
||||
z.object({ transport: z.literal('apns'), enabled: z.literal(true), token: z.string().regex(/^[a-fA-F0-9]{32,512}$/).refine(v => v.length % 2 === 0),
|
||||
environment: z.enum(['sandbox', 'production']), topic: z.string().min(1).max(128) }).strict(),
|
||||
z.object({ transport: z.literal('web'), enabled: z.literal(true), subscription: WebSubscription }).strict(),
|
||||
]);
|
||||
export type Registration = z.infer<typeof InstallationBody>;
|
||||
export type Subscription = z.infer<typeof WebSubscription>;
|
||||
export const NotificationContext = z.object({ version: z.literal(1), sourceId: z.string().uuid(), conversationId: z.string().min(1).max(128),
|
||||
cursor: Cursor, hasMore: z.boolean(), latestUserMessageAt: z.string().datetime().nullable(), nativeReadCursor: Cursor,
|
||||
uncertain: z.boolean(), webPresence: z.object({ active: z.boolean(), unknown: z.boolean(), observedAt: z.string().datetime() }),
|
||||
candidates: z.array(z.object({ messageId: z.string().min(1).max(200), cursor: Cursor,
|
||||
createdAt: z.string().datetime(), trigger: z.enum(['user', 'external_event', 'timer', 'worker', 'unknown']) })).max(200),
|
||||
});
|
||||
export type Context = z.infer<typeof NotificationContext>;
|
||||
export type Identity = { version: 1; sourceId: string; kind: 'message'; messageId: string; cursor: string } |
|
||||
{ version: 1; sourceId: string; kind: 'read_state'; readCursor: string };
|
||||
export const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
export const ownerEpoch = (token: string) => digest(`sybil-agent-push-owner-v1\0${token}`);
|
||||
export const fail = (statusCode: number, message: string) => Object.assign(new Error(message), { statusCode });
|
||||
export function parseSafe<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||
const result = schema.safeParse(value);
|
||||
// Do not reflect supplied endpoints/tokens or validation objects into logs/errors.
|
||||
if (!result.success) throw fail(400, 'Invalid Agent push request');
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/** Exact provider origins only: no arbitrary-host/redirect/private-network egress. */
|
||||
export function validatePushEndpoint(endpoint: string): URL {
|
||||
let url: URL;
|
||||
try { url = new URL(endpoint); } catch { throw fail(400, 'Invalid Web Push subscription'); }
|
||||
const allowed = url.hostname === 'fcm.googleapis.com' || url.hostname === 'updates.push.services.mozilla.com'
|
||||
|| url.hostname === 'web.push.apple.com' || /^[a-z0-9-]+\.push\.apple\.com$/.test(url.hostname);
|
||||
if (!allowed || url.protocol !== 'https:' || url.username || url.password || url.hash || (url.port && url.port !== '443')
|
||||
|| endpoint.length > 2048 || !url.pathname || url.pathname === '/') throw fail(400, 'Unsupported Web Push provider');
|
||||
return url;
|
||||
}
|
||||
export function validateSubscription(subscription: Subscription, now: number) {
|
||||
validatePushEndpoint(subscription.endpoint);
|
||||
if (subscription.expirationTime != null && subscription.expirationTime <= now) throw fail(400, 'Expired Web Push subscription');
|
||||
try {
|
||||
const key = Buffer.from(subscription.keys.p256dh, 'base64url');
|
||||
if (key.length !== 65 || key[0] !== 4 || Buffer.from(subscription.keys.auth, 'base64url').length !== 16) throw new Error();
|
||||
// Validate the curve point as well as its length before persistence.
|
||||
ECDH.convertKey(key, 'prime256v1', undefined, undefined, 'uncompressed');
|
||||
} catch { throw fail(400, 'Invalid Web Push encryption keys'); }
|
||||
}
|
||||
export function validateAPNsKey(key: string) {
|
||||
const publicKey = createPublicKey(key);
|
||||
if (publicKey.asymmetricKeyType !== 'ec' || publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') throw new Error('Invalid APNs key');
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createHash, sign } from 'node:crypto';
|
||||
import { lookup } from 'node:dns';
|
||||
import { connect } from 'node:http2';
|
||||
import { request } from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import webpush from 'web-push';
|
||||
import type { PushConfig } from './agent-push-config.js';
|
||||
import { validatePushEndpoint, type Identity, type Registration } from './agent-push-contract.js';
|
||||
|
||||
export type SendResult = { outcome: 'accepted' | 'retry' | 'invalid' | 'failed' | 'ambiguous'; retryAfterMs?: number };
|
||||
export type ProviderRequest = { endpoint: string; headers: Record<string, string | number>; body: Buffer };
|
||||
export type ProviderResponse = { status: number; body?: string; retryAfter?: string };
|
||||
export type RequestTransport = (request: ProviderRequest) => Promise<ProviderResponse>;
|
||||
export type PushSend = (registration: Registration, identity: Identity, expiresAt: number, now: number) => Promise<SendResult>;
|
||||
|
||||
function boundedRetry(value?: string): number | undefined {
|
||||
if (!value || !/^\d+$/.test(value)) return undefined;
|
||||
return Math.min(60_000, Math.max(1_000, Number(value) * 1000));
|
||||
}
|
||||
function classify(response: ProviderResponse, apns: boolean): SendResult {
|
||||
if (response.status >= 200 && response.status < 300) return { outcome: 'accepted' };
|
||||
if (response.status === 410 || (!apns && response.status === 404)) return { outcome: 'invalid' };
|
||||
if (apns && response.status === 400) {
|
||||
try { if (['BadDeviceToken', 'DeviceTokenNotForTopic', 'Unregistered'].includes(JSON.parse(response.body || '{}').reason)) return { outcome: 'invalid' }; } catch { /* Never emit provider text. */ }
|
||||
}
|
||||
if (response.status === 429 || response.status >= 500) return { outcome: 'retry', retryAfterMs: boundedRetry(response.retryAfter) };
|
||||
return { outcome: 'failed' };
|
||||
}
|
||||
|
||||
/** Fixed Apple origin, strict ten-second boundary. Unknown network outcomes are never auto-retried. */
|
||||
export const createAPNsRequest = (open: typeof connect = connect, timeoutMs = 10_000): RequestTransport => async input => new Promise((resolve, reject) => {
|
||||
const url = new URL(input.endpoint);
|
||||
const session = open(url.origin);
|
||||
let settled = false;
|
||||
const finish = (error?: Error, value?: ProviderResponse) => {
|
||||
if (settled) return; settled = true; clearTimeout(timer); session.destroy();
|
||||
error ? reject(new Error('APNs transport unavailable')) : resolve(value!);
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error()), timeoutMs);
|
||||
session.on('error', () => finish(new Error()));
|
||||
session.on('goaway', () => finish(new Error()));
|
||||
session.on('close', () => finish(new Error()));
|
||||
session.on('connect', () => {
|
||||
if (settled) return;
|
||||
try {
|
||||
const stream = session.request({ ':method': 'POST', ':path': url.pathname, ...input.headers });
|
||||
let body = '', status = 0, retryAfter: string | undefined;
|
||||
stream.on('response', headers => { status = Number(headers[':status']); retryAfter = String(headers['retry-after'] || ''); });
|
||||
stream.on('data', data => { body += data.toString(); if (body.length > 4096) finish(new Error()); });
|
||||
stream.on('error', () => finish(new Error()));
|
||||
stream.on('end', () => finish(undefined, { status, body, retryAfter }));
|
||||
stream.end(input.body);
|
||||
} catch { finish(new Error()); }
|
||||
});
|
||||
});
|
||||
|
||||
export const apnsRequest = createAPNsRequest();
|
||||
|
||||
// Only global addresses are permitted even when an allowlisted provider's DNS is compromised.
|
||||
export function isPublicAddress(address: string): boolean {
|
||||
if (isIP(address) === 4) {
|
||||
const [a,b] = address.split('.').map(Number);
|
||||
return !(a === 0 || a === 10 || a === 127 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && [0, 168].includes(b)) || (a === 100 && b >= 64 && b <= 127) || (a === 198 && [18,19,51].includes(b)) || (a === 203 && b === 0));
|
||||
}
|
||||
if (isIP(address) === 6) return /^[23][0-9a-f]{3}:/i.test(address) && !/^2001:(db8|0):/i.test(address) && !/^(2002|3fff):/i.test(address);
|
||||
return false;
|
||||
}
|
||||
export const createWebRequest = (open: typeof request = request, timeoutMs = 10_000): RequestTransport => input => new Promise((resolve, reject) => {
|
||||
const url = validatePushEndpoint(input.endpoint);
|
||||
// https.request never follows redirects. The DNS result passed to the connection is checked here (no resolve/use race).
|
||||
let settled = false;
|
||||
const finish = (error?: Error, response?: ProviderResponse) => {
|
||||
if (settled) return; settled = true; clearTimeout(deadline);
|
||||
if (error) { req?.destroy(); reject(new Error('Web Push transport unavailable')); } else resolve(response!);
|
||||
};
|
||||
const deadline = setTimeout(() => finish(new Error()), timeoutMs);
|
||||
let req: ReturnType<typeof request>;
|
||||
try { req = open(url, { method: 'POST', headers: input.headers, timeout: 10_000,
|
||||
lookup: (hostname, options, callback) => {
|
||||
lookup(hostname, { all: true, verbatim: true }, (error, addresses) => {
|
||||
if (error || !addresses.length || addresses.some(value => !isPublicAddress(value.address))) {
|
||||
callback(new Error('Unsafe push provider address'), '', 4); return;
|
||||
}
|
||||
const selected = addresses.find(value => !options.family || value.family === options.family) || addresses[0];
|
||||
// Node's automatic address selection requests all results.
|
||||
if (options.all) (callback as unknown as (error: Error | null, results: { address: string; family: number }[]) => void)(null, addresses);
|
||||
else callback(null, selected.address, selected.family);
|
||||
});
|
||||
},
|
||||
}, res => {
|
||||
res.resume(); // Provider bodies can contain endpoint/token values; never retain or log them.
|
||||
res.on('end', () => finish(undefined, { status: res.statusCode || 0, retryAfter: String(res.headers['retry-after'] || '') }));
|
||||
res.on('error', () => finish(new Error()));
|
||||
res.on('aborted', () => finish(new Error()));
|
||||
});
|
||||
req.on('timeout', () => req.destroy(new Error('Web Push timeout')));
|
||||
req.on('error', () => finish(new Error()));
|
||||
req.on('close', () => { if (!settled) finish(new Error()); });
|
||||
req.end(input.body);
|
||||
} catch { finish(new Error()); }
|
||||
});
|
||||
export const webRequest = createWebRequest();
|
||||
|
||||
export function createPushSender(config: PushConfig, adapters: { apns?: RequestTransport; web?: RequestTransport } = {}): PushSend {
|
||||
let jwt: { value: string; issuedAt: number } | undefined;
|
||||
return async (registration, identity, expiresAt, now) => {
|
||||
if (!config.enabled || !config.adminToken || expiresAt <= now) return { outcome: 'failed' };
|
||||
try {
|
||||
if (registration.transport === 'apns') {
|
||||
const credential = config.apns;
|
||||
if (!credential || credential.topic !== registration.topic || credential.environment !== registration.environment) return { outcome: 'failed' };
|
||||
if (!jwt || now - jwt.issuedAt >= 50 * 60_000 || now < jwt.issuedAt) {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: credential.keyId })).toString('base64url');
|
||||
const claims = Buffer.from(JSON.stringify({ iss: credential.teamId, iat: Math.floor(now / 1000) })).toString('base64url');
|
||||
const value = `${header}.${claims}`;
|
||||
jwt = { value: `${value}.${sign('sha256', Buffer.from(value), { key: credential.key, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`, issuedAt: now };
|
||||
}
|
||||
const clear = identity.kind === 'read_state';
|
||||
const body = Buffer.from(JSON.stringify({ aps: clear ? { 'content-available': 1 } : { alert: { title: 'Sybil Agent', body: 'You have a new message.' }, sound: 'default', 'thread-id': 'sybil-agent-root' }, ...identity }));
|
||||
const response = await (adapters.apns || apnsRequest)({
|
||||
endpoint: `https://${registration.environment === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com'}/3/device/${registration.token}`,
|
||||
headers: { authorization: `bearer ${jwt.value}`, 'apns-topic': credential.topic, 'apns-push-type': clear ? 'background' : 'alert',
|
||||
'apns-priority': clear ? '5' : '10', 'apns-expiration': String(Math.floor(Math.min(expiresAt, now + 60_000) / 1000)),
|
||||
'apns-collapse-id': `sybil-${clear ? 'read' : 'message'}-${createHash('sha256').update(identity.sourceId).digest('hex').slice(0,24)}`,
|
||||
'content-type': 'application/json' }, body,
|
||||
});
|
||||
return classify(response, true);
|
||||
}
|
||||
// Safari requires visible pushes; there is deliberately no Web Push read-clear transport.
|
||||
if (!config.web || identity.kind !== 'message') return { outcome: 'failed' };
|
||||
validatePushEndpoint(registration.subscription.endpoint);
|
||||
const payload = JSON.stringify({ ...identity, title: 'Sybil Agent', body: 'You have a new message.', url: '/?agent=root', tag: `sybil-agent:${identity.sourceId}` });
|
||||
const details = webpush.generateRequestDetails(registration.subscription, payload, { vapidDetails: config.web,
|
||||
contentEncoding: 'aes128gcm', TTL: Math.max(1, Math.min(60, Math.floor((expiresAt - now) / 1000))), urgency: 'normal',
|
||||
topic: createHash('sha256').update(identity.sourceId).digest('base64url').slice(0,32) });
|
||||
const response = await (adapters.web || webRequest)({ endpoint: details.endpoint, headers: details.headers, body: details.body! });
|
||||
return classify(response, false);
|
||||
} catch {
|
||||
// Encryption/config and I/O errors are intentionally indistinguishable externally. Never replay an uncertain send.
|
||||
return { outcome: 'ambiguous' };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Prisma, PrismaClient } from '@prisma/client';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { requireAdmin } from './auth.js';
|
||||
import { env } from './env.js';
|
||||
import type { PushConfig } from './agent-push-config.js';
|
||||
import { loadPushConfig } from './agent-push-config.js';
|
||||
import { Cursor, InstallationBody, InstallationId, MAX_AGE_MS, MAX_ATTEMPTS, NotificationContext, PresenceBody, PUSH_POLICY,
|
||||
digest, fail, ownerEpoch, parseSafe, validateSubscription, type Context, type Identity, type Presence, type Registration } from './agent-push-contract.js';
|
||||
import { createPushSender, type PushSend } from './agent-push-transports.js';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
export type ReadState = { cursor: string; updatedAt: string | null; sourceId: string | null };
|
||||
type Options = { db: PrismaClient; config: PushConfig; context: (after?: string, sourceId?: string) => Promise<Context>; send: PushSend; now?: () => number;
|
||||
broadcast?: (state: ReadState) => void; warmupMs?: number };
|
||||
const date = (ms: number) => new Date(ms);
|
||||
const activeStatuses = ['pending', 'intent'];
|
||||
|
||||
/** Sole coordinator: persisted source checkpoint, installations, presence, claims and outcomes. */
|
||||
export class AgentPushCoordinator {
|
||||
readonly instanceId = randomUUID();
|
||||
readonly epoch: string;
|
||||
private readonly now: () => number;
|
||||
private readonly warmUntil: number;
|
||||
private started = false;
|
||||
private untrackedConnections = new Set<string>();
|
||||
private running = false;
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
private stopped = false;
|
||||
constructor(readonly options: Options) {
|
||||
this.epoch = ownerEpoch(options.config.adminToken || 'disabled');
|
||||
this.now = options.now || Date.now;
|
||||
this.warmUntil = this.now() + (options.warmupMs ?? PUSH_POLICY.presenceLeaseMs);
|
||||
}
|
||||
async initialize() {
|
||||
if (this.started) return;
|
||||
await this.options.db.$transaction(async tx => {
|
||||
const current = await tx.agentPushCoordinator.findUnique({ where: { id: 'root' } });
|
||||
if (!current) await tx.agentPushCoordinator.create({ data: { id: 'root', ownerEpoch: this.epoch } });
|
||||
else if (current.ownerEpoch !== this.epoch) {
|
||||
// Credentials rotate ownership generation; old endpoint opt-ins do not cross it.
|
||||
await tx.agentPushInstallation.updateMany({ where: { enabled: true }, data: { enabled: false } });
|
||||
await tx.agentPushDelivery.updateMany({ where: { status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'owner_changed' } });
|
||||
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { ownerEpoch: this.epoch, contextObservedAt: null,
|
||||
nativeUnknown: true, uncertain: true, lockOwner: null, lockUntil: null } });
|
||||
}
|
||||
});
|
||||
this.started = true;
|
||||
}
|
||||
start() {
|
||||
if (this.timer || !this.options.config.adminToken) return;
|
||||
this.timer = setInterval(() => { void this.tick(); }, 2_000); this.timer.unref();
|
||||
void this.tick();
|
||||
}
|
||||
async stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); while (this.running) await new Promise(r => setTimeout(r, 20)); }
|
||||
async config() {
|
||||
await this.initialize();
|
||||
const state = await this.options.db.agentPushCoordinator.findUnique({ where: { id: 'root' } });
|
||||
const config = this.options.config;
|
||||
return { version: 1, enabled: config.enabled, apnsAvailable: Boolean(config.enabled && config.apns),
|
||||
webPushAvailable: Boolean(config.enabled && config.web), ...(config.enabled && config.web ? { vapidPublicKey: config.web.publicKey } : {}),
|
||||
sourceId: state?.sourceId || null, quarantined: state?.quarantined || false, policy: PUSH_POLICY };
|
||||
}
|
||||
async register(id: string, value: Registration) {
|
||||
await this.initialize();
|
||||
const config = this.options.config;
|
||||
if (!config.enabled) throw fail(503, 'Agent push is disabled');
|
||||
if (value.transport === 'apns') {
|
||||
if (!config.apns || config.apns.topic !== value.topic || config.apns.environment !== value.environment) throw fail(400, 'APNs topic or environment unavailable');
|
||||
value = { ...value, token: value.token.toLowerCase() };
|
||||
} else { if (!config.web) throw fail(503, 'Web Push is unavailable'); validateSubscription(value.subscription, this.now()); }
|
||||
const payload = JSON.stringify(value), fingerprint = digest(value.transport === 'apns' ? `${value.environment}:${value.topic}:${value.token}` : value.subscription.endpoint);
|
||||
return this.options.db.$transaction(async tx => {
|
||||
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
|
||||
if (old?.revokedAt) throw fail(409, 'Installation was retired; explicit opt-in requires a new installation ID');
|
||||
if (old?.enabled && old.ownerEpoch === this.epoch && old.payload === payload) return { installationId: id, transport: old.transport, enabled: true, version: old.version };
|
||||
if (await tx.agentPushInstallation.count({ where: { ownerEpoch: this.epoch, enabled: true } }) >= 64 && !old?.enabled) throw fail(429, 'Too many push installations');
|
||||
// A rotated/recreated local installation cannot retain another active delivery to the same endpoint.
|
||||
const duplicates = await tx.agentPushInstallation.findMany({ where: { fingerprint, revokedAt: null, id: { not: id } }, select: { id: true } });
|
||||
if (duplicates.length) {
|
||||
await tx.agentPushInstallation.updateMany({ where: { id: { in: duplicates.map(v => v.id) } }, data: { enabled: false, revokedAt: date(this.now()), payload: '', fingerprint: '' } });
|
||||
await tx.agentPushDelivery.updateMany({ where: { installationId: { in: duplicates.map(v => v.id) }, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'endpoint_rebound' } });
|
||||
}
|
||||
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'registration_changed' } });
|
||||
const record = await tx.agentPushInstallation.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true },
|
||||
update: { ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true, version: { increment: 1 } } });
|
||||
return { installationId: id, transport: record.transport, enabled: record.enabled, version: record.version };
|
||||
});
|
||||
}
|
||||
async revoke(id: string) {
|
||||
await this.options.db.$transaction(async tx => {
|
||||
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
|
||||
if (!old?.revokedAt) await tx.agentPushInstallation.upsert({ where: { id },
|
||||
create: { id, ownerEpoch: this.epoch, transport: 'revoked', payload: '', fingerprint: '', enabled: false, revokedAt: date(this.now()) },
|
||||
update: { enabled: false, revokedAt: date(this.now()), version: { increment: 1 }, payload: '', fingerprint: '' } });
|
||||
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'revoked' } });
|
||||
});
|
||||
}
|
||||
async presence(id: string, value: Presence) {
|
||||
const now = this.now();
|
||||
return this.options.db.$transaction(async tx => {
|
||||
const old = await tx.agentPushPresence.findUnique({ where: { id } });
|
||||
if (old?.ownerEpoch === this.epoch && value.sequence <= old.sequence) return { expiresAt: old.expiresAt.toISOString() };
|
||||
if (!old && await tx.agentPushPresence.count({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } }) >= 512) throw fail(429, 'Too many presence sessions');
|
||||
const data = { ownerEpoch: this.epoch, sequence: value.sequence, visible: value.visibility === 'visible', focused: value.focused,
|
||||
lastInteractionAt: value.interacted ? date(now) : old?.ownerEpoch === this.epoch ? old.lastInteractionAt : null, expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) };
|
||||
const row = await tx.agentPushPresence.upsert({ where: { id }, create: { id, ...data }, update: data });
|
||||
return { expiresAt: row.expiresAt.toISOString() };
|
||||
});
|
||||
}
|
||||
async retirePresence(id: string) {
|
||||
// Keep recent interaction's grace even after a best-effort pagehide release.
|
||||
await this.options.db.agentPushPresence.updateMany({ where: { id, ownerEpoch: this.epoch }, data: { visible: false, focused: false } });
|
||||
}
|
||||
async connect(id: string, kind?: string, sessionId?: string) {
|
||||
this.untrackedConnections.add(id);
|
||||
await this.options.db.agentPushConnection.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, kind: kind || 'unknown', sessionId,
|
||||
expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) }, update: { expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) } });
|
||||
this.untrackedConnections.delete(id);
|
||||
}
|
||||
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); }
|
||||
async notePrompt() {
|
||||
if (!this.options.config.adminToken) return;
|
||||
await this.initialize();
|
||||
// Covers submissions arriving after the last runtime snapshot but before the send claim.
|
||||
await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch }, data: { localPromptAt: date(this.now()) } });
|
||||
}
|
||||
async readState(tx: Tx | PrismaClient = this.options.db): Promise<ReadState> {
|
||||
const row = await tx.agentReadState.findUnique({ where: { id: 'root' } });
|
||||
return { cursor: row?.cursor || '0', updatedAt: row?.updatedAt.toISOString() || null, sourceId: row?.sourceId || null };
|
||||
}
|
||||
private async mergeRead(tx: Tx, cursor: string, sourceId: string | null): Promise<ReadState | undefined> {
|
||||
const old = await tx.agentReadState.findUnique({ where: { id: 'root' } });
|
||||
if (old?.sourceId && sourceId && old.sourceId !== sourceId) return;
|
||||
if (Number(cursor) <= Number(old?.cursor || 0)) return;
|
||||
await tx.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt", "sourceId") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP, ${sourceId})
|
||||
ON CONFLICT("id") DO UPDATE SET "cursor" = excluded."cursor", "updatedAt" = CURRENT_TIMESTAMP,
|
||||
"sourceId" = COALESCE("AgentReadState"."sourceId", excluded."sourceId")
|
||||
WHERE CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER)`;
|
||||
if (sourceId) {
|
||||
await tx.$executeRaw`UPDATE "AgentPushDelivery" SET "status" = 'cancelled', "outcome" = 'read', "updatedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "ownerEpoch" = ${this.epoch} AND "sourceId" = ${sourceId} AND "kind" = 'message' AND "status" = 'pending' AND CAST("cursor" AS INTEGER) <= ${Number(cursor)}`;
|
||||
if (this.options.config.enabled && this.options.config.apns) {
|
||||
const installations = await tx.agentPushInstallation.findMany({ where: { ownerEpoch: this.epoch, enabled: true, transport: 'apns' } });
|
||||
for (const installation of installations) {
|
||||
// Do not send background clears to installations that never received an alert.
|
||||
const delivered = await tx.agentPushDelivery.findFirst({ where: { installationId: installation.id, sourceId, kind: 'message', status: { in: ['accepted', 'intent', 'ambiguous'] } } });
|
||||
if (!delivered) continue;
|
||||
await tx.agentPushDelivery.updateMany({ where: { installationId: installation.id, kind: 'read_state', status: 'pending' }, data: { status: 'cancelled', outcome: 'superseded' } });
|
||||
const id = digest(`clear:${installation.id}:${installation.version}:${sourceId}:${cursor}`);
|
||||
const now = this.now();
|
||||
await tx.agentPushDelivery.upsert({ where: { id }, update: {}, create: { id, ownerEpoch: this.epoch, installationId: installation.id,
|
||||
installationVersion: installation.version, sourceId, cursor, messageId: `read:${cursor}`, kind: 'read_state', trigger: 'read',
|
||||
dueAt: date(Math.max(now, (installation.lastClearAt?.getTime() || 0) + 60_000)), expiresAt: date(now + MAX_AGE_MS) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.readState(tx);
|
||||
}
|
||||
async advanceRead(cursor: string, suppliedSource?: string): Promise<ReadState> {
|
||||
await this.initialize();
|
||||
const result = await this.options.db.$transaction(async tx => {
|
||||
const coordinator = await tx.agentPushCoordinator.findUnique({ where: { id: 'root' } });
|
||||
if (suppliedSource && coordinator?.sourceId !== suppliedSource) throw fail(409, 'Agent source changed; refresh before acknowledging');
|
||||
// Quarantine never relabels old read positions as a different runtime.
|
||||
const changed = await this.mergeRead(tx, cursor, coordinator?.sourceId || null);
|
||||
return { state: changed || await this.readState(tx), changed: Boolean(changed) };
|
||||
});
|
||||
if (result.changed) this.options.broadcast?.(result.state);
|
||||
return result.state;
|
||||
}
|
||||
private async acquire(): Promise<boolean> {
|
||||
const now = this.now();
|
||||
const result = await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch,
|
||||
OR: [{ lockOwner: this.instanceId }, { lockUntil: null }, { lockUntil: { lte: date(now) } }] },
|
||||
data: { lockOwner: this.instanceId, lockUntil: date(now + 30_000) } });
|
||||
return result.count === 1;
|
||||
}
|
||||
private async observe(): Promise<boolean> {
|
||||
const state = await this.options.db.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
const context = parseSafe(NotificationContext, await this.options.context(state.sourceId ? state.cursor : undefined, state.sourceId || undefined));
|
||||
const now = this.now(), observedAt = Date.parse(context.webPresence.observedAt);
|
||||
if ((!state.sourceId || (state.sourceId === context.sourceId && Number(context.cursor) >= Number(state.cursor))) && (observedAt > now + 5_000 || observedAt < now - 10_000 || (state.contextObservedAt && observedAt < state.contextObservedAt.getTime())
|
||||
|| (context.latestUserMessageAt && Date.parse(context.latestUserMessageAt) > now + 5_000)
|
||||
|| (state.latestUserMessageAt && (!context.latestUserMessageAt || Date.parse(context.latestUserMessageAt) < state.latestUserMessageAt.getTime())))) throw new Error('Uncertain runtime clock');
|
||||
const read = await this.options.db.$transaction(async tx => {
|
||||
const current = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
if (current.lockOwner !== this.instanceId || current.ownerEpoch !== this.epoch) throw new Error('Lost coordinator lease');
|
||||
if ((current.sourceId && current.sourceId !== context.sourceId) || (current.sourceId && Number(context.cursor) < Number(current.cursor))) {
|
||||
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { quarantined: true, uncertain: true } });
|
||||
await tx.agentPushDelivery.updateMany({ where: { status: 'pending' }, data: { status: 'cancelled', outcome: 'source_changed' } });
|
||||
return undefined;
|
||||
}
|
||||
if (current.quarantined) return undefined;
|
||||
const initial = !current.sourceId;
|
||||
const previousCursor = Number(current.cursor);
|
||||
let candidateCursor = previousCursor;
|
||||
for (const candidate of context.candidates) {
|
||||
if (Number(candidate.cursor) < candidateCursor || Number(candidate.cursor) > Number(context.cursor)) throw new Error('Invalid source order');
|
||||
candidateCursor = Number(candidate.cursor);
|
||||
}
|
||||
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { sourceId: context.sourceId, cursor: context.cursor,
|
||||
latestUserMessageAt: context.latestUserMessageAt ? date(Date.parse(context.latestUserMessageAt)) : null,
|
||||
nativeActive: context.webPresence.active, nativeUnknown: context.webPresence.unknown, contextObservedAt: date(observedAt), uncertain: context.uncertain,
|
||||
lockUntil: date(now + 30_000) } });
|
||||
await tx.agentReadState.upsert({ where: { id: 'root' }, create: { id: 'root', cursor: '0', sourceId: context.sourceId }, update: {} });
|
||||
await tx.agentReadState.updateMany({ where: { id: 'root', sourceId: null }, data: { sourceId: context.sourceId } });
|
||||
const changedRead = await this.mergeRead(tx, context.nativeReadCursor, context.sourceId);
|
||||
const readState = await this.readState(tx);
|
||||
if (!initial && this.options.config.enabled && !context.uncertain) {
|
||||
const installations = await tx.agentPushInstallation.findMany({ where: { ownerEpoch: this.epoch, enabled: true } });
|
||||
for (const candidate of context.candidates) {
|
||||
const createdAt = Date.parse(candidate.createdAt);
|
||||
if (Number(candidate.cursor) <= previousCursor || Number(candidate.cursor) <= Number(readState.cursor) || candidate.trigger === 'unknown'
|
||||
|| createdAt > now + 5_000 || createdAt + MAX_AGE_MS <= now || (candidate.trigger === 'user' && !context.latestUserMessageAt)) continue;
|
||||
for (const installation of installations) {
|
||||
const id = digest(`${this.epoch}:${installation.id}:${installation.version}:${context.sourceId}:${candidate.messageId}`);
|
||||
if (await tx.agentPushDelivery.findUnique({ where: { id } })) continue;
|
||||
const older = await tx.agentPushDelivery.findFirst({ where: { installationId: installation.id, sourceId: context.sourceId, kind: 'message', status: 'pending' }, orderBy: { createdAt: 'asc' } });
|
||||
await tx.agentPushDelivery.updateMany({ where: { installationId: installation.id, sourceId: context.sourceId, kind: 'message', status: 'pending' }, data: { status: 'cancelled', outcome: 'coalesced' } });
|
||||
const burstStart = older?.createdAt.getTime() || now;
|
||||
await tx.agentPushDelivery.create({ data: { id, ownerEpoch: this.epoch, installationId: installation.id, installationVersion: installation.version,
|
||||
sourceId: context.sourceId, messageId: candidate.messageId, cursor: candidate.cursor, kind: 'message', trigger: candidate.trigger,
|
||||
priority: Math.max(older?.priority || 0, ['timer', 'external_event'].includes(candidate.trigger) ||
|
||||
(context.latestUserMessageAt && createdAt - Date.parse(context.latestUserMessageAt) >= 30_000) ? 2 : 1),
|
||||
createdAt: date(burstStart), dueAt: date(Math.max(Math.min(now + PUSH_POLICY.debounceMs, burstStart + 15_000),
|
||||
context.latestUserMessageAt ? Date.parse(context.latestUserMessageAt) + PUSH_POLICY.promptGraceMs : 0)), expiresAt: date(createdAt + MAX_AGE_MS) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep the durable active outbox plus at most 10,000 completed receipts. The source checkpoint,
|
||||
// not receipt retention, prevents historical replay after cleanup.
|
||||
await tx.$executeRaw`DELETE FROM "AgentPushDelivery" WHERE "id" IN (
|
||||
SELECT "id" FROM "AgentPushDelivery" WHERE "status" NOT IN ('pending','intent') ORDER BY "updatedAt" ASC
|
||||
LIMIT MAX(0, (SELECT COUNT(*) FROM "AgentPushDelivery" WHERE "status" NOT IN ('pending','intent')) - 10000))`;
|
||||
return changedRead;
|
||||
});
|
||||
if (read) this.options.broadcast?.(read);
|
||||
return context.hasMore;
|
||||
}
|
||||
private async browserActive(tx: Tx, now: number): Promise<boolean> {
|
||||
if (this.untrackedConnections.size) return true;
|
||||
const presences = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
|
||||
if (presences.some(v => (v.visible && v.focused) || (v.lastInteractionAt && v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs > now))) return true;
|
||||
const connections = await tx.agentPushConnection.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
|
||||
return connections.some(v => v.kind !== 'native' && (v.kind !== 'web' || !presences.some(p => p.id === v.sessionId)));
|
||||
}
|
||||
private async dispatch(id: string) {
|
||||
const claim = await this.options.db.$transaction(async tx => {
|
||||
const now = this.now();
|
||||
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
const row = await tx.agentPushDelivery.findUnique({ where: { id } });
|
||||
if (!row || row.status !== 'pending' || row.dueAt.getTime() > now) return;
|
||||
const installation = await tx.agentPushInstallation.findUnique({ where: { id: row.installationId } });
|
||||
const read = await this.readState(tx);
|
||||
if (!installation?.enabled || installation.ownerEpoch !== this.epoch || installation.version !== row.installationVersion || row.expiresAt.getTime() <= now
|
||||
|| row.ownerEpoch !== this.epoch || row.sourceId !== state.sourceId || state.quarantined || (row.kind === 'message' && Number(row.cursor) <= Number(read.cursor))) {
|
||||
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'obsolete' } }); return;
|
||||
}
|
||||
if (!this.options.config.enabled || state.lockOwner !== this.instanceId || !state.lockUntil || state.lockUntil.getTime() <= now
|
||||
|| state.uncertain || !state.contextObservedAt || state.contextObservedAt.getTime() < now - 10_000 || now < this.warmUntil) return;
|
||||
if (row.kind === 'message' && (state.nativeActive || state.nativeUnknown || await this.browserActive(tx, now)
|
||||
|| (state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|
||||
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now))) {
|
||||
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(now + PUSH_POLICY.debounceMs) } }); return;
|
||||
}
|
||||
if (row.attempts >= MAX_ATTEMPTS) { await tx.agentPushDelivery.update({ where: { id }, data: { status: 'failed', outcome: 'attempt_limit' } }); return; }
|
||||
const registration = parseSafe(InstallationBody, JSON.parse(installation.payload));
|
||||
if (registration.transport === 'web' && registration.subscription.expirationTime != null && registration.subscription.expirationTime <= now) {
|
||||
await tx.agentPushInstallation.update({ where: { id: installation.id }, data: { enabled: false } });
|
||||
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'expired_subscription' } }); return;
|
||||
}
|
||||
// Durable crossing intent is committed BEFORE invoking an external adapter. A crash here is ambiguous, never replayed.
|
||||
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'intent', attempts: { increment: 1 }, outcome: null } });
|
||||
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { lockUntil: date(now + 30_000) } });
|
||||
if (row.kind === 'read_state') await tx.agentPushInstallation.update({ where: { id: installation.id }, data: { lastClearAt: date(now) } });
|
||||
const identity: Identity = row.kind === 'read_state' ? { version: 1, sourceId: row.sourceId, kind: 'read_state', readCursor: read.cursor }
|
||||
: { version: 1, sourceId: row.sourceId, kind: 'message', messageId: row.messageId, cursor: row.cursor };
|
||||
return { row, registration, identity, now };
|
||||
});
|
||||
if (!claim) return;
|
||||
let result;
|
||||
try { result = await this.options.send(claim.registration, claim.identity, claim.row.expiresAt.getTime(), claim.now); }
|
||||
catch { result = { outcome: 'ambiguous' as const }; }
|
||||
await this.options.db.$transaction(async tx => {
|
||||
const pending = result.outcome === 'retry' && claim.row.attempts + 1 < MAX_ATTEMPTS;
|
||||
await tx.agentPushDelivery.updateMany({ where: { id, status: 'intent' }, data: { status: pending ? 'pending' : result.outcome === 'retry' ? 'failed' : result.outcome,
|
||||
outcome: result.outcome, ...(pending ? { dueAt: date(this.now() + (result.retryAfterMs ?? Math.min(60_000, 2_000 * 2 ** claim.row.attempts))) } : {}) } });
|
||||
if (result.outcome === 'invalid') await tx.agentPushInstallation.updateMany({ where: { id: claim.row.installationId, version: claim.row.installationVersion, ownerEpoch: this.epoch }, data: { enabled: false } });
|
||||
});
|
||||
}
|
||||
async tick() {
|
||||
if (this.running || this.stopped || !this.options.config.adminToken) return;
|
||||
this.running = true;
|
||||
try {
|
||||
await this.initialize(); if (!await this.acquire()) return;
|
||||
// Only the lease holder recovers expired intents. Never resend a potentially accepted provider request.
|
||||
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'intent', updatedAt: { lt: date(this.now() - 30_000) } }, data: { status: 'ambiguous', outcome: 'interrupted_send' } });
|
||||
let more = true;
|
||||
for (let i = 0; i < 10 && more; i++) { if (!await this.acquire()) return; more = await this.observe(); }
|
||||
if (more) return; // Current native read/prompt/presence must be caught up before dispatch.
|
||||
const pending = await this.options.db.agentPushDelivery.findMany({ where: { status: 'pending', dueAt: { lte: date(this.now()) } }, orderBy: [{ priority: 'desc' }, { dueAt: 'asc' }], take: 20, select: { id: true } });
|
||||
for (const row of pending) {
|
||||
if (this.stopped || !await this.acquire()) break;
|
||||
// Fetch fresh runtime native presence/read/latest prompt before EACH provider boundary.
|
||||
if (await this.observe()) break;
|
||||
await this.dispatch(row.id);
|
||||
}
|
||||
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'pending', expiresAt: { lte: date(this.now()) } }, data: { status: 'cancelled', outcome: 'expired' } });
|
||||
await this.options.db.agentPushPresence.deleteMany({ where: { expiresAt: { lt: date(this.now() - 86_400_000) } } });
|
||||
await this.options.db.agentPushConnection.deleteMany({ where: { expiresAt: { lt: date(this.now()) } } });
|
||||
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
|
||||
} catch {
|
||||
// An unavailable source, DB, malformed contract or clock uncertainty blocks dispatch without leaking payloads.
|
||||
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { uncertain: true } }); } catch { /* Retry on next loop. */ }
|
||||
} finally {
|
||||
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient, upstream: { baseUrl: string; apiKey?: string }, broadcast: (state: ReadState) => void,
|
||||
overrides?: Partial<Options>): Promise<AgentPushCoordinator> {
|
||||
const config = overrides?.config || loadPushConfig();
|
||||
const coordinator = new AgentPushCoordinator({ db, config, broadcast,
|
||||
context: async (after, sourceId) => {
|
||||
if (!upstream.apiKey) throw new Error('Runtime unavailable');
|
||||
// A replaced/restored runtime can reject the old cursor before returning its identity.
|
||||
// Share one deadline across the incremental request and its single high-water probe.
|
||||
const signal = AbortSignal.timeout(10_000);
|
||||
const request = async (cursor?: string) => {
|
||||
const url = new URL('/api/notification-context', upstream.baseUrl);
|
||||
if (cursor !== undefined) url.searchParams.set('after', cursor); url.searchParams.set('limit', '200');
|
||||
return fetch(url, { headers: { Authorization: `Bearer ${upstream.apiKey}`, 'X-Sybil-Client': 'backend-proxy-v1' }, signal, redirect: 'error' });
|
||||
};
|
||||
let response = await request(after);
|
||||
const probe = response.status === 409 && after !== undefined;
|
||||
if (probe) { await response.body?.cancel(); response = await request(); }
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error('Runtime unavailable'); }
|
||||
const reader = response.body?.getReader(); if (!reader) throw new Error('Invalid runtime response');
|
||||
const chunks: Uint8Array[] = []; let length = 0;
|
||||
try { for (;;) { const { done, value } = await reader.read(); if (done) break; length += value.length;
|
||||
if (length > 512 * 1024) throw new Error('Invalid runtime response'); chunks.push(value); }
|
||||
} finally { await reader.cancel(); }
|
||||
const context = parseSafe(NotificationContext, JSON.parse(Buffer.concat(chunks).toString()));
|
||||
// This probe may only establish quarantine. Never use it to skip a backlog, advance
|
||||
// the checkpoint, merge new reads, or replay historical candidates after a generic409.
|
||||
if (probe && (!sourceId || context.hasMore || context.candidates.length > 0
|
||||
|| (context.sourceId === sourceId && Number(context.cursor) >= Number(after)))) throw new Error('Runtime cursor unavailable');
|
||||
return context;
|
||||
}, send: createPushSender(config), ...overrides });
|
||||
const auth = (req: FastifyRequest) => { if (!env.ADMIN_TOKEN) throw fail(503, 'Agent push requires authenticated access'); requireAdmin(req); };
|
||||
const safe = <T>(fn: () => Promise<T>): Promise<T> => fn().catch(error => { if (error?.statusCode) throw error; throw fail(503, 'Agent push is unavailable'); });
|
||||
const routeOptions = { bodyLimit: 16 * 1024 };
|
||||
app.get('/v1/agent/push/config', async (req, reply) => { reply.header('Cache-Control', 'no-store'); auth(req); return safe(() => coordinator.config()); });
|
||||
app.put<{ Params: { installationId: string } }>('/v1/agent/push/installations/:installationId', routeOptions, async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store'); auth(req);
|
||||
const id = parseSafe(InstallationId, req.params.installationId), value = parseSafe(InstallationBody, req.body);
|
||||
return safe(() => coordinator.register(id, value));
|
||||
});
|
||||
app.delete<{ Params: { installationId: string } }>('/v1/agent/push/installations/:installationId', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store'); auth(req); await safe(() => coordinator.revoke(parseSafe(InstallationId, req.params.installationId))); return reply.code(204).send();
|
||||
});
|
||||
app.put<{ Params: { sessionId: string } }>('/v1/agent/presence/:sessionId', routeOptions, async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store'); auth(req); return safe(() => coordinator.presence(parseSafe(InstallationId, req.params.sessionId), parseSafe(PresenceBody, req.body)));
|
||||
});
|
||||
app.delete<{ Params: { sessionId: string } }>('/v1/agent/presence/:sessionId', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store'); auth(req); await safe(() => coordinator.retirePresence(parseSafe(InstallationId, req.params.sessionId))); return reply.code(204).send();
|
||||
});
|
||||
app.addHook('onReady', async () => { if (config.adminToken && upstream.apiKey) coordinator.start(); });
|
||||
app.addHook('onClose', async () => coordinator.stop());
|
||||
return coordinator;
|
||||
}
|
||||
+23
-22
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { registerPushRoutes, type AgentPushCoordinator, type ReadState } from './agent-push.js';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
@@ -14,8 +16,8 @@ const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional()
|
||||
.refine(value => !(value.after && value.before), 'Use after or before, not both');
|
||||
const MessageBody = z.object({ content: z.string().max(40_000), requestId: z.string().min(1).max(256), attachments: AgentAttachmentReferences.optional() })
|
||||
.refine(value => value.content.trim().length > 0 || Boolean(value.attachments?.length), 'content or attachments are required');
|
||||
const ReadBody = z.object({ cursor: Cursor });
|
||||
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() });
|
||||
const ReadBody = z.object({ cursor: Cursor, sourceId: z.string().uuid().optional() });
|
||||
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional(), clientKind: z.enum(['web', 'native']).optional(), presenceSessionId: z.string().uuid().optional() });
|
||||
const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() });
|
||||
const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
||||
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string(), attachmentIds: z.array(z.string()).optional() });
|
||||
@@ -27,22 +29,19 @@ const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
|
||||
return result.data;
|
||||
};
|
||||
|
||||
async function readState() {
|
||||
const value = await prisma.agentReadState.findUnique({ where: { id: 'root' } });
|
||||
return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null };
|
||||
}
|
||||
|
||||
/** Sybil Agent owns the conversation; the chat server stores only its shared read cursor. */
|
||||
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
|
||||
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
|
||||
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
|
||||
const readListeners = new Set<(state: ReadState) => void>();
|
||||
const push = await registerPushRoutes(app, prisma, { baseUrl, apiKey }, state => { for (const send of readListeners) send(state); });
|
||||
// An open development proxy must not expose a token-protected attachment runtime's
|
||||
// history/model context using its server-side credential.
|
||||
const guardOpenProxy = async (signal?: AbortSignal) => {
|
||||
if (env.ADMIN_TOKEN) return;
|
||||
try {
|
||||
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
|
||||
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
|
||||
});
|
||||
const descriptor = await response.json() as { capabilities?: { attachments?: unknown } };
|
||||
if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime');
|
||||
@@ -54,7 +53,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL(path, baseUrl), { method,
|
||||
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
|
||||
@@ -88,6 +87,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const body = parse(MessageBody, req.body);
|
||||
await push.notePrompt();
|
||||
if (body.attachments?.length) await requireAgentAttachments(proxyJson);
|
||||
const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
@@ -103,26 +103,20 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
|
||||
app.get('/v1/agent/read-state', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
return readState();
|
||||
return push.readState();
|
||||
});
|
||||
app.put('/v1/agent/read-state', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const { cursor } = parse(ReadBody, req.body);
|
||||
// One SQLite statement makes concurrent tabs monotonic. Never write an older
|
||||
// cursor over one saved by another connection between a read and an update.
|
||||
await prisma.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT("id") DO UPDATE SET
|
||||
"cursor" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN excluded."cursor" ELSE "AgentReadState"."cursor" END,
|
||||
"updatedAt" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN CURRENT_TIMESTAMP ELSE "AgentReadState"."updatedAt" END`;
|
||||
return readState();
|
||||
const { cursor, sourceId } = parse(ReadBody, req.body);
|
||||
return push.advanceRead(cursor, sourceId);
|
||||
});
|
||||
|
||||
await registerAgentAttachmentRoutes(app, { baseUrl, apiKey, proxyJson });
|
||||
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy);
|
||||
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy, push, readListeners);
|
||||
}
|
||||
|
||||
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise<void>) {
|
||||
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise<void>, push: AgentPushCoordinator, readListeners: Set<(state: ReadState) => void>) {
|
||||
const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
|
||||
const cleanup = new Set<() => void>();
|
||||
const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||
@@ -134,6 +128,8 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string
|
||||
if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
|
||||
sockets.handleUpgrade(request, socket, head, browser => {
|
||||
let upstream: WebSocket | undefined;
|
||||
const connectionId = randomUUID();
|
||||
const sendRead = (state: ReadState) => send({ type: 'read_state', ...state });
|
||||
let authenticated = false, authenticating = false, disposed = false, alive = true;
|
||||
const send = (value: unknown) => {
|
||||
if (browser.readyState !== WebSocket.OPEN) return;
|
||||
@@ -142,10 +138,12 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string
|
||||
};
|
||||
const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000);
|
||||
const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000);
|
||||
browser.on('pong', () => { alive = true; });
|
||||
browser.on('pong', () => { alive = true; if (authenticated && env.ADMIN_TOKEN) void push.connect(connectionId).catch(() => {}); });
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose);
|
||||
readListeners.delete(sendRead);
|
||||
void push.disconnect(connectionId).catch(() => {});
|
||||
upstream?.terminate(); browser.terminate();
|
||||
};
|
||||
cleanup.add(dispose);
|
||||
@@ -163,8 +161,11 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string
|
||||
try { await guardOpenProxy(); } catch { browser.close(4503, 'Authenticated Sybil access is required'); return; }
|
||||
if (disposed || browser.readyState !== WebSocket.OPEN) return;
|
||||
authenticated = true; clearTimeout(authTimeout);
|
||||
readListeners.add(sendRead);
|
||||
if (env.ADMIN_TOKEN) await push.connect(connectionId, auth.clientKind, auth.presenceSessionId).catch(() => {});
|
||||
if (disposed) { void push.disconnect(connectionId).catch(() => {}); return; }
|
||||
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
|
||||
upstream = new WebSocket(target, { headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
|
||||
upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
|
||||
upstream.on('message', (data, binary) => {
|
||||
if (binary) { browser.close(1011, 'Invalid Sybil Agent frame'); return; }
|
||||
|
||||
+2
-2
@@ -8,9 +8,9 @@ export const prisma = new PrismaClient({
|
||||
});
|
||||
|
||||
prisma.$on("error", (e: any) => {
|
||||
console.error("[prisma:error]", e);
|
||||
console.error("[prisma:error] Database operation failed");
|
||||
});
|
||||
|
||||
prisma.$on("warn", (e: any) => {
|
||||
console.warn("[prisma:warn]", e);
|
||||
console.warn("[prisma:warn] Database warning");
|
||||
});
|
||||
|
||||
@@ -72,6 +72,14 @@ const EnvSchema = z.object({
|
||||
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||
SYBIL_AGENT_BASE_URL: z.string().url().default("http://sybil-agent:8788"),
|
||||
SYBIL_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||
// Off until a deliberate server opt-in plus per-installation user consent.
|
||||
AGENT_PUSH_ENABLED: BooleanFlagSchema,
|
||||
AGENT_PUSH_APNS_KEY_FILE: OptionalTrimmedStringSchema,
|
||||
AGENT_PUSH_APNS_KEY_ID: z.string().regex(/^[A-Z0-9]{10}$/).optional(),
|
||||
AGENT_PUSH_APNS_TEAM_ID: z.string().regex(/^[A-Z0-9]{10}$/).optional(),
|
||||
AGENT_PUSH_APNS_TOPIC: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$/).default("net.buzzert.sybil2"),
|
||||
AGENT_PUSH_APNS_ENVIRONMENT: z.enum(["sandbox", "production"]).default("production"),
|
||||
AGENT_PUSH_VAPID_FILE: OptionalTrimmedStringSchema,
|
||||
EXA_API_KEY: z.string().optional(),
|
||||
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
|
||||
|
||||
|
||||
+10
-5
@@ -9,6 +9,9 @@ import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-cata
|
||||
import { registerAgentRoutes } from "./agent.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
// Push URL parameters are identifiers, never credentials. Redact even malformed caller URLs.
|
||||
const logUrl = (url: string) => url.startsWith('/v1/agent/push/') ? '/v1/agent/push/[redacted]'
|
||||
: url.startsWith('/v1/agent/presence/') ? '/v1/agent/presence/[redacted]' : url;
|
||||
const app = Fastify({
|
||||
bodyLimit: 32 * 1024 * 1024,
|
||||
disableRequestLogging: true,
|
||||
@@ -44,7 +47,7 @@ await app.register(sensible);
|
||||
|
||||
app.addHook("onRequest", async (req) => {
|
||||
if (req.url.startsWith("/health")) return;
|
||||
req.log.info({ method: req.method, url: req.url }, "request received");
|
||||
req.log.info({ method: req.method, url: logUrl(req.url) }, "request received");
|
||||
});
|
||||
|
||||
app.addHook("onResponse", async (req, reply) => {
|
||||
@@ -53,7 +56,7 @@ app.addHook("onResponse", async (req, reply) => {
|
||||
req.log.info(
|
||||
{
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
url: logUrl(req.url),
|
||||
statusCode: reply.statusCode,
|
||||
responseTimeMs,
|
||||
},
|
||||
@@ -68,16 +71,18 @@ app.setErrorHandler((err, req, reply) => {
|
||||
req.log.error(
|
||||
{
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
url: logUrl(req.url),
|
||||
statusCode,
|
||||
err: e,
|
||||
err: req.url.startsWith('/v1/agent/push/') || req.url.startsWith('/v1/agent/presence/') ? { message: 'Agent push request failed' } : e,
|
||||
},
|
||||
"request failed"
|
||||
);
|
||||
}
|
||||
reply.status(statusCode).send({
|
||||
error: true,
|
||||
message: e.message ?? String(e),
|
||||
message: (req.url.startsWith('/v1/agent/push/') || req.url.startsWith('/v1/agent/presence/'))
|
||||
? (statusCode === 400 ? 'Invalid Agent push request' : statusCode >= 500 ? 'Agent push is unavailable' : e.message || 'Agent push request failed')
|
||||
: e.message ?? String(e),
|
||||
statusCode,
|
||||
...(typeof e.code === 'string' ? { code: e.code } : {}),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { once } from 'node:events';
|
||||
import { createServer, request as httpRequest, type ServerResponse } from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import sensible from '@fastify/sensible';
|
||||
process.env.AGENT_PUSH_ENABLED = 'false';
|
||||
process.env.ADMIN_TOKEN = 'attachments-browser-token-test';
|
||||
process.env.DATABASE_URL = 'file:/tmp/sybil-attachment-proxy-unused.sqlite';
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sybil-attachment-proxy-'));
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
process.env.DATABASE_URL = `file:${join(directory, 'test.db')}`;
|
||||
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: process.env, stdio: 'pipe' });
|
||||
const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/agent.js'), import('../src/env.js')]);
|
||||
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key';
|
||||
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' };
|
||||
@@ -28,7 +37,7 @@ const source=createServer(async(req,res)=>{
|
||||
source.listen(0,'127.0.0.1');await once(source,'listening');
|
||||
const app=Fastify();await app.register(sensible);await registerAgentRoutes(app,{baseUrl:`http://127.0.0.1:${(source.address() as {port:number}).port}`,apiKey:key});
|
||||
await app.listen({port:0,host:'127.0.0.1'});const base=`http://127.0.0.1:${(app.server.address() as {port:number}).port}`;
|
||||
test.after(async()=>{await app.close();source.closeAllConnections();await new Promise<void>(r=>source.close(()=>r()));});
|
||||
test.after(async()=>{await app.close();source.closeAllConnections();await new Promise<void>(r=>source.close(()=>r()));const {prisma}=await import('../src/db.js');await prisma.$disconnect();rmSync(directory,{recursive:true,force:true});});
|
||||
function form(){const body=new FormData();body.append('file',new Blob(['data'],{type:'text/plain'}),'résumé.txt');body.append('uploadId','12345678-1234-4234-8234-123456789abc');return body;}
|
||||
|
||||
test('auth precedes parsing/forwarding; capability gates old or unsafe upstream and open backend',async()=>{
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createECDH, generateKeyPairSync, randomUUID, verify } from 'node:crypto';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Fastify from 'fastify';
|
||||
import sensible from '@fastify/sensible';
|
||||
import webpush from 'web-push';
|
||||
import type { Context, Registration } from '../src/agent-push-contract.js';
|
||||
import type { PushConfig } from '../src/agent-push-config.js';
|
||||
import type { SendResult } from '../src/agent-push-transports.js';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sybil-push-test-'));
|
||||
process.env.DATABASE_URL = `file:${join(directory, 'push.db')}`;
|
||||
process.env.ADMIN_TOKEN = 'push-test-bearer-0123456789';
|
||||
process.env.AGENT_PUSH_ENABLED = 'false';
|
||||
for (const key of ['AGENT_PUSH_APNS_KEY_FILE','AGENT_PUSH_APNS_KEY_ID','AGENT_PUSH_APNS_TEAM_ID','AGENT_PUSH_VAPID_FILE']) delete process.env[key];
|
||||
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: process.env, stdio: 'pipe' });
|
||||
const [{ prisma }, { AgentPushCoordinator, registerPushRoutes }, { createPushSender, isPublicAddress, createAPNsRequest, createWebRequest }, { validatePushEndpoint }, { env }] = await Promise.all([
|
||||
import('../src/db.js'), import('../src/agent-push.js'), import('../src/agent-push-transports.js'), import('../src/agent-push-contract.js'), import('../src/env.js'),
|
||||
]);
|
||||
const apns: Registration = { transport: 'apns', enabled: true, token: 'a'.repeat(64), environment: 'sandbox', topic: 'net.buzzert.sybil2' };
|
||||
const config: PushConfig = { enabled: true, adminToken: process.env.ADMIN_TOKEN,
|
||||
apns: { key: 'fixture-unused-by-injected-sender', keyId: 'ABCDEFGHIJ', teamId: 'DQQH5H6GBD', topic: apns.topic, environment: 'sandbox' },
|
||||
web: { publicKey: 'fixture-unused', privateKey: 'fixture-unused', subject: 'mailto:tests@example.com' } };
|
||||
function subscription(): Registration {
|
||||
const curve = createECDH('prime256v1'); curve.generateKeys();
|
||||
return { transport: 'web', enabled: true, subscription: { endpoint: `https://web.push.apple.com/test/${randomUUID()}`, expirationTime: null,
|
||||
keys: { p256dh: curve.getPublicKey().toString('base64url'), auth: Buffer.alloc(16, 1).toString('base64url') } } };
|
||||
}
|
||||
async function fixture(options: { warmupMs?: number; config?: PushConfig } = {}) {
|
||||
let now = Date.now();
|
||||
const source = randomUUID();
|
||||
let context: Context = { version: 1, sourceId: source, conversationId: '1', cursor: '10', hasMore: false,
|
||||
latestUserMessageAt: new Date(now - 30_000).toISOString(), nativeReadCursor: '0', uncertain: false,
|
||||
webPresence: { active: false, unknown: false, observedAt: new Date(now).toISOString() }, candidates: [] };
|
||||
const sent: { registration: Registration; identity: any }[] = [], reads: any[] = [];
|
||||
let result: SendResult = { outcome: 'accepted' }, failure = false;
|
||||
const manager = new AgentPushCoordinator({ db: prisma, config: options.config || config, now: () => now,
|
||||
warmupMs: options.warmupMs ?? 0, context: async after => { if (failure) throw new Error('upstream secret must not leak');
|
||||
return { ...context, webPresence: { ...context.webPresence, observedAt: new Date(now).toISOString() }, candidates: after === undefined ? [] : context.candidates.filter(c => Number(c.cursor) > Number(after)) }; },
|
||||
send: async (registration, identity) => { sent.push({ registration, identity }); return result; }, broadcast: value => reads.push(value) });
|
||||
await manager.initialize();
|
||||
const installationId = randomUUID(); await manager.register(installationId, apns); await manager.tick();
|
||||
return { manager, installationId, source, sent, reads, get now() { return now; }, advance(ms: number) { now += ms; },
|
||||
result(value: SendResult) { result = value; }, failure(value: boolean) { failure = value; },
|
||||
context(value: Partial<Context>) { context = { ...context, ...value }; },
|
||||
candidate(cursor = '11', trigger: Context['candidates'][number]['trigger'] = 'user') { context = { ...context, cursor,
|
||||
candidates: [...context.candidates, { cursor, messageId: `outbound:${cursor}`, createdAt: new Date(now).toISOString(), trigger }] }; },
|
||||
};
|
||||
}
|
||||
test.beforeEach(async () => {
|
||||
for (const table of ['AgentPushDelivery','AgentPushConnection','AgentPushPresence','AgentPushInstallation','AgentPushCoordinator','AgentReadState']) await prisma.$executeRawUnsafe(`DELETE FROM "${table}"`);
|
||||
});
|
||||
test.after(async () => { await prisma.$disconnect(); rmSync(directory, { recursive: true, force: true }); });
|
||||
|
||||
test('first source bootstrap never alerts history; later durable candidates debounce, survive restart and deduplicate', async () => {
|
||||
const f = await fixture(); assert.equal((await f.manager.config()).sourceId, f.source); assert.equal(f.sent.length, 0);
|
||||
f.candidate(); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
assert.deepEqual(f.sent[0].identity, { version: 1, sourceId: f.source, kind: 'message', messageId: 'outbound:11', cursor: '11' });
|
||||
assert.equal((await prisma.agentPushDelivery.findFirst())?.status, 'accepted');
|
||||
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.tick(); assert.equal(f.sent.length, 1);
|
||||
});
|
||||
|
||||
test('all tabs veto: visible focused idle, recent interaction and unknown connections; leases eventually expire', async () => {
|
||||
const f = await fixture(), tab1 = randomUUID(), tab2 = randomUUID(), unknown = randomUUID();
|
||||
await f.manager.presence(tab1, { sequence: 1, visibility: 'hidden', focused: false, interacted: false });
|
||||
await f.manager.presence(tab2, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'focused but idle still vetoes');
|
||||
await f.manager.presence(tab2, { sequence: 2, visibility: 'hidden', focused: false, interacted: true });
|
||||
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'recent input vetoes blur');
|
||||
await f.manager.presence(tab2, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
assert.equal((await prisma.agentPushPresence.findUniqueOrThrow({ where: { id: tab2 } })).sequence, 2);
|
||||
await f.manager.connect(unknown); f.advance(31_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'unknown authenticated socket vetoes');
|
||||
f.advance(61_000); await f.manager.tick(); assert.equal(f.sent.length, 1, 'expired stale sessions retire');
|
||||
});
|
||||
|
||||
test('instrumented hidden web socket is inactive; missing lease and native-runtime unknown veto', async () => {
|
||||
const f = await fixture(), tab = randomUUID(), connection = randomUUID();
|
||||
await f.manager.connect(connection, 'web', tab); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
await f.manager.presence(tab, { sequence: 0, visibility: 'hidden', focused: false, interacted: false });
|
||||
f.context({ webPresence: { active: false, unknown: true, observedAt: '' } }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.context({ webPresence: { active: false, unknown: false, observedAt: '' } }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
});
|
||||
|
||||
test('prompt grace resets on latest accepted prompt, root bursts coalesce, unknown provenance never sends', async () => {
|
||||
const f = await fixture(); f.context({ latestUserMessageAt: new Date(f.now).toISOString() });
|
||||
f.candidate(); await f.manager.tick(); f.advance(3_000); f.candidate('12', 'timer'); await f.manager.tick();
|
||||
f.context({ latestUserMessageAt: new Date(f.now).toISOString() }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.advance(3_000); await f.manager.tick(); assert.equal(f.sent.length, 1); assert.equal(f.sent[0].identity.cursor, '12');
|
||||
f.candidate('13', 'unknown'); await f.manager.tick(); f.advance(10_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
});
|
||||
|
||||
test('canonical read and native read cancel pending sends and broadcast source-bound state', async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); await f.manager.advanceRead('11', f.source); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.candidate('12'); await f.manager.tick(); f.context({ nativeReadCursor: '12' }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
assert.equal((await f.manager.readState()).cursor, '12'); assert.equal(f.reads.at(-1).sourceId, f.source);
|
||||
assert.equal((await f.manager.advanceRead('1')).cursor, '12');
|
||||
await assert.rejects(f.manager.advanceRead('13', randomUUID()), { statusCode: 409 });
|
||||
});
|
||||
|
||||
test('read clears target APNs only, coalesce and rate-limit; Web Push is never invisible', async () => {
|
||||
const f = await fixture(); await f.manager.register(randomUUID(), subscription()); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 2);
|
||||
await f.manager.advanceRead('11', f.source); await f.manager.tick();
|
||||
const clears = f.sent.filter(v => v.identity.kind === 'read_state'); assert.equal(clears.length, 1); assert.equal(clears[0].registration.transport, 'apns');
|
||||
await f.manager.advanceRead('12', f.source); await f.manager.tick(); assert.equal(f.sent.length, 3);
|
||||
f.advance(60_000); await f.manager.tick(); assert.equal(f.sent.filter(v => v.identity.kind === 'read_state').length, 2);
|
||||
});
|
||||
|
||||
test('unavailable source, uncertainty, startup warm-up and source replacement suppress safely', async () => {
|
||||
const f = await fixture({ warmupMs: 60_000 }); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.advance(60_000); f.failure(true); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.failure(false); f.context({ uncertain: true }); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.context({ uncertain: false, sourceId: randomUUID(), latestUserMessageAt: null }); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
assert.equal((await f.manager.config()).quarantined, true); assert.equal((await f.manager.config()).sourceId, f.source);
|
||||
assert.equal((await f.manager.readState()).sourceId, f.source);
|
||||
});
|
||||
|
||||
test('known transient rejections retry boundedly; interrupted intent and ambiguous I/O never replay', async () => {
|
||||
const f = await fixture(); f.result({ outcome: 'retry', retryAfterMs: 1_000 }); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
for (let i = 0; i < 6; i++) { await f.manager.tick(); f.advance(2_000); }
|
||||
assert.equal(f.sent.length, 4); assert.equal((await prisma.agentPushDelivery.findFirst())?.status, 'failed');
|
||||
f.candidate('12'); f.result({ outcome: 'ambiguous' }); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
f.advance(60_000); await f.manager.tick(); assert.equal(f.sent.length, 5);
|
||||
f.candidate('13'); await f.manager.tick(); await prisma.agentPushDelivery.updateMany({ where: { status: 'pending' }, data: { status: 'intent', updatedAt: new Date(f.now - 40_000) } });
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 5); assert.equal(await prisma.agentPushDelivery.count({ where: { status: 'ambiguous' } }), 2);
|
||||
});
|
||||
|
||||
test('revoke and rotation prevent pending sends; permanent rejection cannot invalidate a newer endpoint version', async () => {
|
||||
const f = await fixture(); const same = await f.manager.register(f.installationId, apns); assert.equal(same.version, 1);
|
||||
f.candidate(); await f.manager.tick(); await f.manager.revoke(f.installationId); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
const newId = randomUUID(); await f.manager.register(newId, apns); f.candidate('12'); await f.manager.tick(); f.advance(2_000);
|
||||
f.manager.options.send = async () => { await f.manager.register(newId, { ...apns, token: 'b'.repeat(64) }); return { outcome: 'invalid' }; };
|
||||
await f.manager.tick(); const latest = await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: newId } }); assert.equal(latest.enabled, true); assert.equal(latest.version, 2);
|
||||
});
|
||||
|
||||
test('credential generation change disables opt-ins; two coordinators do not send one delivery twice', async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
const second = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await second.initialize();
|
||||
await Promise.all([f.manager.tick(), second.tick()]); assert.equal(f.sent.length, 1);
|
||||
const rotated = new AgentPushCoordinator({ ...f.manager.options, config: { ...config, adminToken: 'rotated-fixture-token' }, warmupMs: 0 }); await rotated.initialize();
|
||||
assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } })).enabled, false);
|
||||
});
|
||||
|
||||
test('authenticated routes reject open mode, wrong owner/body, malformed endpoints and secrets are never reflected', async () => {
|
||||
const app = Fastify({ logger: false }); await app.register(sensible);
|
||||
await registerPushRoutes(app, prisma, { baseUrl: 'http://unused.invalid' }, () => {}, { config, context: async () => { throw new Error(); }, send: async () => { throw new Error('must never send'); } });
|
||||
const headers = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, url = `/v1/agent/push/installations/${randomUUID()}`;
|
||||
assert.equal((await app.inject({ url: '/v1/agent/push/config' })).statusCode, 401);
|
||||
assert.equal((await app.inject({ url: '/v1/agent/push/config', headers: { authorization: 'Bearer wrong' } })).statusCode, 403);
|
||||
const valid = await app.inject({ method: 'PUT', url, headers, payload: apns }); assert.equal(valid.statusCode, 200); assert.ok(!valid.body.includes(apns.token));
|
||||
assert.equal((await app.inject({ method: 'PUT', url, headers, payload: { ...apns, ownerId: 'someone-else' } })).statusCode, 400);
|
||||
const invalid = subscription(); if (invalid.transport === 'web') invalid.subscription.endpoint = 'https://127.0.0.1/private-secret';
|
||||
const response = await app.inject({ method: 'PUT', url, headers, payload: invalid }); assert.equal(response.statusCode, 400); assert.ok(!response.body.includes('private-secret'));
|
||||
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
|
||||
try { assert.equal((await app.inject({ url: '/v1/agent/push/config', headers })).statusCode, 503); } finally { env.ADMIN_TOKEN = saved; }
|
||||
assert.equal((await app.inject({ method: 'DELETE', url, headers })).statusCode, 204);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('provider endpoint validation rejects SSRF targets and private/reserved DNS addresses', () => {
|
||||
for (const value of ['http://fcm.googleapis.com/x','https://user:pass@fcm.googleapis.com/x','https://fcm.googleapis.com.evil.example/x', 'https://fcm.googleapis.com:8443/x', 'https://[::1]/x','https://web.push.apple.com/x#fragment']) assert.throws(() => validatePushEndpoint(value));
|
||||
for (const address of ['127.0.0.1','10.0.0.1','169.254.1.1','192.168.1.1','172.16.0.1','::1','::ffff:127.0.0.1','fd00::1','fe80::1','2001:db8::1']) assert.equal(isPublicAddress(address), false, address);
|
||||
assert.equal(isPublicAddress('8.8.8.8'), true); assert.equal(isPublicAddress('2606:4700::1111'), true);
|
||||
});
|
||||
|
||||
test('real APNs ES256 request construction uses generic alerts, explicit environment and background clear headers through injected HTTP2 boundary', async () => {
|
||||
const key = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const requests: any[] = []; const now = Date.now(), sourceId = randomUUID();
|
||||
const sender = createPushSender({ ...config, apns: { ...config.apns!, key: key.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString() } },
|
||||
{ apns: async request => { requests.push(request); return { status: 200 }; } });
|
||||
const identity = { version: 1 as const, sourceId, kind: 'message' as const, messageId: 'outbound:12', cursor: '12' };
|
||||
assert.equal((await sender(apns, identity, now + 300_000, now)).outcome, 'accepted');
|
||||
assert.equal(requests[0].endpoint, `https://api.sandbox.push.apple.com/3/device/${apns.token}`);
|
||||
assert.equal(requests[0].headers['apns-push-type'], 'alert'); assert.equal(requests[0].headers['apns-topic'], apns.topic);
|
||||
const [header, claims, signature] = requests[0].headers.authorization.slice(7).split('.');
|
||||
assert.equal(JSON.parse(Buffer.from(header, 'base64url').toString()).kid, 'ABCDEFGHIJ');
|
||||
assert.equal(JSON.parse(Buffer.from(claims, 'base64url').toString()).iss, 'DQQH5H6GBD');
|
||||
assert.equal(verify('sha256', Buffer.from(`${header}.${claims}`), { key: key.publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64url')), true);
|
||||
assert.deepEqual(JSON.parse(requests[0].body).aps.alert, { title: 'Sybil Agent', body: 'You have a new message.' });
|
||||
await sender(apns, { version: 1, sourceId, kind: 'read_state', readCursor: '12' }, now + 60_000, now);
|
||||
assert.equal(requests[1].headers['apns-push-type'], 'background'); assert.equal(requests[1].headers['apns-priority'], '5');
|
||||
assert.deepEqual(JSON.parse(requests[1].body).aps, { 'content-available': 1 });
|
||||
});
|
||||
|
||||
test('standard encrypted aes128gcm + VAPID Web Push uses library request construction and never sends read clears', async () => {
|
||||
const vapid = webpush.generateVAPIDKeys(), registrations = subscription(), requests: any[] = [];
|
||||
const sender = createPushSender({ ...config, web: { ...vapid, subject: 'mailto:tests@example.com' } }, { web: async request => { requests.push(request); return { status: 201 }; } });
|
||||
const now = Date.now(), sourceId = randomUUID();
|
||||
assert.equal((await sender(registrations, { version: 1, sourceId, kind: 'message', messageId: 'outbound:1', cursor: '1' }, now + 300_000, now)).outcome, 'accepted');
|
||||
assert.equal(requests.length, 1); assert.equal(requests[0].headers['Content-Encoding'], 'aes128gcm'); assert.equal(requests[0].headers.TTL, 60);
|
||||
assert.match(requests[0].headers.Authorization, /^vapid t=/); assert.ok(Buffer.isBuffer(requests[0].body)); assert.ok(!requests[0].body.includes('You have a new message.'));
|
||||
assert.equal((await sender(registrations, { version: 1, sourceId, kind: 'read_state', readCursor: '1' }, now + 60_000, now)).outcome, 'failed'); assert.equal(requests.length, 1);
|
||||
const gone = createPushSender({ ...config, web: { ...vapid, subject: 'mailto:tests@example.com' } }, { web: async () => ({ status: 410 }) });
|
||||
assert.equal((await gone(registrations, { version: 1, sourceId, kind: 'message', messageId: 'outbound:2', cursor: '2' }, now + 60_000, now)).outcome, 'invalid');
|
||||
});
|
||||
|
||||
|
||||
test('production-scoped APNs configuration refuses sandbox registration and cannot send it', async () => {
|
||||
const production = { ...config, apns: { ...config.apns!, environment: 'production' as const } };
|
||||
const manager = new AgentPushCoordinator({ db: prisma, config: production, context: async () => { throw new Error(); }, send: async () => { throw new Error('must not send'); } });
|
||||
await assert.rejects(manager.register(randomUUID(), apns), { statusCode: 400 });
|
||||
let requests = 0;
|
||||
const sender = createPushSender(production, { apns: async () => { requests++; throw new Error('must not send'); } });
|
||||
const now = Date.now();
|
||||
assert.equal((await sender(apns, { version: 1, sourceId: randomUUID(), kind: 'message', messageId: 'outbound:1', cursor: '1' }, now + 60_000, now)).outcome, 'failed');
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test('raw mocked HTTP2 handles request throws, goaway and close; raw HTTPS has absolute timeout even without socket inactivity', async () => {
|
||||
const input = { endpoint: 'https://api.push.apple.com/3/device/fixture', headers: {}, body: Buffer.from('{}') };
|
||||
for (const event of ['connect', 'goaway', 'close', 'never']) {
|
||||
const session = new EventEmitter() as any; let destroyed = 0;
|
||||
session.destroy = () => { destroyed++; }; session.request = () => { throw new Error('fixture request error'); };
|
||||
const transport = createAPNsRequest((() => { if (event !== 'never') queueMicrotask(() => session.emit(event)); return session; }) as any, 5);
|
||||
await assert.rejects(transport(input), /APNs transport unavailable/); assert.equal(destroyed, 1);
|
||||
}
|
||||
const req = new EventEmitter() as any; let destroyed = false; req.destroy = () => { destroyed = true; }; req.end = () => {};
|
||||
const web = createWebRequest((() => req) as any, 5);
|
||||
await assert.rejects(web({ ...input, endpoint: 'https://web.push.apple.com/fixture' }), /Web Push transport unavailable/); assert.equal(destroyed, true);
|
||||
const throwing = createWebRequest((() => { throw new Error('fixture constructor error'); }) as any, 5);
|
||||
await assert.rejects(throwing({ ...input, endpoint: 'https://web.push.apple.com/fixture' }), /Web Push transport unavailable/);
|
||||
});
|
||||
|
||||
|
||||
test('DELETE tombstones unknown and known UUIDs across restart; late PUT and rebound endpoints cannot re-enable retired IDs', async () => {
|
||||
const f = await fixture(), unknown = randomUUID();
|
||||
await f.manager.revoke(unknown);
|
||||
await assert.rejects(f.manager.register(unknown, apns), { statusCode: 409 });
|
||||
await f.manager.revoke(f.installationId); await f.manager.revoke(f.installationId);
|
||||
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.initialize();
|
||||
await assert.rejects(restarted.register(f.installationId, apns), { statusCode: 409 });
|
||||
const first = randomUUID(), second = randomUUID();
|
||||
await restarted.register(first, apns); await restarted.register(second, apns);
|
||||
await assert.rejects(restarted.register(first, apns), { statusCode: 409 });
|
||||
assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: first } })).payload, '');
|
||||
assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: second } })).enabled, true);
|
||||
});
|
||||
|
||||
|
||||
test('concurrent backend prompt admission starts durable grace even before runtime snapshot catches up', async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
await f.manager.notePrompt(); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.advance(4_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
});
|
||||
|
||||
|
||||
async function httpContextFixture(f: Awaited<ReturnType<typeof fixture>>, snapshot: unknown, statuses = { incremental: 409, bootstrap: 200 }) {
|
||||
const requests: (string | null)[] = [], sent: unknown[] = [];
|
||||
const source = createServer((req, res) => {
|
||||
assert.equal(req.headers.authorization, 'Bearer fixture-runtime-secret');
|
||||
assert.equal(req.headers['x-sybil-client'], 'backend-proxy-v1');
|
||||
const url = new URL(req.url!, 'http://fixture.invalid');
|
||||
assert.equal(url.pathname, '/api/notification-context'); assert.equal(url.searchParams.get('limit'), '200');
|
||||
const after = url.searchParams.get('after'); requests.push(after);
|
||||
res.statusCode = after === null ? statuses.bootstrap : statuses.incremental;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(after === null && statuses.bootstrap === 200 ? snapshot : { error: 'cursor unavailable' }));
|
||||
});
|
||||
source.listen(0, '127.0.0.1'); await once(source, 'listening');
|
||||
const app = Fastify({ logger: false }); await app.register(sensible);
|
||||
const manager = await registerPushRoutes(app, prisma, { baseUrl: `http://127.0.0.1:${(source.address() as { port: number }).port}`, apiKey: 'fixture-runtime-secret' }, () => {}, {
|
||||
config, warmupMs: 0, now: () => f.now,
|
||||
send: async (_registration, identity) => { sent.push(identity); return { outcome: 'accepted' }; },
|
||||
});
|
||||
return { manager, requests, sent, close: async () => { await manager.stop(); await app.close(); source.closeAllConnections(); await new Promise<void>(resolve => source.close(() => resolve())); } };
|
||||
}
|
||||
function highWaterSnapshot(f: Awaited<ReturnType<typeof fixture>>, overrides: Partial<Context> = {}): Context {
|
||||
return { version: 1, sourceId: randomUUID(), conversationId: '1', cursor: '3', hasMore: false, latestUserMessageAt: null,
|
||||
nativeReadCursor: '0', uncertain: false, webPresence: { active: false, unknown: false, observedAt: new Date(f.now).toISOString() }, candidates: [], ...overrides };
|
||||
}
|
||||
for (const identityChanged of [true, false]) test(`actual HTTP409 probes once and quarantines ${identityChanged ? 'replacement source' : 'same-source cursor regression'} without rebasing reads/checkpoint`, async () => {
|
||||
const f = await fixture(); await f.manager.advanceRead('4', f.source); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
const oldRead = await f.manager.readState();
|
||||
const http = await httpContextFixture(f, highWaterSnapshot(f, identityChanged ? {} : { sourceId: f.source }));
|
||||
try {
|
||||
await http.manager.tick(); assert.deepEqual(http.requests, ['11', null]); assert.equal(http.sent.length, 0);
|
||||
const state = await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
assert.equal(state.quarantined, true); assert.equal(state.sourceId, f.source); assert.equal(state.cursor, '11');
|
||||
assert.deepEqual(await http.manager.readState(), oldRead);
|
||||
const delivery = await prisma.agentPushDelivery.findFirstOrThrow(); assert.equal(delivery.status, 'cancelled'); assert.equal(delivery.outcome, 'source_changed');
|
||||
assert.equal(await prisma.agentPushDelivery.count(), 1, 'probe never enumerates/replays historical candidates');
|
||||
} finally { await http.close(); }
|
||||
});
|
||||
for (const failure of ['rejected', 'conflict', 'malformed', 'candidates', 'paginated', 'same-source-equal', 'same-source-higher', 'non-conflict']) test(`HTTP cursor recovery stays fail closed for ${failure}`, async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000); const oldRead = await f.manager.readState();
|
||||
const snapshot = failure === 'malformed' ? { invalid: true } : highWaterSnapshot(f, {
|
||||
...(failure === 'candidates' ? { candidates: [{ messageId: 'outbound:3', cursor: '3', createdAt: new Date(f.now).toISOString(), trigger: 'timer' as const }] } : {}),
|
||||
...(failure === 'paginated' ? { hasMore: true } : {}),
|
||||
...(failure.startsWith('same-source') ? { sourceId: f.source, cursor: failure === 'same-source-higher' ? '12' : '11', nativeReadCursor: '11' } : {}),
|
||||
});
|
||||
const http = await httpContextFixture(f, snapshot, { incremental: failure === 'non-conflict' ? 503 : 409,
|
||||
bootstrap: failure === 'rejected' ? 503 : failure === 'conflict' ? 409 : 200 });
|
||||
try {
|
||||
await http.manager.tick(); assert.deepEqual(http.requests, failure === 'non-conflict' ? ['11'] : ['11', null]); assert.equal(http.sent.length, 0);
|
||||
const state = await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
assert.equal(state.uncertain, true); assert.equal(state.quarantined, false); assert.equal(state.sourceId, f.source); assert.equal(state.cursor, '11');
|
||||
assert.deepEqual(await http.manager.readState(), oldRead); assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).status, 'pending');
|
||||
} finally { await http.close(); }
|
||||
});
|
||||
|
||||
test('endpoint rebind retires a provider-disabled UUID, rejects its delayed PUT, and leaves no live endpoint after new owner revoke', async () => {
|
||||
const f = await fixture(); f.result({ outcome: 'invalid' }); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
const invalid = await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } });
|
||||
assert.equal(invalid.enabled, false); assert.equal(invalid.revokedAt, null); assert.notEqual(invalid.fingerprint, '');
|
||||
const newer = randomUUID(); await f.manager.register(newer, apns);
|
||||
assert.notEqual((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } })).revokedAt, null);
|
||||
await assert.rejects(f.manager.register(f.installationId, apns), { statusCode: 409 });
|
||||
await f.manager.revoke(newer); assert.equal(await prisma.agentPushInstallation.count({ where: { enabled: true } }), 0);
|
||||
});
|
||||
|
||||
test('endpoint rebind also retires disabled registrations from a prior credential generation', async () => {
|
||||
const f = await fixture();
|
||||
const current = new AgentPushCoordinator({ ...f.manager.options, config: { ...config, adminToken: 'new-fixture-bearer-generation' }, warmupMs: 0 });
|
||||
await current.initialize(); assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } })).enabled, false);
|
||||
const newer = randomUUID(); await current.register(newer, apns);
|
||||
await assert.rejects(f.manager.register(f.installationId, apns), { statusCode: 409 });
|
||||
await assert.rejects(current.register(f.installationId, apns), { statusCode: 409 });
|
||||
await current.revoke(newer); assert.equal(await prisma.agentPushInstallation.count({ where: { enabled: true } }), 0);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { WebSocket, WebSocketServer } from 'ws';
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-'));
|
||||
process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`;
|
||||
process.env.AGENT_PUSH_ENABLED = 'false';
|
||||
process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789';
|
||||
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], {
|
||||
cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe',
|
||||
@@ -93,7 +94,7 @@ test('REST authenticates, proxies the shared room, and retains stable message ID
|
||||
const first = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
|
||||
const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
|
||||
assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json());
|
||||
assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
assert.deepEqual(requests.findLast(request => request.method === 'POST')!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
assert.equal((await prisma.chat.count()), 0, 'Sybil Agent messages are not copied into ordinary Sybil chats');
|
||||
assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400);
|
||||
@@ -165,6 +166,11 @@ test('WebSocket first-frame authentication gates the private connection and forw
|
||||
assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']);
|
||||
assert.equal(frames[1].messages[0].content, 'Later result');
|
||||
assert.equal(frames[2].activity.thinking, true);
|
||||
const beforeRead = subscriptions.length;
|
||||
await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '901' } });
|
||||
await eventually(() => frames.some(frame => frame.type === 'read_state' && frame.cursor === '901'));
|
||||
assert.equal(subscriptions.length, beforeRead, 'read events are backend broadcasts, never runtime subscriptions');
|
||||
assert.equal(frames.find(frame => frame.type === 'read_state').sourceId, null);
|
||||
assert.ok(!JSON.stringify(frames).includes(privateKey));
|
||||
browser.send(JSON.stringify({ type: 'subscribe', after: '43' }));
|
||||
await eventually(() => subscriptions.at(-1)?.after === '43');
|
||||
|
||||
@@ -12,3 +12,119 @@ self.addEventListener("fetch", (event) => {
|
||||
if (event.request.mode !== "navigate") return;
|
||||
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
|
||||
});
|
||||
|
||||
// This worker never receives credentials and never performs authenticated fetches.
|
||||
// Safari requires a visible notification for each valid message push. Reads are
|
||||
// reconciled only by foreground page messages, never an invisible Web Push.
|
||||
const PUSH_TITLE = "Sybil Agent", PUSH_BODY = "You have a new message.", PUSH_URL = "/?agent=root";
|
||||
const sourceId = value => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
const cursor = value => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value));
|
||||
const messageId = value => typeof value === "string" && /^[A-Za-z0-9:_-]{1,200}$/.test(value);
|
||||
const owned = value => value?.version === 1 && value.kind === "message" && sourceId(value.sourceId) && cursor(value.cursor) && messageId(value.messageId);
|
||||
let queue = Promise.resolve();
|
||||
const serial = operation => { const result = queue.then(operation); queue = result.catch(() => undefined); return result; };
|
||||
function database() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open("sybil-agent-notifications-v1", 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore("sources", { keyPath: "sourceId" });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(new Error("Notification storage unavailable."));
|
||||
request.onblocked = () => reject(new Error("Notification storage unavailable."));
|
||||
});
|
||||
}
|
||||
async function stored(id) {
|
||||
const db = await database();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const request = db.transaction("sources").objectStore("sources").get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(new Error("Notification storage unavailable."));
|
||||
});
|
||||
} finally { db.close(); }
|
||||
}
|
||||
async function save(value) {
|
||||
const db = await database();
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction("sources", "readwrite"), store = tx.objectStore("sources");
|
||||
store.put({ ...value, touched: Date.now() });
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const rows = request.result.sort((a, b) => b.touched - a.touched);
|
||||
for (const stale of rows.slice(16)) store.delete(stale.sourceId);
|
||||
};
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(new Error("Notification storage unavailable."));
|
||||
tx.onabort = () => reject(new Error("Notification storage unavailable."));
|
||||
});
|
||||
} finally { db.close(); }
|
||||
}
|
||||
async function announce(type) {
|
||||
for (const client of await self.clients.matchAll({ type: "window", includeUncontrolled: true })) {
|
||||
if (new URL(client.url).origin === self.location.origin) client.postMessage({ type });
|
||||
}
|
||||
}
|
||||
self.addEventListener("push", event => {
|
||||
event.waitUntil(serial(async () => {
|
||||
let value;
|
||||
try { const raw = event.data?.text(); if (!raw || raw.length > 4096) return; value = JSON.parse(raw); } catch { return; }
|
||||
if (!owned(value) || value.url !== PUSH_URL || value.tag !== `sybil-agent:${value.sourceId}`
|
||||
|| value.title !== PUSH_TITLE || value.body !== PUSH_BODY) return;
|
||||
const previous = await stored(value.sourceId).catch(() => null);
|
||||
// Re-delivery replaces the same owned tag without regressing its cursor.
|
||||
// Still show it visibly: local read state is not a reason for an invisible push.
|
||||
const identity = owned(previous?.latest) && BigInt(previous.latest.cursor) > BigInt(value.cursor) ? previous.latest : {
|
||||
version: 1, kind: "message", sourceId: value.sourceId, messageId: value.messageId, cursor: BigInt(value.cursor).toString(),
|
||||
};
|
||||
await self.registration.showNotification(PUSH_TITLE, {
|
||||
body: PUSH_BODY, tag: `sybil-agent:${identity.sourceId}`, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png",
|
||||
renotify: false, data: identity,
|
||||
});
|
||||
await save({ ...previous, sourceId: identity.sourceId, latest: identity }).catch(() => undefined);
|
||||
await announce("sybil-push-shown");
|
||||
}));
|
||||
});
|
||||
self.addEventListener("notificationclick", event => {
|
||||
event.notification.close();
|
||||
if (!owned(event.notification.data) || event.notification.tag !== `sybil-agent:${event.notification.data.sourceId}`) return;
|
||||
event.waitUntil((async () => {
|
||||
const destination = new URL(PUSH_URL, self.location.origin).href;
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
for (const client of windows) {
|
||||
const url = new URL(client.url);
|
||||
if (url.origin !== self.location.origin || !["/", "/search"].includes(url.pathname)) continue;
|
||||
try { const routed = await client.navigate(destination); if (routed) { await routed.focus(); return; } } catch { /* Try another client. */ }
|
||||
}
|
||||
await self.clients.openWindow(destination);
|
||||
// A tap routes only. The rendered transcript owns the read acknowledgment.
|
||||
})());
|
||||
});
|
||||
self.addEventListener("message", event => {
|
||||
const value = event.data;
|
||||
if (value?.version !== 1 || !["sybil-agent-read", "sybil-agent-logout"].includes(value.type)) return;
|
||||
event.waitUntil(serial(async () => {
|
||||
// A same-origin WindowClient is required; no remote/message-channel authority.
|
||||
const client = event.source?.id ? await self.clients.get(event.source.id) : null;
|
||||
if (!client || client.type !== "window" || new URL(client.url).origin !== self.location.origin) return;
|
||||
if (value.type === "sybil-agent-read") {
|
||||
if (!sourceId(value.sourceId) || !cursor(value.cursor)) return;
|
||||
const previous = await stored(value.sourceId).catch(() => null);
|
||||
const readCursor = cursor(previous?.readCursor) && BigInt(previous.readCursor) > BigInt(value.cursor) ? previous.readCursor : BigInt(value.cursor).toString();
|
||||
await save({ ...previous, sourceId: value.sourceId, readCursor }).catch(() => undefined);
|
||||
const notifications = await self.registration.getNotifications({ tag: `sybil-agent:${value.sourceId}` });
|
||||
for (const notification of notifications) {
|
||||
const data = notification.data;
|
||||
if (owned(data) && data.sourceId === value.sourceId && notification.tag === `sybil-agent:${value.sourceId}` && BigInt(data.cursor) <= BigInt(readCursor)) notification.close();
|
||||
}
|
||||
} else {
|
||||
for (const notification of await self.registration.getNotifications()) {
|
||||
if (owned(notification.data) && notification.tag === `sybil-agent:${notification.data.sourceId}`) notification.close();
|
||||
}
|
||||
}
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
}));
|
||||
});
|
||||
self.addEventListener("pushsubscriptionchange", event => {
|
||||
// Re-registration needs a currently authenticated page. Never cache its token here.
|
||||
event.waitUntil(announce("sybil-push-subscription-changed"));
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { ChatComposer } from "@/components/chat/chat-composer";
|
||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||
import { AgentConversation } from "@/components/agent/agent-conversation";
|
||||
import { AgentNotifications } from "@/components/agent/agent-notifications";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import { SearchMatchLabel } from "@/components/search/search-match-label";
|
||||
import { SybilCharacter } from "@/components/sybil-character";
|
||||
@@ -965,6 +966,7 @@ export default function App() {
|
||||
isCheckingSession,
|
||||
isSigningIn,
|
||||
isAuthenticated,
|
||||
authMode,
|
||||
authError,
|
||||
handleAuthFailure: baseHandleAuthFailure,
|
||||
handleSignIn,
|
||||
@@ -3721,6 +3723,7 @@ export default function App() {
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">Showing the 100 most recent matches. Refine your search to find more.</p>
|
||||
) : null}
|
||||
</div>
|
||||
{authMode === "token" ? <AgentNotifications onLogout={logout} /> : null}
|
||||
</aside>
|
||||
|
||||
<main
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { Bell } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { disableAgentBrowserPush, enableAgentBrowserPush, getAgentPushState, observeAgentPush, refreshAgentBrowserPush } from "@/lib/agent-push-browser";
|
||||
|
||||
export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void> }) {
|
||||
const [state, setState] = useState(getAgentPushState), [signingOut, setSigningOut] = useState(false);
|
||||
useEffect(() => observeAgentPush(setState), []);
|
||||
return <details className="shrink-0 border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
|
||||
<summary className="flex cursor-pointer items-center gap-2 rounded py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"><Bell className="h-4 w-4" />Agent notifications</summary>
|
||||
<div className="space-y-2 pb-1 pt-2">
|
||||
<p role="status">{state.message}</p>
|
||||
{state.error ? <p role="alert" className="text-rose-300">{state.error}</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.enabled ? <Button className="h-8 text-xs" variant="secondary" disabled={state.busy} onClick={() => void disableAgentBrowserPush()}>Turn off notifications</Button>
|
||||
: <Button className="h-8 text-xs" variant="secondary" disabled={!state.canEnable} onClick={() => void enableAgentBrowserPush()}>Enable notifications</Button>}
|
||||
<Button className="h-8 text-xs" variant="ghost" disabled={state.busy} onClick={() => void refreshAgentBrowserPush()}>Refresh</Button>
|
||||
<Button className="h-8 text-xs" variant="ghost" disabled={signingOut} onClick={() => { setSigningOut(true); void onLogout().finally(() => setSigningOut(false)); }}>Sign out</Button>
|
||||
</div>
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
|
||||
import {
|
||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentReadState,
|
||||
createAgentReadWriter, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||
type AgentActivity, type AgentRoomState,
|
||||
} from "@/lib/agent-room";
|
||||
import { AGENT_OUTBOX_STORAGE_KEY, createAgentOptimisticOutbox } from "@/lib/agent-optimistic-outbox";
|
||||
import { createAgentRoomPresenter } from "@/lib/agent-room-presentation";
|
||||
import { getAgentPresenceSessionId, syncAgentNotificationRead } from "@/lib/agent-push-browser";
|
||||
|
||||
import { AGENT_ATTACHMENT_DRAFT_KEY, createAgentUploadQueue, type AgentAttachment, type AgentAttachmentCapability } from "@/lib/agent-attachments";
|
||||
|
||||
@@ -47,7 +48,8 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
},
|
||||
});
|
||||
useEffect(() => () => uploadQueue.current?.dispose(), []);
|
||||
const mark = useRef<((cursor: string) => void) | null>(null);
|
||||
const mark = useRef<((cursor: string, sourceId: string | null | undefined) => void) | null>(null);
|
||||
const sourceMismatch = useRef(false);
|
||||
const [outboxRevision, setOutboxRevision] = useState(0);
|
||||
const update = useCallback((change: (state: AgentRoomState) => AgentRoomState) => {
|
||||
const next = change(current.current); current.current = next; setView(previous => ({ ...previous, room: next }));
|
||||
@@ -80,11 +82,13 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
sourceMismatch.current = false;
|
||||
client.current = null; mark.current = null; setAttachmentCapability(undefined);
|
||||
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let sourceChanged = false;
|
||||
let socket: WebSocket | undefined;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -100,7 +104,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
client.current = api;
|
||||
const presenter = createAgentRoomPresenter({ initial: current.current, reduceEvent: applyAgentEvent,
|
||||
publish: (next, isTyping) => {
|
||||
if (!active) return;
|
||||
if (!active || sourceChanged) return;
|
||||
outbox.current?.reconcile(next);
|
||||
current.current = next;
|
||||
setView({ room: next, isTyping });
|
||||
@@ -108,35 +112,36 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
});
|
||||
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
|
||||
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
|
||||
// Read acknowledgments may be ahead of the batched UI for ~100ms. The
|
||||
// writer must observe that acknowledged cursor immediately to avoid a
|
||||
// repeated PUT loop while the presenter waits to publish its batch.
|
||||
let acknowledgedReadCursor = current.current.readCursor;
|
||||
const readWriter = createAgentReadWriter({
|
||||
current: () => acknowledgedReadCursor,
|
||||
save: cursor => api.markRead(cursor, controller.signal),
|
||||
const readSession = createAgentReadSession({
|
||||
initial: current.current,
|
||||
save: (cursor, sourceId) => api.markRead(cursor, controller.signal, sourceId),
|
||||
invalidated: failure => {
|
||||
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
|
||||
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
||||
presenter.dispose(); controller.abort(); socket?.close();
|
||||
setError(failure.message); setConnection("offline"); setLoading(false);
|
||||
},
|
||||
apply: read => {
|
||||
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
|
||||
if (active) presenter.update(state => applyAgentReadState(state, read));
|
||||
void syncAgentNotificationRead(read);
|
||||
if (active && !sourceChanged) presenter.update(state => applyAgentReadState(state, read));
|
||||
},
|
||||
});
|
||||
mark.current = cursor => { void readWriter.request(cursor).catch(reportError); };
|
||||
mark.current = (cursor, sourceId) => { void readSession.request(cursor, sourceId).catch(reportError); };
|
||||
|
||||
const refreshRead = async () => {
|
||||
const read = await api.readState(controller.signal);
|
||||
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
|
||||
if (active) presenter.update(state => applyAgentReadState(state, read));
|
||||
if (active && !sourceChanged) readSession.receive(read);
|
||||
};
|
||||
const catchUp = async (replay = false) => syncAgentHistory(api, current.current.cursor, page => {
|
||||
if (active) presenter.push({ type: "messages", ...page }, { replay });
|
||||
if (active && !sourceChanged) presenter.push({ type: "messages", ...page }, { replay });
|
||||
}, controller.signal);
|
||||
const refreshActivity = async () => {
|
||||
const descriptor = await api.descriptor(controller.signal);
|
||||
if (active) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
|
||||
if (active && !sourceChanged) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (!active || authFailed) return;
|
||||
if (!active || authFailed || sourceChanged) return;
|
||||
clearTimeout(reconnectTimer); clearTimeout(socketTimeout);
|
||||
setConnection(reconnectAttempt ? "reconnecting" : "connecting");
|
||||
socketAuthenticated = false;
|
||||
@@ -144,12 +149,12 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
socket = next;
|
||||
socketTimeout = setTimeout(() => { if (!socketAuthenticated) next.close(); }, 12_000);
|
||||
next.onopen = () => {
|
||||
if (!active || socket !== next) { next.close(); return; }
|
||||
if (!active || sourceChanged || socket !== next) { next.close(); return; }
|
||||
lastPacket = Date.now();
|
||||
next.send(agentAuthFrame(getAuthToken(), current.current.cursor));
|
||||
next.send(agentAuthFrame(getAuthToken(), current.current.cursor, getAgentPresenceSessionId()));
|
||||
};
|
||||
next.onmessage = ({ data }) => {
|
||||
if (!active || socket !== next) return;
|
||||
if (!active || sourceChanged || socket !== next) return;
|
||||
lastPacket = Date.now();
|
||||
try {
|
||||
const event = parseAgentRoomEvent(String(data));
|
||||
@@ -159,6 +164,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
clearTimeout(socketTimeout); socketAuthenticated = true; reconnectAttempt = 0;
|
||||
setConnection("connected"); setError(null); return;
|
||||
}
|
||||
if (event.type === "read_state") {
|
||||
readSession.receive(event); return;
|
||||
}
|
||||
presenter.push(event);
|
||||
} catch (failure) {
|
||||
reportError(failure);
|
||||
@@ -167,9 +175,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
next.close();
|
||||
}
|
||||
};
|
||||
next.onerror = () => { if (active) setConnection("reconnecting"); };
|
||||
next.onerror = () => { if (active && !sourceChanged) setConnection("reconnecting"); };
|
||||
next.onclose = event => {
|
||||
if (!active || socket !== next) return;
|
||||
if (!active || sourceChanged || socket !== next) return;
|
||||
clearTimeout(socketTimeout); socketAuthenticated = false;
|
||||
if (event.code === 4401) {
|
||||
authFailed = true; setConnection("offline"); setError("Agent authentication failed. Sign in again or retry."); return;
|
||||
@@ -186,21 +194,21 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
// The first start begins at zero, not the latest page. Unread counts
|
||||
// therefore include older public replies even after a long absence.
|
||||
await catchUp(!started);
|
||||
if (!active) return;
|
||||
if (!active || sourceChanged) return;
|
||||
presenter.flush();
|
||||
started = true; setLoading(false); connect();
|
||||
} catch (failure) {
|
||||
if (!active) return;
|
||||
if (!active || sourceChanged) return;
|
||||
reportError(failure); setLoading(false); setConnection("offline");
|
||||
startupTimer = setTimeout(() => { void start(); }, 10_000);
|
||||
}
|
||||
};
|
||||
const maintain = async (foreground = false) => {
|
||||
if (!active || !started || maintaining || authFailed) return;
|
||||
if (!active || sourceChanged || !started || maintaining || authFailed) return;
|
||||
maintaining = true;
|
||||
try {
|
||||
await refreshRead();
|
||||
await readWriter.flush();
|
||||
await readSession.flush();
|
||||
if (!socketAuthenticated) {
|
||||
await Promise.all([catchUp(), refreshActivity()]);
|
||||
} else if (foreground) await catchUp();
|
||||
@@ -217,7 +225,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
document.addEventListener("visibilitychange", focus);
|
||||
void start();
|
||||
return () => {
|
||||
active = false; controller.abort();
|
||||
active = false; readSession.cancel(); controller.abort();
|
||||
presenter.dispose();
|
||||
clearInterval(interval); clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
||||
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
|
||||
@@ -228,12 +236,13 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
}, [enabled, revision, update]);
|
||||
|
||||
const renderedCursor = room.cursor;
|
||||
const renderedSourceId = room.sourceId;
|
||||
const markRead = useCallback(() => {
|
||||
if (!enabled || isLoading) return;
|
||||
// This callback belongs to this rendered snapshot. Reading current.current
|
||||
// here would incorrectly mark a socket message that has not rendered yet.
|
||||
mark.current?.(renderedCursor);
|
||||
}, [enabled, isLoading, renderedCursor]);
|
||||
mark.current?.(renderedCursor, renderedSourceId);
|
||||
}, [enabled, isLoading, renderedCursor, renderedSourceId]);
|
||||
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
|
||||
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
||||
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
|
||||
@@ -250,7 +259,10 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
||||
await outbox.current.retry(messageId);
|
||||
}, [enabled]);
|
||||
const retry = useCallback(() => { setRevision(value => value + 1); }, []);
|
||||
const retry = useCallback(() => {
|
||||
if (sourceMismatch.current) { window.location.reload(); return; }
|
||||
setRevision(value => value + 1);
|
||||
}, []);
|
||||
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
|
||||
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
|
||||
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { getConfiguredToken, setAuthToken, verifySession } from "@/lib/api";
|
||||
import { revokeAgentBrowserPush, startAgentBrowserSession } from "@/lib/agent-push-browser";
|
||||
|
||||
const TOKEN_STORAGE_KEY = "sybil_admin_token";
|
||||
|
||||
@@ -47,6 +48,7 @@ export function useSessionAuth() {
|
||||
};
|
||||
|
||||
const handleAuthFailure = (message: string | null) => {
|
||||
void revokeAgentBrowserPush();
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
setAuthError(message ? normalizeAuthError(message) : null);
|
||||
@@ -69,7 +71,8 @@ export function useSessionAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
await revokeAgentBrowserPush();
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
setIsAuthenticated(false);
|
||||
@@ -77,6 +80,18 @@ export function useSessionAuth() {
|
||||
setAuthError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || authMode !== "token") return;
|
||||
return startAgentBrowserSession();
|
||||
}, [isAuthenticated, authMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// Signing out in one tab retires the others instead of using a stale token.
|
||||
const signedOut = (event: StorageEvent) => { if (event.key === TOKEN_STORAGE_KEY && !event.newValue) handleAuthFailure(null); };
|
||||
window.addEventListener("storage", signedOut);
|
||||
return () => window.removeEventListener("storage", signedOut);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = readStoredToken() ?? getConfiguredToken();
|
||||
void (async () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { getApiBaseUrl, getAuthToken } from "./api";
|
||||
import { ensureServiceWorker } from "../pwa";
|
||||
import {
|
||||
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
|
||||
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
|
||||
} from "./agent-push.ts";
|
||||
|
||||
let sessionId: string | undefined;
|
||||
function browserUuid() {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return crypto.randomUUID();
|
||||
if (typeof globalThis.crypto?.getRandomValues !== "function") throw new Error("Secure browser identifiers are unavailable.");
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = bytes[6] & 15 | 64; bytes[8] = bytes[8] & 63 | 128;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
/** A document UUID avoids duplicated sessionStorage IDs when the user duplicates a tab. */
|
||||
export function getAgentPresenceSessionId() { try { return sessionId ||= browserUuid(); } catch { return undefined; } }
|
||||
const api = new AgentPushClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
|
||||
async function consentScope() {
|
||||
const token = getAuthToken(), base = new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href;
|
||||
const digest = await agentPushAuthorityScope(base, token);
|
||||
if (token !== getAuthToken() || base !== new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href) return null;
|
||||
return digest;
|
||||
}
|
||||
const listeners = new Set<(state: AgentPushView) => void>();
|
||||
function bounded<T>(promise: Promise<T>, timeoutMs = 10000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Browser notification operation timed out.")), timeoutMs);
|
||||
promise.then(value => { clearTimeout(timeout); resolve(value); }, () => { clearTimeout(timeout); reject(new Error("Browser notification operation failed.")); });
|
||||
});
|
||||
}
|
||||
const subscriptionHandle = (subscription: PushSubscription): PushSubscriptionHandle => ({
|
||||
toJSON: () => subscription.toJSON(), unsubscribe: () => bounded(subscription.unsubscribe()),
|
||||
});
|
||||
const readConsent = () => { try { return parseAgentPushConsent(localStorage.getItem(AGENT_PUSH_STORAGE_KEY)); } catch { return null; } };
|
||||
const support = () => {
|
||||
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
const standalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches
|
||||
|| (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
if (ios && !standalone) return "install" as const;
|
||||
return window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window && !!navigator.locks
|
||||
? "supported" as const : "unsupported" as const;
|
||||
};
|
||||
async function postWorker(message: unknown) {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
// No registration from background sync. A page worker can safely receive only sanitized IDs.
|
||||
const registration = await navigator.serviceWorker.getRegistration("/");
|
||||
const worker = registration?.active;
|
||||
if (!worker) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const channel = new MessageChannel(), timeout = setTimeout(() => { channel.port1.close(); resolve(); }, 1500);
|
||||
channel.port1.onmessage = () => { clearTimeout(timeout); channel.port1.close(); resolve(); };
|
||||
worker.postMessage(message, [channel.port2]);
|
||||
});
|
||||
}
|
||||
export async function syncAgentNotificationRead(read: { sourceId?: string | null; cursor: string }) {
|
||||
if (!getAuthToken() || !agentPushSourceId(read.sourceId) || !agentPushCursor(read.cursor)) return;
|
||||
await postWorker({ type: "sybil-agent-read", version: 1, sourceId: read.sourceId, cursor: BigInt(read.cursor).toString() }).catch(() => undefined);
|
||||
}
|
||||
function decodePublicKey(value: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4));
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
}
|
||||
const controller = createAgentPushController({
|
||||
api, read: readConsent, scope: consentScope,
|
||||
write: value => { localStorage.setItem(AGENT_PUSH_STORAGE_KEY, JSON.stringify(value)); },
|
||||
remove: () => localStorage.removeItem(AGENT_PUSH_STORAGE_KEY),
|
||||
uuid: browserUuid,
|
||||
lock: async operation => {
|
||||
if (!navigator.locks) return await operation(); // Cleanup only; support() disables opt-in.
|
||||
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try { return await navigator.locks.request("sybil-agent-push-installation-v1", { signal: controller.signal }, operation); }
|
||||
finally { clearTimeout(timeout); }
|
||||
},
|
||||
changed: state => { for (const listener of listeners) listener(state); },
|
||||
platform: {
|
||||
support, permission: () => "Notification" in window ? Notification.permission : "denied",
|
||||
requestPermission: () => Notification.requestPermission(),
|
||||
subscription: async () => {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
const registration = await bounded(navigator.serviceWorker.getRegistration("/"));
|
||||
const subscription = registration ? await bounded(registration.pushManager.getSubscription()) : null;
|
||||
return subscription ? subscriptionHandle(subscription) : null;
|
||||
},
|
||||
subscribe: async key => subscriptionHandle(await bounded((await ensureServiceWorker()).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }))),
|
||||
clearNotifications: () => postWorker({ type: "sybil-agent-logout", version: 1 }),
|
||||
},
|
||||
});
|
||||
export const getAgentPushState = () => controller.state();
|
||||
export function observeAgentPush(listener: (state: AgentPushView) => void) { listeners.add(listener); listener(controller.state()); return () => { listeners.delete(listener); }; }
|
||||
export const enableAgentBrowserPush = () => controller.enable();
|
||||
export const disableAgentBrowserPush = () => controller.disable();
|
||||
export const refreshAgentBrowserPush = () => controller.refresh();
|
||||
|
||||
const presence = createAgentPresenceReporter({
|
||||
snapshot: () => ({ visibility: document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
|
||||
send: (value, keepalive) => { const id = getAgentPresenceSessionId(); return id ? api.presence(id, value, keepalive) : Promise.reject(new Error("Attention session unavailable.")); },
|
||||
});
|
||||
let stopSession: (() => void) | undefined;
|
||||
export async function revokeAgentBrowserPush() {
|
||||
stopSession?.();
|
||||
// Revoke/mark the durable tombstone before the caller clears the bearer token.
|
||||
const id = getAgentPresenceSessionId();
|
||||
await Promise.allSettled([readConsent() ? controller.disable() : postWorker({ type: "sybil-agent-logout", version: 1 }), ...(id ? [api.retirePresence(id)] : [])]);
|
||||
}
|
||||
/** Mounted from shared authentication so /search, every chat and every tab participate. */
|
||||
export function startAgentBrowserSession() {
|
||||
stopSession?.();
|
||||
let active = true, refreshing = false, interactionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const report = (keepalive = false, hidden = false) => { if (active) void presence.report(keepalive, hidden).catch(() => undefined); };
|
||||
const refresh = async () => {
|
||||
if (!active || refreshing) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
await controller.refresh();
|
||||
if (active && document.visibilityState === "visible") {
|
||||
const read = await api.readState();
|
||||
if (active && read) await syncAgentNotificationRead(read);
|
||||
}
|
||||
} catch { /* Absence of an acknowledged source/read never closes notifications. */ }
|
||||
finally { refreshing = false; }
|
||||
};
|
||||
const attention = () => { report(); if (document.visibilityState === "visible") void refresh(); };
|
||||
const interaction = (event: Event) => {
|
||||
if (!event.isTrusted) return;
|
||||
presence.interact();
|
||||
if (!interactionTimer) {
|
||||
report();
|
||||
interactionTimer = setTimeout(() => { interactionTimer = undefined; report(); }, 1000);
|
||||
}
|
||||
};
|
||||
const hide = () => report(true, true);
|
||||
const storage = (event: StorageEvent) => { if (event.key === AGENT_PUSH_STORAGE_KEY) void refresh(); };
|
||||
const workerMessage = (event: MessageEvent) => { if (["sybil-push-subscription-changed", "sybil-push-shown"].includes(event.data?.type)) void refresh(); };
|
||||
const heartbeat = setInterval(() => { report(); void refresh(); }, 15000);
|
||||
window.addEventListener("focus", attention); window.addEventListener("blur", attention);
|
||||
window.addEventListener("pageshow", attention); window.addEventListener("online", attention);
|
||||
window.addEventListener("pagehide", hide); window.addEventListener("storage", storage);
|
||||
document.addEventListener("visibilitychange", attention);
|
||||
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.addEventListener(type, interaction, { passive: true, capture: true });
|
||||
navigator.serviceWorker?.addEventListener("message", workerMessage);
|
||||
report(); void refresh();
|
||||
const stop = () => {
|
||||
if (!active) return;
|
||||
active = false; clearInterval(heartbeat); clearTimeout(interactionTimer);
|
||||
window.removeEventListener("focus", attention); window.removeEventListener("blur", attention);
|
||||
window.removeEventListener("pageshow", attention); window.removeEventListener("online", attention);
|
||||
window.removeEventListener("pagehide", hide); window.removeEventListener("storage", storage);
|
||||
document.removeEventListener("visibilitychange", attention);
|
||||
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.removeEventListener(type, interaction, true);
|
||||
navigator.serviceWorker?.removeEventListener("message", workerMessage);
|
||||
if (stopSession === stop) stopSession = undefined;
|
||||
// On unmount preserve the final lease until expiry; a late PUT cannot undo a DELETE.
|
||||
};
|
||||
stopSession = stop;
|
||||
return stop;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/** Browser-only push contracts. Secrets stay in page request headers, never worker messages. */
|
||||
export const AGENT_PUSH_STORAGE_KEY = "sybil.agent.push.v1";
|
||||
export const AGENT_PUSH_CHANGED_EVENT = "sybil-agent-push-changed";
|
||||
export const agentPushCursor = (value: unknown): value is string => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value));
|
||||
export const agentPushUuid = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
export const agentPushSourceId = agentPushUuid;
|
||||
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
|
||||
export type AgentPushConfig = {
|
||||
version: 1; enabled: boolean; webPushAvailable: boolean; apnsAvailable: boolean;
|
||||
sourceId: string | null; quarantined: boolean; vapidPublicKey?: string;
|
||||
policy: { presenceHeartbeatMs: number; presenceLeaseMs: number };
|
||||
};
|
||||
export function parseAgentPushConfig(value: unknown): AgentPushConfig {
|
||||
const config = object(value), policy = object(config?.policy);
|
||||
if (!config || config.version !== 1 || typeof config.enabled !== "boolean" || typeof config.webPushAvailable !== "boolean"
|
||||
|| typeof config.apnsAvailable !== "boolean" || (config.sourceId !== null && !agentPushSourceId(config.sourceId))
|
||||
|| (config.quarantined !== undefined && typeof config.quarantined !== "boolean") || !policy
|
||||
|| typeof policy.presenceHeartbeatMs !== "number" || policy.presenceHeartbeatMs < 5000 || policy.presenceHeartbeatMs > 30000
|
||||
|| typeof policy.presenceLeaseMs !== "number" || policy.presenceLeaseMs < 30000 || policy.presenceLeaseMs > 120000
|
||||
|| (config.vapidPublicKey !== undefined && (typeof config.vapidPublicKey !== "string" || !/^[A-Za-z0-9_-]{87}$/.test(config.vapidPublicKey)))) {
|
||||
throw new Error("Notification configuration is unavailable.");
|
||||
}
|
||||
return { ...config, quarantined: config.quarantined === true } as AgentPushConfig;
|
||||
}
|
||||
export type AgentPresence = { sequence: number; visibility: "visible" | "hidden"; focused: boolean; interacted: boolean };
|
||||
/** Nonsecret identity only; the bearer is never retained with the digest. */
|
||||
export async function agentPushAuthorityScope(baseUrl: string, token: string | null, origin = globalThis.location?.href): Promise<string | null> {
|
||||
if (!token || !globalThis.crypto?.subtle) return null;
|
||||
const base = new URL(baseUrl.replace(/\/+$/, ""), origin).href;
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([base, token])));
|
||||
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
export class AgentPushClient {
|
||||
private options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch };
|
||||
constructor(options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch }) { this.options = options; }
|
||||
private async request(path: string, method = "GET", body?: unknown, keepalive = false, expectedScope?: string): Promise<unknown> {
|
||||
const token = this.options.token();
|
||||
if (!token) throw new Error("Sign in to manage notifications.");
|
||||
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
// Capture the bearer before hashing, then use that exact bearer for the
|
||||
// request. A concurrent sign-in must not turn old-authority cleanup into
|
||||
// a new-authority DELETE whose204 falsely clears a durable tombstone.
|
||||
if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new Error("Notification authority changed.");
|
||||
const response = await (this.options.fetch || globalThis.fetch)(`${this.options.baseUrl.replace(/\/+$/, "")}/v1/agent${path}`, {
|
||||
method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), cache: "no-store", redirect: "error", credentials: "omit", signal: controller.signal, keepalive,
|
||||
});
|
||||
// Never echo server/provider bodies: they may contain endpoint or key material.
|
||||
if (!response.ok) throw new Error(response.status === 401 ? "Sign in to manage notifications." : "Notification settings could not be saved. Try again.");
|
||||
return response.status === 204 ? undefined : await response.json();
|
||||
} catch { throw new Error("Notification service is unavailable. Try again when connected."); }
|
||||
finally { clearTimeout(timeout); }
|
||||
}
|
||||
async config() { return parseAgentPushConfig(await this.request("/push/config")); }
|
||||
async register(id: string, subscription: PushSubscriptionJSON, expectedScope?: string) {
|
||||
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
|
||||
const value = object(await this.request(`/push/installations/${id}`, "PUT", { transport: "web", enabled: true, subscription }, false, expectedScope));
|
||||
if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new Error("Notification registration was not acknowledged.");
|
||||
}
|
||||
async revoke(id: string, expectedScope?: string) {
|
||||
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
|
||||
await this.request(`/push/installations/${id}`, "DELETE", undefined, false, expectedScope);
|
||||
}
|
||||
async presence(id: string, presence: AgentPresence, keepalive = false) {
|
||||
if (!agentPushUuid(id)) throw new Error("Invalid attention session.");
|
||||
await this.request(`/presence/${id}`, "PUT", presence, keepalive);
|
||||
}
|
||||
async retirePresence(id: string) { if (agentPushUuid(id)) await this.request(`/presence/${id}`, "DELETE", undefined, true); }
|
||||
async readState() {
|
||||
const value = object(await this.request("/read-state"));
|
||||
if (agentPushSourceId(value?.sourceId) && agentPushCursor(value?.cursor)) return { sourceId: value.sourceId, cursor: BigInt(value.cursor).toString() };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const MAX_AGENT_PUSH_REVOCATIONS = 16;
|
||||
export type AgentPushRevocation = { installationId: string; scope: string };
|
||||
export type AgentPushConsent = { version: 1; installationId: string; revision: string; scope?: string; sourceId?: string; enabled: boolean; revocationPending: boolean; pendingRevocations?: AgentPushRevocation[] };
|
||||
export function parseAgentPushConsent(raw: string | null): AgentPushConsent | null {
|
||||
try {
|
||||
const value = object(JSON.parse(raw || "null"));
|
||||
if (!(value?.version === 1 && agentPushUuid(value.installationId) && agentPushUuid(value.revision)
|
||||
&& (value.scope === undefined || (typeof value.scope === "string" && /^[a-f0-9]{64}$/.test(value.scope)))
|
||||
&& (value.pendingRevocations === undefined || (Array.isArray(value.pendingRevocations) && value.pendingRevocations.length <= MAX_AGENT_PUSH_REVOCATIONS
|
||||
&& value.pendingRevocations.every(item => agentPushUuid(object(item)?.installationId) && typeof object(item)?.scope === "string" && /^[a-f0-9]{64}$/.test(String(object(item)?.scope)))))
|
||||
&& typeof value.enabled === "boolean" && typeof value.revocationPending === "boolean")) return null;
|
||||
// Keep the installation identity for cleanup when migrating old/unbound
|
||||
// consent. An absent or malformed source can never authorize registration.
|
||||
return { ...value, sourceId: agentPushSourceId(value.sourceId) ? value.sourceId : undefined } as AgentPushConsent;
|
||||
} catch { return null; }
|
||||
}
|
||||
export type AgentPushView = { busy: boolean; enabled: boolean; canEnable: boolean; message: string; error: string | null };
|
||||
export type PushSubscriptionHandle = Pick<PushSubscription, "toJSON" | "unsubscribe">;
|
||||
type PushPlatform = {
|
||||
support: () => "supported" | "unsupported" | "install";
|
||||
permission: () => NotificationPermission;
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
subscription: () => Promise<PushSubscriptionHandle | null>;
|
||||
subscribe: (publicKey: string) => Promise<PushSubscriptionHandle>;
|
||||
clearNotifications: () => Promise<void>;
|
||||
};
|
||||
/** Explicit consent survives restarts; a revoke tombstone always wins over an older enable. */
|
||||
export function createAgentPushController(options: {
|
||||
api: Pick<AgentPushClient, "config" | "register" | "revoke">;
|
||||
platform: PushPlatform; read: () => AgentPushConsent | null; write: (consent: AgentPushConsent) => void; remove?: () => void;
|
||||
scope: () => Promise<string | null>;
|
||||
uuid: () => string; lock: <T>(operation: () => Promise<T>) => Promise<T>; changed: (view: AgentPushView) => void;
|
||||
}) {
|
||||
let config: AgentPushConfig | null = null, busy = false, error: string | null = null, localRevocation: AgentPushConsent | null = null;
|
||||
let scope: string | null = null;
|
||||
let registeredSubscription: string | null = null;
|
||||
const currentConsent = () => localRevocation || options.read();
|
||||
const matchesContext = (record: AgentPushConsent) => !!scope && record.scope === scope
|
||||
&& agentPushSourceId(record.sourceId) && record.sourceId === config?.sourceId;
|
||||
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !config.quarantined && agentPushSourceId(config.sourceId);
|
||||
const refreshContext = async () => {
|
||||
const nextScope = await options.scope(), nextConfig = await options.api.config();
|
||||
if (nextScope !== await options.scope()) { scope = null; config = null; throw new Error("Notification authority changed."); }
|
||||
scope = nextScope; config = nextConfig;
|
||||
};
|
||||
const recheckContext = async (record: AgentPushConsent) => {
|
||||
await refreshContext();
|
||||
return matchesContext(record) && transportAvailable();
|
||||
};
|
||||
const state = (): AgentPushView => {
|
||||
const consent = currentConsent(), support = options.platform.support(), permission = options.platform.permission();
|
||||
const available = !!scope && transportAvailable();
|
||||
const cleanupFull = (consent?.pendingRevocations?.length || 0) >= MAX_AGENT_PUSH_REVOCATIONS;
|
||||
const enabled = consent?.enabled === true && !consent.revocationPending && permission === "granted";
|
||||
const message = support === "install" ? "On iPhone or iPad, add Sybil to your Home Screen, then enable notifications there."
|
||||
: support === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS."
|
||||
: permission === "denied" ? "Notifications are blocked. Allow them in browser settings, then try again."
|
||||
: cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more."
|
||||
: consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected."
|
||||
: !available ? "Notifications are not configured on this server yet."
|
||||
: enabled ? "Notifications are on for this browser. Alerts use a private, generic preview."
|
||||
: "Get an alert for unread Agent replies when all your web sessions are inactive.";
|
||||
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error };
|
||||
};
|
||||
const publish = () => options.changed(state());
|
||||
const write = (consent: AgentPushConsent) => { options.write(consent); publish(); };
|
||||
const consent = (): AgentPushConsent => currentConsent() || { version: 1, installationId: options.uuid(), revision: options.uuid(), ...(scope ? { scope } : {}), enabled: false, revocationPending: false };
|
||||
const retireAtOriginalAuthority = async (record: Pick<AgentPushConsent, "installationId" | "scope">) => {
|
||||
if (!record.scope || record.scope !== await options.scope()) return false;
|
||||
await options.api.revoke(record.installationId, record.scope);
|
||||
return true;
|
||||
};
|
||||
const retryRevocations = async () => {
|
||||
const original = currentConsent();
|
||||
if (!original) return;
|
||||
for (const pending of original.pendingRevocations || []) {
|
||||
if (currentConsent()?.revision !== original.revision) return;
|
||||
const removed = await retireAtOriginalAuthority(pending).catch(() => false);
|
||||
const latest = currentConsent();
|
||||
if (removed && latest?.revision === original.revision) {
|
||||
write({ ...latest, pendingRevocations: (latest.pendingRevocations || []).filter(item => item.installationId !== pending.installationId || item.scope !== pending.scope) });
|
||||
}
|
||||
}
|
||||
};
|
||||
const revoke = async (record: AgentPushConsent) => {
|
||||
let serverRemoved = false, subscriptionRemoved = false;
|
||||
await Promise.all([
|
||||
retireAtOriginalAuthority(record).then(removed => { serverRemoved = removed; }).catch(() => undefined),
|
||||
options.platform.subscription().then(async subscription => { subscriptionRemoved = !subscription || await subscription.unsubscribe(); }).catch(() => undefined),
|
||||
options.platform.clearNotifications().catch(() => undefined),
|
||||
]);
|
||||
const authority = await options.scope().catch(() => null);
|
||||
const latest = currentConsent();
|
||||
if (latest?.revision === record.revision && subscriptionRemoved) {
|
||||
const queue = latest.pendingRevocations || [];
|
||||
const archived = !!record.scope && queue.some(item => item.installationId === record.installationId && item.scope === record.scope);
|
||||
const differentAuthority = record.scope && record.scope !== authority;
|
||||
if (serverRemoved || archived) {
|
||||
const removed = { ...latest, revocationPending: false };
|
||||
try { write(removed); localRevocation = null; } catch { localRevocation = removed; }
|
||||
} else if (differentAuthority && queue.length < MAX_AGENT_PUSH_REVOCATIONS) {
|
||||
// Keep only the old nonsecret authority digest and ID. A current-server
|
||||
// DELETE/204 is never evidence that a different server retired this ID.
|
||||
const archivedRecord = { ...latest, revocationPending: false,
|
||||
pendingRevocations: [...queue, { installationId: record.installationId, scope: record.scope! }] };
|
||||
try { write(archivedRecord); localRevocation = null; } catch { localRevocation = latest; }
|
||||
}
|
||||
}
|
||||
};
|
||||
const invalidate = async (record: AgentPushConsent) => {
|
||||
if (currentConsent()?.revision !== record.revision) {
|
||||
await retireAtOriginalAuthority(record).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const pending = { ...record, enabled: false, revocationPending: true, revision: options.uuid() };
|
||||
registeredSubscription = null;
|
||||
try { write(pending); } catch { localRevocation = pending; }
|
||||
await revoke(pending);
|
||||
};
|
||||
const disable = async () => {
|
||||
error = null;
|
||||
const record = { ...consent(), enabled: false, revocationPending: true, revision: options.uuid() };
|
||||
// Synchronous durable intent is necessary before logout clears page credentials.
|
||||
registeredSubscription = null;
|
||||
try { write(record); }
|
||||
catch {
|
||||
localRevocation = record;
|
||||
// Some browsers reject setItem when full but still permit removal.
|
||||
try { options.remove?.(); } catch { /* Unsubscribe/revoke below still run. */ }
|
||||
error = "Browser storage is unavailable. Check that notifications are off in browser settings.";
|
||||
}
|
||||
busy = true; publish();
|
||||
try { await options.lock(() => revoke(record)); }
|
||||
catch {
|
||||
// A stuck tab must not prevent logout cleanup. The server permanently
|
||||
// retires this UUID, so cleanup remains safe without acquiring that lock.
|
||||
await revoke(record).catch(() => undefined);
|
||||
error = "Notifications are off here. Removal will retry when connected.";
|
||||
}
|
||||
finally { busy = false; publish(); }
|
||||
};
|
||||
const refresh = async () => {
|
||||
if (busy) return;
|
||||
try {
|
||||
await refreshContext();
|
||||
await options.lock(async () => {
|
||||
await retryRevocations();
|
||||
let record = currentConsent();
|
||||
if (!record) return;
|
||||
if (record.revocationPending) { await revoke(record); return; }
|
||||
if (!record.enabled) return;
|
||||
if (!matchesContext(record)) { await invalidate(record); return; }
|
||||
const subscription = await options.platform.subscription();
|
||||
if (currentConsent()?.revision !== record.revision) return;
|
||||
if (options.platform.permission() !== "granted" || !subscription) {
|
||||
record = { ...record, enabled: false, revocationPending: true, revision: options.uuid() }; write(record); await revoke(record); return;
|
||||
}
|
||||
if (transportAvailable()) {
|
||||
const serialized = JSON.stringify(subscription.toJSON());
|
||||
if (registeredSubscription !== serialized) {
|
||||
const matching = await recheckContext(record);
|
||||
if (currentConsent()?.revision !== record.revision) return;
|
||||
if (!matching) { await invalidate(record); return; }
|
||||
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
|
||||
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
|
||||
// A source replacement may complete while the PUT is in flight.
|
||||
// Never retain that registration without a fresh source check.
|
||||
try {
|
||||
const stillMatching = await recheckContext(record);
|
||||
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
|
||||
if (!stillMatching) { await invalidate(record); return; }
|
||||
} catch { await invalidate(record); throw new Error("Notification service is unavailable."); }
|
||||
registeredSubscription = serialized;
|
||||
}
|
||||
}
|
||||
});
|
||||
error = null;
|
||||
} catch { error = "Notification service is unavailable. Try again when connected."; }
|
||||
publish();
|
||||
};
|
||||
const enable = async () => {
|
||||
if (!state().canEnable || state().enabled || busy || !config?.vapidPublicKey || !scope || !agentPushSourceId(config.sourceId)) return;
|
||||
error = null;
|
||||
// Explicit revocation permanently retires the old UUID on the server. Each
|
||||
// new opt-in has a new identity, so a delayed old PUT cannot resurrect it.
|
||||
const record: AgentPushConsent = { version: 1, installationId: options.uuid(), revision: options.uuid(), scope, sourceId: config.sourceId,
|
||||
enabled: false, revocationPending: false, pendingRevocations: currentConsent()?.pendingRevocations || [] };
|
||||
const publicKey = config.vapidPublicKey;
|
||||
try { options.write(record); localRevocation = null; } catch { error = "Allow browser storage before enabling notifications."; publish(); return; }
|
||||
// Must execute in the original click stack, before locks, worker startup or fetches.
|
||||
let permission: Promise<NotificationPermission>;
|
||||
try { permission = options.platform.requestPermission(); }
|
||||
catch { error = "Notifications could not be requested. Check browser settings and try again."; publish(); return; }
|
||||
busy = true; publish();
|
||||
try {
|
||||
if (await permission !== "granted") { error = "Notifications were not allowed. You can change this in browser settings."; return; }
|
||||
await options.lock(async () => {
|
||||
const unchanged = () => options.read()?.revision === record.revision;
|
||||
if (!unchanged()) return;
|
||||
const matching = await recheckContext(record);
|
||||
if (!unchanged()) return;
|
||||
if (!matching || config?.vapidPublicKey !== publicKey) { await invalidate(record); return; }
|
||||
const subscription = await options.platform.subscription() || await options.platform.subscribe(publicKey);
|
||||
if (!unchanged()) { await subscription.unsubscribe(); return; }
|
||||
// A tombstone also covers a lost registration response or failed local storage write.
|
||||
const pending = { ...record, enabled: false, revocationPending: true }; write(pending);
|
||||
try {
|
||||
const matchingBeforeRegister = await recheckContext(record);
|
||||
if (!unchanged() || !matchingBeforeRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
|
||||
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
|
||||
if (!unchanged()) { await retireAtOriginalAuthority(record); await subscription.unsubscribe(); return; }
|
||||
const matchingAfterRegister = await recheckContext(record);
|
||||
if (!unchanged() || !matchingAfterRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
|
||||
write({ ...record, enabled: true, revocationPending: false });
|
||||
registeredSubscription = JSON.stringify(subscription.toJSON());
|
||||
} catch {
|
||||
await revoke(pending);
|
||||
throw new Error("Notifications could not be enabled. Try again when connected.");
|
||||
}
|
||||
});
|
||||
} catch { error = "Notifications could not be enabled. Try again when connected."; }
|
||||
finally { busy = false; publish(); }
|
||||
};
|
||||
return { state, refresh, enable, disable };
|
||||
}
|
||||
|
||||
/** Independent ordered lease per document, including focused-but-idle and recent input. */
|
||||
export function createAgentPresenceReporter(options: {
|
||||
snapshot: () => { visibility: "visible" | "hidden"; focused: boolean };
|
||||
send: (presence: AgentPresence, keepalive: boolean) => Promise<void>;
|
||||
}) {
|
||||
let sequence = 0, interaction = 0, acknowledgedInteraction = 0;
|
||||
return {
|
||||
interact() { interaction++; },
|
||||
async report(keepalive = false, hidden = false) {
|
||||
const snapshot = hidden ? { visibility: "hidden" as const, focused: false } : options.snapshot();
|
||||
const capturedInteraction = interaction;
|
||||
const presence = { ...snapshot, focused: snapshot.visibility === "visible" && snapshot.focused, sequence: ++sequence, interacted: capturedInteraction > acknowledgedInteraction };
|
||||
await options.send(presence, keepalive);
|
||||
acknowledgedInteraction = Math.max(acknowledgedInteraction, capturedInteraction);
|
||||
},
|
||||
};
|
||||
}
|
||||
+91
-13
@@ -1,5 +1,6 @@
|
||||
import type { Message } from "./api";
|
||||
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
|
||||
import { agentPushCursor, agentPushSourceId, agentPushUuid } from "./agent-push.ts";
|
||||
|
||||
export type AgentActivity = {
|
||||
state: "idle" | "thinking" | "tool" | "waiting" | "error";
|
||||
@@ -19,15 +20,18 @@ export type AgentDescriptor = {
|
||||
id: string; title: string; model: string; activity: AgentActivity;
|
||||
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
|
||||
};
|
||||
export type AgentReadState = { cursor: string; updatedAt: string | null };
|
||||
export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null };
|
||||
export type AgentRoomState = {
|
||||
conversationId: string | null; messages: Message[]; cursor: string;
|
||||
readCursor: string; activity: AgentActivity | null;
|
||||
/** undefined before the first read snapshot; null pins a legacy source-less session. */
|
||||
sourceId?: string | null;
|
||||
};
|
||||
export type AgentRoomEvent =
|
||||
| ({ type: "messages" } & AgentMessagesPage)
|
||||
| { type: "activity"; conversationId: string; activity: AgentActivity }
|
||||
| { type: "ready"; conversationId?: string }
|
||||
| ({ type: "read_state" } & AgentReadState)
|
||||
| { type: "pong" | "heartbeat" }
|
||||
| { type: "error"; error?: string; message?: string };
|
||||
|
||||
@@ -81,6 +85,17 @@ export function mergeAgentMessages(previous: readonly Message[], incoming: reado
|
||||
export const createAgentRoomState = (): AgentRoomState => ({
|
||||
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
||||
});
|
||||
export class AgentReadSourceChangedError extends Error {
|
||||
constructor() { super("Agent history changed. Reload the page before marking messages read."); this.name = "AgentReadSourceChangedError"; }
|
||||
}
|
||||
function readSource(state: Pick<AgentRoomState, "sourceId" | "cursor" | "readCursor" | "messages">, read: AgentReadState): string | null {
|
||||
if (read.sourceId != null && !agentPushSourceId(read.sourceId)) throw new AgentReadSourceChangedError();
|
||||
const incoming = read.sourceId ?? null;
|
||||
if (state.sourceId !== undefined && state.sourceId !== incoming) throw new AgentReadSourceChangedError();
|
||||
// A source cannot be attached retroactively to already rendered unknown history.
|
||||
if (state.sourceId === undefined && incoming !== null && (state.cursor !== "0" || state.readCursor !== "0" || state.messages.length > 0)) throw new AgentReadSourceChangedError();
|
||||
return incoming;
|
||||
}
|
||||
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
|
||||
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
|
||||
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
|
||||
@@ -88,10 +103,12 @@ export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage):
|
||||
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
|
||||
}
|
||||
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
|
||||
return { ...state, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
||||
const sourceId = readSource(state, read);
|
||||
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
||||
}
|
||||
export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState {
|
||||
if (event.type === "messages") return applyAgentPage(state, event);
|
||||
if (event.type === "read_state") return applyAgentReadState(state, event);
|
||||
if (event.type !== "activity") return state;
|
||||
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
|
||||
return { ...state, conversationId: event.conversationId, activity: event.activity };
|
||||
@@ -110,7 +127,10 @@ export function agentDisplayMessages(state: AgentRoomState): Message[] {
|
||||
export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
|
||||
const event = object(JSON.parse(raw));
|
||||
if (!event || typeof event.type !== "string") throw new Error("Invalid agent room frame.");
|
||||
if (!["messages", "activity", "ready", "pong", "heartbeat", "error"].includes(event.type)) return null;
|
||||
if (!["messages", "activity", "ready", "pong", "heartbeat", "error", "read_state"].includes(event.type)) return null;
|
||||
if (event.type === "read_state" && (!agentPushCursor(event.cursor)
|
||||
|| (event.updatedAt !== null && typeof event.updatedAt !== "string")
|
||||
|| (event.sourceId !== undefined && event.sourceId !== null && !agentPushSourceId(event.sourceId)))) throw new Error("Invalid Agent read frame.");
|
||||
if (event.type === "messages") {
|
||||
if (typeof event.conversationId !== "string" || !Array.isArray(event.messages) || typeof event.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
|
||||
if (event.cursor !== null) normalizeAgentCursor(event.cursor);
|
||||
@@ -123,8 +143,9 @@ export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
|
||||
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
|
||||
return event as AgentRoomEvent;
|
||||
}
|
||||
export function agentAuthFrame(token: string | null, cursor: string): string {
|
||||
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor) });
|
||||
export function agentAuthFrame(token: string | null, cursor: string, presenceSessionId?: string): string {
|
||||
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor),
|
||||
...(agentPushUuid(presenceSessionId) ? { clientKind: "web", presenceSessionId } : {}) });
|
||||
}
|
||||
|
||||
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
|
||||
@@ -146,10 +167,10 @@ export class AgentRoomClient {
|
||||
const response = await this.fetcher(`${this.baseUrl}/v1/agent${path}`, { ...init, headers });
|
||||
let value: unknown;
|
||||
try { value = await response.json(); }
|
||||
catch { throw new Error(`Agent API returned ${response.status} without a JSON response.`); }
|
||||
catch { throw Object.assign(new Error(`Agent API returned ${response.status} without a JSON response.`), { status: response.status }); }
|
||||
if (!response.ok) {
|
||||
const error = object(value);
|
||||
throw new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`);
|
||||
throw Object.assign(new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`), { status: response.status });
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
@@ -158,8 +179,8 @@ export class AgentRoomClient {
|
||||
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
|
||||
}
|
||||
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
||||
markRead(cursor: string, signal?: AbortSignal): Promise<AgentReadState> {
|
||||
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor) }), signal });
|
||||
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
|
||||
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal });
|
||||
}
|
||||
async submit(content: string, requestId: string, attachments: AgentAttachment[] = []): Promise<AgentSubmission> {
|
||||
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId, ...(attachments.length ? { attachments: attachments.map(({ id }) => ({ id })) } : {}) }) }));
|
||||
@@ -215,18 +236,75 @@ export function createAgentReadWriter(options: {
|
||||
current: () => string; save: (cursor: string) => Promise<AgentReadState>; apply: (read: AgentReadState) => void;
|
||||
}) {
|
||||
let desired = "0";
|
||||
let stopped = false;
|
||||
let running: Promise<void> | undefined;
|
||||
const cancel = () => { stopped = true; desired = "0"; };
|
||||
const flush = (): Promise<void> => {
|
||||
if (stopped) return Promise.resolve();
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
while (BigInt(desired) > BigInt(options.current())) {
|
||||
while (!stopped && BigInt(desired) > BigInt(options.current())) {
|
||||
const captured = desired;
|
||||
const saved = await options.save(captured);
|
||||
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
|
||||
options.apply(saved);
|
||||
if (stopped) return;
|
||||
try {
|
||||
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
|
||||
options.apply(saved);
|
||||
// A rejected/source-mismatched acknowledgment must never become a PUT loop.
|
||||
if (BigInt(options.current()) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
|
||||
} catch (error) { cancel(); throw error; }
|
||||
}
|
||||
})().finally(() => { running = undefined; });
|
||||
return running;
|
||||
};
|
||||
return { request(cursor: string) { desired = advanceAgentCursor(desired, cursor); return flush(); }, flush };
|
||||
return { request(cursor: string) { if (!stopped) desired = advanceAgentCursor(desired, cursor); return flush(); }, flush, cancel };
|
||||
}
|
||||
|
||||
/** Pin reads to the source that owns this rendered room, including queued writes. */
|
||||
export function createAgentReadSession(options: {
|
||||
initial: AgentRoomState;
|
||||
save: (cursor: string, sourceId: string | null) => Promise<AgentReadState>;
|
||||
apply: (read: AgentReadState) => void;
|
||||
invalidated?: (error: AgentReadSourceChangedError) => void;
|
||||
}) {
|
||||
let binding = options.initial.sourceId, acknowledged = options.initial.readCursor;
|
||||
let failure: AgentReadSourceChangedError | undefined;
|
||||
const invalidate = () => {
|
||||
if (!failure) { failure = new AgentReadSourceChangedError(); writer.cancel(); options.invalidated?.(failure); }
|
||||
return failure;
|
||||
};
|
||||
const receive = (read: AgentReadState) => {
|
||||
if (failure) throw failure;
|
||||
try {
|
||||
const nextSource = readSource({ ...options.initial, sourceId: binding, readCursor: acknowledged }, read);
|
||||
const nextCursor = advanceAgentCursor(acknowledged, read.cursor);
|
||||
binding = nextSource; acknowledged = nextCursor;
|
||||
options.apply({ ...read, sourceId: binding });
|
||||
} catch (error) { if (error instanceof AgentReadSourceChangedError) throw invalidate(); throw error; }
|
||||
};
|
||||
const writer = createAgentReadWriter({
|
||||
current: () => acknowledged,
|
||||
save: async cursor => {
|
||||
if (failure || binding === undefined) throw invalidate();
|
||||
const source = binding;
|
||||
try { return await options.save(cursor, source); }
|
||||
catch (error) {
|
||||
if (error instanceof AgentReadSourceChangedError || object(error)?.status === 409) throw invalidate();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
apply: receive,
|
||||
});
|
||||
return {
|
||||
receive,
|
||||
request(cursor: string, renderedSourceId: string | null | undefined) {
|
||||
if (failure) return Promise.reject(failure);
|
||||
if (binding === undefined || renderedSourceId !== binding) return Promise.reject(invalidate());
|
||||
return writer.request(cursor);
|
||||
},
|
||||
flush: writer.flush,
|
||||
cancel: writer.cancel,
|
||||
sourceId: () => binding,
|
||||
readCursor: () => acknowledged,
|
||||
};
|
||||
}
|
||||
|
||||
+13
-6
@@ -1,12 +1,19 @@
|
||||
export async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
|
||||
const registration = await navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" });
|
||||
// ready can otherwise remain pending forever after an install failure.
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("The notification worker did not become ready. Reload and try again.")), 15000);
|
||||
void navigator.serviceWorker.ready.then(ready => { clearTimeout(timeout); resolve(ready); }, () => {
|
||||
clearTimeout(timeout); reject(new Error("The notification worker is unavailable."));
|
||||
});
|
||||
void registration.update().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerServiceWorker() {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
void navigator.serviceWorker
|
||||
.register("/sw.js", { updateViaCache: "none" })
|
||||
.then((registration) => registration.update())
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Sybil service worker registration failed", error);
|
||||
});
|
||||
void ensureServiceWorker().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const workerSource = await readFile(new URL("../public/sw.js", import.meta.url), "utf8");
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce", otherSource = "550caef2-b4bc-482c-bd9f-58045ed0a8a8";
|
||||
const payload = (cursor = "5", extra = {}) => ({ version: 1, kind: "message", sourceId, messageId: `outbound:${cursor}`, cursor,
|
||||
title: "Sybil Agent", body: "You have a new message.", url: "/?agent=root", tag: `sybil-agent:${sourceId}`, ...extra });
|
||||
function idb(records) {
|
||||
const request = value => { const result = {}; queueMicrotask(() => { result.result = structuredClone(value); result.onsuccess?.(); }); return result; };
|
||||
return { open() {
|
||||
const opening = {};
|
||||
queueMicrotask(() => {
|
||||
opening.result = { close() {}, transaction() {
|
||||
const transaction = { objectStore: () => ({
|
||||
get: key => request(records.get(key)), getAll: () => request([...records.values()]),
|
||||
put: value => records.set(value.sourceId, structuredClone(value)), delete: key => records.delete(key),
|
||||
}) };
|
||||
setImmediate(() => transaction.oncomplete?.()); return transaction;
|
||||
} };
|
||||
opening.onsuccess?.();
|
||||
}); return opening;
|
||||
} };
|
||||
}
|
||||
function fixture(sharedRecords = new Map()) {
|
||||
const handlers = new Map(), shown = [], notifications = [], routes = [], messages = [], fetches = [];
|
||||
const normalClient = { id: "client", type: "window", url: "https://sybil.example.test/?chat=somewhere", postMessage: value => messages.push(value),
|
||||
navigate: async url => { routes.push(["navigate", url]); return normalClient; }, focus: async () => { routes.push(["focus"]); return normalClient; } };
|
||||
let windows = [normalClient];
|
||||
const makeNotification = value => {
|
||||
const notification = { tag: value.tag, data: value.data, closed: false, close() { notification.closed = true; } };
|
||||
notifications.push(notification); return notification;
|
||||
};
|
||||
const self = {
|
||||
addEventListener: (name, handler) => handlers.set(name, handler), skipWaiting() {}, location: { origin: "https://sybil.example.test" },
|
||||
clients: { claim: async () => undefined, matchAll: async () => windows,
|
||||
get: async id => windows.find(client => client.id === id), openWindow: async url => { routes.push(["open", url]); } },
|
||||
registration: {
|
||||
showNotification: async (title, options) => {
|
||||
shown.push({ title, ...structuredClone(options) });
|
||||
for (const notification of notifications) if (notification.tag === options.tag) notification.close();
|
||||
makeNotification(options);
|
||||
},
|
||||
getNotifications: async ({ tag } = {}) => notifications.filter(value => !value.closed && (!tag || value.tag === tag)),
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt,
|
||||
fetch: async request => { fetches.push(request); throw new Error("Network blocked in worker tests"); }, Request });
|
||||
const emit = async (type, properties = {}) => {
|
||||
const waits = []; handlers.get(type)?.({ ...properties, waitUntil: value => waits.push(value) }); await Promise.all(waits);
|
||||
};
|
||||
return { shown, notifications, routes, messages, fetches, records: sharedRecords, makeNotification,
|
||||
push: value => emit("push", { data: { text: () => typeof value === "string" ? value : JSON.stringify(value) } }),
|
||||
click: notification => emit("notificationclick", { notification }),
|
||||
message: (data, id = "client") => emit("message", { data, source: { id }, ports: [] }),
|
||||
emit, windows: value => { windows = value; }, normalClient,
|
||||
};
|
||||
}
|
||||
|
||||
test("actual worker push handler shows fixed generic notification and never fetches credentials", async () => {
|
||||
const f = fixture(); await f.push(payload());
|
||||
assert.equal(f.shown.length, 1); assert.equal(f.shown[0].title, "Sybil Agent"); assert.equal(f.shown[0].body, "You have a new message.");
|
||||
assert.deepEqual(f.shown[0].data, { version: 1, kind: "message", sourceId, messageId: "outbound:5", cursor: "5" });
|
||||
assert.equal(f.shown[0].tag, `sybil-agent:${sourceId}`); assert.equal(f.shown[0].renotify, false);
|
||||
assert.deepEqual(f.fetches, []); assert.ok(!JSON.stringify([...f.records]).includes("token"));
|
||||
});
|
||||
|
||||
test("every valid message push remains visible, including read or duplicate deliveries; stale payload never regresses identity", async () => {
|
||||
const records = new Map(), f = fixture(records);
|
||||
await f.message({ type: "sybil-agent-read", version: 1, sourceId, cursor: "10" });
|
||||
await f.push(payload("8")); await f.push(payload("8")); await f.push(payload("5"));
|
||||
assert.equal(f.shown.length, 3, "Safari visibility requirement is preserved");
|
||||
assert.ok(f.shown.every(notification => notification.data.cursor === "8"));
|
||||
const restarted = fixture(records); await restarted.push(payload("6"));
|
||||
assert.equal(restarted.shown[0].data.cursor, "8");
|
||||
assert.equal(records.get(sourceId).readCursor, "10");
|
||||
});
|
||||
|
||||
test("malformed/unexpected payloads cannot supply text, destinations, cross-source tags or silent read pushes", async () => {
|
||||
const f = fixture();
|
||||
for (const value of ["not-json", "x".repeat(5000), payload("1", { title: "sensitive text" }), payload("1", { body: "transcript" }),
|
||||
payload("1", { url: "https://evil.example/" }), payload("1", { tag: "some-other-app" }), payload("-1"), payload("9007199254740992"),
|
||||
payload("1", { sourceId: "../../bad" }), payload("1", { messageId: "<script>" }), { version: 1, kind: "read_state", sourceId, readCursor: "8" }]) await f.push(value);
|
||||
assert.equal(f.shown.length, 0); assert.equal(f.fetches.length, 0);
|
||||
});
|
||||
|
||||
test("authenticated page read sync closes only owned matching source at or below monotonic read cursor", async () => {
|
||||
const f = fixture();
|
||||
const older = f.makeNotification({ tag: `sybil-agent:${sourceId}`, data: payload("4") });
|
||||
const newer = f.makeNotification({ tag: `sybil-agent:${sourceId}`, data: payload("8") });
|
||||
const different = f.makeNotification({ tag: `sybil-agent:${otherSource}`, data: payload("1", { sourceId: otherSource }) });
|
||||
const unrelated = f.makeNotification({ tag: "calendar", data: payload("1") });
|
||||
const read = { type: "sybil-agent-read", version: 1, sourceId, cursor: "5" };
|
||||
await f.message(read, "unknown-client"); assert.equal(older.closed, false);
|
||||
f.windows([{ ...f.normalClient, id: "foreign", url: "https://evil.example/" }]);
|
||||
await f.message(read, "foreign"); assert.equal(older.closed, false);
|
||||
f.windows([f.normalClient]); await f.message(read); await f.message({ ...read, cursor: "2" });
|
||||
assert.equal(older.closed, true); assert.equal(newer.closed, false); assert.equal(different.closed, false); assert.equal(unrelated.closed, false);
|
||||
assert.equal(f.records.get(sourceId).readCursor, "5");
|
||||
await f.message({ ...read, cursor: "8" }); assert.equal(newer.closed, true);
|
||||
});
|
||||
|
||||
test("notification click closes and navigates a same-origin client using fixed root URL, without marking read", async () => {
|
||||
const f = fixture(); await f.push(payload());
|
||||
const notification = f.notifications[0]; notification.data.url = "https://evil.example/";
|
||||
await f.click(notification);
|
||||
assert.equal(notification.closed, true);
|
||||
assert.deepEqual(f.routes, [["navigate", "https://sybil.example.test/?agent=root"], ["focus"]]);
|
||||
assert.equal(f.records.get(sourceId).readCursor, undefined);
|
||||
f.windows([{ ...f.normalClient, url: "https://evil.example/" }]); await f.click(notification);
|
||||
assert.deepEqual(f.routes.at(-1), ["open", "https://sybil.example.test/?agent=root"]);
|
||||
});
|
||||
|
||||
test("logout closes only owned alerts and subscription change asks pages to re-register without network or secrets", async () => {
|
||||
const f = fixture(); await f.push(payload());
|
||||
const unrelated = f.makeNotification({ tag: "another-app", data: payload() });
|
||||
await f.message({ type: "sybil-agent-logout", version: 1 });
|
||||
assert.equal(f.notifications[0].closed, true); assert.equal(unrelated.closed, false);
|
||||
await f.emit("pushsubscriptionchange");
|
||||
assert.equal(f.messages.at(-1).type, "sybil-push-subscription-changed");
|
||||
assert.deepEqual(f.fetches, []);
|
||||
});
|
||||
|
||||
test("worker bounds durable source metadata", async () => {
|
||||
const f = fixture();
|
||||
for (let index = 0; index < 20; index++) await f.message({ type: "sybil-agent-read", version: 1, sourceId: `4599d348-e053-4937-987d-${String(index).padStart(12, "0")}`, cursor: "1" });
|
||||
assert.equal(f.records.size, 16);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import assert from "node:assert/strict";
|
||||
// Fresh isolated Chromium only. All REST, sockets and PushManager provider calls
|
||||
// are mocked. The real /sw.js and browser IndexedDB/notification APIs execute.
|
||||
// PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome
|
||||
// WEB_TEST_URL=http://127.0.0.1:5197 node tests/agent-push.browser.mjs
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
|
||||
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
|
||||
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
|
||||
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
|
||||
const context = await browser.newContext({ viewport: { width: 1200, height: 900 }, permissions: ["notifications"] });
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce", requests = [], sockets = [], errors = [], blocked = [];
|
||||
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
|
||||
const config = { version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId: null, quarantined: false,
|
||||
vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } };
|
||||
const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
|
||||
let readCursor = "0", readSourceId = sourceId, failDelete = false;
|
||||
await context.addInitScript(() => {
|
||||
if (!localStorage.getItem("fixture-bootstrapped")) {
|
||||
localStorage.setItem("fixture-bootstrapped", "true"); localStorage.setItem("sybil_admin_token", "browser-fixture-token");
|
||||
}
|
||||
window.__pushCalls = [];
|
||||
window.__fixturePermission = localStorage.getItem("fixture-subscription") ? "granted" : "default";
|
||||
window.__fixtureDecision = "denied";
|
||||
Object.defineProperty(Notification, "permission", { configurable: true, get: () => window.__fixturePermission });
|
||||
Notification.requestPermission = () => {
|
||||
window.__pushCalls.push({ type: "permission", gesture: navigator.userActivation.isActive });
|
||||
window.__fixturePermission = window.__fixtureDecision; return Promise.resolve(window.__fixturePermission);
|
||||
};
|
||||
const subscription = () => ({
|
||||
toJSON: () => ({ endpoint: "https://fcm.googleapis.com/fcm/send/browser-fixture", expirationTime: null, keys: { p256dh: "B".repeat(87), auth: "A".repeat(22) } }),
|
||||
unsubscribe: async () => { window.__pushCalls.push({ type: "unsubscribe" }); localStorage.removeItem("fixture-subscription"); return true; },
|
||||
});
|
||||
PushManager.prototype.getSubscription = async () => localStorage.getItem("fixture-subscription") ? subscription() : null;
|
||||
PushManager.prototype.subscribe = async options => {
|
||||
window.__pushCalls.push({ type: "subscribe", visible: options.userVisibleOnly, keyLength: options.applicationServerKey.byteLength });
|
||||
localStorage.setItem("fixture-subscription", "true"); return subscription();
|
||||
};
|
||||
});
|
||||
await context.route("**/*", async route => {
|
||||
const request = route.request(), url = new URL(request.url());
|
||||
if (url.origin !== target.origin) { blocked.push(url.origin); await route.abort(); return; }
|
||||
if (!url.pathname.startsWith("/api/")) { await route.continue(); return; }
|
||||
const method = request.method(), path = url.pathname;
|
||||
const body = request.postData() ? request.postDataJSON() : undefined;
|
||||
requests.push({ path, method, body, authorization: request.headers().authorization });
|
||||
assert.ok(["Bearer browser-fixture-token", "Bearer browser-fixture-rotated-token"].includes(request.headers().authorization));
|
||||
let value;
|
||||
if (path.endsWith("/push/config")) value = config;
|
||||
else if (path.includes("/push/installations/")) {
|
||||
if (method === "DELETE") { await route.fulfill({ status: failDelete ? 503 : 204, ...(failDelete ? { json: { message: "offline" } } : {}) }); return; }
|
||||
value = { installationId: path.split("/").at(-1), transport: "web", enabled: true, version: 1 };
|
||||
} else if (path.includes("/presence/")) {
|
||||
if (method === "DELETE") { await route.fulfill({ status: 204 }); return; }
|
||||
value = { expiresAt: new Date(Date.now() + 60000).toISOString() };
|
||||
} else if (path.endsWith("/read-state")) {
|
||||
if (method === "PUT") readCursor = body.cursor;
|
||||
value = { cursor: readCursor, sourceId: readSourceId, updatedAt: null };
|
||||
} else if (path.endsWith("/agent/messages")) value = { conversationId: "1", messages: [], cursor: "0", beforeCursor: null, hasMore: false };
|
||||
else if (path.endsWith("/agent")) value = { id: "1", title: "Sybil Agent", activity: idle, protocol: "pi-room.v1", websocket: "/v1/agent/ws" };
|
||||
else if (path.includes("session")) value = { mode: "token", authenticated: true };
|
||||
else if (path.endsWith("/tools") || path.endsWith("/chat-tools")) value = { tools: [] };
|
||||
else if (path.endsWith("/models")) value = { models: [], providers: {} };
|
||||
else value = [];
|
||||
await route.fulfill({ json: value });
|
||||
});
|
||||
await context.routeWebSocket("**/api/v1/agent/ws", socket => {
|
||||
socket.onMessage(raw => {
|
||||
const frame = JSON.parse(String(raw));
|
||||
if (frame.type === "auth") { sockets.push({ socket, frame }); socket.send(JSON.stringify({ type: "ready", conversationId: "1" })); }
|
||||
});
|
||||
});
|
||||
const page = await context.newPage(); page.on("pageerror", error => errors.push(error.message));
|
||||
const expectEventually = async predicate => { for (let i = 0; i < 100; i++) { if (await predicate()) return; await new Promise(resolve => setTimeout(resolve, 50)); } assert.fail("Condition did not settle"); };
|
||||
try {
|
||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await page.getByText("Notifications are not configured on this server yet.", { exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole("button", { name: "Enable notifications", exact: true }).isDisabled(), true, "unknown source cannot opt in");
|
||||
assert.deepEqual(await page.evaluate(() => window.__pushCalls), []);
|
||||
config.sourceId = sourceId;
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
assert.equal(requests.filter(request => request.path.includes("/push/installations/")).length, 0);
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByText(/Notifications were not allowed/).waitFor();
|
||||
assert.equal(requests.filter(request => request.path.includes("/push/installations/")).length, 0);
|
||||
assert.deepEqual(await page.evaluate(() => window.__pushCalls), [{ type: "permission", gesture: true }]);
|
||||
await page.evaluate(() => { window.__fixturePermission = "default"; window.__fixtureDecision = "granted"; });
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
const registrations = requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT");
|
||||
assert.equal(registrations.length, 1); assert.equal(registrations[0].body.transport, "web");
|
||||
assert.deepEqual(await page.evaluate(() => window.__pushCalls.filter(call => call.type === "subscribe")), [{ type: "subscribe", visible: true, keyLength: 65 }]);
|
||||
await expectEventually(() => sockets.length === 1);
|
||||
const firstSession = sockets[0].frame.presenceSessionId;
|
||||
assert.equal(sockets[0].frame.clientKind, "web");
|
||||
assert.ok(requests.some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible"));
|
||||
|
||||
// /search has no Agent pane or room socket but must still report attention.
|
||||
const second = await context.newPage(); second.on("pageerror", error => errors.push(error.message));
|
||||
await second.goto(new URL("/search", target).href, { waitUntil: "networkidle" });
|
||||
await second.bringToFront();
|
||||
await expectEventually(() => new Set(requests.filter(request => request.path.includes("/presence/")).map(request => request.path)).size >= 2);
|
||||
const sessionPaths = [...new Set(requests.filter(request => request.path.includes("/presence/")).map(request => request.path))];
|
||||
assert.equal(sessionPaths.length, 2);
|
||||
const secondPath = sessionPaths.find(path => !path.endsWith(firstSession));
|
||||
await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.focused === true));
|
||||
await second.getByRole("textbox").first().click();
|
||||
await second.getByRole("textbox").first().pressSequentially("interaction");
|
||||
await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.interacted === true));
|
||||
for (const path of sessionPaths) {
|
||||
const sequences = requests.filter(request => request.path === path && request.method === "PUT").map(request => request.body.sequence);
|
||||
assert.deepEqual(sequences, [...new Set(sequences)].sort((a, b) => a - b));
|
||||
}
|
||||
await page.bringToFront();
|
||||
const worker = context.serviceWorkers().find(worker => new URL(worker.url()).pathname === "/sw.js");
|
||||
assert.ok(worker, "actual service worker activated");
|
||||
const push = async cursor => worker.evaluate(async ({ cursor, sourceId }) => {
|
||||
const value = { version: 1, kind: "message", sourceId, cursor, messageId: `outbound:${cursor}`, title: "Sybil Agent", body: "You have a new message.", url: "/?agent=root", tag: `sybil-agent:${sourceId}` };
|
||||
const event = new Event("push"), waits = [];
|
||||
Object.defineProperties(event, { data: { value: { text: () => JSON.stringify(value) } }, waitUntil: { value: promise => waits.push(promise) } });
|
||||
self.dispatchEvent(event); await Promise.all(waits);
|
||||
return (await self.registration.getNotifications()).map(notification => ({ title: notification.title, body: notification.body, tag: notification.tag, data: notification.data }));
|
||||
}, { cursor, sourceId });
|
||||
const notification = await push("8");
|
||||
assert.equal(notification.length, 1); assert.equal(notification[0].title, "Sybil Agent"); assert.equal(notification[0].body, "You have a new message.");
|
||||
assert.equal(notification[0].data.cursor, "8");
|
||||
assert.equal(requests.filter(request => request.method === "PUT" && request.path.endsWith("/read-state")).length, 0, "push reception never marks read");
|
||||
readCursor = "7";
|
||||
sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId, cursor: readCursor, updatedAt: null }));
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
assert.equal(await worker.evaluate(async () => (await self.registration.getNotifications()).length), 1);
|
||||
readCursor = "8";
|
||||
sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId, cursor: readCursor, updatedAt: null }));
|
||||
await expectEventually(async () => await worker.evaluate(async () => (await self.registration.getNotifications()).length) === 0);
|
||||
const dbRows = await worker.evaluate(async () => {
|
||||
const db = await new Promise(resolve => { const request = indexedDB.open("sybil-agent-notifications-v1"); request.onsuccess = () => resolve(request.result); });
|
||||
const rows = await new Promise(resolve => { const request = db.transaction("sources").objectStore("sources").getAll(); request.onsuccess = () => resolve(request.result); });
|
||||
db.close(); return rows;
|
||||
});
|
||||
assert.equal(dbRows[0].readCursor, "8"); assert.doesNotMatch(JSON.stringify(dbRows), /browser-fixture-token|fcm.googleapis/);
|
||||
|
||||
// The rendered room belongs to source A. A B read frame must stop the room,
|
||||
// preserving A's cursor instead of relabeling old queued reads with B.
|
||||
await second.close();
|
||||
await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||
sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId: replacementSourceId, cursor: "0", updatedAt: null }));
|
||||
await page.getByText("Agent history changed. Reload the page before marking messages read.", { exact: true }).waitFor();
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
assert.equal(requests.filter(request => request.method === "PUT" && request.path.endsWith("/read-state")).length, 0, "source mismatch cannot produce relabeled writes or a write loop");
|
||||
|
||||
// Replacing the source at the same backend/token invalidates persisted push
|
||||
// consent. The new source requires another gesture and another installation.
|
||||
const oldConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
const registrationCount = requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT").length;
|
||||
config.sourceId = replacementSourceId; readSourceId = replacementSourceId; readCursor = "0";
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
const retiredConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.equal(retiredConsent.enabled, false); assert.equal(retiredConsent.sourceId, sourceId);
|
||||
assert.equal(requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT").length, registrationCount);
|
||||
assert.ok(requests.some(request => request.path.endsWith(oldConsent.installationId) && request.method === "DELETE"));
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
const replacementConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.equal(replacementConsent.enabled, true); assert.equal(replacementConsent.sourceId, replacementSourceId);
|
||||
assert.equal(replacementConsent.scope, oldConsent.scope); assert.notEqual(replacementConsent.installationId, oldConsent.installationId);
|
||||
assert.ok((await page.evaluate(() => window.__pushCalls.filter(call => call.type === "permission"))).every(call => call.gesture === true));
|
||||
const socketCount = sockets.length;
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expectEventually(() => sockets.length > socketCount);
|
||||
assert.equal(await page.getByText("Agent history changed. Reload the page before marking messages read.", { exact: true }).count(), 0, "explicit reload binds a fresh room to B");
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
|
||||
// Credential rotation is a new authority scope. The old UUID remains queued
|
||||
// locally even if this server would return204 for an unknown DELETE.
|
||||
const originalAuthority = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
await page.evaluate(() => localStorage.setItem("sybil_admin_token", "browser-fixture-rotated-token"));
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
const rotatedOff = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.deepEqual(rotatedOff.pendingRevocations, [{ installationId: originalAuthority.installationId, scope: originalAuthority.scope }]);
|
||||
assert.ok(!requests.some(request => request.method === "DELETE" && request.path.endsWith(originalAuthority.installationId)
|
||||
&& request.authorization === "Bearer browser-fixture-rotated-token"), "rotated credentials cannot falsely acknowledge old-authority cleanup");
|
||||
await page.evaluate(() => { window.__fixtureDecision = "granted"; });
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
const rotatedOn = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.equal(rotatedOn.enabled, true); assert.notEqual(rotatedOn.scope, originalAuthority.scope);
|
||||
assert.notEqual(rotatedOn.installationId, originalAuthority.installationId); assert.deepEqual(rotatedOn.pendingRevocations, rotatedOff.pendingRevocations);
|
||||
await page.evaluate(() => localStorage.setItem("sybil_admin_token", "browser-fixture-token"));
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
const returned = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.deepEqual(returned.pendingRevocations, [{ installationId: rotatedOn.installationId, scope: rotatedOn.scope }]);
|
||||
assert.ok(requests.some(request => request.method === "DELETE" && request.path.endsWith(originalAuthority.installationId)
|
||||
&& request.authorization === "Bearer browser-fixture-token"));
|
||||
await page.evaluate(() => { window.__fixtureDecision = "granted"; });
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
|
||||
// Offline sign-out still removes the local subscription and leaves a tombstone.
|
||||
failDelete = true;
|
||||
await page.getByRole("button", { name: "Sign out", exact: true }).click();
|
||||
await page.getByRole("button", { name: /Sign in/i }).waitFor();
|
||||
const local = await page.evaluate(() => ({ token: localStorage.getItem("sybil_admin_token"), subscription: localStorage.getItem("fixture-subscription"), consent: JSON.parse(localStorage.getItem("sybil.agent.push.v1")) }));
|
||||
assert.equal(local.token, null); assert.equal(local.subscription, null); assert.equal(local.consent.enabled, false); assert.equal(local.consent.revocationPending, true);
|
||||
assert.ok(requests.some(request => request.method === "DELETE" && request.path.includes("/push/installations/") && request.authorization === "Bearer browser-fixture-token"));
|
||||
// Existing app fonts are blocked as well; nothing leaves the loopback fixture.
|
||||
assert.ok(blocked.every(origin => origin === "https://fonts.googleapis.com")); assert.deepEqual(errors, []);
|
||||
console.log("PASS real worker activation/showNotification/IndexedDB/read close; unknown-source/gesture denial/enable; two-tab + standalone search presence; auth-frame binding; source-replacement read quiescence and explicit re-opt-in/reload; authority-scoped cleanup/credential rotation/return retry; offline logout; no provider network");
|
||||
} finally { await context.close(); await browser.close(); }
|
||||
@@ -0,0 +1,467 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
AgentPushClient, agentPushAuthorityScope, agentPushCursor, createAgentPresenceReporter, createAgentPushController,
|
||||
MAX_AGENT_PUSH_REVOCATIONS, parseAgentPushConfig, parseAgentPushConsent,
|
||||
} from "../src/lib/agent-push.ts";
|
||||
import { agentAuthFrame, applyAgentEvent, createAgentRoomState, parseAgentRoomEvent } from "../src/lib/agent-room.ts";
|
||||
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
||||
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
|
||||
const configuration = () => ({ version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId, quarantined: false,
|
||||
vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } });
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
function fixture(overrides = {}, shared = {}) {
|
||||
let record = shared.record || null, subscription = null, permission = "default", support = "supported", config = configuration();
|
||||
const calls = [], states = [];
|
||||
const api = {
|
||||
config: async () => config,
|
||||
register: async (id, value) => { calls.push(["register", id, value]); },
|
||||
revoke: async id => { calls.push(["revoke", id]); },
|
||||
...overrides.api,
|
||||
};
|
||||
const makeSubscription = (endpoint = "https://push.example.test/opaque") => ({
|
||||
toJSON: () => ({ endpoint, keys: { p256dh: "fixture-public", auth: "fixture-auth" } }),
|
||||
unsubscribe: async () => { calls.push(["unsubscribe"]); subscription = null; return true; },
|
||||
});
|
||||
const platform = {
|
||||
support: () => support, permission: () => permission,
|
||||
requestPermission: () => { calls.push(["permission"]); permission = "granted"; return Promise.resolve(permission); },
|
||||
subscription: async () => subscription,
|
||||
subscribe: async key => { calls.push(["subscribe", key]); return subscription = makeSubscription(); },
|
||||
clearNotifications: async () => { calls.push(["clear"]); },
|
||||
...overrides.platform,
|
||||
};
|
||||
const options = {
|
||||
api, platform, read: () => record, write: value => { record = structuredClone(value); }, uuid: randomUUID,
|
||||
scope: async () => "a".repeat(64),
|
||||
lock: async operation => operation(), changed: state => states.push(state), ...shared.options,
|
||||
};
|
||||
const controller = createAgentPushController(options);
|
||||
return { controller, options, calls, states, api, platform, makeSubscription, record: () => record,
|
||||
setConfig(value) { config = value; }, setPermission(value) { permission = value; }, setSupport(value) { support = value; }, setSubscription(value) { subscription = value; } };
|
||||
}
|
||||
|
||||
test("explicit gesture requests permission before yielding; permission alone never opts in", async () => {
|
||||
const f = fixture(); f.setPermission("granted");
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.record(), null); assert.deepEqual(f.calls, []);
|
||||
const enabling = f.controller.enable();
|
||||
assert.deepEqual(f.calls, [["permission"]], "request runs directly inside the click stack");
|
||||
await enabling;
|
||||
assert.equal(f.record().enabled, true);
|
||||
assert.equal(f.record().sourceId, sourceId);
|
||||
assert.equal(f.record().revocationPending, false);
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
||||
await f.controller.refresh(); await f.controller.refresh();
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, 1, "heartbeat does not repeatedly register an unchanged subscription");
|
||||
});
|
||||
|
||||
test("denial, disabled config, unsupported browser and iOS installation guidance do not subscribe", async () => {
|
||||
for (const support of ["unsupported", "install"]) {
|
||||
const f = fixture(); f.setSupport(support); await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.deepEqual(f.calls, []);
|
||||
assert.match(f.controller.state().message, support === "install" ? /Home Screen/ : /HTTPS/);
|
||||
}
|
||||
const disabled = fixture(); disabled.setConfig({ ...configuration(), enabled: false });
|
||||
await disabled.controller.refresh(); await disabled.controller.enable(); assert.deepEqual(disabled.calls, []);
|
||||
const denied = fixture({ platform: { requestPermission: async () => "denied" } });
|
||||
await denied.controller.refresh(); await denied.controller.enable();
|
||||
assert.equal(denied.record().enabled, false); assert.equal(denied.calls.length, 0);
|
||||
assert.match(denied.controller.state().error, /not allowed/);
|
||||
});
|
||||
|
||||
test("registration failure rolls back subscription, persists revoke intent and never echoes provider errors", async () => {
|
||||
const f = fixture({ api: { register: async () => { throw new Error("https://private-provider/token-secret"); }, revoke: async () => { throw new Error("offline"); } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, true);
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
assert.doesNotMatch(JSON.stringify(f.states), /private-provider|token-secret/);
|
||||
assert.equal(f.controller.state().canEnable, false);
|
||||
});
|
||||
|
||||
test("offline logout writes tombstone synchronously, unsubscribes locally and retries before any enable", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const id = f.record().installationId;
|
||||
f.api.revoke = async () => { throw new Error("offline"); };
|
||||
const disabling = f.controller.disable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, true);
|
||||
await disabling;
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
const count = f.calls.filter(([name]) => name === "register").length;
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, count);
|
||||
f.api.revoke = async revoked => { assert.equal(revoked, id); };
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.record().revocationPending, false); assert.equal(f.record().enabled, false);
|
||||
});
|
||||
|
||||
test("storage failures during logout still revoke, unsubscribe and close notifications", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
f.options.write = () => { throw new Error("quota"); };
|
||||
let removed = false; f.options.remove = () => { removed = true; };
|
||||
await f.controller.disable();
|
||||
assert.ok(removed); assert.equal(f.controller.state().enabled, false);
|
||||
for (const name of ["revoke", "unsubscribe", "clear"]) assert.ok(f.calls.some(([action]) => action === name));
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.controller.state().enabled, false);
|
||||
});
|
||||
|
||||
test("a stuck cross-tab lock cannot prevent server retirement or local logout cleanup", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
f.options.lock = async () => { throw new Error("lock acquisition expired"); };
|
||||
await f.controller.disable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false);
|
||||
for (const name of ["revoke", "unsubscribe", "clear"]) assert.ok(f.calls.some(([action]) => action === name));
|
||||
});
|
||||
|
||||
test("synchronous permission API failures remain a recoverable opt-in error", async () => {
|
||||
const f = fixture({ platform: { requestPermission: () => { throw new Error("user gesture rejected"); } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /could not be requested/);
|
||||
});
|
||||
|
||||
test("subscription rotation upserts the same installation; permission revocation removes it", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const id = f.record().installationId;
|
||||
f.setSubscription(f.makeSubscription("https://push.example.test/rotated"));
|
||||
await f.controller.refresh();
|
||||
const registrations = f.calls.filter(([name]) => name === "register");
|
||||
assert.equal(registrations.length, 2); assert.ok(registrations.every(([, installation]) => installation === id));
|
||||
f.setPermission("denied"); await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false);
|
||||
});
|
||||
|
||||
test("logout while permission prompt is open cancels the older opt-in", async () => {
|
||||
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable();
|
||||
await f.controller.disable(); prompt.resolve("granted"); await enabling;
|
||||
assert.equal(f.record().enabled, false);
|
||||
assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
||||
});
|
||||
|
||||
test("two-tab enable → disable → enable uses permanent server tombstones and a new installation UUID", async () => {
|
||||
let sharedRecord = null, queue = Promise.resolve(), currentRegistration = false;
|
||||
const calls = [], pendingRegister = deferred(), retired = new Set(); let first = true;
|
||||
const common = { options: {
|
||||
read: () => sharedRecord, write: value => { sharedRecord = structuredClone(value); },
|
||||
lock: operation => { const running = queue.then(operation); queue = running.catch(() => undefined); return running; },
|
||||
} };
|
||||
const api = {
|
||||
register: async id => { calls.push("register"); if (first) { first = false; await pendingRegister.promise; } assert.ok(!retired.has(id), "retired UUID must never be reused"); currentRegistration = true; },
|
||||
revoke: async id => { calls.push("revoke"); retired.add(id); currentRegistration = false; },
|
||||
};
|
||||
const a = fixture({ api }, common), b = fixture({ api }, common);
|
||||
await a.controller.refresh(); await b.controller.refresh();
|
||||
const enabling = a.controller.enable(); await settle();
|
||||
assert.deepEqual(calls, ["register"]);
|
||||
const disabling = b.controller.disable();
|
||||
assert.equal(sharedRecord.enabled, false); assert.equal(sharedRecord.revocationPending, true);
|
||||
pendingRegister.resolve(); await Promise.all([enabling, disabling]);
|
||||
assert.equal(currentRegistration, false);
|
||||
const id = sharedRecord.installationId;
|
||||
await b.controller.enable();
|
||||
assert.equal(currentRegistration, true); assert.equal(sharedRecord.enabled, true); assert.notEqual(sharedRecord.installationId, id);
|
||||
assert.ok(retired.has(id));
|
||||
assert.equal(calls.at(-1), "register");
|
||||
});
|
||||
|
||||
test("a timed-out old PUT completing after DELETE cannot revive the retired installation or affect a new opt-in", async () => {
|
||||
const retired = new Set(), active = new Set(), late = deferred(); let firstId, lateStatus, first = true;
|
||||
const f = fixture({ api: {
|
||||
register: async id => {
|
||||
if (first) {
|
||||
first = false; firstId = id;
|
||||
late.promise.then(() => { lateStatus = retired.has(id) ? 409 : 200; if (lateStatus === 200) active.add(id); });
|
||||
throw new Error("request timed out while server is still processing");
|
||||
}
|
||||
assert.ok(!retired.has(id)); active.add(id);
|
||||
},
|
||||
revoke: async id => { retired.add(id); active.delete(id); },
|
||||
} });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false); assert.ok(retired.has(firstId));
|
||||
await f.controller.enable(); const currentId = f.record().installationId;
|
||||
assert.notEqual(currentId, firstId); assert.ok(active.has(currentId));
|
||||
late.resolve(); await settle();
|
||||
assert.equal(lateStatus, 409); assert.deepEqual([...active], [currentId]);
|
||||
});
|
||||
|
||||
test("authority changes retain original-server cleanup without issuing a misleading DELETE to the new server", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId, registrations = f.calls.filter(([name]) => name === "register").length;
|
||||
f.options.scope = async () => "b".repeat(64);
|
||||
await f.controller.refresh(); await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false);
|
||||
assert.equal(f.record().revocationPending, false, "local unsubscribe succeeded, but old server cleanup stays queued");
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
||||
assert.ok(!f.calls.some(([name]) => name === "revoke"), "new server's unknown-ID DELETE/204 cannot retire the original server registration");
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, registrations);
|
||||
await f.controller.enable();
|
||||
const newId = f.record().installationId;
|
||||
assert.notEqual(newId, oldId); assert.equal(f.record().scope, "b".repeat(64)); assert.equal(f.record().enabled, true);
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
||||
const reloaded = createAgentPushController(f.options);
|
||||
await reloaded.refresh();
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }], "queue survives a reload on B");
|
||||
assert.ok(!f.calls.some(([name]) => name === "revoke"));
|
||||
f.options.scope = async () => "a".repeat(64);
|
||||
await reloaded.refresh();
|
||||
assert.deepEqual(f.calls.filter(([name]) => name === "revoke"), [["revoke", oldId]], "returning with A credentials retries only A's intent");
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: newId, scope: "b".repeat(64) }]);
|
||||
assert.equal(f.record().enabled, false);
|
||||
await reloaded.enable();
|
||||
assert.equal(f.record().enabled, true); assert.equal(f.record().scope, "a".repeat(64));
|
||||
assert.notEqual(f.record().installationId, oldId);
|
||||
});
|
||||
|
||||
test("revoked old credentials do not prevent fresh opt-in after same-server credential rotation", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
f.api.revoke = async () => { throw new Error("old credentials revoked"); };
|
||||
await f.controller.disable();
|
||||
assert.equal(f.record().revocationPending, true);
|
||||
f.options.scope = async () => "c".repeat(64);
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, true); assert.equal(f.record().scope, "c".repeat(64));
|
||||
assert.notEqual(f.record().installationId, oldId);
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
||||
assert.doesNotMatch(JSON.stringify(f.record()), /credentials|provider|opaque|fixture-auth/);
|
||||
});
|
||||
|
||||
test("failed old-authority retries retain intent while other queued authorities are never contacted", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable(); await f.controller.disable();
|
||||
const queue = [{ installationId: randomUUID(), scope: "a".repeat(64) }, { installationId: randomUUID(), scope: "b".repeat(64) }];
|
||||
f.options.write({ ...f.record(), pendingRevocations: queue });
|
||||
const attempted = []; f.api.revoke = async id => { attempted.push(id); throw new Error("offline"); };
|
||||
await f.controller.refresh();
|
||||
assert.deepEqual(attempted, [queue[0].installationId]); assert.deepEqual(f.record().pendingRevocations, queue);
|
||||
await f.controller.enable();
|
||||
assert.equal(f.record().enabled, true); assert.deepEqual(f.record().pendingRevocations, queue);
|
||||
});
|
||||
|
||||
test("bounded revocation queue never silently evicts cleanup and gives actionable opt-in recovery", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable(); await f.controller.disable();
|
||||
const queue = Array.from({ length: MAX_AGENT_PUSH_REVOCATIONS }, () => ({ installationId: randomUUID(), scope: "b".repeat(64) }));
|
||||
f.options.write({ ...f.record(), pendingRevocations: queue });
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.match(f.controller.state().message, /Sign in to a previous server/);
|
||||
const count = f.calls.filter(([name]) => name === "register").length;
|
||||
await f.controller.enable();
|
||||
assert.deepEqual(f.record().pendingRevocations, queue); assert.equal(f.calls.filter(([name]) => name === "register").length, count);
|
||||
assert.ok(parseAgentPushConsent(JSON.stringify(f.record())));
|
||||
assert.equal(parseAgentPushConsent(JSON.stringify({ ...f.record(), pendingRevocations: [...queue, queue[0]] })), null);
|
||||
assert.equal(parseAgentPushConsent(JSON.stringify({ ...f.record(), pendingRevocations: [{ installationId: queue[0].installationId, scope: "raw-token" }] })), null);
|
||||
f.options.scope = async () => "b".repeat(64);
|
||||
await f.controller.refresh();
|
||||
assert.deepEqual(f.record().pendingRevocations, []); assert.equal(f.controller.state().canEnable, true);
|
||||
});
|
||||
|
||||
test("in-flight cleanup cannot overwrite a newer tab's consent revision", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const entered = deferred(), release = deferred();
|
||||
let reads = 0;
|
||||
f.options.scope = async () => {
|
||||
if (++reads === 2) { entered.resolve(); await release.promise; }
|
||||
return "a".repeat(64);
|
||||
};
|
||||
const disabling = f.controller.disable(); await entered.promise;
|
||||
const newer = { ...f.record(), installationId: randomUUID(), revision: randomUUID(), enabled: true, revocationPending: false };
|
||||
f.options.write(newer); release.resolve(); await disabling;
|
||||
assert.deepEqual(f.record(), newer);
|
||||
});
|
||||
|
||||
test("principal scope changing while permission is pending prevents registration", async () => {
|
||||
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable();
|
||||
f.options.scope = async () => "b".repeat(64);
|
||||
prompt.resolve("granted"); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
||||
});
|
||||
|
||||
test("authority changing during config fetch cannot enable against a mixed identity snapshot", async () => {
|
||||
const f = fixture({ api: { config: async () => { f.options.scope = async () => "b".repeat(64); return configuration(); } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.equal(f.record(), null); assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test("same backend/token with a replaced source invalidates persisted consent on reload and requires a new opt-in", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const old = structuredClone(f.record());
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId });
|
||||
const reloaded = createAgentPushController(f.options);
|
||||
await reloaded.refresh(); await reloaded.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, 1, "source replacement cannot reuse old consent");
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === old.installationId));
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
await reloaded.enable();
|
||||
assert.equal(f.record().enabled, true); assert.equal(f.record().sourceId, replacementSourceId);
|
||||
assert.equal(f.record().scope, old.scope); assert.notEqual(f.record().installationId, old.installationId);
|
||||
assert.equal(f.calls.filter(([name]) => name === "permission").length, 2, "replacement requires another explicit permission gesture");
|
||||
});
|
||||
|
||||
test("null or invalid source prevents new opt-in even when transport is configured", async () => {
|
||||
for (const unknown of [null, undefined, "not-a-source-uuid"]) {
|
||||
const f = fixture(); f.setConfig({ ...configuration(), sourceId: unknown });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.equal(f.record(), null);
|
||||
assert.deepEqual(f.calls, [], "unknown source must not request permission, subscribe or register");
|
||||
}
|
||||
});
|
||||
|
||||
test("losing a known source retires old consent and never resumes it automatically", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
f.setConfig({ ...configuration(), sourceId: null });
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.controller.state().canEnable, false);
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
f.setConfig(configuration()); await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
||||
});
|
||||
|
||||
test("legacy or malformed source bindings retain installation identity for retirement, not re-enrollment", async () => {
|
||||
for (const invalid of [undefined, null, "invalid-source"]) {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
const stored = parseAgentPushConsent(JSON.stringify({ ...f.record(), sourceId: invalid }));
|
||||
assert.equal(stored.installationId, oldId); assert.equal(stored.sourceId, undefined);
|
||||
f.options.write(stored);
|
||||
await createAgentPushController(f.options).refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
}
|
||||
});
|
||||
|
||||
test("source change while permission is pending prevents subscription and retires the incomplete intent", async () => {
|
||||
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId });
|
||||
f.setPermission("granted"); prompt.resolve("granted"); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
||||
assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
});
|
||||
|
||||
test("source change while subscribe is pending unsubscribes without registering", async () => {
|
||||
const entered = deferred(), release = deferred();
|
||||
const f = fixture({ platform: { subscribe: async () => {
|
||||
entered.resolve(); await release.promise;
|
||||
const subscription = f.makeSubscription(); f.setSubscription(subscription); return subscription;
|
||||
} } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable(); await entered.promise;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 0);
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
});
|
||||
|
||||
test("source change during initial registration rolls back instead of persisting enabled consent", async () => {
|
||||
const entered = deferred(), release = deferred(), registered = [], retired = [];
|
||||
const f = fixture({ api: {
|
||||
register: async id => { registered.push(id); entered.resolve(); await release.promise; },
|
||||
revoke: async id => { retired.push(id); },
|
||||
} });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable(); await entered.promise;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
||||
assert.deepEqual(retired, registered); assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
});
|
||||
|
||||
test("source change during automatic rotation registration retires the source-bound installation", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId, entered = deferred(), release = deferred();
|
||||
f.setSubscription(f.makeSubscription("https://push.example.test/rotated"));
|
||||
f.api.register = async id => { assert.equal(id, oldId); entered.resolve(); await release.promise; };
|
||||
const refreshing = f.controller.refresh(); await entered.promise;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await refreshing;
|
||||
assert.equal(f.record().enabled, false); assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
});
|
||||
|
||||
test("source verification failure after registration fails closed and rolls back", async () => {
|
||||
const f = fixture({ api: { register: async () => { f.api.config = async () => { throw new Error("source unavailable"); }; } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.ok(f.calls.some(([name]) => name === "revoke"));
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
});
|
||||
|
||||
test("attention is per-tab, ordered, focused while idle, and recent input persists until acknowledged", async () => {
|
||||
const rows = [], failure = deferred(); let focus = true;
|
||||
const a = createAgentPresenceReporter({ snapshot: () => ({ visibility: "visible", focused: focus }), send: async value => { rows.push(["a", value]); } });
|
||||
const b = createAgentPresenceReporter({ snapshot: () => ({ visibility: "hidden", focused: true }), send: async value => { rows.push(["b", value]); } });
|
||||
await a.report(); await b.report();
|
||||
assert.equal(rows[0][1].focused, true); assert.equal(rows[0][1].interacted, false);
|
||||
assert.equal(rows[1][1].focused, false); assert.equal(rows[1][1].sequence, 1);
|
||||
a.interact(); focus = false; await a.report(); await a.report();
|
||||
assert.equal(rows[2][1].interacted, true); assert.equal(rows[2][1].focused, false);
|
||||
assert.equal(rows[3][1].interacted, false); assert.equal(rows[3][1].sequence, 3);
|
||||
let attempts = 0;
|
||||
const c = createAgentPresenceReporter({ snapshot: () => ({ visibility: "visible", focused: false }), send: async value => {
|
||||
assert.equal(value.interacted, true); if (++attempts === 1) return failure.promise;
|
||||
} });
|
||||
c.interact(); const report = c.report(); failure.reject(new Error("offline")); await assert.rejects(report); await c.report();
|
||||
const id = randomUUID(), frame = JSON.parse(agentAuthFrame("fixture-token", "2", id));
|
||||
assert.equal(frame.clientKind, "web"); assert.equal(frame.presenceSessionId, id);
|
||||
assert.equal(JSON.parse(agentAuthFrame(null, "0", "not-an-id")).clientKind, undefined);
|
||||
});
|
||||
|
||||
test("read frames validate source/cursor and preserve existing read semantics", () => {
|
||||
const event = parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "9", updatedAt: null, sourceId }));
|
||||
assert.equal(applyAgentEvent(createAgentRoomState(), event).readCursor, "9");
|
||||
for (const cursor of ["-1", "1e5", "9007199254740992", "1".repeat(200)]) {
|
||||
assert.equal(agentPushCursor(cursor), false);
|
||||
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor, updatedAt: null, sourceId })));
|
||||
}
|
||||
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "1", updatedAt: null, sourceId: "https://evil.test" })));
|
||||
assert.equal(parseAgentPushConsent('{"enabled":true}'), null);
|
||||
assert.throws(() => parseAgentPushConfig({ ...configuration(), policy: { presenceHeartbeatMs: 1, presenceLeaseMs: 60000 } }));
|
||||
});
|
||||
|
||||
test("API keeps credentials in headers, validates receipts, handles 204 and redacts provider bodies", async () => {
|
||||
const id = randomUUID(), requests = []; let mode = "config";
|
||||
const api = new AgentPushClient({ baseUrl: "/api", token: () => "fixture-bearer", fetch: async (url, init) => {
|
||||
requests.push({ url, init });
|
||||
if (mode === "revoke") return new Response(null, { status: 204 });
|
||||
if (mode === "fail") return new Response(JSON.stringify({ error: "private-token-and-endpoint" }), { status: 500 });
|
||||
if (mode === "register") return Response.json({ installationId: id, transport: "web", enabled: true, version: 1 });
|
||||
if (mode === "read") return Response.json({ cursor: "0008", sourceId });
|
||||
return Response.json(configuration());
|
||||
} });
|
||||
await api.config(); mode = "register"; await api.register(id, { endpoint: "https://provider.test/endpoint-secret" });
|
||||
mode = "read"; assert.deepEqual(await api.readState(), { sourceId, cursor: "8" });
|
||||
mode = "revoke"; await api.revoke(id);
|
||||
mode = "fail"; await assert.rejects(api.revoke(id), error => !/private-token/.test(error.message));
|
||||
for (const request of requests) {
|
||||
assert.equal(request.init.headers.Authorization, "Bearer fixture-bearer");
|
||||
assert.equal(request.init.cache, "no-store"); assert.equal(request.init.redirect, "error");
|
||||
assert.doesNotMatch(request.url, /fixture-bearer|endpoint-secret/);
|
||||
}
|
||||
await assert.rejects(api.revoke("../../bad"));
|
||||
const unauthenticated = new AgentPushClient({ baseUrl: "/api", token: () => null, fetch: () => { throw new Error("must not send"); } });
|
||||
await assert.rejects(unauthenticated.config(), /Sign in/);
|
||||
});
|
||||
|
||||
test("authority-guarded API refuses mismatched credentials and pins the captured bearer across async hashing", async () => {
|
||||
const id = randomUUID(), requests = [], baseUrl = "https://sybil.example.test/api/";
|
||||
const scope = await agentPushAuthorityScope(baseUrl, "original-fixture-token");
|
||||
assert.equal(scope, await agentPushAuthorityScope("/api", "original-fixture-token", "https://sybil.example.test/"));
|
||||
assert.match(scope, /^[a-f0-9]{64}$/); assert.equal(await agentPushAuthorityScope(baseUrl, null), null);
|
||||
let token = "rotated-fixture-token";
|
||||
const api = new AgentPushClient({ baseUrl, token: () => token, fetch: async (url, init) => {
|
||||
requests.push({ url, init });
|
||||
return init.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json({ installationId: id, transport: "web", enabled: true, version: 1 });
|
||||
} });
|
||||
await assert.rejects(api.revoke(id, scope));
|
||||
await assert.rejects(api.register(id, { endpoint: "https://provider.test/fixture" }, scope));
|
||||
assert.deepEqual(requests, [], "wrong authority cannot send a mutation or falsely acknowledge cleanup");
|
||||
token = "original-fixture-token";
|
||||
const deleting = api.revoke(id, scope); token = "rotated-fixture-token"; await deleting;
|
||||
token = "original-fixture-token";
|
||||
const registering = api.register(id, { endpoint: "https://provider.test/fixture" }, scope); token = "rotated-fixture-token"; await registering;
|
||||
assert.equal(requests.length, 2);
|
||||
assert.ok(requests.every(({ init }) => init.headers.Authorization === "Bearer original-fixture-token"));
|
||||
assert.ok(requests.every(({ url }) => !url.includes("fixture-token")));
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadWriter,
|
||||
AgentRoomClient, AgentReadSourceChangedError, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadSession, createAgentReadWriter,
|
||||
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
|
||||
} from "../src/lib/agent-room.ts";
|
||||
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
||||
@@ -16,6 +16,9 @@ const page = (cursor, messages = [], hasMore = false) => ({
|
||||
conversationId: "1", messages, cursor, beforeCursor: messages.length ? String(messages[0].metadata.entryId) : cursor, hasMore,
|
||||
});
|
||||
const activity = (queuedMessages = []) => ({ state: "idle", isThinking: false, queued: queuedMessages.length, backgroundWorkers: 0, tools: [], queuedMessages });
|
||||
const sourceA = "4599d348-e053-4937-987d-1392746fb2ce", sourceB = "22222222-2222-4222-8222-222222222222";
|
||||
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
||||
const sourceRoom = () => applyAgentPage(applyAgentReadState(createAgentRoomState(), { sourceId: sourceA, cursor: "90", updatedAt: null }), page("100", [message(100)]));
|
||||
|
||||
test("tool starts and results merge across replay without downgrading completed cards", () => {
|
||||
const start = message(2, "tool", { id: "tool:1:call1", metadata: { kind: "tool_call", entryId: 2, status: "initiated", args: { query: "cookie" }, startedAt: "2026-10-14T10:00:00Z" } });
|
||||
@@ -113,6 +116,93 @@ test("read acknowledgments coalesce concurrent seen snapshots and reject unackno
|
||||
await assert.rejects(broken.request("2"), /did not acknowledge/);
|
||||
});
|
||||
|
||||
test("source replacement through GET or WS quiesces queued old-history reads without relabeling their cursor", async () => {
|
||||
for (const via of ["GET", "WS"]) {
|
||||
let room = sourceRoom(); const saved = [], invalidated = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); throw new Error("temporarily offline"); },
|
||||
apply: read => { room = applyAgentReadState(room, read); }, invalidated: error => invalidated.push(error),
|
||||
});
|
||||
await assert.rejects(session.request("100", sourceA), /temporarily offline/);
|
||||
const replacement = { cursor: "0", sourceId: sourceB, updatedAt: null };
|
||||
const incoming = via === "WS" ? parseAgentRoomEvent(JSON.stringify({ type: "read_state", ...replacement })) : replacement;
|
||||
assert.throws(() => session.receive(incoming), AgentReadSourceChangedError);
|
||||
for (let i = 0; i < 3; i++) await session.flush();
|
||||
await assert.rejects(session.request("100", sourceA), AgentReadSourceChangedError);
|
||||
await assert.rejects(session.request("5", sourceB), AgentReadSourceChangedError);
|
||||
assert.deepEqual(saved, [{ cursor: "100", sourceId: sourceA }], `${via}: no write to B and no retry loop`);
|
||||
assert.equal(room.sourceId, sourceA); assert.equal(room.readCursor, "90"); assert.equal(room.cursor, "100");
|
||||
assert.equal(session.sourceId(), sourceA); assert.equal(invalidated.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("source replacement cancels an in-flight old read and prevents a coalesced larger write", async () => {
|
||||
let room = sourceRoom(); const gate = deferred(), saved = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); return gate.promise; },
|
||||
apply: read => { room = applyAgentReadState(room, read); },
|
||||
});
|
||||
const first = session.request("100", sourceA), second = session.request("110", sourceA);
|
||||
assert.throws(() => session.receive({ cursor: "0", sourceId: sourceB, updatedAt: null }), AgentReadSourceChangedError);
|
||||
gate.resolve({ cursor: "100", sourceId: sourceA, updatedAt: null });
|
||||
await Promise.all([first, second]); await session.flush();
|
||||
assert.deepEqual(saved, [{ cursor: "100", sourceId: sourceA }]);
|
||||
assert.equal(room.sourceId, sourceA); assert.equal(room.readCursor, "90");
|
||||
});
|
||||
|
||||
test("mismatched acknowledgment and server409 stop retries without applying a read to new history", async () => {
|
||||
for (const mode of ["ack", "409"]) {
|
||||
let room = sourceRoom(), saves = 0, invalidated = 0;
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async () => { saves++; if (mode === "409") throw Object.assign(new Error("cursor not valid for this source"), { status: 409 });
|
||||
return { cursor: "100", sourceId: sourceB, updatedAt: null }; },
|
||||
apply: read => { room = applyAgentReadState(room, read); }, invalidated: () => invalidated++,
|
||||
});
|
||||
await assert.rejects(session.request("100", sourceA), AgentReadSourceChangedError);
|
||||
await session.flush(); await session.flush();
|
||||
await assert.rejects(session.request("101", sourceA), AgentReadSourceChangedError);
|
||||
assert.equal(saves, 1); assert.equal(invalidated, 1); assert.equal(room.readCursor, "90"); assert.equal(room.sourceId, sourceA);
|
||||
}
|
||||
});
|
||||
|
||||
test("read writer stops after an ignored acknowledgment instead of repeating the same PUT forever", async () => {
|
||||
let saves = 0;
|
||||
const writer = createAgentReadWriter({ current: () => "0", save: async cursor => { saves++; return { cursor, updatedAt: null }; }, apply: () => {} });
|
||||
await assert.rejects(writer.request("2"), /did not acknowledge/);
|
||||
await writer.flush(); await writer.request("3"); assert.equal(saves, 1);
|
||||
});
|
||||
|
||||
test("legacy source-absent sessions stay compatible but cannot adopt a source after rendering old history", async () => {
|
||||
let room = applyAgentPage(createAgentRoomState(), page("5", [message(5)])); const saved = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); return { cursor, updatedAt: null }; },
|
||||
apply: read => { room = applyAgentReadState(room, read); },
|
||||
});
|
||||
session.receive({ cursor: "0", updatedAt: null });
|
||||
assert.equal(room.sourceId, null);
|
||||
await session.request("5", room.sourceId);
|
||||
assert.deepEqual(saved, [{ cursor: "5", sourceId: null }]); assert.equal(room.readCursor, "5");
|
||||
assert.throws(() => session.receive({ cursor: "0", sourceId: sourceB, updatedAt: null }), AgentReadSourceChangedError);
|
||||
await session.flush(); assert.equal(saved.length, 1);
|
||||
assert.throws(() => applyAgentReadState(applyAgentPage(createAgentRoomState(), page("5", [message(5)])), { cursor: "0", sourceId: sourceA, updatedAt: null }), AgentReadSourceChangedError);
|
||||
assert.throws(() => applyAgentReadState(sourceRoom(), { cursor: "0", updatedAt: null }), AgentReadSourceChangedError);
|
||||
});
|
||||
|
||||
test("rendered snapshot source must match the pinned session and new sessions cannot read before binding", async () => {
|
||||
for (const initial of [createAgentRoomState(), sourceRoom()]) {
|
||||
const session = createAgentReadSession({ initial, save: async () => assert.fail("must not send unbound or relabeled reads"), apply: () => {} });
|
||||
await assert.rejects(session.request("100", sourceB), AgentReadSourceChangedError); await session.flush();
|
||||
}
|
||||
let room = createAgentRoomState(); const saved = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); return { cursor, sourceId, updatedAt: null }; },
|
||||
apply: read => { room = applyAgentReadState(room, read); },
|
||||
});
|
||||
session.receive({ cursor: "0", sourceId: sourceA, updatedAt: null });
|
||||
room = applyAgentPage(room, page("5", [message(5)])); await session.request(room.cursor, room.sourceId);
|
||||
assert.deepEqual(saved, [{ cursor: "5", sourceId: sourceA }]); assert.equal(room.readCursor, "5");
|
||||
});
|
||||
|
||||
test("failed persistence after acknowledgment retains the original receipt key across reload", async () => {
|
||||
let stored = [], writes = 0;
|
||||
const storage = { read: () => structuredClone(stored), write: rows => {
|
||||
@@ -168,6 +258,18 @@ test("client uses Sybil auth and routes while websocket credentials remain in th
|
||||
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42" });
|
||||
});
|
||||
|
||||
test("client sends the pinned source and exposes409 without requiring a JSON response body", async () => {
|
||||
for (const json of [true, false]) {
|
||||
let body;
|
||||
const client = new AgentRoomClient({ fetch: async (_url, init) => {
|
||||
body = JSON.parse(init.body);
|
||||
return json ? Response.json({ message: "Read source changed" }, { status: 409 }) : new Response(null, { status: 409 });
|
||||
} });
|
||||
await assert.rejects(client.markRead("100", undefined, sourceA), error => error.status === 409);
|
||||
assert.deepEqual(body, { cursor: "100", sourceId: sourceA });
|
||||
}
|
||||
});
|
||||
|
||||
test("malformed successful receipts keep optimistic messages retryable under the original request ID", async () => {
|
||||
const requests = [];
|
||||
let response = {};
|
||||
|
||||
Reference in New Issue
Block a user