dsh-builtin-browser 0.4.1 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1699 -0
- package/README.en.md +114 -33
- package/README.md +108 -32
- package/client.js +478 -29
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +426 -167
- package/docs/architecture.md +14 -4
- package/docs/tool-reference.md +13 -13
- package/docs/user-guide.md +58 -3
- package/docs/why-browser.md +1 -1
- package/lib/browser/types.d.ts +34 -3
- package/lib/browser-electron/bridge-connection.d.ts +49 -26
- package/lib/browser-electron/bridge-connection.js +185 -22
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +133 -40
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +36 -2
- package/lib/browser-electron/history-store.js +76 -7
- package/lib/browser-electron/host-main.js +86 -28
- package/lib/browser-electron/provider.d.ts +106 -3
- package/lib/browser-electron/provider.js +407 -60
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +259 -25
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +68 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +160 -23
- package/lib/browser-electron/system-browser.d.ts +102 -12
- package/lib/browser-electron/system-browser.js +411 -51
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +149 -74
- package/package.json +10 -4
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +34 -3
- package/src/browser-electron/bridge-connection.ts +179 -22
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +132 -39
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +81 -9
- package/src/browser-electron/host-main.ts +115 -36
- package/src/browser-electron/provider.ts +492 -69
- package/src/browser-electron/remote-host.ts +276 -38
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +87 -0
- package/src/browser-electron/settings-store.ts +173 -24
- package/src/browser-electron/system-browser.ts +408 -39
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +155 -76
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
* that connection proves knowledge of the random per-spawn token (passed to
|
|
20
20
|
* the child via its stdin (first line, never in argv). A local process that
|
|
21
21
|
* connects to the loopback port without the token can neither impersonate the
|
|
22
|
-
* child nor inject replies — it is disconnected
|
|
23
|
-
* only written after the hello authenticates,
|
|
24
|
-
*
|
|
22
|
+
* child nor inject replies — it is disconnected as soon as its first line
|
|
23
|
+
* arrives. Commands are only written after the hello authenticates, and every
|
|
24
|
+
* other inbound op (including the fire-and-forget `userAction` / `viewClosed`
|
|
25
|
+
* notifications) is dispatched only once that check has passed, so an
|
|
26
|
+
* unauthenticated peer can neither read traffic nor drive a session.
|
|
25
27
|
* @module dsh-browser/browser-electron/remote-host
|
|
26
28
|
*/
|
|
27
29
|
import type { BrowserUserAction, ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
|
|
@@ -113,6 +115,12 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
113
115
|
private electronAvailable;
|
|
114
116
|
/** Earliest time the next probe may run after a negative one. */
|
|
115
117
|
private nextProbeAt;
|
|
118
|
+
/** Consecutive child starts that did not survive; drives {@link startRetryAt}. */
|
|
119
|
+
private startFailures;
|
|
120
|
+
/** Earliest time `ready()` may spawn another child after a failed start. */
|
|
121
|
+
private startRetryAt;
|
|
122
|
+
/** When the last start failure was reported, used to keep one death to one log line. */
|
|
123
|
+
private lastStartFailureAt;
|
|
116
124
|
/** Window groups (windowId per view), re-sent on every materialization so
|
|
117
125
|
* a restarted child still places views in the right windows. */
|
|
118
126
|
private readonly groups;
|
|
@@ -145,6 +153,26 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
145
153
|
* on a cooldown heals it by itself while keeping the scan off the hot path.
|
|
146
154
|
*/
|
|
147
155
|
available(): boolean;
|
|
156
|
+
/**
|
|
157
|
+
* Retire the cached "an Electron binary is here" answer.
|
|
158
|
+
*
|
|
159
|
+
* A success is cached for the host's lifetime because rescanning on every tool call was
|
|
160
|
+
* the original bug — but the probe reads the FILESYSTEM, so it cannot see a binary that
|
|
161
|
+
* stops being usable afterwards. Only a real spawn attempt can observe that, which is why
|
|
162
|
+
* the two failure paths in {@link RemoteElectronViewHost.start} call this: the next
|
|
163
|
+
* `available()` rescans and reports what is actually on disk, instead of advertising a
|
|
164
|
+
* provider that cannot start.
|
|
165
|
+
*/
|
|
166
|
+
private forgetBinary;
|
|
167
|
+
/**
|
|
168
|
+
* Whether the binary the host would spawn — or the entry it needs — is no longer there.
|
|
169
|
+
*
|
|
170
|
+
* Only asked after a launch actually failed, so a healthy path never pays for it. The
|
|
171
|
+
* entry script counts because Electron exits 1 with empty stderr when it cannot load it,
|
|
172
|
+
* and resolution itself throws exactly when it can find nothing, which is the same answer.
|
|
173
|
+
* @returns true when the next `available()` must rescan instead of reusing its cache.
|
|
174
|
+
*/
|
|
175
|
+
private binaryIsGone;
|
|
148
176
|
/**
|
|
149
177
|
* Ensure the child is up and ready (lazy on first use; restarts after a crash).
|
|
150
178
|
*
|
|
@@ -155,6 +183,27 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
155
183
|
* instead, which those callers already handle.
|
|
156
184
|
*/
|
|
157
185
|
private ready;
|
|
186
|
+
/**
|
|
187
|
+
* Record one child that did not survive, and set the earliest retry time.
|
|
188
|
+
*
|
|
189
|
+
* `available()` answers "is there a binary to spawn" from the FILESYSTEM, and a success
|
|
190
|
+
* there is cached for the host's lifetime (correctly — rescanning on every tool call was
|
|
191
|
+
* the bug). That probe therefore cannot see the failure mode where the binary exists and
|
|
192
|
+
* crashes on every launch: the signature `code=1` with empty stderr that host-log.test.mjs
|
|
193
|
+
* asserts. Each browser call then spawned a child that died within milliseconds, with no
|
|
194
|
+
* backoff and nothing that ever gives up — an endless crash loop, each iteration a window
|
|
195
|
+
* flashing on screen.
|
|
196
|
+
*
|
|
197
|
+
* The first retry is immediate, so the ordinary transient death (the DSH restart in
|
|
198
|
+
* remote-host-recovery.test.mjs) still heals on the very next call. After that the delay
|
|
199
|
+
* grows: once {@link START_FAILURES_MAX} attempts have failed, every later attempt is
|
|
200
|
+
* spaced by the table's last entry — the host keeps trying, but a permanently broken
|
|
201
|
+
* install now costs one spawn per 30s instead of one per tool call. Nothing is marked
|
|
202
|
+
* dead for good: a repaired installation heals on the next attempt, and `dispose()`
|
|
203
|
+
* still stops everything immediately.
|
|
204
|
+
* @param error - what the failed attempt reported.
|
|
205
|
+
*/
|
|
206
|
+
private noteStartFailure;
|
|
158
207
|
private start;
|
|
159
208
|
/** The child died: tear down so the next use starts a fresh child. */
|
|
160
209
|
private onChildExit;
|
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
* that connection proves knowledge of the random per-spawn token (passed to
|
|
20
20
|
* the child via its stdin (first line, never in argv). A local process that
|
|
21
21
|
* connects to the loopback port without the token can neither impersonate the
|
|
22
|
-
* child nor inject replies — it is disconnected
|
|
23
|
-
* only written after the hello authenticates,
|
|
24
|
-
*
|
|
22
|
+
* child nor inject replies — it is disconnected as soon as its first line
|
|
23
|
+
* arrives. Commands are only written after the hello authenticates, and every
|
|
24
|
+
* other inbound op (including the fire-and-forget `userAction` / `viewClosed`
|
|
25
|
+
* notifications) is dispatched only once that check has passed, so an
|
|
26
|
+
* unauthenticated peer can neither read traffic nor drive a session.
|
|
25
27
|
* @module dsh-browser/browser-electron/remote-host
|
|
26
28
|
*/
|
|
27
29
|
import { execFileSync, spawn } from 'node:child_process';
|
|
@@ -458,6 +460,9 @@ class ElectronChildClient {
|
|
|
458
460
|
const line = `${hostStamp()} spawn error: ${String(error)} electron=${electron} hostMain=${this.hostMainPath}`;
|
|
459
461
|
process.stderr.write(`[dsh-browser host] ${line}\n`);
|
|
460
462
|
appendHostLog(`${line}\n`);
|
|
463
|
+
// The cached availability answer is retired by `onExit` below, which this reaches
|
|
464
|
+
// through fail(): the decision needs the host's view of what is on disk, not the
|
|
465
|
+
// client's.
|
|
461
466
|
this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`));
|
|
462
467
|
});
|
|
463
468
|
this.child.on('exit', (code, signal) => {
|
|
@@ -479,12 +484,44 @@ class ElectronChildClient {
|
|
|
479
484
|
this.fail(new Error(`dsh-builtin-browser: ${line}`));
|
|
480
485
|
});
|
|
481
486
|
}
|
|
487
|
+
/**
|
|
488
|
+
* Drop the escalation kill once there is nothing left for it to target.
|
|
489
|
+
*
|
|
490
|
+
* Called on the paths where the child is known to be gone — after an observed exit, and on
|
|
491
|
+
* release. It is deliberately NOT called from `fail()`: a dead socket says nothing about the
|
|
492
|
+
* process on the other end, and the timer exists for exactly the case where the child ignores
|
|
493
|
+
* the socket closing. Clearing it there disarmed the only escalation there was, which is how
|
|
494
|
+
* a child that ignored its socket could outlive dispose() and keep holding the profile.
|
|
495
|
+
*/
|
|
496
|
+
clearKillGrace() {
|
|
497
|
+
if (this.killGraceTimer === undefined)
|
|
498
|
+
return;
|
|
499
|
+
clearTimeout(this.killGraceTimer);
|
|
500
|
+
this.killGraceTimer = undefined;
|
|
501
|
+
}
|
|
502
|
+
/** Drop the "did not authenticate" deadline once it can no longer be meaningful. */
|
|
503
|
+
clearHelloTimer() {
|
|
504
|
+
if (this.helloTimer === undefined)
|
|
505
|
+
return;
|
|
506
|
+
clearTimeout(this.helloTimer);
|
|
507
|
+
this.helloTimer = undefined;
|
|
508
|
+
}
|
|
482
509
|
/** Reject everything in flight, mark the client dead, and notify the host. */
|
|
483
510
|
fail(err) {
|
|
484
511
|
if (this.dead)
|
|
485
512
|
return;
|
|
486
513
|
this.dead = true;
|
|
487
514
|
this.connected = false;
|
|
515
|
+
// The authentication deadline outlives the socket it was armed for otherwise: it is
|
|
516
|
+
// keyed on `awaitingHello` alone, so a client that already failed on some other path
|
|
517
|
+
// kept a live timer until it fired (harmless, but it is a dangling timer in a process
|
|
518
|
+
// that may be shutting down).
|
|
519
|
+
this.clearHelloTimer();
|
|
520
|
+
// NOT clearKillGrace(): this path runs when the SOCKET died, which says nothing about
|
|
521
|
+
// whether the process did. Clearing the escalation here disarmed it for exactly the case it
|
|
522
|
+
// exists for — a child that ignores its socket closing — so dispose() could return with the
|
|
523
|
+
// child still running and still holding the profile. The timer clears itself when the child
|
|
524
|
+
// exits, and `kill()` replaces it on an explicit release.
|
|
488
525
|
// Tag every failure as "host gone" so the view layer can tell a dead host
|
|
489
526
|
// apart from a page-level error and rebuild against a fresh child.
|
|
490
527
|
const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message);
|
|
@@ -557,19 +594,35 @@ class ElectronChildClient {
|
|
|
557
594
|
// Non-protocol line; ignore.
|
|
558
595
|
continue;
|
|
559
596
|
}
|
|
560
|
-
if (msg.op === 'userAction') {
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
//
|
|
569
|
-
// the
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
this.
|
|
597
|
+
if (msg.op === 'userAction' || msg.op === 'viewClosed') {
|
|
598
|
+
// A fire-and-forget notification from the child's own UI (toolbar). These carry no
|
|
599
|
+
// reply and no pending id, and they hand the provider a session-level verb — so they
|
|
600
|
+
// are dispatched ONLY once the peer has proved it knows the spawn token.
|
|
601
|
+
//
|
|
602
|
+
// They used to be handled before the hello check, while the module comment claimed an
|
|
603
|
+
// unauthenticated peer "is disconnected immediately … a spoofed socket never sees
|
|
604
|
+
// traffic". Both were wrong: any loopback process that won the race for the single
|
|
605
|
+
// accepted socket could inject `viewClosed` and end the human's browser session
|
|
606
|
+
// before the token check rejected it. Only the loopback interface can connect, so
|
|
607
|
+
// this is a same-machine race rather than a remote attack — which is exactly the
|
|
608
|
+
// kind of thing the token exists to close.
|
|
609
|
+
if (this.awaitingHello) {
|
|
610
|
+
this.awaitingHello = false;
|
|
611
|
+
this.clearHelloTimer();
|
|
612
|
+
this.socket?.destroy();
|
|
613
|
+
this.fail(new Error('dsh-builtin-browser: browser host sent a notification before authenticating'));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (msg.op === 'userAction') {
|
|
617
|
+
// Route it straight to the host's user-action handler (which the provider registered).
|
|
618
|
+
this.onUserAction?.(msg.action);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
// The human closed the window, so the interface that session showed is gone. The
|
|
622
|
+
// provider decides what that ends — browsing history and login state survive on
|
|
623
|
+
// disk, only the session itself does not.
|
|
624
|
+
this.onViewClosed?.(typeof msg.windowId === 'string' ? msg.windowId : '');
|
|
625
|
+
}
|
|
573
626
|
continue;
|
|
574
627
|
}
|
|
575
628
|
if (this.awaitingHello) {
|
|
@@ -577,10 +630,7 @@ class ElectronChildClient {
|
|
|
577
630
|
// Anything else (or a wrong token) means a spoofed connection: drop
|
|
578
631
|
// it and fail every pending call rather than trust the line.
|
|
579
632
|
this.awaitingHello = false;
|
|
580
|
-
|
|
581
|
-
clearTimeout(this.helloTimer);
|
|
582
|
-
this.helloTimer = undefined;
|
|
583
|
-
}
|
|
633
|
+
this.clearHelloTimer();
|
|
584
634
|
if (msg.op !== 'hello' || msg.token !== this.token) {
|
|
585
635
|
this.socket?.destroy();
|
|
586
636
|
this.fail(new Error('dsh-builtin-browser: browser host authentication failed'));
|
|
@@ -635,13 +685,43 @@ class ElectronChildClient {
|
|
|
635
685
|
}
|
|
636
686
|
});
|
|
637
687
|
}
|
|
638
|
-
/**
|
|
688
|
+
/**
|
|
689
|
+
* How long the child gets to exit by itself after the socket closes, before it is killed.
|
|
690
|
+
* The child quits on that signal, so in practice this is a few dozen milliseconds; the timer
|
|
691
|
+
* exists for the case where it does not, and it is deliberately NOT unref'd — the parent must
|
|
692
|
+
* stay alive long enough to enforce it, or the escalation would be dropped with the process.
|
|
693
|
+
*/
|
|
694
|
+
static KILL_GRACE_MS = 500;
|
|
695
|
+
killGraceTimer;
|
|
696
|
+
/**
|
|
697
|
+
* Terminate the child, after giving it the chance to shut down properly.
|
|
698
|
+
*
|
|
699
|
+
* The socket goes first, because that is the signal the child actually handles: its `close`
|
|
700
|
+
* listener quits the app, and a normal quit is what lets Chromium write its profile out. Only
|
|
701
|
+
* a child still alive after the grace period is killed outright.
|
|
702
|
+
*
|
|
703
|
+
* This order used to be the other way round, justified by a comment claiming the child's own
|
|
704
|
+
* path "closes its window and flushes the profile", which made killing first the safer order.
|
|
705
|
+
* Both halves of that were wrong: the path ran `app.exit(0)`, which skips before-quit/will-quit
|
|
706
|
+
* and does not flush Chromium's profile writers at all; and on Windows `child.kill()` is
|
|
707
|
+
* TerminateProcess, which lands before the child can run any handler whatsoever. So the
|
|
708
|
+
* graceful path that comment was protecting never got to start, and the profile went
|
|
709
|
+
* unflushed on every single dispose.
|
|
710
|
+
*/
|
|
639
711
|
kill() {
|
|
712
|
+
this.clearHelloTimer();
|
|
640
713
|
try {
|
|
641
714
|
this.socket?.destroy();
|
|
642
715
|
}
|
|
643
716
|
catch { /* already closed */ }
|
|
644
|
-
this.child
|
|
717
|
+
const child = this.child;
|
|
718
|
+
this.clearKillGrace();
|
|
719
|
+
this.killGraceTimer = setTimeout(() => {
|
|
720
|
+
try {
|
|
721
|
+
child.kill();
|
|
722
|
+
}
|
|
723
|
+
catch { /* already gone */ }
|
|
724
|
+
}, ElectronChildClient.KILL_GRACE_MS);
|
|
645
725
|
}
|
|
646
726
|
}
|
|
647
727
|
/** One view in the child: its id, used for every command. */
|
|
@@ -691,6 +771,26 @@ const PROBE_RETRY_MS = (() => {
|
|
|
691
771
|
const raw = Number(process.env.DSH_BROWSER_PROBE_RETRY_MS);
|
|
692
772
|
return Number.isFinite(raw) && raw >= 0 ? raw : 30000;
|
|
693
773
|
})();
|
|
774
|
+
/**
|
|
775
|
+
* Retry schedule for a browser host that will not stay up, in milliseconds, indexed by the
|
|
776
|
+
* number of consecutive failed starts (see {@link RemoteElectronViewHost.noteStartFailure}).
|
|
777
|
+
*
|
|
778
|
+
* The first entry is 0 on purpose: an ordinary one-off death must still heal on the very
|
|
779
|
+
* next call, which is the behaviour remote-host-recovery.test.mjs pins. From the second
|
|
780
|
+
* failure on, each attempt is spaced. `available()` can only answer "is there a binary",
|
|
781
|
+
* so a binary that exists and crashes on every launch (`code=1` with empty stderr) would
|
|
782
|
+
* otherwise produce an endless crash loop, one window per tool call, with nothing to make
|
|
783
|
+
* it stop.
|
|
784
|
+
*/
|
|
785
|
+
const START_RETRY_SCHEDULE_MS = [0, 500, 2_000, 8_000, 30_000];
|
|
786
|
+
/** Attempts after which the host stops retrying immediately and keeps the longest delay. */
|
|
787
|
+
const START_FAILURES_MAX = START_RETRY_SCHEDULE_MS.length;
|
|
788
|
+
/** Two reports within this window describe the same dying child, not two attempts. */
|
|
789
|
+
const START_FAILURE_DEDUPE_MS = 1_000;
|
|
790
|
+
/** Render a delay for a human-readable log line. */
|
|
791
|
+
function formatDelay(ms) {
|
|
792
|
+
return ms >= 1_000 ? `${(ms / 1_000).toFixed(1)}s` : `${String(ms)}ms`;
|
|
793
|
+
}
|
|
694
794
|
/**
|
|
695
795
|
* Self-hosted view host: spawns the plugin's Electron child on first use and
|
|
696
796
|
* keeps it alive until dispose(). Fallback when no desktop shell provides
|
|
@@ -711,6 +811,12 @@ export class RemoteElectronViewHost {
|
|
|
711
811
|
electronAvailable;
|
|
712
812
|
/** Earliest time the next probe may run after a negative one. */
|
|
713
813
|
nextProbeAt = 0;
|
|
814
|
+
/** Consecutive child starts that did not survive; drives {@link startRetryAt}. */
|
|
815
|
+
startFailures = 0;
|
|
816
|
+
/** Earliest time `ready()` may spawn another child after a failed start. */
|
|
817
|
+
startRetryAt;
|
|
818
|
+
/** When the last start failure was reported, used to keep one death to one log line. */
|
|
819
|
+
lastStartFailureAt = 0;
|
|
714
820
|
/** Window groups (windowId per view), re-sent on every materialization so
|
|
715
821
|
* a restarted child still places views in the right windows. */
|
|
716
822
|
groups = new Map();
|
|
@@ -765,6 +871,38 @@ export class RemoteElectronViewHost {
|
|
|
765
871
|
}
|
|
766
872
|
return this.electronAvailable;
|
|
767
873
|
}
|
|
874
|
+
/**
|
|
875
|
+
* Retire the cached "an Electron binary is here" answer.
|
|
876
|
+
*
|
|
877
|
+
* A success is cached for the host's lifetime because rescanning on every tool call was
|
|
878
|
+
* the original bug — but the probe reads the FILESYSTEM, so it cannot see a binary that
|
|
879
|
+
* stops being usable afterwards. Only a real spawn attempt can observe that, which is why
|
|
880
|
+
* the two failure paths in {@link RemoteElectronViewHost.start} call this: the next
|
|
881
|
+
* `available()` rescans and reports what is actually on disk, instead of advertising a
|
|
882
|
+
* provider that cannot start.
|
|
883
|
+
*/
|
|
884
|
+
forgetBinary() {
|
|
885
|
+
this.electronAvailable = undefined;
|
|
886
|
+
this.nextProbeAt = 0;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Whether the binary the host would spawn — or the entry it needs — is no longer there.
|
|
890
|
+
*
|
|
891
|
+
* Only asked after a launch actually failed, so a healthy path never pays for it. The
|
|
892
|
+
* entry script counts because Electron exits 1 with empty stderr when it cannot load it,
|
|
893
|
+
* and resolution itself throws exactly when it can find nothing, which is the same answer.
|
|
894
|
+
* @returns true when the next `available()` must rescan instead of reusing its cache.
|
|
895
|
+
*/
|
|
896
|
+
binaryIsGone() {
|
|
897
|
+
if (!existsSync(this.hostMainPath))
|
|
898
|
+
return true;
|
|
899
|
+
try {
|
|
900
|
+
return !existsSync(this.spawnExecutable ?? resolveElectronPath());
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
768
906
|
/**
|
|
769
907
|
* Ensure the child is up and ready (lazy on first use; restarts after a crash).
|
|
770
908
|
*
|
|
@@ -788,8 +926,35 @@ export class RemoteElectronViewHost {
|
|
|
788
926
|
}
|
|
789
927
|
if (this.readyPromise !== undefined)
|
|
790
928
|
return this.readyPromise;
|
|
929
|
+
// Backoff gate for a host that keeps failing to START (see START_RETRY_SCHEDULE_MS).
|
|
930
|
+
// The client is undefined exactly when the previous child is gone — a start failure, or
|
|
931
|
+
// a child that died after it came up — so this only delays restarts, never first use.
|
|
932
|
+
const retryAt = this.startRetryAt;
|
|
933
|
+
if (retryAt !== undefined && Date.now() < retryAt && this.client === undefined) {
|
|
934
|
+
const waitMs = retryAt - Date.now();
|
|
935
|
+
const waiting = Promise.reject(new HostGoneError(`browser host did not start; retrying in ${formatDelay(waitMs)} `
|
|
936
|
+
+ `(attempt ${String(this.startFailures)}${this.startFailures >= START_FAILURES_MAX ? ', start attempts are now spaced out rather than immediate' : ''})`));
|
|
937
|
+
// Keep the rejection observable to callers while marking it handled here: the
|
|
938
|
+
// fire-and-forget callers that drive this path attach their own handler, but one that
|
|
939
|
+
// forgets must not turn the recovery gate into an unhandled rejection.
|
|
940
|
+
waiting.catch(() => undefined);
|
|
941
|
+
return waiting;
|
|
942
|
+
}
|
|
791
943
|
const started = this.start();
|
|
792
|
-
const wrapped = started.
|
|
944
|
+
const wrapped = started.then(() => {
|
|
945
|
+
// A child that came up clears the record: the next death is a new failure, not
|
|
946
|
+
// another entry in this run of them.
|
|
947
|
+
this.startFailures = 0;
|
|
948
|
+
this.startRetryAt = undefined;
|
|
949
|
+
}, error => {
|
|
950
|
+
// Record the failure BEFORE tearing anything down, and gate the teardown on the
|
|
951
|
+
// retry state being gone rather than on readyPromise alone. Ordering matters: a
|
|
952
|
+
// child that exits while the readiness call is still in flight fires the client's
|
|
953
|
+
// 'exit' handler first (onChildExit invalidates this promise and starts the retry
|
|
954
|
+
// clock), and the rejection then lands here — at which point clearing startRetryAt
|
|
955
|
+
// would erase the very backoff that was just armed, so every tool call spawned
|
|
956
|
+
// another dying child. noteStartFailure's dedupe window counts that pair once.
|
|
957
|
+
this.noteStartFailure(error);
|
|
793
958
|
// A failed startup must not poison the host forever: tear down whatever
|
|
794
959
|
// was half-created and let the next call retry from scratch.
|
|
795
960
|
if (this.readyPromise === wrapped) {
|
|
@@ -806,6 +971,54 @@ export class RemoteElectronViewHost {
|
|
|
806
971
|
this.readyPromise = wrapped;
|
|
807
972
|
return wrapped;
|
|
808
973
|
}
|
|
974
|
+
/**
|
|
975
|
+
* Record one child that did not survive, and set the earliest retry time.
|
|
976
|
+
*
|
|
977
|
+
* `available()` answers "is there a binary to spawn" from the FILESYSTEM, and a success
|
|
978
|
+
* there is cached for the host's lifetime (correctly — rescanning on every tool call was
|
|
979
|
+
* the bug). That probe therefore cannot see the failure mode where the binary exists and
|
|
980
|
+
* crashes on every launch: the signature `code=1` with empty stderr that host-log.test.mjs
|
|
981
|
+
* asserts. Each browser call then spawned a child that died within milliseconds, with no
|
|
982
|
+
* backoff and nothing that ever gives up — an endless crash loop, each iteration a window
|
|
983
|
+
* flashing on screen.
|
|
984
|
+
*
|
|
985
|
+
* The first retry is immediate, so the ordinary transient death (the DSH restart in
|
|
986
|
+
* remote-host-recovery.test.mjs) still heals on the very next call. After that the delay
|
|
987
|
+
* grows: once {@link START_FAILURES_MAX} attempts have failed, every later attempt is
|
|
988
|
+
* spaced by the table's last entry — the host keeps trying, but a permanently broken
|
|
989
|
+
* install now costs one spawn per 30s instead of one per tool call. Nothing is marked
|
|
990
|
+
* dead for good: a repaired installation heals on the next attempt, and `dispose()`
|
|
991
|
+
* still stops everything immediately.
|
|
992
|
+
* @param error - what the failed attempt reported.
|
|
993
|
+
*/
|
|
994
|
+
noteStartFailure(error) {
|
|
995
|
+
// The same death arrives twice: the child's exit reports it, and ready() rejecting
|
|
996
|
+
// reports it again. Counting both pushed the first retry to the 500ms step and made
|
|
997
|
+
// the log say "attempt 2" for a first failure, while START_RETRY_SCHEDULE_MS[0] = 0
|
|
998
|
+
// exists precisely so an ordinary death heals on the next call. So a report inside the
|
|
999
|
+
// window is dropped ENTIRELY — the count and the log line alike.
|
|
1000
|
+
//
|
|
1001
|
+
// That last part used to be split in two: the guard below, and a second dedupe that only
|
|
1002
|
+
// suppressed the LOG. The second one read `now - this.lastStartFailureAt` after the line
|
|
1003
|
+
// above had already stored `failureAt` into that same field, so its window was 0-1ms wide
|
|
1004
|
+
// and the comparison was unconditionally true — the log line was never written at all.
|
|
1005
|
+
// Nothing noticed, because this function exists to make a silent failure visible and its
|
|
1006
|
+
// only output was the thing being dropped: a child that exists and dies instantly (code=1,
|
|
1007
|
+
// empty stderr) left no trace anywhere.
|
|
1008
|
+
const failureAt = Date.now();
|
|
1009
|
+
if (this.lastStartFailureAt !== undefined && failureAt - this.lastStartFailureAt < START_FAILURE_DEDUPE_MS)
|
|
1010
|
+
return;
|
|
1011
|
+
this.lastStartFailureAt = failureAt;
|
|
1012
|
+
this.startFailures += 1;
|
|
1013
|
+
const index = Math.min(this.startFailures, START_RETRY_SCHEDULE_MS.length) - 1;
|
|
1014
|
+
const delayMs = START_RETRY_SCHEDULE_MS[index] ?? 0;
|
|
1015
|
+
this.startRetryAt = delayMs > 0 ? failureAt + delayMs : undefined;
|
|
1016
|
+
const line = `${hostStamp()} browser host start failed (attempt ${String(this.startFailures)}`
|
|
1017
|
+
+ `${this.startFailures >= START_FAILURES_MAX ? ', giving up on immediate retries' : ''}): ${String(error)}`
|
|
1018
|
+
+ `${delayMs > 0 ? ` — next attempt in ${formatDelay(delayMs)}` : ''}`;
|
|
1019
|
+
process.stderr.write(`[dsh-browser host] ${line}\n`);
|
|
1020
|
+
appendHostLog(`${line}\n`);
|
|
1021
|
+
}
|
|
809
1022
|
async start() {
|
|
810
1023
|
// Listen on an ephemeral loopback port; the child connects back. The
|
|
811
1024
|
// server accepts exactly one connection: the child's. Any further
|
|
@@ -874,6 +1087,20 @@ export class RemoteElectronViewHost {
|
|
|
874
1087
|
const wasDisposed = this.disposed;
|
|
875
1088
|
if (!wasDisposed)
|
|
876
1089
|
process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n');
|
|
1090
|
+
// A child that started and then died is also a failed start, and it is the shape a crash
|
|
1091
|
+
// loop takes (the child comes up far enough to be pending on `ping`, then exits): count
|
|
1092
|
+
// it so the restart is spaced instead of attempted on every single tool call.
|
|
1093
|
+
if (!wasDisposed)
|
|
1094
|
+
this.noteStartFailure('the browser host process exited');
|
|
1095
|
+
// The cached `available()` answer came from the FILESYSTEM, so only a real attempt can
|
|
1096
|
+
// contradict it. A binary that stops being usable after the probe said yes — an
|
|
1097
|
+
// interrupted reinstall, a removed mount, a wiped cache — kept the provider advertised
|
|
1098
|
+
// as available for the rest of the process's life, because provider selection reads
|
|
1099
|
+
// that same cached yes; every later call then paid a doomed spawn. Both failure routes
|
|
1100
|
+
// (a spawn error, and a child that exits) arrive here, so this is the one place that
|
|
1101
|
+
// observes the loss, and it costs two stats on a path that is already failing.
|
|
1102
|
+
if (this.binaryIsGone())
|
|
1103
|
+
this.forgetBinary();
|
|
877
1104
|
this.client = undefined;
|
|
878
1105
|
this.server?.close();
|
|
879
1106
|
this.server = undefined;
|
|
@@ -1024,11 +1251,18 @@ class DeferredRemoteView {
|
|
|
1024
1251
|
materializeOnce() {
|
|
1025
1252
|
if (this.materialized === undefined) {
|
|
1026
1253
|
const pending = this.materialize();
|
|
1027
|
-
|
|
1028
|
-
|
|
1254
|
+
// Compare against the promise actually stored — the derived one — not against `pending`.
|
|
1255
|
+
// Storing `pending.catch(...)` and comparing `this.materialized === pending` compared two
|
|
1256
|
+
// different objects, so the test was never true and the reset never ran: a materialization
|
|
1257
|
+
// that failed with anything other than a HostGoneError (the child alive but not answering
|
|
1258
|
+
// a ping, say) poisoned the handle for good, and the retry the comment below promises was
|
|
1259
|
+
// unreachable.
|
|
1260
|
+
const guarded = pending.catch(error => {
|
|
1261
|
+
if (this.materialized === guarded)
|
|
1029
1262
|
this.materialized = undefined;
|
|
1030
1263
|
throw error;
|
|
1031
1264
|
});
|
|
1265
|
+
this.materialized = guarded;
|
|
1032
1266
|
}
|
|
1033
1267
|
return this.materialized;
|
|
1034
1268
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RPC operation vocabulary, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The child (`host-main.ts`) switches on these names and the parent (`remote-host.ts`) sends
|
|
5
|
+
* them. Both spelled them out independently and nothing connected the two, so a rename on one
|
|
6
|
+
* side compiled fine and failed at run time as "unknown op" — and this pair has already
|
|
7
|
+
* produced one silent failure of that shape (`noteStartFailure`'s dedupe comparison, which was
|
|
8
|
+
* never true and meant a log line that never appeared).
|
|
9
|
+
*
|
|
10
|
+
* The lists are the source of truth for a test that reads both switch statements and asserts
|
|
11
|
+
* they agree, which is what makes a one-sided rename fail loudly instead of at run time.
|
|
12
|
+
*/
|
|
13
|
+
/** Operations the parent asks the child to perform. */
|
|
14
|
+
export declare const RPC_OPS: readonly ["ping", "createView", "destroyView", "showView", "groupView", "focus", "command", "capture", "download", "flushAuth", "restoreAuth", "navigate", "new-tab", "activate", "close", "back", "forward", "reload", "userActionError"];
|
|
15
|
+
/** Notifications the child sends the parent, which carry no reply. */
|
|
16
|
+
export declare const RPC_NOTIFICATIONS: readonly ["userAction", "viewClosed"];
|
|
17
|
+
/** One operation name. */
|
|
18
|
+
export type RpcOp = (typeof RPC_OPS)[number];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RPC operation vocabulary, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The child (`host-main.ts`) switches on these names and the parent (`remote-host.ts`) sends
|
|
5
|
+
* them. Both spelled them out independently and nothing connected the two, so a rename on one
|
|
6
|
+
* side compiled fine and failed at run time as "unknown op" — and this pair has already
|
|
7
|
+
* produced one silent failure of that shape (`noteStartFailure`'s dedupe comparison, which was
|
|
8
|
+
* never true and meant a log line that never appeared).
|
|
9
|
+
*
|
|
10
|
+
* The lists are the source of truth for a test that reads both switch statements and asserts
|
|
11
|
+
* they agree, which is what makes a one-sided rename fail loudly instead of at run time.
|
|
12
|
+
*/
|
|
13
|
+
/** Operations the parent asks the child to perform. */
|
|
14
|
+
export const RPC_OPS = [
|
|
15
|
+
'ping',
|
|
16
|
+
'createView',
|
|
17
|
+
'destroyView',
|
|
18
|
+
'showView',
|
|
19
|
+
'groupView',
|
|
20
|
+
'focus',
|
|
21
|
+
'command',
|
|
22
|
+
'capture',
|
|
23
|
+
'download',
|
|
24
|
+
'flushAuth',
|
|
25
|
+
'restoreAuth',
|
|
26
|
+
'navigate',
|
|
27
|
+
'new-tab',
|
|
28
|
+
'activate',
|
|
29
|
+
'close',
|
|
30
|
+
'back',
|
|
31
|
+
'forward',
|
|
32
|
+
'reload',
|
|
33
|
+
'userActionError',
|
|
34
|
+
];
|
|
35
|
+
/** Notifications the child sends the parent, which carry no reply. */
|
|
36
|
+
export const RPC_NOTIFICATIONS = ['userAction', 'viewClosed'];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admission for the settings route: who may read the document, and who may write it.
|
|
3
|
+
*
|
|
4
|
+
* Its own module for a blunt reason — `entry.ts` imports the host's peer packages (cordis, the
|
|
5
|
+
* tools registry) and therefore cannot be loaded by the test suite, while this is precisely the
|
|
6
|
+
* logic whose failure is invisible in a browser: too strict and the panel silently stops being
|
|
7
|
+
* able to save, too loose and any process on the machine can rewrite the operator's switches.
|
|
8
|
+
*
|
|
9
|
+
* It is NOT authentication, and cannot be. The plugin has no credential to check: DSH hands
|
|
10
|
+
* plugin routes the raw request (`packages/host/webserver/src/index.ts` registers handlers with
|
|
11
|
+
* no gate of its own), so the strongest same-origin evidence available is what a browser
|
|
12
|
+
* reliably sends.
|
|
13
|
+
*
|
|
14
|
+
* What that buys and what it does not:
|
|
15
|
+
* - a plain HTTP client (curl, a script, another process) cannot WRITE, because it sends no
|
|
16
|
+
* `user-agent` and no `accept-language`;
|
|
17
|
+
* - a page of THIS origin can, because a browser sending a same-origin write looks exactly
|
|
18
|
+
* like the panel. That is inherent to exposing an HTTP endpoint at all, and it is why the
|
|
19
|
+
* settings panel's own text must not promise a lock that does not exist.
|
|
20
|
+
*/
|
|
21
|
+
import type { IncomingMessage } from 'node:http';
|
|
22
|
+
/**
|
|
23
|
+
* Whether a request may touch the settings document.
|
|
24
|
+
* @param request - the incoming request.
|
|
25
|
+
* @param writing - true for PUT/POST, which is held to the stricter rule.
|
|
26
|
+
* @returns whether the request is admitted.
|
|
27
|
+
*/
|
|
28
|
+
export declare function sameOrigin(request: IncomingMessage, writing?: boolean): boolean;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** The shell's own URL schemes. Only the shell can mint a document on one of these. */
|
|
2
|
+
const APP_SCHEMES = ['dsh-app:', 'dsh-desktop:'];
|
|
3
|
+
/**
|
|
4
|
+
* The scheme of a header value, when it names a URL.
|
|
5
|
+
* @param value - a raw header value.
|
|
6
|
+
* @returns the scheme, or undefined when the value is absent, opaque or unparseable.
|
|
7
|
+
*/
|
|
8
|
+
function schemeOf(value) {
|
|
9
|
+
if (value === '' || value === 'null')
|
|
10
|
+
return undefined;
|
|
11
|
+
try {
|
|
12
|
+
return new URL(value).protocol;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether a request may touch the settings document.
|
|
20
|
+
* @param request - the incoming request.
|
|
21
|
+
* @param writing - true for PUT/POST, which is held to the stricter rule.
|
|
22
|
+
* @returns whether the request is admitted.
|
|
23
|
+
*/
|
|
24
|
+
export function sameOrigin(request, writing = false) {
|
|
25
|
+
const origin = String(request.headers.origin ?? '').trim();
|
|
26
|
+
const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase();
|
|
27
|
+
// A shell URL, in either the Origin or the referrer: the strongest evidence there is.
|
|
28
|
+
if (APP_SCHEMES.includes(schemeOf(origin) ?? ''))
|
|
29
|
+
return true;
|
|
30
|
+
if (APP_SCHEMES.includes(schemeOf(String(request.headers.referer ?? '')) ?? ''))
|
|
31
|
+
return true;
|
|
32
|
+
// MEASURED on the running desktop, and what three earlier attempts each guessed wrong: a
|
|
33
|
+
// request from the shell's own settings panel carries NO origin, NO sec-fetch-site and NO
|
|
34
|
+
// referer. It is not "dsh-app://app" and it is not "null" — the headers are simply absent,
|
|
35
|
+
// because Chromium attaches no cross-origin metadata to a request leaving a custom scheme.
|
|
36
|
+
//
|
|
37
|
+
// So the rule for that shape cannot be "a request without an Origin is a script", which is
|
|
38
|
+
// what refused every desktop save, and it cannot lean on `Sec-Fetch-Site` either. It has to
|
|
39
|
+
// test what a hand-rolled client does not send:
|
|
40
|
+
// - a renderer sends a full Mozilla-compatible `user-agent` AND an `accept-language`;
|
|
41
|
+
// - curl sends neither unless told to, and a local process that forges both can also read
|
|
42
|
+
// the settings document directly — it sits readable on disk, as the panel's own text
|
|
43
|
+
// says. This was never the boundary it looked like.
|
|
44
|
+
if (origin === '') {
|
|
45
|
+
const agent = String(request.headers['user-agent'] ?? '');
|
|
46
|
+
const language = String(request.headers['accept-language'] ?? '');
|
|
47
|
+
if (/^Mozilla\/5\.0/.test(agent) && language !== '')
|
|
48
|
+
return true;
|
|
49
|
+
// Reads stay open: they return the document the panel is already showing, and refusing them
|
|
50
|
+
// would only break hand-inspection of a file that sits readable on disk anyway.
|
|
51
|
+
return !writing;
|
|
52
|
+
}
|
|
53
|
+
// `cross-site` is another origin entirely; `same-site` is another origin of the same site
|
|
54
|
+
// (a.example.com -> b.example.com), which is not this panel either. Both are refused.
|
|
55
|
+
if (site === 'cross-site' || site === 'same-site')
|
|
56
|
+
return false;
|
|
57
|
+
// `Origin: null` is a sandboxed or opaque-origin document. Never this panel.
|
|
58
|
+
if (origin === 'null')
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
const parsed = new URL(origin);
|
|
62
|
+
// The host comparison is for http(s), where a page and its route share one host.
|
|
63
|
+
return parsed.host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|