specrails-desktop 2.63.1 → 2.64.0
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/client/dist/assets/{ActivityFeedPage-B1bpTy7O.js → ActivityFeedPage-D23wXKgy.js} +1 -1
- package/client/dist/assets/{AgentBrowserCapture-BussBbN3.js → AgentBrowserCapture-DQzS4XcK.js} +1 -1
- package/client/dist/assets/{AgentModeAnalyticsPane-BiT1n3BU.js → AgentModeAnalyticsPane-CFTiSl8T.js} +2 -2
- package/client/dist/assets/{AgentModeCodePane-CUjNfeKa.js → AgentModeCodePane-DAaSzIPA.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-RVEwyS59.js → AgentModeJobsPane-C29YpixV.js} +1 -1
- package/client/dist/assets/{AgentsPage-DKE-eMC5.js → AgentsPage-B-sSTP1I.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-Bser2Ly3.js → AnalyticsPage-81fI80nA.js} +1 -1
- package/client/dist/assets/{CodePage-BK0grm6e.js → CodePage-Ct4XEPdx.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-DEBidZbs.js → DesktopAnalyticsPage-Bt37XOLF.js} +1 -1
- package/client/dist/assets/{DocsDialog-Dhyo57n9.js → DocsDialog-_IEr8umI.js} +1 -1
- package/client/dist/assets/{DocsPage-BcEkPXd6.js → DocsPage-DX9WOqt2.js} +1 -1
- package/client/dist/assets/{ExportDropdown-mRmqdkjr.js → ExportDropdown-Bb1lHx3r.js} +1 -1
- package/client/dist/assets/{InteractiveJobComposer-BlCe3vrU.js → InteractiveJobComposer-DDI6TdsS.js} +1 -1
- package/client/dist/assets/{JobDetailModal-CkAejD1F.js → JobDetailModal-CkD0PBaZ.js} +1 -1
- package/client/dist/assets/{JobDetailPage-P-FS_lKz.js → JobDetailPage-B8Rd4U5J.js} +1 -1
- package/client/dist/assets/{JobsPage-CoUXgYse.js → JobsPage-ChOiSbJn.js} +1 -1
- package/client/dist/assets/{LoopBuilderPage-iFylNk68.js → LoopBuilderPage-6Xwt2G6G.js} +1 -1
- package/client/dist/assets/{LoopPreviewModal-4obS_Ptg.js → LoopPreviewModal-CyDapSvB.js} +1 -1
- package/client/dist/assets/{LoopWindowSurface-CLr5OPqj.js → LoopWindowSurface-gRdx4nmg.js} +1 -1
- package/client/dist/assets/{LoopsPage-eKpWFtCp.js → LoopsPage-BPGVFsH-.js} +1 -1
- package/client/dist/assets/{MinimizedChatsContext-BHIUPVmC.js → MinimizedChatsContext-DHfigjHS.js} +1 -1
- package/client/dist/assets/{PluginsPage-BgTC2CQx.js → PluginsPage-DQRRdCUi.js} +1 -1
- package/client/dist/assets/{ProjectSettingsDialog-B4chTfU8.js → ProjectSettingsDialog-Ce8DSG6H.js} +1 -1
- package/client/dist/assets/{RepositoryDeliveries-DAIxIYsl.js → RepositoryDeliveries-B4ybHOyB.js} +1 -1
- package/client/dist/assets/{RepositoryScopeSelector-DeCoUsRN.js → RepositoryScopeSelector-2eSqR9WK.js} +1 -1
- package/client/dist/assets/{ReviewPacketPage-Cktv0-9b.js → ReviewPacketPage-WVoio8GJ.js} +1 -1
- package/client/dist/assets/{TemplatePreviewModal-BZTV1TM1.js → TemplatePreviewModal-CTBZUXAN.js} +1 -1
- package/client/dist/assets/{TicketDetailModalContext-DiPngt2g.js → TicketDetailModalContext-Dkwo2cDs.js} +1 -1
- package/client/dist/assets/{Trans-ZiXIfj50.js → Trans-BbFxRFtR.js} +1 -1
- package/client/dist/assets/{formatDistanceToNow-CdN5lTyD.js → formatDistanceToNow-NjPHBLfB.js} +1 -1
- package/client/dist/assets/{getTimezoneOffsetInMilliseconds-62h40I4t.js → getTimezoneOffsetInMilliseconds-vJ7qUoqN.js} +1 -1
- package/client/dist/assets/{index-CECj3joE.js → index-IdGpV21X.js} +36 -36
- package/client/dist/assets/{jira-api-C_D_RDhl.js → jira-api-Cabj-H0J.js} +1 -1
- package/client/dist/assets/{project-repositories-Be0CWklo.js → project-repositories-Cnnuj9Ia.js} +1 -1
- package/client/dist/assets/{settings-Hw7av-B1.js → settings-B1NV8Ow9.js} +1 -1
- package/client/dist/assets/settings-BQ8XFZeE.js +1 -0
- package/client/dist/assets/settings-BRkxfBbO.js +1 -0
- package/client/dist/assets/settings-C_CcU1dP.js +1 -0
- package/client/dist/assets/settings-Cto9n0eL.js +1 -0
- package/client/dist/assets/{settings-nryN6wuS.js → settings-Pyw8uYtL.js} +1 -1
- package/client/dist/assets/settings-b3HVCNNd.js +1 -0
- package/client/dist/assets/settings-hdive4u6.js +1 -0
- package/client/dist/assets/{spending-eWiZGhC_.js → spending-CQnrWiR3.js} +1 -1
- package/client/dist/assets/{useDesktop-C0mRIlHT.js → useDesktop-BjLT0_EE.js} +1 -1
- package/client/dist/assets/{useRuntimeRuns-B1a6GChu.js → useRuntimeRuns-C97qEqMg.js} +1 -1
- package/client/dist/assets/{useSharedWebSocket-CcsE5986.js → useSharedWebSocket-Bj4LMp7b.js} +2 -2
- package/client/dist/index.html +15 -15
- package/docs/ci-cd.md +3 -4
- package/docs/getting-started.md +1 -1
- package/docs/internals/api-reference.md +26 -0
- package/docs/internals/core-runtime-updates.md +3 -3
- package/docs/internals/source-map.md +9 -3
- package/docs/platforms/macos.md +4 -4
- package/docs/platforms/windows.md +5 -5
- package/docs/running-pipelines.md +12 -0
- package/package.json +1 -1
- package/server/dist/index.js +14 -1
- package/server/dist/mobile/mobile-admin-router.js +16 -0
- package/server/dist/mobile/mobile-gateway.js +18 -0
- package/server/dist/mobile/mobile-missions.js +6 -4
- package/server/dist/mobile/mobile-preview-channel.js +179 -0
- package/server/dist/mobile/mobile-preview.js +366 -0
- package/server/dist/mobile/mobile-router.js +9 -1
- package/server/dist/mobile/mobile-webrtc-peer.js +54 -4
- package/server/dist/mobile/mobile-ws.js +4 -2
- package/server/dist/modules/browser/domain/preview-origins.js +117 -0
- package/server/dist/modules/browser/runtime/browser-capture-manager.js +141 -18
- package/server/dist/modules/browser/runtime/browser-capture-types.js +10 -1
- package/server/dist/modules/browser/runtime/browser-playwright.js +19 -0
- package/server/dist/modules/browser/runtime/preview-targets.js +80 -0
- package/server/dist/modules/terminals/runtime/background-process-service.js +24 -1
- package/server/dist/project-registry.js +10 -0
- package/client/dist/assets/settings-BO2N-oPf.js +0 -1
- package/client/dist/assets/settings-BQChAs_w.js +0 -1
- package/client/dist/assets/settings-BzmYlcQB.js +0 -1
- package/client/dist/assets/settings-CGjXb6TX.js +0 -1
- package/client/dist/assets/settings-CMxEWY46.js +0 -1
- package/client/dist/assets/settings-C_dRSgI2.js +0 -1
|
@@ -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':
|
|
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
|
|
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
|
|
174
|
-
// mutates synchronously here.
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPreviewTargetSource = createPreviewTargetSource;
|
|
4
|
+
const transient_children_1 = require("../../../transient-children");
|
|
5
|
+
const preview_origins_1 = require("../domain/preview-origins");
|
|
6
|
+
/** Background-process statuses whose announced URLs are still served. */
|
|
7
|
+
const LIVE_STATUSES = new Set(['starting', 'running']);
|
|
8
|
+
/** Dev servers announce their URL in the first lines; a long-running process
|
|
9
|
+
* can still re-announce after a restart, so scan the full retained tail. */
|
|
10
|
+
const SCAN_LIMIT = 2000;
|
|
11
|
+
const defaultReader = {
|
|
12
|
+
list: (projectId) => (0, transient_children_1.listBackgroundProcesses)({ projectId }),
|
|
13
|
+
logs: (process) => (0, transient_children_1.getBackgroundProcessLogs)(process.pid, { projectId: process.projectId, processId: process.processId, limit: SCAN_LIMIT }),
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Derives preview targets from mission background-process logs on demand.
|
|
17
|
+
* Targets are derived state: nothing is persisted, an exited process simply
|
|
18
|
+
* stops contributing, and a process recovered after a restart (`interrupted`,
|
|
19
|
+
* OS state unknown) never does. A per-process memo keyed on the log tail keeps
|
|
20
|
+
* the per-request navigation guard cheap.
|
|
21
|
+
*/
|
|
22
|
+
function createPreviewTargetSource(reader = defaultReader) {
|
|
23
|
+
const memo = new Map();
|
|
24
|
+
const urlsFor = (process) => {
|
|
25
|
+
let snapshot = null;
|
|
26
|
+
try {
|
|
27
|
+
snapshot = reader.logs(process);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
if (!snapshot)
|
|
33
|
+
return [];
|
|
34
|
+
const last = snapshot.lines[snapshot.lines.length - 1];
|
|
35
|
+
const key = `${snapshot.nextSequence}:${last?.sequence ?? 0}:${last?.line.length ?? 0}`;
|
|
36
|
+
const cached = memo.get(process.processId);
|
|
37
|
+
if (cached?.key === key)
|
|
38
|
+
return cached.urls;
|
|
39
|
+
const urls = (0, preview_origins_1.previewTargetsFromLines)(snapshot.lines.map((line) => line.line));
|
|
40
|
+
memo.set(process.processId, { projectId: process.projectId, key, urls });
|
|
41
|
+
return urls;
|
|
42
|
+
};
|
|
43
|
+
const live = (projectId) => {
|
|
44
|
+
let processes = [];
|
|
45
|
+
try {
|
|
46
|
+
processes = reader.list(projectId);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const running = processes.filter((process) => process.projectId === projectId && LIVE_STATUSES.has(process.status));
|
|
52
|
+
// Drop memo entries of processes that are no longer live so the cache is
|
|
53
|
+
// bounded by the running set of the projects being previewed.
|
|
54
|
+
const ids = new Set(running.map((process) => process.processId));
|
|
55
|
+
for (const [processId, entry] of memo) {
|
|
56
|
+
if (entry.projectId === projectId && !ids.has(processId))
|
|
57
|
+
memo.delete(processId);
|
|
58
|
+
}
|
|
59
|
+
return running;
|
|
60
|
+
};
|
|
61
|
+
const target = (process) => ({ processId: process.processId, chatId: process.chatId, urls: urlsFor(process) });
|
|
62
|
+
return {
|
|
63
|
+
list: (projectId) => live(projectId).map(target).filter((entry) => entry.urls.length > 0),
|
|
64
|
+
forProcess: (projectId, processId) => {
|
|
65
|
+
const process = live(projectId).find((candidate) => candidate.processId === processId);
|
|
66
|
+
return process ? target(process) : null;
|
|
67
|
+
},
|
|
68
|
+
announcedOrigins: (projectId) => {
|
|
69
|
+
const origins = new Set();
|
|
70
|
+
for (const process of live(projectId)) {
|
|
71
|
+
for (const url of urlsFor(process)) {
|
|
72
|
+
const origin = (0, preview_origins_1.previewOrigin)(url);
|
|
73
|
+
if (origin)
|
|
74
|
+
origins.add(origin);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return origins;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -12,6 +12,7 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
12
12
|
const loop_runs_store_1 = require("../../loops/runtime/loop-runs-store");
|
|
13
13
|
const project_repositories_1 = require("../../../project-repositories");
|
|
14
14
|
const transient_children_1 = require("../../../transient-children");
|
|
15
|
+
const preview_origins_1 = require("../../browser/domain/preview-origins");
|
|
15
16
|
/** The same scope and lifecycle policy applies to REST and the mission MCP. */
|
|
16
17
|
function resolveBackgroundCwd(repositoryRoot, cwd) {
|
|
17
18
|
if (cwd !== undefined && typeof cwd !== 'string')
|
|
@@ -25,7 +26,29 @@ function resolveBackgroundCwd(repositoryRoot, cwd) {
|
|
|
25
26
|
function backgroundProcessHooks(broadcast) {
|
|
26
27
|
const publish = (type) => (process) => broadcast({ type, process,
|
|
27
28
|
projectId: process.projectId, timestamp: new Date().toISOString() });
|
|
28
|
-
|
|
29
|
+
// Companion app preview: announce once per newly seen local origin so phones
|
|
30
|
+
// refetch the derived targets instead of tailing every log line.
|
|
31
|
+
const announced = new Set();
|
|
32
|
+
const onOutput = (event) => {
|
|
33
|
+
let fresh = false;
|
|
34
|
+
for (const url of (0, preview_origins_1.extractPreviewUrls)(event.line)) {
|
|
35
|
+
const origin = (0, preview_origins_1.previewOrigin)(url);
|
|
36
|
+
if (origin && !announced.has(origin)) {
|
|
37
|
+
announced.add(origin);
|
|
38
|
+
fresh = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (fresh)
|
|
42
|
+
broadcast({ type: 'background_process.preview_targets', processId: event.processId, chatId: event.chatId,
|
|
43
|
+
projectId: event.projectId, timestamp: new Date().toISOString() });
|
|
44
|
+
};
|
|
45
|
+
const exited = publish('background_process.exited');
|
|
46
|
+
return {
|
|
47
|
+
onStarted: publish('background_process.started'),
|
|
48
|
+
onUpdated: publish('background_process.updated'),
|
|
49
|
+
onOutput,
|
|
50
|
+
onExited: (process) => { announced.clear(); exited(process); },
|
|
51
|
+
};
|
|
29
52
|
}
|
|
30
53
|
function backgroundStartBusyReason(c) {
|
|
31
54
|
const activeJobId = typeof c.queueManager?.getActiveJobId === 'function' ? c.queueManager.getActiveJobId() : null;
|
|
@@ -30,6 +30,7 @@ const ticket_watcher_1 = require("./modules/specs/runtime/ticket-watcher");
|
|
|
30
30
|
const terminal_manager_1 = require("./modules/terminals/runtime/terminal-manager");
|
|
31
31
|
const browser_capture_manager_1 = require("./modules/browser/runtime/browser-capture-manager");
|
|
32
32
|
const browser_context_pool_1 = require("./modules/browser/runtime/browser-context-pool");
|
|
33
|
+
const preview_targets_1 = require("./modules/browser/runtime/preview-targets");
|
|
33
34
|
const explore_cwd_manager_1 = require("./modules/conversations/runtime/explore-cwd-manager");
|
|
34
35
|
const hooks_1 = require("./hooks");
|
|
35
36
|
const transient_children_1 = require("./transient-children");
|
|
@@ -119,6 +120,9 @@ class ProjectRegistry {
|
|
|
119
120
|
// Chromium profile under ~/.specrails/browser-profile, so cookies/logins are
|
|
120
121
|
// shared across every project. Launched lazily, disposed once at shutdown().
|
|
121
122
|
_browserContextPool;
|
|
123
|
+
// Companion app preview: URLs announced by running mission processes,
|
|
124
|
+
// derived on demand from their logs (shared by every project's browser).
|
|
125
|
+
_previewTargets = (0, preview_targets_1.createPreviewTargetSource)();
|
|
122
126
|
constructor(broadcast, desktopDbPath, desktopPort) {
|
|
123
127
|
this._broadcast = broadcast;
|
|
124
128
|
this._desktopDb = (0, desktop_db_1.initDesktopDb)(desktopDbPath ?? (0, desktop_db_1.getDesktopDbPath)());
|
|
@@ -128,6 +132,9 @@ class ProjectRegistry {
|
|
|
128
132
|
this._failedProjects = new Map();
|
|
129
133
|
this._browserContextPool = new browser_context_pool_1.SharedBrowserContextPool();
|
|
130
134
|
}
|
|
135
|
+
get previewTargets() {
|
|
136
|
+
return this._previewTargets;
|
|
137
|
+
}
|
|
131
138
|
get desktopDb() {
|
|
132
139
|
return this._desktopDb;
|
|
133
140
|
}
|
|
@@ -1136,6 +1143,9 @@ class ProjectRegistry {
|
|
|
1136
1143
|
// Share ONE persistent Chromium profile (global cookies/logins) across all
|
|
1137
1144
|
// projects instead of a per-project profile.
|
|
1138
1145
|
contextPool: this._browserContextPool,
|
|
1146
|
+
// Only device-owned (Companion preview) sessions may load these loopback
|
|
1147
|
+
// origins; Desktop sessions keep the regular SSRF guard.
|
|
1148
|
+
previewOrigins: () => this._previewTargets.announcedOrigins(project.id),
|
|
1139
1149
|
});
|
|
1140
1150
|
// ── Loops engine (the Loops feature) ──────────────────────────────────────
|
|
1141
1151
|
const railLoopRuns = new Map();
|