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
+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 } : {}),
});