@@ -27,6 +27,7 @@ import {
2727 type BrowserStatusResult ,
2828 type ObservedResult ,
2929 type ObservedLink ,
30+ hostMatchesGrant ,
3031 isReadonlyCdpMethod ,
3132 registrableHost ,
3233} from '../shared/browser-bridge-types' ;
@@ -83,43 +84,58 @@ export function getConversationId(): string | null {
8384 return conversationId ;
8485}
8586
86- // Local user-Stop signal. Stop/resume lifecycle: the SERVER is the single
87- // source of truth. The renderer's Stop button sets the SERVER control gate
88- // (POST /browse/control/stop) — while stopped the server hands out NO
89- // commands — and a fresh user turn resumes it server-side
87+ // Local user-Stop latch. THE canonical Stop-lifecycle description lives here
88+ // (the poller's comments point at this block).
89+ //
90+ // Stop/resume lifecycle: the SERVER is the single source of truth. Stop sets
91+ // the SERVER control gate (POST /browse/control/stop) — while stopped the
92+ // server hands out NO commands — and a fresh user turn resumes it server-side
9093// (resume_on_new_turn). Re-approval is required only after
9194// take-over/lost/revoke, never after Stop.
9295//
9396// This local latch exists ONLY to close the hand-out→execute race: a command
94- // the server handed to the wire just before the Stop landed (Stop leaves the
97+ // the server handed to the wire before its stop gate was set (Stop leaves the
9598// bridge connected, so the poller's currentState() re-check can't catch it).
96- // It records WHEN Stop was pressed (a timestamp, set via IPC BROWSER_STOP);
97- // the poller compares it against when its long-poll started and gates only
98- // commands that could predate the stop, then clears the latch. It must NEVER
99- // gate post-resume commands — those come from a server whose gate was already
100- // stopped and then resumed by a new turn. Also cleared on attach()/dispose as
101- // belt-and-braces.
99+ // `requestedAt` is set immediately on IPC BROWSER_STOP (Stop takes effect
100+ // locally in <1s). The renderer also POSTs /browse/control/stop, but it does
101+ // so fire-and-forget — the server gate is only KNOWN to be set once an ack is
102+ // recorded, so the MAIN process re-POSTs the (idempotent) stop from the
103+ // poller each cycle while un-acked and records `ackAt` when a 2xx lands
104+ // (main-owned ack: no dependence on the renderer's POST succeeding).
105+ //
106+ // The poller gates every handed-out command while the latch is armed
107+ // (requestedAt set, no ack yet). The ack is awaited BEFORE the poll in the
108+ // same single-in-flight loop, so once acked, any command a later poll returns
109+ // was necessarily handed out by a server whose gate had already been set —
110+ // i.e. resumed by a fresh user turn — and the latch is cleared and the
111+ // command executes. The latch must never outlive its ack to gate post-resume
112+ // commands (that was the old bug: a boolean latch persisted until
113+ // re-approval). Also cleared on attach()/dispose as belt-and-braces.
102114let stopRequestedAt : number | null = null ;
115+ let stopAckAt : number | null = null ;
103116
104117export function requestStop ( ) : void {
105118 stopRequestedAt = Date . now ( ) ;
119+ // A fresh Stop needs a fresh server ack — the previous gate may have been
120+ // resumed since.
121+ stopAckAt = null ;
106122}
107123
108- // Was Stop pressed at-or-after `sinceMs`? Used by the poller to detect the
109- // raced case: a stop that landed while its long-poll was outstanding .
110- export function isStopRequestedSince ( sinceMs : number ) : boolean {
111- return stopRequestedAt !== null && stopRequestedAt >= sinceMs ;
124+ // Record that the server confirmed (2xx) its stop gate is set. No-op when no
125+ // Stop is latched (a stray ack must not fabricate a latch state) .
126+ export function ackStopRequest ( ) : void {
127+ if ( stopRequestedAt !== null ) stopAckAt = Date . now ( ) ;
112128}
113129
114- // Is any un-cleared Stop latched at all (regardless of timing)?
115- export function isStopRequested ( ) : boolean {
116- return stopRequestedAt !== null ;
130+ export function getStopLatch ( ) : { requestedAt : number | null ; ackAt : number | null } {
131+ return { requestedAt : stopRequestedAt , ackAt : stopAckAt } ;
117132}
118133
119- // The latch's raced-case job is done (the poller gated or safely bypassed
120- // it ); post-resume commands must flow freely.
134+ // The latch's raced-case job is done (the server gate is known set and the
135+ // poller finished a post-ack cycle ); post-resume commands must flow freely.
121136export function clearStopRequest ( ) : void {
122137 stopRequestedAt = null ;
138+ stopAckAt = null ;
123139}
124140
125141// Main-side bridge-state subscribers (e.g. the command poller). Distinct from
@@ -218,6 +234,7 @@ export function __resetBridgeForTest(): void {
218234 bridgeStateListeners . clear ( ) ;
219235 conversationId = null ;
220236 stopRequestedAt = null ;
237+ stopAckAt = null ;
221238}
222239
223240async function defaultListTargets ( base : string ) : Promise < CdpTarget [ ] > {
@@ -409,6 +426,7 @@ export async function attach(
409426 // Belt-and-braces: a fresh approval starts a clean session, so any stale
410427 // Stop latch is meaningless (resume itself is server-side, on a new turn).
411428 stopRequestedAt = null ;
429+ stopAckAt = null ;
412430 // New approval supersedes any in-flight approve() awaiting connect.
413431 approvalGeneration += 1 ;
414432 pendingApproval = { targetId, cancel : ( ) => { } } ;
@@ -490,6 +508,8 @@ export async function approve(): Promise<{ ok: boolean; state: BridgeState; reas
490508 if ( ! frame . url ) return ;
491509 const host = registrableHost ( frame . url ) ;
492510 // Empty host (about:blank etc.) — ignore, no cross-host expansion.
511+ // (Deliberately NOT hostMatchesGrant: transient hostless frames are
512+ // tolerated here; the post-read verification fails them closed.)
493513 if ( host && host !== approvedTarget . domain ) {
494514 handleLost ( 'The tab navigated to a different site, so the approval no longer applies.' ) ;
495515 }
@@ -651,32 +671,36 @@ async function extractObserved(): Promise<ObservedResult> {
651671// client-side redirect) between the frameNavigated listener firing and the
652672// readback — the grant is for the approved domain ONLY, so an off-domain
653673// observation is discarded, the bridge drops to lost (re-approval required),
654- // and the caller gets a refusal instead of unapproved-site content. Returns
655- // the refusal result, or null when the observation is on-grant.
674+ // and the caller gets a refusal instead of unapproved-site content.
675+ //
676+ // FAIL CLOSED on hostless URLs: when observed.url IS present but yields no
677+ // registrable host (file:///…, data:…, about:blank), the tab left the
678+ // approved site — refuse, don't approve. (This is why the check is spelled
679+ // out here instead of using hostMatchesGrant: the helper's empty-host-refuses
680+ // semantics match, but the missing-url pass below does not.) Only an ABSENT
681+ // observed.url passes unverified — extraction gave nothing to check.
682+ // Returns the refusal result, or null when the observation is on-grant.
656683function verifyObservedOnApprovedDomain (
657684 action : BrowserActionType ,
658685 observed : ObservedResult ,
659686) : BrowserActionResult | null {
660687 const target = approvedTarget ;
661688 if ( ! target || ! observed . url ) return null ;
662689 const host = registrableHost ( observed . url ) ;
663- if ( ! host || host === target . domain ) return null ;
690+ if ( host === target . domain ) return null ;
664691 // Keep the per-action status mapping: a navigate that LANDED off-domain is
665692 // a failed navigation; any other primitive observing off-domain content is
666693 // a permission refusal.
667- const reason =
668- action === 'navigate'
669- ? 'That link leaves the approved site.'
670- : 'The tab is on a different site than the one you approved.' ;
694+ const offSiteReason = 'The tab is on a different site than the one you approved.' ;
671695 handleLost (
672696 action === 'navigate'
673697 ? 'The page redirected to a different site, so the approval no longer applies.'
674- : 'The tab is on a different site than the one you approved.' ,
698+ : offSiteReason ,
675699 ) ;
676700 return {
677701 status : action === 'navigate' ? 'navigation_failed' : 'permission_denied' ,
678702 action,
679- reason,
703+ reason : action === 'navigate' ? 'That link leaves the approved site.' : offSiteReason ,
680704 } ;
681705}
682706
@@ -710,7 +734,7 @@ export async function navigateApprovedLink(href: string): Promise<BrowserActionR
710734 reason : 'That link is not one of the links found on the approved page.' ,
711735 } ;
712736 }
713- if ( registrableHost ( href ) !== target . domain ) {
737+ if ( ! hostMatchesGrant ( href , target . domain ) ) {
714738 return {
715739 status : 'navigation_failed' ,
716740 action : 'navigate' ,
@@ -799,6 +823,7 @@ export function disposeAllBridges(): void {
799823 bridgeState = 'disconnected' ;
800824 conversationId = null ;
801825 stopRequestedAt = null ;
826+ stopAckAt = null ;
802827 // Tear down the Chrome WE launched (app quit / full drain). If Chrome was
803828 // already running on the port when we attached, managedChrome is null and we
804829 // leave the user's own Chrome alone.
0 commit comments