feat(agent): add opt-in push notifications

This commit is contained in:
2026-10-14 22:03:21 -07:00
parent 80748f764f
commit a1c0264d36
51 changed files with 4815 additions and 121 deletions
+148
View File
@@ -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",
+2
View File
@@ -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");
+77
View File
@@ -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])
}
+34
View File
@@ -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;
}
+67
View File
@@ -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');
}
+144
View File
@@ -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' };
}
};
}
+374
View File
@@ -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
View File
@@ -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
View File
@@ -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");
});
+8
View File
@@ -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
View File
@@ -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 } : {}),
});
+11 -2
View File
@@ -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()=>{
+329
View File
@@ -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);
});
+7 -1
View File
@@ -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');