The device that initiates a sign request should not receive its own push notification — the in-page WebSocket toast handles it locally. This worked when triggers always came from the browser-served terminal but regressed once shellwatch-agent (#171, #173, #177) made it possible to trigger connects from the CLI on the same host as the browser.
A second, more severe failure mode appears on iOS PWAs (Safari → Add to Home Screen): the suppression is fundamentally unable to work there.
Intended suppression
Server side (src/pending-action/push-channel.ts) fans out to every subscription for the account — there is no "originating subscription" concept. The only suppression lives in the service worker (client/src/service-worker.ts:60-64, added in 525cdc3):
const windows = await self.clients.matchAll({ type: "window" });
const hasVisibleTab = windows.some((c) => c.visibilityState === "visible");
if (hasVisibleTab) return;
Premise: "triggering device == device with a visible ShellWatch tab."
Why it regressed in the browser
With the agent, ssh from the CLI now triggers sign requests while the user is in their terminal — the browser tab on the same machine is typically backgrounded or on another desktop, so visibilityState !== "visible" and the SW shows the push.
Why it always fails on iOS PWAs
Verified: suppression works in desktop browsers, fails on iPhone home-screen PWAs. Several iOS-specific reasons compound:
- iOS strictly enforces "user-visible push." Safari requires every
push event to result in a showNotification() call. If the SW returns without showing one, iOS displays a generic system notification on its own (and after repeated silent pushes, may revoke push permission for the site). Chrome enforces this loosely via a budget; Safari does not.
- PWA scope isolation. A home-screen PWA runs in its own WebKit process.
clients.matchAll() only sees that PWA's windows — Safari tabs on the same site don't count.
- Aggressive suspension. When a push arrives, iOS may have already suspended the PWA. The SW gets woken in isolation;
matchAll({type:"window"}) returns [] even if the user just triggered the action seconds ago.
visibilityState during transitions. Can briefly report "hidden" or "prerender" even when the window is about to be shown.
clients.claim() timing. iOS applies claim() more slowly than Chromium; right after a SW update the PWA window may stay uncontrolled longer, and matchAll defaults to includeUncontrolled: false.
The takeaway: device-local suppression in the SW is not sufficient. On iOS PWAs it cannot work. The fix has to be server-side — don't send the push to the originating subscription in the first place.
Proposed solution
Identify the originating push subscription on the server, exclude it from fan-out.
Data flow
- Client tags itself with its own push endpoint on every request that can create a pending action.
- Server propagates that tag into the
PendingAction.
PushChannel.send() filters out the matching subscription.
Sketch
Client (client/src/lib/stores/push.ts): cache the current push endpoint and expose a getter.
let cachedEndpoint: string | null = null;
export async function getMyPushEndpoint(): Promise<string | null> {
if (cachedEndpoint) return cachedEndpoint;
if (!pushSupported) return null;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
cachedEndpoint = sub?.endpoint ?? null;
return cachedEndpoint;
}
// Update in subscribePush / unsubscribePush so the cache stays consistent.
Client transport: stamp every API request and the WS upgrade with the endpoint.
- Wrap
fetch (or use a small apiFetch helper) to add X-Push-Endpoint: <endpoint> when present.
- Pass
?push=<encoded> on the WebSocket upgrade URL (headers can't be set on WebSocket constructors).
Server boundary: small Fastify hook that promotes the header/query into a typed field.
// src/server/auth/request.d.ts
declare module "fastify" {
interface FastifyRequest {
originPushEndpoint?: string;
}
}
// preHandler hook, alongside the existing auth gate:
app.addHook("preHandler", (req, _reply, done) => {
const fromHeader = req.headers["x-push-endpoint"];
if (typeof fromHeader === "string" && isAllowedPushEndpoint(fromHeader)) {
req.originPushEndpoint = fromHeader;
}
done();
});
For the WS path, read the query param in the upgrade handler and store it on the tracked client (WebSocketChannel.onConnect already tracks {socket, accountId} — extend with originPushEndpoint).
Action model: extend PendingAction and CreateActionParams with an optional originating endpoint.
// src/pending-action/types.ts
interface PendingActionBase {
// ...
originPushEndpoint?: string;
}
Pass it through wherever actions are created (agent-proxy handler, MCP handler, REST step-up routes) using whichever request-scoped value is available.
Push channel filter: one-line change in src/pending-action/push-channel.ts.
const subscriptions = this.repo
.findByAccountId(action.accountId)
.filter((sub) => sub.endpoint !== action.originPushEndpoint);
Notes / tradeoffs
- External triggers (CLI ssh via agent) genuinely have no "originating subscription" — they correctly fall through to fan-out. The visibility-based SW check stays as a backstop for those and for desktop multi-tab cases.
- The header is non-secret (the push endpoint is already known to the browser and the server). Validate against
isAllowedPushEndpoint to avoid being used as a tracking-pixel oracle.
- Add
includeUncontrolled: true to the SW's matchAll call (line 62) regardless — it's a free correctness fix, mirrors what the notificationclick handler already does, and helps right after SW updates.
What this does NOT fix on iOS
If the originating device's PWA somehow triggers an action without sending the header (bug, race, stale cache), iOS will still show its generic notification. Worth adding a server-side log when fan-out includes a subscription whose owning account just had a recent action created — useful for spotting cases where the tagging missed.
The device that initiates a sign request should not receive its own push notification — the in-page WebSocket toast handles it locally. This worked when triggers always came from the browser-served terminal but regressed once
shellwatch-agent(#171, #173, #177) made it possible to trigger connects from the CLI on the same host as the browser.A second, more severe failure mode appears on iOS PWAs (Safari → Add to Home Screen): the suppression is fundamentally unable to work there.
Intended suppression
Server side (
src/pending-action/push-channel.ts) fans out to every subscription for the account — there is no "originating subscription" concept. The only suppression lives in the service worker (client/src/service-worker.ts:60-64, added in 525cdc3):Premise: "triggering device == device with a visible ShellWatch tab."
Why it regressed in the browser
With the agent,
sshfrom the CLI now triggers sign requests while the user is in their terminal — the browser tab on the same machine is typically backgrounded or on another desktop, sovisibilityState !== "visible"and the SW shows the push.Why it always fails on iOS PWAs
Verified: suppression works in desktop browsers, fails on iPhone home-screen PWAs. Several iOS-specific reasons compound:
pushevent to result in ashowNotification()call. If the SW returns without showing one, iOS displays a generic system notification on its own (and after repeated silent pushes, may revoke push permission for the site). Chrome enforces this loosely via a budget; Safari does not.clients.matchAll()only sees that PWA's windows — Safari tabs on the same site don't count.matchAll({type:"window"})returns[]even if the user just triggered the action seconds ago.visibilityStateduring transitions. Can briefly report"hidden"or"prerender"even when the window is about to be shown.clients.claim()timing. iOS appliesclaim()more slowly than Chromium; right after a SW update the PWA window may stay uncontrolled longer, andmatchAlldefaults toincludeUncontrolled: false.The takeaway: device-local suppression in the SW is not sufficient. On iOS PWAs it cannot work. The fix has to be server-side — don't send the push to the originating subscription in the first place.
Proposed solution
Identify the originating push subscription on the server, exclude it from fan-out.
Data flow
PendingAction.PushChannel.send()filters out the matching subscription.Sketch
Client (
client/src/lib/stores/push.ts): cache the current push endpoint and expose a getter.Client transport: stamp every API request and the WS upgrade with the endpoint.
fetch(or use a smallapiFetchhelper) to addX-Push-Endpoint: <endpoint>when present.?push=<encoded>on the WebSocket upgrade URL (headers can't be set onWebSocketconstructors).Server boundary: small Fastify hook that promotes the header/query into a typed field.
For the WS path, read the query param in the upgrade handler and store it on the tracked client (
WebSocketChannel.onConnectalready tracks{socket, accountId}— extend withoriginPushEndpoint).Action model: extend
PendingActionandCreateActionParamswith an optional originating endpoint.Pass it through wherever actions are created (agent-proxy handler, MCP handler, REST step-up routes) using whichever request-scoped value is available.
Push channel filter: one-line change in
src/pending-action/push-channel.ts.Notes / tradeoffs
isAllowedPushEndpointto avoid being used as a tracking-pixel oracle.includeUncontrolled: trueto the SW'smatchAllcall (line 62) regardless — it's a free correctness fix, mirrors what thenotificationclickhandler already does, and helps right after SW updates.What this does NOT fix on iOS
If the originating device's PWA somehow triggers an action without sending the header (bug, race, stale cache), iOS will still show its generic notification. Worth adding a server-side log when fan-out includes a subscription whose owning account just had a recent action created — useful for spotting cases where the tagging missed.