Skip to content

Push notification shown on device that triggered connect #181

@rado0x54

Description

@rado0x54

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:

  1. 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.
  2. 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.
  3. 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.
  4. visibilityState during transitions. Can briefly report "hidden" or "prerender" even when the window is about to be shown.
  5. 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

  1. Client tags itself with its own push endpoint on every request that can create a pending action.
  2. Server propagates that tag into the PendingAction.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions