@parall/daemon 1.34.0 → 1.35.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/bundle/manifest.json +9 -9
- package/bundle/parall-claude-agent.js +168 -92
- package/bundle/parall-codex-agent.js +163 -91
- package/bundle/parall-daemon.js +702 -38
- package/dist/clip-runtime/browser-profile-manager.d.ts +10 -0
- package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -1
- package/dist/clip-runtime/browser-profile-manager.js +38 -26
- package/dist/clip-runtime/browser-viewer-streamer.d.ts +188 -0
- package/dist/clip-runtime/browser-viewer-streamer.d.ts.map +1 -0
- package/dist/clip-runtime/browser-viewer-streamer.js +595 -0
- package/dist/clip-runtime/subprocess.d.ts +11 -0
- package/dist/clip-runtime/subprocess.d.ts.map +1 -0
- package/dist/clip-runtime/subprocess.js +33 -0
- package/dist/supervisor.d.ts +10 -1
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +48 -6
- package/package.json +6 -6
package/bundle/parall-daemon.js
CHANGED
|
@@ -27118,6 +27118,7 @@ var ENDPOINTS = {
|
|
|
27118
27118
|
// Org-scoped
|
|
27119
27119
|
ORG: (orgId) => `${API_BASE}/orgs/${orgId}`,
|
|
27120
27120
|
ORG_MEMBERS: (orgId) => `${API_BASE}/orgs/${orgId}/members`,
|
|
27121
|
+
TEAMS: (orgId) => `${API_BASE}/orgs/${orgId}/teams`,
|
|
27121
27122
|
ORG_MEMBERS_ONLINE: (orgId) => `${API_BASE}/orgs/${orgId}/members/online`,
|
|
27122
27123
|
ORG_MEMBER: (orgId, userId) => `${API_BASE}/orgs/${orgId}/members/${userId}`,
|
|
27123
27124
|
ORG_MEMBER_CHATS: (orgId, memberId) => `${API_BASE}/orgs/${orgId}/members/${memberId}/chats`,
|
|
@@ -27162,6 +27163,7 @@ var ENDPOINTS = {
|
|
|
27162
27163
|
AGENT: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}`,
|
|
27163
27164
|
AGENT_API_KEYS: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/api-keys`,
|
|
27164
27165
|
AGENT_API_KEY: (orgId, agentId, key) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/api-keys/${key}`,
|
|
27166
|
+
AGENT_API_KEY_REGENERATE: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/api-keys/regenerate`,
|
|
27165
27167
|
AGENT_AVATAR: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/avatar`,
|
|
27166
27168
|
AGENT_ACTIVITY: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/activity`,
|
|
27167
27169
|
AGENT_MONITOR: (orgId, agentId) => `${API_BASE}/orgs/${orgId}/agents/${agentId}/monitor`,
|
|
@@ -27201,6 +27203,7 @@ var ENDPOINTS = {
|
|
|
27201
27203
|
MACHINE_AGENT_WORKSPACE_SETUP: (orgId, machineId, agentId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/agents/${agentId}/workspace/setup`,
|
|
27202
27204
|
MACHINE_LLM_SOURCE: (orgId, machineId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/llm-source`,
|
|
27203
27205
|
MACHINE_PROVIDER_ENABLED: (orgId, machineId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/provider-enabled`,
|
|
27206
|
+
MACHINE_CAPABILITIES: (orgId, machineId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/capabilities`,
|
|
27204
27207
|
MACHINE_RUNTIME_AUTH: (orgId, machineId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/runtime-auth`,
|
|
27205
27208
|
MACHINE_KEYS: (orgId, machineId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/keys`,
|
|
27206
27209
|
MACHINE_KEY: (orgId, machineId, keyId) => `${API_BASE}/orgs/${orgId}/machines/${machineId}/keys/${keyId}`,
|
|
@@ -27221,6 +27224,12 @@ var ENDPOINTS = {
|
|
|
27221
27224
|
MACHINES_ME_AGENT_WORKSPACE_STATE: (agentId) => `${API_BASE}/machines/me/agents/${agentId}/workspace-state`,
|
|
27222
27225
|
MACHINES_ME_WS_TICKET: `${API_BASE}/machines/me/ws/ticket`,
|
|
27223
27226
|
MACHINES_ME_BROWSE_RESPONSE: (requestId) => `${API_BASE}/machines/me/browse-response/${requestId}`,
|
|
27227
|
+
// Hosted browser live-viewer control plane (api-server, NOT clip-service):
|
|
27228
|
+
// the web client drives WebRTC signaling + tab nav through VIEWER_COMMAND;
|
|
27229
|
+
// api-server brokers each command to the host daemon via the machine:{id}
|
|
27230
|
+
// request/reply bridge (mirrors filesystem browse), and the daemon replies on
|
|
27231
|
+
// VIEWER_RESPONSE. See docs/engineering-design/hosted-browser-provider-design.md.
|
|
27232
|
+
MACHINES_ME_BROWSER_PROFILE_VIEWER_RESPONSE: (requestId) => `${API_BASE}/machines/me/browser-profiles/viewer-response/${requestId}`,
|
|
27224
27233
|
// Tasks (org-scoped)
|
|
27225
27234
|
TASKS: (orgId) => `${API_BASE}/orgs/${orgId}/tasks`,
|
|
27226
27235
|
TASK: (orgId, taskId) => `${API_BASE}/orgs/${orgId}/tasks/${taskId}`,
|
|
@@ -27286,6 +27295,9 @@ var ENDPOINTS = {
|
|
|
27286
27295
|
// Wiki Path Scopes (AFCS ACL)
|
|
27287
27296
|
WIKI_PATH_SCOPES: (orgId, wikiId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/path-scopes`,
|
|
27288
27297
|
WIKI_PATH_SCOPE: (orgId, wikiId, scopeId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/path-scopes/${scopeId}`,
|
|
27298
|
+
// Wiki Path Restrictions (AFCS narrowing ACL — private subtrees)
|
|
27299
|
+
WIKI_RESTRICTIONS: (orgId, wikiId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/restrictions`,
|
|
27300
|
+
WIKI_RESTRICTION: (orgId, wikiId, restrictionId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/restrictions/${restrictionId}`,
|
|
27289
27301
|
WIKI_ACCESS_STATUS: (orgId, wikiId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/access-status`,
|
|
27290
27302
|
WIKI_ACCESS_REQUESTS: (orgId, wikiId) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/access-requests`,
|
|
27291
27303
|
// Wiki History (commits, file commits, blame)
|
|
@@ -27351,6 +27363,7 @@ var ENDPOINTS = {
|
|
|
27351
27363
|
// Clips (org-scoped, served by clip-service)
|
|
27352
27364
|
CLIPS: (orgId) => `${CLIP_BASE}/orgs/${orgId}/clips`,
|
|
27353
27365
|
CLIP: (orgId, clipId) => `${CLIP_BASE}/orgs/${orgId}/clips/${clipId}`,
|
|
27366
|
+
CLIPS_BULK_METADATA: (orgId) => `${CLIP_BASE}/orgs/${orgId}/clips/bulk-metadata`,
|
|
27354
27367
|
CLIP_AGENTS: (orgId, clipId) => `${CLIP_BASE}/orgs/${orgId}/clips/${clipId}/agents`,
|
|
27355
27368
|
AGENT_CLIPS: (orgId, agentId) => `${CLIP_BASE}/orgs/${orgId}/agents/${agentId}/clips`,
|
|
27356
27369
|
AGENT_CLIP: (orgId, agentId, clipId) => `${CLIP_BASE}/orgs/${orgId}/agents/${agentId}/clips/${clipId}`,
|
|
@@ -27363,6 +27376,9 @@ var ENDPOINTS = {
|
|
|
27363
27376
|
BROWSER_PROFILE_RESET: (orgId, profileId) => `${CLIP_BASE}/orgs/${orgId}/browser-profiles/${profileId}/reset`,
|
|
27364
27377
|
BROWSER_PROFILE_CONSENTS: (orgId, profileId) => `${CLIP_BASE}/orgs/${orgId}/browser-profiles/${profileId}/consents`,
|
|
27365
27378
|
BROWSER_PROFILE_CONSENT: (orgId, profileId, clipId) => `${CLIP_BASE}/orgs/${orgId}/browser-profiles/${profileId}/consents/${clipId}`,
|
|
27379
|
+
// Live viewer command — on api-server (API_BASE), not clip-service: it rides
|
|
27380
|
+
// the machine control-plane request/reply bridge that lives in api-server.
|
|
27381
|
+
BROWSER_PROFILE_VIEWER_COMMAND: (orgId, profileId) => `${API_BASE}/orgs/${orgId}/browser-profiles/${profileId}/viewer/command`,
|
|
27366
27382
|
// Clip registry (global, served by clip-service → Pinix Hub proxy)
|
|
27367
27383
|
CLIP_REGISTRY: () => `${CLIP_BASE}/registry/clips`
|
|
27368
27384
|
};
|
|
@@ -27439,6 +27455,7 @@ var WS_EVENTS = {
|
|
|
27439
27455
|
MACHINE_CONFIG_UPDATED: "machine.config.updated",
|
|
27440
27456
|
MACHINE_CLIP_SYNC: "machine.clip.sync",
|
|
27441
27457
|
MACHINE_BROWSER_PROFILE_LIFECYCLE: "machine.browser_profile.lifecycle",
|
|
27458
|
+
MACHINE_BROWSER_PROFILE_VIEWER: "machine.browser_profile.viewer",
|
|
27442
27459
|
AGENT_NEW_SESSION: "agent.new_session",
|
|
27443
27460
|
CLIP_CREATED: "clip.created",
|
|
27444
27461
|
CLIP_REMOVED: "clip.removed",
|
|
@@ -27590,7 +27607,10 @@ var ParallClient = class _ParallClient {
|
|
|
27590
27607
|
method,
|
|
27591
27608
|
headers,
|
|
27592
27609
|
body: body ? JSON.stringify(body) : void 0,
|
|
27593
|
-
signal
|
|
27610
|
+
signal,
|
|
27611
|
+
// keepalive lets a request fired during page unload (e.g. the browser
|
|
27612
|
+
// viewer's stream.close on pagehide) outlive the document.
|
|
27613
|
+
keepalive: opts?.keepalive
|
|
27594
27614
|
});
|
|
27595
27615
|
} catch (err) {
|
|
27596
27616
|
throw _ParallClient.normalizeFetchError(err);
|
|
@@ -27761,6 +27781,10 @@ var ParallClient = class _ParallClient {
|
|
|
27761
27781
|
const res = await this.request("GET", ENDPOINTS.ORG_MEMBERS(orgId));
|
|
27762
27782
|
return res.data;
|
|
27763
27783
|
}
|
|
27784
|
+
async getTeams(orgId) {
|
|
27785
|
+
const res = await this.request("GET", ENDPOINTS.TEAMS(orgId));
|
|
27786
|
+
return res.data;
|
|
27787
|
+
}
|
|
27764
27788
|
async getOnlineMembers(orgId) {
|
|
27765
27789
|
const res = await this.request("GET", ENDPOINTS.ORG_MEMBERS_ONLINE(orgId));
|
|
27766
27790
|
return res.user_ids ?? [];
|
|
@@ -27999,6 +28023,10 @@ var ParallClient = class _ParallClient {
|
|
|
27999
28023
|
async createAgentApiKey(orgId, agentId) {
|
|
28000
28024
|
return this.request("POST", ENDPOINTS.AGENT_API_KEYS(orgId, agentId));
|
|
28001
28025
|
}
|
|
28026
|
+
/** Revokes all of the agent's active API keys and mints a replacement. */
|
|
28027
|
+
async regenerateAgentApiKey(orgId, agentId) {
|
|
28028
|
+
return this.request("POST", ENDPOINTS.AGENT_API_KEY_REGENERATE(orgId, agentId));
|
|
28029
|
+
}
|
|
28002
28030
|
async revokeAgentApiKey(orgId, agentId, key) {
|
|
28003
28031
|
return this.request("DELETE", ENDPOINTS.AGENT_API_KEY(orgId, agentId, key));
|
|
28004
28032
|
}
|
|
@@ -28170,6 +28198,17 @@ var ParallClient = class _ParallClient {
|
|
|
28170
28198
|
provider_enabled: providerEnabled
|
|
28171
28199
|
});
|
|
28172
28200
|
}
|
|
28201
|
+
/**
|
|
28202
|
+
* Replace the machine's capability set (admin). Primary use: healing a
|
|
28203
|
+
* machine that registered without `browser_provider` during the capability
|
|
28204
|
+
* migration's rolling-deploy window. Removing `agent_host` is rejected by the
|
|
28205
|
+
* server while agents are attached (409 AGENTS_STILL_ATTACHED).
|
|
28206
|
+
*/
|
|
28207
|
+
async patchMachineCapabilities(orgId, machineId, capabilities) {
|
|
28208
|
+
return this.request("PATCH", ENDPOINTS.MACHINE_CAPABILITIES(orgId, machineId), {
|
|
28209
|
+
capabilities
|
|
28210
|
+
});
|
|
28211
|
+
}
|
|
28173
28212
|
/** Get machine-level runtime auth state. */
|
|
28174
28213
|
async getMachineRuntimeAuth(orgId, machineId) {
|
|
28175
28214
|
return this.request("GET", ENDPOINTS.MACHINE_RUNTIME_AUTH(orgId, machineId));
|
|
@@ -28260,6 +28299,22 @@ var ParallClient = class _ParallClient {
|
|
|
28260
28299
|
async postBrowseResponse(requestId, response) {
|
|
28261
28300
|
return this.request("POST", ENDPOINTS.MACHINES_ME_BROWSE_RESPONSE(requestId), response);
|
|
28262
28301
|
}
|
|
28302
|
+
/**
|
|
28303
|
+
* `POST /machines/me/browser-profiles/viewer-response/{requestId}` — daemon
|
|
28304
|
+
* reply to a `machine.browser_profile.viewer` control command. Wakes the
|
|
28305
|
+
* api-server request/reply bridge (mirrors {@link postBrowseResponse}).
|
|
28306
|
+
*/
|
|
28307
|
+
async postBrowserProfileViewerResponse(requestId, response) {
|
|
28308
|
+
return this.request("POST", ENDPOINTS.MACHINES_ME_BROWSER_PROFILE_VIEWER_RESPONSE(requestId), response);
|
|
28309
|
+
}
|
|
28310
|
+
/**
|
|
28311
|
+
* `POST /orgs/{orgId}/browser-profiles/{profileId}/viewer/command` — drive the
|
|
28312
|
+
* hosted browser live viewer (WebRTC signaling + tab nav). Authz: profile
|
|
28313
|
+
* owner or org admin. api-server brokers the command to the host daemon.
|
|
28314
|
+
*/
|
|
28315
|
+
async browserViewerCommand(orgId, profileId, req, opts) {
|
|
28316
|
+
return this.request("POST", ENDPOINTS.BROWSER_PROFILE_VIEWER_COMMAND(orgId, profileId), req, void 0, false, opts);
|
|
28317
|
+
}
|
|
28263
28318
|
async resizeMachine(orgId, machineId, spec) {
|
|
28264
28319
|
return this.request("PATCH", ENDPOINTS.MACHINE_SPEC(orgId, machineId), spec);
|
|
28265
28320
|
}
|
|
@@ -28636,6 +28691,17 @@ var ParallClient = class _ParallClient {
|
|
|
28636
28691
|
async deleteWikiPathScope(orgId, wikiId, scopeId) {
|
|
28637
28692
|
await this.request("DELETE", ENDPOINTS.WIKI_PATH_SCOPE(orgId, wikiId, scopeId));
|
|
28638
28693
|
}
|
|
28694
|
+
// ---- Wiki Path Restrictions (narrowing ACL — private subtrees) ----
|
|
28695
|
+
async getWikiRestrictions(orgId, wikiId) {
|
|
28696
|
+
const res = await this.request("GET", ENDPOINTS.WIKI_RESTRICTIONS(orgId, wikiId));
|
|
28697
|
+
return res.data;
|
|
28698
|
+
}
|
|
28699
|
+
async createWikiRestriction(orgId, wikiId, data) {
|
|
28700
|
+
return this.request("POST", ENDPOINTS.WIKI_RESTRICTIONS(orgId, wikiId), data);
|
|
28701
|
+
}
|
|
28702
|
+
async deleteWikiRestriction(orgId, wikiId, restrictionId) {
|
|
28703
|
+
await this.request("DELETE", ENDPOINTS.WIKI_RESTRICTION(orgId, wikiId, restrictionId));
|
|
28704
|
+
}
|
|
28639
28705
|
async getWikiAccessStatus(orgId, wikiId, path15) {
|
|
28640
28706
|
return this.request("GET", ENDPOINTS.WIKI_ACCESS_STATUS(orgId, wikiId), void 0, path15 ? { path: path15 } : void 0);
|
|
28641
28707
|
}
|
|
@@ -28798,6 +28864,10 @@ var ParallClient = class _ParallClient {
|
|
|
28798
28864
|
async updateClip(orgId, clipId, req) {
|
|
28799
28865
|
return this.request("PATCH", ENDPOINTS.CLIP(orgId, clipId), req);
|
|
28800
28866
|
}
|
|
28867
|
+
/** Atomically set display_name and/or description on multiple clip instances (application metadata). */
|
|
28868
|
+
async bulkUpdateClipMetadata(orgId, req) {
|
|
28869
|
+
return this.request("POST", ENDPOINTS.CLIPS_BULK_METADATA(orgId), req);
|
|
28870
|
+
}
|
|
28801
28871
|
async deleteClip(orgId, clipId) {
|
|
28802
28872
|
await this.request("DELETE", ENDPOINTS.CLIP(orgId, clipId));
|
|
28803
28873
|
}
|
|
@@ -29698,7 +29768,7 @@ async function runCLI(args) {
|
|
|
29698
29768
|
init_config();
|
|
29699
29769
|
|
|
29700
29770
|
// ts/daemon/dist/supervisor.js
|
|
29701
|
-
import { spawn as
|
|
29771
|
+
import { spawn as spawn6 } from "node:child_process";
|
|
29702
29772
|
import * as fs12 from "node:fs";
|
|
29703
29773
|
import * as path13 from "node:path";
|
|
29704
29774
|
|
|
@@ -32001,13 +32071,561 @@ var ClipProvider = class _ClipProvider {
|
|
|
32001
32071
|
};
|
|
32002
32072
|
|
|
32003
32073
|
// ts/daemon/dist/clip-runtime/browser-profile-manager.js
|
|
32004
|
-
import { spawn as
|
|
32074
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
32005
32075
|
import { randomBytes } from "node:crypto";
|
|
32006
32076
|
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync7 } from "node:fs";
|
|
32007
32077
|
import { createRequire } from "node:module";
|
|
32008
|
-
import * as net from "node:net";
|
|
32009
32078
|
import * as path9 from "node:path";
|
|
32010
32079
|
import { fileURLToPath } from "node:url";
|
|
32080
|
+
|
|
32081
|
+
// ts/daemon/dist/clip-runtime/browser-viewer-streamer.js
|
|
32082
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
32083
|
+
|
|
32084
|
+
// ts/daemon/dist/clip-runtime/subprocess.js
|
|
32085
|
+
import * as net from "node:net";
|
|
32086
|
+
function formatErrorForLog(err) {
|
|
32087
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
32088
|
+
return message.replace(/\s+/g, " ").slice(0, 300);
|
|
32089
|
+
}
|
|
32090
|
+
async function findFreePort() {
|
|
32091
|
+
return new Promise((resolve8, reject) => {
|
|
32092
|
+
const server = net.createServer();
|
|
32093
|
+
server.unref();
|
|
32094
|
+
server.on("error", reject);
|
|
32095
|
+
server.listen(0, "127.0.0.1", () => {
|
|
32096
|
+
const address = server.address();
|
|
32097
|
+
server.close(() => {
|
|
32098
|
+
if (address && typeof address === "object") {
|
|
32099
|
+
resolve8(address.port);
|
|
32100
|
+
} else {
|
|
32101
|
+
reject(new Error("failed to allocate free port"));
|
|
32102
|
+
}
|
|
32103
|
+
});
|
|
32104
|
+
});
|
|
32105
|
+
});
|
|
32106
|
+
}
|
|
32107
|
+
function sleep(ms) {
|
|
32108
|
+
return new Promise((resolve8) => setTimeout(resolve8, ms));
|
|
32109
|
+
}
|
|
32110
|
+
|
|
32111
|
+
// ts/daemon/dist/clip-runtime/browser-viewer-streamer.js
|
|
32112
|
+
var BB_VIEWER_HEALTH_TIMEOUT_MS = 1e4;
|
|
32113
|
+
var BB_VIEWER_COMMAND_TIMEOUT_MS = 15e3;
|
|
32114
|
+
var BB_VIEWER_UNANSWERED_REAP_MS = 9e4;
|
|
32115
|
+
var BB_VIEWER_TERM_GRACE_MS = 5e3;
|
|
32116
|
+
var BrowserViewerStreamer = class {
|
|
32117
|
+
host;
|
|
32118
|
+
// Live bb-viewer (WebRTC streamer) subprocess per profile. One per profile;
|
|
32119
|
+
// see StreamerState. Cleaned up on stream.close, stopProfile, resetProfile,
|
|
32120
|
+
// and stop().
|
|
32121
|
+
streamers = /* @__PURE__ */ new Map();
|
|
32122
|
+
// Set by shutdown(): an in-flight spawnStreamer has no map entry yet, so a
|
|
32123
|
+
// shutdown during its health poll would otherwise let the bb-viewer child
|
|
32124
|
+
// survive daemon stop (on BYOC nothing else reaps it).
|
|
32125
|
+
stopping = false;
|
|
32126
|
+
constructor(host) {
|
|
32127
|
+
this.host = host;
|
|
32128
|
+
}
|
|
32129
|
+
/**
|
|
32130
|
+
* Handle a viewer control command for a profile. Throws on any failure (the
|
|
32131
|
+
* supervisor maps a throw to a `{error:{message}}` reply); never returns a
|
|
32132
|
+
* partial result. `data.input` / `data.turn` / `data.session_id` come from
|
|
32133
|
+
* MachineBrowserProfileViewerData.
|
|
32134
|
+
*/
|
|
32135
|
+
async handleViewerCommand(profileId, sessionId, command, input, turn) {
|
|
32136
|
+
if (!profileId)
|
|
32137
|
+
throw new Error("browser profile id is required");
|
|
32138
|
+
switch (command) {
|
|
32139
|
+
case "stream.start":
|
|
32140
|
+
return this.viewerStreamStart(profileId, sessionId, turn);
|
|
32141
|
+
case "stream.answer":
|
|
32142
|
+
return this.viewerStreamAnswer(profileId, sessionId, input);
|
|
32143
|
+
case "stream.close":
|
|
32144
|
+
return this.viewerStreamClose(profileId, sessionId, input);
|
|
32145
|
+
case "stream.switch":
|
|
32146
|
+
return this.viewerStreamSwitch(profileId, sessionId, input);
|
|
32147
|
+
case "close":
|
|
32148
|
+
return this.viewerCloseTab(profileId, sessionId, input);
|
|
32149
|
+
case "tab_list":
|
|
32150
|
+
case "tab_new":
|
|
32151
|
+
case "open":
|
|
32152
|
+
case "reload":
|
|
32153
|
+
case "back":
|
|
32154
|
+
case "forward":
|
|
32155
|
+
return this.viewerForwardNav(profileId, sessionId, command, input);
|
|
32156
|
+
default:
|
|
32157
|
+
throw new Error(`unknown viewer command: ${command}`);
|
|
32158
|
+
}
|
|
32159
|
+
}
|
|
32160
|
+
/**
|
|
32161
|
+
* stream.start — spawn a FRESH bb-viewer for this profile (killing any prior
|
|
32162
|
+
* one), resolve the profile's account-scoped page-target CDP ws URL, and run
|
|
32163
|
+
* the streamer's `connect` command. Records sessionId on the streamer entry.
|
|
32164
|
+
*/
|
|
32165
|
+
async viewerStreamStart(profileId, sessionId, turn) {
|
|
32166
|
+
this.killProfileStreamer(profileId);
|
|
32167
|
+
const cdpUrl = await this.host.withDaemonRecovery(() => this.resolveAccountPageCdpUrl(profileId), `viewer stream.start ${profileId}`);
|
|
32168
|
+
await this.bringTargetToFront(cdpUrl);
|
|
32169
|
+
const streamer = await this.spawnStreamer(profileId, sessionId, turn);
|
|
32170
|
+
try {
|
|
32171
|
+
const result = await this.streamerCommand(streamer, "connect", { cdpUrl });
|
|
32172
|
+
streamer.streamedTabId = targetIdFromCdpUrl(cdpUrl);
|
|
32173
|
+
const reapTimer = setTimeout(() => {
|
|
32174
|
+
const current = this.streamers.get(profileId);
|
|
32175
|
+
if (current === streamer && !current.answered) {
|
|
32176
|
+
this.host.log.warn(`[bb-viewer] no stream.answer for ${profileId} within ${BB_VIEWER_UNANSWERED_REAP_MS / 1e3}s; reaping abandoned streamer`);
|
|
32177
|
+
this.killProfileStreamer(profileId);
|
|
32178
|
+
}
|
|
32179
|
+
}, BB_VIEWER_UNANSWERED_REAP_MS);
|
|
32180
|
+
reapTimer.unref?.();
|
|
32181
|
+
return { ...result, streamed_tab_id: streamer.streamedTabId };
|
|
32182
|
+
} catch (err) {
|
|
32183
|
+
this.killProfileStreamer(profileId);
|
|
32184
|
+
throw err;
|
|
32185
|
+
}
|
|
32186
|
+
}
|
|
32187
|
+
/**
|
|
32188
|
+
* stream.answer — apply the client's WebRTC answer to the profile's streamer.
|
|
32189
|
+
* Rejects if the streamer's sessionId no longer matches (a stale viewer
|
|
32190
|
+
* session from a superseded stream.start).
|
|
32191
|
+
*/
|
|
32192
|
+
async viewerStreamAnswer(profileId, sessionId, input) {
|
|
32193
|
+
const streamer = this.streamers.get(profileId);
|
|
32194
|
+
if (!streamer)
|
|
32195
|
+
throw new Error("no active viewer streamer for profile");
|
|
32196
|
+
if (streamer.sessionId !== sessionId)
|
|
32197
|
+
throw new Error("stale viewer session");
|
|
32198
|
+
streamer.answered = true;
|
|
32199
|
+
return this.streamerCommand(streamer, "answer", input ?? {});
|
|
32200
|
+
}
|
|
32201
|
+
/**
|
|
32202
|
+
* stream.close — best-effort stop the streamer, then kill + remove it. A
|
|
32203
|
+
* stale session's close (independent HTTP legs can arrive out of order after
|
|
32204
|
+
* a retry) must NOT tear down a successor session's live streamer, so a
|
|
32205
|
+
* mismatched sessionId is ignored. An EMPTY sessionId gets the same stale
|
|
32206
|
+
* treatment — a client closed mid-handshake never received a session id, and
|
|
32207
|
+
* its unscoped close racing the next session's stream.start would kill that
|
|
32208
|
+
* session's live streamer (React StrictMode's dev double-mount exercises
|
|
32209
|
+
* exactly this ordering). The only unscoped close is an explicit
|
|
32210
|
+
* `{force:true}` input, reserved for a server-initiated admin disconnect.
|
|
32211
|
+
*/
|
|
32212
|
+
async viewerStreamClose(profileId, sessionId, input) {
|
|
32213
|
+
const streamer = this.streamers.get(profileId);
|
|
32214
|
+
if (streamer) {
|
|
32215
|
+
const force = input?.force === true;
|
|
32216
|
+
if (!force && streamer.sessionId !== sessionId) {
|
|
32217
|
+
return { ok: true, stale: true };
|
|
32218
|
+
}
|
|
32219
|
+
try {
|
|
32220
|
+
await this.streamerCommand(streamer, "stop", {});
|
|
32221
|
+
} catch (err) {
|
|
32222
|
+
this.host.log.warn(`[bb-viewer] stop command failed for ${profileId} (killing anyway): ${formatErrorForLog(err)}`);
|
|
32223
|
+
}
|
|
32224
|
+
this.killProfileStreamer(profileId);
|
|
32225
|
+
}
|
|
32226
|
+
return { ok: true };
|
|
32227
|
+
}
|
|
32228
|
+
/**
|
|
32229
|
+
* stream.switch — point the existing streamer at a different account-scoped
|
|
32230
|
+
* tab. input {tab} is the bb-browser short tab id / target id. Guarded by
|
|
32231
|
+
* sessionId like stream.answer: a superseded session must not steer the
|
|
32232
|
+
* current streamer, and an EMPTY sessionId is rejected the same way — every
|
|
32233
|
+
* real caller has its session id by the time it can switch (it arrives with
|
|
32234
|
+
* the stream.start response), so a session-less switch is by definition not
|
|
32235
|
+
* the current viewer.
|
|
32236
|
+
*/
|
|
32237
|
+
async viewerStreamSwitch(profileId, sessionId, input) {
|
|
32238
|
+
const streamer = this.streamers.get(profileId);
|
|
32239
|
+
if (!streamer)
|
|
32240
|
+
throw new Error("no active viewer streamer for profile");
|
|
32241
|
+
if (!sessionId || streamer.sessionId !== sessionId)
|
|
32242
|
+
throw new Error("stale viewer session");
|
|
32243
|
+
const tab = input?.tab;
|
|
32244
|
+
if (tab === void 0 || tab === null || tab === "") {
|
|
32245
|
+
throw new Error("stream.switch requires input.tab");
|
|
32246
|
+
}
|
|
32247
|
+
const cdpUrl = await this.host.withDaemonRecovery(() => this.resolveAccountPageCdpUrl(profileId, tab), `viewer stream.switch ${profileId}`);
|
|
32248
|
+
await this.bringTargetToFront(cdpUrl);
|
|
32249
|
+
const result = await this.streamerCommand(streamer, "switch", { cdpUrl });
|
|
32250
|
+
streamer.streamedTabId = targetIdFromCdpUrl(cdpUrl);
|
|
32251
|
+
return { ...result, streamed_tab_id: streamer.streamedTabId };
|
|
32252
|
+
}
|
|
32253
|
+
/**
|
|
32254
|
+
* close — close an account tab via bb-browser, then, if the closed tab was
|
|
32255
|
+
* the one being streamed, re-point the streamer at the profile's next active
|
|
32256
|
+
* tab. Without the re-switch the streamer stays bound to a destroyed CDP
|
|
32257
|
+
* target and the viewer goes black. Returns `streamed_tab_id` when a
|
|
32258
|
+
* re-switch happened so the client can adopt the new active tab.
|
|
32259
|
+
*/
|
|
32260
|
+
async viewerCloseTab(profileId, sessionId, input) {
|
|
32261
|
+
const ref = viewerTabRef(input ?? {});
|
|
32262
|
+
const closedTargetId = ref !== void 0 ? await this.accountTabTargetId(profileId, ref) : await this.accountActivePageTargetId(profileId);
|
|
32263
|
+
const result = await this.viewerForwardNav(profileId, sessionId, "close", {
|
|
32264
|
+
...input ?? {},
|
|
32265
|
+
tabId: closedTargetId
|
|
32266
|
+
});
|
|
32267
|
+
const streamer = this.streamers.get(profileId);
|
|
32268
|
+
if (!streamer || !streamer.streamedTabId || streamer.streamedTabId !== closedTargetId) {
|
|
32269
|
+
return result;
|
|
32270
|
+
}
|
|
32271
|
+
const cdpUrl = await this.host.withDaemonRecovery(() => this.resolveAccountPageCdpUrl(profileId), `viewer close re-switch ${profileId}`);
|
|
32272
|
+
await this.bringTargetToFront(cdpUrl);
|
|
32273
|
+
const sw = await this.streamerCommand(streamer, "switch", { cdpUrl });
|
|
32274
|
+
streamer.streamedTabId = targetIdFromCdpUrl(cdpUrl);
|
|
32275
|
+
return { ...result, ...sw, streamed_tab_id: streamer.streamedTabId };
|
|
32276
|
+
}
|
|
32277
|
+
/**
|
|
32278
|
+
* Bring a CDP page target to the foreground via Chrome's DevTools HTTP
|
|
32279
|
+
* endpoint (`/json/activate/{targetId}`, same host:port as the CDP ws). Chrome
|
|
32280
|
+
* does not composite backgrounded tabs, so `Page.startScreencast` on a
|
|
32281
|
+
* background tab yields zero frames (black viewer). Best-effort: a failure
|
|
32282
|
+
* must not block the stream. host:port + targetId are parsed from the cdpUrl
|
|
32283
|
+
* (`ws://host:port/devtools/page/<targetId>`) to avoid an extra /status call.
|
|
32284
|
+
*/
|
|
32285
|
+
async bringTargetToFront(cdpUrl) {
|
|
32286
|
+
const m = /^ws:\/\/([^/]+)\/devtools\/page\/(.+)$/.exec(cdpUrl);
|
|
32287
|
+
if (!m)
|
|
32288
|
+
return;
|
|
32289
|
+
const [, hostPort, targetId] = m;
|
|
32290
|
+
try {
|
|
32291
|
+
const resp = await fetch(`http://${hostPort}/json/activate/${targetId}`, {
|
|
32292
|
+
method: "GET",
|
|
32293
|
+
signal: AbortSignal.timeout(5e3)
|
|
32294
|
+
});
|
|
32295
|
+
if (!resp.ok) {
|
|
32296
|
+
this.host.log.warn(`[bb-viewer] activate target ${targetId} returned ${resp.status} (continuing)`);
|
|
32297
|
+
}
|
|
32298
|
+
} catch (err) {
|
|
32299
|
+
this.host.log.warn(`[bb-viewer] activate target ${targetId} failed (continuing): ${formatErrorForLog(err)}`);
|
|
32300
|
+
}
|
|
32301
|
+
}
|
|
32302
|
+
/**
|
|
32303
|
+
* Forward a navigation command (tab_list/tab_new/open/reload/back/forward/
|
|
32304
|
+
* close) to bb-browser scoped to the profile's account. This lets the human
|
|
32305
|
+
* in the live viewer navigate and handle OAuth popups directly. Reuses the
|
|
32306
|
+
* same account-scoped sendCommand path the agent invokes use.
|
|
32307
|
+
*
|
|
32308
|
+
* SECURITY — the `account` field alone does NOT scope tab addressing:
|
|
32309
|
+
* bb-browser's ensurePageTarget resolves a client-supplied tab/tabId against
|
|
32310
|
+
* EVERY page target in the shared Chrome (all accounts), and tab_list
|
|
32311
|
+
* returns every tab with `account` as a mere annotation (verified in
|
|
32312
|
+
* @pinixai/bb-browser-pro@0.15.0 dist/daemon.js ensurePageTarget/tab_list).
|
|
32313
|
+
* On a multi-profile host that would let one profile's viewer list,
|
|
32314
|
+
* navigate, and close other profiles' logged-in tabs. So before forwarding:
|
|
32315
|
+
* (a) any tab ref must resolve through accountTabTargetId, which throws on
|
|
32316
|
+
* tabs the profile's account does not own; (b) ref-less tab-addressed
|
|
32317
|
+
* commands are pinned to the profile's own active tab instead of
|
|
32318
|
+
* bb-browser's account-blind global current tab; (c) tab_list output is
|
|
32319
|
+
* filtered to the profile's own rows; and (d) open/tab_new URLs must be
|
|
32320
|
+
* http(s)/about:blank — file:// or chrome:// would read the host
|
|
32321
|
+
* filesystem / browser internals straight into the video stream.
|
|
32322
|
+
*/
|
|
32323
|
+
async viewerForwardNav(profileId, sessionId, command, input) {
|
|
32324
|
+
if (command !== "tab_list") {
|
|
32325
|
+
const streamer = this.streamers.get(profileId);
|
|
32326
|
+
if (streamer && streamer.sessionId !== sessionId) {
|
|
32327
|
+
throw new Error("stale viewer session");
|
|
32328
|
+
}
|
|
32329
|
+
}
|
|
32330
|
+
return this.host.withDaemonRecovery(async () => {
|
|
32331
|
+
await this.host.ensureAccount(profileId);
|
|
32332
|
+
const request3 = { ...input ?? {} };
|
|
32333
|
+
if (command === "open" || command === "tab_new") {
|
|
32334
|
+
assertViewerNavigableUrl(request3.url);
|
|
32335
|
+
}
|
|
32336
|
+
const ref = viewerTabRef(request3);
|
|
32337
|
+
if (ref !== void 0) {
|
|
32338
|
+
request3.tabId = await this.accountTabTargetId(profileId, ref);
|
|
32339
|
+
delete request3.tab;
|
|
32340
|
+
} else if (REFLESS_PIN_NAV.has(command)) {
|
|
32341
|
+
request3.tabId = await this.accountActivePageTargetId(profileId);
|
|
32342
|
+
}
|
|
32343
|
+
const result = await this.host.sendBrowserCommand({
|
|
32344
|
+
...request3,
|
|
32345
|
+
method: command,
|
|
32346
|
+
account: profileId
|
|
32347
|
+
});
|
|
32348
|
+
return command === "tab_list" ? filterTabListToAccount(result, profileId) : result;
|
|
32349
|
+
}, `viewer ${command} ${profileId}`);
|
|
32350
|
+
}
|
|
32351
|
+
/**
|
|
32352
|
+
* Resolve `ws://<cdpHost>:<cdpPort>/devtools/page/<targetId>` for a PAGE
|
|
32353
|
+
* target owned by THIS profile's account — NOT bb-browser's global current
|
|
32354
|
+
* tab (which `getCurrentTabCdpUrl`/`getTabCdpUrl` in the bundled daemon use
|
|
32355
|
+
* via `cdp.currentTargetId`, an account-blind global; see daemon.js
|
|
32356
|
+
* line ~10389/10410). When `tabRef` is given (stream.switch) we resolve that
|
|
32357
|
+
* specific account tab; otherwise the profile's active page tab.
|
|
32358
|
+
*
|
|
32359
|
+
* Evidence from the installed @pinixai/bb-browser-pro@0.15.0 dist/daemon.js:
|
|
32360
|
+
* - GET /status (handleStatus, line ~1659-1668) returns `cdpHost` + `cdpPort`
|
|
32361
|
+
* — the CDP host/port the daemon's Chrome listens on.
|
|
32362
|
+
* - The CDP page ws URL format is `ws://<cdp.host>:<cdp.port>/devtools/page/<page.id>`
|
|
32363
|
+
* (getCurrentTabCdpUrl, line ~10392), where `page.id` is the full CDP
|
|
32364
|
+
* targetId (getTargets maps `t.targetId` → `.id`, line ~2461-2466).
|
|
32365
|
+
* - tab_list (command handler, line ~1041-1059) returns per-tab
|
|
32366
|
+
* `{ tabId: t.id, tab: <shortId>, account: <accountName>, url, ... }`, so
|
|
32367
|
+
* `tabId` is exactly the full targetId for the devtools URL and `account`
|
|
32368
|
+
* is the per-tab attribution we filter on (reused by findAccountTabOnHost).
|
|
32369
|
+
*/
|
|
32370
|
+
async resolveAccountPageCdpUrl(profileId, tabRef) {
|
|
32371
|
+
await this.host.ensureAccount(profileId);
|
|
32372
|
+
const { host, port } = await this.host.cdpEndpoint();
|
|
32373
|
+
const targetId = tabRef !== void 0 ? await this.accountTabTargetId(profileId, tabRef) : await this.accountActivePageTargetId(profileId);
|
|
32374
|
+
return `ws://${host}:${port}/devtools/page/${targetId}`;
|
|
32375
|
+
}
|
|
32376
|
+
/**
|
|
32377
|
+
* Full CDP targetId of the profile account's active page tab (the owned tab
|
|
32378
|
+
* Chrome marks active, else the first owned tab). If the account owns no
|
|
32379
|
+
* page tab yet, open `about:blank` for it first (mirrors the agent-invoke
|
|
32380
|
+
* path's "always give a fresh account an owned tab" rule).
|
|
32381
|
+
*/
|
|
32382
|
+
async accountActivePageTargetId(profileId) {
|
|
32383
|
+
let targetId = await this.firstAccountPageTargetId(profileId);
|
|
32384
|
+
if (targetId !== void 0)
|
|
32385
|
+
return targetId;
|
|
32386
|
+
await this.host.sendBrowserCommand({
|
|
32387
|
+
method: "tab_new",
|
|
32388
|
+
account: profileId,
|
|
32389
|
+
url: "about:blank"
|
|
32390
|
+
});
|
|
32391
|
+
targetId = await this.firstAccountPageTargetId(profileId);
|
|
32392
|
+
if (targetId === void 0) {
|
|
32393
|
+
throw new Error(`no page tab found for profile account ${profileId}`);
|
|
32394
|
+
}
|
|
32395
|
+
return targetId;
|
|
32396
|
+
}
|
|
32397
|
+
/**
|
|
32398
|
+
* Full CDP targetId (`tabId`) of the account's active page tab: the owned tab
|
|
32399
|
+
* Chrome marks `active` when it belongs to this account, else the first owned
|
|
32400
|
+
* tab (the global active flag is account-blind — another profile's tab may
|
|
32401
|
+
* hold it, which must not leak here).
|
|
32402
|
+
*/
|
|
32403
|
+
async firstAccountPageTargetId(profileId) {
|
|
32404
|
+
const tabs = await this.accountTabs(profileId);
|
|
32405
|
+
let firstOwned;
|
|
32406
|
+
for (const t of tabs) {
|
|
32407
|
+
if (t.account !== profileId)
|
|
32408
|
+
continue;
|
|
32409
|
+
const tabId = t.tabId;
|
|
32410
|
+
if (typeof tabId !== "string" || !tabId)
|
|
32411
|
+
continue;
|
|
32412
|
+
if (t.active === true)
|
|
32413
|
+
return tabId;
|
|
32414
|
+
firstOwned ??= tabId;
|
|
32415
|
+
}
|
|
32416
|
+
return firstOwned;
|
|
32417
|
+
}
|
|
32418
|
+
/**
|
|
32419
|
+
* Full CDP targetId for a specific account tab, addressed by the bb-browser
|
|
32420
|
+
* short tab id (`tab`) or full target id (`tabId`). Used by stream.switch.
|
|
32421
|
+
*/
|
|
32422
|
+
async accountTabTargetId(profileId, tabRef) {
|
|
32423
|
+
const ref = String(tabRef);
|
|
32424
|
+
const tabs = await this.accountTabs(profileId);
|
|
32425
|
+
for (const t of tabs) {
|
|
32426
|
+
if (t.account !== profileId)
|
|
32427
|
+
continue;
|
|
32428
|
+
const tabId = typeof t.tabId === "string" ? t.tabId : void 0;
|
|
32429
|
+
const shortTab = t.tab === void 0 ? void 0 : String(t.tab);
|
|
32430
|
+
if (tabId && tabId === ref || shortTab && shortTab === ref) {
|
|
32431
|
+
if (!tabId)
|
|
32432
|
+
break;
|
|
32433
|
+
return tabId;
|
|
32434
|
+
}
|
|
32435
|
+
}
|
|
32436
|
+
throw new Error(`tab ${ref} not found for profile account ${profileId}`);
|
|
32437
|
+
}
|
|
32438
|
+
/** `tab_list {account}` rows, typed to the fields we read (tabId/tab/account/active). */
|
|
32439
|
+
async accountTabs(profileId) {
|
|
32440
|
+
const list = await this.host.sendBrowserCommand({ method: "tab_list", account: profileId });
|
|
32441
|
+
return Array.isArray(list.tabs) ? list.tabs : [];
|
|
32442
|
+
}
|
|
32443
|
+
// ---- bb-viewer streamer lifecycle ----
|
|
32444
|
+
/**
|
|
32445
|
+
* Spawn a bb-viewer streamer for the profile and wait until healthy. Binary
|
|
32446
|
+
* is PRLL_BB_VIEWER_BIN (our image sets it to /usr/local/bin/bb-viewer),
|
|
32447
|
+
* defaulting to `bb-viewer` on PATH — we NEVER download it. A free port is
|
|
32448
|
+
* allocated per streamer. Stores the entry in `this.streamers` once healthy.
|
|
32449
|
+
*/
|
|
32450
|
+
async spawnStreamer(profileId, sessionId, turn) {
|
|
32451
|
+
if (this.stopping)
|
|
32452
|
+
throw new Error("viewer streamer is shutting down");
|
|
32453
|
+
const bin = process.env.PRLL_BB_VIEWER_BIN ?? "bb-viewer";
|
|
32454
|
+
const port = await findFreePort();
|
|
32455
|
+
const args = ["--api-only", "--port", String(port)];
|
|
32456
|
+
if (turn?.url) {
|
|
32457
|
+
args.push("--turn-url", turn.url, "--turn-user", turn.username ?? "", "--turn-cred", turn.credential ?? "");
|
|
32458
|
+
}
|
|
32459
|
+
const streamerEnv = {};
|
|
32460
|
+
for (const key of ["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "DISPLAY"]) {
|
|
32461
|
+
const val = process.env[key];
|
|
32462
|
+
if (val !== void 0)
|
|
32463
|
+
streamerEnv[key] = val;
|
|
32464
|
+
}
|
|
32465
|
+
this.host.log.info(`[bb-viewer] spawning for profile ${profileId} on port ${port}`);
|
|
32466
|
+
const child = spawn3(bin, args, { stdio: ["ignore", "pipe", "pipe"], env: streamerEnv });
|
|
32467
|
+
let childError = null;
|
|
32468
|
+
child.once("error", (err) => {
|
|
32469
|
+
childError = err;
|
|
32470
|
+
});
|
|
32471
|
+
child.once("exit", () => {
|
|
32472
|
+
const current = this.streamers.get(profileId);
|
|
32473
|
+
if (current?.child === child) {
|
|
32474
|
+
this.streamers.delete(profileId);
|
|
32475
|
+
this.host.log.warn(`[bb-viewer] streamer for ${profileId} exited; cleared stale entry`);
|
|
32476
|
+
}
|
|
32477
|
+
});
|
|
32478
|
+
child.stdout?.on("data", (chunk) => {
|
|
32479
|
+
const text = chunk.toString("utf8").trim();
|
|
32480
|
+
if (text)
|
|
32481
|
+
this.host.log.info(`[bb-viewer] ${text}`);
|
|
32482
|
+
});
|
|
32483
|
+
child.stderr?.on("data", (chunk) => {
|
|
32484
|
+
const text = chunk.toString("utf8").trim();
|
|
32485
|
+
if (text)
|
|
32486
|
+
this.host.log.warn(`[bb-viewer] ${text}`);
|
|
32487
|
+
});
|
|
32488
|
+
const deadline = Date.now() + BB_VIEWER_HEALTH_TIMEOUT_MS;
|
|
32489
|
+
while (Date.now() < deadline) {
|
|
32490
|
+
if (this.stopping) {
|
|
32491
|
+
this.killChild(child, "streamer for stopping daemon");
|
|
32492
|
+
throw new Error("viewer streamer is shutting down");
|
|
32493
|
+
}
|
|
32494
|
+
if (childError) {
|
|
32495
|
+
throw childError;
|
|
32496
|
+
}
|
|
32497
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
32498
|
+
throw new Error("bb-viewer exited during startup");
|
|
32499
|
+
}
|
|
32500
|
+
try {
|
|
32501
|
+
const resp = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
32502
|
+
signal: AbortSignal.timeout(2e3)
|
|
32503
|
+
});
|
|
32504
|
+
if (resp.ok) {
|
|
32505
|
+
const streamer = { child, port, sessionId };
|
|
32506
|
+
this.streamers.set(profileId, streamer);
|
|
32507
|
+
this.host.log.info(`[bb-viewer] ready for profile ${profileId} on port ${port}`);
|
|
32508
|
+
return streamer;
|
|
32509
|
+
}
|
|
32510
|
+
} catch {
|
|
32511
|
+
}
|
|
32512
|
+
await sleep(200);
|
|
32513
|
+
}
|
|
32514
|
+
this.killChild(child, "unhealthy streamer");
|
|
32515
|
+
throw new Error("bb-viewer did not become healthy in time");
|
|
32516
|
+
}
|
|
32517
|
+
/**
|
|
32518
|
+
* SIGTERM with a SIGKILL escalation: a bb-viewer that hangs or ignores TERM
|
|
32519
|
+
* would otherwise outlive its teardown holding the port + CDP session (the
|
|
32520
|
+
* shell-level pattern-kill only exists on the hosted pod, not BYOC). The
|
|
32521
|
+
* escalation timer is unref'd so it never holds the daemon open; it is
|
|
32522
|
+
* cleared on exit. Caveat: a daemon process that exits immediately after
|
|
32523
|
+
* shutdown() abandons the timer — acceptable, the TERM was still sent.
|
|
32524
|
+
*/
|
|
32525
|
+
killChild(child, label) {
|
|
32526
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
32527
|
+
return;
|
|
32528
|
+
try {
|
|
32529
|
+
child.kill("SIGTERM");
|
|
32530
|
+
} catch {
|
|
32531
|
+
return;
|
|
32532
|
+
}
|
|
32533
|
+
const killTimer = setTimeout(() => {
|
|
32534
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
32535
|
+
this.host.log.warn(`[bb-viewer] ${label} ignored SIGTERM; escalating to SIGKILL`);
|
|
32536
|
+
try {
|
|
32537
|
+
child.kill("SIGKILL");
|
|
32538
|
+
} catch {
|
|
32539
|
+
}
|
|
32540
|
+
}
|
|
32541
|
+
}, BB_VIEWER_TERM_GRACE_MS);
|
|
32542
|
+
killTimer.unref?.();
|
|
32543
|
+
child.once("exit", () => clearTimeout(killTimer));
|
|
32544
|
+
}
|
|
32545
|
+
/**
|
|
32546
|
+
* POST a command to the profile's bb-viewer `/command` endpoint. Parses the
|
|
32547
|
+
* `{result}` / `{error:{message}}` envelope (bb-viewer api.go) and returns
|
|
32548
|
+
* the inner result, throwing the error message on failure.
|
|
32549
|
+
*/
|
|
32550
|
+
async streamerCommand(streamer, method, params) {
|
|
32551
|
+
const controller = new AbortController();
|
|
32552
|
+
const timer = setTimeout(() => controller.abort(), BB_VIEWER_COMMAND_TIMEOUT_MS);
|
|
32553
|
+
try {
|
|
32554
|
+
const resp = await fetch(`http://127.0.0.1:${streamer.port}/command`, {
|
|
32555
|
+
method: "POST",
|
|
32556
|
+
headers: { "Content-Type": "application/json" },
|
|
32557
|
+
body: JSON.stringify({ method, params }),
|
|
32558
|
+
signal: controller.signal
|
|
32559
|
+
});
|
|
32560
|
+
const text = await resp.text();
|
|
32561
|
+
const parsed = text ? JSON.parse(text) : {};
|
|
32562
|
+
if (parsed.error) {
|
|
32563
|
+
throw new Error(parsed.error.message || `bb-viewer ${method} failed`);
|
|
32564
|
+
}
|
|
32565
|
+
if (!resp.ok) {
|
|
32566
|
+
throw new Error(`bb-viewer ${method} returned ${resp.status}: ${text}`);
|
|
32567
|
+
}
|
|
32568
|
+
return parsed.result ?? {};
|
|
32569
|
+
} finally {
|
|
32570
|
+
clearTimeout(timer);
|
|
32571
|
+
}
|
|
32572
|
+
}
|
|
32573
|
+
/** Kill + remove the profile's bb-viewer streamer, if any. Idempotent. */
|
|
32574
|
+
killProfileStreamer(profileId) {
|
|
32575
|
+
const streamer = this.streamers.get(profileId);
|
|
32576
|
+
if (!streamer)
|
|
32577
|
+
return;
|
|
32578
|
+
this.streamers.delete(profileId);
|
|
32579
|
+
this.killChild(streamer.child, `streamer for ${profileId}`);
|
|
32580
|
+
}
|
|
32581
|
+
/**
|
|
32582
|
+
* Tear down every profile streamer and refuse new spawns (daemon stop).
|
|
32583
|
+
* Idempotent.
|
|
32584
|
+
*/
|
|
32585
|
+
shutdown() {
|
|
32586
|
+
this.stopping = true;
|
|
32587
|
+
for (const profileId of [...this.streamers.keys()]) {
|
|
32588
|
+
this.killProfileStreamer(profileId);
|
|
32589
|
+
}
|
|
32590
|
+
}
|
|
32591
|
+
};
|
|
32592
|
+
function targetIdFromCdpUrl(cdpUrl) {
|
|
32593
|
+
return /\/devtools\/page\/(.+)$/.exec(cdpUrl)?.[1];
|
|
32594
|
+
}
|
|
32595
|
+
var REFLESS_PIN_NAV = /* @__PURE__ */ new Set(["reload", "back", "forward", "close"]);
|
|
32596
|
+
function viewerTabRef(request3) {
|
|
32597
|
+
for (const key of ["tabId", "tab"]) {
|
|
32598
|
+
const v = request3[key];
|
|
32599
|
+
if (typeof v === "string" && v !== "" || typeof v === "number")
|
|
32600
|
+
return String(v);
|
|
32601
|
+
}
|
|
32602
|
+
return void 0;
|
|
32603
|
+
}
|
|
32604
|
+
var VIEWER_NAVIGABLE_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
32605
|
+
function assertViewerNavigableUrl(raw) {
|
|
32606
|
+
if (raw === void 0 || raw === null || raw === "")
|
|
32607
|
+
return;
|
|
32608
|
+
if (typeof raw !== "string")
|
|
32609
|
+
throw new Error("url must be a string");
|
|
32610
|
+
if (raw === "about:blank")
|
|
32611
|
+
return;
|
|
32612
|
+
let parsed;
|
|
32613
|
+
try {
|
|
32614
|
+
parsed = new URL(raw);
|
|
32615
|
+
} catch {
|
|
32616
|
+
throw new Error(`invalid url: ${raw}`);
|
|
32617
|
+
}
|
|
32618
|
+
if (!VIEWER_NAVIGABLE_PROTOCOLS.has(parsed.protocol)) {
|
|
32619
|
+
throw new Error(`url scheme not allowed in the live viewer: ${parsed.protocol.replace(/:$/, "")}`);
|
|
32620
|
+
}
|
|
32621
|
+
}
|
|
32622
|
+
function filterTabListToAccount(result, account) {
|
|
32623
|
+
const tabs = Array.isArray(result.tabs) ? result.tabs : [];
|
|
32624
|
+
const owned = tabs.filter((t) => t?.account === account);
|
|
32625
|
+
return { ...result, tabs: owned, activeIndex: owned.findIndex((t) => t?.active === true) };
|
|
32626
|
+
}
|
|
32627
|
+
|
|
32628
|
+
// ts/daemon/dist/clip-runtime/browser-profile-manager.js
|
|
32011
32629
|
var BB_BROWSER_DAEMON_START_TIMEOUT_MS = 15e3;
|
|
32012
32630
|
var BrowserCommandError = class extends Error {
|
|
32013
32631
|
method;
|
|
@@ -32027,9 +32645,20 @@ var BrowserProfileManager = class {
|
|
|
32027
32645
|
ensuredAccounts = /* @__PURE__ */ new Set();
|
|
32028
32646
|
ensuringAccounts = /* @__PURE__ */ new Map();
|
|
32029
32647
|
reportedStatuses = /* @__PURE__ */ new Map();
|
|
32648
|
+
// Live viewer orchestration (bb-viewer streamer subprocesses + viewer nav)
|
|
32649
|
+
// lives in BrowserViewerStreamer; the manager hands it the account-scoped
|
|
32650
|
+
// bb-browser command path via the host interface.
|
|
32651
|
+
viewer;
|
|
32030
32652
|
constructor(opts) {
|
|
32031
32653
|
this.opts = opts;
|
|
32032
32654
|
mkdirSync5(this.opts.homeDir, { recursive: true });
|
|
32655
|
+
this.viewer = new BrowserViewerStreamer({
|
|
32656
|
+
log: this.opts.log,
|
|
32657
|
+
sendBrowserCommand: (request3) => this.sendCommand(request3),
|
|
32658
|
+
ensureAccount: (account) => this.ensureAccount(account),
|
|
32659
|
+
withDaemonRecovery: (operation, label) => this.withDaemonRecovery(operation, label),
|
|
32660
|
+
cdpEndpoint: () => this.cdpEndpoint()
|
|
32661
|
+
});
|
|
32033
32662
|
}
|
|
32034
32663
|
async invoke({ profileId, account, command, input }) {
|
|
32035
32664
|
if (!profileId)
|
|
@@ -32096,12 +32725,14 @@ var BrowserProfileManager = class {
|
|
|
32096
32725
|
if (!profileId)
|
|
32097
32726
|
throw new Error("browser profile id is required");
|
|
32098
32727
|
this.reportedStatuses.delete(profileId);
|
|
32728
|
+
this.viewer.killProfileStreamer(profileId);
|
|
32099
32729
|
this.reportStatus(profileId, "stopped");
|
|
32100
32730
|
}
|
|
32101
32731
|
async resetProfile(profileId) {
|
|
32102
32732
|
if (!profileId)
|
|
32103
32733
|
throw new Error("browser profile id is required");
|
|
32104
32734
|
this.reportedStatuses.delete(profileId);
|
|
32735
|
+
this.viewer.killProfileStreamer(profileId);
|
|
32105
32736
|
try {
|
|
32106
32737
|
await this.withDaemonRecovery(() => this.resetProfileOnce(profileId), `reset profile ${profileId}`);
|
|
32107
32738
|
this.reportStatus(profileId, "stopped");
|
|
@@ -32112,6 +32743,7 @@ var BrowserProfileManager = class {
|
|
|
32112
32743
|
}
|
|
32113
32744
|
}
|
|
32114
32745
|
async stop() {
|
|
32746
|
+
this.viewer.shutdown();
|
|
32115
32747
|
const daemon = this.daemon;
|
|
32116
32748
|
this.daemon = null;
|
|
32117
32749
|
this.starting = null;
|
|
@@ -32128,6 +32760,25 @@ var BrowserProfileManager = class {
|
|
|
32128
32760
|
daemon.child.kill("SIGTERM");
|
|
32129
32761
|
}
|
|
32130
32762
|
}
|
|
32763
|
+
/**
|
|
32764
|
+
* Viewer control command entrypoint — delegates to BrowserViewerStreamer
|
|
32765
|
+
* (browser-viewer-streamer.ts), which owns the per-profile bb-viewer
|
|
32766
|
+
* subprocesses and viewer tab navigation.
|
|
32767
|
+
*/
|
|
32768
|
+
async handleViewerCommand(profileId, sessionId, command, input, turn) {
|
|
32769
|
+
return this.viewer.handleViewerCommand(profileId, sessionId, command, input, turn);
|
|
32770
|
+
}
|
|
32771
|
+
/** Read cdpHost/cdpPort from the bb-browser daemon `GET /status`. */
|
|
32772
|
+
async cdpEndpoint() {
|
|
32773
|
+
const daemon = await this.ensureDaemon();
|
|
32774
|
+
const status = await this.post("/status", daemon, void 0, 5e3);
|
|
32775
|
+
const host = status.cdpHost?.trim();
|
|
32776
|
+
const port = status.cdpPort;
|
|
32777
|
+
if (!host || typeof port !== "number" || !Number.isFinite(port)) {
|
|
32778
|
+
throw new Error("bb-browser /status did not report a CDP endpoint");
|
|
32779
|
+
}
|
|
32780
|
+
return { host, port };
|
|
32781
|
+
}
|
|
32131
32782
|
async prepareOpenProfile(profileId, url) {
|
|
32132
32783
|
const accountCreated = await this.ensureAccount(profileId, url !== "about:blank" ? url : void 0);
|
|
32133
32784
|
await this.sendCommand({ method: "tab_list", account: profileId });
|
|
@@ -32386,7 +33037,7 @@ var BrowserProfileManager = class {
|
|
|
32386
33037
|
const port = await findFreePort();
|
|
32387
33038
|
const token = randomToken();
|
|
32388
33039
|
const daemonPath = resolveBbBrowserDaemonPath();
|
|
32389
|
-
const child =
|
|
33040
|
+
const child = spawn4(process.execPath, [daemonPath, "--host", host, "--port", String(port), "--token", token], {
|
|
32390
33041
|
env: {
|
|
32391
33042
|
...process.env,
|
|
32392
33043
|
BB_BROWSER_HOME: this.opts.homeDir
|
|
@@ -32500,10 +33151,6 @@ function comparableUrl(url) {
|
|
|
32500
33151
|
return null;
|
|
32501
33152
|
}
|
|
32502
33153
|
}
|
|
32503
|
-
function formatErrorForLog(err) {
|
|
32504
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
32505
|
-
return message.replace(/\s+/g, " ").slice(0, 300);
|
|
32506
|
-
}
|
|
32507
33154
|
function resolveBbBrowserDaemonPath() {
|
|
32508
33155
|
const sibling = path9.join(path9.dirname(fileURLToPath(import.meta.url)), "bb-browser-daemon.js");
|
|
32509
33156
|
if (existsSync8(sibling))
|
|
@@ -32518,29 +33165,9 @@ function resolveBbBrowserDaemonPath() {
|
|
|
32518
33165
|
}
|
|
32519
33166
|
return full;
|
|
32520
33167
|
}
|
|
32521
|
-
async function findFreePort() {
|
|
32522
|
-
return new Promise((resolve8, reject) => {
|
|
32523
|
-
const server = net.createServer();
|
|
32524
|
-
server.unref();
|
|
32525
|
-
server.on("error", reject);
|
|
32526
|
-
server.listen(0, "127.0.0.1", () => {
|
|
32527
|
-
const address = server.address();
|
|
32528
|
-
server.close(() => {
|
|
32529
|
-
if (address && typeof address === "object") {
|
|
32530
|
-
resolve8(address.port);
|
|
32531
|
-
} else {
|
|
32532
|
-
reject(new Error("failed to allocate free port"));
|
|
32533
|
-
}
|
|
32534
|
-
});
|
|
32535
|
-
});
|
|
32536
|
-
});
|
|
32537
|
-
}
|
|
32538
33168
|
function randomToken() {
|
|
32539
33169
|
return randomBytes(16).toString("hex");
|
|
32540
33170
|
}
|
|
32541
|
-
function sleep(ms) {
|
|
32542
|
-
return new Promise((resolve8) => setTimeout(resolve8, ms));
|
|
32543
|
-
}
|
|
32544
33171
|
|
|
32545
33172
|
// ts/daemon/dist/supervisor.js
|
|
32546
33173
|
init_config();
|
|
@@ -32940,7 +33567,7 @@ function shellQuote(value) {
|
|
|
32940
33567
|
}
|
|
32941
33568
|
|
|
32942
33569
|
// ts/daemon/dist/workspace.js
|
|
32943
|
-
import { spawn as
|
|
33570
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
32944
33571
|
import { createHash as createHash3 } from "node:crypto";
|
|
32945
33572
|
import * as fs11 from "node:fs";
|
|
32946
33573
|
import * as path12 from "node:path";
|
|
@@ -33244,7 +33871,7 @@ function runCommand(cmd, args, cwd, timeoutMs = 12e4, env = process.env) {
|
|
|
33244
33871
|
clearTimeout(killTimer);
|
|
33245
33872
|
fn();
|
|
33246
33873
|
};
|
|
33247
|
-
const child =
|
|
33874
|
+
const child = spawn5(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
33248
33875
|
timeoutTimer = setTimeout(() => {
|
|
33249
33876
|
timedOut = true;
|
|
33250
33877
|
child.kill("SIGTERM");
|
|
@@ -33397,7 +34024,12 @@ var DaemonSupervisor = class {
|
|
|
33397
34024
|
children = /* @__PURE__ */ new Map();
|
|
33398
34025
|
spawningAgents = /* @__PURE__ */ new Set();
|
|
33399
34026
|
pendingWorkspaceSetup = /* @__PURE__ */ new Set();
|
|
33400
|
-
|
|
34027
|
+
// ONE queue per profile for BOTH lifecycle (open/stop/reset) and viewer
|
|
34028
|
+
// (stream.*/nav) commands. They mutate the same streamer + account state, so
|
|
34029
|
+
// two independent queues would let a stop/reset interleave with an in-flight
|
|
34030
|
+
// stream.start (the kill fires before the streamer registers and misses it,
|
|
34031
|
+
// leaving a live streamer on a profile the server just marked stopped).
|
|
34032
|
+
browserProfileOpQueues = /* @__PURE__ */ new Map();
|
|
33401
34033
|
workspaceSetupRetryTimers = /* @__PURE__ */ new Map();
|
|
33402
34034
|
cancelledSpawns = /* @__PURE__ */ new Set();
|
|
33403
34035
|
ws = null;
|
|
@@ -33558,6 +34190,10 @@ var DaemonSupervisor = class {
|
|
|
33558
34190
|
this.log.info(`WS: browser profile lifecycle requested \u2014 profile=${data.profile_id} action=${data.action}`);
|
|
33559
34191
|
void this.enqueueBrowserProfileLifecycle(data);
|
|
33560
34192
|
});
|
|
34193
|
+
this.ws.on("machine.browser_profile.viewer", (data) => {
|
|
34194
|
+
this.log.info(`WS: browser profile viewer command \u2014 profile=${data.profile_id} command=${data.command}`);
|
|
34195
|
+
this.enqueueBrowserProfileViewer(data);
|
|
34196
|
+
});
|
|
33561
34197
|
this.ws.onStateChange((state) => {
|
|
33562
34198
|
if (state === "disconnected" || state === "reconnecting") {
|
|
33563
34199
|
this.log.warn(`machine WS state: ${state}`);
|
|
@@ -33869,17 +34505,45 @@ var DaemonSupervisor = class {
|
|
|
33869
34505
|
}
|
|
33870
34506
|
}
|
|
33871
34507
|
enqueueBrowserProfileLifecycle(data) {
|
|
33872
|
-
|
|
34508
|
+
return this.enqueueBrowserProfileOp(data.profile_id, () => this.handleBrowserProfileLifecycle(data));
|
|
34509
|
+
}
|
|
34510
|
+
enqueueBrowserProfileOp(profileId, op) {
|
|
34511
|
+
const previous = this.browserProfileOpQueues.get(profileId) ?? Promise.resolve();
|
|
33873
34512
|
const next = previous.catch(() => {
|
|
33874
|
-
}).then(
|
|
33875
|
-
this.
|
|
34513
|
+
}).then(op);
|
|
34514
|
+
this.browserProfileOpQueues.set(profileId, next);
|
|
33876
34515
|
void next.finally(() => {
|
|
33877
|
-
if (this.
|
|
33878
|
-
this.
|
|
34516
|
+
if (this.browserProfileOpQueues.get(profileId) === next) {
|
|
34517
|
+
this.browserProfileOpQueues.delete(profileId);
|
|
33879
34518
|
}
|
|
33880
34519
|
});
|
|
33881
34520
|
return next;
|
|
33882
34521
|
}
|
|
34522
|
+
/**
|
|
34523
|
+
* Handle one viewer control command and reply via REST. Always answers the
|
|
34524
|
+
* request/reply bridge exactly once ({result} on success, {error:{message}}
|
|
34525
|
+
* on failure) and never throws into the WS event loop — mirrors
|
|
34526
|
+
* handleFilesystemBrowse. Serialized per profile by enqueueBrowserProfileViewer.
|
|
34527
|
+
*/
|
|
34528
|
+
async handleBrowserProfileViewer(data) {
|
|
34529
|
+
if (!this.browserProfileManager) {
|
|
34530
|
+
await this.client.postBrowserProfileViewerResponse(data.request_id, {
|
|
34531
|
+
error: { message: "Browser profile runtime is disabled on this daemon" }
|
|
34532
|
+
}).catch((err) => this.log.warn(`browser profile viewer negative ACK failed: ${String(err)}`));
|
|
34533
|
+
return;
|
|
34534
|
+
}
|
|
34535
|
+
try {
|
|
34536
|
+
const result = await this.browserProfileManager.handleViewerCommand(data.profile_id, data.session_id, data.command, data.input, data.turn);
|
|
34537
|
+
await this.client.postBrowserProfileViewerResponse(data.request_id, { result });
|
|
34538
|
+
} catch (err) {
|
|
34539
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
34540
|
+
this.log.warn(`browser profile viewer command failed (profile=${data.profile_id}, command=${data.command}): ${message}`);
|
|
34541
|
+
await this.client.postBrowserProfileViewerResponse(data.request_id, { error: { message } }).catch((replyErr) => this.log.warn(`browser profile viewer error ACK failed: ${String(replyErr)}`));
|
|
34542
|
+
}
|
|
34543
|
+
}
|
|
34544
|
+
enqueueBrowserProfileViewer(data) {
|
|
34545
|
+
void this.enqueueBrowserProfileOp(data.profile_id, () => this.handleBrowserProfileViewer(data));
|
|
34546
|
+
}
|
|
33883
34547
|
async handleClipSync(_data) {
|
|
33884
34548
|
await this.reconcileMachineClips();
|
|
33885
34549
|
}
|
|
@@ -34356,7 +35020,7 @@ var DaemonSupervisor = class {
|
|
|
34356
35020
|
const env = adapter.buildEnv(baseEnv, state.agentId, state.orgId, state.credential.api_key, dirs, effectiveProviderConfig);
|
|
34357
35021
|
const spawnCmd = adapter.args.length > 0 ? `${adapter.bin} ${adapter.args.join(" ")}` : adapter.bin;
|
|
34358
35022
|
this.log.info(`spawning agent ${state.agentId} runtime=${state.runtimeType} cmd=${spawnCmd} (attempt ${state.restartAttempts + 1})`);
|
|
34359
|
-
const child =
|
|
35023
|
+
const child = spawn6(adapter.bin, adapter.args, {
|
|
34360
35024
|
env,
|
|
34361
35025
|
stdio: ["ignore", "inherit", "inherit"],
|
|
34362
35026
|
detached: false
|