specrails-desktop 2.63.2 → 2.64.1

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.
Files changed (76) hide show
  1. package/client/dist/assets/{ActivityFeedPage-B1bpTy7O.js → ActivityFeedPage-Dtp7EyHc.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-BussBbN3.js → AgentBrowserCapture-CLqlEFw8.js} +1 -1
  3. package/client/dist/assets/{AgentModeAnalyticsPane-BiT1n3BU.js → AgentModeAnalyticsPane-B8_hM3Tw.js} +2 -2
  4. package/client/dist/assets/{AgentModeCodePane-CUjNfeKa.js → AgentModeCodePane-CNF8ZNjE.js} +2 -2
  5. package/client/dist/assets/{AgentModeJobsPane-RVEwyS59.js → AgentModeJobsPane-CJpJbO9m.js} +1 -1
  6. package/client/dist/assets/AgentsPage-BC8XSR79.js +120 -0
  7. package/client/dist/assets/{AnalyticsPage-Bser2Ly3.js → AnalyticsPage-Cdv4jLtW.js} +1 -1
  8. package/client/dist/assets/{CodePage-BK0grm6e.js → CodePage-DI1kegAr.js} +1 -1
  9. package/client/dist/assets/{DesktopAnalyticsPage-DEBidZbs.js → DesktopAnalyticsPage-Bdp8E8U6.js} +1 -1
  10. package/client/dist/assets/{DocsDialog-Dhyo57n9.js → DocsDialog-Bm4RS7FX.js} +1 -1
  11. package/client/dist/assets/{DocsPage-BcEkPXd6.js → DocsPage-DihYcU8r.js} +1 -1
  12. package/client/dist/assets/{ExportDropdown-mRmqdkjr.js → ExportDropdown-Bb1lHx3r.js} +1 -1
  13. package/client/dist/assets/{InteractiveJobComposer-BlCe3vrU.js → InteractiveJobComposer-BO6ASN-2.js} +1 -1
  14. package/client/dist/assets/{JobDetailModal-CkAejD1F.js → JobDetailModal-CuPYq7tP.js} +1 -1
  15. package/client/dist/assets/{JobDetailPage-P-FS_lKz.js → JobDetailPage-BusMjJi9.js} +1 -1
  16. package/client/dist/assets/{JobsPage-CoUXgYse.js → JobsPage-C0QbsF2d.js} +1 -1
  17. package/client/dist/assets/{LoopBuilderPage-iFylNk68.js → LoopBuilderPage-B7TlU4iS.js} +1 -1
  18. package/client/dist/assets/{LoopPreviewModal-4obS_Ptg.js → LoopPreviewModal-BRWtXOD2.js} +1 -1
  19. package/client/dist/assets/{LoopWindowSurface-CLr5OPqj.js → LoopWindowSurface-DE0v2K1p.js} +1 -1
  20. package/client/dist/assets/{LoopsPage-eKpWFtCp.js → LoopsPage-C4q1DmCE.js} +1 -1
  21. package/client/dist/assets/{MinimizedChatsContext-BHIUPVmC.js → MinimizedChatsContext-DHfigjHS.js} +1 -1
  22. package/client/dist/assets/{PluginsPage-BgTC2CQx.js → PluginsPage-Z1JHSMdR.js} +1 -1
  23. package/client/dist/assets/{ProjectSettingsDialog-B4chTfU8.js → ProjectSettingsDialog-BUNj0tvN.js} +1 -1
  24. package/client/dist/assets/{RepositoryDeliveries-DAIxIYsl.js → RepositoryDeliveries-B4ybHOyB.js} +1 -1
  25. package/client/dist/assets/{RepositoryScopeSelector-DeCoUsRN.js → RepositoryScopeSelector-2eSqR9WK.js} +1 -1
  26. package/client/dist/assets/{ReviewPacketPage-Cktv0-9b.js → ReviewPacketPage-hwfPNjyy.js} +1 -1
  27. package/client/dist/assets/{TemplatePreviewModal-BZTV1TM1.js → TemplatePreviewModal-V7FpMWuw.js} +1 -1
  28. package/client/dist/assets/{TicketDetailModalContext-DiPngt2g.js → TicketDetailModalContext-Dkwo2cDs.js} +1 -1
  29. package/client/dist/assets/{Trans-ZiXIfj50.js → Trans-BbFxRFtR.js} +1 -1
  30. package/client/dist/assets/{formatDistanceToNow-CdN5lTyD.js → formatDistanceToNow-NjPHBLfB.js} +1 -1
  31. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-62h40I4t.js → getTimezoneOffsetInMilliseconds-vJ7qUoqN.js} +1 -1
  32. package/client/dist/assets/{index-CECj3joE.js → index-Ce4yZtKf.js} +36 -36
  33. package/client/dist/assets/{jira-api-C_D_RDhl.js → jira-api-Cabj-H0J.js} +1 -1
  34. package/client/dist/assets/{project-repositories-Be0CWklo.js → project-repositories-Cnnuj9Ia.js} +1 -1
  35. package/client/dist/assets/{settings-Hw7av-B1.js → settings-B1NV8Ow9.js} +1 -1
  36. package/client/dist/assets/settings-BQ8XFZeE.js +1 -0
  37. package/client/dist/assets/settings-BRkxfBbO.js +1 -0
  38. package/client/dist/assets/settings-C_CcU1dP.js +1 -0
  39. package/client/dist/assets/settings-Cto9n0eL.js +1 -0
  40. package/client/dist/assets/{settings-nryN6wuS.js → settings-Pyw8uYtL.js} +1 -1
  41. package/client/dist/assets/settings-b3HVCNNd.js +1 -0
  42. package/client/dist/assets/settings-hdive4u6.js +1 -0
  43. package/client/dist/assets/{spending-eWiZGhC_.js → spending-CQnrWiR3.js} +1 -1
  44. package/client/dist/assets/{useDesktop-C0mRIlHT.js → useDesktop-BjLT0_EE.js} +1 -1
  45. package/client/dist/assets/{useRuntimeRuns-B1a6GChu.js → useRuntimeRuns-C97qEqMg.js} +1 -1
  46. package/client/dist/assets/{useSharedWebSocket-CcsE5986.js → useSharedWebSocket-Bj4LMp7b.js} +2 -2
  47. package/client/dist/index.html +15 -15
  48. package/docs/internals/api-reference.md +26 -0
  49. package/docs/internals/core-runtime-updates.md +3 -3
  50. package/docs/internals/source-map.md +9 -2
  51. package/local-runner/dist/specrails-local-runner.js +487 -148
  52. package/mcp-bridge/dist/specrails-mcp.js +330 -42
  53. package/package.json +6 -3
  54. package/server/dist/index.js +14 -1
  55. package/server/dist/mobile/mobile-admin-router.js +16 -0
  56. package/server/dist/mobile/mobile-gateway.js +18 -0
  57. package/server/dist/mobile/mobile-missions.js +6 -4
  58. package/server/dist/mobile/mobile-preview-channel.js +179 -0
  59. package/server/dist/mobile/mobile-preview.js +366 -0
  60. package/server/dist/mobile/mobile-router.js +9 -1
  61. package/server/dist/mobile/mobile-webrtc-peer.js +54 -4
  62. package/server/dist/mobile/mobile-ws.js +4 -2
  63. package/server/dist/modules/browser/domain/preview-origins.js +117 -0
  64. package/server/dist/modules/browser/runtime/browser-capture-manager.js +141 -18
  65. package/server/dist/modules/browser/runtime/browser-capture-types.js +10 -1
  66. package/server/dist/modules/browser/runtime/browser-playwright.js +19 -0
  67. package/server/dist/modules/browser/runtime/preview-targets.js +80 -0
  68. package/server/dist/modules/terminals/runtime/background-process-service.js +24 -1
  69. package/server/dist/project-registry.js +10 -0
  70. package/client/dist/assets/AgentsPage-DKE-eMC5.js +0 -118
  71. package/client/dist/assets/settings-BO2N-oPf.js +0 -1
  72. package/client/dist/assets/settings-BQChAs_w.js +0 -1
  73. package/client/dist/assets/settings-BzmYlcQB.js +0 -1
  74. package/client/dist/assets/settings-CGjXb6TX.js +0 -1
  75. package/client/dist/assets/settings-CMxEWY46.js +0 -1
  76. package/client/dist/assets/settings-C_dRSgI2.js +0 -1
