feat(agent): preview public replies in push notifications
TestFlight / Build and upload (push) Successful in 1m53s
TestFlight / Build and upload (push) Successful in 1m53s
This commit is contained in:
Generated
+13
@@ -19,6 +19,7 @@
|
||||
"exa-js": "^2.4.0",
|
||||
"fastify": "^5.7.2",
|
||||
"html-to-text": "^9.0.5",
|
||||
"marked": "17.0.2",
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
@@ -1741,6 +1742,18 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.2.tgz",
|
||||
"integrity": "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"exa-js": "^2.4.0",
|
||||
"fastify": "^5.7.2",
|
||||
"html-to-text": "^9.0.5",
|
||||
"marked": "17.0.2",
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { convert } from 'html-to-text';
|
||||
import { marked } from 'marked';
|
||||
import { z } from 'zod';
|
||||
import { Cursor, parseSafe } from './agent-push-contract.js';
|
||||
|
||||
export const MAX_PREVIEW_BYTES = 3000;
|
||||
export const APNS_PAYLOAD_BYTES = 4096;
|
||||
// RFC 8291: 4096 bytes less the 86-byte header, delimiter and 16-byte tag.
|
||||
export const WEB_PUSH_PAYLOAD_BYTES = 3993;
|
||||
const FALLBACK = 'You have a new message.';
|
||||
export type PublicMessage = { conversationId: string; messageId: string; cursor: string };
|
||||
export type MessageText = (message: PublicMessage) => Promise<string>;
|
||||
|
||||
const Page = z.object({ conversationId: z.string(), cursor: Cursor, beforeCursor: Cursor,
|
||||
messages: z.array(z.unknown()).max(1000) });
|
||||
const Message = z.object({ id: z.string(), role: z.literal('assistant'), content: z.string(),
|
||||
metadata: z.object({ kind: z.literal('message'), status: z.literal('complete'), entryId: z.number().int().safe() }) });
|
||||
|
||||
/** Resolve only an already eligible candidate from one immutable root public entry. */
|
||||
export function createMessageText(upstream: { baseUrl: string; apiKey?: string }): MessageText {
|
||||
return async candidate => {
|
||||
if (!upstream.apiKey || !Cursor.safeParse(candidate.cursor).success || Number(candidate.cursor) < 1
|
||||
|| !(candidate.messageId === `outbound:${candidate.cursor}` || new RegExp(`^entry:${candidate.cursor}:\\d+$`).test(candidate.messageId))) {
|
||||
throw new Error('Public Agent message unavailable');
|
||||
}
|
||||
const url = new URL('/api/conversations/root/messages', upstream.baseUrl);
|
||||
url.searchParams.set('after', String(Number(candidate.cursor) - 1));
|
||||
url.searchParams.set('limit', '1'); url.searchParams.set('includeTools', 'false');
|
||||
const response = await fetch(url, { headers: { Authorization: `Bearer ${upstream.apiKey}`, 'X-Sybil-Client': 'backend-proxy-v1' },
|
||||
signal: AbortSignal.timeout(10_000), redirect: 'error' });
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error('Public Agent message unavailable'); }
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Public Agent message unavailable');
|
||||
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('Public Agent message unavailable');
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { await reader.cancel(); }
|
||||
const page = parseSafe(Page, JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
if (page.conversationId !== candidate.conversationId || page.cursor !== candidate.cursor || page.beforeCursor !== candidate.cursor) {
|
||||
throw new Error('Public Agent message unavailable');
|
||||
}
|
||||
const matches = page.messages.filter(value => value && typeof value === 'object' && 'id' in value && value.id === candidate.messageId);
|
||||
const message = matches.length === 1 ? Message.safeParse(matches[0]) : undefined;
|
||||
if (!message?.success || message.data.metadata.entryId !== Number(candidate.cursor)) throw new Error('Public Agent message unavailable');
|
||||
// Never inspect metadata, tool results, activity, reasoning blocks or linked resources for text.
|
||||
return message.data.content;
|
||||
};
|
||||
}
|
||||
|
||||
export function previewText(message: string): string {
|
||||
const html = marked.parse(message, { async: false });
|
||||
const text = convert(html, { wordwrap: false,
|
||||
selectors: [
|
||||
{ selector: 'a', options: { ignoreHref: true } },
|
||||
{ selector: 'img', format: 'imageAlt' },
|
||||
...['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map(selector => ({ selector, options: { uppercase: false } })),
|
||||
{ selector: 'hr', format: 'skip' },
|
||||
],
|
||||
formatters: { imageAlt: (element: { attribs: { alt?: string } }, _walk: unknown, builder: { addInline: (text: string) => void }) => builder.addInline(element.attribs.alt || '') },
|
||||
});
|
||||
// A string body for native OS display; normalize whitespace/control characters and malformed UTF-16.
|
||||
return Buffer.from(text).toString('utf8').replace(/[\u0000-\u001f\u007f-\u009f\s]+/gu, ' ').trim();
|
||||
}
|
||||
|
||||
/** Budget the complete serialized UTF-8 payload, including JSON escaping and identity overhead. */
|
||||
export function previewPayload(message: string | undefined, payload: (body: string) => unknown, maxBytes: number): Buffer {
|
||||
const text = (message === undefined ? '' : previewText(message)) || FALLBACK;
|
||||
const budget = Math.min(MAX_PREVIEW_BYTES, maxBytes - Buffer.byteLength(JSON.stringify(payload(''))));
|
||||
if (budget < 3) throw new Error('Agent push identity exceeds payload budget');
|
||||
let body = text;
|
||||
if (Buffer.byteLength(JSON.stringify(text)) - 2 > budget) {
|
||||
body = ''; let bytes = 0;
|
||||
for (const character of text) {
|
||||
const size = Buffer.byteLength(JSON.stringify(character)) - 2;
|
||||
if (bytes + size > budget - 3) break;
|
||||
body += character; bytes += size;
|
||||
}
|
||||
body = body.trimEnd() + '…';
|
||||
}
|
||||
const result = Buffer.from(JSON.stringify(payload(body)));
|
||||
if (result.length > maxBytes) throw new Error('Agent push exceeds payload budget');
|
||||
return result;
|
||||
}
|
||||
@@ -6,12 +6,13 @@ 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';
|
||||
import { APNS_PAYLOAD_BYTES, previewPayload, WEB_PUSH_PAYLOAD_BYTES } from './agent-push-preview.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>;
|
||||
export type PushSend = (registration: Registration, identity: Identity, expiresAt: number, now: number, messageText?: string) => Promise<SendResult>;
|
||||
|
||||
function boundedRetry(value?: string): number | undefined {
|
||||
if (!value || !/^\d+$/.test(value)) return undefined;
|
||||
@@ -104,7 +105,7 @@ 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) => {
|
||||
return async (registration, identity, expiresAt, now, messageText) => {
|
||||
if (!config.enabled || !config.adminToken || expiresAt <= now) return { outcome: 'failed' };
|
||||
try {
|
||||
if (registration.transport === 'apns') {
|
||||
@@ -117,7 +118,8 @@ export function createPushSender(config: PushConfig, adapters: { apns?: RequestT
|
||||
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 body = clear ? Buffer.from(JSON.stringify({ aps: { 'content-available': 1 }, ...identity }))
|
||||
: previewPayload(messageText, body => ({ aps: { alert: { title: 'Sybil Agent', body }, sound: 'default', 'thread-id': 'sybil-agent-root' }, ...identity }), APNS_PAYLOAD_BYTES);
|
||||
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',
|
||||
@@ -130,10 +132,11 @@ export function createPushSender(config: PushConfig, adapters: { apns?: RequestT
|
||||
// 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 payload = previewPayload(messageText, body => ({ ...identity, title: 'Sybil Agent', body, url: '/?agent=root', tag: `sybil-agent:${identity.sourceId}` }), WEB_PUSH_PAYLOAD_BYTES);
|
||||
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) });
|
||||
if (!details.body || details.body.length > 4096) return { outcome: 'failed' };
|
||||
const response = await (adapters.web || webRequest)({ endpoint: details.endpoint, headers: details.headers, body: details.body! });
|
||||
return classify(response, false);
|
||||
} catch {
|
||||
|
||||
@@ -8,10 +8,11 @@ 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';
|
||||
import { createMessageText, type MessageText } from './agent-push-preview.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;
|
||||
type Options = { db: PrismaClient; config: PushConfig; context: (after?: string, sourceId?: string) => Promise<Context>; messageText: MessageText; send: PushSend; now?: () => number;
|
||||
broadcast?: (state: ReadState) => void; warmupMs?: number };
|
||||
const date = (ms: number) => new Date(ms);
|
||||
const activeStatuses = ['pending', 'intent'];
|
||||
@@ -28,6 +29,7 @@ export class AgentPushCoordinator {
|
||||
private running = false;
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
private stopped = false;
|
||||
private observedRoot?: { sourceId: string; conversationId: string };
|
||||
constructor(readonly options: Options) {
|
||||
this.epoch = ownerEpoch(options.config.adminToken || 'disabled');
|
||||
this.now = options.now || Date.now;
|
||||
@@ -261,6 +263,7 @@ export class AgentPushCoordinator {
|
||||
return changedRead;
|
||||
});
|
||||
if (read) this.options.broadcast?.(read);
|
||||
this.observedRoot = { sourceId: context.sourceId, conversationId: context.conversationId };
|
||||
return context.hasMore;
|
||||
}
|
||||
private async browserSuppression(tx: Tx, now: number): Promise<Suppression | undefined> {
|
||||
@@ -278,8 +281,8 @@ export class AgentPushCoordinator {
|
||||
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace',
|
||||
clientKind: unobserved[0]?.kind === 'web' ? 'web' : firstUntracked?.kind || 'unknown' };
|
||||
}
|
||||
private async dispatch(id: string) {
|
||||
const claim = await this.options.db.$transaction(async tx => {
|
||||
private async claim(id: string, commit: boolean) {
|
||||
return 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 } });
|
||||
@@ -313,17 +316,31 @@ export class AgentPushCoordinator {
|
||||
await tx.agentPushInstallation.update({ where: { id: installation.id }, data: { enabled: false } });
|
||||
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'expired_subscription' } }); return;
|
||||
}
|
||||
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 };
|
||||
if (!commit) return { row, registration, identity, now };
|
||||
// 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 };
|
||||
});
|
||||
}
|
||||
private async dispatch(id: string) {
|
||||
// Only resolve text for an otherwise dispatchable candidate, outside the DB transaction.
|
||||
// Recheck source/read/presence and every claim condition after the bounded public lookup.
|
||||
const ready = await this.claim(id, false);
|
||||
if (!ready) return;
|
||||
let messageText: string | undefined;
|
||||
if (ready.identity.kind === 'message') {
|
||||
if (this.observedRoot?.sourceId !== ready.identity.sourceId) throw new Error('Public Agent source unavailable');
|
||||
messageText = await this.options.messageText({ ...ready.identity, conversationId: this.observedRoot.conversationId });
|
||||
if (await this.observe()) return;
|
||||
}
|
||||
const claim = await this.claim(id, true);
|
||||
if (!claim) return;
|
||||
let result;
|
||||
try { result = await this.options.send(claim.registration, claim.identity, claim.row.expiresAt.getTime(), claim.now); }
|
||||
try { result = await this.options.send(claim.registration, claim.identity, claim.row.expiresAt.getTime(), claim.now, messageText); }
|
||||
catch { result = { outcome: 'ambiguous' as const }; }
|
||||
await this.options.db.$transaction(async tx => {
|
||||
const pending = result.outcome === 'retry' && claim.row.attempts + 1 < MAX_ATTEMPTS;
|
||||
@@ -398,7 +415,7 @@ export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient,
|
||||
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 });
|
||||
}, messageText: createMessageText(upstream), 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 };
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createECDH, generateKeyPairSync, randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import { createRequire } from 'node:module';
|
||||
import test from 'node:test';
|
||||
import webpush from 'web-push';
|
||||
import { createMessageText, MAX_PREVIEW_BYTES, previewPayload, previewText, WEB_PUSH_PAYLOAD_BYTES } from '../src/agent-push-preview.js';
|
||||
import { createPushSender, type ProviderRequest } from '../src/agent-push-transports.js';
|
||||
|
||||
const { decrypt } = createRequire(import.meta.url)('http_ece');
|
||||
|
||||
test('preview lookup uses only the exact eligible completed root assistant content on the fixed public endpoint', async t => {
|
||||
const requests: string[] = [];
|
||||
const message = { id: 'outbound:11', role: 'assistant', content: 'Your **public answer** includes the requested passphrase: sky-blue.',
|
||||
metadata: { kind: 'message', status: 'complete', entryId: 11, reasoning: 'private reasoning', resultPreview: 'tool secret', output: 'worker output' } };
|
||||
const original = { conversationId: '1', cursor: '11', beforeCursor: '11', messages: [message] };
|
||||
let page: unknown = original, status = 200;
|
||||
const server = createServer((req, res) => {
|
||||
requests.push(req.url!);
|
||||
assert.equal(req.headers.authorization, 'Bearer fixture-key');
|
||||
assert.equal(req.headers['x-sybil-client'], 'backend-proxy-v1');
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', Location: 'http://never-fetch.invalid/' });
|
||||
res.end(JSON.stringify(page));
|
||||
});
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
t.after(async () => { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); });
|
||||
const load = createMessageText({ baseUrl: `http://127.0.0.1:${(server.address() as { port: number }).port}`, apiKey: 'fixture-key' });
|
||||
const candidate = { conversationId: '1', cursor: '11', messageId: 'outbound:11' };
|
||||
assert.equal(await load(candidate), message.content, 'explicit public assistant text is authorized without guessing/redacting secrets');
|
||||
assert.deepEqual(requests, ['/api/conversations/root/messages?after=10&limit=1&includeTools=false']);
|
||||
page = { ...original, messages: [{ ...message, id: 'entry:11:0' }] };
|
||||
assert.equal(await load({ ...candidate, messageId: 'entry:11:0' }), message.content);
|
||||
for (const invalid of [
|
||||
{ ...original, conversationId: 'worker-conversation' }, { ...original, cursor: '12' }, { ...original, beforeCursor: '10' },
|
||||
{ ...original, messages: [] }, { ...original, messages: [message, message] },
|
||||
...['tool', 'user', 'system', 'reasoning'].map(role => ({ ...original, messages: [{ ...message, role }] })),
|
||||
...[{ kind: 'tool_call' }, { kind: 'worker' }, { status: 'initiated' }, { entryId: 12 }].map(metadata => ({ ...original, messages: [{ ...message, metadata: { ...message.metadata, ...metadata } }] })),
|
||||
{ ...original, messages: [{ ...message, content: [{ type: 'reasoning', text: 'private reasoning' }] }] },
|
||||
{ ...original, messages: [{ ...message, id: 'tool:1:11' }] },
|
||||
]) { page = invalid; await assert.rejects(load(candidate)); }
|
||||
page = { ...original, messages: [{ ...message, content: '' }] };
|
||||
assert.equal(await load(candidate), '', 'empty content never borrows metadata, tool output or reasoning');
|
||||
const before = requests.length;
|
||||
for (const messageId of ['tool:1:11', 'outbound:12', 'https://never-fetch.invalid/', 'entry:11:0/../../state']) {
|
||||
await assert.rejects(load({ ...candidate, messageId }));
|
||||
}
|
||||
assert.equal(requests.length, before);
|
||||
page = { ...original, messages: [{ ...message, content: 'x'.repeat(512 * 1024) }] };
|
||||
await assert.rejects(load(candidate));
|
||||
status = 302; page = original; await assert.rejects(load(candidate));
|
||||
status = 503; await assert.rejects(load(candidate));
|
||||
assert.ok(requests.every(url => url === requests[0]), 'no worker/raw-state/event/linked-resource requests or redirects');
|
||||
});
|
||||
|
||||
test('Markdown becomes readable message text without link destinations, image sources or HTML internals', () => {
|
||||
const markdown = '# Status\n\nThe **report** is _ready_ & checked.\n\n- Read [the summary](https://private.invalid/secret)\n- `keep_snake_case()`\n\n> Done.\n\n```ts\nconst answer = 42;\n```\n\n\n<!-- hidden comment -->\n<script>internal script</script>';
|
||||
const result = previewText(markdown);
|
||||
for (const text of ['Status', 'The report is ready & checked.', 'Read the summary', 'keep_snake_case()', 'Done.', 'const answer = 42;', 'Chart']) assert.ok(result.includes(text), result);
|
||||
for (const hidden of ['https://', 'private.invalid', 'secret', 'hidden comment', 'internal script', '**', '```', '# Status']) assert.ok(!result.includes(hidden), result);
|
||||
assert.equal(previewText('Plain text: 日本語 🌧️ — keep_this_path.txt'), 'Plain text: 日本語 🌧️ — keep_this_path.txt');
|
||||
assert.equal(previewText('A\u0000B\n\tC\ud800'), 'A B C�');
|
||||
});
|
||||
|
||||
test('actual APNs and decrypted Web Push bodies carry public text with UTF-8/JSON/encryption bounds', async () => {
|
||||
const apnsKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const receiver = createECDH('prime256v1'); receiver.generateKeys();
|
||||
const auth = Buffer.alloc(16, 1).toString('base64url');
|
||||
const native = { transport: 'apns' as const, enabled: true as const, token: 'a'.repeat(64), environment: 'sandbox' as const, topic: 'net.buzzert.sybil2' };
|
||||
const browser = { transport: 'web' as const, enabled: true as const, subscription: { endpoint: 'https://web.push.apple.com/fixture',
|
||||
keys: { p256dh: receiver.getPublicKey().toString('base64url'), auth } } };
|
||||
const apns: ProviderRequest[] = [], web: ProviderRequest[] = [];
|
||||
const sender = createPushSender({ enabled: true, adminToken: 'fixture',
|
||||
apns: { key: apnsKey.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), keyId: 'ABCDEFGHIJ', teamId: 'DQQH5H6GBD', environment: 'sandbox', topic: native.topic },
|
||||
web: { ...webpush.generateVAPIDKeys(), subject: 'mailto:tests@example.com' } }, {
|
||||
apns: async request => { apns.push(request); return { status: 200 }; }, web: async request => { web.push(request); return { status: 201 }; },
|
||||
});
|
||||
const identity = { version: 1 as const, kind: 'message' as const, sourceId: randomUUID(), messageId: 'outbound:11', cursor: '11' };
|
||||
const now = Date.now();
|
||||
for (const content of ['The **report** is ready. 日本語 🌧️', 'Requested public passphrase: sky-blue.', 'a'.repeat(3000),
|
||||
'a'.repeat(3001), '世界🌧️é'.repeat(2000), '"\\'.repeat(4000), '\ud800'.repeat(4000), ' ']) {
|
||||
assert.equal((await sender(native, identity, now + 60_000, now, content)).outcome, 'accepted');
|
||||
assert.equal((await sender(browser, identity, now + 60_000, now, content)).outcome, 'accepted');
|
||||
const apnsBytes = apns.at(-1)!.body, encrypted = web.at(-1)!.body;
|
||||
const plaintext: Buffer = decrypt(encrypted, { version: 'aes128gcm', privateKey: receiver, authSecret: auth });
|
||||
const apnsPayload = JSON.parse(apnsBytes.toString()), webPayload = JSON.parse(plaintext.toString());
|
||||
const body = apnsPayload.aps.alert.body;
|
||||
assert.equal(typeof body, 'string'); assert.equal(webPayload.body, body);
|
||||
assert.ok(Buffer.byteLength(body) <= MAX_PREVIEW_BYTES);
|
||||
assert.ok(apnsBytes.length <= 4096); assert.ok(plaintext.length <= 3993); assert.ok(encrypted.length <= 4096);
|
||||
assert.equal(encrypted.length, plaintext.length + 103);
|
||||
assert.equal(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(body)), body);
|
||||
for (const [key, value] of Object.entries(identity)) { assert.equal(apnsPayload[key], value); assert.equal(webPayload[key], value); }
|
||||
assert.equal(webPayload.url, '/?agent=root'); assert.equal(webPayload.tag, `sybil-agent:${identity.sourceId}`);
|
||||
const flattened = previewText(content) || 'You have a new message.';
|
||||
if (Buffer.byteLength(JSON.stringify(flattened)) - 2 <= MAX_PREVIEW_BYTES) assert.equal(body, flattened);
|
||||
else { assert.ok(body.endsWith('…')); assert.ok(flattened.startsWith(body.slice(0, -1))); }
|
||||
assert.ok(!encrypted.includes(Buffer.from(body)), 'Web Push plaintext is encrypted');
|
||||
}
|
||||
await sender(native, { version: 1, kind: 'read_state', sourceId: identity.sourceId, readCursor: '11' }, now + 60_000, now, 'never include text in a clear');
|
||||
assert.deepEqual(JSON.parse(apns.at(-1)!.body.toString()).aps, { 'content-available': 1 });
|
||||
assert.ok(!apns.at(-1)!.body.includes('never include'));
|
||||
});
|
||||
|
||||
test('payload budget includes identity overhead and JSON escaping before truncating on a Unicode boundary', () => {
|
||||
const result = previewPayload('界"\\🌧'.repeat(2000), body => ({ messageId: '"'.repeat(1000), body }), WEB_PUSH_PAYLOAD_BYTES);
|
||||
assert.ok(result.length <= WEB_PUSH_PAYLOAD_BYTES);
|
||||
const body = JSON.parse(result.toString()).body;
|
||||
assert.ok(body.endsWith('…')); assert.equal(body.includes('�'), false);
|
||||
assert.throws(() => previewPayload('hello', body => ({ extra: 'x'.repeat(5000), body }), 4096));
|
||||
});
|
||||
@@ -40,12 +40,13 @@ async function fixture(options: { warmupMs?: number; config?: PushConfig } = {})
|
||||
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[] = [];
|
||||
const sent: { registration: Registration; identity: any; messageText?: string }[] = [], 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) });
|
||||
messageText: async () => 'The Agent finished the requested work.',
|
||||
send: async (registration, identity, _expiresAt, _now, messageText) => { sent.push({ registration, identity, messageText }); 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; },
|
||||
@@ -66,10 +67,50 @@ test('first source bootstrap never alerts history; later durable candidates debo
|
||||
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(f.sent[0].messageText, 'The Agent finished the requested work.');
|
||||
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('preview resolves only the coalesced eligible root candidate after attention clears and survives a pending restart', async () => {
|
||||
const f = await fixture(), tab = randomUUID(), lookups: unknown[] = [];
|
||||
f.manager.options.messageText = async candidate => { lookups.push(candidate); return `Public reply ${candidate.messageId}`; };
|
||||
f.candidate('11', 'unknown'); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
assert.equal(lookups.length, 0);
|
||||
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
f.candidate('12', 'worker'); await f.manager.tick(); f.candidate('13', 'timer'); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
assert.equal(lookups.length, 0, 'focused clients do not cause repeated message fetches');
|
||||
await f.manager.retirePresence(tab); f.advance(6_000);
|
||||
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.tick();
|
||||
assert.equal(f.sent.length, 1); assert.equal(f.sent[0].messageText, 'Public reply outbound:13');
|
||||
assert.equal((lookups[0] as any).conversationId, '1'); assert.equal((lookups[0] as any).messageId, 'outbound:13');
|
||||
assert.ok(!JSON.stringify(await prisma.agentPushDelivery.findMany()).includes('Public reply'), 'receipts contain no preview text');
|
||||
});
|
||||
|
||||
for (const change of ['read', 'focus', 'source', 'revoke']) test(`preview lookup cannot bypass ${change} changes before the send intent`, async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
f.manager.options.messageText = async () => {
|
||||
if (change === 'read') f.context({ nativeReadCursor: '11' });
|
||||
if (change === 'source') f.context({ sourceId: randomUUID() });
|
||||
if (change === 'focus') await f.manager.presence(randomUUID(), { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
if (change === 'revoke') await f.manager.revoke(f.installationId);
|
||||
return 'Public text that must not be sent now';
|
||||
};
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).attempts, 0);
|
||||
});
|
||||
|
||||
test('unavailable public text suppresses without claiming a provider send; the next valid lookup sends actual text', async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
f.manager.options.messageText = async () => { throw new Error('private upstream detail'); };
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
const pending = await prisma.agentPushDelivery.findFirstOrThrow();
|
||||
assert.equal(pending.status, 'pending'); assert.equal(pending.attempts, 0);
|
||||
assert.equal(pending.lastSuppressionReason, 'source_or_coordinator_unavailable');
|
||||
f.manager.options.messageText = async () => 'Recovered public reply';
|
||||
await f.manager.tick(); assert.equal(f.sent[0].messageText, 'Recovered public reply');
|
||||
});
|
||||
|
||||
test('all tabs veto while focused; hidden heartbeats and stale sequences cannot extend short transition grace', async () => {
|
||||
const f = await fixture(), tab1 = randomUUID(), tab2 = randomUUID();
|
||||
await f.manager.presence(tab1, { sequence: 1, visibility: 'hidden', focused: false, interacted: false });
|
||||
@@ -243,20 +284,20 @@ test('provider endpoint validation rejects SSRF targets and private/reserved DNS
|
||||
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 () => {
|
||||
test('real APNs ES256 request construction uses message text, 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((await sender(apns, identity, now + 300_000, now, 'Your **report** is ready.')).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.' });
|
||||
assert.deepEqual(JSON.parse(requests[0].body).aps.alert, { title: 'Sybil Agent', body: 'Your report is ready.' });
|
||||
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 });
|
||||
@@ -277,7 +318,7 @@ test('standard encrypted aes128gcm + VAPID Web Push uses library request constru
|
||||
|
||||
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'); } });
|
||||
const manager = new AgentPushCoordinator({ db: prisma, config: production, context: async () => { throw new Error(); }, messageText: 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'); } });
|
||||
|
||||
Reference in New Issue
Block a user