Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
bfef7c8
docs(ssh-relay): Milestone 2 sealed agent relay design spec
theogravity Oct 8, 2026
62e4d7a
docs(ssh-relay): M2 spec review round 1 - security honesty, protocol …
theogravity Oct 8, 2026
da80ea3
docs(ssh-relay): M2 spec review round 2 - signing scope, replay seq, …
theogravity Oct 8, 2026
9c8269a
docs(ssh-relay): M2 spec review round 3 - encryption-key pinning, out…
theogravity Oct 8, 2026
a480cac
docs(ssh-relay): M2 spec review round 4 - roster enumeration, grant-s…
theogravity Oct 8, 2026
c5193f1
docs(ssh-relay): M2 spec review round 5 - detection-not-prevention ho…
theogravity Oct 8, 2026
84a329c
docs(ssh-relay): M2 spec review round 6 - cap enforcement, mirror gat…
theogravity Oct 8, 2026
60e2de9
docs(ssh-relay): M2 spec review round 7 - rotation is not an identity…
theogravity Oct 8, 2026
a07ea69
docs(ssh-relay): M2 spec review round 8 - symmetric pre-first-pin window
theogravity Oct 8, 2026
971d680
docs(ssh-relay): M2 spec review round 9 - name the three rotation stores
theogravity Oct 8, 2026
439447d
docs(ssh-relay): M2 spec review round 10 - 'concerns' three stores (r…
theogravity Oct 8, 2026
924da82
docs(ssh-relay): M2 implementation plan (16 tasks, subagent-driven)
theogravity Oct 8, 2026
8751acc
docs(ssh-relay): plan pre-flight - correct agent request codes to 13/…
theogravity Oct 8, 2026
7d40691
feat(ssh-relay): per-machine ES256 signing keypair, registered at enroll
theogravity Oct 8, 2026
0429c4a
test+docs(ssh-relay): address Task 1 review (identities nullability t…
theogravity Oct 8, 2026
ad6f1fb
feat(ssh-relay): always-strict node machine pin store (both keys, no …
theogravity Oct 8, 2026
9b85642
fix(ssh-relay): Task 2 review - base64url round-trip, prototype-safe …
theogravity Oct 8, 2026
ab95cd9
feat(ssh-relay): ssh_register_identity delivers signing keys for pre-…
theogravity Oct 8, 2026
80658cd
test(ssh-relay): cover the CAS-loser branch; fix dead JSDoc link (Tas…
theogravity Oct 8, 2026
c134a33
feat(ssh-relay): protocol 18, relay open/close commands + blind relay…
theogravity Oct 8, 2026
84def3d
fix(ssh-relay): Task 4 review - signing-key shape gate, observable ov…
theogravity Oct 8, 2026
b03e732
feat(ssh-relay): sealed envelope codec with signed canonical payload …
theogravity Oct 8, 2026
1059f82
fix(ssh-relay): Task 5 review - structural nonce binding, required ex…
theogravity Oct 8, 2026
d86bc56
feat(ssh-relay): B-side ssh-agent proxy socket over sealed relay frames
theogravity Oct 8, 2026
7d8b2b2
docs(ssh-relay): plan Task 7 agent codes corrected to 13/15 (real age…
theogravity Oct 8, 2026
083ad86
fix(ssh-relay): Task 6 review - burn slot on conn death, deliver-or-f…
theogravity Oct 8, 2026
57d1aa4
docs(ssh-relay): plan T7 - OpenSSH agent-proto.h numbering (13/15), n…
theogravity Oct 8, 2026
6beb4f6
feat(ssh-relay): A-side responder enforces origin, method allow-list,…
theogravity Oct 8, 2026
757a895
docs(ssh-relay): agent numbering is probed, not baked (measured OpenS…
theogravity Oct 8, 2026
a83f91f
feat(ssh-relay): probe A's agent numbering; scope sign before forward
theogravity Oct 8, 2026
27ee9c9
fix(ssh-relay): strictly parse SIGN_RESPONSE body; drop chimeric prox…
theogravity Oct 8, 2026
8d2f182
docs(ssh-relay): record the shipped Task 7 seam shape in the plan
theogravity Oct 8, 2026
af7adf1
feat(ssh-relay): paneId in the relay-open grammar, shared agent-socke…
theogravity Oct 8, 2026
96317bd
feat(ssh-relay): plane relay broker with lifetime, quota, blind routi…
theogravity Oct 8, 2026
a1f43c3
feat(ssh-relay): route the handler seam and every pane death through …
theogravity Oct 8, 2026
ad4b52f
feat(ssh-relay): node relay executors, deliver-or-throw pump, dispatc…
theogravity Oct 8, 2026
182d23f
feat(ssh-relay): bound the node relay registry to SSH_RELAY_MAX_PER_NODE
theogravity Oct 8, 2026
f579b6a
fix(ssh-relay): release proxy/responder on cap-throw; tombstone close…
theogravity Oct 8, 2026
6d10cfb
test(ssh-relay): typecheck seam for the agent relay sources the integ…
theogravity Oct 8, 2026
ad0b53c
test(ssh-relay): relay integration matrix, replay inertness, reverse …
theogravity Oct 8, 2026
29c0a6f
test(ssh-relay): pin openability so relay refusals cannot pass vacuously
theogravity Oct 8, 2026
6f1e4ba
feat(ssh-grants): migration 0050 - grants, first-use queue, host pins
theogravity Oct 8, 2026
dd39d40
feat(ssh-grants): grant repository, five named refusal codes, grant_a…
theogravity Oct 8, 2026
c4904d7
feat(ssh-grants): the grant layer service - first use, approval, matc…
theogravity Oct 8, 2026
507aa96
feat(ssh-grants): grants + approvals routes, the relay launch leg, th…
theogravity Oct 8, 2026
6394a54
docs(security): SS10 gains the grant + relay audit families
theogravity Oct 8, 2026
8121676
docs(ssh-relay): approve audit names the chosen fingerprints (spec 9/…
theogravity Oct 8, 2026
2a0a8af
fix(ssh-relay): approve audit names the chosen fingerprints (spec 9/1…
theogravity Oct 8, 2026
0f664bc
feat(ssh-relay): arm ssh_agent_identities in the node-link grammar wi…
theogravity Oct 8, 2026
db89b04
feat(ssh-relay): ssh_agent_identities enumerates A's public roster fo…
theogravity Oct 8, 2026
1810b4b
feat(ssh-grants): the approvals roster fetch - ssh_agent_identities o…
theogravity Oct 8, 2026
998eacc
test(ssh-relay): flip matrix case 10 - ssh_agent_identities answered …
theogravity Oct 8, 2026
51e1042
fix(node): spell the package.json import with { type: json }
theogravity Oct 8, 2026
9df2412
feat(ssh-protocol): the ssh_host_key capture arm and the required rel…
theogravity Oct 8, 2026
cb0b4bf
feat(pane-runtime): relay-mode ssh config render pins the trust sourc…
theogravity Oct 8, 2026
b3b8eb7
feat(node): ssh_host_key capture arm and the B-side pinned known_host…
theogravity Oct 8, 2026
451b53d
feat(ssh-host-pins): the TOFU store - capture from A, explicit supply…
theogravity Oct 8, 2026
59ef1e3
feat(ssh-relay): every relay leg carries the destination pin
theogravity Oct 8, 2026
69264e6
test(node): narrow the ssh_host_key answer and pin the JWK-refusal fi…
theogravity Oct 8, 2026
bcbab66
fix(ssh-relay): neutralize GlobalKnownHostsFile in relay mode; idempo…
theogravity Oct 9, 2026
7ec2e82
feat(ssh-relay): ssh-fingerprint block in the runtime report, hand-ro…
theogravity Oct 9, 2026
5f352fc
feat(ssh-relay): agent carries the §4.6 trust block in ready and on i…
theogravity Oct 9, 2026
6a56de1
feat(ssh-relay): mirror the trust block at ready, serialize it under …
theogravity Oct 9, 2026
b6008a8
feat(ssh-relay): machine trust card on the node page, owner/edit only…
theogravity Oct 9, 2026
32b3666
feat(ssh-relay): render the trust card on the node's own dashboard; c…
theogravity Oct 9, 2026
c9c27a2
fix(install.sh): the 404 advice branch prints --key <setup key>, neve…
theogravity Oct 9, 2026
e3692b8
feat(ssh-relay): non-interactive 'Set up Subshell here' enrollment ov…
theogravity Oct 9, 2026
a90c696
feat(ssh-setup-here): the destination upgrade service - mint, exec, r…
theogravity Oct 9, 2026
0626927
feat(ssh-setup-here): POST /api/ssh/setup-here, the destination-upgra…
theogravity Oct 9, 2026
a94138d
feat(web): 'Set up Subshell here' action on the SSH pane
theogravity Oct 9, 2026
67059f1
test(ssh-setup-here): keep the new suites clean under verify-types
theogravity Oct 9, 2026
a081310
fix(ssh-relay): classify dark-destination curl failure as the named e…
theogravity Oct 9, 2026
f0b52d5
feat(ssh-relay): client shapes and hooks for grants, approvals, and h…
theogravity Oct 9, 2026
84dba14
feat(ssh-relay): grants screen with the instant-both-ways revoke
theogravity Oct 9, 2026
169a4bd
feat(ssh-relay): first-use approval queue naming the pane, machine, d…
theogravity Oct 9, 2026
9891936
feat(ssh-relay): destination trust screen with delete and the explici…
theogravity Oct 9, 2026
1d03040
feat(ssh-relay): plane trust section, display-only line, owner-only r…
theogravity Oct 9, 2026
d0d8868
feat(ssh-relay): SSH settings page and its rail entry
theogravity Oct 9, 2026
f3b84b5
fix(ssh-relay): grants edit, visible stale-roster handling, localized…
theogravity Oct 9, 2026
aa8dba5
feat(ssh-relay): §4.5 machine trust re-pair end to end (T17)
theogravity Oct 9, 2026
7c96ca1
feat(ssh-relay): manual grant create with a roster-by-node read
theogravity Oct 9, 2026
2acf2b0
test(ssh-relay): pin the grant create cap as the sole submit gate; co…
theogravity Oct 9, 2026
952a675
test(ssh-relay): crown-jewel end-to-end relay enrollment over a real …
theogravity Oct 9, 2026
f482eea
chore: changeset for the ssh relay milestone (major)
theogravity Oct 9, 2026
9d60fa7
docs(ssh-relay): record the shipped teardown deviation (5.6 grace rep…
theogravity Oct 9, 2026
d794a35
fix(notify): grant_approval pushes land on settings, not a phantom pa…
theogravity Oct 9, 2026
c73834f
fix(node): machine pin refuses a second pin; roster read refuses an o…
theogravity Oct 9, 2026
c0d3c85
docs(ssh-relay): close-reason coverage, fingerprint spelling, selecto…
theogravity Oct 9, 2026
530589c
docs(ssh-grants): correct the IPv6 limitation wording (wildcards stil…
theogravity Oct 9, 2026
575c32b
fix(ssh-grants): lowercase the first-use selector; the IPv6 limitatio…
theogravity Oct 9, 2026
8a9cd52
chore(mobile): spell PR 338 without the hash in comments (design chec…
theogravity Oct 9, 2026
6b42126
test(ssh): align fixtures with current main
theogravity Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/ssh-relay-milestone-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@internal/server": major
"@internal/node": major
"@internal/subshell-protocol": major
"@internal/pane-runtime": major
"@internal/server-web": major
"@internal/node-web": major
"@internal/node-admin": major
"@internal/backend-errors": major
"@internal/mobile": major
---

SSH sealed agent relay (milestone 2): a machine that holds no keys can open an SSH-terminal pane anywhere its operator's key home can reach. The connecting machine's node binds a per-pane ssh-agent proxy socket (the pane's scoped `SSH_AUTH_SOCK`, unbound at teardown); every agent request is sealed machine-to-machine over the plane, which brokers the pairing and routes opaque envelopes by a routing ref and nothing else. The key home's responder probes the live agent's numbering (OpenSSH classic vs RFC 9987, learned per session, never assumed), forwards only `REQUEST_IDENTITIES` and `SIGN_REQUEST`, and scopes both to the grant's selected fingerprints. Origin rides an ES256 signature inside the sealed envelope; endpoint-contributed nonces and a monotonic per-direction sequence make every relayed byte replay-inert. Grants (`ssh_key_grants`) bind a key home to a destination selector; the first use of a new pair asks the key home's owner to approve (a durable `ssh_grant_requests` row plus an owner notification, a loud fail-fast refusal, never a hung pane), the approval records the chosen public fingerprints and captures the destination's host-key pin from the key home's own `known_hosts`, and revocation is instant both ways. Panes verify D against a per-session pinned `known_hosts` delivered on the signed relay-open (`StrictHostKeyChecking yes`, system-wide file disabled); a changed destination host key is a hard block and delete-plus-fresh-capture is the operator's TOFU recovery. Machine identity: every agent node carries an ES256 signing keypair reported at enroll, peers pin each other's signing AND encryption halves byte-equal in a store separate from channel pins, and the node's loopback dashboard and the machine trust card show the fingerprints for the out-of-band comparison. "Set up Subshell here" turns an open pane's destination into an enrolled node by driving the ordinary install one-liner over a separate non-interactive ssh exec, so the minted setup key never reaches the pane screen or its log; an installer line carrying a key is redacted twice over. The grants screen, the destination trust screen, and the launcher's key-home picker complete the surface.

Deployment: update the server before the nodes. The link gate is exact-match at protocol 18, so an agent that has not crossed the bump is held and takes no relay traffic. Migrations 0049 through 0052 add the signing-key slots, the grants, the pending-approval rows, the host-key pins, and the pane's key-home column; never run them against a live database without a full archive. Nothing changes for anyone until a node's SSH gate is enabled and a launch names a key home.

Proof: e2e spec 23 (the crown jewel) drives the whole path against a real `ssh-agent`, a real `sshd`, real node daemons, and the real broker, with a plane-side capture asserting only opaque envelopes cross, and the 10.x numbering, the fingerprint scope, the revoke cut, the lifetime cap, the setup-key redaction, and the changed-host-key block plus TOFU recovery all asserted end to end.
2 changes: 1 addition & 1 deletion .claude/rules/security-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path,

Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row.

Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10.
Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_grant.request|ssh_grant.create (via first-use|manual; approve/create name the CHOSEN fingerprint VALUES + destination per spec §10 - the approve row is the only durable record of the operator's selection, and a fingerprint appears in NO log line or notification body)|ssh_grant.update|ssh_grant.delete (relaysClosed; the revoke also cuts live relays via closeForGrant "grant-revoked")|ssh_grant.approve|ssh_grant.deny (audit-only; grant-request expiry writes nothing)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A/grant ids, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, grantId on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; saved-host CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10.

## Deliberate non-validation & accepted risks

Expand Down
47 changes: 47 additions & 0 deletions apps/client/mobile/src/__tests__/push-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "bun:test";
import { decidePushAction, SETTINGS_HREF } from "../lib/push-routing";

/**
* The tap/action decision behind a push (PR #338 review, Important 1): a
* `grant_approval` push carries the grant REQUEST uuid as `sid`, so the old
* blind push to `/subshell/<sid>` was a 404 dead-end and its Silence bell
* PATCHed a nonexistent subshell. `push-bridge` keeps the side effects; this
* pure helper owns the choice, which is all the regression needs pinning.
*/
describe("decidePushAction", () => {
it("grant_approval tap routes to the settings tab, never a phantom pane", () => {
const a = decidePushAction({ sid: "req-uuid", kind: "grant_approval", origin: "https://a" }, undefined);
expect(a).toEqual({ action: "route", href: SETTINGS_HREF });
expect(JSON.stringify(a)).not.toContain("/subshell/");
// The settings tab must be the spelled group route the app itself uses.
expect(SETTINGS_HREF).toBe("/(tabs)/settings");
});

it("grant_approval silence is ignored: no PATCH intent against a request id", () => {
const a = decidePushAction({ sid: "req-uuid", kind: "grant_approval", origin: "https://a" }, "silence");
expect(a).toEqual({ action: "ignore" });
});

it("pane kinds tap to /subshell/<sid>, encoded, unchanged", () => {
const a = decidePushAction({ sid: "sess-1", kind: "turn_complete", origin: "https://a" }, undefined);
expect(a).toEqual({ action: "route", href: "/subshell/sess-1" });
expect(decidePushAction({ sid: "a b", kind: "needs_attention" }, "open")).toEqual({
action: "route",
href: "/subshell/a%20b",
});
});

it("pane kinds silence asks for the bell PATCH with the sid", () => {
expect(decidePushAction({ sid: "sess-1", kind: "needs_attention" }, "silence")).toEqual({
action: "silence",
sid: "sess-1",
});
});

it("a pane payload with no sid is ignored (the pre-existing guard)", () => {
expect(decidePushAction({ kind: "turn_complete", origin: "https://a" }, undefined)).toEqual({
action: "ignore",
});
expect(decidePushAction({ kind: "exited" }, "silence")).toEqual({ action: "ignore" });
});
});
13 changes: 10 additions & 3 deletions apps/client/mobile/src/lib/notif-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@
* handler share a shape.
*/
export interface SubshellNotifData {
/** Subshell uuid — the only identifier that crosses the relay */
/** Subshell uuid (a `grant_approval` push carries the grant REQUEST uuid) */
sid?: string;
/** Event class driving the generic body copy */
kind?: "turn_complete" | "needs_attention" | "exited" | "crashed" | "crashed_final";
/** Event class driving the generic body copy and the tap's destination (the server's NotifyKind, spelled here) */
kind?:
| "turn_complete"
| "needs_attention"
| "exited"
| "crashed"
| "crashed_final"
| "maintenance"
| "grant_approval";
/** Origin that sent it — future multi-instance routing hint */
origin?: string;
}
38 changes: 38 additions & 0 deletions apps/client/mobile/src/lib/push-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { SubshellNotifData } from "@/lib/notif-data";

/**
* The tap/action decision behind a push response, kept pure so the routing
* rule is testable without the native listener (push-bridge owns the side
* effects: origin switching, the PATCH, the navigation).
*/

/** The settings tab, spelled like the app spells its group routes (`/(tabs)`). */
export const SETTINGS_HREF = "/(tabs)/settings";

/** What the app should DO about a push, decided from the opaque payload. */
export type PushAction =
/** Navigate to `href` (after any instance switch push-bridge performs). */
| { action: "route"; href: string }
/** The lock-screen Silence bell: one `setNotify(sid, false)` PATCH. */
| { action: "silence"; sid: string }
/** Do nothing at all: no navigation, no request. */
| { action: "ignore" };

/**
* `grant_approval` pushes name the grant REQUEST uuid in `sid`, not a pane:
* their tap lands on the settings tab (the real surface) and their silence
* action is ignored (there is no subshell bell to PATCH). Every other kind
* behaves exactly as it always has: tap to `/subshell/<sid>`, silence to the
* PATCH (PR 338 review, Important 1).
* @param data - The opaque payload (sid + kind + origin)
* @param actionIdentifier - The response's action id; only "silence" acts
*/
export function decidePushAction(data: SubshellNotifData, actionIdentifier?: string): PushAction {
const silence = actionIdentifier === "silence";
if (data.kind === "grant_approval") {
return silence ? { action: "ignore" } : { action: "route", href: SETTINGS_HREF };
}
if (!data.sid) return { action: "ignore" };
if (silence) return { action: "silence", sid: data.sid };
return { action: "route", href: `/subshell/${encodeURIComponent(data.sid)}` };
}
11 changes: 11 additions & 0 deletions apps/client/mobile/src/native/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ export function configureNotifications(): void {
options: { opensAppToForeground: false, isAuthenticationRequired: true },
},
]);
// The action-free twin, named by `grant_approval` payloads server-side: a
// remote push surfaces actions only for a REGISTERED category, and that
// kind's sid is a grant REQUEST id, so the Silence bell has nothing to
// PATCH (PR 338 review, Important 1). Plain Open only.
void Notifications.setNotificationCategoryAsync("subshell-plain", [
{
identifier: "open",
buttonTitle: "Open",
options: { opensAppToForeground: true, isAuthenticationRequired: true },
},
]);
}

/**
Expand Down
17 changes: 13 additions & 4 deletions apps/client/mobile/src/providers/push-bridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SUBSHELLS_KEY } from "@/hooks/query-keys";
import { useIconBadge } from "@/hooks/use-icon-badge";
import { useApp } from "@/lib/app-state";
import type { SubshellNotifData } from "@/lib/notif-data";
import { decidePushAction } from "@/lib/push-routing";
import { configureNotifications, enrollPush } from "@/native/push";
import { clientForOrigin } from "@/native/subshell-client-factory";
import { useSubshell } from "@/providers/subshell-provider";
Expand All @@ -24,7 +25,10 @@ import { useSubshell } from "@/providers/subshell-provider";
* route itself is the biometric gate, §Security notes), "Silence bell" →
* one PATCH against the ORIGIN's client + list refresh, without
* foregrounding. An origin this phone no longer knows (forgotten instance)
* is ignored rather than opened on the wrong server.
* is ignored rather than opened on the wrong server. A `grant_approval`
* push switches instances the same way but lands on the settings tab, and
* its Silence action is ignored (its sid names a grant request, not a pane;
* PR 338 review, Important 1).
*
* The response listener mounts ONCE and reads the registry via
* `useApp.getState()` at call time: an earlier version kept activeId/instances
Expand Down Expand Up @@ -53,21 +57,26 @@ export function PushBridge() {
const { activeId, instances, setActive } = useApp.getState();
const origin = data.origin && data.origin !== activeId ? data.origin : null;
if (origin && !instances.some((i) => i.id === origin)) return; // unknown/forgotten instance
// The helper decides (pure, pinned in src/lib/push-routing.ts); this
// closure only performs. A `grant_approval` push routes to settings and
// ignores the Silence bell: its sid is a grant request, not a pane.
const action = decidePushAction(data, response.actionIdentifier);
if (action.action === "ignore") return;
// The action belongs to the ORIGIN's instance, whatever is active now.
const actor = origin ? clientForOrigin(origin) : client;
if (response.actionIdentifier === "silence") {
if (action.action === "silence") {
// Background action: one quick PATCH; the app never opens.
if (!actor) return;
try {
await actor.setNotify(data.sid, false);
await actor.setNotify(action.sid, false);
await qc.invalidateQueries({ queryKey: SUBSHELLS_KEY });
} catch {
/* signed-out mid-flight: the bell state re-converges on next open */
}
return;
}
if (origin) setActive(origin); // provider clears the query cache on switch
router.push(`/subshell/${encodeURIComponent(data.sid)}`);
router.push(action.href);
};
const sub = Notifications.addNotificationResponseReceivedListener((r) => void respond(r));
// Cold start from a notification tap must route too, not just live taps.
Expand Down
Loading
Loading