@@ -9,6 +9,7 @@ const mobile_auth_1 = require("./mobile-auth");
9
9
  const mobile_devices_1 = require("./mobile-devices");
10
10
  const mobile_missions_1 = require("./mobile-missions");
11
11
  const mobile_code_1 = require("./mobile-code");
12
+ const mobile_preview_1 = require("./mobile-preview");
12
13
  // The gateway's authenticated REST surface: the `/v1/*` allow-list. Each route
13
14
  // forwards, in-process, via a REAL loopback HTTP request to
14
15
  // http://127.0.0.1:<desktopPort> with the master token injected server-side as
@@ -455,7 +456,9 @@ function createMobileRouter(deps) {
455
456
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
456
457
  return { status: response.status, json: await response.json() };
457
458
  };
458
- v1.use((0, mobile_missions_1.createMobileMissionsRouter)({ db: deps.db, upstream: mobileUpstream }));
459
+ v1.use((0, mobile_missions_1.createMobileMissionsRouter)({ db: deps.db, upstream: mobileUpstream, appPreview: () => deps.preview?.available() ?? false }));
460
+ if (deps.preview)
461
+ v1.use((0, mobile_preview_1.createMobilePreviewRouter)({ db: deps.db, preview: deps.preview }));
459
462
  v1.use((0, mobile_code_1.createMobileCodeRouter)(mobileUpstream));
