Stop notifications after removing a host (#3176)

* fix(notifications): stop pushes after host removal

Push tokens were persisted indefinitely with no relationship to app pairing state. Give registrations a 48-hour renewable lease so offline unpairing converges, and revoke immediately when the host is reachable.

* fix(notifications): make token revocation bounded and durable
This commit is contained in:
Mohamed Boudra
2026-08-10 23:33:31 +02:00
committed by GitHub
parent 29ae25d7aa
commit 6aa9f92156
33 changed files with 702 additions and 227 deletions
+1 -1
View File
@@ -164,7 +164,7 @@ import { createConfiguredTerminalManager } from "../terminal/terminal-manager-fa
import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js";
import type { PushNotificationSender } from "./push/notifications.js";
import type { PushNotificationSender } from "./push/index.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
@@ -3,7 +3,7 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-
import { DaemonClient } from "./test-utils/daemon-client.js";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { AgentSnapshotPayload } from "./messages.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
import type { PushNotificationSender, PushPayload } from "./push/index.js";
import { PRESENCE_THRESHOLD_MS } from "./agent-attention-policy.js";
class RecordingPushNotificationSender implements PushNotificationSender {
@@ -0,0 +1,67 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type pino from "pino";
import { afterEach, describe, expect, test } from "vitest";
import { createPushNotifications } from "./index.js";
function createLogger(): pino.Logger {
const logger = {
child: () => logger,
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return logger as unknown as pino.Logger;
}
describe("push notifications", () => {
const homes: string[] = [];
afterEach(() => {
for (const home of homes.splice(0)) {
rmSync(home, { recursive: true, force: true });
}
});
test("an offline device stops receiving notifications after 48 hours", async () => {
const home = mkdtempSync(path.join(tmpdir(), "paseo-push-notifications-"));
homes.push(home);
const filePath = path.join(home, "push-tokens.json");
let now = Date.parse("2026-08-10T00:00:00.000Z");
const deliveries: string[][] = [];
const pushNotifications = createPushNotifications({
logger: createLogger(),
filePath,
now: () => now,
deliver: async (tokens) => deliveries.push(tokens),
});
pushNotifications.renew("ExponentPushToken[offline-device]");
now += 48 * 60 * 60 * 1000;
await pushNotifications.send({ title: "Agent finished", body: "Done" });
expect(deliveries).toEqual([]);
expect(JSON.parse(readFileSync(filePath, "utf8"))).toEqual({ subscriptions: [] });
});
test("online revocation stops notifications immediately", async () => {
const home = mkdtempSync(path.join(tmpdir(), "paseo-push-notifications-"));
homes.push(home);
const deliveries: string[][] = [];
const pushNotifications = createPushNotifications({
logger: createLogger(),
filePath: path.join(home, "push-tokens.json"),
now: () => Date.parse("2026-08-10T00:00:00.000Z"),
deliver: async (tokens) => deliveries.push(tokens),
});
pushNotifications.renew("ExponentPushToken[online-device]");
pushNotifications.revoke("ExponentPushToken[online-device]");
await pushNotifications.send({ title: "Agent finished", body: "Done" });
expect(deliveries).toEqual([]);
});
});
+45
View File
@@ -0,0 +1,45 @@
import type pino from "pino";
import { PushService, type PushPayload } from "./push-service.js";
import { PushTokenStore } from "./token-store.js";
export type { PushPayload };
const PUSH_TOKEN_LEASE_MS = 48 * 60 * 60 * 1000;
export interface PushNotifications {
renew(token: string): void;
revoke(token: string): void;
send(payload: PushPayload): Promise<void>;
}
export type PushNotificationSender = Pick<PushNotifications, "send">;
export function createPushNotifications(options: {
logger: pino.Logger;
filePath: string;
now?: () => number;
deliver?: (tokens: string[], payload: PushPayload) => Promise<void>;
}): PushNotifications {
const now = options.now ?? Date.now;
const store = new PushTokenStore(options.logger, options.filePath, now, PUSH_TOKEN_LEASE_MS);
const service = new PushService(options.logger, (token) => store.revokeToken(token));
const deliver =
options.deliver ??
((tokens: string[], payload: PushPayload) => service.sendPush(tokens, payload));
return {
renew(token) {
store.renewToken(token);
},
revoke(token) {
store.revokeToken(token);
},
async send(payload) {
const tokens = store.getActiveTokens();
options.logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length === 0) return;
await deliver(tokens, payload);
},
};
}
@@ -1,29 +0,0 @@
import type pino from "pino";
import { PushService, type PushPayload } from "./push-service.js";
import type { PushTokenStore } from "./token-store.js";
export type { PushPayload };
export interface PushNotificationSender {
send(payload: PushPayload): Promise<void>;
}
export function createPushNotificationSender(
logger: pino.Logger,
tokenStore: PushTokenStore,
): PushNotificationSender {
const pushService = new PushService(logger, tokenStore);
return {
async send(payload) {
const tokens = tokenStore.getAllTokens();
logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length === 0) {
return;
}
await pushService.sendPush(tokens, payload);
},
};
}
@@ -1,4 +1,3 @@
import type { PushTokenStore } from "./token-store.js";
import type pino from "pino";
export interface PushPayload {
@@ -31,11 +30,11 @@ const MAX_BATCH_SIZE = 100;
*/
export class PushService {
private readonly logger: pino.Logger;
private readonly tokenStore: PushTokenStore;
private readonly revokeToken: (token: string) => void;
constructor(logger: pino.Logger, tokenStore: PushTokenStore) {
constructor(logger: pino.Logger, revokeToken: (token: string) => void) {
this.logger = logger.child({ component: "push-service" });
this.tokenStore = tokenStore;
this.revokeToken = revokeToken;
}
async sendPush(tokens: string[], payload: PushPayload): Promise<void> {
@@ -102,7 +101,7 @@ export class PushService {
ticket.details?.error === "DeviceNotRegistered" ||
ticket.details?.error === "InvalidCredentials"
) {
this.tokenStore.removeToken(message.to);
this.revokeToken(message.to);
}
}
}
@@ -5,6 +5,7 @@ import type pino from "pino";
import { describe, expect, test } from "vitest";
import { PRIVATE_FILE_MODE } from "../private-files.js";
import { createPushNotifications } from "./index.js";
import { PushTokenStore } from "./token-store.js";
const MODE_MASK = 0o777;
@@ -16,6 +17,7 @@ function createLogger(): pino.Logger {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return logger as unknown as pino.Logger;
}
@@ -29,9 +31,12 @@ describe.skipIf(process.platform === "win32")("PushTokenStore file permissions",
const home = mkdtempSync(path.join(tmpdir(), "paseo-push-tokens-"));
const tokenPath = path.join(home, "push-tokens.json");
try {
const store = new PushTokenStore(createLogger(), tokenPath);
const pushNotifications = createPushNotifications({
logger: createLogger(),
filePath: tokenPath,
});
store.addToken("ExponentPushToken[test]");
pushNotifications.renew("ExponentPushToken[test]");
expect(modeOf(tokenPath)).toBe(PRIVATE_FILE_MODE);
} finally {
@@ -39,19 +44,61 @@ describe.skipIf(process.platform === "win32")("PushTokenStore file permissions",
}
});
test("repairs existing push token file permissions when loading", () => {
test("repairs existing push token file permissions when loading", async () => {
const home = mkdtempSync(path.join(tmpdir(), "paseo-push-tokens-"));
const tokenPath = path.join(home, "push-tokens.json");
try {
writeFileSync(tokenPath, JSON.stringify({ tokens: ["ExponentPushToken[test]"] }));
chmodSync(tokenPath, PERMISSIVE_FILE_MODE);
const store = new PushTokenStore(createLogger(), tokenPath);
const deliveries: string[][] = [];
const pushNotifications = createPushNotifications({
logger: createLogger(),
filePath: tokenPath,
deliver: async (tokens) => deliveries.push(tokens),
});
await pushNotifications.send({ title: "Agent finished", body: "Done" });
expect(store.getAllTokens()).toEqual(["ExponentPushToken[test]"]);
expect(deliveries).toEqual([["ExponentPushToken[test]"]]);
expect(modeOf(tokenPath)).toBe(PRIVATE_FILE_MODE);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe("PushTokenStore persistence", () => {
test("does not apply a revocation until the updated subscriptions are durable", () => {
let rejectWrites = false;
let persisted = "";
const store = new PushTokenStore(
createLogger(),
path.join(tmpdir(), `missing-push-token-store-${process.pid}`, "push-tokens.json"),
() => Date.parse("2026-08-10T00:00:00.000Z"),
48 * 60 * 60 * 1000,
(_filePath, payload) => {
if (rejectWrites) throw new Error("disk full");
persisted = String(payload);
},
);
store.renewToken("ExponentPushToken[test]");
rejectWrites = true;
expect(() => store.revokeToken("ExponentPushToken[test]")).toThrow("disk full");
expect(store.getActiveTokens()).toEqual(["ExponentPushToken[test]"]);
expect(JSON.parse(persisted)).toEqual({
subscriptions: [
{
token: "ExponentPushToken[test]",
expiresAt: "2026-08-12T00:00:00.000Z",
},
],
});
rejectWrites = false;
store.revokeToken("ExponentPushToken[test]");
expect(store.getActiveTokens()).toEqual([]);
expect(JSON.parse(persisted)).toEqual({ subscriptions: [] });
});
});
+92 -24
View File
@@ -10,36 +10,69 @@ import { ensurePrivateFile, writePrivateFileAtomicSync } from "../private-files.
*/
export class PushTokenStore {
private readonly logger: pino.Logger;
private tokens: Set<string> = new Set();
private subscriptions = new Map<string, number>();
private readonly filePath: string;
private readonly now: () => number;
private readonly leaseMs: number;
private readonly write: typeof writePrivateFileAtomicSync;
constructor(logger: pino.Logger, filePath: string) {
constructor(
logger: pino.Logger,
filePath: string,
now: () => number,
leaseMs: number,
write: typeof writePrivateFileAtomicSync = writePrivateFileAtomicSync,
) {
this.logger = logger.child({ component: "token-store" });
this.filePath = filePath;
this.now = now;
this.leaseMs = leaseMs;
this.write = write;
this.loadFromDisk();
}
addToken(token: string): void {
renewToken(token: string): void {
const normalized = token.trim();
if (!normalized) return;
if (this.tokens.has(normalized)) return;
this.tokens.add(normalized);
this.persist();
this.logger.debug({ total: this.tokens.size }, "Added token");
const now = this.now();
const currentExpiry = this.subscriptions.get(normalized);
if (currentExpiry !== undefined && currentExpiry - now > this.leaseMs / 2) return;
const next = new Map(this.subscriptions);
next.set(normalized, now + this.leaseMs);
this.persist(next);
this.subscriptions = next;
this.logger.debug({ total: this.subscriptions.size }, "Renewed token");
}
removeToken(token: string): void {
revokeToken(token: string): void {
const normalized = token.trim();
if (!normalized) return;
const deleted = this.tokens.delete(normalized);
if (deleted) {
this.persist();
this.logger.debug({ total: this.tokens.size }, "Removed token");
if (!this.subscriptions.has(normalized)) return;
const next = new Map(this.subscriptions);
next.delete(normalized);
this.persist(next);
this.subscriptions = next;
this.logger.debug({ total: this.subscriptions.size }, "Revoked token");
}
getActiveTokens(): string[] {
const now = this.now();
const active = new Map(this.subscriptions);
for (const [token, expiresAt] of this.subscriptions) {
if (expiresAt <= now) {
active.delete(token);
}
}
}
getAllTokens(): string[] {
return Array.from(this.tokens);
if (active.size !== this.subscriptions.size) {
try {
this.persist(active);
this.subscriptions = active;
} catch {
// Keep the previous state so a later send retries pruning. Expired tokens
// are still excluded from this delivery.
}
}
return Array.from(active.keys());
}
private loadFromDisk(): void {
@@ -49,25 +82,60 @@ export class PushTokenStore {
}
ensurePrivateFile(this.filePath);
const raw = readFileSync(this.filePath, "utf-8");
const parsed = JSON.parse(raw) as { tokens?: unknown };
const tokens = Array.isArray(parsed.tokens)
? parsed.tokens.filter((t): t is string => typeof t === "string" && t.trim().length > 0)
const parsed = JSON.parse(raw) as { subscriptions?: unknown; tokens?: unknown };
const loaded = new Map<string, number>();
const subscriptions = Array.isArray(parsed.subscriptions) ? parsed.subscriptions : [];
for (const value of subscriptions) {
if (!value || typeof value !== "object") continue;
const candidate = value as { token?: unknown; expiresAt?: unknown };
if (typeof candidate.token !== "string" || typeof candidate.expiresAt !== "string")
continue;
const token = candidate.token.trim();
const expiresAt = Date.parse(candidate.expiresAt);
if (token && Number.isFinite(expiresAt)) {
loaded.set(token, expiresAt);
}
}
this.subscriptions = loaded;
const legacyTokens = Array.isArray(parsed.tokens)
? parsed.tokens.filter((token): token is string => typeof token === "string")
: [];
this.tokens = new Set(tokens.map((t) => t.trim()));
this.logger.info({ total: this.tokens.size }, "Loaded push tokens");
if (legacyTokens.length > 0) {
const migrated = new Map(loaded);
const expiresAt = this.now() + this.leaseMs;
for (const token of legacyTokens) {
const normalized = token.trim();
if (normalized) migrated.set(normalized, expiresAt);
}
this.persist(migrated);
this.subscriptions = migrated;
}
this.logger.info({ total: this.subscriptions.size }, "Loaded push tokens");
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn({ err }, "Failed to load push tokens");
}
}
private persist(): void {
private persist(subscriptions: ReadonlyMap<string, number>): void {
try {
const payload = JSON.stringify({ tokens: Array.from(this.tokens) }, null, 2) + "\n";
writePrivateFileAtomicSync(this.filePath, payload);
const payload =
JSON.stringify(
{
subscriptions: Array.from(subscriptions, ([token, expiresAt]) => ({
token,
expiresAt: new Date(expiresAt).toISOString(),
})),
},
null,
2,
) + "\n";
this.write(this.filePath, payload);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn({ err }, "Failed to persist push tokens");
throw err;
}
}
}
+85 -2
View File
@@ -33,7 +33,7 @@ import {
asAgentManager,
asAgentStorage,
asDownloadTokenStore,
asPushTokenStore,
asPushNotifications,
asScheduleService,
asCheckoutDiffManager,
asGitHubService,
@@ -316,6 +316,7 @@ interface SessionForTestOptions {
daemonVersion?: SessionOptions["daemonVersion"];
daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"];
downloadTokenStore?: SessionOptions["downloadTokenStore"];
pushNotifications?: SessionOptions["pushNotifications"];
messages?: unknown[];
targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>;
binaryMessages?: Uint8Array[];
@@ -365,7 +366,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
logger,
downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: options.pushNotifications ?? asPushNotifications(),
paseoHome: options.paseoHome ?? "/tmp/paseo-home",
agentManager: asAgentManager({
listAgents: vi.fn(() => []),
@@ -1443,6 +1444,88 @@ describe("project config RPC authorization", () => {
});
});
test("push token registration can be revoked by the connected client", async () => {
const renewed: string[] = [];
const revoked: string[] = [];
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
pushNotifications: asPushNotifications({
renew: (token: string) => renewed.push(token),
revoke: (token: string) => revoked.push(token),
}),
});
await session.handleMessage({
type: "register_push_token",
token: "ExponentPushToken[test-device]",
});
await session.handleMessage({
type: "push.unregister.request",
token: "ExponentPushToken[test-device]",
requestId: "revoke-1",
});
await session.handleMessage({
type: "client_heartbeat",
deviceType: "mobile",
focusedAgentId: null,
lastActivityAt: "2026-08-10T00:00:00.000Z",
appVisible: false,
});
expect(renewed).toEqual(["ExponentPushToken[test-device]"]);
expect(revoked).toEqual(["ExponentPushToken[test-device]"]);
expect(messages).toEqual([
{
type: "push.unregister.response",
payload: { requestId: "revoke-1" },
},
]);
});
test("push token revocation only acknowledges durable removal", async () => {
const renewed: string[] = [];
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
pushNotifications: asPushNotifications({
renew: (token: string) => renewed.push(token),
revoke: () => {
throw new Error("disk full");
},
}),
});
await session.handleMessage({
type: "register_push_token",
token: "ExponentPushToken[test-device]",
});
await session.handleMessage({
type: "push.unregister.request",
token: "ExponentPushToken[test-device]",
requestId: "revoke-failed",
});
await session.handleMessage({
type: "client_heartbeat",
deviceType: "mobile",
focusedAgentId: null,
lastActivityAt: "2026-08-10T00:00:00.000Z",
appVisible: false,
});
expect(renewed).toEqual(["ExponentPushToken[test-device]", "ExponentPushToken[test-device]"]);
expect(messages.some((message) => message.type === "push.unregister.response")).toBe(false);
expect(messages).toContainEqual({
type: "rpc_error",
payload: {
requestId: "revoke-failed",
requestType: "push.unregister.request",
error: "Request failed: disk full",
code: "handler_error",
},
});
});
describe("daemon status + pairing RPC", () => {
const tempDirs: string[] = [];
+21 -6
View File
@@ -169,7 +169,7 @@ import type { HubRelationshipManagement } from "./hub/relationship-controller.js
import { HubExecutionController } from "./hub/execution-controller.js";
import type { HubExecutionAgents } from "./hub/daemon-executions.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import type { PushNotifications } from "./push/index.js";
import {
archivePersistedWorkspaceRecord,
archiveWorkspaceContents,
@@ -439,7 +439,7 @@ export interface SessionOptions {
onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
logger: pino.Logger;
downloadTokenStore: DownloadTokenStore;
pushTokenStore: PushTokenStore;
pushNotifications: PushNotifications;
paseoHome: string;
worktreesRoot?: string;
agentManager: AgentManager;
@@ -627,7 +627,7 @@ export class Session {
private readonly workspaceProvisioning: WorkspaceProvisioningService;
private readonly workspaceRecovery: WorkspaceRecoveryService;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore;
private readonly pushNotifications: PushNotifications;
private unsubscribeAgentEvents: (() => void) | null = null;
private unsubscribeProjectMutations: (() => void) | null = null;
private unsubscribeWorkspaceMutations: (() => void) | null = null;
@@ -649,6 +649,7 @@ export class Session {
appVisible: boolean;
appVisibilityChangedAt: Date;
} | null = null;
private registeredPushToken: string | null = null;
private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private readonly serviceProxy: ServiceProxySubsystem | null;
@@ -691,7 +692,7 @@ export class Session {
onWorkspaceRecovered,
logger,
downloadTokenStore,
pushTokenStore,
pushNotifications,
paseoHome,
worktreesRoot,
agentManager,
@@ -741,7 +742,7 @@ export class Session {
this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0);
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
this.pushTokenStore = pushTokenStore;
this.pushNotifications = pushNotifications;
this.paseoHome = paseoHome;
this.worktreesRoot = worktreesRoot;
this.sessionLogger = logger.child({
@@ -2277,6 +2278,16 @@ export class Session {
case "register_push_token":
this.handleRegisterPushToken(msg.token);
return;
case "push.unregister.request":
this.pushNotifications.revoke(msg.token);
if (this.registeredPushToken?.trim() === msg.token.trim()) {
this.registeredPushToken = null;
}
this.emit({
type: "push.unregister.response",
payload: { requestId: msg.requestId },
});
return;
}
}
@@ -3722,6 +3733,9 @@ export class Session {
if (msg.appVisible && focusedTerminalId) {
void this.clearFocusedTerminalAttention(focusedTerminalId);
}
if (this.registeredPushToken) {
this.pushNotifications.renew(this.registeredPushToken);
}
}
private async clearFocusedTerminalAttention(terminalId: string): Promise<void> {
@@ -3740,7 +3754,8 @@ export class Session {
* Handle push token registration
*/
private handleRegisterPushToken(token: string): void {
this.pushTokenStore.addToken(token);
this.registeredPushToken = token;
this.pushNotifications.renew(token);
this.sessionLogger.info("Registered push token");
}
@@ -195,7 +195,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
logger: createStub<pino.Logger>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
pushNotifications: createStub<SessionOptions["pushNotifications"]>({}),
paseoHome: "/tmp/paseo-test",
agentManager: createStub<SessionOptions["agentManager"]>({
subscribe: () => () => {},
@@ -93,7 +93,7 @@ function createHarness(input: {
onMessage: (m) => emitted.push(m),
logger: createStub<SessionOptions["logger"]>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
pushNotifications: createStub<SessionOptions["pushNotifications"]>({}),
paseoHome: mkdtempSync(path.join(tmpdir(), "paseo-invariant-test-")),
agentManager: createStub<SessionOptions["agentManager"]>({
subscribe: () => () => {},
@@ -53,7 +53,7 @@ import {
asAgentManager,
asAgentStorage,
asDownloadTokenStore,
asPushTokenStore,
asPushNotifications,
asScheduleService,
asCheckoutDiffManager,
asDaemonConfigStore,
@@ -639,7 +639,7 @@ function createSessionForWorkspaceTests(
onWorkspaceRecovered: options.onWorkspaceRecovered,
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: options.paseoHome ?? "/tmp/paseo-test",
worktreesRoot: options.worktreesRoot,
agentManager,
@@ -946,7 +946,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: path.join(workdir, "paseo-home"),
agentManager,
agentStorage,
@@ -1098,7 +1098,7 @@ test("create_agent_request launches from an exact subdirectory in a created work
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: path.join(workdir, "paseo-home"),
agentManager,
agentStorage,
@@ -1235,7 +1235,7 @@ test("create_agent_request does not title an existing workspace from the agent p
onMessage: vi.fn(),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: path.join(workdir, "paseo-home"),
agentManager,
agentStorage,
@@ -1503,7 +1503,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: "/tmp/paseo-test",
agentManager: asAgentManager({
subscribe: () => () => {},
@@ -1868,7 +1868,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(sessionLogger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: "/tmp/paseo-test",
agentManager: asAgentManager({
subscribe: () => () => {},
@@ -2036,7 +2036,7 @@ test("close_items_request archives stored agents that are not currently loaded",
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(sessionLogger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: "/tmp/paseo-test",
agentManager: asAgentManager({
subscribe: () => () => {},
@@ -2195,7 +2195,7 @@ test("close_items_request continues after an archive failure", async () => {
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(sessionLogger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: "/tmp/paseo-test",
agentManager: asAgentManager({
subscribe: () => () => {},
@@ -3456,7 +3456,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: "/tmp/paseo-test",
agentManager: asAgentManager({
subscribe: () => () => {},
@@ -103,7 +103,7 @@ describe("snapshot mutation ownership boundary", () => {
onMessage,
logger: createStub<SessionOptions["logger"]>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
pushNotifications: createStub<SessionOptions["pushNotifications"]>({}),
paseoHome: "/tmp/paseo-test",
agentManager: createStub<SessionOptions["agentManager"]>({
subscribe: () => () => {},
@@ -11,7 +11,7 @@ import {
} from "../bootstrap.js";
import type { AgentClient, AgentProvider } from "../agent/agent-sdk-types.js";
import { createTestAgentClients } from "./fake-agent-client.js";
import type { PushNotificationSender } from "../push/notifications.js";
import type { PushNotificationSender } from "../push/index.js";
interface TestPaseoDaemonOptions {
daemonVersion?: string;
@@ -53,8 +53,12 @@ export function asDownloadTokenStore(): SessionOptions["downloadTokenStore"] {
return createStub<SessionOptions["downloadTokenStore"]>({});
}
export function asPushTokenStore(): SessionOptions["pushTokenStore"] {
return createStub<SessionOptions["pushTokenStore"]>({});
export function asPushNotifications(
stub: {
[K in keyof SessionOptions["pushNotifications"]]?: unknown;
} = {},
): SessionOptions["pushNotifications"] {
return createStub<SessionOptions["pushNotifications"]>(stub);
}
export function asScheduleService(): SessionOptions["scheduleService"] {
@@ -9,7 +9,7 @@ import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
import type { PushNotificationSender, PushPayload } from "./push/index.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
const WORKSPACE_ID = "workspace-1";
@@ -86,20 +86,12 @@ vi.mock("./session.js", () => ({
Session: sessionMock.MockSession,
}));
vi.mock("./push/token-store.js", () => ({
PushTokenStore: class {
getAllTokens(): string[] {
return [];
}
},
}));
vi.mock("./push/push-service.js", () => ({
PushService: class {
async sendPush(): Promise<void> {
// no-op
}
},
vi.mock("./push/index.js", () => ({
createPushNotifications: () => ({
renew: () => undefined,
revoke: () => undefined,
send: async () => undefined,
}),
}));
import { z } from "zod";
@@ -15,7 +15,7 @@ import type {
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
import type { PushNotificationSender, PushPayload } from "./push/index.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
const wsModuleMock = vi.hoisted(() => {
+14 -7
View File
@@ -45,8 +45,11 @@ import type { GitCommandRuntimeMetricsSnapshot } from "../utils/git-command-runt
import { snapshotGitCommandRuntimeMetrics } from "../utils/run-git-command.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js";
import { PushTokenStore } from "./push/token-store.js";
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
import {
createPushNotifications,
type PushNotifications,
type PushNotificationSender,
} from "./push/index.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -534,7 +537,7 @@ export class VoiceAssistantWebSocketServer {
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore;
private readonly pushNotifications: PushNotifications;
private readonly pushNotificationSender: PushNotificationSender;
private readonly mcpBaseUrl: string | null;
private speech!: SpeechService | null;
@@ -683,9 +686,11 @@ export class VoiceAssistantWebSocketServer {
});
const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
this.pushNotificationSender =
pushNotificationSender ?? createPushNotificationSender(pushLogger, this.pushTokenStore);
this.pushNotifications = createPushNotifications({
logger: pushLogger,
filePath: join(paseoHome, "push-tokens.json"),
});
this.pushNotificationSender = pushNotificationSender ?? this.pushNotifications;
this.agentManager.setAgentAttentionCallback((params) => {
void this.broadcastAgentAttention(params).catch((err) => {
@@ -1332,7 +1337,7 @@ export class VoiceAssistantWebSocketServer {
);
},
downloadTokenStore: this.downloadTokenStore,
pushTokenStore: this.pushTokenStore,
pushNotifications: this.pushNotifications,
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
agentManager: this.agentManager,
@@ -1542,6 +1547,8 @@ export class VoiceAssistantWebSocketServer {
...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}),
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
...(this.advertiseRelayConfig ? { relayConfig: true } : {}),
// COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10.
pushTokenRevocation: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true,
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
@@ -209,7 +209,7 @@ function createSessionForWireCompatTest(options?: {
onMessage: (message) => messages.push(message),
logger: pino({ level: "silent" }),
downloadTokenStore: {} as SessionOptions["downloadTokenStore"],
pushTokenStore: {} as SessionOptions["pushTokenStore"],
pushNotifications: {} as SessionOptions["pushNotifications"],
paseoHome: "/tmp/paseo-home",
agentManager: new InMemoryAgentManager(
options?.rows ?? rows,