feat(agent): preview public replies in push notifications
TestFlight / Build and upload (push) Successful in 1m53s

This commit is contained in:
2026-10-14 23:36:38 -07:00
parent b9a6f5dd7d
commit 7510603b77
14 changed files with 335 additions and 32 deletions
+11 -5
View File
@@ -17,6 +17,8 @@ self.addEventListener("fetch", (event) => {
// Safari requires a visible notification for each valid message push. Reads are
// reconciled only by foreground page messages, never an invisible Web Push.
const PUSH_TITLE = "Sybil Agent", PUSH_BODY = "You have a new message.", PUSH_URL = "/?agent=root";
const bytes = value => new TextEncoder().encode(value).length;
const preview = value => typeof value === "string" && value.trim().length > 0 && bytes(value) <= 3000;
const sourceId = value => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
const cursor = value => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value));
const messageId = value => typeof value === "string" && /^[A-Za-z0-9:_-]{1,200}$/.test(value);
@@ -67,20 +69,24 @@ async function announce(type) {
self.addEventListener("push", event => {
event.waitUntil(serial(async () => {
let value;
try { const raw = event.data?.text(); if (!raw || raw.length > 4096) return; value = JSON.parse(raw); } catch { return; }
try { const raw = event.data?.text(); if (!raw || bytes(raw) > 3993) return; value = JSON.parse(raw); } catch { return; }
if (!owned(value) || value.url !== PUSH_URL || value.tag !== `sybil-agent:${value.sourceId}`
|| value.title !== PUSH_TITLE || value.body !== PUSH_BODY) return;
|| value.title !== PUSH_TITLE || !preview(value.body)) return;
const previous = await stored(value.sourceId).catch(() => null);
// Re-delivery replaces the same owned tag without regressing its cursor.
// Still show it visibly: local read state is not a reason for an invisible push.
const identity = owned(previous?.latest) && BigInt(previous.latest.cursor) > BigInt(value.cursor) ? previous.latest : {
const stale = owned(previous?.latest) && BigInt(previous.latest.cursor) > BigInt(value.cursor);
const identity = stale ? previous.latest : {
version: 1, kind: "message", sourceId: value.sourceId, messageId: value.messageId, cursor: BigInt(value.cursor).toString(),
};
// Keep the displayed text paired with the retained identity across late pushes/restarts.
// Older workers persisted identity only, so their retained notifications use the fallback.
const body = stale ? preview(previous.latestBody) ? previous.latestBody : PUSH_BODY : value.body;
await self.registration.showNotification(PUSH_TITLE, {
body: PUSH_BODY, tag: `sybil-agent:${identity.sourceId}`, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png",
body, tag: `sybil-agent:${identity.sourceId}`, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png",
renotify: false, data: identity,
});
await save({ ...previous, sourceId: identity.sourceId, latest: identity }).catch(() => undefined);
await save({ ...previous, sourceId: identity.sourceId, latest: identity, latestBody: body }).catch(() => undefined);
await announce("sybil-push-shown");
}));
});
@@ -9,6 +9,7 @@ export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void>
return <details className="shrink-0 border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
<summary className="flex cursor-pointer items-center gap-2 rounded py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"><Bell className="h-4 w-4" />Agent notifications</summary>
<div className="space-y-2 pb-1 pt-2">
<p>Notifications include Agent message text and may appear on your lock screen.</p>
<p role="status">{state.message}</p>
{state.error ? <p role="alert" className="text-rose-300">{state.error}</p> : null}
<div className="flex flex-wrap gap-2">
+1 -1
View File
@@ -135,7 +135,7 @@ export function createAgentPushController(options: {
: cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more."
: consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected."
: !available ? "Notifications are not configured on this server yet."
: enabled ? "Notifications are on for this browser. Alerts use a private, generic preview."
: enabled ? "Notifications are on for this browser."
: "Get an alert for unread Agent replies when all your web sessions are inactive.";
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error };
};
+21 -7
View File
@@ -45,7 +45,7 @@ function fixture(sharedRecords = new Map()) {
getNotifications: async ({ tag } = {}) => notifications.filter(value => !value.closed && (!tag || value.tag === tag)),
},
};
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt,
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt, TextEncoder,
fetch: async request => { fetches.push(request); throw new Error("Network blocked in worker tests"); }, Request });
const emit = async (type, properties = {}) => {
const waits = []; handlers.get(type)?.({ ...properties, waitUntil: value => waits.push(value) }); await Promise.all(waits);
@@ -58,9 +58,9 @@ function fixture(sharedRecords = new Map()) {
};
}
test("actual worker push handler shows fixed generic notification and never fetches credentials", async () => {
const f = fixture(); await f.push(payload());
assert.equal(f.shown.length, 1); assert.equal(f.shown[0].title, "Sybil Agent"); assert.equal(f.shown[0].body, "You have a new message.");
test("actual worker push handler displays supplied public message text and never fetches credentials", async () => {
const f = fixture(); await f.push(payload("5", { body: "Your report is ready. 日本語 🌧️" }));
assert.equal(f.shown.length, 1); assert.equal(f.shown[0].title, "Sybil Agent"); assert.equal(f.shown[0].body, "Your report is ready. 日本語 🌧️");
assert.deepEqual(f.shown[0].data, { version: 1, kind: "message", sourceId, messageId: "outbound:5", cursor: "5" });
assert.equal(f.shown[0].tag, `sybil-agent:${sourceId}`); assert.equal(f.shown[0].renotify, false);
assert.deepEqual(f.fetches, []); assert.ok(!JSON.stringify([...f.records]).includes("token"));
@@ -69,22 +69,36 @@ test("actual worker push handler shows fixed generic notification and never fetc
test("every valid message push remains visible, including read or duplicate deliveries; stale payload never regresses identity", async () => {
const records = new Map(), f = fixture(records);
await f.message({ type: "sybil-agent-read", version: 1, sourceId, cursor: "10" });
await f.push(payload("8")); await f.push(payload("8")); await f.push(payload("5"));
await f.push(payload("8", { body: "Latest reply" })); await f.push(payload("8", { body: "Latest reply" })); await f.push(payload("5", { body: "Older reply" }));
assert.equal(f.shown.length, 3, "Safari visibility requirement is preserved");
assert.ok(f.shown.every(notification => notification.data.cursor === "8"));
assert.ok(f.shown.every(notification => notification.body === "Latest reply"));
const restarted = fixture(records); await restarted.push(payload("6"));
assert.equal(restarted.shown[0].data.cursor, "8");
assert.equal(restarted.shown[0].body, "Latest reply");
assert.equal(records.get(sourceId).readCursor, "10");
});
test("malformed/unexpected payloads cannot supply text, destinations, cross-source tags or silent read pushes", async () => {
test("malformed/unexpected payloads cannot supply invalid bodies, destinations, cross-source tags or silent read pushes", async () => {
const f = fixture();
for (const value of ["not-json", "x".repeat(5000), payload("1", { title: "sensitive text" }), payload("1", { body: "transcript" }),
for (const value of ["not-json", "x".repeat(5000), payload("1", { title: "sensitive text" }), payload("1", { body: { text: "invalid" } }),
payload("1", { body: " " }), payload("1", { body: "界".repeat(1001) }), payload("1", { body: "x".repeat(3001) }),
payload("1", { body: "界".repeat(900), extra: "界".repeat(500) }),
payload("1", { url: "https://evil.example/" }), payload("1", { tag: "some-other-app" }), payload("-1"), payload("9007199254740992"),
payload("1", { sourceId: "../../bad" }), payload("1", { messageId: "<script>" }), { version: 1, kind: "read_state", sourceId, readCursor: "8" }]) await f.push(value);
assert.equal(f.shown.length, 0); assert.equal(f.fetches.length, 0);
});
test("worker accepts bounded text, renders HTML literally and remains compatible with old generic pushes and stored identities", async () => {
const f = fixture();
for (const body of ["界".repeat(1000), "🌧".repeat(750), '<b>public literal text</b>', "You have a new message."]) {
await f.push(payload("9", { body })); assert.equal(f.shown.at(-1).body, body);
}
const records = new Map([[sourceId, { sourceId, latest: payload("10") }]]);
const upgraded = fixture(records); await upgraded.push(payload("8", { body: "Earlier reply" }));
assert.equal(upgraded.shown[0].data.cursor, "10"); assert.equal(upgraded.shown[0].body, "You have a new message.");
});
test("authenticated page read sync closes only owned matching source at or below monotonic read cursor", async () => {
const f = fixture();
const older = f.makeNotification({ tag: `sybil-agent:${sourceId}`, data: payload("4") });