460
463
  router.use('/v1', v1);
461
464
  // Any unmatched gateway path → 404 JSON (NEVER falls through to a SPA handler;
@@ -472,6 +475,11 @@ exports.MOBILE_ALLOWLIST = [
472
475
  { method: 'GET', path: '/v1/projects/:pid/repositories/:rid/code/find' },
473
476
  { method: 'GET', path: '/v1/projects/:pid/repositories/:rid/code/file' },
474
477
  { method: 'GET', path: '/v1/capabilities' },
478
+ { method: 'GET', path: '/v1/projects/:pid/missions/:cid/preview-targets' },
479
+ { method: 'POST', path: '/v1/projects/:pid/preview-sessions' },
480
+ { method: 'POST', path: '/v1/projects/:pid/preview-sessions/:sid/attach' },
481
+ { method: 'POST', path: '/v1/projects/:pid/preview-sessions/:sid/navigate' },
482
+ { method: 'DELETE', path: '/v1/projects/:pid/preview-sessions/:sid' },
475
483
  { method: 'GET', path: '/v1/mission-models' },
476
484
  { method: 'PATCH', path: '/v1/projects/:pid/missions/:cid/messages/:mid/intent' },
477
485
  { method: 'GET', path: '/v1/projects/:pid/repositories' },
@@ -14,6 +14,9 @@ class MobileWebrtcGateway {
14
14
  // answer to whatever happened to be pending. Per-room keying isolates them.
15
15
  _pending = new Map();
16
16
  _active = new Set();
17
+ /** The live peer of each authenticated device (latest wins), so Desktop can
18
+ * open extra DataChannels (app preview) on the connection it already has. */
19
+ _byDevice = new Map();
17
20
  constructor(_deps) {
18
21
  this._deps = _deps;
19
22
  }
@@ -48,17 +51,23 @@ class MobileWebrtcGateway {
48
51
  // require a valid existing device token instead, and never mint.
49
52
  new mobile_datachannel_1.DataChannelPeer(toChannelLike(dc), {
50
53
  registerDevice: async (input) => {
54
+ let result;
51
55
  if (isReconnect) {
52
- return this._deps.registerDevice({
56
+ result = await this._deps.registerDevice({
53
57
  deviceName: input.deviceName,
54
58
  platform: input.platform,
55
59
  token: input.token,
56
60
  requireExistingToken: true,
57
61
  });
58
62
  }
59
- return input.secret && (0, auth_1.safeEqual)(input.secret, secret)
60
- ? this._deps.registerDevice({ deviceName: input.deviceName, platform: input.platform, token: input.token })
61
- : { ok: false };
63
+ else {
64
+ result = input.secret && (0, auth_1.safeEqual)(input.secret, secret)
65
+ ? await this._deps.registerDevice({ deviceName: input.deviceName, platform: input.platform, token: input.token })
66
+ : { ok: false };
67
+ }
68
+ if (result.ok)
69
+ this._byDevice.set(result.deviceId, pc);
70
+ return result;
62
71
  },
63
72
  rpcDispatch: this._deps.rpcDispatch,
64
73
  bridge: this._deps.bridge,
@@ -67,6 +76,9 @@ class MobileWebrtcGateway {
67
76
  console.log('[webrtc] pc connectionState:', s);
68
77
  if (s === 'failed' || s === 'closed' || s === 'disconnected') {
69
78
  this._active.delete(pc);
79
+ for (const [deviceId, owned] of this._byDevice)
80
+ if (owned === pc)
81
+ this._byDevice.delete(deviceId);
70
82
  // Drop this pc from whichever room slot still holds it.
71
83
  if (this._pending.get(key)?.pc === pc)
72
84
  this._pending.delete(key);
@@ -101,6 +113,24 @@ class MobileWebrtcGateway {
101
113
  return { ok: false };
102
114
  }
103
115
  }
116
+ /** Open an ordered, reliable DataChannel on the device's live peer (no SDP
117
+ * renegotiation: the SCTP association already exists). Null when the
118
+ * device has no connected WebRTC peer. */
119
+ openChannel(deviceId, label) {
120
+ const pc = this._byDevice.get(deviceId);
121
+ if (!pc || pc.connectionState !== 'connected')
122
+ return null;
123
+ try {
124
+ return toPreviewChannel(pc.createDataChannel(label, { ordered: true }));
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ /** Forget a device's peer (revoked). Its channels close with the peer. */
131
+ forgetDevice(deviceId) {
132
+ this._byDevice.delete(deviceId);
133
+ }
104
134
  /** Tear down every peer (gateway stopping / disabled). */
105
135
  stop() {
106
136
  for (const key of [...this._pending.keys()])
@@ -108,6 +138,7 @@ class MobileWebrtcGateway {
108
138
  for (const pc of this._active)
109
139
  this._close(pc);
110
140
  this._active.clear();
141
+ this._byDevice.clear();
111
142
  }
112
143
  _close(pc) {
113
144
  try {
@@ -137,6 +168,25 @@ function toChannelLike(dc) {
137
168
  },
138
169
  };
139
170
  }
171
+ function toPreviewChannel(dc) {
172
+ return {
173
+ get label() { return dc.label; },
174
+ get readyState() { return dc.readyState; },
175
+ get bufferedAmount() { return dc.bufferedAmount; },
176
+ send: (data) => dc.send(data),
177
+ close: () => dc.close(),
178
+ onOpen: (cb) => {
179
+ if (dc.readyState === 'open')
180
+ queueMicrotask(cb);
181
+ else
182
+ dc.stateChanged.subscribe((s) => { if (s === 'open')
183
+ cb(); });
184
+ },
185
+ onMessage: (cb) => { dc.onMessage.subscribe((d) => cb(d)); },
186
+ onClose: (cb) => { dc.stateChanged.subscribe((s) => { if (s === 'closed')
187
+ cb(); }); },
188
+ };
189
+ }
140
190
  /** Vanilla ICE: resolve once gathering completes so the offer SDP carries all
141
191
  * candidates (there's no trickle channel — the SDP travels by QR). */
142
192
  function waitIceComplete(pc) {
@@ -33,7 +33,8 @@ function topicFor(type) {
33
33
  case 'agent_pr_decision':
34
34
  case 'background_process.started':
35
35
  case 'background_process.updated':
36
- case 'background_process.exited': return 'missions';
36
+ case 'background_process.exited':
37
+ case 'background_process.preview_targets': return 'missions';
37
38
  case 'queue': return 'queue';
38
39
  case 'phase': return 'phase';
39
40
  case 'ticket_created':
@@ -253,7 +254,8 @@ class MobileWsBridge {
253
254
  continue;
254
255
  const value = msg;
255
256
  const process = value.process;
256
- const cid = typeof value.conversationId === 'string' ? value.conversationId : process?.chatId;
257
+ const cid = typeof value.conversationId === 'string' ? value.conversationId
258
+ : typeof value.chatId === 'string' ? value.chatId : process?.chatId;
257
259
  const projectId = cid ? this._missionProjectFor(cid) : null;
258
260
  if (!projectId || !s.projects.has(projectId))
259
261
  continue;
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ // Pure rules for the Companion app preview: which local URLs a background
3
+ // process announces, how loopback origins are normalised, and which
4
+ // navigations a device-owned preview session may perform. No I/O here — the
5
+ // runtime feeds log lines and the announced-origin set in.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.isLoopbackHost = isLoopbackHost;
8
+ exports.previewOrigin = previewOrigin;
9
+ exports.extractPreviewUrls = extractPreviewUrls;
10
+ exports.previewTargetsFromLines = previewTargetsFromLines;
11
+ exports.isPreviewRequestAllowed = isPreviewRequestAllowed;
12
+ exports.isManualPreviewUrlAllowed = isManualPreviewUrlAllowed;
13
+ const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
14
+ // Host alternatives a dev server prints for "this machine". Bracketed IPv6
15
+ // forms are matched literally; the wildcard binds are rewritten to localhost.
16
+ const LOCAL_URL = /\bhttps?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1?\])(?::\d{1,5})?(?:\/[^\s'"<>)\]`]*)?/gi;
17
+ const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
18
+ /** A loopback host as dev servers print it (wildcard binds included). */
19
+ function isLoopbackHost(hostname) {
20
+ const host = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
21
+ return host === 'localhost' || host.endsWith('.localhost') || host === '::1' || host === '::' ||
22
+ host === '0.0.0.0' || /^127(?:\.\d{1,3}){3}$/.test(host);
23
+ }
24
+ /**
25
+ * The comparable origin of `raw`, with every loopback spelling collapsed to
26
+ * `localhost` (so `http://127.0.0.1:5173` and `http://[::1]:5173` match an
27
+ * announced `http://localhost:5173`). Null for unparsable or non-http(s) URLs.
28
+ */
29
+ function previewOrigin(raw) {
30
+ let url;
31
+ try {
32
+ url = new URL(raw.trim());
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
38
+ return null;
39
+ const host = isLoopbackHost(url.hostname) ? 'localhost' : url.hostname.toLowerCase();
40
+ const port = url.port || (url.protocol === 'https:' ? '443' : '80');
41
+ return `${url.protocol}//${host}:${port}`;
42
+ }
43
+ /** Normalise an announced URL: loopback host → `localhost`, explicit path kept. */
44
+ function normalizeAnnouncedUrl(raw) {
45
+ let url;
46
+ try {
47
+ url = new URL(raw.replace(TRAILING_PUNCTUATION, ''));
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ if (!isLoopbackHost(url.hostname))
53
+ return null;
54
+ const port = url.port ? Number(url.port) : NaN;
55
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
56
+ return null;
57
+ url.hostname = 'localhost';
58
+ url.hash = '';
59
+ return url.toString();
60
+ }
61
+ /**
62
+ * Local URLs a log line announces (`➜ Local: http://localhost:5173/`,
63
+ * `ready on http://0.0.0.0:3000`), normalised and de-duplicated by origin in
64
+ * order of appearance. URLs without an explicit port are ignored: a dev server
65
+ * always prints one, while bare `http://localhost` mentions are usually docs.
66
+ */
67
+ function extractPreviewUrls(line) {
68
+ const text = line.replace(ANSI, '');
69
+ const found = [];
70
+ const origins = new Set();
71
+ for (const match of text.matchAll(LOCAL_URL)) {
72
+ const url = normalizeAnnouncedUrl(match[0]);
73
+ const origin = url && previewOrigin(url);
74
+ if (!url || !origin || origins.has(origin))
75
+ continue;
76
+ origins.add(origin);
77
+ found.push(url);
78
+ }
79
+ return found;
80
+ }
81
+ /**
82
+ * Latest-first, origin-unique targets from a process's log lines (oldest first).
83
+ * A re-announced origin moves to the front with its newest URL.
84
+ */
85
+ function previewTargetsFromLines(lines) {
86
+ const byOrigin = new Map();
87
+ for (const line of lines) {
88
+ for (const url of extractPreviewUrls(line)) {
89
+ const origin = previewOrigin(url);
90
+ byOrigin.delete(origin);
91
+ byOrigin.set(origin, url);
92
+ }
93
+ }
94
+ return [...byOrigin.values()].reverse();
95
+ }
96
+ /**
97
+ * Whether a device-owned preview session may load `url` (a navigation or a
98
+ * subresource request). Announced origins of the project are allowed even
99
+ * though they are loopback; everything else must pass the regular server-side
100
+ * guard (`fallback`, i.e. `isNavigableUrl`), which rejects loopback/private
101
+ * hosts and non-http(s) schemes.
102
+ */
103
+ function isPreviewRequestAllowed(url, announcedOrigins, fallback) {
104
+ const origin = previewOrigin(url);
105
+ if (origin && announcedOrigins.has(origin))
106
+ return true;
107
+ return fallback(url);
108
+ }
109
+ /**
110
+ * Whether a device may *type* `url` (create or manual navigate). Stricter than
111
+ * the request guard: only paths on announced origins, never an arbitrary
112
+ * public URL — Desktop must not become a general browser proxy for the phone.
113
+ */
114
+ function isManualPreviewUrlAllowed(url, announcedOrigins) {
115
+ const origin = previewOrigin(url);
116
+ return origin !== null && announcedOrigins.has(origin);
117
+ }
@@ -10,6 +10,7 @@ const ids_1 = require("../../../ids");
10
10
  const attachment_manager_1 = require("../../../attachment-manager");
11
11
  const browser_capture_types_1 = require("./browser-capture-types");
12
12
  const browser_playwright_1 = require("./browser-playwright");
13
+ const preview_origins_1 = require("../domain/preview-origins");
13
14
  const browser_viewport_1 = require("./browser-viewport");
14
15
  const WS_OPEN = 1;
15
16
  const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
@@ -20,6 +21,14 @@ function isTargetClosedError(err) {
20
21
  return /target page, context or browser has been closed|target closed|has been closed/i.test(msg);
21
22
  }
22
23
  const MAX_SESSIONS_PER_PROJECT = 4;
24
+ /** Companion app-preview sessions have their own budget so a phone can never
25
+ * take Desktop's sessions: one per device (a new one replaces it), two per
26
+ * project across devices. */
27
+ const MAX_DEVICE_SESSIONS_PER_PROJECT = 2;
28
+ /** The announced-origin set is consulted on every request of a preview page
29
+ * (a dev server loads hundreds of modules); re-derive it at most this often. */
30
+ const PREVIEW_ORIGINS_TTL_MS = 1000;
31
+ const DESKTOP_OWNER = { kind: 'desktop' };
23
32
  const DEFAULT_SESSION_IDLE_TTL_MS = 10 * 60_000;
24
33
  const DEFAULT_SESSION_SWEEP_INTERVAL_MS = 60_000;
25
34
  const DOM_HTML_BYTE_CAP = 100_000;
@@ -56,13 +65,15 @@ class BrowserCaptureManager {
56
65
  contextPool;
57
66
  now;
58
67
  sessionIdleTtlMs;
68
+ previewOrigins;
69
+ previewOriginsCache = null;
59
70
  idleSweepTimer = null;
60
71
  context = null;
61
72
  contextPromise = null;
62
73
  sessions = new Map();
63
74
  /** Count of in-flight `create()` calls that have reserved a slot but not yet
64
75
  * committed their session — guards the cap against concurrent creates. */
65
- _reserved = 0;
76
+ _reserved = { desktop: 0, device: 0 };
66
77
  disposed = false;
67
78
  constructor(opts) {
68
79
  this.projectId = opts.projectId;
@@ -72,6 +83,7 @@ class BrowserCaptureManager {
72
83
  this.attachments = opts.attachments ?? attachment_manager_1.attachmentManager;
73
84
  this.contextPool = opts.contextPool ?? null;
74
85
  this.now = opts.now ?? Date.now;
86
+ this.previewOrigins = opts.previewOrigins ?? (() => new Set());
75
87
  const requestedIdleTtlMs = opts.sessionIdleTtlMs;
76
88
  this.sessionIdleTtlMs = typeof requestedIdleTtlMs === 'number' && Number.isFinite(requestedIdleTtlMs) && requestedIdleTtlMs > 0
77
89
  ? requestedIdleTtlMs
@@ -144,6 +156,7 @@ class BrowserCaptureManager {
144
156
  viewportWidth: s.viewport.width,
145
157
  viewportHeight: s.viewport.height,
146
158
  createdAt: s.createdAt,
159
+ owner: s.owner,
147
160
  };
148
161
  }
149
162
  listSessions() {
@@ -167,27 +180,46 @@ class BrowserCaptureManager {
167
180
  s.activeOperations = Math.max(0, s.activeOperations - 1);
168
181
  s.lastActivityAt = this.now();
169
182
  }
183
+ liveCount(kind) {
184
+ return [...this.sessions.values()].filter((s) => !s.closed && s.owner.kind === kind).length;
185
+ }
170
186
  async create(opts) {
187
+ const owner = opts?.owner ?? DESKTOP_OWNER;
188
+ const kind = owner.kind;
189
+ const limit = kind === 'device' ? MAX_DEVICE_SESSIONS_PER_PROJECT : MAX_SESSIONS_PER_PROJECT;
190
+ if (owner.kind === 'device') {
191
+ // A phone may only type announced origins (never an arbitrary URL), and
192
+ // is validated BEFORE replacing its previous session so a bad request
193
+ // leaves the current preview alone.
194
+ const initialUrl = opts?.initialUrl?.trim() ?? '';
195
+ if (!initialUrl || !(0, preview_origins_1.isManualPreviewUrlAllowed)(initialUrl, this.currentPreviewOrigins(true))) {
196
+ throw new browser_capture_types_1.BrowserNavigationRejectedError();
197
+ }
198
+ for (const existing of [...this.sessions.values()]) {
199
+ if (!existing.closed && existing.owner.kind === 'device' && existing.owner.deviceId === owner.deviceId) {
200
+ await this.kill(existing.id);
201
+ }
202
+ }
203
+ }
171
204
  // Reserve the slot SYNCHRONOUSLY before any await so N concurrent creates can't
172
205
  // all observe `< MAX` and race past the cap (BUG-BROWSER-02). `_reserved`
173
- // counts in-flight (not-yet-inserted) sessions; it's the only state that
174
- // mutates synchronously here.
175
- const live = [...this.sessions.values()].filter((s) => !s.closed).length;
176
- if (live + this._reserved >= MAX_SESSIONS_PER_PROJECT) {
177
- throw new browser_capture_types_1.BrowserLimitExceededError(MAX_SESSIONS_PER_PROJECT);
206
+ // counts in-flight (not-yet-inserted) sessions per budget; it's the only
207
+ // state that mutates synchronously here.
208
+ if (this.liveCount(kind) + this._reserved[kind] >= limit) {
209
+ throw new browser_capture_types_1.BrowserLimitExceededError(limit);
178
210
  }
179
- this._reserved++;
211
+ this._reserved[kind]++;
180
212
  let page;
181
213
  try {
182
214
  const ctx = await this.ensureContext();
183
215
  page = await ctx.newPage();
184
216
  }
185
217
  catch (err) {
186
- this._reserved--;
218
+ this._reserved[kind]--;
187
219
  throw err;
188
220
  }
189
221
  if (this.disposed) {
190
- this._reserved--;
222
+ this._reserved[kind]--;
191
223
  try {
192
224
  await page.close();
193
225
  }
@@ -197,20 +229,37 @@ class BrowserCaptureManager {
197
229
  // Re-check AFTER the awaits: another create that started before us may have
198
230
  // committed its session in the meantime. If the cap is now exceeded, tear the
199
231
  // extra page down and reject rather than silently exceeding the limit.
200
- const liveNow = [...this.sessions.values()].filter((s) => !s.closed).length;
201
- if (liveNow >= MAX_SESSIONS_PER_PROJECT) {
202
- this._reserved--;
232
+ if (this.liveCount(kind) >= limit) {
233
+ this._reserved[kind]--;
203
234
  try {
204
235
  await page.close();
205
236
  }
206
237
  catch { /* ignore */ }
207
- throw new browser_capture_types_1.BrowserLimitExceededError(MAX_SESSIONS_PER_PROJECT);
238
+ throw new browser_capture_types_1.BrowserLimitExceededError(limit);
239
+ }
240
+ if (kind === 'device') {
241
+ // Fail closed: a preview page without request interception could reach
242
+ // any loopback port of this machine.
243
+ try {
244
+ if (!page.setRequestGuard)
245
+ throw new Error('request guard unsupported');
246
+ await page.setRequestGuard((url) => this.allowDeviceRequest(url));
247
+ }
248
+ catch (err) {
249
+ this._reserved[kind]--;
250
+ try {
251
+ await page.close();
252
+ }
253
+ catch { /* ignore */ }
254
+ throw new browser_capture_types_1.BrowserLaunchError('failed to secure preview page', err);
255
+ }
208
256
  }
209
257
  const id = (0, ids_1.newId)();
210
258
  const activityAt = this.now();
211
259
  const session = {
212
260
  id,
213
261
  projectId: this.projectId,
262
+ owner,
214
263
  page,
215
264
  popups: [],
216
265
  popupView: false,
@@ -229,7 +278,7 @@ class BrowserCaptureManager {
229
278
  };
230
279
  this.sessions.set(id, session);
231
280
  // The reservation is now realised as a committed session — release it.
232
- this._reserved--;
281
+ this._reserved[kind]--;
233
282
  // Adopt popups the page opens (OAuth login windows etc.) as secondary pages
234
283
  // of this session, and keep the client's URL bar live on in-page navigation
235
284
  // (link clicks / redirects the REST navigate path never sees).
@@ -239,7 +288,7 @@ class BrowserCaptureManager {
239
288
  return;
240
289
  session.url = url;
241
290
  if (url && url !== 'about:blank')
242
- this.setLastUrl(url);
291
+ this.rememberUrl(session, url);
243
292
  this.broadcastControl(session, { type: 'nav', url, title: session.title });
244
293
  });
245
294
  // Start capturing the page's network requests before the first navigation so
@@ -256,7 +305,13 @@ class BrowserCaptureManager {
256
305
  // away, so the user watches the page paint progressively instead of staring
257
306
  // at "opening…". The URL is set optimistically to the target; the resolved
258
307
  // URL/title land via the `nav` control broadcast every client already handles.
259
- const target = opts?.initialUrl?.trim() || this.getLastUrl() || 'about:blank';
308
+ if (opts?.viewport) {
309
+ try {
310
+ await this.handleInput(id, { type: 'resize', ...opts.viewport });
311
+ }
312
+ catch { /* the default viewport still renders */ }
313
+ }
314
+ const target = opts?.initialUrl?.trim() || (kind === 'desktop' ? this.getLastUrl() : null) || 'about:blank';
260
315
  session.url = target;
261
316
  void this.runInitialNavigation(session, target);
262
317
  return this.toMeta(session);
@@ -276,9 +331,34 @@ class BrowserCaptureManager {
276
331
  s.url = result.url;
277
332
  s.title = result.title;
278
333
  if (result.url && result.url !== 'about:blank')
279
- this.setLastUrl(result.url);
334
+ this.rememberUrl(s, result.url);
280
335
  this.broadcastControl(s, { type: 'nav', url: result.url, title: result.title });
281
336
  }
337
+ /** Desktop's "last URL" is a Desktop convenience; a phone's preview of a
338
+ * loopback origin must never become the next Desktop session's start page. */
339
+ rememberUrl(s, url) {
340
+ if (s.owner.kind === 'desktop')
341
+ this.setLastUrl(url);
342
+ }
343
+ currentPreviewOrigins(fresh = false) {
344
+ const at = this.now();
345
+ if (!fresh && this.previewOriginsCache && at - this.previewOriginsCache.at < PREVIEW_ORIGINS_TTL_MS) {
346
+ return this.previewOriginsCache.origins;
347
+ }
348
+ let origins;
349
+ try {
350
+ origins = this.previewOrigins();
351
+ }
352
+ catch {
353
+ origins = new Set();
354
+ }
355
+ this.previewOriginsCache = { at, origins };
356
+ return origins;
357
+ }
358
+ /** The request guard of device-owned pages (documents + subresources). */
359
+ allowDeviceRequest(url) {
360
+ return (0, preview_origins_1.isPreviewRequestAllowed)(url, this.currentPreviewOrigins(), browser_playwright_1.isNavigableUrl);
361
+ }
282
362
  // ─── Popup support (OAuth login windows etc.) ───────────────────────────────
283
363
  topPopup(s) {
284
364
  return s.popups.length > 0 ? s.popups[s.popups.length - 1] : null;
@@ -299,6 +379,22 @@ class BrowserCaptureManager {
299
379
  void popup.close().catch(() => { });
300
380
  return;
301
381
  }
382
+ if (s.owner.kind === 'device') {
383
+ // A popup is a new page: install the same guard, and close one that was
384
+ // opened straight onto a forbidden URL before the guard could see it.
385
+ const opened = popup.currentUrl();
386
+ if (opened && opened !== 'about:blank' && !this.allowDeviceRequest(opened)) {
387
+ void popup.close().catch(() => { });
388
+ return;
389
+ }
390
+ if (!popup.setRequestGuard) {
391
+ void popup.close().catch(() => { });
392
+ return;
393
+ }
394
+ void popup.setRequestGuard((url) => this.allowDeviceRequest(url)).catch(() => {
395
+ void popup.close().catch(() => { });
396
+ });
397
+ }
302
398
  s.popups.push(popup);
303
399
  s.popupView = true;
304
400
  // Popups inherit the session viewport so coordinate mapping + screencast
@@ -553,7 +649,7 @@ class BrowserCaptureManager {
553
649
  s.url = result.url;
554
650
  s.title = result.title;
555
651
  if (result.url && result.url !== 'about:blank')
556
- this.setLastUrl(result.url);
652
+ this.rememberUrl(s, result.url);
557
653
  this.broadcastControl(s, { type: 'nav', url: result.url, title: result.title });
558
654
  }
559
655
  return result;
@@ -875,6 +971,33 @@ class BrowserCaptureManager {
875
971
  this.context = null;
876
972
  this.contextPromise = null;
877
973
  }
974
+ /** Owner of a live session, or null when it does not exist. */
975
+ sessionOwner(sessionId) {
976
+ return this.getSession(sessionId)?.owner ?? null;
977
+ }
978
+ /**
979
+ * Manual navigation from a device: the session must belong to that device,
980
+ * and `goto` only accepts paths on announced origins (the page itself may
981
+ * still follow public links/redirects through the request guard).
982
+ */
983
+ async navigateAsDevice(sessionId, deviceId, action, url) {
984
+ const owner = this.sessionOwner(sessionId);
985
+ if (!owner || owner.kind !== 'device' || owner.deviceId !== deviceId)
986
+ return null;
987
+ if (action === 'goto' && !(url && (0, preview_origins_1.isManualPreviewUrlAllowed)(url, this.currentPreviewOrigins(true)))) {
988
+ throw new browser_capture_types_1.BrowserNavigationRejectedError();
989
+ }
990
+ return this.navigate(sessionId, action, url);
991
+ }
992
+ /** Kill every session a device owns (device revoked / unpaired). */
993
+ async killDeviceSessions(deviceId) {
994
+ let killed = 0;
995
+ for (const s of [...this.sessions.values()]) {
996
+ if (s.owner.kind === 'device' && s.owner.deviceId === deviceId && await this.kill(s.id))
997
+ killed += 1;
998
+ }
999
+ return killed;
1000
+ }
878
1001
  sessionCount() {
879
1002
  return [...this.sessions.values()].filter((s) => !s.closed).length;
880
1003
  }
@@ -6,7 +6,7 @@
6
6
  // no real Chromium. The real Playwright/CDP implementation lives in
7
7
  // `browser-playwright.ts` and is the ONLY module that imports `playwright`.
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.BrowserLaunchError = exports.BrowserLimitExceededError = void 0;
9
+ exports.BrowserLaunchError = exports.BrowserNavigationRejectedError = exports.BrowserLimitExceededError = void 0;
10
10
  // ─── Error classes (mirror terminal-manager's typed errors) ───────────────────
11
11
  class BrowserLimitExceededError extends Error {
12
12
  limit;
@@ -17,6 +17,15 @@ class BrowserLimitExceededError extends Error {
17
17
  }
18
18
  }
19
19
  exports.BrowserLimitExceededError = BrowserLimitExceededError;
20
+ /** A device-owned preview session was asked to load a URL its origin policy
21
+ * rejects (an unannounced loopback port, or any manual non-announced URL). */
22
+ class BrowserNavigationRejectedError extends Error {
23
+ constructor() {
24
+ super('browser_navigation_rejected');
25
+ this.name = 'BrowserNavigationRejectedError';
26
+ }
27
+ }
28
+ exports.BrowserNavigationRejectedError = BrowserNavigationRejectedError;
20
29
  class BrowserLaunchError extends Error {
21
30
  cause;
22
31
  constructor(message, cause) {
@@ -447,6 +447,25 @@ class PlaywrightPageHandle {
447
447
  return null;
448
448
  }
449
449
  }
450
+ async setRequestGuard(allow) {
451
+ // Page-scoped (not context-scoped): the shared global context also serves
452
+ // Desktop sessions, which must keep their own unchanged guard and must not
453
+ // pay for request interception.
454
+ await this.page.route('**/*', async (route) => {
455
+ let url = '';
456
+ try {
457
+ url = route.request().url();
458
+ }
459
+ catch { /* treat as rejected */ }
460
+ try {
461
+ if (url && allow(url))
462
+ await route.fallback();
463
+ else
464
+ await route.abort('blockedbyclient');
465
+ }
466
+ catch { /* the page or request went away */ }
467
+ });
468
+ }
450
469
  async enableNetwork() {
451
470
  if (this.netEnabled)
452
471
  return;